diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..3103b9d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,58 @@ +# Builds the Android version (android/) into SplitScreenMod-android-arm64.zip / .levipack for LeviLauncher on Android. +name: Android + +on: + pull_request: + push: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write # to publish the android-latest pre-release + steps: + - uses: actions/checkout@v7 + + # The runner already has the Android SDK; android-actions/setup-android failed on its own sdkmanager install. + # NDK 28.2 is installed if missing; the runner's newest NDK is the fallback. + - run: | + sudo apt-get update + sudo apt-get install -y ninja-build + NDK="$ANDROID_HOME/ndk/28.2.13676358" + if [ ! -d "$NDK" ]; then + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;28.2.13676358" > /dev/null || true + fi + if [ ! -d "$NDK" ]; then NDK="$ANDROID_NDK_LATEST_HOME"; fi + echo "Using NDK $NDK" + echo "NDK=$NDK" >> "$GITHUB_ENV" + + - run: | + cmake -S android -B build-android -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DANDROID_STL=c++_shared \ + -DCMAKE_BUILD_TYPE=Release + cmake --build build-android --target levi_package + + # The mod's resource pack (menu look, black "Choose your player" screen, texts in every language), imported + # into Minecraft once. Its pause menu button is for Windows only and is left out. + - run: | + cd resource_packs/SplitScreenUI + zip -qr ../../build-android/SplitScreenUI.mcpack . -x 'ui/pause_screen.json' + mkdir -p ../../build-android/package && cp ../../build-android/SplitScreenUI.mcpack ../../build-android/package/ + + - uses: actions/upload-artifact@v7 + with: + name: ${{ github.event.repository.name }}-${{ github.sha }}-android-arm64 + path: build-android/package/ + + # The newest Android build is always at the same link, so it can be downloaded straight on the phone: + # https://github.com///releases/download/android-latest/SplitScreenMod-android-arm64.zip + - if: github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release delete android-latest --cleanup-tag -y || true + gh release create android-latest build-android/SplitScreenMod-android-arm64.zip build-android/SplitScreenUI.mcpack \ + --prerelease --target "$GITHUB_SHA" --title "Android (experimental, newest build)" \ + --notes "Newest experimental Android build from ${GITHUB_REF_NAME} (${GITHUB_SHA::7}). Add it in LeviLauncher for Android with Mods > Add Mod, and import SplitScreenUI.mcpack into Minecraft (Settings > Global Resources) for the menus." diff --git a/README.md b/README.md index 4c8a0f2..fcbb84b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Minecraft Bedrock already contains split-screen (it is used on Xbox), but the Wi ## Installation +> **Android:** the Windows zip does not work on Android (LeviLauncher for Android shows "Invalid ZIP Mod Package", because it needs an Android `.so`, not a Windows `.dll`). An experimental Android version is in [`android/`](android/README.md). + **Easiest:** in [LeviLauncher](https://github.com/LiteLDev/LeviLauncher), open your instance's **Manage Mods**, press **Import .mcmod** and pick `SplitScreenMod.mcmod` from the [latest release](../../releases/latest). **By hand:** diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 0000000..e1a54db --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,90 @@ +# SplitScreenMod for LeviLauncher on Android (preloader-android SDK). +# Build: see android/README.md. The result is a .levipack (a zip) that LeviLauncher for Android imports. +cmake_minimum_required(VERSION 3.22) + +project(SplitScreenModAndroid LANGUAGES CXX ASM) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(MOD_ID "SplitScreenMod" CACHE STRING "LeviLauncher mod package directory name") +set(MOD_NAME "SplitScreenMod" CACHE STRING "LeviLauncher mod display name") +set(MOD_AUTHOR "SnakyWarrior" CACHE STRING "LeviLauncher mod author") +set(MOD_VERSION "0.10.3" CACHE STRING "LeviLauncher mod version") +set(MOD_LIBRARY_NAME "SplitScreenMod" CACHE STRING "Native library name without lib prefix or .so suffix") +set(MOD_MINECRAFT_VERSIONS "[\"1.26.*\"]" CACHE STRING "Raw JSON array for manifest minecraft_versions") + +if(NOT ANDROID) + message(FATAL_ERROR "Configure with the Android NDK toolchain file (see android/README.md)") +endif() +if(NOT CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") + message(FATAL_ERROR "Only arm64-v8a is supported, got ${CMAKE_ANDROID_ARCH_ABI}") +endif() + +include(FetchContent) +FetchContent_Declare( + preloader_android + GIT_REPOSITORY https://github.com/LiteLDev/preloader-android.git + GIT_TAG 0.2.2 + SOURCE_SUBDIR cmake/preloader-sdk +) +FetchContent_Declare( + fmt + GIT_REPOSITORY https://github.com/fmtlib/fmt.git + GIT_TAG 11.2.0 +) +set(FMT_DOC OFF CACHE BOOL "" FORCE) +set(FMT_TEST OFF CACHE BOOL "" FORCE) +set(FMT_INSTALL OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(preloader_android fmt) +FetchContent_GetProperties(preloader_android) + +# The SDK symbols are provided by the preloader that LeviLauncher has already loaded. +option(LEVI_LINK_PRELOADER "Build and link the preloader target instead of resolving SDK symbols at runtime" ON) +if(LEVI_LINK_PRELOADER AND NOT TARGET preloader) + add_subdirectory("${preloader_android_SOURCE_DIR}" "${CMAKE_BINARY_DIR}/_preloader_android") +endif() + +add_library(${MOD_LIBRARY_NAME} SHARED src/main.cpp src/SplitScreen.cpp src/Elf.cpp src/InputProbe.cpp src/DeviceManagerProbe.cpp src/ControllerSplit.cpp src/FormProbe.cpp src/Menus.cpp src/VTrace.cpp src/PostTrace.cpp src/Trampolines.S src/VTrampolines.S src/PostTrampolines.S) +target_include_directories(${MOD_LIBRARY_NAME} PRIVATE src "${preloader_android_SOURCE_DIR}/include") +target_compile_definitions(${MOD_LIBRARY_NAME} PRIVATE SPLITSCREEN_VERSION="${MOD_VERSION}") +target_compile_options(${MOD_LIBRARY_NAME} PRIVATE -Wall -Wextra -fvisibility=hidden -fvisibility-inlines-hidden) +target_link_options(${MOD_LIBRARY_NAME} PRIVATE "-Wl,-z,max-page-size=16384") + +find_library(log-lib log REQUIRED) +target_link_libraries(${MOD_LIBRARY_NAME} PRIVATE ${log-lib} android dl fmt::fmt) +if(TARGET preloader) + target_link_libraries(${MOD_LIBRARY_NAME} PRIVATE preloader) +else() + target_link_options(${MOD_LIBRARY_NAME} PRIVATE "-Wl,--unresolved-symbols=ignore-all") +endif() + +add_custom_command(TARGET ${MOD_LIBRARY_NAME} POST_BUILD + COMMAND "${CMAKE_STRIP}" --strip-unneeded "$" + VERBATIM +) + +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/manifest.json.in" "${CMAKE_CURRENT_BINARY_DIR}/manifest.json" @ONLY) + +# SplitScreenMod-android-arm64.levipack: a zip with SplitScreenMod/manifest.json, SplitScreenMod/libSplitScreenMod.so, +# config/ and pack/ (the resource pack). +# The same zip is also written as .zip, which LeviLauncher's "Add Mod" accepts. +set(PACKAGE_ROOT "${CMAKE_CURRENT_BINARY_DIR}/package") +set(PACKAGE_MOD_DIR "${PACKAGE_ROOT}/${MOD_ID}") +set(PACKAGE_FILE "${CMAKE_CURRENT_BINARY_DIR}/${MOD_ID}-android-arm64.levipack") +add_custom_target(levi_package + COMMAND "${CMAKE_COMMAND}" -E rm -rf "${PACKAGE_ROOT}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${PACKAGE_MOD_DIR}" + COMMAND "${CMAKE_COMMAND}" -E copy "$" "${PACKAGE_MOD_DIR}/lib${MOD_LIBRARY_NAME}.so" + COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_CURRENT_BINARY_DIR}/manifest.json" "${PACKAGE_MOD_DIR}/manifest.json" + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/config" "${PACKAGE_MOD_DIR}/config" + # the mod's resource pack, put into the game and turned on by the mod, with the Android changes from android/pack + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/../resource_packs/SplitScreenUI" "${PACKAGE_MOD_DIR}/pack" + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/pack" "${PACKAGE_MOD_DIR}/pack" + COMMAND "${CMAKE_COMMAND}" -E chdir "${PACKAGE_ROOT}" "${CMAKE_COMMAND}" -E tar cf "${PACKAGE_FILE}" --format=zip "${MOD_ID}" + COMMAND "${CMAKE_COMMAND}" -E copy "${PACKAGE_FILE}" "${CMAKE_CURRENT_BINARY_DIR}/${MOD_ID}-android-arm64.zip" + DEPENDS ${MOD_LIBRARY_NAME} + VERBATIM +) diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ee1cce8 --- /dev/null +++ b/android/README.md @@ -0,0 +1,40 @@ +# SplitScreenMod for Android (experimental) + +The Windows mod (in `src/`) cannot run on Android: it is built for LeviLamina on Windows x64, and the Android +LeviLauncher only loads Android libraries (`.so`). This folder is a separate mod for **LeviLauncher on Android**, +built with LeviLauncher's [preloader-android](https://github.com/LiteLDev/preloader-android) SDK. + +## What this version does + +Version 0.4 plays split screen on Android: + +- **Player 1 keeps the touch screen.** Press Start on a controller: a new player joins with it (player 2, 3, 4) and + the screen splits. Start on a controller that already plays opens that player's pause menu. A player that leaves + can join again with Start. +- Every controller is a controller of its own: the game on Android sends every controller to player 1, the mod gives + each Android device its own controller number. +- Split screen is on: the game keeps it off on phones it thinks are too small (`AppPlatform::allowSplitScreen`). + +Settings (`config/config.json`): `firstControllerIsPlayer1` gives the first controller to player 1 instead of touch. + +Not ported from Windows yet: the player picker, the pause menu's Split screen menu (layout, swap, remove), the P1-P4 +pictures, and the 20 second disconnect countdown. The mod still logs a lot (it is in development): everything it does +is written to `splitscreen.log` and `Download/SplitScreenMod-android.log`. + +## Install + +Download the newest build from [releases/android-latest](../../../releases/download/android-latest/SplitScreenMod-android-arm64.zip) (always the same link) and add it in +LeviLauncher's **Mods → Add Mod**. Start the game, enter a world, then send `Download/SplitScreenMod-android.log`. + +## Build + +Needs the Android NDK (28.2), CMake 3.22+ and Ninja: + +``` +cmake -S android -B build-android -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=$ANDROID_HOME/ndk/28.2.13676358/build/cmake/android.toolchain.cmake \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DANDROID_STL=c++_shared -DCMAKE_BUILD_TYPE=Release +cmake --build build-android --target levi_package +``` + +The package is `build-android/SplitScreenMod-android-arm64.zip` (also as `.levipack`). diff --git a/android/config/config.json b/android/config/config.json new file mode 100644 index 0000000..7ba3ddb --- /dev/null +++ b/android/config/config.json @@ -0,0 +1,7 @@ +{ + "forceSplitScreen": true, + "separateControllers": true, + "forceSplitscreenJoin": true, + "firstControllerIsPlayer1": false, + "startJoins": true +} diff --git a/android/config/config.schema.json b/android/config/config.schema.json new file mode 100644 index 0000000..4997a84 --- /dev/null +++ b/android/config/config.schema.json @@ -0,0 +1,31 @@ +{ + "title": "SplitScreenMod", + "type": "object", + "properties": { + "forceSplitScreen": { + "type": "boolean", + "title": "Turn split screen on", + "description": "Turns the game's split screen on when the mod can find it for certain. Turn off if the game misbehaves." + }, + "separateControllers": { + "type": "boolean", + "title": "One player per controller", + "description": "Gives each controller its own player (the game on Android sends every controller to player 1). Turn off if controllers misbehave." + }, + "forceSplitscreenJoin": { + "type": "boolean", + "title": "Let other controllers join", + "description": "Lets a second, third or fourth controller join as a split-screen player. Turn off if the game misbehaves." + }, + "firstControllerIsPlayer1": { + "type": "boolean", + "title": "First controller plays player 1", + "description": "Off: player 1 keeps the touch screen and every controller joins as a new player with Start. On: the first controller plays player 1." + }, + "startJoins": { + "type": "boolean", + "title": "Start joins directly", + "description": "On: Start on a controller without a player adds a player in a loaded world (the game shows no join prompt on Android). Off: only the game's own join prompt adds players." + } + } +} diff --git a/android/manifest.json.in b/android/manifest.json.in new file mode 100644 index 0000000..6617337 --- /dev/null +++ b/android/manifest.json.in @@ -0,0 +1,9 @@ +{ + "type": "preload-native", + "name": "@MOD_NAME@", + "author": "@MOD_AUTHOR@", + "version": "@MOD_VERSION@", + "entry": "lib@MOD_LIBRARY_NAME@.so", + "icon": "", + "minecraft_versions": @MOD_MINECRAFT_VERSIONS@ +} diff --git a/android/pack/ui/pause_screen.json b/android/pack/ui/pause_screen.json new file mode 100644 index 0000000..c857129 --- /dev/null +++ b/android/pack/ui/pause_screen.json @@ -0,0 +1,89 @@ +{ + "namespace": "pause", + + // Android: the Windows version's pause_screen.json with the Split screen button always shown (its bindings come from + // the Windows mod) and without the free-controller button. A square name-tag button in the row of small buttons + // under Save & Quit, styled like them. The mod handles "button.splitscreen_menu". + "smaller_buttons_panel/stacked_column": { + "modifications": [ + { + "control_name": "fill_3", + "operation": "insert_before", + "value": [ + { + "splitscreen_padding": { + "type": "panel", + "size": [ "2%", 0 ] + } + }, + { + "splitscreen_small_button": { + "type": "input_panel", + "size": [ "100%c + 4px", 32 ], + "controls": [ { "splitscreen_btn@pause.splitscreen_button_small_with_tooltip": {} } ] + } + } + ] + } + ] + }, + + "splitscreen_icon": { + "type": "image", + "texture": "textures/items/name_tag", + "layer": 3, + "size": [ 16, 16 ] + }, + + "splitscreen_button_small@common_buttons.light_content_button": { + "$pressed_button_name": "button.splitscreen_menu", + "$focus_id": "icon_panel_main_focus", + "focus_change_down": "return_to_game", + "$button_tts_header": "splitscreen.title.menu", + "size": [ 24, 24 ], + "$button_content": "pause.splitscreen_icon", + "$pressed_alpha": 0.65 + }, + + // the game's controller glyph, centered in the button + "splitscreen_free_icon": { + "type": "image", + "texture": "textures/ui/controller_glyph", + "layer": 3, + "size": [ 16, 16 ], + "anchor_from": "center", + "anchor_to": "center" + }, + + // alone in the world: take player 1's controller away so it can choose a player + "splitscreen_free_button_small@common_buttons.light_content_button": { + "$pressed_button_name": "button.splitscreen_free_controller", + "$default_button_texture": "textures/ui/button_red", + "$hover_button_texture": "textures/ui/button_red", + "$pressed_button_texture": "textures/ui/button_red_pressed", + "$focus_id": "icon_panel_main_focus", + "focus_change_down": "return_to_game", + "$button_tts_header": "splitscreen.pause.freeController", + "size": [ 24, 24 ], + "$button_content": "pause.splitscreen_free_icon", + "$pressed_alpha": 0.65 + }, + + "splitscreen_free_button_small_with_tooltip": { + "type": "panel", + "size": [ 24, 24 ], + "controls": [ + { "button@pause.splitscreen_free_button_small": {} }, + { "tooltip_trigger@pause.icon_button_tooltip_trigger": { "$tooltip_text": "splitscreen.pause.freeController" } } + ] + }, + + "splitscreen_button_small_with_tooltip": { + "type": "panel", + "size": [ 24, 24 ], + "controls": [ + { "button@pause.splitscreen_button_small": {} }, + { "tooltip_trigger@pause.icon_button_tooltip_trigger": { "$tooltip_text": "splitscreen.title.menu" } } + ] + } +} diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp new file mode 100644 index 0000000..f5f5f27 --- /dev/null +++ b/android/src/ControllerSplit.cpp @@ -0,0 +1,555 @@ +#include "ControllerSplit.h" + +#include "DeviceManagerProbe.h" +#include "VTrace.h" + +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +extern "C" { +uintptr_t splitscreen_originals[16]; +void splitscreen_trampoline_0(); +void splitscreen_trampoline_1(); +void splitscreen_trampoline_2(); +void splitscreen_trampoline_3(); +void splitscreen_trampoline_4(); +void splitscreen_trampoline_5(); +void splitscreen_trampoline_6(); +void splitscreen_trampoline_7(); +void splitscreen_trampoline_8(); +void splitscreen_trampoline_9(); +void splitscreen_trampoline_10(); +void splitscreen_trampoline_11(); +void splitscreen_trampoline_12(); +void splitscreen_trampoline_13(); +void splitscreen_trampoline_14(); +void splitscreen_trampoline_15(); +} + +namespace splitscreen { + +namespace { + +constexpr int kMaxControllers = 4; +constexpr int kMaxHooks = 16; + +Module const* gGame = nullptr; +LogFn gLog; +uintptr_t gMethods[kMaxHooks]; +std::atomic gCalls[kMaxHooks]; +std::atomic gRemapsLogged{0}; + +// device id -> controller number; the first controller gets 0 (player 1) +std::mutex gDevicesMutex; +std::map gControllerOf; +std::map gLastSeen; +std::map> gHeld; // device -> key codes held down (see applyPendingSwaps) + +thread_local int tCurrentController = -1; + +// The first controller number a controller can get: 1 keeps player 1 on touch (every controller joins as a new +// player, as on Windows player 1 keeps keyboard and mouse); 0 gives the first controller to player 1. +std::atomic gFirstController{1}; + +// Whether Android still has input device `device` (InputDevice.getDevice(id) != null). The game does not tell the +// mod when a controller is switched off, and a controller that comes back gets a new device id: without this check +// its old device kept its controller number, and with 1-3 taken a returning controller fell to controller 0 and +// took player 1 (and the game left the world). True when Java cannot be asked. +JavaVM* gVm = nullptr; +jclass gInputDevice = nullptr; +jmethodID gGetDevice = nullptr; +bool gJavaTried = false; + +bool deviceExists(int32_t device) { + if (!gJavaTried) { + gJavaTried = true; + using GetVms = jint (*)(JavaVM**, jsize, jsize*); + GetVms getVms = nullptr; + for (char const* lib : {"libart.so", "libnativehelper.so", "libandroid_runtime.so"}) + if (void* h = dlopen(lib, RTLD_NOW | RTLD_NOLOAD)) + if ((getVms = reinterpret_cast(dlsym(h, "JNI_GetCreatedJavaVMs")))) break; + jsize count = 0; + if (!getVms || getVms(&gVm, 1, &count) != JNI_OK || count < 1) gVm = nullptr; + gLog(fmt::format("controllers: Java {}", gVm ? "found: gone controllers are noticed" : "NOT found")); + } + if (!gVm) return true; + JNIEnv* env = nullptr; + if (gVm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK +#ifdef __ANDROID__ + && gVm->AttachCurrentThread(&env, nullptr) != JNI_OK) +#else + && gVm->AttachCurrentThread(reinterpret_cast(&env), nullptr) != JNI_OK) +#endif + return true; + if (!gInputDevice) { + jclass local = env->FindClass("android/view/InputDevice"); + if (!local || env->ExceptionCheck()) { + env->ExceptionClear(); + gVm = nullptr; + gLog("controllers: android.view.InputDevice NOT found"); + return true; + } + gInputDevice = static_cast(env->NewGlobalRef(local)); + env->DeleteLocalRef(local); + gGetDevice = env->GetStaticMethodID(gInputDevice, "getDevice", "(I)Landroid/view/InputDevice;"); + if (!gGetDevice || env->ExceptionCheck()) { + env->ExceptionClear(); + gVm = nullptr; + return true; + } + } + jobject found = env->CallStaticObjectMethod(gInputDevice, gGetDevice, jint(device)); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return true; + } + bool exists = found != nullptr; + if (found) env->DeleteLocalRef(found); + return exists; +} + +// Forgets the controllers Android no longer has (gDevicesMutex held), at most once a second unless `now`. +std::chrono::steady_clock::time_point gLastPurge; +void purgeGoneDevices(bool now) { + auto t = std::chrono::steady_clock::now(); + if (!now && t - gLastPurge < std::chrono::seconds(1)) return; + gLastPurge = t; + for (auto it = gControllerOf.begin(); it != gControllerOf.end();) + if (!deviceExists(it->first)) { + gLog(fmt::format("controllers: device {} (controller {}) is gone", it->first, it->second)); + gHeld.erase(it->first); + it = gControllerOf.erase(it); + } else + ++it; +} + +// A press and its release must reach the same player: a controller that moves to another player (or is blocked) +// while a button is held left the game with a button held on a player that was then removed, and the game crashed +// ("terminating"). So swaps wait until the controllers involved hold no button. (gDevicesMutex) +std::vector> gPendingSwaps; // controller numbers to swap once nothing is held + +bool holdsKeys(int controller) { + for (auto& [d, c] : gControllerOf) + if (c == controller && gHeld.contains(d) && !gHeld[d].empty()) return true; + return false; +} + +void applyPendingSwaps() { // gDevicesMutex held + for (auto it = gPendingSwaps.begin(); it != gPendingSwaps.end();) { + auto [a, b] = *it; + if (holdsKeys(a) || holdsKeys(b)) { + ++it; + continue; + } + for (auto& [d, c] : gControllerOf) + if (c == a) c = b; + else if (c == b) c = a; + gLog(fmt::format("controllers: controllers {} and {} swapped", a, b)); + it = gPendingSwaps.erase(it); + } +} + +std::function()> gPlayingFn; +std::function gTookOverFn; +std::vector gTookOver; // lost players a new controller took over, told after the event + +int controllerFor(int32_t device) { + std::lock_guard lock(gDevicesMutex); + auto now = std::chrono::steady_clock::now(); + if (auto it = gControllerOf.find(device); it != gControllerOf.end()) { + gLastSeen[device] = now; + return it->second; + } + gLastSeen[device] = now; + purgeGoneDevices(true); + std::array used{}; + for (auto [d, c] : gControllerOf) used[c] = true; + // a player who lost their controller gets the new one first + if (gPlayingFn) + for (int c : gPlayingFn()) + if (c >= gFirstController && c < kMaxControllers && !used[c]) { + gControllerOf[device] = c; + gLog(fmt::format("controllers: device {} is controller {}: it takes over player {}, who had none", device, + c, c + 1)); + gTookOver.push_back(c); + return c; + } + for (int c = gFirstController; c < kMaxControllers; ++c) + if (!used[c]) { + gControllerOf[device] = c; + gLog(fmt::format("controllers: device {} is controller {}{}", device, c, + c == 0 ? " (player 1)" : " (joins with Start as a new player)")); + return c; + } + // Java could not tell: the controller silent the longest (10 seconds at least) is taken to be gone + int32_t oldest = -1; + for (auto [d, c] : gControllerOf) + if (c >= gFirstController && (oldest < 0 || gLastSeen[d] < gLastSeen[oldest])) oldest = d; + if (oldest >= 0 && now - gLastSeen[oldest] > std::chrono::seconds(10)) { + int c = gControllerOf[oldest]; + gControllerOf.erase(oldest); + gControllerOf[device] = c; + gLog(fmt::format("controllers: device {} is controller {}: device {} was silent the longest", device, c, oldest)); + gTookOver.push_back(c); + return c; + } + gLog(fmt::format("controllers: device {} gets no controller: 3 controllers already play", device)); + return -1; +} + +void releaseDevice(int32_t device) { + std::lock_guard lock(gDevicesMutex); + if (gControllerOf.erase(device)) gLog(fmt::format("controllers: device {} removed", device)); +} + +// Reads memory without crashing on a bad pointer. +bool safeRead(uintptr_t address, void* out, size_t size) { + iovec local{out, size}, remote{reinterpret_cast(address), size}; + return process_vm_readv(getpid(), &local, 1, &remote, 1, 0) == ssize_t(size); +} + +constexpr int32_t kSourceGamepad = 0x00000401; +constexpr int32_t kSourceJoystick = 0x01000010; + +std::function gStartPressed; + +// GameActivityKeyEvent: deviceId, source, action, (pad), eventTime, downTime, flags, metaState, modifiers, +// repeatCount, keyCode, scanCode, ... (checked on a device: the int at index 12 is the Android key code) +constexpr int kKeyCodeIndex = 12; +constexpr int kKeyCodeStart = 108; // KEYCODE_BUTTON_START +constexpr int kKeyCodeSelect = 109; // KEYCODE_BUTTON_SELECT (View / Back / Share / -) + +std::function gSelectPressed; +std::mutex gTasksMutex; +struct Task { + std::chrono::steady_clock::time_point due; + std::function run; +}; +std::vector gTasks; // run on the input thread, at the first input event after they are due +constexpr int kKeyActionDown = 0; + +} // namespace +void runDueTasks(); +namespace { + +// --- dispatch --------------------------------------------------------------------------------------------------- +using Fn8 = uint64_t (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t); +Fn8 gDispatch = nullptr; +std::atomic gEventsLogged{0}; +std::atomic gKeysToLog{0}; // after a swap: which keys each controller presses +std::atomic gBlocked[kMaxControllers] = {}; +std::atomic gUnassignedPresses{0}; +std::atomic gLastPresser{0}; + +uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, uint64_t a4, uint64_t a5, uint64_t a6, + uint64_t a7) { + int controller = -1; + uintptr_t event = 0; + int32_t index = -1, head[2] = {0, 0}; + bool startDown = false, selectDown = false, padEvent = false; + int32_t keyCode = -1, keyAction = -1; + if (safeRead(variant + 8, &index, 4) && index != -1 && safeRead(variant, &event, 8) && event + && safeRead(event, head, 8)) { + int32_t key[kKeyCodeIndex + 1] = {}; + if (index == 0 && safeRead(event, key, sizeof key)) { + keyCode = key[kKeyCodeIndex]; + keyAction = key[2]; + startDown = key[kKeyCodeIndex] == kKeyCodeStart && key[2] == kKeyActionDown; + selectDown = key[kKeyCodeIndex] == kKeyCodeSelect && key[2] == kKeyActionDown; + } + bool pad = (head[1] & kSourceGamepad) == kSourceGamepad || (head[1] & kSourceJoystick) == kSourceJoystick; + if (pad) controller = controllerFor(head[0]); + padEvent = pad; + if (index == 0 && pad && gKeysToLog > 0) { + --gKeysToLog; + gLog(fmt::format("controllers: device {} (controller {}) key {} {}", head[0], controller, key[kKeyCodeIndex], + key[2] == kKeyActionDown ? "down" : "up")); + } + if (gEventsLogged < 24) { + ++gEventsLogged; + gLog(fmt::format("controllers: event kind {} device {} source 0x{:X} -> controller {}", index, head[0], + head[1], controller)); + } + } + // a 5th controller would move player 1; a blocked one's player is leaving (it gets no new presses, but the + // release of a held button still reaches it) + bool keyDown = index == 0 && keyAction == kKeyActionDown, keyUp = index == 0 && keyAction != kKeyActionDown; + if (padEvent && ((controller < 0 && gFirstController > 0) || (controller >= 0 && gBlocked[controller] && !keyUp))) { + runDueTasks(); + return 0; + } + // who used a menu last: the controller that pressed a button, or player 1 (touch, keyboard) + if (padEvent && keyDown && controller >= 0) gLastPresser = controller; + else if (!padEvent && index >= 0) gLastPresser = 0; + if (padEvent && index == 0) { + std::lock_guard lock(gDevicesMutex); + if (keyDown) gHeld[head[0]].insert(keyCode); + else gHeld[head[0]].erase(keyCode); + } + // the first presses of a controller that plays nobody: what the game does with them (its join prompt) + if (padEvent && keyDown && controller > 0 && gPlayingFn && gUnassignedPresses < 6) { + auto playing = gPlayingFn(); + if (std::find(playing.begin(), playing.end(), controller) == playing.end()) { + ++gUnassignedPresses; + gLog(fmt::format("controllers: key {} on controller {}, which plays nobody (tracing what the game does)", + keyCode, controller)); + startTracing(20); + } + } + int previous = tCurrentController; + tCurrentController = controller; + uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); + tCurrentController = previous; + std::vector tookOver; + { + std::lock_guard lock(gDevicesMutex); + if (!gPendingSwaps.empty()) applyPendingSwaps(); + } + { + std::lock_guard lock(gDevicesMutex); + tookOver.swap(gTookOver); + } + if (gTookOverFn) + for (int c : tookOver) gTookOverFn(c); + if (startDown && controller >= 1 && gStartPressed) gStartPressed(controller); + if (selectDown && controller >= 0 && gSelectPressed) gSelectPressed(controller); // 0: a controller given to player 1 + runDueTasks(); + return result; +} + +// --- the manager's add / remove device (manager, int device) ------------------------------------------------------ +Fn8 gAdded = nullptr, gRemoved = nullptr; + +uint64_t addedDetour(uint64_t a0, uint64_t device, uint64_t a2, uint64_t a3, uint64_t a4, uint64_t a5, uint64_t a6, + uint64_t a7) { + int previous = tCurrentController; + tCurrentController = controllerFor(int32_t(device)); + uint64_t result = gAdded(a0, device, a2, a3, a4, a5, a6, a7); + tCurrentController = previous; + return result; +} + +uint64_t removedDetour(uint64_t a0, uint64_t device, uint64_t a2, uint64_t a3, uint64_t a4, uint64_t a5, uint64_t a6, + uint64_t a7) { + int previous; + { + std::lock_guard lock(gDevicesMutex); + auto it = gControllerOf.find(int32_t(device)); + previous = tCurrentController; + tCurrentController = it == gControllerOf.end() ? -1 : it->second; + } + uint64_t result = gRemoved(a0, device, a2, a3, a4, a5, a6, a7); + tCurrentController = previous; + releaseDevice(int32_t(device)); + return result; +} + +// --- finding things in the code ----------------------------------------------------------------------------------- +bool isAdrp(uint32_t op, int rd) { return (op & 0x9F00001F) == (0x90000000u | uint32_t(rd)); } +uintptr_t adrpTarget(uintptr_t pc, uint32_t op) { + int64_t imm = (int64_t(((op >> 5) & 0x7FFFF) << 2 | ((op >> 29) & 3)) << 43) >> 31; + return (pc & ~uintptr_t(0xFFF)) + imm; +} +bool isAddImm(uint32_t op, int rd, int rn) { + return (op & 0xFF80001F) == (0x91000000u | uint32_t(rd)) && int((op >> 5) & 31) == rn; +} +uintptr_t addImm(uint32_t op) { return uintptr_t((op >> 10) & 0xFFF) << (((op >> 22) & 1) ? 12 : 0); } +bool isBranch(uint32_t op) { return (op & 0x7C000000) == 0x14000000; } // b or bl +uintptr_t branchTarget(uintptr_t pc, uint32_t op) { return pc + ((int64_t(op & 0x3FFFFFF) << 38) >> 36); } + +// dispatch is just before the manager's add function: the function whose code has "ldr w8, [x2, #8]" (the +// variant's index) and "cmn w8, #1" (valueless_by_exception), starting at the nearest "sub sp, sp, #n". +uintptr_t findDispatch(Module const& game, uintptr_t managerAdded, uintptr_t& table) { + auto const* code = reinterpret_cast(managerAdded - 0x400); + for (int i = 0x100 - 1; i >= 0; --i) { + if (code[i] != 0xB9400848) continue; // ldr w8, [x2, #8] + bool cmn = false; + for (int k = i + 1; k < i + 6; ++k) cmn |= code[k] == 0x3100051F; // cmn w8, #1 + if (!cmn) continue; + int start = -1; + for (int k = i; k > i - 24 && k >= 0; --k) + if ((code[k] & 0xFF0003FF) == 0xD10003FF) { // sub sp, sp, #n + start = k; + break; + } + if (start < 0) continue; + // its visitor table: "adrp x10; add x10, x10, #n" + table = 0; + for (int k = i; k < i + 16; ++k) + if (isAdrp(code[k], 10) && isAddImm(code[k + 1], 10, 10)) { + table = adrpTarget(managerAdded - 0x400 + 4 * k, code[k]) + addImm(code[k + 1]); + break; + } + uintptr_t at = managerAdded - 0x400 + 4 * start; + return game.isCode(at) ? at : 0; + } + return 0; +} + +// Calls on `object` ("adrp x0; add x0, x0" then a b/bl) in `size` bytes of code from `from`. +void collectCalls(Module const& game, uintptr_t from, size_t size, uintptr_t object, std::vector& out) { + if (!game.isCode(from) || !game.isCode(from + size)) return; + auto const* code = reinterpret_cast(from); + int n = int(size / 4); + for (int i = 0; i + 1 < n; ++i) { + if (!isAdrp(code[i], 0) || !isAddImm(code[i + 1], 0, 0)) continue; + if (adrpTarget(from + 4 * i, code[i]) + addImm(code[i + 1]) != object) continue; + for (int j = i + 2; j < i + 14 && j < n; ++j) + if (isBranch(code[j])) { + uintptr_t target = branchTarget(from + 4 * j, code[j]); + if (game.isCode(target) && std::find(out.begin(), out.end(), target) == out.end()) out.push_back(target); + break; + } + } +} + +std::string offset(uintptr_t p) { return fmt::format("+0x{:X}", p - gGame->base); } + +} // namespace + +void runDueTasks() { + std::vector> tasks; + { + std::lock_guard lock(gTasksMutex); + auto now = std::chrono::steady_clock::now(); + for (auto it = gTasks.begin(); it != gTasks.end();) + if (it->due <= now) { + tasks.push_back(std::move(it->run)); + it = gTasks.erase(it); + } else + ++it; + } + for (auto& task : tasks) task(); +} + +void onStartPressed(std::function handler) { gStartPressed = std::move(handler); } + +void onSelectPressed(std::function handler) { gSelectPressed = std::move(handler); } + +void runOnInputThread(std::function task, std::chrono::milliseconds delay) { + std::lock_guard lock(gTasksMutex); + gTasks.push_back(Task{std::chrono::steady_clock::now() + delay, std::move(task)}); +} + +void setFirstControllerIsPlayer1(bool yes) { gFirstController = yes ? 0 : 1; } + +void setPlayingPlayers(std::function()> playing) { gPlayingFn = std::move(playing); } + +void onLostPlayerTakenOver(std::function handler) { gTookOverFn = std::move(handler); } + +bool controllerConnected(int controller) { + std::lock_guard lock(gDevicesMutex); + purgeGoneDevices(false); + for (auto [d, c] : gControllerOf) + if (c == controller) return true; + return false; +} + +int lastPresser() { return gLastPresser; } + +void blockController(int controller, bool blocked) { + if (controller >= 0 && controller < kMaxControllers) gBlocked[controller] = blocked; +} + +void swapControllers(int a, int b) { + std::lock_guard lock(gDevicesMutex); + gPendingSwaps.push_back({a, b}); + applyPendingSwaps(); + if (!gPendingSwaps.empty()) gLog(fmt::format("controllers: controllers {} and {} swap once no button is held", a, b)); + gKeysToLog = 40; + gRemapsLogged = 0; +} + +bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, + LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + + uintptr_t table = 0; + uintptr_t dispatch = findDispatch(copy, managerAdded, table); + if (!dispatch) return gLog("controllers: ERROR: the controller manager's dispatch function was not found"), false; + gLog(fmt::format("controllers: dispatch {} (visitors at {})", offset(dispatch), table ? offset(table) : "?")); + + // GameControllerManager and its functions + auto system = findGamepadSystem(copy, managerAdded, managerObject); + if (!system.object) return gLog("controllers: ERROR: GameControllerManager not found"), false; + std::vector methods = system.methods; + if (table) { + for (int v = 0; v < 8; ++v) { + uintptr_t visitor = 0; + if (!safeRead(table + 8 * v, &visitor, 8) || !copy.isCode(visitor)) break; + collectCalls(copy, visitor, 0x1800, system.object, methods); + // visitors are often thunks: also scan what they call first + auto const* code = reinterpret_cast(visitor); + for (int k = 0; k < 16; ++k) + if (isBranch(code[k])) { + collectCalls(copy, branchTarget(visitor + 4 * k, code[k]), 0x1800, system.object, methods); + break; + } + } + } + gLog(fmt::format("controllers: GameControllerManager {} ({}), {} functions", offset(system.object), + className(copy, system.object), methods.size())); + + void (*trampolines[kMaxHooks])() = { + splitscreen_trampoline_0, splitscreen_trampoline_1, splitscreen_trampoline_2, splitscreen_trampoline_3, + splitscreen_trampoline_4, splitscreen_trampoline_5, splitscreen_trampoline_6, splitscreen_trampoline_7, + splitscreen_trampoline_8, splitscreen_trampoline_9, splitscreen_trampoline_10, splitscreen_trampoline_11, + splitscreen_trampoline_12, splitscreen_trampoline_13, splitscreen_trampoline_14, splitscreen_trampoline_15}; + for (size_t i = 0; i < methods.size() && i < kMaxHooks; ++i) { + gMethods[i] = methods[i]; + bool ok = pl::memory::hook(reinterpret_cast(methods[i]), + reinterpret_cast(trampolines[i]), + reinterpret_cast(&splitscreen_originals[i])) + == 0; + gLog(fmt::format("controllers: #{} {} {}", i, offset(methods[i]), ok ? "hooked" : "ERROR: not hooked")); + } + + auto hook = [&](uintptr_t target, void* detour, Fn8* original, char const* name) { + bool ok = pl::memory::hook(reinterpret_cast(target), detour, + reinterpret_cast(original)) + == 0; + gLog(fmt::format("controllers: {} {} {}", name, offset(target), ok ? "hooked" : "ERROR: not hooked")); + return ok; + }; + bool ok = hook(dispatch, reinterpret_cast(&dispatchDetour), &gDispatch, "dispatch"); + if (managerAdded) hook(managerAdded, reinterpret_cast(&addedDetour), &gAdded, "device added"); + if (managerRemoved) hook(managerRemoved, reinterpret_cast(&removedDetour), &gRemoved, "device removed"); + return ok; +} + +} // namespace splitscreen + +// Called by the trampolines (Trampolines.S) on every GameControllerManager call, with the saved x0..x8. +extern "C" void splitscreen_on_call(uint64_t index, uint64_t* regs, uint64_t, uint64_t ret) { + using namespace splitscreen; + uint64_t n = ++gCalls[index]; + if (n == 1 || n == 100 || n == 10000) + gLog(fmt::format("controllers: #{} called {} times (first from {})", index, n, offset(ret))); + int controller = tCurrentController; + if (controller > 0 && int32_t(regs[1]) == 0) { + regs[1] = (regs[1] & ~uint64_t(0xFFFFFFFF)) | uint32_t(controller); + if (gRemapsLogged < 30) { + ++gRemapsLogged; + gLog(fmt::format("controllers: #{} ({}) controller 0 -> {}, args {} {} {}", index, offset(gMethods[index]), + controller, int32_t(regs[2]), int32_t(regs[3]), int32_t(regs[4]))); + } + } +} diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h new file mode 100644 index 0000000..e6a6162 --- /dev/null +++ b/android/src/ControllerSplit.h @@ -0,0 +1,65 @@ +#pragma once + +// One player per controller on Android. +// +// Android's Minecraft keeps every controller apart in its controller manager (one slot per Android device), but +// tells its GameControllerManager that everything comes from controller 0: each call passes 0 in w1. So every +// controller moves player 1. +// +// Every controller event goes through one function of the controller manager, "dispatch": +// dispatch(manager, ?, const std::variant& event, bool) +// whose events start with the Android device id. While dispatch runs (and while the manager adds or removes a +// device), the device's controller number (0 for the first controller, 1 for the second, ...) is the "current +// controller" of that thread, and every GameControllerManager call made on that thread with controller 0 gets it +// instead. GameControllerManager's functions are found from the manager's code and dispatch's visitor table. + +#include "Elf.h" +#include "InputProbe.h" + +#include +#include +#include + +namespace splitscreen { + +// managerAdded/managerRemoved: the manager's add/remove device functions (from the JNI functions' code). +bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, + LogFn log); + +// false (default): player 1 keeps touch and every controller joins as a new player with Start (controllers 1-3). +// true: the first controller plays player 1, as the game alone does. +void setFirstControllerIsPlayer1(bool yes); + +// Called on the input thread, after the game handled it, when a controller other than the first presses Start. +void onStartPressed(std::function handler); + +// Called on the input thread, after the game handled it, when a controller other than the first presses Select +// (View / Back / Share). +void onSelectPressed(std::function handler); + +// The controller numbers that play (players 2-4 by controller number): a new controller takes over one of them +// whose controller is gone before it gets a free number. +void setPlayingPlayers(std::function()> playing); + +// Called on the input thread when a new controller took over player `controller`, who had none. +void onLostPlayerTakenOver(std::function handler); + +// Whether a connected device plays as controller number `controller`. +bool controllerConnected(int controller); + +// The controller number that pressed a button last (0: player 1, also for touch). +int lastPresser(); + +// While `blocked`, the controller's buttons and sticks reach nobody (its player is leaving). +void blockController(int controller, bool blocked); + +// The device of controller number a now plays as b, and b's (if any) as a. +void swapControllers(int a, int b); + +// Runs the tasks that are due (on the input thread, after each input event: also those of a blocked controller). +void runDueTasks(); + +// Runs `task` on the input thread (the game's main thread) at the first input event after `delay`. +void runOnInputThread(std::function task, std::chrono::milliseconds delay = std::chrono::milliseconds(0)); + +} // namespace splitscreen diff --git a/android/src/DeviceManagerProbe.cpp b/android/src/DeviceManagerProbe.cpp new file mode 100644 index 0000000..58bf3a2 --- /dev/null +++ b/android/src/DeviceManagerProbe.cpp @@ -0,0 +1,135 @@ +#include "DeviceManagerProbe.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace splitscreen { + +namespace { + +// AArch64 decoding of the few instructions needed here. +bool isAdrp(uint32_t op, int rd) { return (op & 0x9F00001F) == (0x90000000u | uint32_t(rd)); } +uintptr_t adrpTarget(uintptr_t pc, uint32_t op) { + int64_t imm = (int64_t(((op >> 5) & 0x7FFFF) << 2 | ((op >> 29) & 3)) << 43) >> 31; // sign-extend 21 bits, << 12 + return (pc & ~uintptr_t(0xFFF)) + imm; +} +// add xd, xn, #imm (64-bit, optional lsl #12) +bool isAddImm(uint32_t op, int rd, int rn) { + return (op & 0xFF80001F) == (0x91000000u | uint32_t(rd)) && int((op >> 5) & 31) == rn; +} +uintptr_t addImm(uint32_t op) { return uintptr_t((op >> 10) & 0xFFF) << (((op >> 22) & 1) ? 12 : 0); } +bool isBl(uint32_t op) { return (op & 0xFC000000) == 0x94000000; } +uintptr_t blTarget(uintptr_t pc, uint32_t op) { return pc + ((int64_t(op & 0x3FFFFFF) << 38) >> 36); } + +} // namespace + +std::vector findDeviceManagerCalls(Module const& game, std::vector const& exports) { + (void)game; + std::vector out; + for (char const* jni : {"onInputDeviceAddedNative", "onInputDeviceRemovedNative", "setControllerDetailsNative", + "onInputDeviceChangedNative"}) { + for (auto const& e : exports) { + if (!e.name.starts_with("Java_com_mojang_minecraftpe_input_JellyBeanDeviceManager_") || !e.name.ends_with(jni)) + continue; + auto const* code = reinterpret_cast(e.address); + uintptr_t object = 0; + for (int i = 0; i + 1 < 64; ++i) { + uintptr_t pc = e.address + 4 * i; + if (code[i] == 0xD65F03C0 && object == 0) break; // ret before the pattern: not this shape + if (isAdrp(code[i], 0) && isAddImm(code[i + 1], 0, 0)) { + object = adrpTarget(pc, code[i]) + addImm(code[i + 1]); + continue; + } + if (object && isBl(code[i])) { + out.push_back({jni, object, blTarget(pc, code[i])}); + break; + } + } + } + } + return out; +} + +GamepadSystem findGamepadSystem(Module const& game, uintptr_t managerMethod, uintptr_t managerObject) { + GamepadSystem out; + std::map objects; + std::vector> calls; // object, target + auto const* code = reinterpret_cast(managerMethod); + constexpr int kScan = 1536; // instructions (6 KB) of the manager's code + if (!game.isCode(managerMethod + 4 * kScan)) return out; + for (int i = 0; i + 1 < kScan; ++i) { + if (!isAdrp(code[i], 0) || !isAddImm(code[i + 1], 0, 0)) continue; + uintptr_t pc = managerMethod + 4 * i; + uintptr_t object = adrpTarget(pc, code[i]) + addImm(code[i + 1]); + if (object == managerObject) continue; + for (int j = i + 2; j < i + 14 && j < kScan; ++j) { + uint32_t op = code[j]; + bool b = (op & 0xFC000000) == 0x14000000; + if (isBl(op) || b) { + calls.push_back({object, blTarget(managerMethod + 4 * j, op)}); // b and bl encode the target alike + ++objects[object]; + break; + } + } + } + int best = 0; + for (auto [object, n] : objects) + if (n > best) best = n, out.object = object; + for (auto [object, target] : calls) + if (object == out.object && std::find(out.methods.begin(), out.methods.end(), target) == out.methods.end()) + out.methods.push_back(target); + return out; +} + +std::string className(Module const& game, uintptr_t object) { + auto vptr = *reinterpret_cast(object); + bool inGame = false; + for (auto const& s : game.segments) inGame |= vptr >= s.start + 8 && vptr < s.end && !s.exec; + if (!inGame) return ""; + auto typeInfo = reinterpret_cast(vptr)[-1]; + for (auto const& s : game.segments) + if (typeInfo >= s.start && typeInfo + 16 <= s.end) { + auto name = reinterpret_cast(typeInfo)[1]; + for (auto const& t : game.segments) + if (reinterpret_cast(name) >= t.start && reinterpret_cast(name) < t.end) + return std::string(name, strnlen(name, 200)); + } + return ""; +} + +void watchObject(Module const& game, uintptr_t object, size_t size, LogFn log) { + std::thread([object, size, log, base = game.base] { + using namespace std::chrono_literals; + std::vector last; + for (int round = 0; round < 360; ++round) { + std::vector now(size); + std::memcpy(now.data(), reinterpret_cast(object), size); + if (now != last) { + log(fmt::format("object +0x{:X} at round {}:", object - base, round)); + for (size_t off = 0; off < size; off += 32) { + if (!last.empty() && std::memcmp(now.data() + off, last.data() + off, std::min(32, size - off)) == 0) + continue; // only the lines that changed + std::string line = fmt::format(" +{:03X}:", off); + for (size_t i = off; i < off + 32 && i < size; i += 8) + line += fmt::format(" {:016X}", *reinterpret_cast(now.data() + i)); + log(line); + } + last = std::move(now); + } + std::this_thread::sleep_for(10s); + } + }).detach(); +} + +} // namespace splitscreen + diff --git a/android/src/DeviceManagerProbe.h b/android/src/DeviceManagerProbe.h new file mode 100644 index 0000000..20d98ea --- /dev/null +++ b/android/src/DeviceManagerProbe.h @@ -0,0 +1,42 @@ +#pragma once + +// The game's native controller bookkeeping on Android. +// +// Java's JellyBeanDeviceManager reports each controller to the game through JNI (onInputDeviceAddedNative, +// onInputDeviceRemovedNative, setControllerDetailsNative). Each of these ends in +// adrp x0, object@page; add x0, x0, object@pageoff; mov w1, ; bl +// i.e. a call on one static object in the game: its controller manager. This finds that object and the methods +// from the code (no fixed addresses), logs its class name, the methods' code, and the object's memory whenever it +// changes, to see where devices 11 and 12 end up. + +#include "Elf.h" +#include "InputProbe.h" + +#include + +namespace splitscreen { + +struct DeviceManagerCall { + char const* jni; // e.g. "onInputDeviceAddedNative" + uintptr_t object; // the static object passed in x0 + uintptr_t method; // the function called on it +}; + +std::vector findDeviceManagerCalls(Module const& game, std::vector const& exports); + +// The game's gamepad system: the other static object the controller manager's code calls (with controller id 0 +// in w1 everywhere on Android), and the functions it calls on it. Found by scanning the manager's code after +// `managerMethod` for "adrp x0; add x0" pairs naming an object other than the manager, followed by a call. +struct GamepadSystem { + uintptr_t object = 0; + std::vector methods; +}; +GamepadSystem findGamepadSystem(Module const& game, uintptr_t managerMethod, uintptr_t managerObject); + +// The RTTI class name of a polymorphic object in the game, or "" if it has none. +std::string className(Module const& game, uintptr_t object); + +// Logs the object's memory now and every 10 seconds when it changed (for an hour). +void watchObject(Module const& game, uintptr_t object, size_t size, LogFn log); + +} // namespace splitscreen diff --git a/android/src/Elf.cpp b/android/src/Elf.cpp new file mode 100644 index 0000000..c15c1a9 --- /dev/null +++ b/android/src/Elf.cpp @@ -0,0 +1,264 @@ +#include "Elf.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace splitscreen { + +bool Module::isCode(uintptr_t p) const { + for (auto const& s : segments) + if (s.exec && p >= s.start && p < s.end) return true; + return false; +} + +std::optional findModule(char const* name) { + struct Ctx { + char const* name; + std::optional result; + } ctx{name, std::nullopt}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto& c = *static_cast(data); + if (!info->dlpi_name) return 0; + std::string_view path(info->dlpi_name), want(c.name); + if (path.size() < want.size() || path.substr(path.size() - want.size()) != want) return 0; + Module m; + m.path = info->dlpi_name; + m.base = info->dlpi_addr; + for (int i = 0; i < info->dlpi_phnum; ++i) { + auto const& ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_LOAD) continue; + Segment s; + s.start = info->dlpi_addr + ph.p_vaddr; + s.end = s.start + ph.p_memsz; + s.read = ph.p_flags & PF_R; + s.write = ph.p_flags & PF_W; + s.exec = ph.p_flags & PF_X; + m.segments.push_back(s); + } + c.result = std::move(m); + return 1; + }, + &ctx + ); + return ctx.result; +} + +static bool looksLikeTypeName(std::string_view s) { + if (s.empty() || s.size() > 200) return false; + if (!(std::isdigit(static_cast(s[0])) || s[0] == 'N')) return false; + for (char c : s) + if (static_cast(c) < 0x21 || static_cast(c) > 0x7E) return false; + return true; +} + +std::vector findTypeNames(Module const& m, std::string_view needle) { + std::vector out; + for (auto const& s : m.segments) { + // Type names live in .rodata. The game links .rodata into its first, executable segment (r-x), so every + // readable segment that is not writable is searched. + if (!s.read || s.write) continue; + auto const* begin = reinterpret_cast(s.start); + size_t size = s.end - s.start; + for (size_t at = 0; at + needle.size() <= size;) { + auto const* hit = static_cast(memmem(begin + at, size - at, needle.data(), needle.size())); + if (!hit) break; + at = static_cast(hit - begin) + 1; + // the start of the string: just after the previous NUL + char const* start = hit; + while (start > begin && start[-1] != '\0' && hit - start < 200) --start; + if (start > begin && start[-1] != '\0') continue; + std::string_view str(start, strnlen(start, 256)); + if (!looksLikeTypeName(str)) continue; + auto addr = reinterpret_cast(start); + bool dup = false; + for (auto const& t : out) dup |= t.address == addr; + if (!dup) out.push_back({addr, std::string(str)}); + } + } + return out; +} + +// Calls f(slot address) for each pointer-sized word in the module's writable (relocated) segments equal to value. +template +static void forEachPointerTo(Module const& m, uintptr_t value, F&& f) { + for (auto const& s : m.segments) { + if (!s.read || !s.write) continue; + for (uintptr_t p = (s.start + 7) & ~uintptr_t(7); p + 8 <= s.end; p += 8) + if (*reinterpret_cast(p) == value) f(p, s); + } +} + +uintptr_t findTypeInfo(Module const& m, uintptr_t name) { + uintptr_t found = 0; + forEachPointerTo(m, name, [&](uintptr_t p, Segment const& s) { + // type_info: { vptr, name, ... }; the vptr is non-null + if (!found && p >= s.start + 8 && *reinterpret_cast(p - 8) != 0) found = p - 8; + }); + return found; +} + +std::vector findVtables(Module const& m, uintptr_t typeInfo) { + std::vector out; + forEachPointerTo(m, typeInfo, [&](uintptr_t p, Segment const& s) { + // vtable: { offset_to_top (0 for the primary vtable), type_info*, slot 0, ... } + if (p < s.start + 8 || p + 16 > s.end) return; + if (*reinterpret_cast(p - 8) != 0) return; + out.push_back(reinterpret_cast(p + 8)); + }); + return out; +} + +// True if p points into an executable segment of any loaded library (e.g. __cxa_pure_virtual in libc++). +static bool isCodeAnywhere(uintptr_t p) { + struct Ctx { + uintptr_t p; + bool found; + } ctx{p, false}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto& c = *static_cast(data); + for (int i = 0; i < info->dlpi_phnum; ++i) { + auto const& ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_LOAD || !(ph.p_flags & PF_X)) continue; + uintptr_t start = info->dlpi_addr + ph.p_vaddr; + if (c.p >= start && c.p < start + ph.p_memsz) return c.found = true, 1; + } + return 0; + }, + &ctx + ); + return ctx.found; +} + +size_t countSlots(Module const& m, uintptr_t* vtable, size_t max) { + size_t n = 0; + while (n < max && (m.isCode(vtable[n]) || isCodeAnywhere(vtable[n]))) ++n; + return n; +} + +std::optional constantReturn(uintptr_t fn) { + auto const* code = reinterpret_cast(fn); + if (code[1] != 0xD65F03C0) return std::nullopt; // ret + if (code[0] == 0x2A1F03E0) return 0u; // mov w0, wzr + if ((code[0] & 0xFFE0001F) == 0x52800000) return (code[0] >> 5) & 0xFFFF; // movz w0, #imm + return std::nullopt; +} + +std::string describeFunction(Module const& m, uintptr_t fn) { + std::string out; + Dl_info info{}; + if (dladdr(reinterpret_cast(fn), &info) && info.dli_sname + && reinterpret_cast(info.dli_saddr) == fn) { + int status = 0; + char* plain = abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); + out = status == 0 && plain ? plain : info.dli_sname; + std::free(plain); + out += " "; + } + if (!m.isCode(fn)) return out + "(outside the game)"; + if (auto c = constantReturn(fn)) return out + fmt::format("return {}", *c); + auto const* code = reinterpret_cast(fn); + if ((code[0] & 0xFC000000) == 0x14000000) return out + "tail call"; + return out + fmt::format("{:08X} {:08X} {:08X}", code[0], code[1], code[2]); +} + +// The number of entries in the dynamic symbol table, from DT_HASH (nchain) or DT_GNU_HASH (the highest symbol +// index in its buckets and chains). +static size_t dynamicSymbolCount(uintptr_t hash, uintptr_t gnuHash) { + if (hash) return reinterpret_cast(hash)[1]; + if (!gnuHash) return 0; + auto const* h = reinterpret_cast(gnuHash); + uint32_t nbuckets = h[0]; + uint32_t symoffset = h[1]; + uint32_t bloomSize = h[2]; + auto const* buckets = reinterpret_cast(gnuHash + 16 + bloomSize * sizeof(uintptr_t)); + auto const* chains = buckets + nbuckets; + uint32_t last = 0; + for (uint32_t b = 0; b < nbuckets; ++b) last = std::max(last, buckets[b]); + if (last < symoffset) return symoffset; + while (!(chains[last - symoffset] & 1)) ++last; + return last + 1; +} + +// Calls f(symbol, strtab) for each entry of the module's dynamic symbol table. +template +static void forEachDynamicSymbol(Module const& m, F&& f) { + struct Ctx { + Module const* m; + F* f; + } ctx{&m, &f}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto& c = *static_cast(data); + if (info->dlpi_addr != c.m->base || !info->dlpi_name || c.m->path != info->dlpi_name) return 0; + for (int i = 0; i < info->dlpi_phnum; ++i) { + auto const& ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_DYNAMIC) continue; + uintptr_t symtab = 0, strtab = 0, hash = 0, gnuHash = 0; + for (auto const* d = reinterpret_cast(info->dlpi_addr + ph.p_vaddr); d->d_tag != DT_NULL; + ++d) { + // Android's loader leaves these as addresses relative to the load base + uintptr_t v = d->d_un.d_ptr; + if (v < info->dlpi_addr) v += info->dlpi_addr; + if (d->d_tag == DT_SYMTAB) symtab = v; + else if (d->d_tag == DT_STRTAB) strtab = v; + else if (d->d_tag == DT_HASH) hash = v; + else if (d->d_tag == DT_GNU_HASH) gnuHash = v; + } + if (!symtab || !strtab) return 1; + size_t n = dynamicSymbolCount(hash, gnuHash); + auto* syms = reinterpret_cast(symtab); + for (size_t k = 1; k < n; ++k) (*c.f)(syms[k], reinterpret_cast(strtab)); + return 1; + } + return 1; + }, + &ctx + ); +} + +std::vector exportedFunctions(Module const& m) { + std::vector out; + forEachDynamicSymbol(m, [&](ElfW(Sym) const& sym, char const* strtab) { + if (ELF64_ST_TYPE(sym.st_info) != STT_FUNC || sym.st_shndx == SHN_UNDEF || !sym.st_value) return; + out.push_back({strtab + sym.st_name, m.base + sym.st_value}); + }); + return out; +} + +std::vector importedSymbols(Module const& m) { + std::vector out; + forEachDynamicSymbol(m, [&](ElfW(Sym) const& sym, char const* strtab) { + if (sym.st_shndx == SHN_UNDEF && sym.st_name) out.emplace_back(strtab + sym.st_name); + }); + return out; +} + +bool isPureVirtual(uintptr_t fn) { + Dl_info info{}; + return fn && dladdr(reinterpret_cast(fn), &info) && info.dli_sname + && reinterpret_cast(info.dli_saddr) == fn && std::strcmp(info.dli_sname, "__cxa_pure_virtual") == 0; +} + +bool writeSlot(uintptr_t* slot, uintptr_t value) { + long pageSize = sysconf(_SC_PAGESIZE); + uintptr_t page = reinterpret_cast(slot) & ~(uintptr_t(pageSize) - 1); + if (mprotect(reinterpret_cast(page), pageSize, PROT_READ | PROT_WRITE) != 0) return false; + __atomic_store_n(slot, value, __ATOMIC_SEQ_CST); + mprotect(reinterpret_cast(page), pageSize, PROT_READ); + return true; +} + +} // namespace splitscreen diff --git a/android/src/Elf.h b/android/src/Elf.h new file mode 100644 index 0000000..02f7e67 --- /dev/null +++ b/android/src/Elf.h @@ -0,0 +1,78 @@ +#pragma once + +// Finding the game's classes in libminecraftpe.so without fixed addresses or byte patterns. +// +// Android's game library keeps C++ RTTI (Itanium ABI), so every polymorphic class has its type name as a string +// ("11AppPlatform"), a type_info object that points to that string, and a vtable that points to the type_info. +// Following those pointers gives each class's vtable, which survives game updates far better than byte patterns. + +#include +#include +#include +#include +#include +#include + +namespace splitscreen { + +struct Segment { + uintptr_t start = 0; + uintptr_t end = 0; + bool read = false; + bool write = false; // .data.rel.ro is in a PF_W segment (made read-only after relocation) + bool exec = false; +}; + +struct Module { + std::string path; + uintptr_t base = 0; + std::vector segments; + + [[nodiscard]] bool isCode(uintptr_t p) const; +}; + +// The loaded library whose path ends with `name`, e.g. "libminecraftpe.so". +std::optional findModule(char const* name); + +struct TypeName { + uintptr_t address; // the string in .rodata + std::string name; // mangled, e.g. "19AppPlatform_android" +}; + +// Every RTTI type name that contains `needle` (e.g. "AppPlatform" finds "11AppPlatform", "19AppPlatform_android"). +std::vector findTypeNames(Module const& m, std::string_view needle); + +// The type_info object whose name pointer is `name`, or 0. +uintptr_t findTypeInfo(Module const& m, uintptr_t name); + +// The address points (first virtual function slot) of the primary vtables that use `typeInfo`. +std::vector findVtables(Module const& m, uintptr_t typeInfo); + +// Number of slots from `vtable` that hold code pointers (into this module, or exported functions elsewhere). +size_t countSlots(Module const& m, uintptr_t* vtable, size_t max); + +// A short description of the code at `fn`: its exported symbol if it has one, and what its first instructions do +// ("return 0", "return 1", "tail call", ...). +std::string describeFunction(Module const& m, uintptr_t fn); + +// If the function only returns a constant (mov w0,#n; ret), that constant. +std::optional constantReturn(uintptr_t fn); + +struct Symbol { + std::string name; + uintptr_t address; +}; + +// The functions the library exports (its dynamic symbol table), e.g. the JNI entry points Java calls. +std::vector exportedFunctions(Module const& m); + +// The names of the symbols the library imports from other libraries (undefined in its dynamic symbol table). +std::vector importedSymbols(Module const& m); + +// True if fn is __cxa_pure_virtual (a pure virtual function's slot). +bool isPureVirtual(uintptr_t fn); + +// Writes `value` into a vtable slot (the page is read-only after relocation). +bool writeSlot(uintptr_t* slot, uintptr_t value); + +} // namespace splitscreen diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp new file mode 100644 index 0000000..9ce94de --- /dev/null +++ b/android/src/FormProbe.cpp @@ -0,0 +1,435 @@ +#include "FormProbe.h" + +#include "DeviceManagerProbe.h" +#include "VTrace.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace splitscreen { + +namespace { + +Module const* gGame = nullptr; +LogFn gLog; + +bool safeRead(uintptr_t address, void* out, size_t size) { + iovec local{out, size}, remote{reinterpret_cast(address), size}; + return process_vm_readv(getpid(), &local, 1, &remote, 1, 0) == ssize_t(size); +} + +std::string offset(uintptr_t p) { + return gGame && gGame->isCode(p) ? fmt::format("+0x{:X}", p - gGame->base) : fmt::format("0x{:X}", p); +} + +struct Found { + std::string name; + uintptr_t* vtable = nullptr; + size_t slots = 0; +}; + +// Classes whose RTTI name contains `needle`, with their primary vtables. +std::vector find(char const* needle, bool log) { + std::vector out; + for (auto const& t : findTypeNames(*gGame, needle)) { + uintptr_t ti = findTypeInfo(*gGame, t.address); + if (!ti) continue; + for (auto* vt : findVtables(*gGame, ti)) { + Found f{t.name, vt, countSlots(*gGame, vt, 64)}; + if (log) { + gLog(fmt::format("forms: class {} vtable +0x{:X}, {} slots", f.name, + reinterpret_cast(vt) - gGame->base, f.slots)); + for (size_t i = 0; i < f.slots && i < 24; ++i) + gLog(fmt::format("forms: [{}] {} {}", i, offset(vt[i]), describeFunction(*gGame, vt[i]))); + } + out.push_back(f); + } + } + return out; +} + +// IPacketHandlerDispatcher::handle(NetworkIdentifier const& source, NetEventCallback& callback, +// std::shared_ptr& packet) const: vtable slot 2 (after the two destructors). +using HandleFn = void (*)(void*, void*, void*, void*); +HandleFn gAuthHandle = nullptr, gResponseHandle = nullptr; +std::mutex gSeenMutex; +std::set gSeenSub; +std::atomic gTracingStarted{false}; + +// --- sending a form --------------------------------------------------------------------------------------------- +// LoopbackPacketSender virtual 7: sendToClient(NetworkIdentifier const&, Packet const&, SubClientId) (0.5.0: it is the +// one called with the local players' NetworkIdentifier). Its first call gives the server's sender object. +using SendToClientFn = void (*)(void*, void const*, void const*, uint32_t); +SendToClientFn gSendToClient = nullptr; +std::atomic gSender{nullptr}; + +void sendToClientDetour(void* self, void const* id, void const* packet, uint32_t sub) { + if (!gSender.exchange(self)) gLog(fmt::format("forms: the server's packet sender is {}", self)); + gSendToClient(self, id, packet, sub); +} + +// ModalFormRequestPacket on Android (checked: getId at slot 2 returns 100, getSerializationMode reads +0x50): +// Packet: vptr, priority +8, reliability +12, sender sub client +16, handled +17, receive time +24, +// handler dispatcher +32, compressibility +40; then form id +0x30, form JSON (std::string) +0x38, +// serialization mode +0x50. +uintptr_t gRequestVtable = 0; // address point of ModalFormRequestPacket's vtable +uintptr_t gRequestDispatcher = 0; // the PacketHandlerDispatcherInstance object + +struct RequestPacket { + uintptr_t vptr; + int32_t priority; // MediumPriority + int32_t reliability; // ReliableOrdered + uint8_t senderSubId; + bool handled; + uint8_t pad[6]; + int64_t receiveTime; + uintptr_t handler; + int32_t compressible; // Compressible + int32_t pad2; + uint32_t formId; + uint32_t pad3; + std::string json; + int32_t serializationMode; + int32_t pad4; +}; +#ifdef _LIBCPP_VERSION // the game's std::string (libc++, 24 bytes) +static_assert(offsetof(RequestPacket, formId) == 0x30 && offsetof(RequestPacket, json) == 0x38 + && offsetof(RequestPacket, serializationMode) == 0x50); +#endif + +// ClientboundCloseFormPacket: closes the open form; only the Packet base (0x30 bytes, room left for more). +uintptr_t gCloseVtable = 0, gCloseDispatcher = 0; +struct ClosePacket { + uintptr_t vptr; + int32_t priority; + int32_t reliability; + uint8_t senderSubId; + bool handled; + uint8_t pad[6]; + int64_t receiveTime; + uintptr_t handler; + int32_t compressible; + int32_t pad2; + uint8_t room[0x40]; +}; + +bool sendCloseForm(void const* id, uint32_t sub) { + void* sender = gSender.load(); + if (!sender || !gSendToClient || !gCloseVtable || !gCloseDispatcher) return false; + auto* packet = new ClosePacket{gCloseVtable, 2, 1, 0, false, {}, 0, gCloseDispatcher, 0, 0, {}}; + gLog(fmt::format("forms: closing the form of sub client {}", sub)); + gSendToClient(sender, id, packet, sub); + return true; +} + +// The packet is kept (never freed): the loopback may hand the object itself to the client. +bool sendForm(void const* id, uint32_t sub, uint32_t formId, std::string json) { + void* sender = gSender.load(); + if (!sender || !gSendToClient || !gRequestVtable || !gRequestDispatcher) { + gLog(fmt::format("forms: cannot send yet (sender {}, vtable {}, dispatcher {})", sender, gRequestVtable != 0, + gRequestDispatcher != 0)); + return false; + } + auto* packet = new RequestPacket{gRequestVtable, 2, 1, 0, false, {}, 0, gRequestDispatcher, 0, 0, formId, 0, + std::move(json), 0, 0}; + gLog(fmt::format("forms: sending form {} to sub client {}", formId, sub)); + gSendToClient(sender, id, packet, sub); + gLog(fmt::format("forms: form {} sent", formId)); + return true; +} + +// Player 1 in a world: its PlayerAuthInputPacket keeps coming (one a tick), for 3 seconds at least. +std::atomic gFirstAuth{0}, gLastAuth{0}; +int64_t nowMs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +std::atomic gLastFormAt[4] = {}; // when each player's last menu was sent or answered (ms) + +// Menus: one pending form per player (sub client), sent from that player's next PlayerAuthInputPacket (the server +// thread). A busy player (another screen open) answers "UserBusy" at once: the form is sent again half a second +// later, for up to 20 seconds. The answer: a Json::Value (+0x38: 8-byte value, type +0x40, 1 = int) holding the button index. +struct Pending { + uint32_t formId = 0; + std::string json; + std::function done; + std::chrono::steady_clock::time_point due; + int tries = 0; + std::chrono::steady_clock::time_point created = std::chrono::steady_clock::now(); + std::chrono::seconds timeout{0}; // closed with `timeoutAnswer` this long after it shows + int timeoutAnswer = -1; + std::chrono::steady_clock::time_point shown{}; +}; +std::map gPending; // sub client -> its form +uint32_t gNextFormId = 0x5C5C0001; + +std::atomic gCloseWanted[4] = {}; + +void maybeSendMenu(void const* source, int sub) { + if (sub >= 0 && sub < 4 && gCloseWanted[sub].exchange(false)) { + { + std::lock_guard lock(gSeenMutex); + gPending.erase(sub); + } + sendCloseForm(source, uint32_t(sub)); + return; + } + uint32_t formId = 0; + std::string json; + std::function expired; + int answer = -1; + { + std::lock_guard lock(gSeenMutex); + auto it = gPending.find(sub); + if (it == gPending.end()) return; + auto now = std::chrono::steady_clock::now(); + auto& p = it->second; + if (p.timeout.count() && p.tries > 0 && now > p.shown + p.timeout) { + expired = std::move(p.done); + answer = p.timeoutAnswer; + gPending.erase(it); + } else { + if (now < p.due) return; + p.shown = now; // the countdown starts with the send that shows (a busy player answers the earlier ones) + ++p.tries; + p.due = now + std::chrono::hours(1); // until it is answered + formId = p.formId; + json = p.json; + } + } + if (sub >= 0 && sub < 4) gLastFormAt[sub] = nowMs(); + if (formId) { + sendForm(source, uint32_t(sub), formId, std::move(json)); + return; + } + sendCloseForm(source, uint32_t(sub)); + gLog(fmt::format("menus: player {} did not choose in time: {}", sub + 1, answer)); + if (expired) expired(answer); +} + +// The answer to form `formId` from `sub`: button index, -1 when closed; busy sends it again. +void menuAnswered(int sub, uint32_t formId, bool busy, int button) { + if (sub >= 0 && sub < 4) gLastFormAt[sub] = nowMs(); + std::function done; + { + std::lock_guard lock(gSeenMutex); + auto it = gPending.find(sub); + if (it == gPending.end() || it->second.formId != formId) return; + if (busy && it->second.tries < 40) { // busy while the player loads or another screen is open + it->second.due = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + return; + } + done = std::move(it->second.done); + gPending.erase(it); + } + gLog(fmt::format("menus: player {} chose {}", sub + 1, button)); + if (done) done(busy ? -1 : button); +} + +std::string jsonString(std::string const& text) { + std::string out = "\""; + for (char c : text) { + if (c == '"' || c == '\\') out += '\\'; + if (static_cast(c) < 0x20) { + out += fmt::format("\\u{:04x}", int(c)); + continue; + } + out += c; + } + return out + "\""; +} + +uint8_t packetSubId(void* packetRef) { + uintptr_t packet = 0; + uint8_t sub = 0xFF; + if (packetRef && safeRead(reinterpret_cast(packetRef), &packet, 8) && packet) safeRead(packet + 16, &sub, 1); + return sub; +} + +void authHandleDetour(void* self, void* source, void* callback, void* packetRef) { + uint8_t sub = packetSubId(packetRef); + if (sub == 0) { + int64_t now = nowMs(); + if (now - gLastAuth > 1000) gFirstAuth = now; // a new stretch (a world was left or is loading) + gLastAuth = now; + } + bool fresh; + { + std::lock_guard lock(gSeenMutex); + fresh = gSeenSub.insert(sub).second; + } + if (fresh) { + uint8_t id[160] = {}; + safeRead(reinterpret_cast(source), id, sizeof id); + std::string bytes; + for (int i = 0; i < 40; ++i) bytes += fmt::format("{:02x}", id[i]); + gLog(fmt::format("forms: PlayerAuthInputPacket from sub client {}: source {} [{}...] type {} callback {} ({})", sub, + fmt::ptr(source), bytes, *reinterpret_cast(id + 152), + fmt::ptr(callback), className(*gGame, reinterpret_cast(callback)))); + if (!gTracingStarted.exchange(true)) startTracing(30); // the packet sender's calls while in a world + } + gAuthHandle(self, source, callback, packetRef); + maybeSendMenu(source, sub); +} + +void responseHandleDetour(void* self, void* source, void* callback, void* packetRef) { + uintptr_t packet = 0; + uint32_t formId = 0; + if (safeRead(reinterpret_cast(packetRef), &packet, 8) && packet) safeRead(packet + 0x30, &formId, 4); + // ModalFormResponsePacket: form id +0x30, optional +0x38 (value 16 bytes, engaged +0x48), + // optional +0x50 (engaged +0x51) + uint8_t answer[0x28] = {}; + if (packet) safeRead(packet + 0x30, answer, sizeof answer); + std::string raw; + for (int i = 8; i < 0x28; ++i) raw += fmt::format("{:02x}", answer[i]); + gLog(fmt::format("forms: ModalFormResponsePacket from sub client {}, form id 0x{:X}: json value [{:016X} {:016X}] " + "engaged {}, cancel reason {} engaged {} (raw +0x38: {})", + packetSubId(packetRef), formId, *reinterpret_cast(answer + 8), + *reinterpret_cast(answer + 16), answer[0x18], answer[0x20], answer[0x21], raw)); + if (formId >> 16 == 0x5C5C) { + bool busy = answer[0x21] && answer[0x20] == 1; + int64_t value = *reinterpret_cast(answer + 8); + int button = answer[0x18] && answer[0x10] == 1 ? int(value) : -1; + menuAnswered(packetSubId(packetRef), formId, busy, button); + } + gResponseHandle(self, source, callback, packetRef); +} + +void hookHandle(Found const& f, void* detour, HandleFn* original, char const* what) { + if (f.slots < 3) return gLog(fmt::format("forms: ERROR: {} has {} slots", what, f.slots)); + *original = reinterpret_cast(f.vtable[2]); + bool ok = writeSlot(&f.vtable[2], reinterpret_cast(detour)); + gLog(fmt::format("forms: {} handle {} {}", what, offset(reinterpret_cast(*original)), + ok ? "watched" : "ERROR: not written")); +} + +} // namespace + + +int64_t msSinceMenu(int sub) { return sub >= 0 && sub < 4 ? nowMs() - gLastFormAt[sub] : INT64_MAX; } + +bool inWorld() { + int64_t now = nowMs(); + return now - gLastAuth < 1000 && now - gFirstAuth > 3000; +} + +bool menuOpen(int sub) { + std::lock_guard lock(gSeenMutex); + auto it = gPending.find(sub); + if (it == gPending.end()) return false; + // a menu that was never answered (the player left, the world closed) stops counting after a minute + if (it->second.created + std::chrono::minutes(1) < std::chrono::steady_clock::now()) { + gPending.erase(it); + return false; + } + return true; +} + +void closeMenu(int sub) { + if (sub >= 0 && sub < 4) gCloseWanted[sub] = true; +} + +void cancelMenu(int sub) { + std::lock_guard lock(gSeenMutex); + gPending.erase(sub); +} + +void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::function done, int timeoutSeconds, int timeoutAnswer) { + showForm(sub, title, content, buttons, {}, std::move(done), timeoutSeconds, timeoutAnswer); +} + +void showForm(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::vector const& pictures, std::function done, int timeoutSeconds, + int timeoutAnswer) { + std::string json = "{\"type\":\"form\",\"title\":" + jsonString(title) + ",\"content\":" + jsonString(content) + + ",\"buttons\":["; + for (size_t i = 0; i < buttons.size(); ++i) { + json += (i ? "," : "") + std::string("{\"text\":") + jsonString(buttons[i]); + if (i < pictures.size() && !pictures[i].empty()) + json += ",\"image\":{\"type\":\"path\",\"data\":" + jsonString(pictures[i]) + "}"; + json += "}"; + } + json += "]}"; + std::lock_guard lock(gSeenMutex); + Pending p{gNextFormId++, std::move(json), std::move(done), std::chrono::steady_clock::now(), 0}; + p.timeout = std::chrono::seconds(timeoutSeconds); + p.timeoutAnswer = timeoutAnswer; + gPending[sub] = std::move(p); + if (gNextFormId > 0x5C5CFFFF) gNextFormId = 0x5C5C0001; +} + +void startFormProbe(Module const& game, LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + + // the packets themselves: getId returns 100 (request) and 101 (response) + for (auto const& f : find("22ModalFormRequestPacket", false)) { + if (f.name == "22ModalFormRequestPacket" && f.slots > 2 && constantReturn(f.vtable[2]) == 100u) + gRequestVtable = reinterpret_cast(f.vtable); + // the dispatcher object: a word in the game's data equal to its vtable + if (f.name == "31PacketHandlerDispatcherInstanceI22ModalFormRequestPacketLb0EE") { + int count = 0; + for (auto const& seg : copy.segments) { + if (!seg.read || !seg.write) continue; + for (uintptr_t p = (seg.start + 7) & ~uintptr_t(7); p + 8 <= seg.end; p += 8) + if (*reinterpret_cast(p) == reinterpret_cast(f.vtable)) { + if (!gRequestDispatcher) gRequestDispatcher = p; + ++count; + } + } + gLog(fmt::format("forms: request dispatcher object {} ({} found)", + gRequestDispatcher ? offset(gRequestDispatcher) : "none", count)); + } + } + gLog(fmt::format("forms: ModalFormRequestPacket vtable {}", gRequestVtable ? offset(gRequestVtable) : "not found")); + // ClientboundCloseFormPacket (closes a form when its time is up) and its dispatcher object + for (auto const& f : find("26ClientboundCloseFormPacket", false)) { + if (f.name == "26ClientboundCloseFormPacket" && f.slots > 2) gCloseVtable = reinterpret_cast(f.vtable); + if (f.name == "31PacketHandlerDispatcherInstanceI26ClientboundCloseFormPacketLb0EE") + for (auto const& seg : copy.segments) { + if (!seg.read || !seg.write || gCloseDispatcher) continue; + for (uintptr_t p = (seg.start + 7) & ~uintptr_t(7); p + 8 <= seg.end; p += 8) + if (*reinterpret_cast(p) == reinterpret_cast(f.vtable)) { + gCloseDispatcher = p; + break; + } + } + } + gLog(fmt::format("forms: ClientboundCloseFormPacket vtable {} (id {}), dispatcher {}", + gCloseVtable ? offset(gCloseVtable) : "not found", + gCloseVtable ? int(constantReturn(reinterpret_cast(gCloseVtable)[2]).value_or(0)) : -1, + gCloseDispatcher ? offset(gCloseDispatcher) : "not found")); + + // the server's packet sender: traced (its slots, arguments and callers) while in a world + auto senders = find("20LoopbackPacketSender", false); + for (auto const& s : senders) + if (s.name == "20LoopbackPacketSender" && s.slots > 7) { + gSendToClient = reinterpret_cast(s.vtable[7]); + writeSlot(&s.vtable[7], reinterpret_cast(&sendToClientDetour)); + traceVtable(copy, s.vtable, s.slots, 400, "LoopbackPacketSender", gLog); + } + + // dispatchers: PlayerAuthInputPacket (every local player sends it) and ModalFormResponsePacket + for (auto const& d : find("PacketHandlerDispatcherInstanceI21PlayerAuthInputPacket", false)) + if (!gAuthHandle) hookHandle(d, reinterpret_cast(&authHandleDetour), &gAuthHandle, "PlayerAuthInputPacket"); + for (auto const& d : find("PacketHandlerDispatcherInstanceI23ModalFormResponsePacket", false)) + if (!gResponseHandle) + hookHandle(d, reinterpret_cast(&responseHandleDetour), &gResponseHandle, "ModalFormResponsePacket"); +} + +} // namespace splitscreen diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h new file mode 100644 index 0000000..e92c26f --- /dev/null +++ b/android/src/FormProbe.h @@ -0,0 +1,47 @@ +#pragma once + +// Groundwork for in-game menus (forms) on Android, as LeviLamina does them on Windows: a ModalFormRequestPacket +// (form id, JSON) sent to one local player through the server's LoopbackPacketSender::sendToClient(NetworkIdentifier, +// Packet, SubClientId), and the answer read in PacketHandlerDispatcherInstance::handle. +// This version finds those classes by RTTI and logs what is needed to call them safely. + +#include "Elf.h" +#include "InputProbe.h" + +#include +#include +#include +#include + +namespace splitscreen { + +void startFormProbe(Module const& game, LogFn log); + +// A menu like showMenu whose buttons carry pictures (texture paths; "" for none). +void showForm(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::vector const& pictures, std::function done, int timeoutSeconds = 0, + int timeoutAnswer = -1); + +// Closes player `sub`'s menu on their screen (at their next tick); its answer is ignored. +void closeMenu(int sub); + +// Forgets player `sub`'s menu (the player leaves); its answer, if one still comes, is ignored. +void cancelMenu(int sub); + +// Milliseconds since player `sub`'s last menu was sent or answered. +int64_t msSinceMenu(int sub); + +// Whether player 1 is in a world (and has been for a few seconds: not while it loads). +bool inWorld(); + +// Whether player `sub` has a menu open (or about to open). +bool menuOpen(int sub); + +// Shows player `sub` (sub client id) a menu of buttons; `done` gets the button index, -1 when it is closed. It runs +// on the server thread. With `timeoutSeconds`, the menu is closed that long after it shows and `done` gets +// `timeoutAnswer`. Titles "[ss:m]" (Split screen), "[ss:w]" (Who is playing?) and "[ss:c]" (Choose your player: +// black screen and a 10 second countdown) are drawn by the SplitScreenUI resource pack. +void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::function done, int timeoutSeconds = 0, int timeoutAnswer = -1); + +} // namespace splitscreen diff --git a/android/src/InputProbe.cpp b/android/src/InputProbe.cpp new file mode 100644 index 0000000..503a3ee --- /dev/null +++ b/android/src/InputProbe.cpp @@ -0,0 +1,144 @@ +#include "InputProbe.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace splitscreen { + +namespace { + +Module const* gGame = nullptr; +LogFn gLog; +std::mutex gMutex; + +using GetDeviceIdFn = int32_t (*)(AInputEvent const*); +GetDeviceIdFn gGetDeviceId = nullptr; + +std::set gDevicesSeen; +std::set gCallersSeen; +std::atomic gKeysLogged{0}; + +bool isGamepadKey(int32_t code) { return (code >= 96 && code <= 110) || (code >= 188 && code <= 203); } + +std::string sourceName(int32_t source) { + std::string out; + if ((source & AINPUT_SOURCE_GAMEPAD) == AINPUT_SOURCE_GAMEPAD) out += "gamepad "; + if ((source & AINPUT_SOURCE_JOYSTICK) == AINPUT_SOURCE_JOYSTICK) out += "joystick "; + if ((source & AINPUT_SOURCE_KEYBOARD) == AINPUT_SOURCE_KEYBOARD) out += "keyboard "; + if ((source & AINPUT_SOURCE_TOUCHSCREEN) == AINPUT_SOURCE_TOUCHSCREEN) out += "touchscreen "; + if ((source & AINPUT_SOURCE_MOUSE) == AINPUT_SOURCE_MOUSE) out += "mouse "; + if ((source & AINPUT_SOURCE_DPAD) == AINPUT_SOURCE_DPAD) out += "dpad "; + return out + fmt::format("(0x{:X})", source); +} + +int32_t getDeviceIdWrapper(AInputEvent const* event) { + int32_t id = gGetDeviceId(event); + uintptr_t caller = reinterpret_cast(__builtin_return_address(0)); + bool newCaller = false; + { + std::lock_guard lock(gMutex); + int32_t source = AInputEvent_getSource(event); + if (gDevicesSeen.insert(id).second) + gLog(fmt::format("input: first event from device {}: {}", id, sourceName(source))); + newCaller = gGame && gGame->isCode(caller) && gCallersSeen.size() < 16 && gCallersSeen.insert(caller).second; + if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_KEY && gKeysLogged < 300) { + int32_t code = AKeyEvent_getKeyCode(event); + if (isGamepadKey(code)) { + ++gKeysLogged; + gLog(fmt::format("input: device {} key {} {} (asked from +0x{:X})", id, code, + AKeyEvent_getAction(event) == AKEY_EVENT_ACTION_DOWN ? "down" : "up", + gGame ? caller - gGame->base : caller)); + } + } + } + if (newCaller) { + gLog(fmt::format("input: device id asked from +0x{:X}", caller - gGame->base)); + dumpCode(*gGame, "caller", caller - 512, 1024, gLog); + } + return id; +} + +using KeyFromJavaFn = void (*)(void*, void*, int32_t*); +KeyFromJavaFn gKeyFromJava = nullptr; +std::atomic gRawLogged{0}; + +void keyFromJavaDetour(void* env, void* javaEvent, int32_t* out) { + gKeyFromJava(env, javaEvent, out); + if (!out) return; + // the first few events in full, to check the struct layout of this GameActivity version + if (gRawLogged < 4) { + ++gRawLogged; + std::string raw; + for (int i = 0; i < 16; ++i) raw += fmt::format(" {}", out[i]); + gLog(fmt::format("key event raw:{}", raw)); + } + int32_t device = out[0], source = out[1]; + std::lock_guard lock(gMutex); + if (gDevicesSeen.insert(device).second) gLog(fmt::format("input: first key from device {}: {}", device, sourceName(source))); + if ((source & AINPUT_SOURCE_GAMEPAD) == AINPUT_SOURCE_GAMEPAD && gKeysLogged < 300) { + ++gKeysLogged; + gLog(fmt::format("input: device {} gamepad key event (action {})", device, out[2])); + } +} + +} // namespace + +void watchGameActivityKeys(Module const& game, uintptr_t keyEventFromJava, LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + if (pl::memory::hook(reinterpret_cast(keyEventFromJava), + reinterpret_cast(&keyFromJavaDetour), + reinterpret_cast(&gKeyFromJava)) + != 0) { + gLog("input: ERROR: could not hook GameActivityKeyEvent_fromJava"); + return; + } + gLog(fmt::format("input: watching GameActivityKeyEvent_fromJava (+0x{:X})", keyEventFromJava - copy.base)); +} + +void dumpCode(Module const& game, char const* label, uintptr_t address, size_t size, LogFn const& log) { + if (!game.isCode(address) || !game.isCode(address + size - 1)) return log(fmt::format("code {}: not in the game", label)); + auto const* bytes = reinterpret_cast(address); + for (size_t off = 0; off < size; off += 32) { + std::string line = fmt::format("code {} +0x{:X}:", label, address + off - game.base); + for (size_t i = off; i < off + 32 && i < size; ++i) line += fmt::format(" {:02x}", bytes[i]); + log(line); + } +} + +void startInputProbe(Module const& game, LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + + void* android = dlopen("libandroid.so", RTLD_NOW | RTLD_NOLOAD); + auto target = android ? reinterpret_cast(dlsym(android, "AInputEvent_getDeviceId")) : 0; + if (!target) return gLog("input: AInputEvent_getDeviceId not found"); + gGetDeviceId = reinterpret_cast(target); + + // its GOT entries in the game library: pointer-sized words in the writable segments equal to the function + int patched = 0; + for (auto const& s : copy.segments) { + if (!s.read || !s.write) continue; + for (uintptr_t p = (s.start + 7) & ~uintptr_t(7); p + 8 <= s.end; p += 8) { + auto* slot = reinterpret_cast(p); + if (*slot != target) continue; + if (writeSlot(slot, reinterpret_cast(&getDeviceIdWrapper))) { + ++patched; + gLog(fmt::format("input: watching AInputEvent_getDeviceId (GOT +0x{:X})", p - copy.base)); + } + } + } + if (!patched) gLog("input: AInputEvent_getDeviceId is not in the game's GOT; controller ids are read another way"); +} + +} // namespace splitscreen diff --git a/android/src/InputProbe.h b/android/src/InputProbe.h new file mode 100644 index 0000000..81177dc --- /dev/null +++ b/android/src/InputProbe.h @@ -0,0 +1,29 @@ +#pragma once + +// Finding how the game tells controllers apart on Android. +// +// The game reads controller input straight from Android's native input queue (the JNI functions only cover the +// keyboard and device add/remove). Each event carries an Android device id, read with AInputEvent_getDeviceId. +// Its entry in the game library's GOT is swapped for a wrapper that logs, per device, what kind of device it is, +// its gamepad buttons, and where in the game the id was asked for, with the code around it. + +#include "Elf.h" + +#include +#include + +namespace splitscreen { + +using LogFn = std::function; + +void startInputProbe(Module const& game, LogFn log); + +// GameActivity (the Android Game Development Kit) turns each Java KeyEvent into a GameActivityKeyEvent with +// GameActivityKeyEvent_fromJava(JNIEnv*, jobject, GameActivityKeyEvent*). The struct starts with the Android device +// id and source. This hooks it to log which device each gamepad button comes from. +void watchGameActivityKeys(Module const& game, uintptr_t keyEventFromJava, LogFn log); + +// Writes `size` bytes of code at `address` as hex lines that llvm-mc can disassemble. +void dumpCode(Module const& game, char const* label, uintptr_t address, size_t size, LogFn const& log); + +} // namespace splitscreen diff --git a/android/src/Menus.cpp b/android/src/Menus.cpp new file mode 100644 index 0000000..c4bdd65 --- /dev/null +++ b/android/src/Menus.cpp @@ -0,0 +1,386 @@ +#include "Menus.h" + +#include "FormProbe.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace splitscreen { + +void logLine(std::string const& line); // SplitScreen.cpp + +namespace menus { + +namespace { + +template +void note(fmt::format_string f, Args&&... args) { + logLine(fmt::format(f, std::forward(args)...)); +} + +// --- texts: the resource pack's .lang files ------------------------------------------------------------------------- +std::mutex gTextsMutex; +std::map gTexts; + +std::map readLang(std::filesystem::path const& file) { + std::map out; + std::ifstream in(file); + for (std::string line; std::getline(in, line);) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + auto eq = line.find('='); + if (eq != std::string::npos && line.rfind("##", 0) != 0) out[line.substr(0, eq)] = line.substr(eq + 1); + } + return out; +} + +// `key` in the game's language, its "%s" replaced by `params` in turn. +std::string tr(std::string const& key, std::vector const& params = {}) { + std::string text; + { + std::lock_guard lock(gTextsMutex); + auto it = gTexts.find(key); + text = it != gTexts.end() ? it->second : key; + } + size_t at = 0; + for (auto const& p : params) { + at = text.find("%s", at); + if (at == std::string::npos) break; + text.replace(at, 2, p); + at += p.size(); + } + return text; +} + +// --- the Windows version's helpers ---------------------------------------------------------------------------------- +// Button texts the resource pack draws specially (see its server_form.json): a square button laid over the right end +// of the row above it (remove that player / take player 1's controller), a row that leaves room for that square, and +// the green closing button. +constexpr char const* kKickButton = "[x]"; +constexpr char const* kPadButton = "[p]"; +constexpr char const* kRoomForSquare = " "; // a leading space: not seen, but the UI can test for it +constexpr char const* kTitleMenu = "[ss:m]"; // Split screen +constexpr char const* kTitleWho = "[ss:w]"; // Who is playing? +constexpr char const* kTitleChoose = "[ss:c]"; // Choose your player (black background, countdown) +// Player pictures: P1-P4 (the same as their part of the screen) for players with a controller, else the name tag. +constexpr char const* kNameTag = "textures/items/name_tag"; +// "Choose your player" closes by itself after 10 seconds (the resource pack shows the countdown). +constexpr int kChooseSeconds = 10; +constexpr int kTimedOut = -2; +constexpr auto kRemoveAfter = std::chrono::seconds(20); + +// White: the player colours of the Windows version come from the level, which the Android version cannot read yet. +std::string badge(int sub) { return fmt::format("textures/splitscreen/p{}_24", sub + 1); } + +// "Player 2": the game's local player names are not read on Android yet. +std::string localName(int sub) { + std::string name = tr("splitscreen.tag.player1"); + if (auto one = name.rfind('1'); one != std::string::npos) name.replace(one, 1, std::to_string(sub + 1)); + return name; +} + +// A short line above that player's hotbar, so they see what a menu choice did. Not on Android yet (the game's +// jukebox popup packet is not built yet): logged only. +void tell(int sub, std::string const& text) { note("tell player {}: {}", sub + 1, text); } + +void showMenu(int who, std::string const& text, std::vector const& buttons, + std::vector const& pictures, std::function done, std::string const& title, + int timeoutSeconds = 0) { + if (buttons.empty() || menuOpen(who)) return note("menu for player {} not shown (already open)", who + 1); + showForm(who, title, text, buttons, pictures, std::move(done), timeoutSeconds, kTimedOut); +} + +std::atomic gFreshSub{-1}; // a player that just joined and still has to be asked about + +// When the players or their controllers change, every open Split screen menu is closed and opened again with what is +// true now (the Windows version's refreshSettings). `gSettingsOpen`: player -> when their menu opened. +std::mutex gSettingsMutex; +std::map gSettingsOpen, gReopenAt; +std::string gLastState; +std::mutex gMissingMutex; +std::map gMissingSince; // sub -> when its controller went + +// Show the menu on player `owner`'s screen. `fresh`: the player just joined (rather than picking up a controller). +void offerPicker(int owner, bool fresh) { + auto players = game::players(); + if (!players.contains(owner)) return; + int controller = players.at(owner); + + enum class Kind { Keep, TakeOver, NewPlayer }; + struct Choice { + Kind kind; + int value; // controller (TakeOver) + int sub; + }; + // One button per player in player order (player 1 first): this controller's own player and players without a + // controller. (The Windows version also lists saved players who are not playing.) + std::vector choices{{Kind::Keep, 0, owner}}; + for (auto [sub, other] : players) + if (sub != owner && !game::hasController(other)) choices.push_back({Kind::TakeOver, other, sub}); + std::stable_sort(choices.begin(), choices.end(), [](Choice const& a, Choice const& b) { return a.sub < b.sub; }); + std::vector buttons; + std::vector pictures; + std::vector toChoice; // form button -> choice; -1 for the status tags drawn on the row above + for (int i = 0; i < int(choices.size()); ++i) { + auto& c = choices[i]; + bool hasPad = c.kind == Kind::Keep && !fresh; // only this controller's own player has one in this list + buttons.push_back(localName(c.sub)); + pictures.push_back(hasPad ? badge(c.sub) : kNameTag); + toChoice.push_back(i); + // the stand-in of a new controller is not really playing yet + buttons.push_back(c.kind == Kind::Keep && fresh ? "[tag:Not playing]" + : c.kind == Kind::TakeOver ? "[tag:No controller]" + : "[tag:Playing]"); + pictures.emplace_back(); + toChoice.push_back(-1); + } + if (!fresh && players.size() < 4) { + toChoice.push_back(int(choices.size())); + choices.push_back({Kind::NewPlayer, 0, owner}); + buttons.push_back(tr("splitscreen.newPlayer")); + pictures.emplace_back(); + } + int const keep = int(std::find_if(choices.begin(), choices.end(), [](Choice const& c) { return c.kind == Kind::Keep; }) + - choices.begin()); + if (choices.size() < 2) { + if (fresh) gFreshSub = -1; // nobody else to be: the new player is who joined + return; + } + note("picker: menu for player {} ({} choices)", owner + 1, choices.size()); + showMenu(owner, tr("splitscreen.picker.body"), buttons, pictures, [=](int pick) { + if (fresh) gFreshSub = -1; + if (fresh && pick == kTimedOut) { // nobody chosen in 10 s: nobody joins, the stand-in leaves + note("picker: nobody chosen for player {} in {} s", owner + 1, kChooseSeconds); + return game::requestLeave(owner); + } + pick = pick >= 0 && pick < int(toChoice.size()) ? toChoice[pick] : -1; + if (fresh && (pick == keep || pick < 0 || pick >= int(choices.size()))) // kept: the stand-in is the chosen player + tell(owner, tr("splitscreen.tell.youAre", {localName(owner)})); + if (pick == keep || pick < 0 || pick >= int(choices.size())) return; // closed, or kept + auto& choice = choices[pick]; + switch (choice.kind) { + case Kind::TakeOver: + // (fresh: the player made for this controller is not needed any more) + if (fresh) game::handOver(owner, controller, choice.value); + else game::moveController(controller, choice.value); + tell(choice.sub, tr("splitscreen.tell.youAreNow", {localName(choice.sub)})); + break; + case Kind::NewPlayer: game::join(controller); break; // this player stays and waits for a controller + default: break; + } + }, fresh ? kTitleChoose : kTitleWho, fresh ? kChooseSeconds : 0); +} + +} // namespace + +void setTexts(std::filesystem::path const& pack, std::filesystem::path const& options) { + std::string language = "en_US"; + std::ifstream in(options); + for (std::string line; std::getline(in, line);) + if (line.rfind("game_language:", 0) == 0) { + language = line.substr(14); + while (!language.empty() && (language.back() == '\r' || language.back() == ' ')) language.pop_back(); + } + if (language.empty()) { // the game follows the phone's language: "el-GR" -> "el_GR" + for (char const* prop : {"persist.sys.locale", "ro.product.locale", "persist.sys.language"}) { + char value[PROP_VALUE_MAX] = {}; + __system_property_get(prop, value); + if ((language = value).size() >= 2) break; + } + if (language.size() == 2) language += "_" + std::string(language == "el" ? "GR" : language == "en" ? "US" : ""); + std::replace(language.begin(), language.end(), '-', '_'); + } + auto texts = readLang(pack / "texts" / "en_US.lang"); + auto local = readLang(pack / "texts" / (language + ".lang")); + if (local.empty()) // e.g. "el_CY": the first of the pack's languages that starts the same + for (auto const& f : std::filesystem::directory_iterator(pack / "texts", std::filesystem::directory_options::skip_permission_denied)) + if (f.path().filename().string().rfind(language.substr(0, 2), 0) == 0) { + local = readLang(f.path()); + break; + } + for (auto& [k, v] : local) texts[k] = v; + note("menus: texts in {} ({} lines)", language, texts.size()); + std::lock_guard lock(gTextsMutex); + gTexts = std::move(texts); +} + +// The Split screen menu: the players, to switch to or remove. +void onSettingsButton(int who) { + auto players = game::players(); + if (!players.contains(who) || gFreshSub == who) return; + if (menuOpen(who)) return; + int myPad = players.at(who); + + bool firstPad = game::hasController(0); // player 1's controller, if they have one (else they play on touch) + enum class Act { Swap, Kick, FreePad, Done, Nothing }; + struct Item { + Act act; + int sub; + }; + // The Windows version's menu also has the layout (top and bottom / side by side) and saved players who are not + // playing (to switch to); not on Android yet. + std::vector items{{Act::Nothing, -1}}; + std::vector buttons{"[h:Players]"}; + std::vector pictures{""}; + for (auto [sub, controller] : players) { + bool hasPad = game::hasController(controller); + auto name = localName(sub); + auto status = sub == 0 ? "Player 1" : hasPad ? "Playing" : "No controller"; + // a square button at the end of the row: remove players 2-4, or take player 1's controller if they have one + bool square = sub != 0 || firstPad; + items.push_back({Act::Swap, sub}); + buttons.push_back(fmt::format("{}{}", square ? kRoomForSquare : "", name)); + pictures.push_back(hasPad ? badge(sub) : kNameTag); + if (sub == who) { // "You", drawn on the row above + items.push_back({Act::Nothing, -1}); + buttons.push_back(square ? "[syou]" : "[you]"); + pictures.emplace_back(); + } + items.push_back({Act::Nothing, -1}); // the status tag, drawn on the row above + buttons.push_back(fmt::format("[{}:{}]", square ? "stag" : "tag", status)); + pictures.emplace_back(); + if (square) { + items.push_back({sub == 0 ? Act::FreePad : Act::Kick, sub}); + buttons.push_back(sub == 0 ? kPadButton : kKickButton); + pictures.push_back(sub == 0 ? "textures/ui/controller_glyph" : "textures/splitscreen/cross"); + } + } + items.push_back({Act::Done, -1}); + buttons.push_back("[d]"); + pictures.emplace_back(); + + note("menus: player {} opens the Split screen menu", who + 1); + { + std::lock_guard lock(gSettingsMutex); + gSettingsOpen[who] = std::chrono::steady_clock::now(); + } + showMenu(who, "", buttons, pictures, [items, who, myPad](int pick) { + { + std::lock_guard lock(gSettingsMutex); + gSettingsOpen.erase(who); + } + if (pick < 0 || pick >= int(items.size())) return; + auto players = game::players(); + auto item = items[pick]; + switch (item.act) { + case Act::Swap: { // this controller plays as that player; theirs (if any) comes here + if (item.sub == who || !players.contains(item.sub) || !players.contains(who)) return; + bool iHavePad = game::hasController(myPad); + bool theyHavePad = game::hasController(players.at(item.sub)); + if (!iHavePad) return tell(who, tr("splitscreen.tell.needController")); + // nothing comes back to this player (the other one had no controller): it leaves instead of waiting + if (!theyHavePad && who != 0) game::handOver(who, myPad, players.at(item.sub)); + else game::moveController(myPad, players.at(item.sub)); + tell(item.sub, tr("splitscreen.tell.youAreNow", {localName(item.sub)})); + return; + } + case Act::FreePad: // player 1 goes back to the touch screen; the controller waits for start to be pressed + game::freeFirstController(); + tell(0, tr("splitscreen.tell.pressStart")); + return; + case Act::Kick: + if (players.contains(item.sub)) { + note("settings: player {} removed", item.sub + 1); + tell(who, tr("splitscreen.tell.left", {localName(item.sub)})); + game::requestLeave(item.sub); + } + return; + default: return; + } + }, kTitleMenu); +} + +// A new controller joins at once as the first free player, and its own part of the screen (black, see the resource +// pack's server_form.json) asks who is playing; picking someone else swaps that player in. +void onJoinRequest(int controller) { + if (!inWorld()) return note("controller {} asked to join outside a world (or while it loads): not joining", controller); + if (gFreshSub >= 0) return; // someone is joining already + auto players = game::players(); + int sub = 1; + while (sub <= 3 && players.contains(sub)) ++sub; + if (sub > 3) return; + gFreshSub = sub; + note("picker: controller {} joins as player {}", controller, sub + 1); + game::join(controller, [](int joined) { + if (joined < 0) gFreshSub = -1; + else offerPicker(joined, true); // shown once the new player is in the world + }); +} + +void onControllerConnected(int sub) { + if (sub != 0) offerPicker(sub, false); +} + +void onPlayerLeft(int sub) { + cancelMenu(sub); + { + std::lock_guard lock(gSettingsMutex); + gSettingsOpen.erase(sub); + gReopenAt.erase(sub); + } + int fresh = sub; + gFreshSub.compare_exchange_strong(fresh, -1); + std::lock_guard lock(gMissingMutex); + gMissingSince.erase(sub); +} + +void checkControllers() { + auto now = std::chrono::steady_clock::now(); + // refresh the open Split screen menus when who plays (with or without a controller) changed + { + std::string state; + for (auto [sub, controller] : game::players()) state += fmt::format("{}:{} ", sub, game::hasController(controller)); + std::vector reopen; + { + std::lock_guard lock(gSettingsMutex); + bool changed = !gLastState.empty() && state != gLastState; + gLastState = state; + if (changed) + for (auto it = gSettingsOpen.begin(); it != gSettingsOpen.end();) { + if (now - it->second < std::chrono::seconds(1)) { // already up to date + ++it; + continue; + } + note("settings: refreshing player {}'s menu", it->first + 1); + closeMenu(it->first); + gReopenAt[it->first] = now + std::chrono::milliseconds(800); + it = gSettingsOpen.erase(it); + } + for (auto it = gReopenAt.begin(); it != gReopenAt.end();) + if (it->second <= now) { + reopen.push_back(it->first); + it = gReopenAt.erase(it); + } else + ++it; + } + for (int sub : reopen) onSettingsButton(sub); + } + for (auto [sub, controller] : game::players()) { + if (sub == 0 || sub == gFreshSub) continue; + bool has = game::hasController(controller); + std::lock_guard lock(gMissingMutex); + if (has) { + gMissingSince.erase(sub); + continue; + } + auto [lost, added] = gMissingSince.try_emplace(sub, now); + if (added) note("player {}: controller gone", sub + 1); + if (now - lost->second > kRemoveAfter) { + note("player {}: no controller for {} s, removed", sub + 1, kRemoveAfter.count()); + gMissingSince.erase(lost); + game::requestLeave(sub); // what the player's own "Save & Leave" does + } + } +} + +} // namespace menus + +} // namespace splitscreen diff --git a/android/src/Menus.h b/android/src/Menus.h new file mode 100644 index 0000000..4d41428 --- /dev/null +++ b/android/src/Menus.h @@ -0,0 +1,55 @@ +#pragma once + +// The split screen menus and how a new controller joins: the Windows version's logic (src/mod/MyMod.cpp: +// openSettings, offerPicker, startJoin, watchChoice, checkControllers), with the same choices, the same texts (the +// SplitScreenUI resource pack draws them in the game's language) and the same rules. Only what the game itself does +// is different on Android; that part is the "game" functions below, which SplitScreen.cpp provides. +// +// On Android a player's sub client id is its controller number (player 1 = 0, on touch; players 2-4 = 1-3): +// SplitScreen.cpp keeps them equal by giving a joining controller the number of the first free player. + +#include +#include +#include +#include +#include + +namespace splitscreen { + +namespace game { +// The players in the world: sub client id -> controller number. Player 1 is always there (sub 0). +std::map players(); +// Whether a controller is connected and plays as controller number `controller` (player 1's touch is not one). +bool hasController(int controller); +// What a player's own "Save & Leave" does. +void requestLeave(int sub); +// The controller of `from` now plays `to`, and `to`'s (if any) plays `from`. +void moveController(int from, int to); +// Player `sub` leaves and its controller `from` then plays `to` (what moveController and requestLeave do on Windows; +// on Android the controller moves once the player is gone: moved first, the leaving player crashed the game). +void handOver(int sub, int from, int to); +// Player 1 goes back to the touch screen; its controller waits for Start. +void freeFirstController(); +// Adds a player for controller `controller`; the game gives it the first free sub client id. `joined` gets that id, +// or -1 when no player was added. +void join(int controller, std::function joined = {}); +} // namespace game + +namespace menus { +// The resource pack's texts are read from `pack` (its texts/ folder) in the game's language from `options` +// (the game's options.txt), English when that is not known. +void setTexts(std::filesystem::path const& pack, std::filesystem::path const& options); + +// A controller with no player pressed Start (the game's "press Start to join"). +void onJoinRequest(int controller); +// A player's controller pressed Select (View / Back): the Split screen menu (on Windows, the pause menu's button). +void onSettingsButton(int sub); +// A connected controller was given to player `sub`, who had lost theirs. +void onControllerConnected(int sub); +// A player left the world. +void onPlayerLeft(int sub); +// Once a second: players without a controller for 20 seconds leave. +void checkControllers(); +} // namespace menus + +} // namespace splitscreen diff --git a/android/src/PostTrace.cpp b/android/src/PostTrace.cpp new file mode 100644 index 0000000..9bd486d --- /dev/null +++ b/android/src/PostTrace.cpp @@ -0,0 +1,72 @@ +#include "PostTrace.h" + +#include +#include + +#include +#include + +#include + +namespace { +constexpr size_t kMax = 16; +} + +extern "C" { +uintptr_t splitscreen_poriginals[kMax]; +extern uintptr_t splitscreen_ptrampolines[kMax]; +} + +namespace splitscreen { + +namespace { + +LogFn gLog; +std::string gNames[kMax]; +size_t gSlots[kMax]; +size_t gUsed = 0; +std::atomic gRemaining{0}; + +bool safeRead(uintptr_t address, void* out, size_t size) { + iovec local{out, size}, remote{reinterpret_cast(address), size}; + return process_vm_readv(getpid(), &local, 1, &remote, 1, 0) == ssize_t(size); +} + +} // namespace + +bool watchResults(Module const& game, uintptr_t* vtable, std::vector const& slots, char const* name, LogFn log) { + gLog = std::move(log); + for (size_t slot : slots) { + if (gUsed >= kMax) return false; + size_t i = gUsed++; + gNames[i] = name; + gSlots[i] = slot; + splitscreen_poriginals[i] = vtable[slot]; + if (!writeSlot(&vtable[slot], splitscreen_ptrampolines[i])) { + gLog(fmt::format("results: ERROR: could not write {} slot {}", name, slot)); + return false; + } + (void)game; + } + gLog(fmt::format("results: watching {} functions of {}", slots.size(), name)); + return true; +} + +void logResults(int count) { gRemaining = count; } + +} // namespace splitscreen + +extern "C" void splitscreen_on_post(uint64_t index, uint64_t* regs, uint64_t x0, uint64_t x1, uint64_t d0) { + using namespace splitscreen; + if (gRemaining.load(std::memory_order_relaxed) <= 0) return; + if (--gRemaining < 0) return; + uint64_t sret[4] = {}; + bool hasSret = regs[8] && safeRead(regs[8], sret, sizeof sret); + uint64_t pointee[2] = {}; + bool hasPointee = x0 > 0x10000 && safeRead(x0, pointee, sizeof pointee); + gLog(fmt::format("results: {} virtual {}(w1={} w2={}) -> x0=0x{:X} x1=0x{:X} d0=0x{:X}{}{}", gNames[index], + gSlots[index], int32_t(regs[1]), int32_t(regs[2]), x0, x1, d0, + hasPointee ? fmt::format(" *x0=[0x{:X} 0x{:X}]", pointee[0], pointee[1]) : "", + hasSret ? fmt::format(" [x8]=[0x{:X} 0x{:X} 0x{:X} 0x{:X}]", sret[0], sret[1], sret[2], sret[3]) + : "")); +} diff --git a/android/src/PostTrace.h b/android/src/PostTrace.h new file mode 100644 index 0000000..4050a02 --- /dev/null +++ b/android/src/PostTrace.h @@ -0,0 +1,18 @@ +#pragma once + +// Logs what some virtual functions return (PostTrampolines.S): their x0/x1 result and, for functions that return +// a struct through x8 (e.g. a shared_ptr), the first words at x8. Only while result logging is on. + +#include "Elf.h" +#include "InputProbe.h" + +#include + +namespace splitscreen { + +bool watchResults(Module const& game, uintptr_t* vtable, std::vector const& slots, char const* name, LogFn log); + +// Logs up to `count` results from now on. +void logResults(int count); + +} // namespace splitscreen diff --git a/android/src/PostTrampolines.S b/android/src/PostTrampolines.S new file mode 100644 index 0000000..94dc312 --- /dev/null +++ b/android/src/PostTrampolines.S @@ -0,0 +1,82 @@ +// Detours that see the result (PostTrampolines.S): each saves x0-x8 and q0-q7, calls the original function with +// them (blr, on our own 256-byte frame: only for functions without stack arguments), then calls +// splitscreen_on_post(index, saved arguments, x0, x1, q0's low 64 bits) and returns the original's x0, x1 and q0. + + .text + +.macro PTRAMPOLINE i + .p2align 2 + .type splitscreen_ptrampoline_\i, %function +splitscreen_ptrampoline_\i: + sub sp, sp, #256 + stp x29, x30, [sp] + mov x29, sp + stp x0, x1, [sp, #16] + stp x2, x3, [sp, #32] + stp x4, x5, [sp, #48] + stp x6, x7, [sp, #64] + str x8, [sp, #80] + stp q0, q1, [sp, #96] + stp q2, q3, [sp, #128] + stp q4, q5, [sp, #160] + stp q6, q7, [sp, #192] + adrp x16, splitscreen_poriginals + add x16, x16, :lo12:splitscreen_poriginals + ldr x16, [x16, #(\i * 8)] + blr x16 + stp x0, x1, [sp, #224] + str q0, [sp, #240] + mov x2, x0 + mov x3, x1 + fmov x4, d0 + mov x0, #\i + add x1, sp, #16 + bl splitscreen_on_post + ldr q0, [sp, #240] + ldp x0, x1, [sp, #224] + ldp x29, x30, [sp] + add sp, sp, #256 + ret + .size splitscreen_ptrampoline_\i, . - splitscreen_ptrampoline_\i +.endm + +PTRAMPOLINE 0 +PTRAMPOLINE 1 +PTRAMPOLINE 2 +PTRAMPOLINE 3 +PTRAMPOLINE 4 +PTRAMPOLINE 5 +PTRAMPOLINE 6 +PTRAMPOLINE 7 +PTRAMPOLINE 8 +PTRAMPOLINE 9 +PTRAMPOLINE 10 +PTRAMPOLINE 11 +PTRAMPOLINE 12 +PTRAMPOLINE 13 +PTRAMPOLINE 14 +PTRAMPOLINE 15 + + .section .data.rel.ro, "aw" + .p2align 3 + .globl splitscreen_ptrampolines + .hidden splitscreen_ptrampolines +splitscreen_ptrampolines: + .quad splitscreen_ptrampoline_0 + .quad splitscreen_ptrampoline_1 + .quad splitscreen_ptrampoline_2 + .quad splitscreen_ptrampoline_3 + .quad splitscreen_ptrampoline_4 + .quad splitscreen_ptrampoline_5 + .quad splitscreen_ptrampoline_6 + .quad splitscreen_ptrampoline_7 + .quad splitscreen_ptrampoline_8 + .quad splitscreen_ptrampoline_9 + .quad splitscreen_ptrampoline_10 + .quad splitscreen_ptrampoline_11 + .quad splitscreen_ptrampoline_12 + .quad splitscreen_ptrampoline_13 + .quad splitscreen_ptrampoline_14 + .quad splitscreen_ptrampoline_15 + + .section .note.GNU-stack, "", %progbits diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp new file mode 100644 index 0000000..9fae464 --- /dev/null +++ b/android/src/SplitScreen.cpp @@ -0,0 +1,1208 @@ +// SplitScreenMod for Android: first, experimental version. +// +// Minecraft Bedrock has split screen built in (it is used on consoles); each platform's AppPlatform decides with +// allowSplitScreen() whether it is offered. The Windows mod turns that on, then fixes up controller handling. On +// Android nothing about the game's code is known yet, so this version: +// 1. finds the game's classes by their RTTI names and writes their vtables to splitscreen.log; +// 2. turns allowSplitScreen on when it can be identified for certain (by its exported symbol); +// 3. otherwise only lists the likely candidates, so the next version can pick the right one. + +#include "SplitScreen.h" + +#include "Elf.h" + +#include +#include "InputProbe.h" +#include "DeviceManagerProbe.h" +#include "ControllerSplit.h" +#include "VTrace.h" +#include "PostTrace.h" +#include "FormProbe.h" +#include "Menus.h" + + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace splitscreen { + +namespace { + +constexpr char const* kGameLibrary = "libminecraftpe.so"; + +std::mutex gLogMutex; +std::ofstream gLog; +std::ofstream gDownloadLog; // the same lines in Download/SplitScreenMod-android.log, where it is easy to find + +template +void note(fmt::format_string f, Args&&... args) { + auto line = fmt::format(f, std::forward(args)...); + SplitScreenMod::instance().getSelf().getLogger().info("{}", line); + std::lock_guard lock(gLogMutex); + if (gLog) gLog << line << std::endl; + if (gDownloadLog) gDownloadLog << line << std::endl; +} + +// Download/SplitScreenMod-android.log gets every line as it is written (a copy made once would miss what happens +// later in the game). Android lets an app create files in Download by path; if it does not, the log stays in the +// mod folder only. +void openDownloadLog() { + for (char const* dir : {"/storage/emulated/0/Download", "/sdcard/Download"}) { + auto path = std::filesystem::path(dir) / "SplitScreenMod-android.log"; + std::lock_guard lock(gLogMutex); + gDownloadLog.open(path, std::ios::trunc); + if (gDownloadLog) return; + gDownloadLog.clear(); + } +} + +// config/config.json: { "forceSplitScreen": true } +bool configFlag(std::string_view key, bool fallback) { + std::ifstream in(SplitScreenMod::instance().getSelf().getConfigDir() / "config.json"); + if (!in) return fallback; + std::stringstream ss; + ss << in.rdbuf(); + auto text = ss.str(); + auto at = text.find(fmt::format("\"{}\"", key)); + if (at == std::string::npos) return fallback; + at = text.find(':', at); + if (at == std::string::npos) return fallback; + auto rest = text.find_first_not_of(" \t\r\n", at + 1); + if (rest == std::string::npos) return fallback; + if (text.compare(rest, 4, "true") == 0) return true; + if (text.compare(rest, 5, "false") == 0) return false; + return fallback; +} + +struct ClassInfo { + std::string name; + uintptr_t typeInfo = 0; + std::vector vtables; +}; + +std::vector findClasses(Module const& m, std::string_view needle) { + std::vector out; + for (auto const& t : findTypeNames(m, needle)) { + ClassInfo c; + c.name = t.name; + c.typeInfo = findTypeInfo(m, t.address); + if (c.typeInfo) c.vtables = findVtables(m, c.typeInfo); + out.push_back(std::move(c)); + } + return out; +} + +// Exported symbols that would identify the functions for certain, if the game exports them. +struct Known { + char const* label; + char const* symbol; +}; +constexpr Known kKnown[] = { + {"AppPlatform::allowSplitScreen", "_ZN11AppPlatform16allowSplitScreenEv"}, + {"AppPlatform_android::allowSplitScreen", "_ZN19AppPlatform_android16allowSplitScreenEv"}, + {"MinecraftGame::isSplitscreenJoinEnabled", "_ZNK13MinecraftGame24isSplitscreenJoinEnabledEv"}, + {"MinecraftGame::canAddSplitscreenSubClient", "_ZNK13MinecraftGame26canAddSplitscreenSubClientEi"}, + {"MinecraftGame::addSubClientInstanceAndAssociateToUser", "_ZN13MinecraftGame38addSubClientInstanceAndAssociateToUserEi"}, +}; + +// Where allowSplitScreen is in AppPlatform's vtable, found from the order of AppPlatform's virtual functions +// (the same on every platform; LeviLamina's AppPlatform.h lists them): +// swapBuffers() = 0, discardBackbuffer(), getSystemLocale() = 0, getTermsOfUseHyperlink(), pickImage() = 0, ... +// allowSplitScreen() 28 slots after swapBuffers, then splitScreenRenderDistScalar(uint numPlayers). +// The three pure virtual functions two slots apart mark the place; splitScreenRenderDistScalar, which starts with +// "fmov s0, #1.0", confirms it. In the 1.26 Android game these are slots 77, 79, 81 and 105. +constexpr size_t kAllowSplitScreenAfterSwapBuffers = 28; + +std::optional findAllowSplitScreenSlot(Module const& m, uintptr_t* vt, size_t count) { + std::optional found; + for (size_t i = 0; i + kAllowSplitScreenAfterSwapBuffers + 1 < count; ++i) { + if (!isPureVirtual(vt[i]) || !isPureVirtual(vt[i + 2]) || !isPureVirtual(vt[i + 4])) continue; + if (isPureVirtual(vt[i + 1]) || isPureVirtual(vt[i + 3])) continue; + size_t slot = i + kAllowSplitScreenAfterSwapBuffers; + auto next = reinterpret_cast(vt[slot + 1]); + if (!m.isCode(vt[slot + 1]) || next[0] != 0x1E2E1000) { // fmov s0, #1.0 + note("allowSplitScreen: pure virtual functions at slot {}, but slot {} is not splitScreenRenderDistScalar ({})", + i, slot + 1, describeFunction(m, vt[slot + 1])); + continue; + } + if (found) { + note("allowSplitScreen: more than one match (slots {} and {}), not changing anything", *found, slot); + return std::nullopt; + } + found = slot; + } + return found; +} + +using AllowSplitScreenFn = bool (*)(void*); +AllowSplitScreenFn gOriginalAllowSplitScreen = nullptr; + +std::map gPatched; // slot -> original + +std::atomic gAllowSplitScreenCalls{0}; + +bool allowSplitScreenDetour(void* self) { + ++gAllowSplitScreenCalls; + static std::once_flag first; + std::call_once(first, [self] { + note("allowSplitScreen asked for the first time; the game alone would answer {}", + gOriginalAllowSplitScreen ? (gOriginalAllowSplitScreen(self) ? "true" : "false") : "?"); + }); + return true; +} + +// Every platform class (AppPlatform_android and its relatives) gets the slot changed, including the ones that +// override allowSplitScreen with their own "return false". Classes that only have "AppPlatform" in their name +// (AppPlatformListener, ...) have far fewer virtual functions than AppPlatform and are skipped. +void patchSlotEverywhere(Module const& m, std::vector const& classes, size_t baseCount, size_t slot, + uintptr_t original, char const* what) { + for (auto const& c : classes) + for (auto* vt : c.vtables) { + if (countSlots(m, vt, 2000) < baseCount) continue; + if (vt[slot] != original && !constantReturn(vt[slot])) { + note(" {} slot {}: {} does more than return a constant, left alone", c.name, slot, + describeFunction(m, vt[slot])); + continue; + } + uintptr_t old = vt[slot]; + if (writeSlot(&vt[slot], reinterpret_cast(&allowSplitScreenDetour))) { + gPatched[&vt[slot]] = old; + note(" {} slot {}: {} now returns true", c.name, slot, what); + } else { + note(" {} slot {}: ERROR: could not write the vtable", c.name, slot); + } + } +} + +// --- joining: MinecraftGame's splitscreen checks --------------------------------------------------------------- +// In LeviLamina's IMinecraftGame.h, isSplitscreenJoinEnabled() and canAddSplitscreenSubClient(int) are virtual +// functions 172 and 173 and addSubClientInstanceAndAssociateToUser(int) is 210. In the Android game the last one is +// recognisable: "mov w2, #1; b _addSubClientInstanceAndAssociateToUserInternal" (it passes true). It is the anchor: +// the two checks are 38 and 37 slots before it. +constexpr size_t kAddSubClientSlot = 210, kJoinEnabledBack = 38, kCanAddBack = 37; + +Module const* gGameModule = nullptr; + +// Controllers 1-3 that play as an added player. Start on them is just Start (the pause menu): adding a player again +// for the same controller made the game release sub client 0 and crash (0.3.1). +std::mutex gPlayingMutex; +std::set gPlaying; +// players asked to leave: no longer in game::players(), so no menu opens on a screen that is about to go (opening +// one there crashed the game) +std::set gLeaving; +std::map> gHandOvers; // leaving player -> (its controller, where that goes) once it is gone + +void forgetPlayers(char const* why, int sub) { + std::optional> handOver; + { + std::lock_guard lock(gPlayingMutex); + gLeaving.erase(sub); + if (!gPlaying.erase(sub)) return; + if (auto it = gHandOvers.find(sub); it != gHandOvers.end()) { + handOver = it->second; + gHandOvers.erase(it); + } + } + note("join: {}({}): player {} is gone", why, sub, sub + 1); + menus::onPlayerLeft(sub); + if (handOver) + runOnInputThread([h = *handOver] { + blockController(h.first, false); + swapControllers(h.first, h.second); + }, std::chrono::seconds(1)); + runOnInputThread([sub] { blockController(sub, false); }, std::chrono::seconds(1)); // once it is gone for good +} + +using SubIdFn = void (*)(void*, int); +SubIdFn gQueueRemoval = nullptr, gReleaseSubId = nullptr; +void queueRemovalDetour(void* self, int sub) { + uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); + note("join: queueSubclientRemoval({}) from +0x{:X}", sub, gGameModule ? ret - gGameModule->base : ret); + gQueueRemoval(self, sub); + forgetPlayers("queueSubclientRemoval", sub); +} +void releaseSubIdDetour(void* self, int sub) { + uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); + note("join: releaseClientSubId({}) from +0x{:X}", sub, gGameModule ? ret - gGameModule->base : ret); + gReleaseSubId(self, sub); + forgetPlayers("releaseClientSubId", sub); +} + +using JoinEnabledFn = bool (*)(void*); +using CanAddFn = bool (*)(void*, int); +using AddSubFn = void (*)(void*, int); +JoinEnabledFn gJoinEnabled = nullptr; +CanAddFn gCanAdd = nullptr; +AddSubFn gAddSub = nullptr; +std::atomic gMinecraftGame{nullptr}; +std::atomic gJoinLogged{0}, gCanAddLogged{0}; +std::atomic gLastJoin{-1}; + +bool joinEnabledDetour(void* self) { + gMinecraftGame = self; + bool game = gJoinEnabled(self); + if (gLastJoin.exchange(game) != int(game) || gJoinLogged < 3) { + ++gJoinLogged; + note("join: isSplitscreenJoinEnabled asked; the game alone would answer {}", game); + } + return true; +} + +thread_local int tGameCanAdd = -1; // the game's own canAddSplitscreenSubClient answer while the mod adds a player + +bool canAddDetour(void* self, int controller) { + gMinecraftGame = self; + bool game = gCanAdd(self, controller); + tGameCanAdd = game; + bool answer = controller >= 1 && controller <= 3 ? true : game; + if (gCanAddLogged < 40) { + ++gCanAddLogged; + note("join: canAddSplitscreenSubClient({}) asked; the game alone would answer {}, answering {}", controller, game, + answer); + } + return answer; +} + +// The game's own join prompt ("press Start to join") calls addSubClientInstanceAndAssociateToUser; as on Windows +// (joinRequestDetour) the menus decide who joins, and the mod's own call (tOurJoin) goes through. +thread_local bool tOurJoin = false; +void addSubDetour(void* self, int controller) { + uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); + if (!tOurJoin) { + note("join: the game asks to add a player for controller {} (from +0x{:X})", controller, + gGameModule ? ret - gGameModule->base : ret); + if (controller <= 0) return gAddSub(self, controller); + return menus::onJoinRequest(controller); + } + note("join: addSubClientInstanceAndAssociateToUser({}) called", controller); + gAddSub(self, controller); + note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); +} + + +// Lending player 1's user (as the Windows mod does). The user manager keeps a vector of shared_ptr; virtual 5 +// returns the primary user and virtual 53 the user of a controller (empty when there is none), both through x8. +// For a controller the mod added as player 2-4, virtual 53 returns the primary user and virtual 6 ("has a user") +// true, so the new player gets a user instead of none. +struct UserPtr { // a shared_ptr: returned through x8 like the game's (non-trivial type), moved bitwise + void* object = nullptr; + void* control = nullptr; + UserPtr() = default; + UserPtr(UserPtr const& other) : object(other.object), control(other.control) {} + ~UserPtr() {} +}; +using UserByControllerFn = UserPtr (*)(void*, int); +using PrimaryUserFn = UserPtr (*)(void*); +UserByControllerFn gUserByController = nullptr; +PrimaryUserFn gPrimaryUser = nullptr; +std::mutex gLendMutex; +std::set gLending; // controllers that borrow player 1's user +std::atomic gLendLogged{0}; + +bool lends(int controller) { + if (controller < 1 || controller > 3) return false; + std::lock_guard lock(gLendMutex); + return gLending.contains(controller); +} + +// Only when the game finds no user for a lent controller (0.3.0): the user the game makes for the new player works. +UserPtr userByControllerDetour(void* self, int controller) { + UserPtr user = gUserByController(self, controller); + if (user.object || !lends(controller)) return user; + if (gLendLogged < 10) { + ++gLendLogged; + note("join: controller {} has no user: lending player 1's", controller); + } + return gPrimaryUser(self); +} + +// user manager virtuals 6, 39 (bool(int)) and 7 (void(int)), logged +using UserBoolFn = bool (*)(void*, int); +using UserVoidFn = void (*)(void*, int); +UserBoolFn gUser6 = nullptr, gUser39 = nullptr; +UserVoidFn gUser7 = nullptr; +bool user6Detour(void* self, int c) { + bool r = gUser6(self, c) || lends(c); + note("join: user manager virtual 6({}) = {}", c, r); + return r; +} +thread_local int tControllerTaken = -1; // user manager virtual 39's answer while the mod adds a player + +bool user39Detour(void* self, int c) { + bool r = gUser39(self, c); + tControllerTaken = r; + note("join: user manager virtual 39({}) = {}", c, r); + return r; +} +void user7Detour(void* self, int c) { + uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); + note("join: user manager virtual 7({}) from +0x{:X}", c, gGameModule ? ret - gGameModule->base : ret); + gUser7(self, c); +} + +// _addSubClientInstanceAndAssociateToUserInternal(game, controller, true) asks the user manager (MinecraftGame +// virtual 53, returned as a NonOwnerPointer {control block, ref count, object}) with its virtual 28 +// (controller, true). If that answers true it starts an asynchronous user sign-in for the controller and waits for +// a callback (which never came on Android); if false it creates the new player at once. While the mod adds a +// player, virtual 28 answers false for controllers 1-3. +using UserCheckFn = bool (*)(void*, int, bool); +UserCheckFn gUserCheck = nullptr; +thread_local bool tAddingPlayer = false; +constexpr size_t kUserManagerSlot = 53, kUserCheckSlot = 28; + +bool userCheckDetour(void* self, int controller, bool flag) { + bool game = gUserCheck(self, controller, flag); + bool answer = tAddingPlayer && controller >= 1 && controller <= 3 ? false : game; + note("join: user manager check({}, {}): the game alone would answer {}, answering {}", controller, flag, game, answer); + return answer; +} + +void hookUserManager(Module const& module, void* game) { + if (gUserCheck) return; + struct NonOwner { + uint64_t control, refs, object; + }; + auto getUserManager = reinterpret_cast((*reinterpret_cast(game))[kUserManagerSlot]); + NonOwner users = getUserManager(game); // keeps one reference: never released, it lives as long as the game + if (!users.object) return note("join: ERROR: no user manager"); + auto* vt = *reinterpret_cast(users.object); + note("join: user manager {} ({}), check {}", className(module, users.object), countSlots(module, vt, 400), + describeFunction(module, vt[kUserCheckSlot])); + if (countSlots(module, vt, 400) <= kUserCheckSlot) return note("join: ERROR: the user manager has too few functions"); + gPrimaryUser = reinterpret_cast(vt[5]); + gUserByController = reinterpret_cast(vt[53]); + writeSlot(&vt[53], reinterpret_cast(&userByControllerDetour)); + gUser6 = reinterpret_cast(vt[6]); + gUser39 = reinterpret_cast(vt[39]); + gUser7 = reinterpret_cast(vt[7]); + writeSlot(&vt[6], reinterpret_cast(&user6Detour)); + writeSlot(&vt[39], reinterpret_cast(&user39Detour)); + writeSlot(&vt[7], reinterpret_cast(&user7Detour)); + gUserCheck = reinterpret_cast(vt[kUserCheckSlot]); + if (!writeSlot(&vt[kUserCheckSlot], reinterpret_cast(&userCheckDetour))) { + gUserCheck = nullptr; + note("join: ERROR: could not write the user manager vtable"); + } + // the user manager functions the game called while adding a player (0.2.8): their code and their results + static std::vector const kAsked = {6, 8, 38, 53, 54, 70}; + watchResults(module, vt, kAsked, "UserManager", [](std::string const& l) { note("{}", l); }); + // after the patches above; MinecraftGame uses trampolines 0-319 + traceVtable(module, vt, countSlots(module, vt, 400), 320, "UserManager", [](std::string const& l) { note("{}", l); }); +} + +// The check before adding a player (+0x9CED20C in 1.26), "W"(game, controller, uint8_t* out, bool): +// canAddSplitscreenSubClient(controller); game+512 != 0; not (primary client virtual 113 and a +// GameControllerManager check); a GameControllerManager check(controller); user manager virtual 6(controller); +// not user manager virtual 39(controller) and (game+496)[25] <= 4; then user manager virtual 7(controller). +// While the mod adds a player for controller 1-3 and W refuses, it answers true, so the game creates the player. +using AddCheckFn = bool (*)(void*, int, uint8_t*, bool); +AddCheckFn gAddCheck = nullptr; + +bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { + bool result = gAddCheck(game, controller, out, flag); + bool answer = result; + if (!result && tAddingPlayer && controller >= 1 && controller <= 3 && tGameCanAdd == 1 && tControllerTaken != 1) + answer = true; + if (answer && tAddingPlayer) { + std::lock_guard lock(gPlayingMutex); + gPlaying.insert(controller); + } + note("join: may a player be added for controller {}? the game alone would answer {}, answering {}", controller, + result, answer); + return answer; +} + + +// A controller other than the first pressed Start: add a player for it, once (the game's own "press Start to join" +// does not react to it on Android). +std::mutex gJoinMutex; +std::map gJoinTimes; + +void joinController(int controller) { + void* game = gMinecraftGame.load(); + { + std::lock_guard lock(gJoinMutex); + auto now = std::chrono::steady_clock::now(); + if (auto it = gJoinTimes.find(controller); it != gJoinTimes.end() && now - it->second < std::chrono::seconds(3)) + return; + if (!game || !gAddSub) return note("join: controller {} pressed Start, but MinecraftGame is not known yet", controller); + { + std::lock_guard playing(gPlayingMutex); + if (gPlaying.contains(controller)) return; // it already plays: Start opens its pause menu + } + gJoinTimes[controller] = now; + } + note("join: adding player {} for controller {}", controller + 1, controller); + if (gGameModule) hookUserManager(*gGameModule, game); + { + auto const* g = reinterpret_cast(game); + uintptr_t at496 = *reinterpret_cast(g + 496); + note("join: MinecraftGame+512 = 0x{:X}, (MinecraftGame+496)[25] = {}", *reinterpret_cast(g + 512), + at496 ? int(reinterpret_cast(at496)[25]) : -1); + } + startTracing(60); + logResults(60); + { + std::lock_guard lock(gLendMutex); + gLending.insert(controller); + } + tAddingPlayer = true; + tGameCanAdd = -1; + tControllerTaken = -1; + tOurJoin = true; + addSubDetour(game, controller); + tOurJoin = false; + tAddingPlayer = false; +} + +// ClientInstance virtual 24 runs every frame for each client (args true, true). When the client's leave flags are +// set and the client is not the primary one, it closes the client and calls MinecraftGame::queueSubclientRemoval, +// the game's own way a player leaves (calling queueSubclientRemoval alone left the client half there, and the game +// crashed on a destroyed mutex). The leave flags +0x6E0..+0x6E5 are logged when they change, to see what the game +// sets when a player leaves from the pause menu. The client of each sub client comes from getClientSubId (slot 303). +using TickFn = void (*)(void*, bool, bool); +using GetSubIdFn = uint64_t (*)(void*); +constexpr size_t kLeaveFlags = 0x6E0; +TickFn gClientTick = nullptr; +uintptr_t* gClientVt = nullptr; +using RequestLeaveFn = void (*)(void*, bool, bool); +RequestLeaveFn gRequestLeave = nullptr; // ClientInstance virtual 15 +GetSubIdFn gGetSubId = nullptr; +std::atomic gClientBySub[4] = {}; +std::atomic gLeaveWanted[4] = {}; +uint64_t gFlagsSeen[4] = {}; + +uint64_t getSubIdDetour(void* self) { + uint64_t sub = gGetSubId(self); + if ((sub & 0xFF) < 4 && gClientBySub[sub & 0xFF].load(std::memory_order_relaxed) != self) { + gClientBySub[sub & 0xFF].store(self, std::memory_order_relaxed); + note("leave: client of sub client {} is {}", sub & 0xFF, self); + } + return sub; +} + +uint64_t leaveFlags(void* client) { + uint64_t flags = 0; + std::memcpy(&flags, static_cast(client) + kLeaveFlags, 6); + return flags; +} + +void clientTickDetour(void* self, bool a, bool b) { + int sub = gGetSubId ? int(gGetSubId(self) & 0xFF) : -1; + if (sub >= 1 && sub < 4 && gLeaveWanted[sub].exchange(false)) { + // What the pause menu's Save & Leave calls (0.9.7 log: ClientInstance virtual 15 with true, true, right before + // the game sets the leave bytes; the Windows version's requestLeaveGameAsync). Only setting the bytes + // (+0x6E0, +0x6E1, +0x6E4) left the player's actor in the game's lists after it was removed, and the game + // crashed ("terminating") on it. + if (gRequestLeave) { + note("leave: player {} leaves: requestLeaveGame on client {}", sub + 1, self); + gRequestLeave(self, true, true); + } else { + note("leave: player {} leaves: client {} bytes +0x6E0, +0x6E1, +0x6E4 = 1", sub + 1, self); + auto* bytes = static_cast(self); + bytes[0x6E0] = bytes[0x6E1] = bytes[0x6E4] = 1; + } + } + if (sub >= 0 && sub < 4) { + uint64_t flags = leaveFlags(self); + if (flags != gFlagsSeen[sub]) { + note("leave: client of sub client {} flags +0x6E0: {:012X} -> {:012X} (args {} {})", sub, gFlagsSeen[sub], + flags, a, b); + gFlagsSeen[sub] = flags; + } + } + gClientTick(self, a, b); + if (sub >= 0 && sub < 4) { + uint64_t flags = leaveFlags(self); + if (flags != gFlagsSeen[sub]) { + note("leave: client of sub client {} flags +0x6E0 after its frame: {:012X} -> {:012X}", sub, + gFlagsSeen[sub], flags); + gFlagsSeen[sub] = flags; + } + } +} + +void leaveSplitScreen(int sub) { + void* client = sub >= 1 && sub < 4 ? gClientBySub[sub].load() : nullptr; + if (!gClientTick || !client) + return note("menus: ERROR: no client for player {} (tick {})", sub + 1, gClientTick != nullptr); + note("menus: player {} leaves (client {}) at its next frame", sub + 1, client); + gLeaveWanted[sub] = true; +} + +} // namespace + +void logLine(std::string const& line) { note("{}", line); } + +// The player leaves once its last menu has closed (1.5 s after it was answered). +void leaveWhenQuiet(int sub) { + runOnInputThread([sub] { + if (msSinceMenu(sub) < 1500) return leaveWhenQuiet(sub); + leaveSplitScreen(sub); + }, std::chrono::milliseconds(500)); +} + +// What the menus (Menus.cpp) ask of the game. +namespace game { + +std::map players() { + std::map out{{0, 0}}; + std::lock_guard lock(gPlayingMutex); + for (int sub : gPlaying) + if (!gLeaving.contains(sub)) out[sub] = sub; + return out; +} + +bool hasController(int controller) { return controllerConnected(controller); } + +void requestLeave(int sub) { + if (sub < 1 || sub > 3) return; // player 1 leaving is leaving the world + { + std::lock_guard lock(gPlayingMutex); + if (!gPlaying.contains(sub) || !gLeaving.insert(sub).second) return; + } + cancelMenu(sub); + // A leaving player gets no new presses (their releases still come). + blockController(sub, true); + // Removed at once, a player whose menu is still closing takes the game down with it (a destroyed mutex): the + // player leaves once its screen has closed. + leaveWhenQuiet(sub); +} + +void moveController(int from, int to) { swapControllers(from, to); } + +void handOver(int sub, int from, int to) { + { + std::lock_guard lock(gPlayingMutex); + gHandOvers[sub] = {from, to}; + } + note("menus: player {} leaves, then controller {} plays as controller {}", sub + 1, from, to); + requestLeave(sub); +} + +void freeFirstController() { + auto playing = players(); + for (int c = 1; c <= 3; ++c) + if (!playing.contains(c) && !controllerConnected(c)) { + note("menus: controller taken from player 1 (it waits for Start as controller {})", c); + return swapControllers(0, c); + } + note("menus: no free place for player 1's controller"); +} + +void join(int controller, std::function joined) { + runOnInputThread([controller, joined = std::move(joined)] { + // the new player's sub client id is the first free one: the controller takes that number first + auto playing = players(); + int sub = 1; + while (sub <= 3 && playing.contains(sub)) ++sub; + if (sub > 3) { + note("join: no free place for controller {}", controller); + if (joined) joined(-1); + return; + } + if (controller != sub) swapControllers(controller, sub); + joinController(sub); + if (joined) joined(players().contains(sub) ? sub : -1); + }); +} + +} // namespace game + +namespace { + +bool hookClientLeave(Module const& game) { + constexpr uint32_t kLdrSubIdSlot = 0xF944BD08; // ldr x8, [x8, #0x978]: getClientSubId, slot 303 + constexpr uint32_t kLdrRemoval = 0xF9402908; // ldr x8, [x8, #0x50]: MinecraftGame::queueSubclientRemoval + for (auto const& c : findClasses(game, "14ClientInstance")) { + if (c.name != "14ClientInstance") continue; + for (uintptr_t* vt : c.vtables) { + size_t count = countSlots(game, vt, 2000); + note("leave: ClientInstance vtable +0x{:X}, {} slots", reinterpret_cast(vt) - game.base, count); + if (count <= 303) continue; + for (size_t i = 0; i < count; ++i) { + if (!game.isCode(vt[i]) || !game.isCode(vt[i] + 0x400)) continue; + auto const* code = reinterpret_cast(vt[i]); + for (size_t k = 0; k < 0x100 - 8; ++k) { + if (code[k] != kLdrSubIdSlot) continue; + bool removal = false; + for (size_t j = k + 1; j < k + 8; ++j) removal |= code[j] == kLdrRemoval; + if (!removal) continue; + note("leave: the client frame is slot {} (+0x{:X}), getClientSubId slot 303 ({})", i, + vt[i] - game.base, describeFunction(game, vt[303])); + gClientTick = reinterpret_cast(vt[i]); + gClientVt = vt; + // requestLeaveGame: the slot the pause menu calls (1.26: 15), checked to be a function + if (count > 15 && game.isCode(vt[15])) gRequestLeave = reinterpret_cast(vt[15]); + note("leave: requestLeaveGame is slot 15 ({})", describeFunction(game, vt[15])); + gGetSubId = reinterpret_cast(vt[303]); + // The game's own "this player leaves" (the pause menu's Save & Leave): a function that sets the + // "leave requested" byte +0x6E1 (strb wN, [xM, #0x6E1], before its first ret). The mod only sets + // the bytes; the game's function may do more first, and players removed by the mod's bytes made the + // game crash on a removed player's actor. Each candidate is traced (which one the pause menu calls, + // and with what). + constexpr uint32_t kStrbRequested = 0x39000000 | (0x6E1 << 10); + size_t traced = 0; + for (size_t w = 0; w < count; ++w) { + if (!game.isCode(vt[w]) || !game.isCode(vt[w] + 0x800)) continue; + auto const* fn = reinterpret_cast(vt[w]); + for (size_t n = 0; n < 0x200 && fn[n] != 0xD65F03C0; ++n) + if ((fn[n] & 0xFFFFFC00) == kStrbRequested && (fn[n] & 31) != 31) { + note("leave: slot {} (+0x{:X}) sets +0x6E1 at +0x{:X}", w, vt[w] - game.base, n * 4); + if (traced < 8) { + static std::string names[8]; + names[traced] = fmt::format("ClientInstance{}", w); + traceVtable(game, vt + w, 1, 412 + traced, names[traced].c_str(), + [](std::string const& l) { note("{}", l); }, 20); + ++traced; + } + break; + } + } + return writeSlot(&vt[i], reinterpret_cast(&clientTickDetour)) + && writeSlot(&vt[303], reinterpret_cast(&getSubIdDetour)); + } + } + } + } + note("leave: ERROR: ClientInstance::requestLeaveGame not found"); + return false; +} + +// The Split screen menu, on Select (View / Back) of a controller that plays. The added player's sub client id is its +// controller number (1-3). +using PauseEventFn = uint64_t (*)(void*, void*); +PauseEventFn gPauseEvent = nullptr; +std::atomic gPauseEventsLogged{0}; + +// The resource pack's Split screen button in the pause menu sends "button.splitscreen_menu". The game names buttons +// by the FNV-1a hash of their name (0.9.11 log: B8B6D7EF for it, D65033DE for button.menu_continue); a button event +// is type 1, then (at +8) the hash, then (at +16) the state (1 = pressed). As on Windows, the pause menu closes (the +// press goes on as Resume's) and the player who pressed it gets the Split screen menu. +constexpr uint32_t fnv1a(std::string_view s) { + uint32_t h = 0x811C9DC5; + for (char c : s) h = (h ^ uint8_t(c)) * 0x01000193; + return h; +} +constexpr uint32_t kSplitScreenButton = fnv1a("button.splitscreen_menu"); +constexpr uint32_t kTouchPress = fnv1a("button.menu_select"); +constexpr uint32_t kResumeButton = fnv1a("button.menu_continue"); + +uint64_t pauseEventDetour(void* self, void* event) { + uint32_t words[8] = {}; + std::memcpy(words, event, sizeof words); + if (words[0] == 1 && words[2] == kSplitScreenButton) { // type, (padding), hash, (?), state + if ((words[4] & 0xFF) != 1) return 0; + // what pressed it (at +12): button.menu_select is a touch (player 1's), button.menu_ok a controller's A (the + // controller that pressed a button last) + int who = words[3] == kTouchPress ? 0 : lastPresser(); + note("pause menu: Split screen pressed by player {}", who + 1); + runOnInputThread([who] { menus::onSettingsButton(who); }, std::chrono::milliseconds(300)); + // the pause menu closes the way Resume closes it: the press is handed on as button.menu_continue + uint32_t resume = kResumeButton; + std::memcpy(static_cast(event) + 8, &resume, sizeof resume); + return gPauseEvent(self, event); + } + uint64_t result = gPauseEvent(self, event); + if (words[0] != 5 && words[0] != 8 && gPauseEventsLogged < 60) { + ++gPauseEventsLogged; + note("pause: event {} [{:08X} {:08X} {:08X} {:08X} {:08X} {:08X} {:08X}] -> {} (screen {})", words[0], words[1], + words[2], words[3], words[4], words[5], words[6], words[7], result, self); + } + return result; +} + +void enableJoining(Module const& game) { + static Module copy = game; + gGameModule = © + auto classes = findClasses(game, "13MinecraftGame"); + uintptr_t* vt = nullptr; + for (auto const& c : classes) + if (c.name == "13MinecraftGame" && !c.vtables.empty()) vt = c.vtables.front(); + if (!vt) return note("join: ERROR: the MinecraftGame vtable was not found"); + size_t count = countSlots(game, vt, 2000); + // find the anchor near its expected slot + size_t anchor = SIZE_MAX; + for (size_t i = kAddSubClientSlot - 40; i < std::min(count, kAddSubClientSlot + 40); ++i) { + auto const* code = reinterpret_cast(vt[i]); + if (game.isCode(vt[i]) && code[0] == 0x52800022 && (code[1] & 0xFC000000) == 0x14000000) { + if (anchor != SIZE_MAX) return note("join: ERROR: two candidates for addSubClientInstanceAndAssociateToUser"); + anchor = i; + } + } + if (anchor == SIZE_MAX) return note("join: ERROR: addSubClientInstanceAndAssociateToUser not found"); + size_t joinSlot = anchor - kJoinEnabledBack, canAddSlot = anchor - kCanAddBack; + note("join: MinecraftGame vtable +0x{:X}, {} slots; addSubClient {} , isSplitscreenJoinEnabled {} ({}), " + "canAddSplitscreenSubClient {} ({})", + reinterpret_cast(vt) - game.base, count, anchor, joinSlot, describeFunction(game, vt[joinSlot]), + canAddSlot, describeFunction(game, vt[canAddSlot])); + gJoinEnabled = reinterpret_cast(vt[joinSlot]); + gCanAdd = reinterpret_cast(vt[canAddSlot]); + gAddSub = reinterpret_cast(vt[anchor]); + bool ok = writeSlot(&vt[joinSlot], reinterpret_cast(&joinEnabledDetour)) + && writeSlot(&vt[canAddSlot], reinterpret_cast(&canAddDetour)) + && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); + note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); + // virtuals 10 and 16 (queueSubclientRemoval, releaseClientSubId: IMinecraftGame.h 9 and 15, one slot later here + // like getUserManager 52 -> 53) + gQueueRemoval = reinterpret_cast(vt[10]); + gReleaseSubId = reinterpret_cast(vt[16]); + writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); + hookClientLeave(game); + // The code of the game's "terminating" crash when a player leaves (1.26: the same frames every time). + for (auto [label, at] : {std::pair{"crash9", 0x88A4C30}, {"crash10", 0xFD8ECD0}, {"crash11", 0xFD8DD58}, + {"crash12", 0xB7C1CC0}, {"crash13", 0xB803EEC}}) + dumpCode(game, label, game.base + at - 0x300, 0x340, [](std::string const& l) { note("{}", l); }); + writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); + // On Windows the game's join prompt asks to add a player; on Android the game shows none, so Start on a controller + // without a player asks (the same request; not outside a world). "startJoins": false leaves it to the game. + if (ok) onStartPressed([startJoins = configFlag("startJoins", true)](int controller) { + if (game::players().contains(controller)) return startTracing(60); // its pause menu (traced) + note("join: controller {} pressed Start{}", controller, startJoins ? "" : " (the game's prompt decides)"); + if (startJoins) menus::onJoinRequest(controller); + }); + if (ok) onSelectPressed(menus::onSettingsButton); + if (ok) setPlayingPlayers([] { + std::lock_guard lock(gPlayingMutex); + std::vector out; + for (int sub : gPlaying) + if (!gLeaving.contains(sub)) out.push_back(sub); // a leaving player does not get a new controller + return out; + }); + if (ok) onLostPlayerTakenOver(menus::onControllerConnected); + // W: in _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210), the first bl after + // "add x2, sp, #164" (its out parameter) + { + auto const* code = reinterpret_cast(gAddSub); + uintptr_t internal = reinterpret_cast(gAddSub) + 4 + ((int64_t(code[1] & 0x3FFFFFF) << 38) >> 36); + auto const* body = reinterpret_cast(internal); + uintptr_t check = 0; + for (int i = 0; i < 0x100 && !check; ++i) + if (body[i] == 0x910293E2) // add x2, sp, #164 + for (int k = i + 1; k < i + 8; ++k) + if ((body[k] & 0xFC000000) == 0x94000000) { + check = internal + 4 * k + ((int64_t(body[k] & 0x3FFFFFF) << 38) >> 36); + break; + } + if (!check || !game.isCode(check)) note("join: ERROR: the add-player check was not found"); + else if (pl::memory::hook(reinterpret_cast(check), + reinterpret_cast(&addCheckDetour), + reinterpret_cast(&gAddCheck)) + != 0) + note("join: ERROR: could not hook the add-player check +0x{:X}", check - game.base); + else note("join: add-player check +0x{:X} hooked", check - game.base); + } + // after the patches above, so the trace sees our detours' slots as the originals + if (ok) traceVtable(game, vt, count, 0, "MinecraftGame", [](std::string const& l) { note("{}", l); }); + // The pause menu (its Split screen button) and the game's join prompt: which of their functions run, to find + // how the Windows version's button and prompt work here. + // PauseScreenController virtual 4 gets the screen's events (0.9.9 log: type 5 with two floats is the pointer + // moving): every other event is logged, to find the Split screen button's press. + for (auto const& c : findClasses(game, "21PauseScreenController")) + if (c.name == "21PauseScreenController" && !c.vtables.empty() && countSlots(game, c.vtables.front(), 400) > 4) { + gPauseEvent = reinterpret_cast(c.vtables.front()[4]); + writeSlot(&c.vtables.front()[4], reinterpret_cast(&pauseEventDetour)); + note("pause: watching the pause menu's events"); + } + size_t listenerBase = 575; + for (auto const& c : findClasses(game, "SplitscreenJoinListener")) + for (auto* lvt : c.vtables) { + size_t n = std::min(countSlots(game, lvt, 400), 600 - listenerBase); + if (!n) continue; + note("join: tracing {} ({} slots)", c.name, n); + traceVtable(game, lvt, n, listenerBase, "SplitscreenJoinListener", [](std::string const& l) { note("{}", l); }); + listenerBase += n; + } +} + +void probe() { + using namespace std::chrono_literals; + std::optional game; + for (int i = 0; i < 600 && !(game = findModule(kGameLibrary)); ++i) std::this_thread::sleep_for(200ms); + if (!game) return note("ERROR: {} was not loaded within 2 minutes", kGameLibrary); + std::this_thread::sleep_for(1s); // let the loader finish relocating it + note("game library {} at 0x{:X}", game->path, game->base); + for (auto const& s : game->segments) + note(" segment +0x{:X}..+0x{:X} {}{}{}", s.start - game->base, s.end - game->base, s.read ? 'r' : '-', + s.write ? 'w' : '-', s.exec ? 'x' : '-'); + + // 1. exported symbols + void* handle = dlopen(game->path.c_str(), RTLD_NOW | RTLD_NOLOAD); + std::set allowSplitScreenFns; + for (auto const& k : kKnown) { + auto addr = handle ? reinterpret_cast(dlsym(handle, k.symbol)) : 0; + note("symbol {}: {}", k.label, addr ? fmt::format("+0x{:X}", addr - game->base) : "not exported"); + if (addr && std::string_view(k.label).ends_with("::allowSplitScreen")) allowSplitScreenFns.insert(addr); + } + + // How Java hands input to the game: the exported JNI functions about input, and how many exports there are. + { + auto exports = exportedFunctions(*game); + note("{} exported functions", exports.size()); + size_t jni = 0; + for (auto const& e : exports) { + if (!e.name.starts_with("Java_")) continue; + ++jni; + std::string lower = e.name; + for (auto& ch : lower) ch = static_cast(std::tolower(static_cast(ch))); + for (char const* word : {"key", "controller", "gamepad", "joystick", "input", "button", "motion", "axis", + "device", "trigger", "stick", "touch"}) + if (lower.find(word) != std::string::npos) { + note("jni {} +0x{:X}", e.name, e.address - game->base); + break; + } + } + note("{} JNI functions in all", jni); + } + // The split screen layout option: its name in the game's strings (for the layout menu). + { + int shown = 0; + for (auto const& seg : game->segments) { + if (!seg.read || seg.write) continue; + auto const* begin = reinterpret_cast(seg.start); + size_t size = seg.end - seg.start; + for (char const* needle : {"plitscreen", "plit_screen", "plitScreen", "plit-screen"}) { + size_t n = strlen(needle); + for (size_t at = 0; at + n <= size && shown < 80;) { + auto const* hit = static_cast(memmem(begin + at, size - at, needle, n)); + if (!hit) break; + char const* start = hit; + while (start > begin && start[-1] >= 0x20 && start[-1] < 0x7F && hit - start < 120) --start; + std::string text(start, strnlen(start, 160)); + at = size_t(hit - begin) + std::max(1, text.size() - size_t(hit - start)); + if (text.find("irection") == std::string::npos && text.find("ayout") == std::string::npos + && text.find("ertical") == std::string::npos && text.find("orizontal") == std::string::npos + && text.find("option") == std::string::npos) + continue; + note("layout string +0x{:X}: {}", reinterpret_cast(start) - game->base, text); + ++shown; + } + } + } + } + // The pause menu's buttons: their names ("button.menu_...") and the code that uses them (the pause menu sets up + // a handler for each name, as the Windows version does for its Split screen button). In the background: it reads + // all of the game's code. + std::thread([game = *game] { + std::vector> names; + for (auto const& seg : game.segments) { + if (!seg.read || seg.write) continue; + auto const* begin = reinterpret_cast(seg.start); + size_t size = seg.end - seg.start; + for (size_t at = 0; at + 12 <= size && names.size() < 40;) { + auto const* hit = static_cast(memmem(begin + at, size - at, "button.menu_", 12)); + if (!hit) break; + at = size_t(hit - begin) + 1; + if (hit > begin && hit[-1] != '\0') continue; + names.push_back({reinterpret_cast(hit), std::string(hit, strnlen(hit, 64))}); + } + } + for (auto& [addr, name] : names) note("pause: string +0x{:X} {}", addr - game.base, name); + // code that loads one of them (adrp xN; add xN, xN, #lo12) + std::map wanted; + for (auto& [addr, name] : names) + if (name == "button.menu_continue" || name == "button.menu_quit" || name == "button.menu_settings" + || name == "button.menu_resume" || name == "button.menu_exit") + wanted[addr] = name; + int dumped = 0; + for (auto const& seg : game.segments) { + if (!seg.exec || wanted.empty()) continue; + auto const* code = reinterpret_cast(seg.start); + size_t n = (seg.end - seg.start) / 4; + for (size_t i = 0; i + 1 < n && dumped < 6; ++i) { + uint32_t op = code[i]; + if ((op & 0x9F000000) != 0x90000000) continue; // adrp + int rd = op & 31; + uint32_t next = code[i + 1]; + if ((next & 0xFFC00000) != 0x91000000 || int(next & 31) != rd || int((next >> 5) & 31) != rd) continue; + uintptr_t pc = seg.start + 4 * i; + int64_t imm = (int64_t(((op >> 5) & 0x7FFFF) << 2 | ((op >> 29) & 3)) << 43) >> 31; + uintptr_t addr = (pc & ~uintptr_t(0xFFF)) + imm + ((next >> 10) & 0xFFF); + auto it = wanted.find(addr); + if (it == wanted.end()) continue; + note("pause: {} used at +0x{:X}", it->second, pc - game.base); + dumpCode(game, fmt::format("pause{}", dumped).c_str(), pc - 0x100, 0x200, + [](std::string const& l) { note("{}", l); }); + ++dumped; + } + } + note("pause: search done"); + }).detach(); + // which Android input functions the game imports (native input queue, Paddleboat controller library, ...) + { + auto imports = importedSymbols(*game); + note("{} imported symbols", imports.size()); + for (auto const& name : imports) + for (char const* prefix : {"AInput", "AKeyEvent", "AMotionEvent", "ALooper", "Paddleboat", "GameActivity", + "ANativeActivity", "AGameController"}) + if (name.starts_with(prefix)) { + note("import {}", name); + break; + } + for (auto const& e : exportedFunctions(*game)) + if (e.name.find("addleboat") != std::string::npos || e.name.find("GameActivity") != std::string::npos + || e.name.find("android_main") != std::string::npos || e.name.find("ANativeActivity") != std::string::npos) + note("export {} +0x{:X}", e.name, e.address - game->base); + } + { + auto log = [](std::string const& l) { note("{}", l); }; + for (auto const& e : exportedFunctions(*game)) { + if (e.name == "GameActivityKeyEvent_fromJava") watchGameActivityKeys(*game, e.address, log); + } + + // the controller manager the JellyBeanDeviceManager JNI functions call + auto calls = findDeviceManagerCalls(*game, exportedFunctions(*game)); + uintptr_t manager = 0; + for (auto const& c : calls) { + note("device manager: {} calls +0x{:X} on object +0x{:X} ({})", c.jni, c.method - game->base, + c.object - game->base, className(*game, c.object)); + manager = c.object; + } + if (calls.empty()) note("device manager: not found"); + else if (!configFlag("separateControllers", true)) note("separateControllers is off in config.json"); + else { + uintptr_t added = 0, removed = 0; + for (auto const& c : calls) { + if (std::string_view(c.jni) == "onInputDeviceAddedNative") added = c.method; + if (std::string_view(c.jni) == "onInputDeviceRemovedNative") removed = c.method; + } + setFirstControllerIsPlayer1(configFlag("firstControllerIsPlayer1", false)); + if (added) startControllerSplit(*game, manager, added, removed, log); + else note("controllers: the manager's add function was not found"); + } + } + + // 2. classes by RTTI. PlayerListPacket is a class every version has: if it is not found, the search itself failed. + for (auto const& c : findClasses(*game, "16PlayerListPacket")) + note("check: class {}: type_info {}, {} vtable(s)", c.name, + c.typeInfo ? fmt::format("+0x{:X}", c.typeInfo - game->base) : "not found", c.vtables.size()); + auto platforms = findClasses(*game, "AppPlatform"); + note("{} AppPlatform classes", platforms.size()); + ClassInfo const* basePlatform = nullptr; + for (auto const& c : platforms) { + note("class {}: type_info {}, {} vtable(s)", c.name, + c.typeInfo ? fmt::format("+0x{:X}", c.typeInfo - game->base) : "not found", c.vtables.size()); + if (c.name == "11AppPlatform" && !c.vtables.empty()) basePlatform = &c; + } + uintptr_t* baseVt = basePlatform ? basePlatform->vtables.front() : nullptr; + // the platform classes' vtables (AppPlatform in full, the others where they differ from it) + size_t baseCount = baseVt ? countSlots(*game, baseVt, 2000) : 0; + for (auto const& c : platforms) + for (auto* vt : c.vtables) { + if (countSlots(*game, vt, 2000) < baseCount) continue; + note("class {}: {} slots", c.name, countSlots(*game, vt, 2000)); + } + + for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener"}) { + auto classes = findClasses(*game, needle); + note("{} classes named *{}*", classes.size(), needle); + for (auto const& c : classes) { + if (std::string_view(needle) == "MinecraftGame" && c.name != "13MinecraftGame") continue; + if (std::string_view(needle) == "MinecraftGame" + && (c.name.find("__function") != std::string::npos || c.name.find("shared_ptr") != std::string::npos)) + continue; + note("class {}: {} vtable(s)", c.name, c.vtables.size()); + } + } + + // 3. allowSplitScreen + if (!baseVt) return note("ERROR: the AppPlatform vtable was not found, split screen stays off"); + size_t slot = SIZE_MAX; + size_t count = countSlots(*game, baseVt, 2000); + uintptr_t allowSplitScreen = 0; + if (!allowSplitScreenFns.empty()) { + for (auto const& c : platforms) + for (auto* vt : c.vtables) + for (size_t i = 0, n = countSlots(*game, vt, 2000); i < n && slot == SIZE_MAX; ++i) + if (allowSplitScreenFns.contains(vt[i])) slot = i; + if (slot != SIZE_MAX && slot < count) allowSplitScreen = baseVt[slot]; + else slot = SIZE_MAX; + note("allowSplitScreen by symbol: slot {}", slot == SIZE_MAX ? std::string("not in the vtable") : std::to_string(slot)); + } + if (slot == SIZE_MAX) { + if (auto found = findAllowSplitScreenSlot(*game, baseVt, count)) { + slot = *found; + allowSplitScreen = baseVt[slot]; + note("allowSplitScreen: slot {} ({})", slot, describeFunction(*game, allowSplitScreen)); + } + } + if (slot == SIZE_MAX) { + note("ERROR: allowSplitScreen was not found, split screen stays off. Please send this log."); + return; + } + if (!configFlag("forceSplitScreen", true)) return note("forceSplitScreen is off in config.json"); + gOriginalAllowSplitScreen = reinterpret_cast(allowSplitScreen); + note("turning split screen on (AppPlatform slot {})", slot); + patchSlotEverywhere(*game, platforms, count, slot, allowSplitScreen, "allowSplitScreen"); + startFormProbe(*game, [](std::string const& l) { note("{}", l); }); + if (configFlag("forceSplitscreenJoin", true)) enableJoining(*game); + else note("forceSplitscreenJoin is off in config.json"); +} + +std::once_flag gStarted; + +void watchGame() { + using namespace std::chrono_literals; + uint64_t reported = UINT64_MAX; + int adding = -1; + for (int i = 0; i < 360; ++i) { // an hour + std::this_thread::sleep_for(10s); + uint64_t calls = gAllowSplitScreenCalls; + if (calls != reported) note("allowSplitScreen asked {} time(s) so far", reported = calls); + // MinecraftGame+1480: set while _addSubClientInstanceAndAssociateToUserInternal is adding a player + if (void* game = gMinecraftGame.load()) { + int now = *(reinterpret_cast(game) + 1480); + if (now != adding) note("join: MinecraftGame+1480 (adding a player) = {}", adding = now); + } + } +} + +// The mod's resource pack (menu look, the black "Choose your player" screen, texts in every language) comes in the +// mod's own folder (pack/). It is put into the game's resource_packs folder and turned on as a global resource pack; +// the menus read their texts from it. The game's folder (games/com.mojang) is not in a fixed place under +// LeviLauncher: it is the one the game itself opens files in (/proc/self/fd), remembered in the mod's data folder +// for the next start, when the pack is put in before the game reads its packs. +constexpr char const* kPackUuid = "64c369bd-945a-4e1b-ab9a-4b3377148744"; +namespace fs = std::filesystem; + +fs::path packSource() { return SplitScreenMod::instance().getSelf().getModDir() / "pack"; } +fs::path rememberedFolderFile() { return SplitScreenMod::instance().getSelf().getDataDir() / "game_folder.txt"; } + +// games/com.mojang from a path the game has open, e.g. ".../games/com.mojang/minecraftpe/options.txt" +fs::path gameFolderIn(std::string const& path) { + auto at = path.find("/games/com.mojang/"); + return at == std::string::npos ? fs::path() : fs::path(path.substr(0, at + strlen("/games/com.mojang"))); +} + +fs::path openGameFolder() { + std::error_code ec; + for (auto const& fd : fs::directory_iterator("/proc/self/fd", ec)) { + auto target = fs::read_symlink(fd.path(), ec); + if (ec) continue; + if (auto folder = gameFolderIn(target.string()); !folder.empty()) return folder; + } + return {}; +} + +void turnOnPack(fs::path const& folder) { + std::error_code ec; + if (!fs::exists(packSource() / "manifest.json", ec)) return note("pack: the mod's resource pack is not in its folder"); + fs::path packDir = folder / "resource_packs" / "SplitScreenUI"; + fs::create_directories(packDir, ec); + fs::copy(packSource(), packDir, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + note("pack: copied to {}{}", packDir.string(), ec ? " (ERROR: " + ec.message() + ")" : ""); + + fs::path globalFile = folder / "minecraftpe" / "global_resource_packs.json"; + std::string text; + { + std::ifstream in(globalFile); + std::stringstream ss; + ss << in.rdbuf(); + text = ss.str(); + } + if (text.find(kPackUuid) != std::string::npos) return note("pack: turned on in {}", globalFile.string()); + std::string entry = fmt::format("{{\"pack_id\":\"{}\",\"version\":[1,0,0]}}", kPackUuid); + auto open = text.find('['); + if (open == std::string::npos) text = "[" + entry + "]"; + else { + auto next = text.find_first_not_of(" \t\r\n", open + 1); + text.insert(open + 1, next != std::string::npos && text[next] == ']' ? entry : entry + ","); + } + std::ofstream out(globalFile, std::ios::trunc); + out << text; + note("pack: turned on in {}{}", globalFile.string(), out ? " (from the next start of the game)" : " (ERROR: could not write)"); +} + +// At load: the folder found last time. +void installResourcePack() { + std::ifstream in(rememberedFolderFile()); + std::string folder; + std::getline(in, folder); + if (folder.empty()) return note("pack: the game's folder is not known yet (found once the game runs)"); + note("pack: game folder {}", folder); + turnOnPack(folder); + menus::setTexts(packSource(), fs::path(folder) / "minecraftpe" / "options.txt"); +} + +// While the game runs: the folder it has files open in; a new one is remembered and gets the pack. +void findGameFolder() { + static fs::path known = [] { + std::ifstream in(rememberedFolderFile()); + std::string folder; + std::getline(in, folder); + return fs::path(folder); + }(); + fs::path folder = openGameFolder(); + if (folder.empty() || folder == known) return; + known = folder; + note("pack: the game's folder is {}", folder.string()); + std::error_code ec; + fs::create_directories(rememberedFolderFile().parent_path(), ec); + std::ofstream(rememberedFolderFile(), std::ios::trunc) << folder.string() << "\n"; + turnOnPack(folder); + menus::setTexts(packSource(), folder / "minecraftpe" / "options.txt"); +} + +void watchControllers() { + for (;;) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + menus::checkControllers(); + static int tick = 0; + if (tick++ % 10 == 0) findGameFolder(); + } +} + +} // namespace + +SplitScreenMod& SplitScreenMod::instance() { + static SplitScreenMod mod; + return mod; +} + +bool SplitScreenMod::load() { + std::error_code ignored; + std::filesystem::create_directories(getSelf().getModDir(), ignored); + auto log = getSelf().getModDir() / "splitscreen.log"; + std::filesystem::rename(log, getSelf().getModDir() / "splitscreen.prev.log", ignored); + gLog.open(log, std::ios::trunc); + openDownloadLog(); + note("SplitScreenMod for Android {} (experimental)", SPLITSCREEN_VERSION); + installResourcePack(); + return true; +} + +bool SplitScreenMod::enable() { + std::call_once(gStarted, [] { + std::thread([] { + probe(); + std::thread(watchControllers).detach(); + watchGame(); + }).detach(); + }); + return true; +} + +bool SplitScreenMod::disable() { + for (auto [slot, original] : gPatched) writeSlot(slot, original); + gPatched.clear(); + return true; +} + +} // namespace splitscreen diff --git a/android/src/SplitScreen.h b/android/src/SplitScreen.h new file mode 100644 index 0000000..f0df174 --- /dev/null +++ b/android/src/SplitScreen.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace splitscreen { + +class SplitScreenMod { +public: + static SplitScreenMod& instance(); + + SplitScreenMod() : mSelf(*ll::mod::NativeMod::current()) {} + + [[nodiscard]] ll::mod::NativeMod& getSelf() const { return mSelf; } + + bool load(); + bool enable(); + bool disable(); + +private: + ll::mod::NativeMod& mSelf; +}; + +} // namespace splitscreen diff --git a/android/src/Trampolines.S b/android/src/Trampolines.S new file mode 100644 index 0000000..242ec60 --- /dev/null +++ b/android/src/Trampolines.S @@ -0,0 +1,63 @@ +// Detours for functions whose signature is not known: each saves every argument register (x0-x8, q0-q7), calls +// splitscreen_on_call(index, saved registers, caller's frame pointer, return address), restores the registers +// (the C++ side may change the saved ones) and jumps to the original function, which returns to the caller. + + .text + +.macro TRAMPOLINE i + .globl splitscreen_trampoline_\i + .hidden splitscreen_trampoline_\i + .type splitscreen_trampoline_\i, %function + .p2align 2 +splitscreen_trampoline_\i: + stp x29, x30, [sp, #-240]! + mov x29, sp + stp x0, x1, [sp, #16] + stp x2, x3, [sp, #32] + stp x4, x5, [sp, #48] + stp x6, x7, [sp, #64] + str x8, [sp, #80] + stp q0, q1, [sp, #96] + stp q2, q3, [sp, #128] + stp q4, q5, [sp, #160] + stp q6, q7, [sp, #192] + mov x0, #\i + add x1, sp, #16 + ldr x2, [sp] + ldr x3, [sp, #8] + bl splitscreen_on_call + ldp q6, q7, [sp, #192] + ldp q4, q5, [sp, #160] + ldp q2, q3, [sp, #128] + ldp q0, q1, [sp, #96] + ldr x8, [sp, #80] + ldp x6, x7, [sp, #64] + ldp x4, x5, [sp, #48] + ldp x2, x3, [sp, #32] + ldp x0, x1, [sp, #16] + ldp x29, x30, [sp], #240 + adrp x16, splitscreen_originals + add x16, x16, :lo12:splitscreen_originals + ldr x16, [x16, #(\i * 8)] + br x16 + .size splitscreen_trampoline_\i, . - splitscreen_trampoline_\i +.endm + +TRAMPOLINE 0 +TRAMPOLINE 1 +TRAMPOLINE 2 +TRAMPOLINE 3 +TRAMPOLINE 4 +TRAMPOLINE 5 +TRAMPOLINE 6 +TRAMPOLINE 7 +TRAMPOLINE 8 +TRAMPOLINE 9 +TRAMPOLINE 10 +TRAMPOLINE 11 +TRAMPOLINE 12 +TRAMPOLINE 13 +TRAMPOLINE 14 +TRAMPOLINE 15 + + .section .note.GNU-stack, "", %progbits diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp new file mode 100644 index 0000000..5f8624a --- /dev/null +++ b/android/src/VTrace.cpp @@ -0,0 +1,99 @@ +#include "VTrace.h" + +#include +#include + +#include +#include +#include +#include + +#include + +namespace { +constexpr size_t kMaxSlots = 600; +} + +extern "C" { +uintptr_t splitscreen_voriginals[kMaxSlots]; +extern uintptr_t splitscreen_vtrampolines[kMaxSlots]; +} + +namespace splitscreen { + +namespace { + +Module const* gGame = nullptr; +LogFn gLog; +std::string gNames[kMaxSlots]; // the class of each trampoline +size_t gSlots[kMaxSlots]; // its vtable slot +std::atomic gTracing{false}; +std::atomic gCounts[kMaxSlots]; +std::atomic gSeen[kMaxSlots]; +std::atomic gAlways[kMaxSlots]; // calls still logged whether tracing or not + +std::string offset(uintptr_t p) { + return gGame && gGame->isCode(p) ? fmt::format("+0x{:X}", p - gGame->base) : fmt::format("0x{:X}", p); +} + +} // namespace + +bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t first, char const* name, LogFn log, + int alwaysLog) { + static Module copy = game; + gGame = © + gLog = std::move(log); + if (first >= kMaxSlots) return false; + slots = std::min(slots, kMaxSlots - first); + for (size_t i = 0; i < slots; ++i) { + size_t t = first + i; + gNames[t] = name; + gSlots[t] = i; + gAlways[t] = alwaysLog; + splitscreen_voriginals[t] = vtable[i]; + if (!writeSlot(&vtable[i], splitscreen_vtrampolines[t])) { + gLog(fmt::format("trace: ERROR: could not write {} slot {}", name, i)); + for (size_t k = 0; k < i; ++k) writeSlot(&vtable[k], splitscreen_voriginals[first + k]); + return false; + } + } + gLog(fmt::format("trace: watching {} slots of {}", slots, name)); + return true; +} + +void startTracing(int seconds) { + if (gTracing.exchange(true)) return; + for (auto& c : gCounts) c = 0; + for (auto& s : gSeen) s = false; + gLog(fmt::format("trace: calls for the next {} seconds", seconds)); + std::thread([seconds] { + std::this_thread::sleep_for(std::chrono::seconds(seconds)); + gTracing = false; + std::string summary; + for (size_t i = 0; i < kMaxSlots; ++i) + if (gCounts[i]) summary += fmt::format(" {}:{}x{}", gNames[i], gSlots[i], gCounts[i].load()); + gLog(fmt::format("trace: calls (class:slot x count):{}", summary)); + }).detach(); +} + +} // namespace splitscreen + +extern "C" void splitscreen_on_vcall(uint64_t slot, uint64_t* regs, uint64_t ret) { + using namespace splitscreen; + if (gAlways[slot].load(std::memory_order_relaxed) > 0) { + --gAlways[slot]; + // the 48 bytes x1 points to, when it is a pointer (an event, a string, ...) + uint8_t bytes[48] = {}; + std::string at; + iovec local{bytes, sizeof bytes}, remote{reinterpret_cast(regs[1]), sizeof bytes}; + if (regs[1] > 0x10000 && process_vm_readv(getpid(), &local, 1, &remote, 1, 0) == ssize_t(sizeof bytes)) + for (uint8_t b : bytes) at += fmt::format("{:02x}", b); + gLog(fmt::format("trace: {} virtual {} called from {} (x1=0x{:X} w1={} w2={} w3={}) [x1]={}", gNames[slot], + gSlots[slot], offset(ret), regs[1], int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]), at)); + } + if (!gTracing.load(std::memory_order_relaxed)) return; + ++gCounts[slot]; + if (gSeen[slot].exchange(true)) return; + gLog(fmt::format("trace: {} virtual {} called from {} (x1=0x{:X} w1={} w2={})", gNames[slot], gSlots[slot], + offset(ret), regs[1], int32_t(regs[1]), int32_t(regs[2]))); +} diff --git a/android/src/VTrace.h b/android/src/VTrace.h new file mode 100644 index 0000000..be8a459 --- /dev/null +++ b/android/src/VTrace.h @@ -0,0 +1,18 @@ +#pragma once + +// Traces which virtual functions of one class are called while tracing is on (VTrampolines.S): every slot of the +// vtable is replaced by a register-saving detour that logs the slot the first time it is called (and how often +// it was called when tracing ends), then jumps to the original function. + +#include "Elf.h" +#include "InputProbe.h" + +namespace splitscreen { + +// Each traced class uses its own range of trampolines, starting at `first` (600 in all). +bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t first, char const* name, LogFn log, int alwaysLog = 0); + +// Logs calls from now on for `seconds`, then a summary. +void startTracing(int seconds); + +} // namespace splitscreen diff --git a/android/src/VTrampolines.S b/android/src/VTrampolines.S new file mode 100644 index 0000000..857f372 --- /dev/null +++ b/android/src/VTrampolines.S @@ -0,0 +1,1249 @@ +// Generated (android/src/VTrampolines.S): one register-saving detour per vtable slot, for tracing which virtual +// functions of a class are called. Each saves x0-x8 and q0-q7, calls +// splitscreen_on_vcall(slot, saved registers, return address) and jumps to splitscreen_voriginals[slot]. + + .text + +.macro VTRAMPOLINE i + .p2align 2 + .type splitscreen_vtrampoline_\i, %function +splitscreen_vtrampoline_\i: + stp x29, x30, [sp, #-240]! + mov x29, sp + stp x0, x1, [sp, #16] + stp x2, x3, [sp, #32] + stp x4, x5, [sp, #48] + stp x6, x7, [sp, #64] + str x8, [sp, #80] + stp q0, q1, [sp, #96] + stp q2, q3, [sp, #128] + stp q4, q5, [sp, #160] + stp q6, q7, [sp, #192] + mov x0, #\i + add x1, sp, #16 + ldr x2, [sp, #8] + bl splitscreen_on_vcall + ldp q6, q7, [sp, #192] + ldp q4, q5, [sp, #160] + ldp q2, q3, [sp, #128] + ldp q0, q1, [sp, #96] + ldr x8, [sp, #80] + ldp x6, x7, [sp, #64] + ldp x4, x5, [sp, #48] + ldp x2, x3, [sp, #32] + ldp x0, x1, [sp, #16] + ldp x29, x30, [sp], #240 + adrp x16, splitscreen_voriginals + add x16, x16, :lo12:splitscreen_voriginals + ldr x16, [x16, #(\i * 8)] + br x16 + .size splitscreen_vtrampoline_\i, . - splitscreen_vtrampoline_\i +.endm + +VTRAMPOLINE 0 +VTRAMPOLINE 1 +VTRAMPOLINE 2 +VTRAMPOLINE 3 +VTRAMPOLINE 4 +VTRAMPOLINE 5 +VTRAMPOLINE 6 +VTRAMPOLINE 7 +VTRAMPOLINE 8 +VTRAMPOLINE 9 +VTRAMPOLINE 10 +VTRAMPOLINE 11 +VTRAMPOLINE 12 +VTRAMPOLINE 13 +VTRAMPOLINE 14 +VTRAMPOLINE 15 +VTRAMPOLINE 16 +VTRAMPOLINE 17 +VTRAMPOLINE 18 +VTRAMPOLINE 19 +VTRAMPOLINE 20 +VTRAMPOLINE 21 +VTRAMPOLINE 22 +VTRAMPOLINE 23 +VTRAMPOLINE 24 +VTRAMPOLINE 25 +VTRAMPOLINE 26 +VTRAMPOLINE 27 +VTRAMPOLINE 28 +VTRAMPOLINE 29 +VTRAMPOLINE 30 +VTRAMPOLINE 31 +VTRAMPOLINE 32 +VTRAMPOLINE 33 +VTRAMPOLINE 34 +VTRAMPOLINE 35 +VTRAMPOLINE 36 +VTRAMPOLINE 37 +VTRAMPOLINE 38 +VTRAMPOLINE 39 +VTRAMPOLINE 40 +VTRAMPOLINE 41 +VTRAMPOLINE 42 +VTRAMPOLINE 43 +VTRAMPOLINE 44 +VTRAMPOLINE 45 +VTRAMPOLINE 46 +VTRAMPOLINE 47 +VTRAMPOLINE 48 +VTRAMPOLINE 49 +VTRAMPOLINE 50 +VTRAMPOLINE 51 +VTRAMPOLINE 52 +VTRAMPOLINE 53 +VTRAMPOLINE 54 +VTRAMPOLINE 55 +VTRAMPOLINE 56 +VTRAMPOLINE 57 +VTRAMPOLINE 58 +VTRAMPOLINE 59 +VTRAMPOLINE 60 +VTRAMPOLINE 61 +VTRAMPOLINE 62 +VTRAMPOLINE 63 +VTRAMPOLINE 64 +VTRAMPOLINE 65 +VTRAMPOLINE 66 +VTRAMPOLINE 67 +VTRAMPOLINE 68 +VTRAMPOLINE 69 +VTRAMPOLINE 70 +VTRAMPOLINE 71 +VTRAMPOLINE 72 +VTRAMPOLINE 73 +VTRAMPOLINE 74 +VTRAMPOLINE 75 +VTRAMPOLINE 76 +VTRAMPOLINE 77 +VTRAMPOLINE 78 +VTRAMPOLINE 79 +VTRAMPOLINE 80 +VTRAMPOLINE 81 +VTRAMPOLINE 82 +VTRAMPOLINE 83 +VTRAMPOLINE 84 +VTRAMPOLINE 85 +VTRAMPOLINE 86 +VTRAMPOLINE 87 +VTRAMPOLINE 88 +VTRAMPOLINE 89 +VTRAMPOLINE 90 +VTRAMPOLINE 91 +VTRAMPOLINE 92 +VTRAMPOLINE 93 +VTRAMPOLINE 94 +VTRAMPOLINE 95 +VTRAMPOLINE 96 +VTRAMPOLINE 97 +VTRAMPOLINE 98 +VTRAMPOLINE 99 +VTRAMPOLINE 100 +VTRAMPOLINE 101 +VTRAMPOLINE 102 +VTRAMPOLINE 103 +VTRAMPOLINE 104 +VTRAMPOLINE 105 +VTRAMPOLINE 106 +VTRAMPOLINE 107 +VTRAMPOLINE 108 +VTRAMPOLINE 109 +VTRAMPOLINE 110 +VTRAMPOLINE 111 +VTRAMPOLINE 112 +VTRAMPOLINE 113 +VTRAMPOLINE 114 +VTRAMPOLINE 115 +VTRAMPOLINE 116 +VTRAMPOLINE 117 +VTRAMPOLINE 118 +VTRAMPOLINE 119 +VTRAMPOLINE 120 +VTRAMPOLINE 121 +VTRAMPOLINE 122 +VTRAMPOLINE 123 +VTRAMPOLINE 124 +VTRAMPOLINE 125 +VTRAMPOLINE 126 +VTRAMPOLINE 127 +VTRAMPOLINE 128 +VTRAMPOLINE 129 +VTRAMPOLINE 130 +VTRAMPOLINE 131 +VTRAMPOLINE 132 +VTRAMPOLINE 133 +VTRAMPOLINE 134 +VTRAMPOLINE 135 +VTRAMPOLINE 136 +VTRAMPOLINE 137 +VTRAMPOLINE 138 +VTRAMPOLINE 139 +VTRAMPOLINE 140 +VTRAMPOLINE 141 +VTRAMPOLINE 142 +VTRAMPOLINE 143 +VTRAMPOLINE 144 +VTRAMPOLINE 145 +VTRAMPOLINE 146 +VTRAMPOLINE 147 +VTRAMPOLINE 148 +VTRAMPOLINE 149 +VTRAMPOLINE 150 +VTRAMPOLINE 151 +VTRAMPOLINE 152 +VTRAMPOLINE 153 +VTRAMPOLINE 154 +VTRAMPOLINE 155 +VTRAMPOLINE 156 +VTRAMPOLINE 157 +VTRAMPOLINE 158 +VTRAMPOLINE 159 +VTRAMPOLINE 160 +VTRAMPOLINE 161 +VTRAMPOLINE 162 +VTRAMPOLINE 163 +VTRAMPOLINE 164 +VTRAMPOLINE 165 +VTRAMPOLINE 166 +VTRAMPOLINE 167 +VTRAMPOLINE 168 +VTRAMPOLINE 169 +VTRAMPOLINE 170 +VTRAMPOLINE 171 +VTRAMPOLINE 172 +VTRAMPOLINE 173 +VTRAMPOLINE 174 +VTRAMPOLINE 175 +VTRAMPOLINE 176 +VTRAMPOLINE 177 +VTRAMPOLINE 178 +VTRAMPOLINE 179 +VTRAMPOLINE 180 +VTRAMPOLINE 181 +VTRAMPOLINE 182 +VTRAMPOLINE 183 +VTRAMPOLINE 184 +VTRAMPOLINE 185 +VTRAMPOLINE 186 +VTRAMPOLINE 187 +VTRAMPOLINE 188 +VTRAMPOLINE 189 +VTRAMPOLINE 190 +VTRAMPOLINE 191 +VTRAMPOLINE 192 +VTRAMPOLINE 193 +VTRAMPOLINE 194 +VTRAMPOLINE 195 +VTRAMPOLINE 196 +VTRAMPOLINE 197 +VTRAMPOLINE 198 +VTRAMPOLINE 199 +VTRAMPOLINE 200 +VTRAMPOLINE 201 +VTRAMPOLINE 202 +VTRAMPOLINE 203 +VTRAMPOLINE 204 +VTRAMPOLINE 205 +VTRAMPOLINE 206 +VTRAMPOLINE 207 +VTRAMPOLINE 208 +VTRAMPOLINE 209 +VTRAMPOLINE 210 +VTRAMPOLINE 211 +VTRAMPOLINE 212 +VTRAMPOLINE 213 +VTRAMPOLINE 214 +VTRAMPOLINE 215 +VTRAMPOLINE 216 +VTRAMPOLINE 217 +VTRAMPOLINE 218 +VTRAMPOLINE 219 +VTRAMPOLINE 220 +VTRAMPOLINE 221 +VTRAMPOLINE 222 +VTRAMPOLINE 223 +VTRAMPOLINE 224 +VTRAMPOLINE 225 +VTRAMPOLINE 226 +VTRAMPOLINE 227 +VTRAMPOLINE 228 +VTRAMPOLINE 229 +VTRAMPOLINE 230 +VTRAMPOLINE 231 +VTRAMPOLINE 232 +VTRAMPOLINE 233 +VTRAMPOLINE 234 +VTRAMPOLINE 235 +VTRAMPOLINE 236 +VTRAMPOLINE 237 +VTRAMPOLINE 238 +VTRAMPOLINE 239 +VTRAMPOLINE 240 +VTRAMPOLINE 241 +VTRAMPOLINE 242 +VTRAMPOLINE 243 +VTRAMPOLINE 244 +VTRAMPOLINE 245 +VTRAMPOLINE 246 +VTRAMPOLINE 247 +VTRAMPOLINE 248 +VTRAMPOLINE 249 +VTRAMPOLINE 250 +VTRAMPOLINE 251 +VTRAMPOLINE 252 +VTRAMPOLINE 253 +VTRAMPOLINE 254 +VTRAMPOLINE 255 +VTRAMPOLINE 256 +VTRAMPOLINE 257 +VTRAMPOLINE 258 +VTRAMPOLINE 259 +VTRAMPOLINE 260 +VTRAMPOLINE 261 +VTRAMPOLINE 262 +VTRAMPOLINE 263 +VTRAMPOLINE 264 +VTRAMPOLINE 265 +VTRAMPOLINE 266 +VTRAMPOLINE 267 +VTRAMPOLINE 268 +VTRAMPOLINE 269 +VTRAMPOLINE 270 +VTRAMPOLINE 271 +VTRAMPOLINE 272 +VTRAMPOLINE 273 +VTRAMPOLINE 274 +VTRAMPOLINE 275 +VTRAMPOLINE 276 +VTRAMPOLINE 277 +VTRAMPOLINE 278 +VTRAMPOLINE 279 +VTRAMPOLINE 280 +VTRAMPOLINE 281 +VTRAMPOLINE 282 +VTRAMPOLINE 283 +VTRAMPOLINE 284 +VTRAMPOLINE 285 +VTRAMPOLINE 286 +VTRAMPOLINE 287 +VTRAMPOLINE 288 +VTRAMPOLINE 289 +VTRAMPOLINE 290 +VTRAMPOLINE 291 +VTRAMPOLINE 292 +VTRAMPOLINE 293 +VTRAMPOLINE 294 +VTRAMPOLINE 295 +VTRAMPOLINE 296 +VTRAMPOLINE 297 +VTRAMPOLINE 298 +VTRAMPOLINE 299 +VTRAMPOLINE 300 +VTRAMPOLINE 301 +VTRAMPOLINE 302 +VTRAMPOLINE 303 +VTRAMPOLINE 304 +VTRAMPOLINE 305 +VTRAMPOLINE 306 +VTRAMPOLINE 307 +VTRAMPOLINE 308 +VTRAMPOLINE 309 +VTRAMPOLINE 310 +VTRAMPOLINE 311 +VTRAMPOLINE 312 +VTRAMPOLINE 313 +VTRAMPOLINE 314 +VTRAMPOLINE 315 +VTRAMPOLINE 316 +VTRAMPOLINE 317 +VTRAMPOLINE 318 +VTRAMPOLINE 319 +VTRAMPOLINE 320 +VTRAMPOLINE 321 +VTRAMPOLINE 322 +VTRAMPOLINE 323 +VTRAMPOLINE 324 +VTRAMPOLINE 325 +VTRAMPOLINE 326 +VTRAMPOLINE 327 +VTRAMPOLINE 328 +VTRAMPOLINE 329 +VTRAMPOLINE 330 +VTRAMPOLINE 331 +VTRAMPOLINE 332 +VTRAMPOLINE 333 +VTRAMPOLINE 334 +VTRAMPOLINE 335 +VTRAMPOLINE 336 +VTRAMPOLINE 337 +VTRAMPOLINE 338 +VTRAMPOLINE 339 +VTRAMPOLINE 340 +VTRAMPOLINE 341 +VTRAMPOLINE 342 +VTRAMPOLINE 343 +VTRAMPOLINE 344 +VTRAMPOLINE 345 +VTRAMPOLINE 346 +VTRAMPOLINE 347 +VTRAMPOLINE 348 +VTRAMPOLINE 349 +VTRAMPOLINE 350 +VTRAMPOLINE 351 +VTRAMPOLINE 352 +VTRAMPOLINE 353 +VTRAMPOLINE 354 +VTRAMPOLINE 355 +VTRAMPOLINE 356 +VTRAMPOLINE 357 +VTRAMPOLINE 358 +VTRAMPOLINE 359 +VTRAMPOLINE 360 +VTRAMPOLINE 361 +VTRAMPOLINE 362 +VTRAMPOLINE 363 +VTRAMPOLINE 364 +VTRAMPOLINE 365 +VTRAMPOLINE 366 +VTRAMPOLINE 367 +VTRAMPOLINE 368 +VTRAMPOLINE 369 +VTRAMPOLINE 370 +VTRAMPOLINE 371 +VTRAMPOLINE 372 +VTRAMPOLINE 373 +VTRAMPOLINE 374 +VTRAMPOLINE 375 +VTRAMPOLINE 376 +VTRAMPOLINE 377 +VTRAMPOLINE 378 +VTRAMPOLINE 379 +VTRAMPOLINE 380 +VTRAMPOLINE 381 +VTRAMPOLINE 382 +VTRAMPOLINE 383 +VTRAMPOLINE 384 +VTRAMPOLINE 385 +VTRAMPOLINE 386 +VTRAMPOLINE 387 +VTRAMPOLINE 388 +VTRAMPOLINE 389 +VTRAMPOLINE 390 +VTRAMPOLINE 391 +VTRAMPOLINE 392 +VTRAMPOLINE 393 +VTRAMPOLINE 394 +VTRAMPOLINE 395 +VTRAMPOLINE 396 +VTRAMPOLINE 397 +VTRAMPOLINE 398 +VTRAMPOLINE 399 +VTRAMPOLINE 400 +VTRAMPOLINE 401 +VTRAMPOLINE 402 +VTRAMPOLINE 403 +VTRAMPOLINE 404 +VTRAMPOLINE 405 +VTRAMPOLINE 406 +VTRAMPOLINE 407 +VTRAMPOLINE 408 +VTRAMPOLINE 409 +VTRAMPOLINE 410 +VTRAMPOLINE 411 +VTRAMPOLINE 412 +VTRAMPOLINE 413 +VTRAMPOLINE 414 +VTRAMPOLINE 415 +VTRAMPOLINE 416 +VTRAMPOLINE 417 +VTRAMPOLINE 418 +VTRAMPOLINE 419 +VTRAMPOLINE 420 +VTRAMPOLINE 421 +VTRAMPOLINE 422 +VTRAMPOLINE 423 +VTRAMPOLINE 424 +VTRAMPOLINE 425 +VTRAMPOLINE 426 +VTRAMPOLINE 427 +VTRAMPOLINE 428 +VTRAMPOLINE 429 +VTRAMPOLINE 430 +VTRAMPOLINE 431 +VTRAMPOLINE 432 +VTRAMPOLINE 433 +VTRAMPOLINE 434 +VTRAMPOLINE 435 +VTRAMPOLINE 436 +VTRAMPOLINE 437 +VTRAMPOLINE 438 +VTRAMPOLINE 439 +VTRAMPOLINE 440 +VTRAMPOLINE 441 +VTRAMPOLINE 442 +VTRAMPOLINE 443 +VTRAMPOLINE 444 +VTRAMPOLINE 445 +VTRAMPOLINE 446 +VTRAMPOLINE 447 +VTRAMPOLINE 448 +VTRAMPOLINE 449 +VTRAMPOLINE 450 +VTRAMPOLINE 451 +VTRAMPOLINE 452 +VTRAMPOLINE 453 +VTRAMPOLINE 454 +VTRAMPOLINE 455 +VTRAMPOLINE 456 +VTRAMPOLINE 457 +VTRAMPOLINE 458 +VTRAMPOLINE 459 +VTRAMPOLINE 460 +VTRAMPOLINE 461 +VTRAMPOLINE 462 +VTRAMPOLINE 463 +VTRAMPOLINE 464 +VTRAMPOLINE 465 +VTRAMPOLINE 466 +VTRAMPOLINE 467 +VTRAMPOLINE 468 +VTRAMPOLINE 469 +VTRAMPOLINE 470 +VTRAMPOLINE 471 +VTRAMPOLINE 472 +VTRAMPOLINE 473 +VTRAMPOLINE 474 +VTRAMPOLINE 475 +VTRAMPOLINE 476 +VTRAMPOLINE 477 +VTRAMPOLINE 478 +VTRAMPOLINE 479 +VTRAMPOLINE 480 +VTRAMPOLINE 481 +VTRAMPOLINE 482 +VTRAMPOLINE 483 +VTRAMPOLINE 484 +VTRAMPOLINE 485 +VTRAMPOLINE 486 +VTRAMPOLINE 487 +VTRAMPOLINE 488 +VTRAMPOLINE 489 +VTRAMPOLINE 490 +VTRAMPOLINE 491 +VTRAMPOLINE 492 +VTRAMPOLINE 493 +VTRAMPOLINE 494 +VTRAMPOLINE 495 +VTRAMPOLINE 496 +VTRAMPOLINE 497 +VTRAMPOLINE 498 +VTRAMPOLINE 499 +VTRAMPOLINE 500 +VTRAMPOLINE 501 +VTRAMPOLINE 502 +VTRAMPOLINE 503 +VTRAMPOLINE 504 +VTRAMPOLINE 505 +VTRAMPOLINE 506 +VTRAMPOLINE 507 +VTRAMPOLINE 508 +VTRAMPOLINE 509 +VTRAMPOLINE 510 +VTRAMPOLINE 511 +VTRAMPOLINE 512 +VTRAMPOLINE 513 +VTRAMPOLINE 514 +VTRAMPOLINE 515 +VTRAMPOLINE 516 +VTRAMPOLINE 517 +VTRAMPOLINE 518 +VTRAMPOLINE 519 +VTRAMPOLINE 520 +VTRAMPOLINE 521 +VTRAMPOLINE 522 +VTRAMPOLINE 523 +VTRAMPOLINE 524 +VTRAMPOLINE 525 +VTRAMPOLINE 526 +VTRAMPOLINE 527 +VTRAMPOLINE 528 +VTRAMPOLINE 529 +VTRAMPOLINE 530 +VTRAMPOLINE 531 +VTRAMPOLINE 532 +VTRAMPOLINE 533 +VTRAMPOLINE 534 +VTRAMPOLINE 535 +VTRAMPOLINE 536 +VTRAMPOLINE 537 +VTRAMPOLINE 538 +VTRAMPOLINE 539 +VTRAMPOLINE 540 +VTRAMPOLINE 541 +VTRAMPOLINE 542 +VTRAMPOLINE 543 +VTRAMPOLINE 544 +VTRAMPOLINE 545 +VTRAMPOLINE 546 +VTRAMPOLINE 547 +VTRAMPOLINE 548 +VTRAMPOLINE 549 +VTRAMPOLINE 550 +VTRAMPOLINE 551 +VTRAMPOLINE 552 +VTRAMPOLINE 553 +VTRAMPOLINE 554 +VTRAMPOLINE 555 +VTRAMPOLINE 556 +VTRAMPOLINE 557 +VTRAMPOLINE 558 +VTRAMPOLINE 559 +VTRAMPOLINE 560 +VTRAMPOLINE 561 +VTRAMPOLINE 562 +VTRAMPOLINE 563 +VTRAMPOLINE 564 +VTRAMPOLINE 565 +VTRAMPOLINE 566 +VTRAMPOLINE 567 +VTRAMPOLINE 568 +VTRAMPOLINE 569 +VTRAMPOLINE 570 +VTRAMPOLINE 571 +VTRAMPOLINE 572 +VTRAMPOLINE 573 +VTRAMPOLINE 574 +VTRAMPOLINE 575 +VTRAMPOLINE 576 +VTRAMPOLINE 577 +VTRAMPOLINE 578 +VTRAMPOLINE 579 +VTRAMPOLINE 580 +VTRAMPOLINE 581 +VTRAMPOLINE 582 +VTRAMPOLINE 583 +VTRAMPOLINE 584 +VTRAMPOLINE 585 +VTRAMPOLINE 586 +VTRAMPOLINE 587 +VTRAMPOLINE 588 +VTRAMPOLINE 589 +VTRAMPOLINE 590 +VTRAMPOLINE 591 +VTRAMPOLINE 592 +VTRAMPOLINE 593 +VTRAMPOLINE 594 +VTRAMPOLINE 595 +VTRAMPOLINE 596 +VTRAMPOLINE 597 +VTRAMPOLINE 598 +VTRAMPOLINE 599 + .section .data.rel.ro, "aw" + .p2align 3 + .globl splitscreen_vtrampolines + .hidden splitscreen_vtrampolines +splitscreen_vtrampolines: + .quad splitscreen_vtrampoline_0 + .quad splitscreen_vtrampoline_1 + .quad splitscreen_vtrampoline_2 + .quad splitscreen_vtrampoline_3 + .quad splitscreen_vtrampoline_4 + .quad splitscreen_vtrampoline_5 + .quad splitscreen_vtrampoline_6 + .quad splitscreen_vtrampoline_7 + .quad splitscreen_vtrampoline_8 + .quad splitscreen_vtrampoline_9 + .quad splitscreen_vtrampoline_10 + .quad splitscreen_vtrampoline_11 + .quad splitscreen_vtrampoline_12 + .quad splitscreen_vtrampoline_13 + .quad splitscreen_vtrampoline_14 + .quad splitscreen_vtrampoline_15 + .quad splitscreen_vtrampoline_16 + .quad splitscreen_vtrampoline_17 + .quad splitscreen_vtrampoline_18 + .quad splitscreen_vtrampoline_19 + .quad splitscreen_vtrampoline_20 + .quad splitscreen_vtrampoline_21 + .quad splitscreen_vtrampoline_22 + .quad splitscreen_vtrampoline_23 + .quad splitscreen_vtrampoline_24 + .quad splitscreen_vtrampoline_25 + .quad splitscreen_vtrampoline_26 + .quad splitscreen_vtrampoline_27 + .quad splitscreen_vtrampoline_28 + .quad splitscreen_vtrampoline_29 + .quad splitscreen_vtrampoline_30 + .quad splitscreen_vtrampoline_31 + .quad splitscreen_vtrampoline_32 + .quad splitscreen_vtrampoline_33 + .quad splitscreen_vtrampoline_34 + .quad splitscreen_vtrampoline_35 + .quad splitscreen_vtrampoline_36 + .quad splitscreen_vtrampoline_37 + .quad splitscreen_vtrampoline_38 + .quad splitscreen_vtrampoline_39 + .quad splitscreen_vtrampoline_40 + .quad splitscreen_vtrampoline_41 + .quad splitscreen_vtrampoline_42 + .quad splitscreen_vtrampoline_43 + .quad splitscreen_vtrampoline_44 + .quad splitscreen_vtrampoline_45 + .quad splitscreen_vtrampoline_46 + .quad splitscreen_vtrampoline_47 + .quad splitscreen_vtrampoline_48 + .quad splitscreen_vtrampoline_49 + .quad splitscreen_vtrampoline_50 + .quad splitscreen_vtrampoline_51 + .quad splitscreen_vtrampoline_52 + .quad splitscreen_vtrampoline_53 + .quad splitscreen_vtrampoline_54 + .quad splitscreen_vtrampoline_55 + .quad splitscreen_vtrampoline_56 + .quad splitscreen_vtrampoline_57 + .quad splitscreen_vtrampoline_58 + .quad splitscreen_vtrampoline_59 + .quad splitscreen_vtrampoline_60 + .quad splitscreen_vtrampoline_61 + .quad splitscreen_vtrampoline_62 + .quad splitscreen_vtrampoline_63 + .quad splitscreen_vtrampoline_64 + .quad splitscreen_vtrampoline_65 + .quad splitscreen_vtrampoline_66 + .quad splitscreen_vtrampoline_67 + .quad splitscreen_vtrampoline_68 + .quad splitscreen_vtrampoline_69 + .quad splitscreen_vtrampoline_70 + .quad splitscreen_vtrampoline_71 + .quad splitscreen_vtrampoline_72 + .quad splitscreen_vtrampoline_73 + .quad splitscreen_vtrampoline_74 + .quad splitscreen_vtrampoline_75 + .quad splitscreen_vtrampoline_76 + .quad splitscreen_vtrampoline_77 + .quad splitscreen_vtrampoline_78 + .quad splitscreen_vtrampoline_79 + .quad splitscreen_vtrampoline_80 + .quad splitscreen_vtrampoline_81 + .quad splitscreen_vtrampoline_82 + .quad splitscreen_vtrampoline_83 + .quad splitscreen_vtrampoline_84 + .quad splitscreen_vtrampoline_85 + .quad splitscreen_vtrampoline_86 + .quad splitscreen_vtrampoline_87 + .quad splitscreen_vtrampoline_88 + .quad splitscreen_vtrampoline_89 + .quad splitscreen_vtrampoline_90 + .quad splitscreen_vtrampoline_91 + .quad splitscreen_vtrampoline_92 + .quad splitscreen_vtrampoline_93 + .quad splitscreen_vtrampoline_94 + .quad splitscreen_vtrampoline_95 + .quad splitscreen_vtrampoline_96 + .quad splitscreen_vtrampoline_97 + .quad splitscreen_vtrampoline_98 + .quad splitscreen_vtrampoline_99 + .quad splitscreen_vtrampoline_100 + .quad splitscreen_vtrampoline_101 + .quad splitscreen_vtrampoline_102 + .quad splitscreen_vtrampoline_103 + .quad splitscreen_vtrampoline_104 + .quad splitscreen_vtrampoline_105 + .quad splitscreen_vtrampoline_106 + .quad splitscreen_vtrampoline_107 + .quad splitscreen_vtrampoline_108 + .quad splitscreen_vtrampoline_109 + .quad splitscreen_vtrampoline_110 + .quad splitscreen_vtrampoline_111 + .quad splitscreen_vtrampoline_112 + .quad splitscreen_vtrampoline_113 + .quad splitscreen_vtrampoline_114 + .quad splitscreen_vtrampoline_115 + .quad splitscreen_vtrampoline_116 + .quad splitscreen_vtrampoline_117 + .quad splitscreen_vtrampoline_118 + .quad splitscreen_vtrampoline_119 + .quad splitscreen_vtrampoline_120 + .quad splitscreen_vtrampoline_121 + .quad splitscreen_vtrampoline_122 + .quad splitscreen_vtrampoline_123 + .quad splitscreen_vtrampoline_124 + .quad splitscreen_vtrampoline_125 + .quad splitscreen_vtrampoline_126 + .quad splitscreen_vtrampoline_127 + .quad splitscreen_vtrampoline_128 + .quad splitscreen_vtrampoline_129 + .quad splitscreen_vtrampoline_130 + .quad splitscreen_vtrampoline_131 + .quad splitscreen_vtrampoline_132 + .quad splitscreen_vtrampoline_133 + .quad splitscreen_vtrampoline_134 + .quad splitscreen_vtrampoline_135 + .quad splitscreen_vtrampoline_136 + .quad splitscreen_vtrampoline_137 + .quad splitscreen_vtrampoline_138 + .quad splitscreen_vtrampoline_139 + .quad splitscreen_vtrampoline_140 + .quad splitscreen_vtrampoline_141 + .quad splitscreen_vtrampoline_142 + .quad splitscreen_vtrampoline_143 + .quad splitscreen_vtrampoline_144 + .quad splitscreen_vtrampoline_145 + .quad splitscreen_vtrampoline_146 + .quad splitscreen_vtrampoline_147 + .quad splitscreen_vtrampoline_148 + .quad splitscreen_vtrampoline_149 + .quad splitscreen_vtrampoline_150 + .quad splitscreen_vtrampoline_151 + .quad splitscreen_vtrampoline_152 + .quad splitscreen_vtrampoline_153 + .quad splitscreen_vtrampoline_154 + .quad splitscreen_vtrampoline_155 + .quad splitscreen_vtrampoline_156 + .quad splitscreen_vtrampoline_157 + .quad splitscreen_vtrampoline_158 + .quad splitscreen_vtrampoline_159 + .quad splitscreen_vtrampoline_160 + .quad splitscreen_vtrampoline_161 + .quad splitscreen_vtrampoline_162 + .quad splitscreen_vtrampoline_163 + .quad splitscreen_vtrampoline_164 + .quad splitscreen_vtrampoline_165 + .quad splitscreen_vtrampoline_166 + .quad splitscreen_vtrampoline_167 + .quad splitscreen_vtrampoline_168 + .quad splitscreen_vtrampoline_169 + .quad splitscreen_vtrampoline_170 + .quad splitscreen_vtrampoline_171 + .quad splitscreen_vtrampoline_172 + .quad splitscreen_vtrampoline_173 + .quad splitscreen_vtrampoline_174 + .quad splitscreen_vtrampoline_175 + .quad splitscreen_vtrampoline_176 + .quad splitscreen_vtrampoline_177 + .quad splitscreen_vtrampoline_178 + .quad splitscreen_vtrampoline_179 + .quad splitscreen_vtrampoline_180 + .quad splitscreen_vtrampoline_181 + .quad splitscreen_vtrampoline_182 + .quad splitscreen_vtrampoline_183 + .quad splitscreen_vtrampoline_184 + .quad splitscreen_vtrampoline_185 + .quad splitscreen_vtrampoline_186 + .quad splitscreen_vtrampoline_187 + .quad splitscreen_vtrampoline_188 + .quad splitscreen_vtrampoline_189 + .quad splitscreen_vtrampoline_190 + .quad splitscreen_vtrampoline_191 + .quad splitscreen_vtrampoline_192 + .quad splitscreen_vtrampoline_193 + .quad splitscreen_vtrampoline_194 + .quad splitscreen_vtrampoline_195 + .quad splitscreen_vtrampoline_196 + .quad splitscreen_vtrampoline_197 + .quad splitscreen_vtrampoline_198 + .quad splitscreen_vtrampoline_199 + .quad splitscreen_vtrampoline_200 + .quad splitscreen_vtrampoline_201 + .quad splitscreen_vtrampoline_202 + .quad splitscreen_vtrampoline_203 + .quad splitscreen_vtrampoline_204 + .quad splitscreen_vtrampoline_205 + .quad splitscreen_vtrampoline_206 + .quad splitscreen_vtrampoline_207 + .quad splitscreen_vtrampoline_208 + .quad splitscreen_vtrampoline_209 + .quad splitscreen_vtrampoline_210 + .quad splitscreen_vtrampoline_211 + .quad splitscreen_vtrampoline_212 + .quad splitscreen_vtrampoline_213 + .quad splitscreen_vtrampoline_214 + .quad splitscreen_vtrampoline_215 + .quad splitscreen_vtrampoline_216 + .quad splitscreen_vtrampoline_217 + .quad splitscreen_vtrampoline_218 + .quad splitscreen_vtrampoline_219 + .quad splitscreen_vtrampoline_220 + .quad splitscreen_vtrampoline_221 + .quad splitscreen_vtrampoline_222 + .quad splitscreen_vtrampoline_223 + .quad splitscreen_vtrampoline_224 + .quad splitscreen_vtrampoline_225 + .quad splitscreen_vtrampoline_226 + .quad splitscreen_vtrampoline_227 + .quad splitscreen_vtrampoline_228 + .quad splitscreen_vtrampoline_229 + .quad splitscreen_vtrampoline_230 + .quad splitscreen_vtrampoline_231 + .quad splitscreen_vtrampoline_232 + .quad splitscreen_vtrampoline_233 + .quad splitscreen_vtrampoline_234 + .quad splitscreen_vtrampoline_235 + .quad splitscreen_vtrampoline_236 + .quad splitscreen_vtrampoline_237 + .quad splitscreen_vtrampoline_238 + .quad splitscreen_vtrampoline_239 + .quad splitscreen_vtrampoline_240 + .quad splitscreen_vtrampoline_241 + .quad splitscreen_vtrampoline_242 + .quad splitscreen_vtrampoline_243 + .quad splitscreen_vtrampoline_244 + .quad splitscreen_vtrampoline_245 + .quad splitscreen_vtrampoline_246 + .quad splitscreen_vtrampoline_247 + .quad splitscreen_vtrampoline_248 + .quad splitscreen_vtrampoline_249 + .quad splitscreen_vtrampoline_250 + .quad splitscreen_vtrampoline_251 + .quad splitscreen_vtrampoline_252 + .quad splitscreen_vtrampoline_253 + .quad splitscreen_vtrampoline_254 + .quad splitscreen_vtrampoline_255 + .quad splitscreen_vtrampoline_256 + .quad splitscreen_vtrampoline_257 + .quad splitscreen_vtrampoline_258 + .quad splitscreen_vtrampoline_259 + .quad splitscreen_vtrampoline_260 + .quad splitscreen_vtrampoline_261 + .quad splitscreen_vtrampoline_262 + .quad splitscreen_vtrampoline_263 + .quad splitscreen_vtrampoline_264 + .quad splitscreen_vtrampoline_265 + .quad splitscreen_vtrampoline_266 + .quad splitscreen_vtrampoline_267 + .quad splitscreen_vtrampoline_268 + .quad splitscreen_vtrampoline_269 + .quad splitscreen_vtrampoline_270 + .quad splitscreen_vtrampoline_271 + .quad splitscreen_vtrampoline_272 + .quad splitscreen_vtrampoline_273 + .quad splitscreen_vtrampoline_274 + .quad splitscreen_vtrampoline_275 + .quad splitscreen_vtrampoline_276 + .quad splitscreen_vtrampoline_277 + .quad splitscreen_vtrampoline_278 + .quad splitscreen_vtrampoline_279 + .quad splitscreen_vtrampoline_280 + .quad splitscreen_vtrampoline_281 + .quad splitscreen_vtrampoline_282 + .quad splitscreen_vtrampoline_283 + .quad splitscreen_vtrampoline_284 + .quad splitscreen_vtrampoline_285 + .quad splitscreen_vtrampoline_286 + .quad splitscreen_vtrampoline_287 + .quad splitscreen_vtrampoline_288 + .quad splitscreen_vtrampoline_289 + .quad splitscreen_vtrampoline_290 + .quad splitscreen_vtrampoline_291 + .quad splitscreen_vtrampoline_292 + .quad splitscreen_vtrampoline_293 + .quad splitscreen_vtrampoline_294 + .quad splitscreen_vtrampoline_295 + .quad splitscreen_vtrampoline_296 + .quad splitscreen_vtrampoline_297 + .quad splitscreen_vtrampoline_298 + .quad splitscreen_vtrampoline_299 + .quad splitscreen_vtrampoline_300 + .quad splitscreen_vtrampoline_301 + .quad splitscreen_vtrampoline_302 + .quad splitscreen_vtrampoline_303 + .quad splitscreen_vtrampoline_304 + .quad splitscreen_vtrampoline_305 + .quad splitscreen_vtrampoline_306 + .quad splitscreen_vtrampoline_307 + .quad splitscreen_vtrampoline_308 + .quad splitscreen_vtrampoline_309 + .quad splitscreen_vtrampoline_310 + .quad splitscreen_vtrampoline_311 + .quad splitscreen_vtrampoline_312 + .quad splitscreen_vtrampoline_313 + .quad splitscreen_vtrampoline_314 + .quad splitscreen_vtrampoline_315 + .quad splitscreen_vtrampoline_316 + .quad splitscreen_vtrampoline_317 + .quad splitscreen_vtrampoline_318 + .quad splitscreen_vtrampoline_319 + .quad splitscreen_vtrampoline_320 + .quad splitscreen_vtrampoline_321 + .quad splitscreen_vtrampoline_322 + .quad splitscreen_vtrampoline_323 + .quad splitscreen_vtrampoline_324 + .quad splitscreen_vtrampoline_325 + .quad splitscreen_vtrampoline_326 + .quad splitscreen_vtrampoline_327 + .quad splitscreen_vtrampoline_328 + .quad splitscreen_vtrampoline_329 + .quad splitscreen_vtrampoline_330 + .quad splitscreen_vtrampoline_331 + .quad splitscreen_vtrampoline_332 + .quad splitscreen_vtrampoline_333 + .quad splitscreen_vtrampoline_334 + .quad splitscreen_vtrampoline_335 + .quad splitscreen_vtrampoline_336 + .quad splitscreen_vtrampoline_337 + .quad splitscreen_vtrampoline_338 + .quad splitscreen_vtrampoline_339 + .quad splitscreen_vtrampoline_340 + .quad splitscreen_vtrampoline_341 + .quad splitscreen_vtrampoline_342 + .quad splitscreen_vtrampoline_343 + .quad splitscreen_vtrampoline_344 + .quad splitscreen_vtrampoline_345 + .quad splitscreen_vtrampoline_346 + .quad splitscreen_vtrampoline_347 + .quad splitscreen_vtrampoline_348 + .quad splitscreen_vtrampoline_349 + .quad splitscreen_vtrampoline_350 + .quad splitscreen_vtrampoline_351 + .quad splitscreen_vtrampoline_352 + .quad splitscreen_vtrampoline_353 + .quad splitscreen_vtrampoline_354 + .quad splitscreen_vtrampoline_355 + .quad splitscreen_vtrampoline_356 + .quad splitscreen_vtrampoline_357 + .quad splitscreen_vtrampoline_358 + .quad splitscreen_vtrampoline_359 + .quad splitscreen_vtrampoline_360 + .quad splitscreen_vtrampoline_361 + .quad splitscreen_vtrampoline_362 + .quad splitscreen_vtrampoline_363 + .quad splitscreen_vtrampoline_364 + .quad splitscreen_vtrampoline_365 + .quad splitscreen_vtrampoline_366 + .quad splitscreen_vtrampoline_367 + .quad splitscreen_vtrampoline_368 + .quad splitscreen_vtrampoline_369 + .quad splitscreen_vtrampoline_370 + .quad splitscreen_vtrampoline_371 + .quad splitscreen_vtrampoline_372 + .quad splitscreen_vtrampoline_373 + .quad splitscreen_vtrampoline_374 + .quad splitscreen_vtrampoline_375 + .quad splitscreen_vtrampoline_376 + .quad splitscreen_vtrampoline_377 + .quad splitscreen_vtrampoline_378 + .quad splitscreen_vtrampoline_379 + .quad splitscreen_vtrampoline_380 + .quad splitscreen_vtrampoline_381 + .quad splitscreen_vtrampoline_382 + .quad splitscreen_vtrampoline_383 + .quad splitscreen_vtrampoline_384 + .quad splitscreen_vtrampoline_385 + .quad splitscreen_vtrampoline_386 + .quad splitscreen_vtrampoline_387 + .quad splitscreen_vtrampoline_388 + .quad splitscreen_vtrampoline_389 + .quad splitscreen_vtrampoline_390 + .quad splitscreen_vtrampoline_391 + .quad splitscreen_vtrampoline_392 + .quad splitscreen_vtrampoline_393 + .quad splitscreen_vtrampoline_394 + .quad splitscreen_vtrampoline_395 + .quad splitscreen_vtrampoline_396 + .quad splitscreen_vtrampoline_397 + .quad splitscreen_vtrampoline_398 + .quad splitscreen_vtrampoline_399 + .quad splitscreen_vtrampoline_400 + .quad splitscreen_vtrampoline_401 + .quad splitscreen_vtrampoline_402 + .quad splitscreen_vtrampoline_403 + .quad splitscreen_vtrampoline_404 + .quad splitscreen_vtrampoline_405 + .quad splitscreen_vtrampoline_406 + .quad splitscreen_vtrampoline_407 + .quad splitscreen_vtrampoline_408 + .quad splitscreen_vtrampoline_409 + .quad splitscreen_vtrampoline_410 + .quad splitscreen_vtrampoline_411 + .quad splitscreen_vtrampoline_412 + .quad splitscreen_vtrampoline_413 + .quad splitscreen_vtrampoline_414 + .quad splitscreen_vtrampoline_415 + .quad splitscreen_vtrampoline_416 + .quad splitscreen_vtrampoline_417 + .quad splitscreen_vtrampoline_418 + .quad splitscreen_vtrampoline_419 + .quad splitscreen_vtrampoline_420 + .quad splitscreen_vtrampoline_421 + .quad splitscreen_vtrampoline_422 + .quad splitscreen_vtrampoline_423 + .quad splitscreen_vtrampoline_424 + .quad splitscreen_vtrampoline_425 + .quad splitscreen_vtrampoline_426 + .quad splitscreen_vtrampoline_427 + .quad splitscreen_vtrampoline_428 + .quad splitscreen_vtrampoline_429 + .quad splitscreen_vtrampoline_430 + .quad splitscreen_vtrampoline_431 + .quad splitscreen_vtrampoline_432 + .quad splitscreen_vtrampoline_433 + .quad splitscreen_vtrampoline_434 + .quad splitscreen_vtrampoline_435 + .quad splitscreen_vtrampoline_436 + .quad splitscreen_vtrampoline_437 + .quad splitscreen_vtrampoline_438 + .quad splitscreen_vtrampoline_439 + .quad splitscreen_vtrampoline_440 + .quad splitscreen_vtrampoline_441 + .quad splitscreen_vtrampoline_442 + .quad splitscreen_vtrampoline_443 + .quad splitscreen_vtrampoline_444 + .quad splitscreen_vtrampoline_445 + .quad splitscreen_vtrampoline_446 + .quad splitscreen_vtrampoline_447 + .quad splitscreen_vtrampoline_448 + .quad splitscreen_vtrampoline_449 + .quad splitscreen_vtrampoline_450 + .quad splitscreen_vtrampoline_451 + .quad splitscreen_vtrampoline_452 + .quad splitscreen_vtrampoline_453 + .quad splitscreen_vtrampoline_454 + .quad splitscreen_vtrampoline_455 + .quad splitscreen_vtrampoline_456 + .quad splitscreen_vtrampoline_457 + .quad splitscreen_vtrampoline_458 + .quad splitscreen_vtrampoline_459 + .quad splitscreen_vtrampoline_460 + .quad splitscreen_vtrampoline_461 + .quad splitscreen_vtrampoline_462 + .quad splitscreen_vtrampoline_463 + .quad splitscreen_vtrampoline_464 + .quad splitscreen_vtrampoline_465 + .quad splitscreen_vtrampoline_466 + .quad splitscreen_vtrampoline_467 + .quad splitscreen_vtrampoline_468 + .quad splitscreen_vtrampoline_469 + .quad splitscreen_vtrampoline_470 + .quad splitscreen_vtrampoline_471 + .quad splitscreen_vtrampoline_472 + .quad splitscreen_vtrampoline_473 + .quad splitscreen_vtrampoline_474 + .quad splitscreen_vtrampoline_475 + .quad splitscreen_vtrampoline_476 + .quad splitscreen_vtrampoline_477 + .quad splitscreen_vtrampoline_478 + .quad splitscreen_vtrampoline_479 + .quad splitscreen_vtrampoline_480 + .quad splitscreen_vtrampoline_481 + .quad splitscreen_vtrampoline_482 + .quad splitscreen_vtrampoline_483 + .quad splitscreen_vtrampoline_484 + .quad splitscreen_vtrampoline_485 + .quad splitscreen_vtrampoline_486 + .quad splitscreen_vtrampoline_487 + .quad splitscreen_vtrampoline_488 + .quad splitscreen_vtrampoline_489 + .quad splitscreen_vtrampoline_490 + .quad splitscreen_vtrampoline_491 + .quad splitscreen_vtrampoline_492 + .quad splitscreen_vtrampoline_493 + .quad splitscreen_vtrampoline_494 + .quad splitscreen_vtrampoline_495 + .quad splitscreen_vtrampoline_496 + .quad splitscreen_vtrampoline_497 + .quad splitscreen_vtrampoline_498 + .quad splitscreen_vtrampoline_499 + .quad splitscreen_vtrampoline_500 + .quad splitscreen_vtrampoline_501 + .quad splitscreen_vtrampoline_502 + .quad splitscreen_vtrampoline_503 + .quad splitscreen_vtrampoline_504 + .quad splitscreen_vtrampoline_505 + .quad splitscreen_vtrampoline_506 + .quad splitscreen_vtrampoline_507 + .quad splitscreen_vtrampoline_508 + .quad splitscreen_vtrampoline_509 + .quad splitscreen_vtrampoline_510 + .quad splitscreen_vtrampoline_511 + .quad splitscreen_vtrampoline_512 + .quad splitscreen_vtrampoline_513 + .quad splitscreen_vtrampoline_514 + .quad splitscreen_vtrampoline_515 + .quad splitscreen_vtrampoline_516 + .quad splitscreen_vtrampoline_517 + .quad splitscreen_vtrampoline_518 + .quad splitscreen_vtrampoline_519 + .quad splitscreen_vtrampoline_520 + .quad splitscreen_vtrampoline_521 + .quad splitscreen_vtrampoline_522 + .quad splitscreen_vtrampoline_523 + .quad splitscreen_vtrampoline_524 + .quad splitscreen_vtrampoline_525 + .quad splitscreen_vtrampoline_526 + .quad splitscreen_vtrampoline_527 + .quad splitscreen_vtrampoline_528 + .quad splitscreen_vtrampoline_529 + .quad splitscreen_vtrampoline_530 + .quad splitscreen_vtrampoline_531 + .quad splitscreen_vtrampoline_532 + .quad splitscreen_vtrampoline_533 + .quad splitscreen_vtrampoline_534 + .quad splitscreen_vtrampoline_535 + .quad splitscreen_vtrampoline_536 + .quad splitscreen_vtrampoline_537 + .quad splitscreen_vtrampoline_538 + .quad splitscreen_vtrampoline_539 + .quad splitscreen_vtrampoline_540 + .quad splitscreen_vtrampoline_541 + .quad splitscreen_vtrampoline_542 + .quad splitscreen_vtrampoline_543 + .quad splitscreen_vtrampoline_544 + .quad splitscreen_vtrampoline_545 + .quad splitscreen_vtrampoline_546 + .quad splitscreen_vtrampoline_547 + .quad splitscreen_vtrampoline_548 + .quad splitscreen_vtrampoline_549 + .quad splitscreen_vtrampoline_550 + .quad splitscreen_vtrampoline_551 + .quad splitscreen_vtrampoline_552 + .quad splitscreen_vtrampoline_553 + .quad splitscreen_vtrampoline_554 + .quad splitscreen_vtrampoline_555 + .quad splitscreen_vtrampoline_556 + .quad splitscreen_vtrampoline_557 + .quad splitscreen_vtrampoline_558 + .quad splitscreen_vtrampoline_559 + .quad splitscreen_vtrampoline_560 + .quad splitscreen_vtrampoline_561 + .quad splitscreen_vtrampoline_562 + .quad splitscreen_vtrampoline_563 + .quad splitscreen_vtrampoline_564 + .quad splitscreen_vtrampoline_565 + .quad splitscreen_vtrampoline_566 + .quad splitscreen_vtrampoline_567 + .quad splitscreen_vtrampoline_568 + .quad splitscreen_vtrampoline_569 + .quad splitscreen_vtrampoline_570 + .quad splitscreen_vtrampoline_571 + .quad splitscreen_vtrampoline_572 + .quad splitscreen_vtrampoline_573 + .quad splitscreen_vtrampoline_574 + .quad splitscreen_vtrampoline_575 + .quad splitscreen_vtrampoline_576 + .quad splitscreen_vtrampoline_577 + .quad splitscreen_vtrampoline_578 + .quad splitscreen_vtrampoline_579 + .quad splitscreen_vtrampoline_580 + .quad splitscreen_vtrampoline_581 + .quad splitscreen_vtrampoline_582 + .quad splitscreen_vtrampoline_583 + .quad splitscreen_vtrampoline_584 + .quad splitscreen_vtrampoline_585 + .quad splitscreen_vtrampoline_586 + .quad splitscreen_vtrampoline_587 + .quad splitscreen_vtrampoline_588 + .quad splitscreen_vtrampoline_589 + .quad splitscreen_vtrampoline_590 + .quad splitscreen_vtrampoline_591 + .quad splitscreen_vtrampoline_592 + .quad splitscreen_vtrampoline_593 + .quad splitscreen_vtrampoline_594 + .quad splitscreen_vtrampoline_595 + .quad splitscreen_vtrampoline_596 + .quad splitscreen_vtrampoline_597 + .quad splitscreen_vtrampoline_598 + .quad splitscreen_vtrampoline_599 + + .section .note.GNU-stack, "", %progbits diff --git a/android/src/main.cpp b/android/src/main.cpp new file mode 100644 index 0000000..ba2fabc --- /dev/null +++ b/android/src/main.cpp @@ -0,0 +1,5 @@ +#include "SplitScreen.h" + +#include + +PL_REGISTER_MOD(splitscreen::SplitScreenMod, splitscreen::SplitScreenMod::instance());