From 04ba0f0354bb138e98113e941b8db4bdd315737f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:19:10 +0000 Subject: [PATCH 01/61] README: say the mod is Windows only and why the Android LeviLauncher rejects the zip Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4c8a0f2..927d360 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Minecraft Bedrock already contains split-screen (it is used on Xbox), but the Wi ## Installation +> **Windows only.** The mod does not work on Android. LeviLauncher for Android only takes mods built for Android (a `.so` file), and this mod is a Windows `.dll`. If you import the Windows zip there, it shows "Invalid ZIP Mod Package". + + **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:** From be673ec10613f2e038cd88689963cbbaf63d8be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 00:29:44 +0000 Subject: [PATCH 02/61] Android: first experimental version for LeviLauncher on Android A separate mod in android/ built with the preloader-android SDK, since the Windows dll cannot load on Android. It finds the game's classes by RTTI, logs their vtables, and turns allowSplitScreen on when the game exports its symbol; otherwise it lists the candidates in the log. A GitHub Actions workflow builds the arm64 package. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- .github/workflows/android.yml | 32 ++++ README.md | 3 +- android/CMakeLists.txt | 86 ++++++++++ android/README.md | 36 +++++ android/config/config.json | 3 + android/config/config.schema.json | 11 ++ android/manifest.json.in | 9 ++ android/src/Elf.cpp | 183 +++++++++++++++++++++ android/src/Elf.h | 64 ++++++++ android/src/SplitScreen.cpp | 261 ++++++++++++++++++++++++++++++ android/src/SplitScreen.h | 23 +++ android/src/main.cpp | 5 + 12 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 android/CMakeLists.txt create mode 100644 android/README.md create mode 100644 android/config/config.json create mode 100644 android/config/config.schema.json create mode 100644 android/manifest.json.in create mode 100644 android/src/Elf.cpp create mode 100644 android/src/Elf.h create mode 100644 android/src/SplitScreen.cpp create mode 100644 android/src/SplitScreen.h create mode 100644 android/src/main.cpp diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..6ceb62f --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,32 @@ +# 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 + steps: + - uses: actions/checkout@v7 + + - uses: android-actions/setup-android@v3 + + - run: | + sdkmanager "platforms;android-28" "ndk;28.2.13676358" + sudo apt-get update + sudo apt-get install -y ninja-build + + - run: | + 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 + + - uses: actions/upload-artifact@v7 + with: + name: ${{ github.event.repository.name }}-${{ github.sha }}-android-arm64 + path: build-android/package/ diff --git a/README.md b/README.md index 927d360..fcbb84b 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,7 @@ Minecraft Bedrock already contains split-screen (it is used on Xbox), but the Wi ## Installation -> **Windows only.** The mod does not work on Android. LeviLauncher for Android only takes mods built for Android (a `.so` file), and this mod is a Windows `.dll`. If you import the Windows zip there, it shows "Invalid ZIP Mod Package". - +> **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). diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 0000000..d13a68f --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,86 @@ +# 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) + +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.1.0" 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) +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} 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 and SplitScreenMod/libSplitScreenMod.so. +# 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" + 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..369ea05 --- /dev/null +++ b/android/README.md @@ -0,0 +1,36 @@ +# 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 + +Nothing about the Android game's code is known yet, so version 0.1 is a first step: + +1. It finds the game's classes by their RTTI names (`AppPlatform`, `AppPlatform_android`, `MinecraftGame`, ...) + and writes their virtual functions to `splitscreen.log`. The log is also copied to `Download/SplitScreenMod-android.log`. +2. If the game exports the `AppPlatform::allowSplitScreen` symbol, it turns split screen on, the same switch the + Windows mod uses. +3. Otherwise it leaves the game alone and lists the likely candidates in the log, so the next version can pick + the right one. + +Controllers, the player menu and the pause menu button of the Windows mod are not ported yet. + +## Install + +Download `SplitScreenMod-android-arm64.zip` from the Actions tab (the **Android** workflow) 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..aa2c0e6 --- /dev/null +++ b/android/config/config.json @@ -0,0 +1,3 @@ +{ + "forceSplitScreen": true +} diff --git a/android/config/config.schema.json b/android/config/config.schema.json new file mode 100644 index 0000000..5e34022 --- /dev/null +++ b/android/config/config.schema.json @@ -0,0 +1,11 @@ +{ + "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." + } + } +} 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/src/Elf.cpp b/android/src/Elf.cpp new file mode 100644 index 0000000..10a5167 --- /dev/null +++ b/android/src/Elf.cpp @@ -0,0 +1,183 @@ +#include "Elf.h" + +#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) { + if (!s.read || s.exec || s.write) continue; // type names live in .rodata + 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]); +} + +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..e0d42bd --- /dev/null +++ b/android/src/Elf.h @@ -0,0 +1,64 @@ +#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); + +// 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/SplitScreen.cpp b/android/src/SplitScreen.cpp new file mode 100644 index 0000000..b4502e1 --- /dev/null +++ b/android/src/SplitScreen.cpp @@ -0,0 +1,261 @@ +// 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 +#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; + +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; +} + +// The log is also copied to Download, where it is easy to find and send. +void copyLogToDownloads(std::filesystem::path const& log) { + std::error_code ignored; + for (char const* dir : {"/storage/emulated/0/Download", "/sdcard/Download"}) { + auto to = std::filesystem::path(dir) / "SplitScreenMod-android.log"; + if (std::filesystem::copy_file(log, to, std::filesystem::copy_options::overwrite_existing, ignored)) { + note("log copied to {}", to.string()); + return; + } + } + note("could not copy the log to Download; it stays at {}", log.string()); +} + +// 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"}, +}; + +// On Windows allowSplitScreen is the 85th virtual function declared by AppPlatform, counting its destructor once. +// The Itanium ABI gives the destructor two slots, so on Android the slot should be near 85; the log lists the +// constant-returning functions around it. +constexpr size_t kExpectedAllowSplitScreenSlot = 85; + +bool allowSplitScreenDetour(void*) { return true; } + +std::map gPatched; // slot -> original + +// 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); + } + } +} + +void dumpVtable(Module const& m, ClassInfo const& c, uintptr_t* vt, uintptr_t* base, size_t from, size_t to) { + size_t n = countSlots(m, vt, 2000); + note(" vtable at +0x{:X}, {} slots", reinterpret_cast(vt) - m.base, n); + for (size_t i = from; i < std::min(n, to); ++i) { + if (base && base != vt && i < countSlots(m, base, 2000) && base[i] == vt[i]) continue; // inherited, unchanged + note(" [{}] +0x{:X} {}", i, vt[i] - m.base, describeFunction(m, vt[i])); + } + (void)c; +} + +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); + } + + // 2. classes by RTTI + 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; + for (auto const& c : platforms) + for (auto* vt : c.vtables) { + note("class {}:", c.name); + dumpVtable(*game, c, vt, baseVt, 0, 2000); + } + + for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener", "GameControllerHandler", "GamepadManager"}) { + auto classes = findClasses(*game, needle); + note("{} classes named *{}*", classes.size(), needle); + for (auto const& c : classes) { + note("class {}: {} vtable(s)", c.name, c.vtables.size()); + if (std::string_view(needle) == "MinecraftGame" && c.name == "13MinecraftGame") + for (auto* vt : c.vtables) dumpVtable(*game, c, vt, nullptr, 0, 2000); + } + } + + // 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) { + std::vector candidates; + for (size_t i = kExpectedAllowSplitScreenSlot - 12; i < std::min(count, kExpectedAllowSplitScreenSlot + 12); ++i) + if (constantReturn(baseVt[i]) == 0u) candidates.push_back(i); + std::string list; + for (auto i : candidates) list += fmt::format(" {}", i); + note("allowSplitScreen not identified for certain; AppPlatform slots near {} that return false:{}", + kExpectedAllowSplitScreenSlot, list.empty() ? " none" : list); + note("split screen stays off in this version. Please send this log so the next version can turn it on."); + return; + } + if (!configFlag("forceSplitScreen", true)) return note("forceSplitScreen is off in config.json"); + note("turning split screen on (AppPlatform slot {})", slot); + patchSlotEverywhere(*game, platforms, count, slot, allowSplitScreen, "allowSplitScreen"); +} + +std::once_flag gStarted; + +} // 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); + note("SplitScreenMod for Android {} (experimental)", SPLITSCREEN_VERSION); + return true; +} + +bool SplitScreenMod::enable() { + std::call_once(gStarted, [this] { + std::thread([this] { + probe(); + copyLogToDownloads(getSelf().getModDir() / "splitscreen.log"); + }).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/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()); From 780ee9f56d10da79ef7c0b6992c8d43e24af87bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 06:50:35 +0000 Subject: [PATCH 03/61] Android CI: use the runner's Android SDK instead of android-actions/setup-android, which failed installing sdkmanager Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- .github/workflows/android.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6ceb62f..b7b35e6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,16 +12,22 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: android-actions/setup-android@v3 - + # 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: | - sdkmanager "platforms;android-28" "ndk;28.2.13676358" 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="$ANDROID_HOME/ndk/28.2.13676358/build/cmake/android.toolchain.cmake" \ + -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 From c5972a76eddca357357cb4fd6a8498998f7805bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 07:09:29 +0000 Subject: [PATCH 04/61] Android 0.1.1: search type names in the game's executable segment too The log from a device showed libminecraftpe.so has .rodata in its first r-x segment, so 0.1.0 found no classes. Also log PlayerListPacket as a check that the RTTI search works. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/Elf.cpp | 4 +++- android/src/SplitScreen.cpp | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index d13a68f..6cd6f43 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.1" 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") diff --git a/android/src/Elf.cpp b/android/src/Elf.cpp index 10a5167..2ec5150 100644 --- a/android/src/Elf.cpp +++ b/android/src/Elf.cpp @@ -65,7 +65,9 @@ static bool looksLikeTypeName(std::string_view s) { std::vector findTypeNames(Module const& m, std::string_view needle) { std::vector out; for (auto const& s : m.segments) { - if (!s.read || s.exec || s.write) continue; // type names live in .rodata + // 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;) { diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index b4502e1..dbc5357 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -167,7 +167,10 @@ void probe() { if (addr && std::string_view(k.label).ends_with("::allowSplitScreen")) allowSplitScreenFns.insert(addr); } - // 2. classes by RTTI + // 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; From 76ef7fa63ee0b23dae71524d737b76e5789ca15f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 07:45:06 +0000 Subject: [PATCH 05/61] Android 0.1.2: turn split screen on (AppPlatform::allowSplitScreen, slot 105) The device log showed the AppPlatform vtable. Aligned with the virtual function order in LeviLamina's AppPlatform.h, its pure virtual slots 77, 79 and 81 are swapBuffers, getSystemLocale and pickImage, so allowSplitScreen is slot 105; slot 106 starts with fmov s0,#1.0 as splitScreenRenderDistScalar(numPlayers) should. AppPlatform_android does not override it. The mod finds the slot this way at runtime and makes it return true, logging what the game alone would have answered. The log no longer dumps every class with AppPlatform in its name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/README.md | 14 ++++---- android/src/Elf.cpp | 6 ++++ android/src/Elf.h | 3 ++ android/src/SplitScreen.cpp | 66 +++++++++++++++++++++++++++++-------- 5 files changed, 69 insertions(+), 22 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 6cd6f43..be9cbc3 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.2" 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") diff --git a/android/README.md b/android/README.md index 369ea05..8116ba1 100644 --- a/android/README.md +++ b/android/README.md @@ -6,14 +6,12 @@ built with LeviLauncher's [preloader-android](https://github.com/LiteLDev/preloa ## What this version does -Nothing about the Android game's code is known yet, so version 0.1 is a first step: - -1. It finds the game's classes by their RTTI names (`AppPlatform`, `AppPlatform_android`, `MinecraftGame`, ...) - and writes their virtual functions to `splitscreen.log`. The log is also copied to `Download/SplitScreenMod-android.log`. -2. If the game exports the `AppPlatform::allowSplitScreen` symbol, it turns split screen on, the same switch the - Windows mod uses. -3. Otherwise it leaves the game alone and lists the likely candidates in the log, so the next version can pick - the right one. +Version 0.1.2 turns split screen on: it finds `AppPlatform::allowSplitScreen` in the game's vtable (slot 105 in +1.26) and makes it answer true. The slot is found from the order of AppPlatform's virtual functions, not from a +fixed number: three pure virtual functions two slots apart (`swapBuffers`, `getSystemLocale`, `pickImage`), then +`allowSplitScreen` 28 slots later, confirmed by the next function `splitScreenRenderDistScalar`. + +Everything it does is written to `splitscreen.log`, which is also copied to `Download/SplitScreenMod-android.log`. Controllers, the player menu and the pause menu button of the Windows mod are not ported yet. diff --git a/android/src/Elf.cpp b/android/src/Elf.cpp index 2ec5150..eda45cf 100644 --- a/android/src/Elf.cpp +++ b/android/src/Elf.cpp @@ -173,6 +173,12 @@ std::string describeFunction(Module const& m, uintptr_t fn) { return out + fmt::format("{:08X} {:08X} {:08X}", code[0], code[1], code[2]); } +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); diff --git a/android/src/Elf.h b/android/src/Elf.h index e0d42bd..9b0236b 100644 --- a/android/src/Elf.h +++ b/android/src/Elf.h @@ -58,6 +58,9 @@ 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); +// 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index dbc5357..0689532 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -105,15 +106,49 @@ constexpr Known kKnown[] = { {"MinecraftGame::addSubClientInstanceAndAssociateToUser", "_ZN13MinecraftGame38addSubClientInstanceAndAssociateToUserEi"}, }; -// On Windows allowSplitScreen is the 85th virtual function declared by AppPlatform, counting its destructor once. -// The Itanium ABI gives the destructor two slots, so on Android the slot should be near 85; the log lists the -// constant-returning functions around it. -constexpr size_t kExpectedAllowSplitScreenSlot = 85; +// 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; +} -bool allowSplitScreenDetour(void*) { return true; } +using AllowSplitScreenFn = bool (*)(void*); +AllowSplitScreenFn gOriginalAllowSplitScreen = nullptr; std::map gPatched; // slot -> original +bool allowSplitScreenDetour(void* self) { + 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. @@ -180,8 +215,11 @@ void probe() { 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 {}:", c.name); dumpVtable(*game, c, vt, baseVt, 0, 2000); } @@ -190,6 +228,7 @@ void probe() { auto classes = findClasses(*game, needle); note("{} classes named *{}*", classes.size(), needle); for (auto const& c : classes) { + if (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()); if (std::string_view(needle) == "MinecraftGame" && c.name == "13MinecraftGame") for (auto* vt : c.vtables) dumpVtable(*game, c, vt, nullptr, 0, 2000); @@ -211,17 +250,18 @@ void probe() { note("allowSplitScreen by symbol: slot {}", slot == SIZE_MAX ? std::string("not in the vtable") : std::to_string(slot)); } if (slot == SIZE_MAX) { - std::vector candidates; - for (size_t i = kExpectedAllowSplitScreenSlot - 12; i < std::min(count, kExpectedAllowSplitScreenSlot + 12); ++i) - if (constantReturn(baseVt[i]) == 0u) candidates.push_back(i); - std::string list; - for (auto i : candidates) list += fmt::format(" {}", i); - note("allowSplitScreen not identified for certain; AppPlatform slots near {} that return false:{}", - kExpectedAllowSplitScreenSlot, list.empty() ? " none" : list); - note("split screen stays off in this version. Please send this log so the next version can turn it on."); + 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"); } From 7ebe41e54423fe84bbdfc442ba0db1d89cb2ad29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:02:48 +0000 Subject: [PATCH 06/61] Android 0.1.3: write the Download log as it happens, count allowSplitScreen calls, log gamepad keys 0.1.2's log showed allowSplitScreen patched at slot 105, but the Download copy was made once right after patching, so it could not show whether the game ever asked. Now every line goes to both logs, the number of allowSplitScreen calls is logged every 10 seconds, and gamepad button key events reaching the game are logged (without consuming them). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 57 +++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index be9cbc3..198906e 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 0689532..aac8c3c 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -11,8 +11,11 @@ #include "Elf.h" +#include + #include +#include #include #include #include @@ -35,6 +38,7 @@ 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) { @@ -42,19 +46,20 @@ void note(fmt::format_string f, Args&&... args) { SplitScreenMod::instance().getSelf().getLogger().info("{}", line); std::lock_guard lock(gLogMutex); if (gLog) gLog << line << std::endl; + if (gDownloadLog) gDownloadLog << line << std::endl; } -// The log is also copied to Download, where it is easy to find and send. -void copyLogToDownloads(std::filesystem::path const& log) { - std::error_code ignored; +// 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 to = std::filesystem::path(dir) / "SplitScreenMod-android.log"; - if (std::filesystem::copy_file(log, to, std::filesystem::copy_options::overwrite_existing, ignored)) { - note("log copied to {}", to.string()); - return; - } + 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(); } - note("could not copy the log to Download; it stays at {}", log.string()); } // config/config.json: { "forceSplitScreen": true } @@ -140,7 +145,10 @@ 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 {}", @@ -268,6 +276,22 @@ void probe() { std::once_flag gStarted; +// Android key codes of gamepad buttons (KeyEvent.KEYCODE_BUTTON_A = 96 ... KEYCODE_BUTTON_MODE = 110, +// KEYCODE_BUTTON_1..16 = 188..203). The log shows whether controller presses reach the game at all. +bool isGamepadKey(int code) { return (code >= 96 && code <= 110) || (code >= 188 && code <= 203); } + +void watchGame() { + using namespace std::chrono_literals; + uint64_t reported = UINT64_MAX; + for (int i = 0; i < 360; ++i) { // an hour + std::this_thread::sleep_for(10s); + uint64_t calls = gAllowSplitScreenCalls; + if (calls == reported) continue; + reported = calls; + note("allowSplitScreen asked {} time(s) so far", calls); + } +} + } // namespace SplitScreenMod& SplitScreenMod::instance() { @@ -281,15 +305,24 @@ bool SplitScreenMod::load() { 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); return true; } bool SplitScreenMod::enable() { - std::call_once(gStarted, [this] { - std::thread([this] { + std::call_once(gStarted, [] { + pl::input::registerKeyCallback([](pl::input::KeyEvent const& e) { + static std::atomic logged{0}; + if (isGamepadKey(e.keyCode) && logged < 200) { + ++logged; + note("gamepad key {} {}", e.keyCode, e.isKeyDown ? "down" : "up"); + } + return false; // the game still gets the key + }); + std::thread([] { probe(); - copyLogToDownloads(getSelf().getModDir() / "splitscreen.log"); + watchGame(); }).detach(); }); return true; From 60811d79bccee02d13805de69d79c554aa97d2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 12:17:19 +0000 Subject: [PATCH 07/61] Android 0.1.4: log the game's JNI functions for input 0.1.3 on a device: the game asks allowSplitScreen (it alone would answer false, the mod answers true) and gamepad keys reach the game, but every controller moves player 1. To see how Java hands controller input to the game, list the exported JNI functions about keys, controllers and input, read from the library's dynamic symbol table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/Elf.cpp | 60 +++++++++++++++++++++++++++++++++++++ android/src/Elf.h | 8 +++++ android/src/SplitScreen.cpp | 21 +++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 198906e..4f2e5a2 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.3" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.4" 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") diff --git a/android/src/Elf.cpp b/android/src/Elf.cpp index eda45cf..dfe666e 100644 --- a/android/src/Elf.cpp +++ b/android/src/Elf.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -173,6 +174,65 @@ std::string describeFunction(Module const& m, uintptr_t fn) { 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; +} + +std::vector exportedFunctions(Module const& m) { + std::vector out; + struct Ctx { + Module const* m; + std::vector* out; + } ctx{&m, &out}; + 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 = 0; k < n; ++k) { + auto const& sym = syms[k]; + if (ELF64_ST_TYPE(sym.st_info) != STT_FUNC || sym.st_shndx == SHN_UNDEF || !sym.st_value) continue; + c.out->push_back({reinterpret_cast(strtab + sym.st_name), info->dlpi_addr + sym.st_value}); + } + return 1; + } + return 1; + }, + &ctx + ); + return out; +} + bool isPureVirtual(uintptr_t fn) { Dl_info info{}; return fn && dladdr(reinterpret_cast(fn), &info) && info.dli_sname diff --git a/android/src/Elf.h b/android/src/Elf.h index 9b0236b..332df04 100644 --- a/android/src/Elf.h +++ b/android/src/Elf.h @@ -58,6 +58,14 @@ 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); + // True if fn is __cxa_pure_virtual (a pure virtual function's slot). bool isPureVirtual(uintptr_t fn); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index aac8c3c..5985c70 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -210,6 +211,26 @@ void probe() { 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); + } + // 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, From f710b262774a4cfca4b5f860b2322f520bc2f8a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:07:03 +0000 Subject: [PATCH 08/61] Android CI: publish the newest build as the android-latest pre-release, always at the same link Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- .github/workflows/android.yml | 13 +++++++++++++ android/README.md | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b7b35e6..14fcc22 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,6 +9,8 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write # to publish the android-latest pre-release steps: - uses: actions/checkout@v7 @@ -36,3 +38,14 @@ jobs: 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 \ + --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." diff --git a/android/README.md b/android/README.md index 8116ba1..8c19249 100644 --- a/android/README.md +++ b/android/README.md @@ -17,7 +17,7 @@ Controllers, the player menu and the pause menu button of the Windows mod are no ## Install -Download `SplitScreenMod-android-arm64.zip` from the Actions tab (the **Android** workflow) and add it in +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 From 77b557099a15be9399f06ea4e042815a36185841 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:09:02 +0000 Subject: [PATCH 09/61] Android 0.1.5: log which device each controller event comes from, and where the game reads it 0.1.4's log: no JNI function carries controller buttons; Java only reports devices added/removed (JellyBeanDeviceManager), so the game reads gamepads from the native input queue. This version swaps the game's GOT entry for AInputEvent_getDeviceId for a wrapper that logs each device's source, its gamepad keys, and every distinct call site in the game with the code around it. It also logs the code of the input JNI functions and of allowSplitScreen, to disassemble offline. Smaller log: no vtable dumps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 6 +- android/src/InputProbe.cpp | 106 ++++++++++++++++++++++++++++++++++++ android/src/InputProbe.h | 24 ++++++++ android/src/SplitScreen.cpp | 39 ++++--------- 4 files changed, 145 insertions(+), 30 deletions(-) create mode 100644 android/src/InputProbe.cpp create mode 100644 android/src/InputProbe.h diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 4f2e5a2..a791a34 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.4" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.5" 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") @@ -47,14 +47,14 @@ 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) +add_library(${MOD_LIBRARY_NAME} SHARED src/main.cpp src/SplitScreen.cpp src/Elf.cpp src/InputProbe.cpp) 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} dl fmt::fmt) +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() diff --git a/android/src/InputProbe.cpp b/android/src/InputProbe.cpp new file mode 100644 index 0000000..a226612 --- /dev/null +++ b/android/src/InputProbe.cpp @@ -0,0 +1,106 @@ +#include "InputProbe.h" + +#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; +} + +} // namespace + +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..9e81073 --- /dev/null +++ b/android/src/InputProbe.h @@ -0,0 +1,24 @@ +#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); + +// 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/SplitScreen.cpp b/android/src/SplitScreen.cpp index 5985c70..44e63d2 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -10,8 +10,8 @@ #include "SplitScreen.h" #include "Elf.h" +#include "InputProbe.h" -#include #include @@ -181,16 +181,6 @@ void patchSlotEverywhere(Module const& m, std::vector const& classes, } } -void dumpVtable(Module const& m, ClassInfo const& c, uintptr_t* vt, uintptr_t* base, size_t from, size_t to) { - size_t n = countSlots(m, vt, 2000); - note(" vtable at +0x{:X}, {} slots", reinterpret_cast(vt) - m.base, n); - for (size_t i = from; i < std::min(n, to); ++i) { - if (base && base != vt && i < countSlots(m, base, 2000) && base[i] == vt[i]) continue; // inherited, unchanged - note(" [{}] +0x{:X} {}", i, vt[i] - m.base, describeFunction(m, vt[i])); - } - (void)c; -} - void probe() { using namespace std::chrono_literals; std::optional game; @@ -229,7 +219,15 @@ void probe() { } } note("{} JNI functions in all", jni); + // the code of the input JNI functions, to read offline + for (auto const& e : exports) + for (char const* name : {"MainActivity_nativeKeyHandler", "JellyBeanDeviceManager_onInputDeviceAddedNative", + "JellyBeanDeviceManager_onInputDeviceRemovedNative", + "JellyBeanDeviceManager_setControllerDetailsNative", + "JellyBeanDeviceManager_onInputDeviceChangedNative"}) + if (e.name.ends_with(name)) dumpCode(*game, name, e.address, 512, [](std::string const& l) { note("{}", l); }); } + startInputProbe(*game, [](std::string const& l) { note("{}", l); }); // 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")) @@ -249,18 +247,16 @@ void probe() { for (auto const& c : platforms) for (auto* vt : c.vtables) { if (countSlots(*game, vt, 2000) < baseCount) continue; - note("class {}:", c.name); - dumpVtable(*game, c, vt, baseVt, 0, 2000); + note("class {}: {} slots", c.name, countSlots(*game, vt, 2000)); } for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener", "GameControllerHandler", "GamepadManager"}) { 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 (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()); - if (std::string_view(needle) == "MinecraftGame" && c.name == "13MinecraftGame") - for (auto* vt : c.vtables) dumpVtable(*game, c, vt, nullptr, 0, 2000); } } @@ -292,15 +288,12 @@ void probe() { if (!configFlag("forceSplitScreen", true)) return note("forceSplitScreen is off in config.json"); gOriginalAllowSplitScreen = reinterpret_cast(allowSplitScreen); note("turning split screen on (AppPlatform slot {})", slot); + dumpCode(*game, "allowSplitScreen", allowSplitScreen, 256, [](std::string const& l) { note("{}", l); }); patchSlotEverywhere(*game, platforms, count, slot, allowSplitScreen, "allowSplitScreen"); } std::once_flag gStarted; -// Android key codes of gamepad buttons (KeyEvent.KEYCODE_BUTTON_A = 96 ... KEYCODE_BUTTON_MODE = 110, -// KEYCODE_BUTTON_1..16 = 188..203). The log shows whether controller presses reach the game at all. -bool isGamepadKey(int code) { return (code >= 96 && code <= 110) || (code >= 188 && code <= 203); } - void watchGame() { using namespace std::chrono_literals; uint64_t reported = UINT64_MAX; @@ -333,14 +326,6 @@ bool SplitScreenMod::load() { bool SplitScreenMod::enable() { std::call_once(gStarted, [] { - pl::input::registerKeyCallback([](pl::input::KeyEvent const& e) { - static std::atomic logged{0}; - if (isGamepadKey(e.keyCode) && logged < 200) { - ++logged; - note("gamepad key {} {}", e.keyCode, e.isKeyDown ? "down" : "up"); - } - return false; // the game still gets the key - }); std::thread([] { probe(); watchGame(); From 5574e59f77897b16cf40a3d0989ee26ba7144e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:18:40 +0000 Subject: [PATCH 10/61] Android 0.1.6: log the game's input imports and controller classes 0.1.5's log: allowSplitScreen on Android is a real check (two AppPlatform calls, slots 218 and 230, against a threshold near 2.2e9, likely memory), so the game has split screen and keeps it off on this device. AInputEvent_getDeviceId is not in the game's GOT, nativeKeyHandler only handles the back key, and onInputDeviceAdded passes the device id to a native device manager. To find where gamepad input is read, log the Android input functions the game imports (AInput*, AKeyEvent*, AMotionEvent*, Paddleboat*, GameActivity*) and the RTTI names of its gamepad/controller/device classes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/Elf.cpp | 33 +++++++++++++++++++++++---------- android/src/Elf.h | 3 +++ android/src/SplitScreen.cpp | 30 +++++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index a791a34..71600ee 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.5" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.6" 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") diff --git a/android/src/Elf.cpp b/android/src/Elf.cpp index dfe666e..c15c1a9 100644 --- a/android/src/Elf.cpp +++ b/android/src/Elf.cpp @@ -192,12 +192,13 @@ static size_t dynamicSymbolCount(uintptr_t hash, uintptr_t gnuHash) { return last + 1; } -std::vector exportedFunctions(Module const& m) { - std::vector out; +// 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; - std::vector* out; - } ctx{&m, &out}; + 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); @@ -219,17 +220,29 @@ std::vector exportedFunctions(Module const& m) { if (!symtab || !strtab) return 1; size_t n = dynamicSymbolCount(hash, gnuHash); auto* syms = reinterpret_cast(symtab); - for (size_t k = 0; k < n; ++k) { - auto const& sym = syms[k]; - if (ELF64_ST_TYPE(sym.st_info) != STT_FUNC || sym.st_shndx == SHN_UNDEF || !sym.st_value) continue; - c.out->push_back({reinterpret_cast(strtab + sym.st_name), info->dlpi_addr + sym.st_value}); - } + 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; } diff --git a/android/src/Elf.h b/android/src/Elf.h index 332df04..02f7e67 100644 --- a/android/src/Elf.h +++ b/android/src/Elf.h @@ -66,6 +66,9 @@ struct Symbol { // 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 44e63d2..d6dabc2 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -227,6 +227,22 @@ void probe() { "JellyBeanDeviceManager_onInputDeviceChangedNative"}) if (e.name.ends_with(name)) dumpCode(*game, name, e.address, 512, [](std::string const& l) { note("{}", l); }); } + // 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); + } startInputProbe(*game, [](std::string const& l) { note("{}", l); }); // 2. classes by RTTI. PlayerListPacket is a class every version has: if it is not found, the search itself failed. @@ -250,7 +266,19 @@ void probe() { note("class {}: {} slots", c.name, countSlots(*game, vt, 2000)); } - for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener", "GameControllerHandler", "GamepadManager"}) { + // the game's controller classes (names only) + for (char const* needle : {"Gamepad", "GamePad", "Controller", "InputDevice", "DeviceManager"}) { + int shown = 0; + for (auto const& t : findTypeNames(*game, needle)) { + if (t.name.starts_with("NSt6__ndk1") || t.name.find("Screen") != std::string::npos + || t.name.find("ZN") != std::string::npos) + continue; + if (++shown > 80) break; + note("type *{}*: {}", needle, t.name); + } + } + + for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener"}) { auto classes = findClasses(*game, needle); note("{} classes named *{}*", classes.size(), needle); for (auto const& c : classes) { From ce389ad25937ffd0e91fac680e01ea99cab5c19d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:27:24 +0000 Subject: [PATCH 11/61] Android 0.1.7: log the device of each gamepad key via GameActivityKeyEvent_fromJava 0.1.6's log: the game runs on GameActivity (AGDK); it exports GameActivityKeyEvent_fromJava and GameActivityMotionEvent_fromJava, whose structs start with the Android device id, and has GamePadMapper and InputDeviceMapper classes. Hook GameActivityKeyEvent_fromJava (env, jobject, out) and, after it fills the struct, log the device and source of gamepad key events, plus the first few structs raw to check the layout. The motion event converter takes many arguments that vary between AGDK versions, so it is only dumped, not hooked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/InputProbe.cpp | 38 +++++++++++++++++++++++++++++++++++++ android/src/InputProbe.h | 5 +++++ android/src/SplitScreen.cpp | 29 ++++++++-------------------- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 71600ee..53a325b 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.6" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.7" 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") diff --git a/android/src/InputProbe.cpp b/android/src/InputProbe.cpp index a226612..503a3ee 100644 --- a/android/src/InputProbe.cpp +++ b/android/src/InputProbe.cpp @@ -1,6 +1,7 @@ #include "InputProbe.h" #include +#include #include #include @@ -65,8 +66,45 @@ int32_t getDeviceIdWrapper(AInputEvent const* event) { 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); diff --git a/android/src/InputProbe.h b/android/src/InputProbe.h index 9e81073..81177dc 100644 --- a/android/src/InputProbe.h +++ b/android/src/InputProbe.h @@ -18,6 +18,11 @@ 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index d6dabc2..034aa4e 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -219,13 +219,6 @@ void probe() { } } note("{} JNI functions in all", jni); - // the code of the input JNI functions, to read offline - for (auto const& e : exports) - for (char const* name : {"MainActivity_nativeKeyHandler", "JellyBeanDeviceManager_onInputDeviceAddedNative", - "JellyBeanDeviceManager_onInputDeviceRemovedNative", - "JellyBeanDeviceManager_setControllerDetailsNative", - "JellyBeanDeviceManager_onInputDeviceChangedNative"}) - if (e.name.ends_with(name)) dumpCode(*game, name, e.address, 512, [](std::string const& l) { note("{}", l); }); } // which Android input functions the game imports (native input queue, Paddleboat controller library, ...) { @@ -243,7 +236,14 @@ void probe() { || 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); } - startInputProbe(*game, [](std::string const& l) { note("{}", l); }); + { + auto log = [](std::string const& l) { note("{}", l); }; + for (auto const& e : exportedFunctions(*game)) { + if (e.name == "GameActivityMotionEvent_fromJava" || e.name == "GameActivityKeyEvent_fromJava") + dumpCode(*game, e.name.c_str(), e.address, 512, log); + if (e.name == "GameActivityKeyEvent_fromJava") watchGameActivityKeys(*game, e.address, log); + } + } // 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")) @@ -266,18 +266,6 @@ void probe() { note("class {}: {} slots", c.name, countSlots(*game, vt, 2000)); } - // the game's controller classes (names only) - for (char const* needle : {"Gamepad", "GamePad", "Controller", "InputDevice", "DeviceManager"}) { - int shown = 0; - for (auto const& t : findTypeNames(*game, needle)) { - if (t.name.starts_with("NSt6__ndk1") || t.name.find("Screen") != std::string::npos - || t.name.find("ZN") != std::string::npos) - continue; - if (++shown > 80) break; - note("type *{}*: {}", needle, t.name); - } - } - for (char const* needle : {"MinecraftGame", "SplitscreenJoinListener"}) { auto classes = findClasses(*game, needle); note("{} classes named *{}*", classes.size(), needle); @@ -316,7 +304,6 @@ void probe() { if (!configFlag("forceSplitScreen", true)) return note("forceSplitScreen is off in config.json"); gOriginalAllowSplitScreen = reinterpret_cast(allowSplitScreen); note("turning split screen on (AppPlatform slot {})", slot); - dumpCode(*game, "allowSplitScreen", allowSplitScreen, 256, [](std::string const& l) { note("{}", l); }); patchSlotEverywhere(*game, platforms, count, slot, allowSplitScreen, "allowSplitScreen"); } From 51b9dd39f694e7c44f755d895617fd99b2139a76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:41:04 +0000 Subject: [PATCH 12/61] Android 0.1.8: find and watch the game's native controller manager 0.1.7's log: GameActivityKeyEvent_fromJava works as hooked; the two controllers are devices 11 and 12, so the game gets the device of each button. The JellyBeanDeviceManager JNI functions all end in a call on one static object (adrp/add x0; bl). Find that object and the methods from the code (checked against the 0.1.5 dump: object +0x1407CA98, methods +0xBF9E118/+0xBF9E4C4/+0xBF9E620), log its RTTI class name and the methods' code, and log the object's memory whenever it changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/src/DeviceManagerProbe.cpp | 96 ++++++++++++++++++++++++++++++ android/src/DeviceManagerProbe.h | 33 ++++++++++ android/src/SplitScreen.cpp | 16 ++++- 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 android/src/DeviceManagerProbe.cpp create mode 100644 android/src/DeviceManagerProbe.h diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 53a325b..c6924ea 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.7" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.8" 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") @@ -47,7 +47,7 @@ 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) +add_library(${MOD_LIBRARY_NAME} SHARED src/main.cpp src/SplitScreen.cpp src/Elf.cpp src/InputProbe.cpp src/DeviceManagerProbe.cpp) 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) diff --git a/android/src/DeviceManagerProbe.cpp b/android/src/DeviceManagerProbe.cpp new file mode 100644 index 0000000..72a7fb7 --- /dev/null +++ b/android/src/DeviceManagerProbe.cpp @@ -0,0 +1,96 @@ +#include "DeviceManagerProbe.h" + +#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; +} + +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..ee2c414 --- /dev/null +++ b/android/src/DeviceManagerProbe.h @@ -0,0 +1,33 @@ +#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 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/SplitScreen.cpp b/android/src/SplitScreen.cpp index 034aa4e..067c047 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -11,6 +11,7 @@ #include "Elf.h" #include "InputProbe.h" +#include "DeviceManagerProbe.h" #include @@ -239,10 +240,21 @@ void probe() { { auto log = [](std::string const& l) { note("{}", l); }; for (auto const& e : exportedFunctions(*game)) { - if (e.name == "GameActivityMotionEvent_fromJava" || e.name == "GameActivityKeyEvent_fromJava") - dumpCode(*game, e.name.c_str(), e.address, 512, log); 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; + std::set dumped; + 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)); + if (dumped.insert(c.method).second) dumpCode(*game, c.jni, c.method, 1024, log); + manager = c.object; + } + if (calls.empty()) note("device manager: not found"); + else watchObject(*game, manager, 1024, log); } // 2. classes by RTTI. PlayerListPacket is a class every version has: if it is not found, the search itself failed. From f98cbcee66dc32c92452b016674dec1fc94157e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:50:03 +0000 Subject: [PATCH 13/61] Android 0.1.9: find the gamepad system and log who calls it 0.1.8's log: the controller manager (+0x1407CA98) keeps one slot per controller (count 1, then 2; 16 per-controller arrays grow), but every call it makes to the game's gamepad system (+0x14273550) passes controller id 0 in w1: connected (+0x11EDB07C), triggers as buttons (+0x11EDA940), details (+0x11EDB18C/1D4/1E8), state (+0x11EDAB28). So Android's build feeds every controller to controller 0. To remap them, the device behind each call is needed. This version finds the gamepad system and its functions from the manager's code (checked on the 0.1.8 dump), hooks them with register-saving assembly trampolines, and logs for each new call site the arguments and the frame-pointer chain of callers, plus call counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 6 +- android/src/DeviceManagerProbe.cpp | 110 +++++++++++++++++++++++++++++ android/src/DeviceManagerProbe.h | 13 ++++ android/src/SplitScreen.cpp | 12 +++- android/src/Trampolines.S | 55 +++++++++++++++ 5 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 android/src/Trampolines.S diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index c6924ea..018db27 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -2,7 +2,7 @@ # 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) +project(SplitScreenModAndroid LANGUAGES CXX ASM) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -12,7 +12,7 @@ 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.1.8" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.9" 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") @@ -47,7 +47,7 @@ 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) +add_library(${MOD_LIBRARY_NAME} SHARED src/main.cpp src/SplitScreen.cpp src/Elf.cpp src/InputProbe.cpp src/DeviceManagerProbe.cpp src/Trampolines.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) diff --git a/android/src/DeviceManagerProbe.cpp b/android/src/DeviceManagerProbe.cpp index 72a7fb7..9c036e4 100644 --- a/android/src/DeviceManagerProbe.cpp +++ b/android/src/DeviceManagerProbe.cpp @@ -1,6 +1,13 @@ #include "DeviceManagerProbe.h" +#include + +#include #include +#include +#include +#include +#include #include #include @@ -53,6 +60,37 @@ std::vector findDeviceManagerCalls(Module const& game, std::v 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; @@ -94,3 +132,75 @@ void watchObject(Module const& game, uintptr_t object, size_t size, LogFn log) { } } // namespace splitscreen + +extern "C" { +uintptr_t splitscreen_originals[8]; +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(); +} + +namespace splitscreen { +namespace { + +Module const* gCallGame = nullptr; +LogFn gCallLog; +uintptr_t gMethods[8]; +std::atomic gCalls[8]; +std::mutex gCallMutex; +std::set> gCallSites; + +std::string offset(uintptr_t p) { + return gCallGame->isCode(p) ? fmt::format("+0x{:X}", p - gCallGame->base) : fmt::format("0x{:X}", p); +} + +} // namespace + +void watchGamepadCalls(Module const& game, std::vector const& methods, LogFn log) { + static Module copy = game; + gCallGame = © + gCallLog = std::move(log); + void (*trampolines[8])() = {splitscreen_trampoline_0, splitscreen_trampoline_1, splitscreen_trampoline_2, + splitscreen_trampoline_3, splitscreen_trampoline_4, splitscreen_trampoline_5, + splitscreen_trampoline_6, splitscreen_trampoline_7}; + for (size_t i = 0; i < methods.size() && i < 8; ++i) { + gMethods[i] = methods[i]; + if (pl::memory::hook(reinterpret_cast(methods[i]), + reinterpret_cast(trampolines[i]), + reinterpret_cast(&splitscreen_originals[i])) + != 0) + gCallLog(fmt::format("gamepad #{}: ERROR: could not hook {}", i, offset(methods[i]))); + else gCallLog(fmt::format("gamepad #{}: watching {}", i, offset(methods[i]))); + } +} + +} // namespace splitscreen + +// Called by the trampolines with the saved x0..x8, the caller's frame pointer and return address. +extern "C" void splitscreen_on_call(uint64_t index, uint64_t* regs, uint64_t fp, uint64_t ret) { + using namespace splitscreen; + uint64_t n = ++gCalls[index]; + if (n == 1 || n == 10 || n == 100 || n == 1000 || n == 10000 || n == 100000) + gCallLog(fmt::format("gamepad #{}: called {} times", index, n)); + { + std::lock_guard lock(gCallMutex); + if (gCallSites.size() >= 48 || !gCallSites.insert({index, ret}).second) return; + } + std::string chain; + uintptr_t frame = fp; + for (int k = 0; k < 10 && frame && (frame & 7) == 0; ++k) { + auto const* f = reinterpret_cast(frame); + uintptr_t next = f[0], lr = f[1]; + if (!lr) break; + chain += " " + offset(lr); + if (next <= frame || next - frame > (1u << 20)) break; + frame = next; + } + gCallLog(fmt::format("gamepad #{} ({}): w1={} w2={} w3={} w4={} from {}; callers:{}", index, offset(gMethods[index]), + int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]), int32_t(regs[4]), offset(ret), chain)); +} diff --git a/android/src/DeviceManagerProbe.h b/android/src/DeviceManagerProbe.h index ee2c414..ffcfd94 100644 --- a/android/src/DeviceManagerProbe.h +++ b/android/src/DeviceManagerProbe.h @@ -24,6 +24,19 @@ struct DeviceManagerCall { 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); + +// Hooks up to 8 functions with register-saving detours that log, for each new call site, the arguments and the +// chain of callers (frame pointers), and how often each function is called. +void watchGamepadCalls(Module const& game, std::vector const& methods, LogFn log); + // 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 067c047..1bfd174 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -246,15 +246,21 @@ void probe() { // the controller manager the JellyBeanDeviceManager JNI functions call auto calls = findDeviceManagerCalls(*game, exportedFunctions(*game)); uintptr_t manager = 0; - std::set dumped; 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)); - if (dumped.insert(c.method).second) dumpCode(*game, c.jni, c.method, 1024, log); manager = c.object; } if (calls.empty()) note("device manager: not found"); - else watchObject(*game, manager, 1024, log); + else { + auto gamepads = findGamepadSystem(*game, calls.front().method, manager); + if (!gamepads.object) note("gamepad system: not found"); + else { + note("gamepad system: object +0x{:X} ({}), {} functions", gamepads.object - game->base, + className(*game, gamepads.object), gamepads.methods.size()); + watchGamepadCalls(*game, gamepads.methods, log); + } + } } // 2. classes by RTTI. PlayerListPacket is a class every version has: if it is not found, the search itself failed. diff --git a/android/src/Trampolines.S b/android/src/Trampolines.S new file mode 100644 index 0000000..ea43ef0 --- /dev/null +++ b/android/src/Trampolines.S @@ -0,0 +1,55 @@ +// 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 + + .section .note.GNU-stack, "", %progbits From de2b6ac2bfc54c1a64d74f2d6f507b10645a4fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 16:59:34 +0000 Subject: [PATCH 14/61] Android 0.1.10: dump the code along the input call chain 0.1.9's log: the gamepad system is GameControllerManager (+0x14273550). A button press goes GameActivity (+0x82EBBC0) -> Minecraft's input loop (+0x88A2580 keys, +0x88A2538 other) -> +0x88A1A8C -> controller manager (+0xBF9E0E0) -> handlers (+0xBFB9..., +0xBFBA...) -> GameControllerManager with controller 0; the trampolines ran without trouble. To see where the device id is read and turned into the manager's slot, dump the code before each in-game frame of the chain (once each). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/DeviceManagerProbe.cpp | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 018db27..009a9d5 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.9" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.1.10" 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") diff --git a/android/src/DeviceManagerProbe.cpp b/android/src/DeviceManagerProbe.cpp index 9c036e4..1017a28 100644 --- a/android/src/DeviceManagerProbe.cpp +++ b/android/src/DeviceManagerProbe.cpp @@ -155,6 +155,8 @@ std::atomic gCalls[8]; std::mutex gCallMutex; std::set> gCallSites; +std::set gDumpedCallers; + std::string offset(uintptr_t p) { return gCallGame->isCode(p) ? fmt::format("+0x{:X}", p - gCallGame->base) : fmt::format("0x{:X}", p); } @@ -185,22 +187,34 @@ void watchGamepadCalls(Module const& game, std::vector const& methods extern "C" void splitscreen_on_call(uint64_t index, uint64_t* regs, uint64_t fp, uint64_t ret) { using namespace splitscreen; uint64_t n = ++gCalls[index]; - if (n == 1 || n == 10 || n == 100 || n == 1000 || n == 10000 || n == 100000) + if (n == 1 || n == 10 || n == 50 || n == 100 || n == 300 || n == 1000 || n == 10000 || n == 100000) gCallLog(fmt::format("gamepad #{}: called {} times", index, n)); { std::lock_guard lock(gCallMutex); if (gCallSites.size() >= 48 || !gCallSites.insert({index, ret}).second) return; } - std::string chain; - uintptr_t frame = fp; + std::string chain; + std::vector inGame{ret}; + uintptr_t frame = fp; for (int k = 0; k < 10 && frame && (frame & 7) == 0; ++k) { auto const* f = reinterpret_cast(frame); uintptr_t next = f[0], lr = f[1]; if (!lr) break; chain += " " + offset(lr); + if (gCallGame->isCode(lr)) inGame.push_back(lr); if (next <= frame || next - frame > (1u << 20)) break; frame = next; } gCallLog(fmt::format("gamepad #{} ({}): w1={} w2={} w3={} w4={} from {}; callers:{}", index, offset(gMethods[index]), int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]), int32_t(regs[4]), offset(ret), chain)); + // the code around each place in the chain (the caller's call and what leads up to it), once each + for (size_t k = 0; k < inGame.size() && k < 5; ++k) { + uintptr_t at = inGame[k]; + { + std::lock_guard lock(gCallMutex); + if (gDumpedCallers.size() >= 14 || !gDumpedCallers.insert(at).second) continue; + } + auto label = fmt::format("caller{}", offset(at)); + dumpCode(*gCallGame, label.c_str(), at - 0x400, 0x440, gCallLog); + } } From 907534e81e5ecd99706dc8dbcb7bbf6e7418cbec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:10:02 +0000 Subject: [PATCH 15/61] Android 0.2.0: one player per controller (first attempt) 0.1.10's log showed the controller manager's dispatch function (+0xBF9E080): every controller event goes through it as a std::variant whose alternative points to an event that starts with the Android device id, and its visitors call GameControllerManager with controller 0. Now each gamepad device gets a controller number in the order it is first used (0 = player 1, 1 = player 2, up to 4). While dispatch, or the manager's add/remove device function, runs for a device, that number is the thread's current controller, and every GameControllerManager call on that thread with controller 0 gets it instead (register-saving trampolines, now 16). Dispatch is found from its code (the variant index check before the manager's add function, checked on the 0.1.10 dump) and GameControllerManager's functions from the manager's code and dispatch's visitors. Events are read with process_vm_readv, so a bad pointer cannot crash the game. New config switch separateControllers (default on). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/config/config.json | 3 +- android/config/config.schema.json | 5 + android/src/ControllerSplit.cpp | 279 +++++++++++++++++++++++++++++ android/src/ControllerSplit.h | 25 +++ android/src/DeviceManagerProbe.cpp | 85 --------- android/src/DeviceManagerProbe.h | 4 - android/src/SplitScreen.cpp | 14 +- android/src/Trampolines.S | 8 + 9 files changed, 329 insertions(+), 98 deletions(-) create mode 100644 android/src/ControllerSplit.cpp create mode 100644 android/src/ControllerSplit.h diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 009a9d5..58bd95c 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1.10" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.0" 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") @@ -47,7 +47,7 @@ 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/Trampolines.S) +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/Trampolines.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) diff --git a/android/config/config.json b/android/config/config.json index aa2c0e6..782cfc6 100644 --- a/android/config/config.json +++ b/android/config/config.json @@ -1,3 +1,4 @@ { - "forceSplitScreen": true + "forceSplitScreen": true, + "separateControllers": true } diff --git a/android/config/config.schema.json b/android/config/config.schema.json index 5e34022..5440347 100644 --- a/android/config/config.schema.json +++ b/android/config/config.schema.json @@ -6,6 +6,11 @@ "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." } } } diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp new file mode 100644 index 0000000..fc98433 --- /dev/null +++ b/android/src/ControllerSplit.cpp @@ -0,0 +1,279 @@ +#include "ControllerSplit.h" + +#include "DeviceManagerProbe.h" + +#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; + +thread_local int tCurrentController = -1; + +int controllerFor(int32_t device) { + std::lock_guard lock(gDevicesMutex); + if (auto it = gControllerOf.find(device); it != gControllerOf.end()) return it->second; + std::array used{}; + for (auto [d, c] : gControllerOf) used[c] = true; + for (int c = 0; c < kMaxControllers; ++c) + if (!used[c]) { + gControllerOf[device] = c; + gLog(fmt::format("controllers: device {} is controller {} (player {})", device, c, c + 1)); + return c; + } + return -1; // more than 4: leave it on controller 0 as the game does +} + +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; + +// --- 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}; + +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}; + if (safeRead(variant + 8, &index, 4) && index != -1 && safeRead(variant, &event, 8) && event + && safeRead(event, head, 8)) { + bool pad = (head[1] & kSourceGamepad) == kSourceGamepad || (head[1] & kSourceJoystick) == kSourceJoystick; + if (pad) controller = controllerFor(head[0]); + if (gEventsLogged < 24) { + ++gEventsLogged; + gLog(fmt::format("controllers: event kind {} device {} source 0x{:X} -> controller {}", index, head[0], + head[1], controller)); + } + } + int previous = tCurrentController; + tCurrentController = controller; + uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); + tCurrentController = previous; + 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 + +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..3ef9b9b --- /dev/null +++ b/android/src/ControllerSplit.h @@ -0,0 +1,25 @@ +#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" + +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); + +} // namespace splitscreen diff --git a/android/src/DeviceManagerProbe.cpp b/android/src/DeviceManagerProbe.cpp index 1017a28..58bf3a2 100644 --- a/android/src/DeviceManagerProbe.cpp +++ b/android/src/DeviceManagerProbe.cpp @@ -133,88 +133,3 @@ void watchObject(Module const& game, uintptr_t object, size_t size, LogFn log) { } // namespace splitscreen -extern "C" { -uintptr_t splitscreen_originals[8]; -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(); -} - -namespace splitscreen { -namespace { - -Module const* gCallGame = nullptr; -LogFn gCallLog; -uintptr_t gMethods[8]; -std::atomic gCalls[8]; -std::mutex gCallMutex; -std::set> gCallSites; - -std::set gDumpedCallers; - -std::string offset(uintptr_t p) { - return gCallGame->isCode(p) ? fmt::format("+0x{:X}", p - gCallGame->base) : fmt::format("0x{:X}", p); -} - -} // namespace - -void watchGamepadCalls(Module const& game, std::vector const& methods, LogFn log) { - static Module copy = game; - gCallGame = © - gCallLog = std::move(log); - void (*trampolines[8])() = {splitscreen_trampoline_0, splitscreen_trampoline_1, splitscreen_trampoline_2, - splitscreen_trampoline_3, splitscreen_trampoline_4, splitscreen_trampoline_5, - splitscreen_trampoline_6, splitscreen_trampoline_7}; - for (size_t i = 0; i < methods.size() && i < 8; ++i) { - gMethods[i] = methods[i]; - if (pl::memory::hook(reinterpret_cast(methods[i]), - reinterpret_cast(trampolines[i]), - reinterpret_cast(&splitscreen_originals[i])) - != 0) - gCallLog(fmt::format("gamepad #{}: ERROR: could not hook {}", i, offset(methods[i]))); - else gCallLog(fmt::format("gamepad #{}: watching {}", i, offset(methods[i]))); - } -} - -} // namespace splitscreen - -// Called by the trampolines with the saved x0..x8, the caller's frame pointer and return address. -extern "C" void splitscreen_on_call(uint64_t index, uint64_t* regs, uint64_t fp, uint64_t ret) { - using namespace splitscreen; - uint64_t n = ++gCalls[index]; - if (n == 1 || n == 10 || n == 50 || n == 100 || n == 300 || n == 1000 || n == 10000 || n == 100000) - gCallLog(fmt::format("gamepad #{}: called {} times", index, n)); - { - std::lock_guard lock(gCallMutex); - if (gCallSites.size() >= 48 || !gCallSites.insert({index, ret}).second) return; - } - std::string chain; - std::vector inGame{ret}; - uintptr_t frame = fp; - for (int k = 0; k < 10 && frame && (frame & 7) == 0; ++k) { - auto const* f = reinterpret_cast(frame); - uintptr_t next = f[0], lr = f[1]; - if (!lr) break; - chain += " " + offset(lr); - if (gCallGame->isCode(lr)) inGame.push_back(lr); - if (next <= frame || next - frame > (1u << 20)) break; - frame = next; - } - gCallLog(fmt::format("gamepad #{} ({}): w1={} w2={} w3={} w4={} from {}; callers:{}", index, offset(gMethods[index]), - int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]), int32_t(regs[4]), offset(ret), chain)); - // the code around each place in the chain (the caller's call and what leads up to it), once each - for (size_t k = 0; k < inGame.size() && k < 5; ++k) { - uintptr_t at = inGame[k]; - { - std::lock_guard lock(gCallMutex); - if (gDumpedCallers.size() >= 14 || !gDumpedCallers.insert(at).second) continue; - } - auto label = fmt::format("caller{}", offset(at)); - dumpCode(*gCallGame, label.c_str(), at - 0x400, 0x440, gCallLog); - } -} diff --git a/android/src/DeviceManagerProbe.h b/android/src/DeviceManagerProbe.h index ffcfd94..20d98ea 100644 --- a/android/src/DeviceManagerProbe.h +++ b/android/src/DeviceManagerProbe.h @@ -33,10 +33,6 @@ struct GamepadSystem { }; GamepadSystem findGamepadSystem(Module const& game, uintptr_t managerMethod, uintptr_t managerObject); -// Hooks up to 8 functions with register-saving detours that log, for each new call site, the arguments and the -// chain of callers (frame pointers), and how often each function is called. -void watchGamepadCalls(Module const& game, std::vector const& methods, LogFn log); - // 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 1bfd174..42d68fa 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -12,6 +12,7 @@ #include "Elf.h" #include "InputProbe.h" #include "DeviceManagerProbe.h" +#include "ControllerSplit.h" #include @@ -252,14 +253,15 @@ void probe() { manager = c.object; } if (calls.empty()) note("device manager: not found"); + else if (!configFlag("separateControllers", true)) note("separateControllers is off in config.json"); else { - auto gamepads = findGamepadSystem(*game, calls.front().method, manager); - if (!gamepads.object) note("gamepad system: not found"); - else { - note("gamepad system: object +0x{:X} ({}), {} functions", gamepads.object - game->base, - className(*game, gamepads.object), gamepads.methods.size()); - watchGamepadCalls(*game, gamepads.methods, log); + 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; } + if (added) startControllerSplit(*game, manager, added, removed, log); + else note("controllers: the manager's add function was not found"); } } diff --git a/android/src/Trampolines.S b/android/src/Trampolines.S index ea43ef0..242ec60 100644 --- a/android/src/Trampolines.S +++ b/android/src/Trampolines.S @@ -51,5 +51,13 @@ 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 From 62284a71c9f8f6062186ce23e3cdfa8076bb8a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:17:48 +0000 Subject: [PATCH 16/61] Android 0.2.1: let other controllers join (MinecraftGame splitscreen checks) 0.2.0 on a device: the second controller became controller 1; the game was told controller 1 connected and its buttons went to controller 1, but no player joined for it. MinecraftGame's vtable (dumped by 0.1.1) lines up with LeviLamina's IMinecraftGame.h around these slots: slot 210 is addSubClientInstanceAndAssociateToUser(int) ("mov w2, #1; b _addSubClientInstanceAndAssociateToUserInternal"), so 172 is isSplitscreenJoinEnabled (an atomic bool load) and 173 canAddSplitscreenSubClient(int). Find slot 210 by that code, then make the two checks answer true (canAdd only for controllers 1-3), logging what the game would have answered, and log calls to addSubClient. New config switch forceSplitscreenJoin (default on). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/config/config.json | 3 +- android/config/config.schema.json | 5 ++ android/src/SplitScreen.cpp | 78 +++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 58bd95c..3033d8c 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.1" 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") diff --git a/android/config/config.json b/android/config/config.json index 782cfc6..ca983e0 100644 --- a/android/config/config.json +++ b/android/config/config.json @@ -1,4 +1,5 @@ { "forceSplitScreen": true, - "separateControllers": true + "separateControllers": true, + "forceSplitscreenJoin": true } diff --git a/android/config/config.schema.json b/android/config/config.schema.json index 5440347..822794a 100644 --- a/android/config/config.schema.json +++ b/android/config/config.schema.json @@ -11,6 +11,11 @@ "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." } } } diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 42d68fa..ac0f90b 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -183,6 +183,82 @@ void patchSlotEverywhere(Module const& m, std::vector const& classes, } } +// --- 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; + +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; +} + +bool canAddDetour(void* self, int controller) { + gMinecraftGame = self; + bool game = gCanAdd(self, controller); + 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; +} + +void addSubDetour(void* self, int controller) { + note("join: addSubClientInstanceAndAssociateToUser({}) called", controller); + gAddSub(self, controller); + note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); +} + +void enableJoining(Module const& game) { + 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"); +} + void probe() { using namespace std::chrono_literals; std::optional game; @@ -325,6 +401,8 @@ void probe() { gOriginalAllowSplitScreen = reinterpret_cast(allowSplitScreen); note("turning split screen on (AppPlatform slot {})", slot); patchSlotEverywhere(*game, platforms, count, slot, allowSplitScreen, "allowSplitScreen"); + if (configFlag("forceSplitscreenJoin", true)) enableJoining(*game); + else note("forceSplitscreenJoin is off in config.json"); } std::once_flag gStarted; From 1824cdba98cbe765e83d33a4f065c76f3f665c42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:28:53 +0000 Subject: [PATCH 17/61] Android 0.2.2: Start on another controller adds a player 0.2.1 on a device: isSplitscreenJoinEnabled is asked and answered true, but canAddSplitscreenSubClient is never asked: the game's own "press Start to join" does not react to controller 1 on Android. So when a controller other than the first presses Start (Android key code 108, key down; key events are dispatch's variant kind 0, the key code is int 12 of GameActivityKeyEvent), call MinecraftGame::addSubClientInstanceAndAssociateToUser(controller) on the MinecraftGame captured from isSplitscreenJoinEnabled, after the game handled the event, on the same thread, once per controller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 15 +++++++++++++++ android/src/ControllerSplit.h | 5 +++++ android/src/SplitScreen.cpp | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 3033d8c..38399d4 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.2" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index fc98433..f3967c8 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -83,6 +83,14 @@ bool safeRead(uintptr_t address, void* out, size_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 kKeyActionDown = 0; + // --- dispatch --------------------------------------------------------------------------------------------------- using Fn8 = uint64_t (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t); Fn8 gDispatch = nullptr; @@ -93,8 +101,12 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, int controller = -1; uintptr_t event = 0; int32_t index = -1, head[2] = {0, 0}; + bool startDown = false; 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)) + startDown = key[kKeyCodeIndex] == kKeyCodeStart && key[2] == kKeyActionDown; bool pad = (head[1] & kSourceGamepad) == kSourceGamepad || (head[1] & kSourceJoystick) == kSourceJoystick; if (pad) controller = controllerFor(head[0]); if (gEventsLogged < 24) { @@ -107,6 +119,7 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, tCurrentController = controller; uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); tCurrentController = previous; + if (startDown && controller >= 1 && gStartPressed) gStartPressed(controller); return result; } @@ -200,6 +213,8 @@ std::string offset(uintptr_t p) { return fmt::format("+0x{:X}", p - gGame->base) } // namespace +void onStartPressed(std::function handler) { gStartPressed = std::move(handler); } + bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, LogFn log) { static Module copy = game; diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 3ef9b9b..3621e5f 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -16,10 +16,15 @@ #include "Elf.h" #include "InputProbe.h" +#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); +// Called on the input thread, after the game handled it, when a controller other than the first presses Start. +void onStartPressed(std::function handler); + } // namespace splitscreen diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index ac0f90b..b151e3e 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -228,6 +228,23 @@ void addSubDetour(void* self, int controller) { note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); } +// 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::set gJoined; + +void joinController(int controller) { + void* game = gMinecraftGame.load(); + { + std::lock_guard lock(gJoinMutex); + if (gJoined.contains(controller)) return; + if (!game || !gAddSub) return note("join: controller {} pressed Start, but MinecraftGame is not known yet", controller); + gJoined.insert(controller); + } + note("join: controller {} pressed Start: adding player {}", controller, controller + 1); + addSubDetour(game, controller); +} + void enableJoining(Module const& game) { auto classes = findClasses(game, "13MinecraftGame"); uintptr_t* vt = nullptr; @@ -257,6 +274,7 @@ void enableJoining(Module const& game) { && 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"); + if (ok) onStartPressed(joinController); } void probe() { From 7b497000cd30ff4f0240a6526a8d0d05b1a90925 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:36:50 +0000 Subject: [PATCH 18/61] Android 0.2.3: watch the user connection behind adding a player 0.2.2 on a device: Start on controller 1 called addSubClientInstanceAndAssociateToUser(1); inside it the game itself asked canAddSplitscreenSubClient(1) and would answer true; it returned, but no player appeared. The internal function connects a user for the controller first and continues in a lambda that receives a Social::UserPlatformConnectionResult. Watch that lambda's std::function operator() (vtable slot 6, found by its RTTI name) to log the result, and dump the internal function's and the callback's code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 38399d4..0c566ad 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index b151e3e..97f863c 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -245,6 +246,36 @@ void joinController(int controller) { addSubDetour(game, controller); } +// addSubClientInstanceAndAssociateToUser first connects a user for the controller; a std::function lambda in +// _addSubClientInstanceAndAssociateToUserInternal gets the Social::UserPlatformConnectionResult. Its __func vtable's +// operator() (slot 6: ~, ~, __clone, __clone, destroy, destroy_deallocate, operator()) is watched to see the result. +using Fn8 = uint64_t (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t); +Fn8 gUserConnected = nullptr; + +uint64_t userConnectedDetour(uint64_t self, uint64_t result, uint64_t a2, uint64_t a3, uint64_t a4, uint64_t a5, + uint64_t a6, uint64_t a7) { + int32_t value = -1; + if (result) std::memcpy(&value, reinterpret_cast(result), 4); + note("join: the user connection for the new player finished with result {}", value); + uint64_t r = gUserConnected(self, result, a2, a3, a4, a5, a6, a7); + note("join: the user connection callback returned"); + return r; +} + +void watchUserConnection(Module const& game) { + for (auto const& c : findClasses(game, "_addSubClientInstanceAndAssociateToUserInternal")) { + if (c.vtables.empty()) continue; + auto* vt = c.vtables.front(); + note("join: callback class {} ({} slots)", c.name, countSlots(game, vt, 64)); + if (countSlots(game, vt, 64) < 7 || !game.isCode(vt[6])) continue; + dumpCode(game, "userCallback", vt[6], 0x300, [](std::string const& l) { note("{}", l); }); + gUserConnected = reinterpret_cast(vt[6]); + if (writeSlot(&vt[6], reinterpret_cast(&userConnectedDetour))) note("join: watching the callback"); + return; + } + note("join: the user connection callback class was not found"); +} + void enableJoining(Module const& game) { auto classes = findClasses(game, "13MinecraftGame"); uintptr_t* vt = nullptr; @@ -275,6 +306,11 @@ void enableJoining(Module const& game) { && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); if (ok) onStartPressed(joinController); + // the code of addSubClientInstanceAndAssociateToUser's internal function (its "b" target) + auto const* code = reinterpret_cast(gAddSub); + uintptr_t internal = reinterpret_cast(gAddSub) + 4 + ((int64_t(code[1] & 0x3FFFFFF) << 38) >> 36); + dumpCode(game, "addSubInternal", internal, 0x800, [](std::string const& l) { note("{}", l); }); + watchUserConnection(game); } void probe() { From 97a0ae00791c64713e5b48af62e4448e9cea0ffe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:50:39 +0000 Subject: [PATCH 19/61] Android 0.2.4: add the player without waiting for a user sign-in 0.2.3's dump of _addSubClientInstanceAndAssociateToUserInternal(game, controller, true): it gets the user manager (MinecraftGame virtual 53) and asks its virtual 28 (controller, true). If that answers true it starts an asynchronous user sign-in for the controller (user manager virtual 11 with a callback), which on Android never comes back; if false it creates the new player at once (a 7536-byte object built with the game). While the mod adds a player for controller 1-3, virtual 28 now answers false; it logs what the game would have answered. The 0.2.3 callback watch (a nested lambda that was never called) is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 79 +++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 0c566ad..e65f7ed 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.3" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.4" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 97f863c..1af4f52 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -229,6 +229,45 @@ void addSubDetour(void* self, int controller) { note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); } +// _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"); + dumpCode(module, "userCheck", vt[kUserCheckSlot], 0x200, [](std::string const& l) { note("{}", l); }); + gUserCheck = reinterpret_cast(vt[kUserCheckSlot]); + if (!writeSlot(&vt[kUserCheckSlot], reinterpret_cast(&userCheckDetour))) { + gUserCheck = nullptr; + note("join: ERROR: could not write the user manager vtable"); + } +} + +Module const* gGameModule = nullptr; + // 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; @@ -243,40 +282,15 @@ void joinController(int controller) { gJoined.insert(controller); } note("join: controller {} pressed Start: adding player {}", controller, controller + 1); + if (gGameModule) hookUserManager(*gGameModule, game); + tAddingPlayer = true; addSubDetour(game, controller); -} - -// addSubClientInstanceAndAssociateToUser first connects a user for the controller; a std::function lambda in -// _addSubClientInstanceAndAssociateToUserInternal gets the Social::UserPlatformConnectionResult. Its __func vtable's -// operator() (slot 6: ~, ~, __clone, __clone, destroy, destroy_deallocate, operator()) is watched to see the result. -using Fn8 = uint64_t (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint64_t); -Fn8 gUserConnected = nullptr; - -uint64_t userConnectedDetour(uint64_t self, uint64_t result, uint64_t a2, uint64_t a3, uint64_t a4, uint64_t a5, - uint64_t a6, uint64_t a7) { - int32_t value = -1; - if (result) std::memcpy(&value, reinterpret_cast(result), 4); - note("join: the user connection for the new player finished with result {}", value); - uint64_t r = gUserConnected(self, result, a2, a3, a4, a5, a6, a7); - note("join: the user connection callback returned"); - return r; -} - -void watchUserConnection(Module const& game) { - for (auto const& c : findClasses(game, "_addSubClientInstanceAndAssociateToUserInternal")) { - if (c.vtables.empty()) continue; - auto* vt = c.vtables.front(); - note("join: callback class {} ({} slots)", c.name, countSlots(game, vt, 64)); - if (countSlots(game, vt, 64) < 7 || !game.isCode(vt[6])) continue; - dumpCode(game, "userCallback", vt[6], 0x300, [](std::string const& l) { note("{}", l); }); - gUserConnected = reinterpret_cast(vt[6]); - if (writeSlot(&vt[6], reinterpret_cast(&userConnectedDetour))) note("join: watching the callback"); - return; - } - note("join: the user connection callback class was not found"); + tAddingPlayer = false; } void enableJoining(Module const& game) { + static Module copy = game; + gGameModule = © auto classes = findClasses(game, "13MinecraftGame"); uintptr_t* vt = nullptr; for (auto const& c : classes) @@ -306,11 +320,6 @@ void enableJoining(Module const& game) { && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); if (ok) onStartPressed(joinController); - // the code of addSubClientInstanceAndAssociateToUser's internal function (its "b" target) - auto const* code = reinterpret_cast(gAddSub); - uintptr_t internal = reinterpret_cast(gAddSub) + 4 + ((int64_t(code[1] & 0x3FFFFFF) << 38) >> 36); - dumpCode(game, "addSubInternal", internal, 0x800, [](std::string const& l) { note("{}", l); }); - watchUserConnection(game); } void probe() { From 1c01b8f6e014e8806ef36b9e097fd202366adadd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:59:10 +0000 Subject: [PATCH 20/61] Android 0.2.5: trace MinecraftGame after adding a player 0.2.4 on a device: the user manager's check (virtual 28) is a plain 'return 0', so the game already took the branch that creates the player at once (a 7536-byte object, then a 7096-byte shared_ptr block holding it, with MinecraftGame+1480 set while adding), yet no player appears. To see what happens next: every slot of MinecraftGame's vtable goes through a generated register-saving trampoline (VTrampolines.S, 320 slots); for 60 seconds after Start the first call of each virtual function is logged with its caller and arguments, then a call count per slot. MinecraftGame+1480 is logged every 10 seconds when it changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/src/SplitScreen.cpp | 14 +- android/src/VTrace.cpp | 78 ++++ android/src/VTrace.h | 17 + android/src/VTrampolines.S | 690 ++++++++++++++++++++++++++++++++++++ 5 files changed, 798 insertions(+), 5 deletions(-) create mode 100644 android/src/VTrace.cpp create mode 100644 android/src/VTrace.h create mode 100644 android/src/VTrampolines.S diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index e65f7ed..88ac8b2 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.4" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.5" 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") @@ -47,7 +47,7 @@ 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/Trampolines.S) +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/VTrace.cpp src/Trampolines.S src/VTrampolines.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) diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 1af4f52..83d58c0 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -13,6 +13,7 @@ #include "InputProbe.h" #include "DeviceManagerProbe.h" #include "ControllerSplit.h" +#include "VTrace.h" #include @@ -283,6 +284,7 @@ void joinController(int controller) { } note("join: controller {} pressed Start: adding player {}", controller, controller + 1); if (gGameModule) hookUserManager(*gGameModule, game); + startTracing(60); tAddingPlayer = true; addSubDetour(game, controller); tAddingPlayer = false; @@ -320,6 +322,8 @@ void enableJoining(Module const& game) { && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); if (ok) onStartPressed(joinController); + // after the patches above, so the trace sees our detours' slots as the originals + if (ok) traceVtable(game, vt, count, "MinecraftGame", [](std::string const& l) { note("{}", l); }); } void probe() { @@ -473,12 +477,16 @@ 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) continue; - reported = calls; - note("allowSplitScreen asked {} time(s) so far", calls); + 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); + } } } diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp new file mode 100644 index 0000000..5ec9990 --- /dev/null +++ b/android/src/VTrace.cpp @@ -0,0 +1,78 @@ +#include "VTrace.h" + +#include +#include +#include +#include + +#include + +namespace { +constexpr size_t kMaxSlots = 320; +} + +extern "C" { +uintptr_t splitscreen_voriginals[kMaxSlots]; +extern uintptr_t splitscreen_vtrampolines[kMaxSlots]; +} + +namespace splitscreen { + +namespace { + +Module const* gGame = nullptr; +LogFn gLog; +std::string gName; +std::atomic gTracing{false}; +std::atomic gCounts[kMaxSlots]; +std::atomic gSeen[kMaxSlots]; + +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, char const* name, LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + gName = name; + slots = std::min(slots, kMaxSlots); + for (size_t i = 0; i < slots; ++i) { + splitscreen_voriginals[i] = vtable[i]; + if (!writeSlot(&vtable[i], splitscreen_vtrampolines[i])) { + gLog(fmt::format("trace: ERROR: could not write {} slot {}", name, i)); + for (size_t k = 0; k < i; ++k) writeSlot(&vtable[k], splitscreen_voriginals[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", gName, 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{}", i, gCounts[i].load()); + gLog(fmt::format("trace: {} calls (slot x count):{}", gName, summary)); + }).detach(); +} + +} // namespace splitscreen + +extern "C" void splitscreen_on_vcall(uint64_t slot, uint64_t* regs, uint64_t ret) { + using namespace splitscreen; + 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={})", gName, 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..b1a58c1 --- /dev/null +++ b/android/src/VTrace.h @@ -0,0 +1,17 @@ +#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 { + +bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, char const* name, LogFn log); + +// 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..04a32bf --- /dev/null +++ b/android/src/VTrampolines.S @@ -0,0 +1,690 @@ +// 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 + + .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 + + .section .note.GNU-stack, "", %progbits From 9a5a7228a97dd727792ce9dd092f49247f460a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:08:19 +0000 Subject: [PATCH 21/61] Android 0.2.6: dump the check that refuses the new player 0.2.5's trace: during addSubClientInstanceAndAssociateToUser(1) MinecraftGame calls only virtual 53 (getUserManager), 173 (canAddSplitscreenSubClient, answered true) and 55 (getPrimaryClientInstance, from +0x9CEC848), then returns: the creating branch (which calls virtual 285) never runs, so the check at +0x9CED20C refuses on something about the primary client. Dump the 0x1100 bytes before the internal add function, which hold that check and the function calling virtual 55. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 88ac8b2..ce8f9f9 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.5" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.6" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 83d58c0..4c2c1bf 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -322,6 +322,13 @@ void enableJoining(Module const& game) { && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); if (ok) onStartPressed(joinController); + // the code before _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210): the check that + // decides whether a player may be added (0x1100 bytes before it in 1.26) + { + auto const* code = reinterpret_cast(gAddSub); + uintptr_t internal = reinterpret_cast(gAddSub) + 4 + ((int64_t(code[1] & 0x3FFFFFF) << 38) >> 36); + dumpCode(game, "beforeAddSub", internal - 0x1100, 0x1100, [](std::string const& l) { note("{}", l); }); + } // after the patches above, so the trace sees our detours' slots as the originals if (ok) traceVtable(game, vt, count, "MinecraftGame", [](std::string const& l) { note("{}", l); }); } From 4fa7fa8cd21c286b814ac00296f289d96d94327a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:17:59 +0000 Subject: [PATCH 22/61] Android 0.2.7: let the add-player check pass for the new player 0.2.6's dump of the check before creating a player (+0x9CED20C in 1.26, W(game, controller, uint8_t* out, bool)): it needs canAddSplitscreenSubClient, game+512 set, not (primary client virtual 113 and a GameControllerManager check), a GameControllerManager check(controller), user manager virtual 6 true, user manager virtual 39 false and (game+496)[25] <= 4; then it calls user manager virtual 7. It refused, so the creating branch never ran. Hook W (found as the first bl after 'add x2, sp, #164' in the internal add function); while the mod adds a player for controller 1-3 and W refuses, answer true. Log the two game fields and user manager virtuals 6, 39 and 7, to see which condition failed. The 0.2.6 dump is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 73 +++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index ce8f9f9..f398770 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.6" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.7" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 4c2c1bf..84775df 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -10,6 +10,8 @@ #include "SplitScreen.h" #include "Elf.h" + +#include #include "InputProbe.h" #include "DeviceManagerProbe.h" #include "ControllerSplit.h" @@ -230,6 +232,26 @@ void addSubDetour(void* self, int controller) { note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); } +// 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); + note("join: user manager virtual 6({}) = {}", c, r); + return r; +} +bool user39Detour(void* self, int c) { + bool r = gUser39(self, c); + note("join: user manager virtual 39({}) = {}", c, r); + return r; +} +void user7Detour(void* self, int c) { + note("join: user manager virtual 7({})", c); + 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 @@ -259,7 +281,12 @@ void hookUserManager(Module const& module, void* game) { 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"); - dumpCode(module, "userCheck", vt[kUserCheckSlot], 0x200, [](std::string const& l) { note("{}", l); }); + 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; @@ -267,6 +294,23 @@ void hookUserManager(Module const& module, void* game) { } } +// 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) answer = true; + note("join: may a player be added for controller {}? the game alone would answer {}, answering {}", controller, + result, answer); + return answer; +} + Module const* gGameModule = nullptr; // A controller other than the first pressed Start: add a player for it, once (the game's own "press Start to join" @@ -284,6 +328,12 @@ void joinController(int controller) { } note("join: controller {} pressed Start: adding player {}", controller, controller + 1); 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); tAddingPlayer = true; addSubDetour(game, controller); @@ -322,12 +372,27 @@ void enableJoining(Module const& game) { && writeSlot(&vt[anchor], reinterpret_cast(&addSubDetour)); note("{}", ok ? "join: splitscreen joining turned on" : "join: ERROR: could not write the MinecraftGame vtable"); if (ok) onStartPressed(joinController); - // the code before _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210): the check that - // decides whether a player may be added (0x1100 bytes before it in 1.26) + // 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); - dumpCode(game, "beforeAddSub", internal - 0x1100, 0x1100, [](std::string const& l) { note("{}", l); }); + 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, "MinecraftGame", [](std::string const& l) { note("{}", l); }); From c4ea2b5ada182805c6580773936aa3bdc6856cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:26:04 +0000 Subject: [PATCH 23/61] Android 0.2.8: trace the user manager too while adding a player 0.2.7 on a device: the add-player check refused because user manager virtual 6(1) is false (no user for controller 1). Forcing it through made the game take the creating branch (MinecraftGame virtual 285 from +0x9CED930) and the screen visibly re-initialised, but no second player appeared; the user manager's virtual 7 was then called with -1 (no user). To see what the new player asks of the user manager, its vtable (72 slots) is traced as well (trampolines 320-391; 400 generated now), user manager virtual 7 logs its caller, and the trace summary names the class of each slot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 10 ++- android/src/VTrace.cpp | 30 ++++--- android/src/VTrace.h | 3 +- android/src/VTrampolines.S | 161 +++++++++++++++++++++++++++++++++++- 5 files changed, 187 insertions(+), 19 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index f398770..d97adc2 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.7" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.8" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 84775df..0a5ff97 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -232,6 +232,8 @@ void addSubDetour(void* self, int controller) { note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); } +Module const* gGameModule = nullptr; + // user manager virtuals 6, 39 (bool(int)) and 7 (void(int)), logged using UserBoolFn = bool (*)(void*, int); using UserVoidFn = void (*)(void*, int); @@ -248,7 +250,8 @@ bool user39Detour(void* self, int c) { return r; } void user7Detour(void* self, int c) { - note("join: user manager virtual 7({})", 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); } @@ -292,6 +295,8 @@ void hookUserManager(Module const& module, void* game) { gUserCheck = nullptr; note("join: ERROR: could not write the user manager vtable"); } + // 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): @@ -311,7 +316,6 @@ bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { return answer; } -Module const* gGameModule = nullptr; // 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). @@ -395,7 +399,7 @@ void enableJoining(Module const& game) { 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, "MinecraftGame", [](std::string const& l) { note("{}", l); }); + if (ok) traceVtable(game, vt, count, 0, "MinecraftGame", [](std::string const& l) { note("{}", l); }); } void probe() { diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp index 5ec9990..494ea47 100644 --- a/android/src/VTrace.cpp +++ b/android/src/VTrace.cpp @@ -8,7 +8,7 @@ #include namespace { -constexpr size_t kMaxSlots = 320; +constexpr size_t kMaxSlots = 400; } extern "C" { @@ -22,7 +22,8 @@ namespace { Module const* gGame = nullptr; LogFn gLog; -std::string gName; +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]; @@ -33,17 +34,20 @@ std::string offset(uintptr_t p) { } // namespace -bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, char const* name, LogFn log) { +bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t first, char const* name, LogFn log) { static Module copy = game; gGame = © gLog = std::move(log); - gName = name; - slots = std::min(slots, kMaxSlots); + if (first >= kMaxSlots) return false; + slots = std::min(slots, kMaxSlots - first); for (size_t i = 0; i < slots; ++i) { - splitscreen_voriginals[i] = vtable[i]; - if (!writeSlot(&vtable[i], splitscreen_vtrampolines[i])) { + size_t t = first + i; + gNames[t] = name; + gSlots[t] = i; + 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[k]); + for (size_t k = 0; k < i; ++k) writeSlot(&vtable[k], splitscreen_voriginals[first + k]); return false; } } @@ -55,14 +59,14 @@ 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", gName, seconds)); + 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{}", i, gCounts[i].load()); - gLog(fmt::format("trace: {} calls (slot x count):{}", gName, summary)); + 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(); } @@ -73,6 +77,6 @@ extern "C" void splitscreen_on_vcall(uint64_t slot, uint64_t* regs, uint64_t ret 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={})", gName, slot, offset(ret), regs[1], - int32_t(regs[1]), int32_t(regs[2]))); + 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 index b1a58c1..cd0bd10 100644 --- a/android/src/VTrace.h +++ b/android/src/VTrace.h @@ -9,7 +9,8 @@ namespace splitscreen { -bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, char const* name, LogFn log); +// Each traced class uses its own range of trampolines, starting at `first` (400 in all). +bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t first, char const* name, LogFn log); // Logs calls from now on for `seconds`, then a summary. void startTracing(int seconds); diff --git a/android/src/VTrampolines.S b/android/src/VTrampolines.S index 04a32bf..1bf5e21 100644 --- a/android/src/VTrampolines.S +++ b/android/src/VTrampolines.S @@ -360,7 +360,86 @@ 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 .section .data.rel.ro, "aw" .p2align 3 .globl splitscreen_vtrampolines @@ -686,5 +765,85 @@ splitscreen_vtrampolines: .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 .section .note.GNU-stack, "", %progbits From 8493b72768c3e779fa36df7eb08ee9c56b9aa14d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:32:35 +0000 Subject: [PATCH 24/61] Android 0.2.9: log what the user manager answers for the new player 0.2.8's trace: while the new player is created the game asks the user manager virtuals 5, 8(1), 70(1), 41, 42, 49, 50, 9, 53(1), 38(1) and 54(1), then calls virtual 7(-1) and the player is gone: it has no user. To make it borrow player 1's user (as on Windows), the answers are needed. New register-saving detours that call the original and see its result (PostTrampolines.S, 16): they log x0/x1/d0, the two words x0 points to, and the first four words at x8 (for results returned in memory, e.g. a shared_ptr). Watched on those user manager functions, whose code is dumped too; the first 80 results after Start are logged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/src/PostTrace.cpp | 72 ++++++++++++++++++++++++++++++ android/src/PostTrace.h | 18 ++++++++ android/src/PostTrampolines.S | 82 +++++++++++++++++++++++++++++++++++ android/src/SplitScreen.cpp | 9 ++++ 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 android/src/PostTrace.cpp create mode 100644 android/src/PostTrace.h create mode 100644 android/src/PostTrampolines.S diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index d97adc2..92dbd3a 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.8" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.2.9" 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") @@ -47,7 +47,7 @@ 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/VTrace.cpp src/Trampolines.S src/VTrampolines.S) +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/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) 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 index 0a5ff97..bbc1e94 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -16,6 +16,7 @@ #include "DeviceManagerProbe.h" #include "ControllerSplit.h" #include "VTrace.h" +#include "PostTrace.h" #include @@ -295,6 +296,13 @@ void hookUserManager(Module const& module, void* game) { 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 = {5, 6, 8, 9, 38, 41, 42, 49, 50, 53, 54, 70}; + for (size_t slot : kAsked) { + auto label = fmt::format("UserManager{}", slot); + dumpCode(module, label.c_str(), vt[slot], 0x80, [](std::string const& l) { note("{}", l); }); + } + 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); }); } @@ -339,6 +347,7 @@ void joinController(int controller) { at496 ? int(reinterpret_cast(at496)[25]) : -1); } startTracing(60); + logResults(80); tAddingPlayer = true; addSubDetour(game, controller); tAddingPlayer = false; From 0da68f716f90656bdaac85b7eca97f3f9608a4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:40:17 +0000 Subject: [PATCH 25/61] Android 0.3.0: lend player 1's user to the new player 0.2.9's results and code: Social::UserManager keeps a vector of shared_ptr (+56, mutex +80). Virtual 5 returns the primary user and virtual 53 the user of a controller (empty for controller 1), both through x8; virtual 8 starts a new user sign-in for a controller (via virtual 70). The new player was created with no user (virtual 7 was then called with -1). As the Windows mod does, a controller the mod adds as player 2-4 now borrows player 1's user: virtual 53 returns the primary user for it instead of nothing, and virtual 6 ('has a user') answers true. The detour returns a non-trivial two-pointer type, so it uses x8 like the game's shared_ptr (checked in the generated AArch64 code) and moves it without touching the reference count. Fewer user manager results are logged; the code dumps are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 52 ++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 92dbd3a..4a2955b 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2.9" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.3.0" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index bbc1e94..470cd9b 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -235,13 +235,48 @@ void addSubDetour(void* self, int controller) { Module const* gGameModule = nullptr; +// 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); +} + +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); + bool r = gUser6(self, c) || lends(c); note("join: user manager virtual 6({}) = {}", c, r); return r; } @@ -285,6 +320,9 @@ void hookUserManager(Module const& module, void* game) { 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]); @@ -297,11 +335,7 @@ void hookUserManager(Module const& module, void* game) { 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 = {5, 6, 8, 9, 38, 41, 42, 49, 50, 53, 54, 70}; - for (size_t slot : kAsked) { - auto label = fmt::format("UserManager{}", slot); - dumpCode(module, label.c_str(), vt[slot], 0x80, [](std::string const& l) { note("{}", l); }); - } + 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); }); @@ -347,7 +381,11 @@ void joinController(int controller) { at496 ? int(reinterpret_cast(at496)[25]) : -1); } startTracing(60); - logResults(80); + logResults(60); + { + std::lock_guard lock(gLendMutex); + gLending.insert(controller); + } tAddingPlayer = true; addSubDetour(game, controller); tAddingPlayer = false; From 840ec3dd4e6d8a59d482287a6cfbf8f5f44450db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:50:50 +0000 Subject: [PATCH 26/61] Android 0.3.1: the second player can join again 0.3.0 on a device: the second player joined and played. The user then left with it, and pressing Start again did nothing, even after reloading the world: the mod tried to add a player only once per controller per run. Start on controller 1-3 now adds a player again whenever the game itself says a player can be added for that controller (its own canAddSplitscreenSubClient answer, remembered for the add); the add-player check is only forced then, so Start on a controller that already plays does not add a second player. Presses closer than 3 seconds count once. MinecraftGame virtuals 10 and 16 (queueSubclientRemoval, releaseClientSubId) are logged with their caller. Lending player 1's user stays as in 0.3.0 (only when the game finds no user): the user the game makes works. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 4a2955b..73eec63 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.3.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.3.1" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 470cd9b..11aa7c7 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -195,6 +195,21 @@ void patchSlotEverywhere(Module const& m, std::vector const& classes, // the two checks are 38 and 37 slots before it. constexpr size_t kAddSubClientSlot = 210, kJoinEnabledBack = 38, kCanAddBack = 37; +Module const* gGameModule = nullptr; + +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); +} +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); +} + using JoinEnabledFn = bool (*)(void*); using CanAddFn = bool (*)(void*, int); using AddSubFn = void (*)(void*, int); @@ -215,9 +230,12 @@ bool joinEnabledDetour(void* self) { 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; @@ -233,7 +251,6 @@ void addSubDetour(void* self, int controller) { note("join: addSubClientInstanceAndAssociateToUser({}) returned", controller); } -Module const* gGameModule = nullptr; // 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. @@ -260,6 +277,7 @@ bool lends(int controller) { 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; @@ -352,7 +370,7 @@ 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) answer = true; + if (!result && tAddingPlayer && controller >= 1 && controller <= 3 && tGameCanAdd == 1) answer = true; note("join: may a player be added for controller {}? the game alone would answer {}, answering {}", controller, result, answer); return answer; @@ -362,15 +380,17 @@ bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { // 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::set gJoined; +std::map gJoinTimes; void joinController(int controller) { void* game = gMinecraftGame.load(); { std::lock_guard lock(gJoinMutex); - if (gJoined.contains(controller)) return; + 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); - gJoined.insert(controller); + gJoinTimes[controller] = now; } note("join: controller {} pressed Start: adding player {}", controller, controller + 1); if (gGameModule) hookUserManager(*gGameModule, game); @@ -387,6 +407,7 @@ void joinController(int controller) { gLending.insert(controller); } tAddingPlayer = true; + tGameCanAdd = -1; addSubDetour(game, controller); tAddingPlayer = false; } @@ -422,6 +443,12 @@ void enableJoining(Module const& game) { && 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)); + writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); if (ok) onStartPressed(joinController); // W: in _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210), the first bl after // "add x2, sp, #164" (its out parameter) From da5f3adc9245efd8846628b39bd68380c51c3976 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 19:10:22 +0000 Subject: [PATCH 27/61] Android 0.3.2: Start on a controller that already plays no longer adds it again 0.3.1 on a device: the second player joined; Start on its controller again (to open or close the pause menu) started a second add for controller 1. The game's own check refused (user manager virtual 39(1): the controller already has a player) but canAddSplitscreenSubClient(1) said true, so the mod forced it; the game then released sub client 0 and crashed. Now the mod remembers the controllers it added a player for and does nothing on their Start (it is just Start, the pause menu), until the game removes a split-screen player (queueSubclientRemoval / releaseClientSubId), so they can join again; and the check is never forced when virtual 39 says the controller is taken. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 73eec63..856d593 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.3.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.3.2" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 11aa7c7..883fc81 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -197,17 +197,31 @@ constexpr size_t kAddSubClientSlot = 210, kJoinEnabledBack = 38, kCanAddBack = 3 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; + +void forgetPlayers(char const* why, int sub) { + std::lock_guard lock(gPlayingMutex); + if (gPlaying.empty()) return; + note("join: {}({}): the added players are gone", why, sub); + gPlaying.clear(); +} + 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*); @@ -298,8 +312,11 @@ bool user6Detour(void* self, int 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; } @@ -370,7 +387,12 @@ 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) answer = true; + 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; @@ -390,6 +412,10 @@ void joinController(int controller) { 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: controller {} pressed Start: adding player {}", controller, controller + 1); @@ -406,8 +432,9 @@ void joinController(int controller) { std::lock_guard lock(gLendMutex); gLending.insert(controller); } - tAddingPlayer = true; - tGameCanAdd = -1; + tAddingPlayer = true; + tGameCanAdd = -1; + tControllerTaken = -1; addSubDetour(game, controller); tAddingPlayer = false; } From 1bfd9106f9f35c391c1fc838d5578715dedd60b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 19:21:02 +0000 Subject: [PATCH 28/61] Android 0.4.0: player 1 keeps touch, every controller joins as a new player As on Windows, where player 1 keeps keyboard and mouse: controller numbers start at 1, so the first controller no longer moves player 1; Start on it adds player 2 (then 3, 4). Config firstControllerIsPlayer1 (default off) gives the first controller to player 1 as before. The Android README says what works and what is not ported yet. The diagnostics stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/README.md | 18 ++++++++++++------ android/config/config.json | 3 ++- android/config/config.schema.json | 5 +++++ android/src/ControllerSplit.cpp | 11 +++++++++-- android/src/ControllerSplit.h | 4 ++++ android/src/SplitScreen.cpp | 1 + 7 files changed, 34 insertions(+), 10 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 856d593..2473061 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.3.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.4.0" 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") diff --git a/android/README.md b/android/README.md index 8c19249..ee1cce8 100644 --- a/android/README.md +++ b/android/README.md @@ -6,14 +6,20 @@ built with LeviLauncher's [preloader-android](https://github.com/LiteLDev/preloa ## What this version does -Version 0.1.2 turns split screen on: it finds `AppPlatform::allowSplitScreen` in the game's vtable (slot 105 in -1.26) and makes it answer true. The slot is found from the order of AppPlatform's virtual functions, not from a -fixed number: three pure virtual functions two slots apart (`swapBuffers`, `getSystemLocale`, `pickImage`), then -`allowSplitScreen` 28 slots later, confirmed by the next function `splitScreenRenderDistScalar`. +Version 0.4 plays split screen on Android: -Everything it does is written to `splitscreen.log`, which is also copied to `Download/SplitScreenMod-android.log`. +- **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`). -Controllers, the player menu and the pause menu button of the Windows mod are not ported yet. +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 diff --git a/android/config/config.json b/android/config/config.json index ca983e0..ff513fd 100644 --- a/android/config/config.json +++ b/android/config/config.json @@ -1,5 +1,6 @@ { "forceSplitScreen": true, "separateControllers": true, - "forceSplitscreenJoin": true + "forceSplitscreenJoin": true, + "firstControllerIsPlayer1": false } diff --git a/android/config/config.schema.json b/android/config/config.schema.json index 822794a..8c57380 100644 --- a/android/config/config.schema.json +++ b/android/config/config.schema.json @@ -16,6 +16,11 @@ "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." } } } diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index f3967c8..cc0023b 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -55,15 +55,20 @@ std::map gControllerOf; 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}; + int controllerFor(int32_t device) { std::lock_guard lock(gDevicesMutex); if (auto it = gControllerOf.find(device); it != gControllerOf.end()) return it->second; std::array used{}; for (auto [d, c] : gControllerOf) used[c] = true; - for (int c = 0; c < kMaxControllers; ++c) + for (int c = gFirstController; c < kMaxControllers; ++c) if (!used[c]) { gControllerOf[device] = c; - gLog(fmt::format("controllers: device {} is controller {} (player {})", device, c, c + 1)); + gLog(fmt::format("controllers: device {} is controller {}{}", device, c, + c == 0 ? " (player 1)" : " (joins with Start as a new player)")); return c; } return -1; // more than 4: leave it on controller 0 as the game does @@ -215,6 +220,8 @@ std::string offset(uintptr_t p) { return fmt::format("+0x{:X}", p - gGame->base) void onStartPressed(std::function handler) { gStartPressed = std::move(handler); } +void setFirstControllerIsPlayer1(bool yes) { gFirstController = yes ? 0 : 1; } + bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, LogFn log) { static Module copy = game; diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 3621e5f..724751a 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -24,6 +24,10 @@ namespace splitscreen { 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 883fc81..5b9cad5 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -580,6 +580,7 @@ void probe() { 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"); } From 90d6828eb843880b875981ac3eaff4285d1fd53f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 19:32:10 +0000 Subject: [PATCH 29/61] Android 0.5.0: groundwork for in-game menus (forms) The Windows mod's menus are LeviLamina forms: a ModalFormRequestPacket (form id at +0x30, JSON at +0x38 after the 0x30-byte Packet) sent to one player, and the answer read in PacketHandlerDispatcherInstance::handle. On Android the plan is to send through the server's LoopbackPacketSender::sendToClient (NetworkIdentifier, Packet, SubClientId). This version finds those classes by RTTI and logs their vtables (getId should answer 100 and 101), traces LoopbackPacketSender's calls while in a world (to confirm which slot is sendToClient and to keep the object), and watches the PlayerAuthInputPacket dispatcher (every local player sends it: its NetworkIdentifier, SubClientId and the ServerNetworkHandler) and the ModalFormResponsePacket dispatcher. The trace now has 600 trampolines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/src/FormProbe.cpp | 135 ++++++++++++ android/src/FormProbe.h | 15 ++ android/src/SplitScreen.cpp | 2 + android/src/VTrace.cpp | 2 +- android/src/VTrace.h | 2 +- android/src/VTrampolines.S | 400 ++++++++++++++++++++++++++++++++++++ 7 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 android/src/FormProbe.cpp create mode 100644 android/src/FormProbe.h diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 2473061..d638a54 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.4.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.5.0" 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") @@ -47,7 +47,7 @@ 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/VTrace.cpp src/PostTrace.cpp src/Trampolines.S src/VTrampolines.S src/PostTrampolines.S) +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/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) diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp new file mode 100644 index 0000000..d0ae822 --- /dev/null +++ b/android/src/FormProbe.cpp @@ -0,0 +1,135 @@ +#include "FormProbe.h" + +#include "DeviceManagerProbe.h" +#include "VTrace.h" + +#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}; + +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); + 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); +} + +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); + gLog(fmt::format("forms: ModalFormResponsePacket from sub client {}, form id {}", packetSubId(packetRef), formId)); + 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 + +void startFormProbe(Module const& game, LogFn log) { + static Module copy = game; + gGame = © + gLog = std::move(log); + + // the packets themselves: getId should return 100 (request) and 101 (response) + find("22ModalFormRequestPacket", true); + find("23ModalFormResponsePacket", true); + + // the server's packet sender: traced (its slots, arguments and callers) while in a world + auto senders = find("20LoopbackPacketSender", true); + for (auto const& s : senders) + if (s.name == "20LoopbackPacketSender" && s.slots) + traceVtable(copy, s.vtable, s.slots, 400, "LoopbackPacketSender", gLog); + + // dispatchers: PlayerAuthInputPacket (every local player sends it) and ModalFormResponsePacket + for (auto const& d : find("PacketHandlerDispatcherInstanceI21PlayerAuthInputPacket", true)) + if (!gAuthHandle) hookHandle(d, reinterpret_cast(&authHandleDetour), &gAuthHandle, "PlayerAuthInputPacket"); + for (auto const& d : find("PacketHandlerDispatcherInstanceI23ModalFormResponsePacket", true)) + 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..bdd4468 --- /dev/null +++ b/android/src/FormProbe.h @@ -0,0 +1,15 @@ +#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" + +namespace splitscreen { + +void startFormProbe(Module const& game, LogFn log); + +} // namespace splitscreen diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 5b9cad5..4e0593b 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -17,6 +17,7 @@ #include "ControllerSplit.h" #include "VTrace.h" #include "PostTrace.h" +#include "FormProbe.h" #include @@ -646,6 +647,7 @@ void probe() { 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"); } diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp index 494ea47..52f52c9 100644 --- a/android/src/VTrace.cpp +++ b/android/src/VTrace.cpp @@ -8,7 +8,7 @@ #include namespace { -constexpr size_t kMaxSlots = 400; +constexpr size_t kMaxSlots = 600; } extern "C" { diff --git a/android/src/VTrace.h b/android/src/VTrace.h index cd0bd10..b2a1c0b 100644 --- a/android/src/VTrace.h +++ b/android/src/VTrace.h @@ -9,7 +9,7 @@ namespace splitscreen { -// Each traced class uses its own range of trampolines, starting at `first` (400 in all). +// 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); // Logs calls from now on for `seconds`, then a summary. diff --git a/android/src/VTrampolines.S b/android/src/VTrampolines.S index 1bf5e21..857f372 100644 --- a/android/src/VTrampolines.S +++ b/android/src/VTrampolines.S @@ -440,6 +440,206 @@ 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 @@ -845,5 +1045,205 @@ splitscreen_vtrampolines: .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 From cd4ed4df96ae7e45ea62366ba3e6e1ce1a12394a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 19:43:32 +0000 Subject: [PATCH 30/61] Android 0.5.1: send a first test form to an added player 0.5.0's log: LoopbackPacketSender virtual 7 is sendToClient(NetworkIdentifier, Packet, SubClientId) (called with the local players' NetworkIdentifier); all local players share that identifier and differ by sub client id; the dispatcher's callback is the ServerNetworkHandler; ModalFormRequestPacket's getId (slot 2) returns 100 and getSerializationMode reads +0x50, the response 101 and +0x58. Now: the server's sender is taken from the first sendToClient call; a ModalFormRequestPacket is built with the game's vtable (form id +0x30, JSON +0x38, mode +0x50, checked by a static_assert under libc++) and its handler pointing at the PacketHandlerDispatcherInstance object (found as the data word holding its vtable). Five seconds after an added player first sends input, it gets a two-button test form, sent from the PlayerAuthInputPacket handler (the server thread); the answer is logged from the ModalFormResponsePacket handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 122 +++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index d638a54..8ac0238 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.5.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.5.1" 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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index d0ae822..9a738cd 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -7,6 +7,9 @@ #include #include +#include +#include +#include #include #include #include @@ -64,6 +67,81 @@ 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 + +// 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; +} + +// Test: a simple form for each added player, 5 seconds after it first sends input. +std::map gFirstInput; +std::set gFormSent; + +void maybeSendTestForm(void const* source, int sub) { + if (sub < 1 || sub > 3) return; + auto now = std::chrono::steady_clock::now(); + { + std::lock_guard lock(gSeenMutex); + auto [it, fresh] = gFirstInput.try_emplace(sub, now); + if (fresh || now - it->second < std::chrono::seconds(5) || gFormSent.contains(sub)) return; + gFormSent.insert(sub); + } + sendForm(source, uint32_t(sub), 0x5C5C0000u + uint32_t(sub), + fmt::format(R"({{"type":"form","title":"Split screen","content":"Player {} - test menu","buttons":[{{"text":"First"}},{{"text":"Second"}}]}})", + sub + 1)); +} + uint8_t packetSubId(void* packetRef) { uintptr_t packet = 0; uint8_t sub = 0xFF; @@ -89,13 +167,21 @@ void authHandleDetour(void* self, void* source, void* callback, void* packetRef) if (!gTracingStarted.exchange(true)) startTracing(30); // the packet sender's calls while in a world } gAuthHandle(self, source, callback, packetRef); + maybeSendTestForm(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); - gLog(fmt::format("forms: ModalFormResponsePacket from sub client {}, form id {}", packetSubId(packetRef), formId)); + // 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); + gLog(fmt::format("forms: ModalFormResponsePacket from sub client {}, form id {}, value [{:016X} {:016X}] engaged {}, " + "cancel {} engaged {}", + packetSubId(packetRef), formId, *reinterpret_cast(answer + 8), + *reinterpret_cast(answer + 16), answer[0x18], answer[0x20], answer[0x21])); gResponseHandle(self, source, callback, packetRef); } @@ -114,20 +200,40 @@ void startFormProbe(Module const& game, LogFn log) { gGame = © gLog = std::move(log); - // the packets themselves: getId should return 100 (request) and 101 (response) - find("22ModalFormRequestPacket", true); - find("23ModalFormResponsePacket", true); + // 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")); // the server's packet sender: traced (its slots, arguments and callers) while in a world - auto senders = find("20LoopbackPacketSender", true); + auto senders = find("20LoopbackPacketSender", false); for (auto const& s : senders) - if (s.name == "20LoopbackPacketSender" && s.slots) + 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", true)) + for (auto const& d : find("PacketHandlerDispatcherInstanceI21PlayerAuthInputPacket", false)) if (!gAuthHandle) hookHandle(d, reinterpret_cast(&authHandleDetour), &gAuthHandle, "PlayerAuthInputPacket"); - for (auto const& d : find("PacketHandlerDispatcherInstanceI23ModalFormResponsePacket", true)) + for (auto const& d : find("PacketHandlerDispatcherInstanceI23ModalFormResponsePacket", false)) if (!gResponseHandle) hookHandle(d, reinterpret_cast(&responseHandleDetour), &gResponseHandle, "ModalFormResponsePacket"); } From b5a77ec7d9419e25ec08ac6edda8d20f7debf723 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 00:51:15 +0000 Subject: [PATCH 31/61] Android 0.5.2: a test form each time a player joins, again while it is busy 0.5.1 on a device: the first form appeared. Its answer came back at once as cancelled with reason 1 (UserBusy: the new player still had a screen open), and the test form was sent once per player per run, so it did not come back when the player joined again. Now every join (Start) asks for a test form 5 seconds later; a UserBusy answer sends it again 2 seconds later, up to 10 times. The answer's Json::Value is logged raw, to learn how the chosen button reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 45 ++++++++++++++++++++++++++++--------- android/src/FormProbe.h | 3 +++ android/src/SplitScreen.cpp | 1 + 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 8ac0238..fdeaaa0 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.5.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.5.2" 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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index 9a738cd..f616c3f 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -124,22 +124,36 @@ bool sendForm(void const* id, uint32_t sub, uint32_t formId, std::string json) { return true; } -// Test: a simple form for each added player, 5 seconds after it first sends input. -std::map gFirstInput; -std::set gFormSent; +// Test: a simple form for each added player, 5 seconds after it joins (requested by the join code). A player that +// is busy (another screen open) answers "UserBusy" at once: the form is sent again 2 seconds later, up to 10 times. +struct Pending { + std::chrono::steady_clock::time_point due; + int tries = 0; +}; +std::map gPending; // sub client -> pending test form void maybeSendTestForm(void const* source, int sub) { if (sub < 1 || sub > 3) return; auto now = std::chrono::steady_clock::now(); + int tries; { std::lock_guard lock(gSeenMutex); - auto [it, fresh] = gFirstInput.try_emplace(sub, now); - if (fresh || now - it->second < std::chrono::seconds(5) || gFormSent.contains(sub)) return; - gFormSent.insert(sub); + auto it = gPending.find(sub); + if (it == gPending.end() || now < it->second.due) return; + tries = ++it->second.tries; + it->second.due = now + std::chrono::hours(1); // until it is answered } sendForm(source, uint32_t(sub), 0x5C5C0000u + uint32_t(sub), - fmt::format(R"({{"type":"form","title":"Split screen","content":"Player {} - test menu","buttons":[{{"text":"First"}},{{"text":"Second"}}]}})", - sub + 1)); + fmt::format(R"j({{"type":"form","title":"Split screen","content":"Player {} - test menu (try {})","buttons":[{{"text":"First"}},{{"text":"Second"}}]}})j", + sub + 1, tries)); +} + +void formAnswered(int sub, bool busy) { + std::lock_guard lock(gSeenMutex); + auto it = gPending.find(sub); + if (it == gPending.end()) return; + if (busy && it->second.tries < 10) it->second.due = std::chrono::steady_clock::now() + std::chrono::seconds(2); + else gPending.erase(it); } uint8_t packetSubId(void* packetRef) { @@ -178,10 +192,13 @@ void responseHandleDetour(void* self, void* source, void* callback, void* packet // optional +0x50 (engaged +0x51) uint8_t answer[0x28] = {}; if (packet) safeRead(packet + 0x30, answer, sizeof answer); - gLog(fmt::format("forms: ModalFormResponsePacket from sub client {}, form id {}, value [{:016X} {:016X}] engaged {}, " - "cancel {} engaged {}", + 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])); + *reinterpret_cast(answer + 16), answer[0x18], answer[0x20], answer[0x21], raw)); + if (formId >> 16 == 0x5C5C) formAnswered(int(formId & 0xFFFF), answer[0x21] && answer[0x20] == 1); gResponseHandle(self, source, callback, packetRef); } @@ -195,6 +212,12 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh } // namespace +void requestTestForm(int sub) { + std::lock_guard lock(gSeenMutex); + gPending[sub] = Pending{std::chrono::steady_clock::now() + std::chrono::seconds(5), 0}; + gLog(fmt::format("forms: test form for sub client {} in 5 seconds", sub)); +} + void startFormProbe(Module const& game, LogFn log) { static Module copy = game; gGame = © diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index bdd4468..9335363 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -12,4 +12,7 @@ namespace splitscreen { void startFormProbe(Module const& game, LogFn log); +// Sends sub client `sub` (an added player) a test form 5 seconds from now. +void requestTestForm(int sub); + } // namespace splitscreen diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 4e0593b..2c9537e 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -438,6 +438,7 @@ void joinController(int controller) { tControllerTaken = -1; addSubDetour(game, controller); tAddingPlayer = false; + requestTestForm(controller); // the added player's sub client id is its controller number (1-3) } void enableJoining(Module const& game) { From 74b02e78bbf6b04aae784e65776379261b636f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 07:03:54 +0000 Subject: [PATCH 32/61] Android 0.6.0: Split screen menu on Select (leave split screen) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 27 ++++++++++- android/src/ControllerSplit.h | 7 +++ android/src/FormProbe.cpp | 86 ++++++++++++++++++++++++--------- android/src/FormProbe.h | 10 +++- android/src/SplitScreen.cpp | 20 +++++++- 6 files changed, 122 insertions(+), 30 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index fdeaaa0..1bbd375 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.5.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.0" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index cc0023b..7220dee 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include @@ -94,6 +95,11 @@ std::function gStartPressed; // 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; +std::vector> gTasks; // run on the input thread, after the next controller event constexpr int kKeyActionDown = 0; // --- dispatch --------------------------------------------------------------------------------------------------- @@ -106,12 +112,15 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, int controller = -1; uintptr_t event = 0; int32_t index = -1, head[2] = {0, 0}; - bool startDown = false; + bool startDown = false, selectDown = false; 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)) - startDown = key[kKeyCodeIndex] == kKeyCodeStart && key[2] == kKeyActionDown; + { + 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]); if (gEventsLogged < 24) { @@ -125,6 +134,13 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); tCurrentController = previous; if (startDown && controller >= 1 && gStartPressed) gStartPressed(controller); + if (selectDown && controller >= 1 && gSelectPressed) gSelectPressed(controller); + std::vector> tasks; + { + std::lock_guard lock(gTasksMutex); + tasks.swap(gTasks); + } + for (auto& task : tasks) task(); return result; } @@ -220,6 +236,13 @@ std::string offset(uintptr_t p) { return fmt::format("+0x{:X}", p - gGame->base) void onStartPressed(std::function handler) { gStartPressed = std::move(handler); } +void onSelectPressed(std::function handler) { gSelectPressed = std::move(handler); } + +void runOnInputThread(std::function task) { + std::lock_guard lock(gTasksMutex); + gTasks.push_back(std::move(task)); +} + void setFirstControllerIsPlayer1(bool yes) { gFirstController = yes ? 0 : 1; } bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 724751a..007cfcf 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -31,4 +31,11 @@ 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); + +// Runs `task` on the input thread (the game's main thread) after the next controller event. +void runOnInputThread(std::function task); + } // namespace splitscreen diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index f616c3f..58a34f9 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -124,36 +125,63 @@ bool sendForm(void const* id, uint32_t sub, uint32_t formId, std::string json) { return true; } -// Test: a simple form for each added player, 5 seconds after it joins (requested by the join code). A player that -// is busy (another screen open) answers "UserBusy" at once: the form is sent again 2 seconds later, up to 10 times. +// 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 2 seconds later, +// up to 10 times. 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::map gPending; // sub client -> pending test form +std::map gPending; // sub client -> its form +uint32_t gNextFormId = 0x5C5C0001; -void maybeSendTestForm(void const* source, int sub) { - if (sub < 1 || sub > 3) return; - auto now = std::chrono::steady_clock::now(); - int tries; +void maybeSendMenu(void const* source, int sub) { + uint32_t formId; + std::string json; { std::lock_guard lock(gSeenMutex); auto it = gPending.find(sub); - if (it == gPending.end() || now < it->second.due) return; - tries = ++it->second.tries; - it->second.due = now + std::chrono::hours(1); // until it is answered + if (it == gPending.end() || std::chrono::steady_clock::now() < it->second.due) return; + ++it->second.tries; + it->second.due = std::chrono::steady_clock::now() + std::chrono::hours(1); // until it is answered + formId = it->second.formId; + json = it->second.json; } - sendForm(source, uint32_t(sub), 0x5C5C0000u + uint32_t(sub), - fmt::format(R"j({{"type":"form","title":"Split screen","content":"Player {} - test menu (try {})","buttons":[{{"text":"First"}},{{"text":"Second"}}]}})j", - sub + 1, tries)); + sendForm(source, uint32_t(sub), formId, std::move(json)); } -void formAnswered(int sub, bool busy) { - std::lock_guard lock(gSeenMutex); - auto it = gPending.find(sub); - if (it == gPending.end()) return; - if (busy && it->second.tries < 10) it->second.due = std::chrono::steady_clock::now() + std::chrono::seconds(2); - else gPending.erase(it); +// 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) { + 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 < 10) { + it->second.due = std::chrono::steady_clock::now() + std::chrono::seconds(2); + 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) { @@ -181,7 +209,7 @@ void authHandleDetour(void* self, void* source, void* callback, void* packetRef) if (!gTracingStarted.exchange(true)) startTracing(30); // the packet sender's calls while in a world } gAuthHandle(self, source, callback, packetRef); - maybeSendTestForm(source, sub); + maybeSendMenu(source, sub); } void responseHandleDetour(void* self, void* source, void* callback, void* packetRef) { @@ -198,7 +226,12 @@ void responseHandleDetour(void* self, void* source, void* callback, void* packet "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) formAnswered(int(formId & 0xFFFF), answer[0x21] && answer[0x20] == 1); + 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); } @@ -212,10 +245,15 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh } // namespace -void requestTestForm(int sub) { +void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::function done) { + 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]) + "}"; + json += "]}"; std::lock_guard lock(gSeenMutex); - gPending[sub] = Pending{std::chrono::steady_clock::now() + std::chrono::seconds(5), 0}; - gLog(fmt::format("forms: test form for sub client {} in 5 seconds", sub)); + gPending[sub] = Pending{gNextFormId++, std::move(json), std::move(done), std::chrono::steady_clock::now(), 0}; + if (gNextFormId > 0x5C5CFFFF) gNextFormId = 0x5C5C0001; } void startFormProbe(Module const& game, LogFn log) { diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 9335363..8b46c5a 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -8,11 +8,17 @@ #include "Elf.h" #include "InputProbe.h" +#include +#include +#include + namespace splitscreen { void startFormProbe(Module const& game, LogFn log); -// Sends sub client `sub` (an added player) a test form 5 seconds from now. -void requestTestForm(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. +void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, + std::function done); } // namespace splitscreen diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 2c9537e..f9984b3 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -438,7 +438,24 @@ void joinController(int controller) { tControllerTaken = -1; addSubDetour(game, controller); tAddingPlayer = false; - requestTestForm(controller); // the added player's sub client id is its controller number (1-3) +} + +// 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). +void openSplitScreenMenu(int controller) { + { + std::lock_guard lock(gPlayingMutex); + if (!gPlaying.contains(controller)) return; + } + note("menus: player {} opens the Split screen menu", controller + 1); + showMenu(controller, "Split screen", fmt::format("Player {}", controller + 1), {"Leave split screen", "Cancel"}, + [controller](int button) { + if (button != 0) return; + runOnInputThread([controller] { + void* game = gMinecraftGame.load(); + if (game && gQueueRemoval) queueRemovalDetour(game, controller); + }); + }); } void enableJoining(Module const& game) { @@ -479,6 +496,7 @@ void enableJoining(Module const& game) { writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); if (ok) onStartPressed(joinController); + if (ok) onSelectPressed(openSplitScreenMenu); // W: in _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210), the first bl after // "add x2, sp, #164" (its out parameter) { From 636b11be46797e5c14f7a7e92d3deaa8824833d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 07:15:36 +0000 Subject: [PATCH 33/61] Android 0.6.1: leave split screen after the form screen closes; forget only the leaving player Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 19 +++++++++++++++---- android/src/ControllerSplit.h | 5 +++-- android/src/SplitScreen.cpp | 20 ++++++++++++++------ 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 1bbd375..2d201e1 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.1" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index 7220dee..985c016 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -99,7 +100,11 @@ constexpr int kKeyCodeSelect = 109; // KEYCODE_BUTTON_SELECT (View / Back / Sha std::function gSelectPressed; std::mutex gTasksMutex; -std::vector> gTasks; // run on the input thread, after the next controller event +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; // --- dispatch --------------------------------------------------------------------------------------------------- @@ -138,7 +143,13 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, std::vector> tasks; { std::lock_guard lock(gTasksMutex); - tasks.swap(gTasks); + 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(); return result; @@ -238,9 +249,9 @@ void onStartPressed(std::function handler) { gStartPressed = std::mov void onSelectPressed(std::function handler) { gSelectPressed = std::move(handler); } -void runOnInputThread(std::function task) { +void runOnInputThread(std::function task, std::chrono::milliseconds delay) { std::lock_guard lock(gTasksMutex); - gTasks.push_back(std::move(task)); + gTasks.push_back(Task{std::chrono::steady_clock::now() + delay, std::move(task)}); } void setFirstControllerIsPlayer1(bool yes) { gFirstController = yes ? 0 : 1; } diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 007cfcf..e9e551e 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -16,6 +16,7 @@ #include "Elf.h" #include "InputProbe.h" +#include #include namespace splitscreen { @@ -35,7 +36,7 @@ void onStartPressed(std::function handler); // (View / Back / Share). void onSelectPressed(std::function handler); -// Runs `task` on the input thread (the game's main thread) after the next controller event. -void runOnInputThread(std::function task); +// 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/SplitScreen.cpp b/android/src/SplitScreen.cpp index f9984b3..8be46b4 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -206,8 +206,8 @@ std::set gPlaying; void forgetPlayers(char const* why, int sub) { std::lock_guard lock(gPlayingMutex); if (gPlaying.empty()) return; - note("join: {}({}): the added players are gone", why, sub); - gPlaying.clear(); + if (!gPlaying.erase(sub)) return; + note("join: {}({}): player {} is gone", why, sub, sub + 1); } using SubIdFn = void (*)(void*, int); @@ -451,10 +451,16 @@ void openSplitScreenMenu(int controller) { showMenu(controller, "Split screen", fmt::format("Player {}", controller + 1), {"Leave split screen", "Cancel"}, [controller](int button) { if (button != 0) return; - runOnInputThread([controller] { - void* game = gMinecraftGame.load(); - if (game && gQueueRemoval) queueRemovalDetour(game, controller); - }); + // Removed at once, the player's client goes while its form screen is still closing, and the game + // crashes on a destroyed mutex: wait for the screen to close. + note("menus: player {} leaves split screen in 1.5 seconds", controller + 1); + runOnInputThread( + [controller] { + void* game = gMinecraftGame.load(); + note("menus: removing player {}", controller + 1); + if (game && gQueueRemoval) queueRemovalDetour(game, controller); + }, + std::chrono::milliseconds(1500)); }); } @@ -494,6 +500,8 @@ void enableJoining(Module const& game) { gQueueRemoval = reinterpret_cast(vt[10]); gReleaseSubId = reinterpret_cast(vt[16]); writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); + // The game's own "player leaves" path (1.21.x): the code before its queueSubclientRemoval call. + dumpCode(game, "leave", game.base + 0x9B7B540 - 0x600, 0x640, [](std::string const& line) { note("{}", line); }); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); if (ok) onStartPressed(joinController); if (ok) onSelectPressed(openSplitScreenMenu); From bdd8c9cbc6d41a7244a95da2a9524d772a16e864 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 07:23:11 +0000 Subject: [PATCH 34/61] Android 0.6.2: leave split screen through ClientInstance::requestLeaveGame, like the game does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 71 ++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 2d201e1..a32e169 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.2" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 8be46b4..7299655 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -440,6 +440,70 @@ void joinController(int controller) { tAddingPlayer = false; } +// ClientInstance: the game's own "this player leaves" (requestLeaveGame(bool switchScreen, bool sync)): it closes +// the player's screens and level and then calls MinecraftGame::queueSubclientRemoval. Calling queueSubclientRemoval +// alone leaves the player's client half there, and the game crashes on a destroyed mutex. The client of each sub +// client comes from getClientSubId (the slot requestLeaveGame calls for queueSubclientRemoval's argument). +using LeaveFn = void (*)(void*, bool, bool); +using GetSubIdFn = uint64_t (*)(void*); +LeaveFn gLeave = nullptr; +GetSubIdFn gGetSubId = nullptr; +std::atomic gClientBySub[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; +} + +void leaveDetour(void* self, bool switchScreen, bool sync) { + uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); + note("leave: requestLeaveGame({}, {}) of client {} (sub client {}) from +0x{:X}", switchScreen, sync, self, + gGetSubId ? int(gGetSubId(self) & 0xFF) : -1, gGameModule ? ret - gGameModule->base : ret); + gLeave(self, switchScreen, sync); +} + +void leaveSplitScreen(int sub) { + void* client = sub >= 1 && sub < 4 ? gClientBySub[sub].load() : nullptr; + if (!gLeave || !client) return note("menus: ERROR: no client for player {} (leave {})", sub + 1, gLeave != nullptr); + note("menus: player {} leaves (client {})", sub + 1, client); + leaveDetour(client, true, false); +} + +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: requestLeaveGame is slot {} (+0x{:X}), getClientSubId slot 303 ({})", i, + vt[i] - game.base, describeFunction(game, vt[303])); + gLeave = reinterpret_cast(vt[i]); + gGetSubId = reinterpret_cast(vt[303]); + return writeSlot(&vt[i], reinterpret_cast(&leaveDetour)) + && 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). void openSplitScreenMenu(int controller) { @@ -456,9 +520,7 @@ void openSplitScreenMenu(int controller) { note("menus: player {} leaves split screen in 1.5 seconds", controller + 1); runOnInputThread( [controller] { - void* game = gMinecraftGame.load(); - note("menus: removing player {}", controller + 1); - if (game && gQueueRemoval) queueRemovalDetour(game, controller); + leaveSplitScreen(controller); }, std::chrono::milliseconds(1500)); }); @@ -500,8 +562,7 @@ void enableJoining(Module const& game) { gQueueRemoval = reinterpret_cast(vt[10]); gReleaseSubId = reinterpret_cast(vt[16]); writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); - // The game's own "player leaves" path (1.21.x): the code before its queueSubclientRemoval call. - dumpCode(game, "leave", game.base + 0x9B7B540 - 0x600, 0x640, [](std::string const& line) { note("{}", line); }); + hookClientLeave(game); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); if (ok) onStartPressed(joinController); if (ok) onSelectPressed(openSplitScreenMenu); From 6aba5231377770f686ae6516c8c3486bca79c905 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 09:16:28 +0000 Subject: [PATCH 35/61] Android 0.6.3: leave split screen by setting the client's leave flag; log the leave flags Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 81 ++++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index a32e169..f0bb96c 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 7299655..97327fb 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -440,15 +440,20 @@ void joinController(int controller) { tAddingPlayer = false; } -// ClientInstance: the game's own "this player leaves" (requestLeaveGame(bool switchScreen, bool sync)): it closes -// the player's screens and level and then calls MinecraftGame::queueSubclientRemoval. Calling queueSubclientRemoval -// alone leaves the player's client half there, and the game crashes on a destroyed mutex. The client of each sub -// client comes from getClientSubId (the slot requestLeaveGame calls for queueSubclientRemoval's argument). -using LeaveFn = void (*)(void*, bool, bool); +// ClientInstance virtual 24 runs every frame for each client (args true, true). When the client's byte +0x6E5 is +// 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*); -LeaveFn gLeave = nullptr; -GetSubIdFn gGetSubId = nullptr; -std::atomic gClientBySub[4] = {}; +constexpr size_t kLeaveFlags = 0x6E0; +constexpr size_t kLeaveFlag = 0x6E5; +TickFn gClientTick = nullptr; +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); @@ -459,18 +464,43 @@ uint64_t getSubIdDetour(void* self) { return sub; } -void leaveDetour(void* self, bool switchScreen, bool sync) { - uintptr_t ret = reinterpret_cast(__builtin_return_address(0)); - note("leave: requestLeaveGame({}, {}) of client {} (sub client {}) from +0x{:X}", switchScreen, sync, self, - gGetSubId ? int(gGetSubId(self) & 0xFF) : -1, gGameModule ? ret - gGameModule->base : ret); - gLeave(self, switchScreen, sync); +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)) { + note("leave: player {} leaves: client {} byte +0x{:X} = 1", sub + 1, self, kLeaveFlag); + static_cast(self)[kLeaveFlag] = 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 (!gLeave || !client) return note("menus: ERROR: no client for player {} (leave {})", sub + 1, gLeave != nullptr); - note("menus: player {} leaves (client {})", sub + 1, client); - leaveDetour(client, true, false); + 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; } bool hookClientLeave(Module const& game) { @@ -490,11 +520,22 @@ bool hookClientLeave(Module const& game) { bool removal = false; for (size_t j = k + 1; j < k + 8; ++j) removal |= code[j] == kLdrRemoval; if (!removal) continue; - note("leave: requestLeaveGame is slot {} (+0x{:X}), getClientSubId slot 303 ({})", i, + note("leave: the client frame is slot {} (+0x{:X}), getClientSubId slot 303 ({})", i, vt[i] - game.base, describeFunction(game, vt[303])); - gLeave = reinterpret_cast(vt[i]); - gGetSubId = reinterpret_cast(vt[303]); - return writeSlot(&vt[i], reinterpret_cast(&leaveDetour)) + gClientTick = reinterpret_cast(vt[i]); + gGetSubId = reinterpret_cast(vt[303]); + // functions that set the leave flag: one of them is how the game asks a player to leave + constexpr uint32_t kStrbLeaveFlag = 0x39000000 | (kLeaveFlag << 10); // strb wN, [xM, #0x6E5] + 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; ++n) + if ((fn[n] & 0xFFFFFC00) == kStrbLeaveFlag && (fn[n] & 31) != 31) { + note("leave: slot {} (+0x{:X}) sets +0x6E5 at +0x{:X}", w, vt[w] - game.base, n * 4); + break; + } + } + return writeSlot(&vt[i], reinterpret_cast(&clientTickDetour)) && writeSlot(&vt[303], reinterpret_cast(&getSubIdDetour)); } } From 93c51acbf186df86d17a4d73d388d062506539de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 09:29:00 +0000 Subject: [PATCH 36/61] Android 0.6.4: leave split screen with the leave flags the pause menu sets Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index f0bb96c..d95ea7d 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.3" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.4" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 97327fb..e98535e 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -440,7 +440,7 @@ void joinController(int controller) { tAddingPlayer = false; } -// ClientInstance virtual 24 runs every frame for each client (args true, true). When the client's byte +0x6E5 is +// 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 @@ -473,8 +473,12 @@ uint64_t leaveFlags(void* client) { 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)) { - note("leave: player {} leaves: client {} byte +0x{:X} = 1", sub + 1, self, kLeaveFlag); - static_cast(self)[kLeaveFlag] = 1; + // what the game sets when a player leaves from the pause menu (0.6.3 log: 000000000000 -> 000100000101): + // +0x6E0, +0x6E1 (leave requested) and +0x6E4. Its next frame sets +0x6E3 and +0x6E5 and clears +0x6E1, and + // the frame after removes the player. Setting +0x6E5 alone skipped the closing and crashed. + 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); From 2d48a4fae35e4a5b518a32b612c194abbefd09b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 10:31:17 +0000 Subject: [PATCH 37/61] Android 0.6.5: one Split screen menu at a time per player Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 5 +++++ android/src/FormProbe.h | 3 +++ android/src/SplitScreen.cpp | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index d95ea7d..4321448 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.4" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.6.5" 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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index 58a34f9..bada279 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -245,6 +245,11 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh } // namespace +bool menuOpen(int sub) { + std::lock_guard lock(gSeenMutex); + return gPending.contains(sub); +} + void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, std::function done) { std::string json = "{\"type\":\"form\",\"title\":" + jsonString(title) + ",\"content\":" + jsonString(content) diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 8b46c5a..756cf2a 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -16,6 +16,9 @@ namespace splitscreen { void startFormProbe(Module const& game, LogFn log); +// 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. void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index e98535e..7d208fc 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -556,6 +556,7 @@ void openSplitScreenMenu(int controller) { std::lock_guard lock(gPlayingMutex); if (!gPlaying.contains(controller)) return; } + if (menuOpen(controller)) return note("menus: player {} already has a menu open", controller + 1); note("menus: player {} opens the Split screen menu", controller + 1); showMenu(controller, "Split screen", fmt::format("Player {}", controller + 1), {"Leave split screen", "Cancel"}, [controller](int button) { From 381e5cffe854127a838f39e8c90e239c903f578f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 10:31:36 +0000 Subject: [PATCH 38/61] Android 0.6.5: an unanswered menu stops blocking Select after a minute Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/src/FormProbe.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index bada279..a674ce8 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -134,6 +134,7 @@ struct Pending { 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::map gPending; // sub client -> its form uint32_t gNextFormId = 0x5C5C0001; @@ -247,7 +248,14 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh bool menuOpen(int sub) { std::lock_guard lock(gSeenMutex); - return gPending.contains(sub); + 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 showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, From 2f338d58945d831d4d372d07e953bbd95364739f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 10:36:21 +0000 Subject: [PATCH 39/61] Android 0.7.0: swap places, become a new player, who-is-playing picker for players without a controller; look for the layout option Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 40 ++++++++ android/src/ControllerSplit.h | 14 +++ android/src/SplitScreen.cpp | 156 +++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 12 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 4321448..db9bec2 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.6.5" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.7.0" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index 985c016..b3be7ac 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -61,11 +61,25 @@ thread_local int tCurrentController = -1; // player, as on Windows player 1 keeps keyboard and mouse); 0 gives the first controller to player 1. std::atomic gFirstController{1}; +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); if (auto it = gControllerOf.find(device); it != gControllerOf.end()) return it->second; 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; @@ -138,6 +152,13 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, tCurrentController = controller; uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); tCurrentController = previous; + std::vector tookOver; + { + 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 >= 1 && gSelectPressed) gSelectPressed(controller); std::vector> tasks; @@ -256,6 +277,25 @@ void runOnInputThread(std::function task, std::chrono::milliseconds dela 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); + for (auto [d, c] : gControllerOf) + if (c == controller) return true; + return false; +} + +void swapControllers(int a, int b) { + std::lock_guard lock(gDevicesMutex); + 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)); +} + bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, LogFn log) { static Module copy = game; diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index e9e551e..5657289 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -18,6 +18,7 @@ #include #include +#include namespace splitscreen { @@ -36,6 +37,19 @@ void onStartPressed(std::function handler); // (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 device of controller number a now plays as b, and b's (if any) as a. +void swapControllers(int a, int b); + // 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)); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 7d208fc..a91f969 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -22,6 +22,7 @@ #include +#include #include #include #include @@ -400,6 +401,72 @@ bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { } +void joinController(int controller); +void openSplitScreenMenu(int controller); + +std::vector playingPlayers() { + std::lock_guard lock(gPlayingMutex); + return {gPlaying.begin(), gPlaying.end()}; +} + +// Players 2-4 who play but have no controller (theirs was disconnected). +std::vector lostPlayers() { + std::vector out; + for (int p : playingPlayers()) + if (!controllerConnected(p)) out.push_back(p); + return out; +} + +// A controller number that neither plays nor has a controller, for a new player; -1 when all are taken. +int freeController() { + auto playing = playingPlayers(); + for (int c = 1; c <= 3; ++c) + if (std::find(playing.begin(), playing.end(), c) == playing.end() && !controllerConnected(c)) return c; + return -1; +} + +// "Who is playing?" on player `at`'s screen, whose controller just came from a new controller: it keeps playing +// `at`, plays another player who lost their controller, or joins as a new player (`at` stays without a controller). +void showPicker(int at) { + auto lost = lostPlayers(); + lost.erase(std::remove(lost.begin(), lost.end(), at), lost.end()); + std::vector buttons{fmt::format("Player {}", at + 1)}; + for (int p : lost) buttons.push_back(fmt::format("Player {} (no controller)", p + 1)); + bool canAdd = freeController() >= 0; + if (canAdd) buttons.push_back("New player"); + note("picker: asking who plays player {}'s new controller ({} other(s) without one, new player {})", at + 1, + lost.size(), canAdd); + showMenu(at, "Who is playing?", "A controller was connected. Who plays with it?", buttons, + [lost, at, canAdd](int button) { + if (button <= 0) return; // it keeps playing `at` (or the menu was closed) + if (button <= int(lost.size())) { + int pick = lost[size_t(button - 1)]; + note("picker: the controller plays player {}", pick + 1); + runOnInputThread([at, pick] { swapControllers(at, pick); }); + return; + } + if (!canAdd) return; + runOnInputThread([at] { + int free = freeController(); + if (free < 0) return note("picker: no free place for a new player"); + note("picker: the controller joins as new player {}", free + 1); + swapControllers(at, free); + joinController(free); + }, std::chrono::milliseconds(500)); + }); +} + +// A controller that was already connected (and plays nobody) pressed Start while players have lost their +// controller: it goes to the first of them, who is asked who plays. +bool offerLostPlayers(int controller) { + auto lost = lostPlayers(); + if (lost.empty()) return false; + note("picker: controller {} joins while {} player(s) have no controller", controller, lost.size()); + swapControllers(controller, lost.front()); + showPicker(lost.front()); + return true; +} + // 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; @@ -419,6 +486,7 @@ void joinController(int controller) { } gJoinTimes[controller] = now; } + if (offerLostPlayers(controller)) return; note("join: controller {} pressed Start: adding player {}", controller, controller + 1); if (gGameModule) hookUserManager(*gGameModule, game); { @@ -558,17 +626,55 @@ void openSplitScreenMenu(int controller) { } if (menuOpen(controller)) return note("menus: player {} already has a menu open", controller + 1); note("menus: player {} opens the Split screen menu", controller + 1); - showMenu(controller, "Split screen", fmt::format("Player {}", controller + 1), {"Leave split screen", "Cancel"}, - [controller](int button) { - if (button != 0) return; - // Removed at once, the player's client goes while its form screen is still closing, and the game - // crashes on a destroyed mutex: wait for the screen to close. - note("menus: player {} leaves split screen in 1.5 seconds", controller + 1); - runOnInputThread( - [controller] { - leaveSplitScreen(controller); - }, - std::chrono::milliseconds(1500)); + enum class Act { Swap, NewPlayer, Leave, Cancel }; + struct Item { + Act act; + int other; + }; + std::vector items; + std::vector buttons; + for (int p : playingPlayers()) { + if (p == controller) continue; + items.push_back({Act::Swap, p}); + buttons.push_back(controllerConnected(p) ? fmt::format("Swap places with Player {}", p + 1) + : fmt::format("Play Player {} (no controller)", p + 1)); + } + if (freeController() >= 0) { + items.push_back({Act::NewPlayer, -1}); + buttons.push_back("Become a new player"); + } + items.push_back({Act::Leave, -1}); + buttons.push_back("Leave split screen"); + items.push_back({Act::Cancel, -1}); + buttons.push_back("Cancel"); + showMenu(controller, "Split screen", fmt::format("You are Player {}", controller + 1), buttons, + [controller, items](int button) { + if (button < 0 || button >= int(items.size())) return; + Item item = items[size_t(button)]; + switch (item.act) { + case Act::Swap: + // this controller plays the other player; theirs (if any) plays this one + note("menus: player {} swaps places with player {}", controller + 1, item.other + 1); + runOnInputThread([controller, other = item.other] { swapControllers(controller, other); }); + break; + case Act::NewPlayer: + // this player stays, without a controller; the controller joins as a new player + runOnInputThread([controller] { + int free = freeController(); + if (free < 0) return note("menus: no free place for a new player"); + note("menus: player {}'s controller becomes new player {}", controller + 1, free + 1); + swapControllers(controller, free); + joinController(free); + }, std::chrono::milliseconds(500)); + break; + case Act::Leave: + // Removed at once, the player's client goes while its form screen is still closing, and the + // game crashes on a destroyed mutex: wait for the screen to close. + note("menus: player {} leaves split screen in 1.5 seconds", controller + 1); + runOnInputThread([controller] { leaveSplitScreen(controller); }, std::chrono::milliseconds(1500)); + break; + case Act::Cancel: break; + } }); } @@ -612,6 +718,8 @@ void enableJoining(Module const& game) { writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); if (ok) onStartPressed(joinController); if (ok) onSelectPressed(openSplitScreenMenu); + if (ok) setPlayingPlayers(playingPlayers); + if (ok) onLostPlayerTakenOver(showPicker); // W: in _addSubClientInstanceAndAssociateToUserInternal (the "b" target of slot 210), the first bl after // "add x2, sp, #164" (its out parameter) { @@ -677,6 +785,32 @@ void probe() { } 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; + } + } + } + } // which Android input functions the game imports (native input queue, Paddleboat controller library, ...) { auto imports = importedSymbols(*game); From 818f71243acbafc695baa3fdb2369a9a88ee6acf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 10:54:35 +0000 Subject: [PATCH 40/61] Android 0.7.1: notice switched-off controllers (Java InputDevice, else the longest silent), never give an extra controller to player 1, fix the new-player loop; log keys after a swap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 111 +++++++++++++++++++++++++++++++- android/src/SplitScreen.cpp | 16 +++-- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index db9bec2..e628839 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.7.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.7.1" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index b3be7ac..6ec2516 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -4,6 +4,9 @@ #include +#include +#include + #include #include @@ -54,6 +57,7 @@ std::atomic gRemapsLogged{0}; // device id -> controller number; the first controller gets 0 (player 1) std::mutex gDevicesMutex; std::map gControllerOf; +std::map gLastSeen; thread_local int tCurrentController = -1; @@ -61,13 +65,90 @@ thread_local int tCurrentController = -1; // 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)); + it = gControllerOf.erase(it); + } else + ++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); - if (auto it = gControllerOf.find(device); it != gControllerOf.end()) return it->second; + 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 @@ -87,7 +168,20 @@ int controllerFor(int32_t device) { c == 0 ? " (player 1)" : " (joins with Start as a new player)")); return c; } - return -1; // more than 4: leave it on controller 0 as the game does + // 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) { @@ -125,13 +219,14 @@ constexpr int kKeyActionDown = 0; 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 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; + bool startDown = false, selectDown = false, padEvent = false; if (safeRead(variant + 8, &index, 4) && index != -1 && safeRead(variant, &event, 8) && event && safeRead(event, head, 8)) { int32_t key[kKeyCodeIndex + 1] = {}; @@ -142,12 +237,19 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, } 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)); } } + if (padEvent && controller < 0 && gFirstController > 0) return 0; // a 5th controller would move player 1 int previous = tCurrentController; tCurrentController = controller; uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); @@ -283,6 +385,7 @@ void onLostPlayerTakenOver(std::function handler) { gTookOverFn = std bool controllerConnected(int controller) { std::lock_guard lock(gDevicesMutex); + purgeGoneDevices(false); for (auto [d, c] : gControllerOf) if (c == controller) return true; return false; @@ -294,6 +397,8 @@ void swapControllers(int a, int b) { if (c == a) c = b; else if (c == b) c = a; gLog(fmt::format("controllers: controllers {} and {} swapped", a, b)); + gKeysToLog = 40; + gRemapsLogged = 0; } bool startControllerSplit(Module const& game, uintptr_t managerObject, uintptr_t managerAdded, uintptr_t managerRemoved, diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index a91f969..bb45bbd 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -401,7 +401,7 @@ bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { } -void joinController(int controller); +void joinController(int controller, bool askLost = true); void openSplitScreenMenu(int controller); std::vector playingPlayers() { @@ -428,6 +428,10 @@ int freeController() { // "Who is playing?" on player `at`'s screen, whose controller just came from a new controller: it keeps playing // `at`, plays another player who lost their controller, or joins as a new player (`at` stays without a controller). void showPicker(int at) { + { + std::lock_guard lock(gPlayingMutex); + if (!gPlaying.contains(at)) return; // the controller took a free place: it joins with Start + } auto lost = lostPlayers(); lost.erase(std::remove(lost.begin(), lost.end(), at), lost.end()); std::vector buttons{fmt::format("Player {}", at + 1)}; @@ -451,7 +455,7 @@ void showPicker(int at) { if (free < 0) return note("picker: no free place for a new player"); note("picker: the controller joins as new player {}", free + 1); swapControllers(at, free); - joinController(free); + joinController(free, false); }, std::chrono::milliseconds(500)); }); } @@ -472,7 +476,7 @@ bool offerLostPlayers(int controller) { std::mutex gJoinMutex; std::map gJoinTimes; -void joinController(int controller) { +void joinController(int controller, bool askLost) { void* game = gMinecraftGame.load(); { std::lock_guard lock(gJoinMutex); @@ -486,7 +490,7 @@ void joinController(int controller) { } gJoinTimes[controller] = now; } - if (offerLostPlayers(controller)) return; + if (askLost && offerLostPlayers(controller)) return; note("join: controller {} pressed Start: adding player {}", controller, controller + 1); if (gGameModule) hookUserManager(*gGameModule, game); { @@ -664,7 +668,7 @@ void openSplitScreenMenu(int controller) { if (free < 0) return note("menus: no free place for a new player"); note("menus: player {}'s controller becomes new player {}", controller + 1, free + 1); swapControllers(controller, free); - joinController(free); + joinController(free, false); }, std::chrono::milliseconds(500)); break; case Act::Leave: @@ -716,7 +720,7 @@ void enableJoining(Module const& game) { writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); hookClientLeave(game); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); - if (ok) onStartPressed(joinController); + if (ok) onStartPressed([](int controller) { joinController(controller); }); if (ok) onSelectPressed(openSplitScreenMenu); if (ok) setPlayingPlayers(playingPlayers); if (ok) onLostPlayerTakenOver(showPicker); From 24fcbb94b6e1300011e3a33f9e07955582a6b5c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 10:56:57 +0000 Subject: [PATCH 41/61] Android 0.7.2: the SplitScreenUI resource pack as an .mcpack; black Choose-your-player picker that closes after 10 seconds; styled menus Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- .github/workflows/android.yml | 11 ++++- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 86 +++++++++++++++++++++++++++++++---- android/src/FormProbe.h | 6 ++- android/src/SplitScreen.cpp | 7 +-- 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 14fcc22..3103b9d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -34,6 +34,13 @@ jobs: -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 @@ -46,6 +53,6 @@ jobs: 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 \ + 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." + --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/android/CMakeLists.txt b/android/CMakeLists.txt index e628839..71b4a73 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.7.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.7.2" 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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index a674ce8..21b04ed 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -109,6 +109,31 @@ static_assert(offsetof(RequestPacket, formId) == 0x30 && offsetof(RequestPacket, && 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(); @@ -135,23 +160,44 @@ struct Pending { 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; void maybeSendMenu(void const* source, int sub) { - uint32_t formId; - std::string json; + 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() || std::chrono::steady_clock::now() < it->second.due) return; - ++it->second.tries; - it->second.due = std::chrono::steady_clock::now() + std::chrono::hours(1); // until it is answered - formId = it->second.formId; - json = it->second.json; + 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; + if (p.tries == 0) p.shown = now; + ++p.tries; + p.due = now + std::chrono::hours(1); // until it is answered + formId = p.formId; + json = p.json; + } + } + if (formId) { + sendForm(source, uint32_t(sub), formId, std::move(json)); + return; } - sendForm(source, uint32_t(sub), formId, std::move(json)); + 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. @@ -259,13 +305,16 @@ bool menuOpen(int sub) { } void showMenu(int sub, std::string const& title, std::string const& content, std::vector const& buttons, - std::function done) { + 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]) + "}"; json += "]}"; std::lock_guard lock(gSeenMutex); - gPending[sub] = Pending{gNextFormId++, std::move(json), std::move(done), std::chrono::steady_clock::now(), 0}; + 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; } @@ -294,6 +343,23 @@ void startFormProbe(Module const& game, LogFn log) { } } 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); diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 756cf2a..c5fc171 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -20,8 +20,10 @@ void startFormProbe(Module const& game, LogFn log); 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. +// 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); + std::function done, int timeoutSeconds = 0, int timeoutAnswer = -1); } // namespace splitscreen diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index bb45bbd..2abdbcc 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -440,7 +440,7 @@ void showPicker(int at) { if (canAdd) buttons.push_back("New player"); note("picker: asking who plays player {}'s new controller ({} other(s) without one, new player {})", at + 1, lost.size(), canAdd); - showMenu(at, "Who is playing?", "A controller was connected. Who plays with it?", buttons, + showMenu(at, "[ss:c]", "A controller was connected. Who plays with it?", buttons, [lost, at, canAdd](int button) { if (button <= 0) return; // it keeps playing `at` (or the menu was closed) if (button <= int(lost.size())) { @@ -457,7 +457,8 @@ void showPicker(int at) { swapControllers(at, free); joinController(free, false); }, std::chrono::milliseconds(500)); - }); + }, + 10, 0); // after 10 seconds it keeps playing `at` } // A controller that was already connected (and plays nobody) pressed Start while players have lost their @@ -651,7 +652,7 @@ void openSplitScreenMenu(int controller) { buttons.push_back("Leave split screen"); items.push_back({Act::Cancel, -1}); buttons.push_back("Cancel"); - showMenu(controller, "Split screen", fmt::format("You are Player {}", controller + 1), buttons, + showMenu(controller, "[ss:m]", fmt::format("You are Player {}", controller + 1), buttons, [controller, items](int button) { if (button < 0 || button >= int(items.size())) return; Item item = items[size_t(button)]; From 1d5b7bb19a608dc89a277a34b58c57945b6cfca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 11:05:04 +0000 Subject: [PATCH 42/61] Android 0.7.3: the resource pack comes inside the mod and is installed and turned on at start Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 8 +++- android/src/SplitScreen.cpp | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 71b4a73..4e0d215 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.7.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.7.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") @@ -68,7 +68,8 @@ add_custom_command(TARGET ${MOD_LIBRARY_NAME} POST_BUILD 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 and SplitScreenMod/libSplitScreenMod.so. +# 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}") @@ -79,6 +80,9 @@ add_custom_target(levi_package 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 (its pause menu button is for Windows) + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/../resource_packs/SplitScreenUI" "${PACKAGE_MOD_DIR}/pack" + COMMAND "${CMAKE_COMMAND}" -E rm -f "${PACKAGE_MOD_DIR}/pack/ui/pause_screen.json" 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} diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 2abdbcc..2c92b30 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -943,6 +943,79 @@ void watchGame() { } } +// 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/) and is put into the game's resource_packs folder and turned on as a global resource pack +// before the game reads its packs. The game's folder (games/com.mojang) is looked for in the launcher's storage. +constexpr char const* kPackUuid = "64c369bd-945a-4e1b-ab9a-4b3377148744"; + +void installResourcePack() { + namespace fs = std::filesystem; + std::error_code ec; + fs::path source = SplitScreenMod::instance().getSelf().getModDir() / "pack"; + if (!fs::exists(source / "manifest.json", ec)) { + // the mod folder LeviLauncher runs from may hold only the library: look next to the config too + source = SplitScreenMod::instance().getSelf().getConfigDir().parent_path() / "pack"; + if (!fs::exists(source / "manifest.json", ec)) return note("pack: the mod's resource pack is not in its folder"); + } + // the app's package, from the mod's path (/data/data//... or /data/user/0//...) + std::string modPath = SplitScreenMod::instance().getSelf().getModDir().string(), package = "org.levimc.launcher"; + for (char const* root : {"/data/data/", "/data/user/0/"}) + if (modPath.starts_with(root)) package = modPath.substr(strlen(root), modPath.find('/', strlen(root)) - strlen(root)); + std::vector candidates{ + "/storage/emulated/0/Android/data/" + package + "/files/games/com.mojang", + "/data/data/" + package + "/games/com.mojang", + "/data/data/" + package + "/files/games/com.mojang", + "/data/user/0/" + package + "/games/com.mojang", + "/storage/emulated/0/games/com.mojang", + }; + // also any games/com.mojang a few levels down in the app's own folders + for (fs::path base : {fs::path("/data/data/" + package), fs::path("/storage/emulated/0/Android/data/" + package)}) { + for (auto it = fs::recursive_directory_iterator(base, fs::directory_options::skip_permission_denied, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (it.depth() > 4) { + it.disable_recursion_pending(); + continue; + } + if (it->path().filename() == "com.mojang" && it->is_directory(ec)) candidates.push_back(it->path()); + } + ec.clear(); + } + fs::path target; + fs::file_time_type newest{}; + for (auto const& c : candidates) { + bool has = fs::exists(c / "minecraftpe", ec); + auto t = fs::last_write_time(c / "minecraftpe", ec); + note("pack: game folder {}: {}", c.string(), has ? "found" : "no"); + if (has && (target.empty() || t > newest)) target = c, newest = t; + } + if (target.empty()) return note("pack: ERROR: the game's folder (games/com.mojang) was not found"); + + fs::path packDir = target / "resource_packs" / "SplitScreenUI"; + fs::create_directories(packDir, ec); + fs::copy(source, packDir, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + note("pack: copied to {}{}", packDir.string(), ec ? " (ERROR: " + ec.message() + ")" : ""); + + fs::path globalFile = target / "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: already 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 ? "" : " (ERROR: could not write)"); +} + } // namespace SplitScreenMod& SplitScreenMod::instance() { @@ -958,6 +1031,7 @@ bool SplitScreenMod::load() { gLog.open(log, std::ios::trunc); openDownloadLog(); note("SplitScreenMod for Android {} (experimental)", SPLITSCREEN_VERSION); + installResourcePack(); return true; } From c0f42d1fd0379159a7f90155877567aaac18300a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 11:28:02 +0000 Subject: [PATCH 43/61] Android 0.8.0: the menus and joining follow the Windows version's logic (Menus.cpp: openSettings, offerPicker, startJoin, checkControllers); find the game's folder from the files it has open; menu texts in the game's language Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 4 +- android/src/ControllerSplit.cpp | 2 +- android/src/FormProbe.cpp | 18 +- android/src/FormProbe.h | 8 + android/src/Menus.cpp | 311 +++++++++++++++++++++++++++++++ android/src/Menus.h | 50 +++++ android/src/SplitScreen.cpp | 318 +++++++++++++------------------- 7 files changed, 521 insertions(+), 190 deletions(-) create mode 100644 android/src/Menus.cpp create mode 100644 android/src/Menus.h diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 4e0d215..384c095 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.7.3" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.8.0" 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") @@ -47,7 +47,7 @@ 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/VTrace.cpp src/PostTrace.cpp src/Trampolines.S src/VTrampolines.S src/PostTrampolines.S) +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) diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index 6ec2516..7ee4484 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -262,7 +262,7 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, if (gTookOverFn) for (int c : tookOver) gTookOverFn(c); if (startDown && controller >= 1 && gStartPressed) gStartPressed(controller); - if (selectDown && controller >= 1 && gSelectPressed) gSelectPressed(controller); + if (selectDown && controller >= 0 && gSelectPressed) gSelectPressed(controller); // 0: a controller given to player 1 std::vector> tasks; { std::lock_guard lock(gTasksMutex); diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index 21b04ed..e2aa5fd 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -304,11 +304,27 @@ bool menuOpen(int sub) { return 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]) + "}"; + 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}; diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index c5fc171..365e2a2 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -16,6 +16,14 @@ 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); + +// Forgets player `sub`'s menu (the player leaves); its answer, if one still comes, is ignored. +void cancelMenu(int sub); + // Whether player `sub` has a menu open (or about to open). bool menuOpen(int sub); diff --git a/android/src/Menus.cpp b/android/src/Menus.cpp new file mode 100644 index 0000000..8968cbf --- /dev/null +++ b/android/src/Menus.cpp @@ -0,0 +1,311 @@ +#include "Menus.h" + +#include "FormProbe.h" + +#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), a row that leaves room for that square, and the green closing button. +constexpr char const* kKickButton = "[x]"; +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 +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: + game::moveController(controller, choice.value); + tell(choice.sub, tr("splitscreen.tell.youAreNow", {localName(choice.sub)})); + if (fresh) game::requestLeave(owner); // the player made for this controller is not needed any more + 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(); + } + auto texts = readLang(pack / "texts" / "en_US.lang"); + for (auto& [k, v] : readLang(pack / "texts" / (language + ".lang"))) 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); + + enum class Act { Swap, Kick, Done, Nothing }; + struct Item { + Act act; + int sub; + }; + // The Windows version's menu also has the layout (top and bottom / side by side), saved players who are not + // playing (to switch to) and player 1's controller; 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 + bool square = sub != 0; + 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({Act::Kick, sub}); + buttons.push_back(kKickButton); + pictures.push_back("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); + showMenu(who, "", buttons, pictures, [items, who, myPad](int pick) { + 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")); + game::moveController(myPad, players.at(item.sub)); + tell(item.sub, tr("splitscreen.tell.youAreNow", {localName(item.sub)})); + // nothing comes back to this player (the other one had no controller): it leaves instead of waiting + if (!theyHavePad && who != 0) game::requestLeave(who); + 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 (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); + 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(); + 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..850bb18 --- /dev/null +++ b/android/src/Menus.h @@ -0,0 +1,50 @@ +#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); +// 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/SplitScreen.cpp b/android/src/SplitScreen.cpp index 2c92b30..e264c9d 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -18,6 +18,7 @@ #include "VTrace.h" #include "PostTrace.h" #include "FormProbe.h" +#include "Menus.h" #include @@ -205,10 +206,12 @@ std::mutex gPlayingMutex; std::set gPlaying; void forgetPlayers(char const* why, int sub) { - std::lock_guard lock(gPlayingMutex); - if (gPlaying.empty()) return; - if (!gPlaying.erase(sub)) return; + { + std::lock_guard lock(gPlayingMutex); + if (!gPlaying.erase(sub)) return; + } note("join: {}({}): player {} is gone", why, sub, sub + 1); + menus::onPlayerLeft(sub); } using SubIdFn = void (*)(void*, int); @@ -401,83 +404,12 @@ bool addCheckDetour(void* game, int controller, uint8_t* out, bool flag) { } -void joinController(int controller, bool askLost = true); -void openSplitScreenMenu(int controller); - -std::vector playingPlayers() { - std::lock_guard lock(gPlayingMutex); - return {gPlaying.begin(), gPlaying.end()}; -} - -// Players 2-4 who play but have no controller (theirs was disconnected). -std::vector lostPlayers() { - std::vector out; - for (int p : playingPlayers()) - if (!controllerConnected(p)) out.push_back(p); - return out; -} - -// A controller number that neither plays nor has a controller, for a new player; -1 when all are taken. -int freeController() { - auto playing = playingPlayers(); - for (int c = 1; c <= 3; ++c) - if (std::find(playing.begin(), playing.end(), c) == playing.end() && !controllerConnected(c)) return c; - return -1; -} - -// "Who is playing?" on player `at`'s screen, whose controller just came from a new controller: it keeps playing -// `at`, plays another player who lost their controller, or joins as a new player (`at` stays without a controller). -void showPicker(int at) { - { - std::lock_guard lock(gPlayingMutex); - if (!gPlaying.contains(at)) return; // the controller took a free place: it joins with Start - } - auto lost = lostPlayers(); - lost.erase(std::remove(lost.begin(), lost.end(), at), lost.end()); - std::vector buttons{fmt::format("Player {}", at + 1)}; - for (int p : lost) buttons.push_back(fmt::format("Player {} (no controller)", p + 1)); - bool canAdd = freeController() >= 0; - if (canAdd) buttons.push_back("New player"); - note("picker: asking who plays player {}'s new controller ({} other(s) without one, new player {})", at + 1, - lost.size(), canAdd); - showMenu(at, "[ss:c]", "A controller was connected. Who plays with it?", buttons, - [lost, at, canAdd](int button) { - if (button <= 0) return; // it keeps playing `at` (or the menu was closed) - if (button <= int(lost.size())) { - int pick = lost[size_t(button - 1)]; - note("picker: the controller plays player {}", pick + 1); - runOnInputThread([at, pick] { swapControllers(at, pick); }); - return; - } - if (!canAdd) return; - runOnInputThread([at] { - int free = freeController(); - if (free < 0) return note("picker: no free place for a new player"); - note("picker: the controller joins as new player {}", free + 1); - swapControllers(at, free); - joinController(free, false); - }, std::chrono::milliseconds(500)); - }, - 10, 0); // after 10 seconds it keeps playing `at` -} - -// A controller that was already connected (and plays nobody) pressed Start while players have lost their -// controller: it goes to the first of them, who is asked who plays. -bool offerLostPlayers(int controller) { - auto lost = lostPlayers(); - if (lost.empty()) return false; - note("picker: controller {} joins while {} player(s) have no controller", controller, lost.size()); - swapControllers(controller, lost.front()); - showPicker(lost.front()); - return true; -} - // 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, bool askLost) { +void joinController(int controller) { void* game = gMinecraftGame.load(); { std::lock_guard lock(gJoinMutex); @@ -491,7 +423,6 @@ void joinController(int controller, bool askLost) { } gJoinTimes[controller] = now; } - if (askLost && offerLostPlayers(controller)) return; note("join: controller {} pressed Start: adding player {}", controller, controller + 1); if (gGameModule) hookUserManager(*gGameModule, game); { @@ -580,6 +511,52 @@ void leaveSplitScreen(int sub) { gLeaveWanted[sub] = true; } +} // namespace + +void logLine(std::string const& line) { note("{}", line); } + +// 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) 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 + // 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. + runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::milliseconds(1500)); +} + +void moveController(int from, int to) { swapControllers(from, to); } + +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 @@ -624,65 +601,6 @@ bool hookClientLeave(Module const& game) { // 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). -void openSplitScreenMenu(int controller) { - { - std::lock_guard lock(gPlayingMutex); - if (!gPlaying.contains(controller)) return; - } - if (menuOpen(controller)) return note("menus: player {} already has a menu open", controller + 1); - note("menus: player {} opens the Split screen menu", controller + 1); - enum class Act { Swap, NewPlayer, Leave, Cancel }; - struct Item { - Act act; - int other; - }; - std::vector items; - std::vector buttons; - for (int p : playingPlayers()) { - if (p == controller) continue; - items.push_back({Act::Swap, p}); - buttons.push_back(controllerConnected(p) ? fmt::format("Swap places with Player {}", p + 1) - : fmt::format("Play Player {} (no controller)", p + 1)); - } - if (freeController() >= 0) { - items.push_back({Act::NewPlayer, -1}); - buttons.push_back("Become a new player"); - } - items.push_back({Act::Leave, -1}); - buttons.push_back("Leave split screen"); - items.push_back({Act::Cancel, -1}); - buttons.push_back("Cancel"); - showMenu(controller, "[ss:m]", fmt::format("You are Player {}", controller + 1), buttons, - [controller, items](int button) { - if (button < 0 || button >= int(items.size())) return; - Item item = items[size_t(button)]; - switch (item.act) { - case Act::Swap: - // this controller plays the other player; theirs (if any) plays this one - note("menus: player {} swaps places with player {}", controller + 1, item.other + 1); - runOnInputThread([controller, other = item.other] { swapControllers(controller, other); }); - break; - case Act::NewPlayer: - // this player stays, without a controller; the controller joins as a new player - runOnInputThread([controller] { - int free = freeController(); - if (free < 0) return note("menus: no free place for a new player"); - note("menus: player {}'s controller becomes new player {}", controller + 1, free + 1); - swapControllers(controller, free); - joinController(free, false); - }, std::chrono::milliseconds(500)); - break; - case Act::Leave: - // Removed at once, the player's client goes while its form screen is still closing, and the - // game crashes on a destroyed mutex: wait for the screen to close. - note("menus: player {} leaves split screen in 1.5 seconds", controller + 1); - runOnInputThread([controller] { leaveSplitScreen(controller); }, std::chrono::milliseconds(1500)); - break; - case Act::Cancel: break; - } - }); -} - void enableJoining(Module const& game) { static Module copy = game; gGameModule = © @@ -721,10 +639,15 @@ void enableJoining(Module const& game) { writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); hookClientLeave(game); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); - if (ok) onStartPressed([](int controller) { joinController(controller); }); - if (ok) onSelectPressed(openSplitScreenMenu); - if (ok) setPlayingPlayers(playingPlayers); - if (ok) onLostPlayerTakenOver(showPicker); + if (ok) onStartPressed([](int controller) { + if (!game::players().contains(controller)) menus::onJoinRequest(controller); // else: its pause menu + }); + if (ok) onSelectPressed(menus::onSettingsButton); + if (ok) setPlayingPlayers([] { + std::lock_guard lock(gPlayingMutex); + return std::vector(gPlaying.begin(), gPlaying.end()); + }); + 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) { @@ -944,66 +867,49 @@ void watchGame() { } // 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/) and is put into the game's resource_packs folder and turned on as a global resource pack -// before the game reads its packs. The game's folder (games/com.mojang) is looked for in the launcher's storage. +// 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; -void installResourcePack() { - 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; - fs::path source = SplitScreenMod::instance().getSelf().getModDir() / "pack"; - if (!fs::exists(source / "manifest.json", ec)) { - // the mod folder LeviLauncher runs from may hold only the library: look next to the config too - source = SplitScreenMod::instance().getSelf().getConfigDir().parent_path() / "pack"; - if (!fs::exists(source / "manifest.json", ec)) return note("pack: the mod's resource pack is not in its folder"); + 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; } - // the app's package, from the mod's path (/data/data//... or /data/user/0//...) - std::string modPath = SplitScreenMod::instance().getSelf().getModDir().string(), package = "org.levimc.launcher"; - for (char const* root : {"/data/data/", "/data/user/0/"}) - if (modPath.starts_with(root)) package = modPath.substr(strlen(root), modPath.find('/', strlen(root)) - strlen(root)); - std::vector candidates{ - "/storage/emulated/0/Android/data/" + package + "/files/games/com.mojang", - "/data/data/" + package + "/games/com.mojang", - "/data/data/" + package + "/files/games/com.mojang", - "/data/user/0/" + package + "/games/com.mojang", - "/storage/emulated/0/games/com.mojang", - }; - // also any games/com.mojang a few levels down in the app's own folders - for (fs::path base : {fs::path("/data/data/" + package), fs::path("/storage/emulated/0/Android/data/" + package)}) { - for (auto it = fs::recursive_directory_iterator(base, fs::directory_options::skip_permission_denied, ec); - !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { - if (it.depth() > 4) { - it.disable_recursion_pending(); - continue; - } - if (it->path().filename() == "com.mojang" && it->is_directory(ec)) candidates.push_back(it->path()); - } - ec.clear(); - } - fs::path target; - fs::file_time_type newest{}; - for (auto const& c : candidates) { - bool has = fs::exists(c / "minecraftpe", ec); - auto t = fs::last_write_time(c / "minecraftpe", ec); - note("pack: game folder {}: {}", c.string(), has ? "found" : "no"); - if (has && (target.empty() || t > newest)) target = c, newest = t; - } - if (target.empty()) return note("pack: ERROR: the game's folder (games/com.mojang) was not found"); + return {}; +} - fs::path packDir = target / "resource_packs" / "SplitScreenUI"; +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(source, packDir, fs::copy_options::recursive | fs::copy_options::overwrite_existing, 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 = target / "minecraftpe" / "global_resource_packs.json"; + fs::path globalFile = folder / "minecraftpe" / "global_resource_packs.json"; std::string text; { - std::ifstream in(globalFile); + std::ifstream in(globalFile); std::stringstream ss; ss << in.rdbuf(); text = ss.str(); } - if (text.find(kPackUuid) != std::string::npos) return note("pack: already turned on in {}", globalFile.string()); + 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 + "]"; @@ -1013,7 +919,46 @@ void installResourcePack() { } std::ofstream out(globalFile, std::ios::trunc); out << text; - note("pack: turned on in {}{}", globalFile.string(), out ? "" : " (ERROR: could not write)"); + 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 @@ -1039,6 +984,7 @@ bool SplitScreenMod::enable() { std::call_once(gStarted, [] { std::thread([] { probe(); + std::thread(watchControllers).detach(); watchGame(); }).detach(); }); From bbc3d6087aa0640903be5d535363977fe5ec7609 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 11:32:31 +0000 Subject: [PATCH 44/61] Android 0.8.1: no menu on a player who is leaving Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 384c095..a26dd6d 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.8.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.8.1" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index e264c9d..c5eb7ef 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -204,10 +204,14 @@ Module const* gGameModule = nullptr; // 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; void forgetPlayers(char const* why, int sub) { { std::lock_guard lock(gPlayingMutex); + gLeaving.erase(sub); if (!gPlaying.erase(sub)) return; } note("join: {}({}): player {} is gone", why, sub, sub + 1); @@ -521,7 +525,8 @@ namespace game { std::map players() { std::map out{{0, 0}}; std::lock_guard lock(gPlayingMutex); - for (int sub : gPlaying) out[sub] = sub; + for (int sub : gPlaying) + if (!gLeaving.contains(sub)) out[sub] = sub; return out; } @@ -529,6 +534,11 @@ 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); // 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. runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::milliseconds(1500)); From df9c51232cedac0f36d0190074d01d399ae2a57b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 12:51:42 +0000 Subject: [PATCH 45/61] Android 0.8.2: a leaving player gets no input and no new controller (crash); texts in the phone's language when the game names none Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 6 ++++++ android/src/ControllerSplit.h | 3 +++ android/src/Menus.cpp | 8 ++++++++ android/src/SplitScreen.cpp | 11 +++++++++-- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index a26dd6d..4afd7bf 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.8.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.8.2" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index 7ee4484..46b63b4 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -220,6 +220,7 @@ using Fn8 = uint64_t (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, uint6 Fn8 gDispatch = nullptr; std::atomic gEventsLogged{0}; std::atomic gKeysToLog{0}; // after a swap: which keys each controller presses +std::atomic gBlocked[kMaxControllers] = {}; 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) { @@ -250,6 +251,7 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, } } if (padEvent && controller < 0 && gFirstController > 0) return 0; // a 5th controller would move player 1 + if (padEvent && controller >= 0 && gBlocked[controller]) return 0; // its player is leaving int previous = tCurrentController; tCurrentController = controller; uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); @@ -391,6 +393,10 @@ bool controllerConnected(int controller) { return false; } +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); for (auto& [d, c] : gControllerOf) diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 5657289..599a0c8 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -47,6 +47,9 @@ void onLostPlayerTakenOver(std::function handler); // Whether a connected device plays as controller number `controller`. bool controllerConnected(int controller); +// 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); diff --git a/android/src/Menus.cpp b/android/src/Menus.cpp index 8968cbf..9c7a1bd 100644 --- a/android/src/Menus.cpp +++ b/android/src/Menus.cpp @@ -12,6 +12,8 @@ #include +#include + namespace splitscreen { void logLine(std::string const& line); // SplitScreen.cpp @@ -178,6 +180,12 @@ void setTexts(std::filesystem::path const& pack, std::filesystem::path const& op 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" + char value[PROP_VALUE_MAX] = {}; + __system_property_get("persist.sys.locale", value); + language = value; + std::replace(language.begin(), language.end(), '-', '_'); + } auto texts = readLang(pack / "texts" / "en_US.lang"); for (auto& [k, v] : readLang(pack / "texts" / (language + ".lang"))) texts[k] = v; note("menus: texts in {} ({} lines)", language, texts.size()); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index c5eb7ef..e9026e0 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -216,6 +216,7 @@ void forgetPlayers(char const* why, int sub) { } note("join: {}({}): player {} is gone", why, sub, sub + 1); menus::onPlayerLeft(sub); + runOnInputThread([sub] { blockController(sub, false); }, std::chrono::seconds(1)); // once it is gone for good } using SubIdFn = void (*)(void*, int); @@ -539,6 +540,9 @@ void requestLeave(int sub) { if (!gPlaying.contains(sub) || !gLeaving.insert(sub).second) return; } cancelMenu(sub); + // A leaving player gets no more input: a button press that reached the player while it was being removed made the + // game throw (0.7.3 and 0.8.1 crashes, "terminating"). + 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. runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::milliseconds(1500)); @@ -654,8 +658,11 @@ void enableJoining(Module const& game) { }); if (ok) onSelectPressed(menus::onSettingsButton); if (ok) setPlayingPlayers([] { - std::lock_guard lock(gPlayingMutex); - return std::vector(gPlaying.begin(), gPlaying.end()); + 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 From c01838b6ae03c26dd86a11961cecfe7cd93c9e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 13:05:56 +0000 Subject: [PATCH 46/61] Android 0.9.0: players join through the game's own prompt (startJoins in config.json for Start), not outside a world or while it loads; player 1 keeps the touch screen; menus come sooner; a leaving player waits 3 s Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/config/config.json | 3 ++- android/config/config.schema.json | 7 +++++- android/src/FormProbe.cpp | 27 ++++++++++++++++++----- android/src/FormProbe.h | 3 +++ android/src/Menus.cpp | 1 + android/src/SplitScreen.cpp | 36 +++++++++++++++++++++++++------ 7 files changed, 64 insertions(+), 15 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 4afd7bf..f3d21eb 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.8.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.0" 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") diff --git a/android/config/config.json b/android/config/config.json index ff513fd..a22b74d 100644 --- a/android/config/config.json +++ b/android/config/config.json @@ -2,5 +2,6 @@ "forceSplitScreen": true, "separateControllers": true, "forceSplitscreenJoin": true, - "firstControllerIsPlayer1": false + "firstControllerIsPlayer1": false, + "startJoins": false } diff --git a/android/config/config.schema.json b/android/config/config.schema.json index 8c57380..0cfb952 100644 --- a/android/config/config.schema.json +++ b/android/config/config.schema.json @@ -20,7 +20,12 @@ "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." + "description": "Off: player 1 keeps the touch screen and every controller joins as a new player through the game's join prompt. On: the first controller plays player 1." + }, + "startJoins": { + "type": "boolean", + "title": "Start joins directly", + "description": "Off: players join through the game's own join prompt. On: Start on a controller without a player asks to join at once (if the game shows no prompt)." } } } diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index e2aa5fd..3d8e3d0 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -151,8 +151,8 @@ bool sendForm(void const* id, uint32_t sub, uint32_t formId, std::string json) { } // 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 2 seconds later, -// up to 10 times. The answer: a Json::Value (+0x38: 8-byte value, type +0x40, 1 = int) holding the button index. +// 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; @@ -184,7 +184,7 @@ void maybeSendMenu(void const* source, int sub) { gPending.erase(it); } else { if (now < p.due) return; - if (p.tries == 0) p.shown = now; + 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; @@ -207,8 +207,8 @@ void menuAnswered(int sub, uint32_t formId, bool busy, int button) { std::lock_guard lock(gSeenMutex); auto it = gPending.find(sub); if (it == gPending.end() || it->second.formId != formId) return; - if (busy && it->second.tries < 10) { - it->second.due = std::chrono::steady_clock::now() + std::chrono::seconds(2); + 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); @@ -238,8 +238,20 @@ uint8_t packetSubId(void* packetRef) { return sub; } +// 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(); +} + 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); @@ -292,6 +304,11 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh } // namespace +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); diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 365e2a2..638baa0 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -24,6 +24,9 @@ void showForm(int sub, std::string const& title, std::string const& content, std // Forgets player `sub`'s menu (the player leaves); its answer, if one still comes, is ignored. void cancelMenu(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); diff --git a/android/src/Menus.cpp b/android/src/Menus.cpp index 9c7a1bd..d4d1c3f 100644 --- a/android/src/Menus.cpp +++ b/android/src/Menus.cpp @@ -269,6 +269,7 @@ void onSettingsButton(int who) { // 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; diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index e9026e0..e54135c 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -269,7 +269,17 @@ bool canAddDetour(void* self, int controller) { 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); @@ -428,7 +438,7 @@ void joinController(int controller) { } gJoinTimes[controller] = now; } - note("join: controller {} pressed Start: adding player {}", controller, controller + 1); + note("join: adding player {} for controller {}", controller + 1, controller); if (gGameModule) hookUserManager(*gGameModule, game); { auto const* g = reinterpret_cast(game); @@ -445,7 +455,9 @@ void joinController(int controller) { tAddingPlayer = true; tGameCanAdd = -1; tControllerTaken = -1; + tOurJoin = true; addSubDetour(game, controller); + tOurJoin = false; tAddingPlayer = false; } @@ -531,7 +543,9 @@ std::map players() { return out; } -bool hasController(int controller) { return controllerConnected(controller); } +// Player 1 plays on the touch screen: that is its controller (it is never "without a controller", and nobody takes it +// over: a controller given to player 1 made the game drop players and crash). +bool hasController(int controller) { return controller == 0 || controllerConnected(controller); } void requestLeave(int sub) { if (sub < 1 || sub > 3) return; // player 1 leaving is leaving the world @@ -544,11 +558,15 @@ void requestLeave(int sub) { // game throw (0.7.3 and 0.8.1 crashes, "terminating"). 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. - runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::milliseconds(1500)); + // player leaves once its screen has closed + // (3 s: with the resource pack's menu look, 1.5 s was not enough) + runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::seconds(3)); } -void moveController(int from, int to) { swapControllers(from, to); } +void moveController(int from, int to) { + if (from == 0 || to == 0) return note("menus: player 1 keeps the touch screen"); + swapControllers(from, to); +} void join(int controller, std::function joined) { runOnInputThread([controller, joined = std::move(joined)] { @@ -653,8 +671,12 @@ void enableJoining(Module const& game) { writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); hookClientLeave(game); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); - if (ok) onStartPressed([](int controller) { - if (!game::players().contains(controller)) menus::onJoinRequest(controller); // else: its pause menu + // Players join through the game's own prompt. With "startJoins" in config.json, Start on a controller without a + // player asks to join directly (for when the game shows no prompt). + if (ok) onStartPressed([startJoins = configFlag("startJoins", false)](int controller) { + if (game::players().contains(controller)) return; // its pause menu + 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([] { From c3d4979f0d35da1f3bd76d1c868cd5f86b9eea35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 13:15:39 +0000 Subject: [PATCH 47/61] Android 0.9.1: Start adds a player in a loaded world (the game shows no join prompt on Android); player 1's touch can be taken over by a controller and its controller freed from the menu, as on Windows; menu language from the phone Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/config/config.json | 2 +- android/config/config.schema.json | 4 +-- android/src/Menus.cpp | 43 +++++++++++++++++++++---------- android/src/Menus.h | 2 ++ android/src/SplitScreen.cpp | 23 ++++++++++------- 6 files changed, 50 insertions(+), 26 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index f3d21eb..c4d5827 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.1" 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") diff --git a/android/config/config.json b/android/config/config.json index a22b74d..7ba3ddb 100644 --- a/android/config/config.json +++ b/android/config/config.json @@ -3,5 +3,5 @@ "separateControllers": true, "forceSplitscreenJoin": true, "firstControllerIsPlayer1": false, - "startJoins": false + "startJoins": true } diff --git a/android/config/config.schema.json b/android/config/config.schema.json index 0cfb952..4997a84 100644 --- a/android/config/config.schema.json +++ b/android/config/config.schema.json @@ -20,12 +20,12 @@ "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 through the game's join prompt. On: the 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": "Off: players join through the game's own join prompt. On: Start on a controller without a player asks to join at once (if the game shows no prompt)." + "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/src/Menus.cpp b/android/src/Menus.cpp index d4d1c3f..4ccb62c 100644 --- a/android/src/Menus.cpp +++ b/android/src/Menus.cpp @@ -62,8 +62,10 @@ std::string tr(std::string const& key, std::vector const& params = // --- 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), a row that leaves room for that square, and the green closing button. +// 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? @@ -181,13 +183,23 @@ void setTexts(std::filesystem::path const& pack, std::filesystem::path const& op 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" - char value[PROP_VALUE_MAX] = {}; - __system_property_get("persist.sys.locale", value); - language = value; + 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"); - for (auto& [k, v] : readLang(pack / "texts" / (language + ".lang"))) texts[k] = v; + 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); @@ -200,13 +212,14 @@ void onSettingsButton(int who) { if (menuOpen(who)) return; int myPad = players.at(who); - enum class Act { Swap, Kick, Done, Nothing }; + 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), saved players who are not - // playing (to switch to) and player 1's controller; not on Android yet. + // 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{""}; @@ -214,8 +227,8 @@ void onSettingsButton(int who) { 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 - bool square = sub != 0; + // 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); @@ -228,9 +241,9 @@ void onSettingsButton(int who) { buttons.push_back(fmt::format("[{}:{}]", square ? "stag" : "tag", status)); pictures.emplace_back(); if (square) { - items.push_back({Act::Kick, sub}); - buttons.push_back(kKickButton); - pictures.push_back("textures/splitscreen/cross"); + 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}); @@ -254,6 +267,10 @@ void onSettingsButton(int who) { if (!theyHavePad && who != 0) game::requestLeave(who); 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); diff --git a/android/src/Menus.h b/android/src/Menus.h index 850bb18..9f4760b 100644 --- a/android/src/Menus.h +++ b/android/src/Menus.h @@ -25,6 +25,8 @@ bool hasController(int controller); 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 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 = {}); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index e54135c..ff1c49d 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -543,9 +543,7 @@ std::map players() { return out; } -// Player 1 plays on the touch screen: that is its controller (it is never "without a controller", and nobody takes it -// over: a controller given to player 1 made the game drop players and crash). -bool hasController(int controller) { return controller == 0 || controllerConnected(controller); } +bool hasController(int controller) { return controllerConnected(controller); } void requestLeave(int sub) { if (sub < 1 || sub > 3) return; // player 1 leaving is leaving the world @@ -563,9 +561,16 @@ void requestLeave(int sub) { runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::seconds(3)); } -void moveController(int from, int to) { - if (from == 0 || to == 0) return note("menus: player 1 keeps the touch screen"); - swapControllers(from, to); +void moveController(int from, int to) { swapControllers(from, to); } + +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) { @@ -671,9 +676,9 @@ void enableJoining(Module const& game) { writeSlot(&vt[10], reinterpret_cast(&queueRemovalDetour)); hookClientLeave(game); writeSlot(&vt[16], reinterpret_cast(&releaseSubIdDetour)); - // Players join through the game's own prompt. With "startJoins" in config.json, Start on a controller without a - // player asks to join directly (for when the game shows no prompt). - if (ok) onStartPressed([startJoins = configFlag("startJoins", false)](int controller) { + // 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; // its pause menu note("join: controller {} pressed Start{}", controller, startJoins ? "" : " (the game's prompt decides)"); if (startJoins) menus::onJoinRequest(controller); From 50df6adc89e97148c22e956931a57bb765025965 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 13:33:37 +0000 Subject: [PATCH 48/61] Android 0.9.2: taking a player whose own player must leave moves the controller once that player is gone (crash); the pause menu's Split screen button in the Android pack; trace the pause menu and the join listener Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 6 +-- android/pack/ui/pause_screen.json | 89 +++++++++++++++++++++++++++++++ android/src/Menus.cpp | 11 ++-- android/src/Menus.h | 3 ++ android/src/SplitScreen.cpp | 29 +++++++++- 5 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 android/pack/ui/pause_screen.json diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index c4d5827..b605310 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.2" 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") @@ -80,9 +80,9 @@ add_custom_target(levi_package 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 (its pause menu button is for Windows) + # 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 rm -f "${PACKAGE_MOD_DIR}/pack/ui/pause_screen.json" + 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} 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/Menus.cpp b/android/src/Menus.cpp index 4ccb62c..3b54e42 100644 --- a/android/src/Menus.cpp +++ b/android/src/Menus.cpp @@ -162,9 +162,10 @@ void offerPicker(int owner, bool fresh) { auto& choice = choices[pick]; switch (choice.kind) { case Kind::TakeOver: - game::moveController(controller, choice.value); + // (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)})); - if (fresh) game::requestLeave(owner); // the player made for this controller is not needed any more break; case Kind::NewPlayer: game::join(controller); break; // this player stays and waits for a controller default: break; @@ -261,10 +262,10 @@ void onSettingsButton(int who) { bool iHavePad = game::hasController(myPad); bool theyHavePad = game::hasController(players.at(item.sub)); if (!iHavePad) return tell(who, tr("splitscreen.tell.needController")); - game::moveController(myPad, players.at(item.sub)); - tell(item.sub, tr("splitscreen.tell.youAreNow", {localName(item.sub)})); // nothing comes back to this player (the other one had no controller): it leaves instead of waiting - if (!theyHavePad && who != 0) game::requestLeave(who); + 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 diff --git a/android/src/Menus.h b/android/src/Menus.h index 9f4760b..4d41428 100644 --- a/android/src/Menus.h +++ b/android/src/Menus.h @@ -25,6 +25,9 @@ bool hasController(int controller); 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, diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index ff1c49d..13a4215 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -207,15 +207,26 @@ 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 } @@ -563,6 +574,15 @@ void requestLeave(int 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) @@ -679,7 +699,7 @@ void enableJoining(Module const& game) { // 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; // its pause menu + 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); }); @@ -716,6 +736,13 @@ void enableJoining(Module const& game) { } // 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. + for (auto const& [cls, base, max] : {std::tuple{"21PauseScreenController", 420, 150}, {"23SplitscreenJoinListener", 575, 25}}) + for (auto const& c : findClasses(game, cls)) + if (c.name == cls && !c.vtables.empty()) + traceVtable(game, c.vtables.front(), std::min(countSlots(game, c.vtables.front(), 400), max), base, + cls, [](std::string const& l) { note("{}", l); }); } void probe() { From c9b1b322b2d0fece00ea7d5783fb30b004c6000c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 13:39:09 +0000 Subject: [PATCH 49/61] Android 0.9.3: the mod's delayed work also runs while the only controller is blocked (a player handing over its controller never left) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 39 +++++++++++++++++++++------------ android/src/ControllerSplit.h | 3 +++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index b605310..f6f342e 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.2" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index 46b63b4..ab2fa9a 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -215,6 +215,10 @@ struct Task { 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; @@ -250,8 +254,11 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, head[1], controller)); } } - if (padEvent && controller < 0 && gFirstController > 0) return 0; // a 5th controller would move player 1 - if (padEvent && controller >= 0 && gBlocked[controller]) return 0; // its player is leaving + // a 5th controller would move player 1; a blocked one's player is leaving + if (padEvent && ((controller < 0 && gFirstController > 0) || (controller >= 0 && gBlocked[controller]))) { + runDueTasks(); + return 0; + } int previous = tCurrentController; tCurrentController = controller; uint64_t result = gDispatch(a0, a1, variant, a3, a4, a5, a6, a7); @@ -265,18 +272,7 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, 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 - 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(); + runDueTasks(); return result; } @@ -370,6 +366,21 @@ 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); } diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 599a0c8..032bb61 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -53,6 +53,9 @@ 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)); From e2bd79805adc196dc59e09bb2a193ae3697125bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 13:54:00 +0000 Subject: [PATCH 50/61] Android 0.9.4: a controller changes player only when it holds no button, and a leaving player still gets its releases (crash); trace what the game does with presses of a controller that plays nobody (its join prompt) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 68 +++++++++++++++++++++++++++++---- android/src/SplitScreen.cpp | 22 ++++++++--- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index f6f342e..68bab27 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.3" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.4" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index ab2fa9a..f799629 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -1,6 +1,7 @@ #include "ControllerSplit.h" #include "DeviceManagerProbe.h" +#include "VTrace.h" #include @@ -58,6 +59,7 @@ std::atomic gRemapsLogged{0}; std::mutex gDevicesMutex; std::map gControllerOf; std::map gLastSeen; +std::map> gHeld; // device -> key codes held down (see applyPendingSwaps) thread_local int tCurrentController = -1; @@ -131,11 +133,38 @@ void purgeGoneDevices(bool now) { 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 @@ -225,6 +254,7 @@ 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}; 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) { @@ -232,11 +262,13 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, 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)) - { + 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; } @@ -254,16 +286,37 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, head[1], controller)); } } - // a 5th controller would move player 1; a blocked one's player is leaving - if (padEvent && ((controller < 0 && gFirstController > 0) || (controller >= 0 && gBlocked[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; } + 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); @@ -410,10 +463,9 @@ void blockController(int controller, bool blocked) { void swapControllers(int a, int b) { std::lock_guard lock(gDevicesMutex); - 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)); + 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; } diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 13a4215..8b3dccd 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -738,11 +738,19 @@ void enableJoining(Module const& game) { 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. - for (auto const& [cls, base, max] : {std::tuple{"21PauseScreenController", 420, 150}, {"23SplitscreenJoinListener", 575, 25}}) - for (auto const& c : findClasses(game, cls)) - if (c.name == cls && !c.vtables.empty()) - traceVtable(game, c.vtables.front(), std::min(countSlots(game, c.vtables.front(), 400), max), base, - cls, [](std::string const& l) { note("{}", l); }); + for (auto const& c : findClasses(game, "21PauseScreenController")) + if (c.name == "21PauseScreenController" && !c.vtables.empty()) + traceVtable(game, c.vtables.front(), std::min(countSlots(game, c.vtables.front(), 400), 150), 420, + "PauseScreenController", [](std::string const& l) { note("{}", l); }); + 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() { @@ -880,7 +888,9 @@ void probe() { note("{} classes named *{}*", classes.size(), needle); for (auto const& c : classes) { if (std::string_view(needle) == "MinecraftGame" && c.name != "13MinecraftGame") continue; - if (c.name.find("__function") != std::string::npos || c.name.find("shared_ptr") != std::string::npos) 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()); } } From 0961c853f89bc1b51eb00dbd60243b9800dfdb5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:07:29 +0000 Subject: [PATCH 51/61] Android 0.9.5: a player leaves once its last menu is 6 s old; dump the code of the leave crash's frames Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 21 ++++++++++++++------- android/src/FormProbe.h | 4 ++++ android/src/SplitScreen.cpp | 14 +++++++++++++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 68bab27..c0cf90f 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.4" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.5" 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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index 3d8e3d0..e1b3a44 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -150,6 +150,15 @@ bool sendForm(void const* id, uint32_t sub, uint32_t formId, std::string json) { 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. @@ -191,6 +200,7 @@ void maybeSendMenu(void const* source, int sub) { json = p.json; } } + if (sub >= 0 && sub < 4) gLastFormAt[sub] = nowMs(); if (formId) { sendForm(source, uint32_t(sub), formId, std::move(json)); return; @@ -202,6 +212,7 @@ void maybeSendMenu(void const* source, int sub) { // 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); @@ -238,13 +249,6 @@ uint8_t packetSubId(void* packetRef) { return sub; } -// 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(); -} - void authHandleDetour(void* self, void* source, void* callback, void* packetRef) { uint8_t sub = packetSubId(packetRef); if (sub == 0) { @@ -304,6 +308,9 @@ void hookHandle(Found const& f, void* detour, HandleFn* original, char const* wh } // 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; diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 638baa0..0c802a8 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -8,6 +8,7 @@ #include "Elf.h" #include "InputProbe.h" +#include #include #include #include @@ -24,6 +25,9 @@ void showForm(int sub, std::string const& title, std::string const& content, std // 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(); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 8b3dccd..f1e9ed9 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -543,6 +543,14 @@ void leaveSplitScreen(int sub) { void logLine(std::string const& line) { note("{}", line); } +// The player leaves once its last menu is 6 seconds old (a player removed soon after a menu took the game down). +void leaveWhenQuiet(int sub) { + runOnInputThread([sub] { + if (msSinceMenu(sub) < 6000) return leaveWhenQuiet(sub); + leaveSplitScreen(sub); + }, std::chrono::milliseconds(500)); +} + // What the menus (Menus.cpp) ask of the game. namespace game { @@ -569,7 +577,7 @@ void requestLeave(int sub) { // 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 // (3 s: with the resource pack's menu look, 1.5 s was not enough) - runOnInputThread([sub] { leaveSplitScreen(sub); }, std::chrono::seconds(3)); + leaveWhenQuiet(sub); } void moveController(int from, int to) { swapControllers(from, to); } @@ -695,6 +703,10 @@ void enableJoining(Module const& game) { 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. From cf58fe26513d23f663204a2afa14dd5175d77710 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:22:12 +0000 Subject: [PATCH 52/61] Android 0.9.6: find and trace the game's own leave function (the ClientInstance functions that set the leave-requested byte) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 23 +++++++++++++++++------ android/src/VTrace.cpp | 10 +++++++++- android/src/VTrace.h | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index c0cf90f..13f7c99 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.5" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.6" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index f1e9ed9..f01d047 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -480,7 +480,6 @@ void joinController(int controller) { using TickFn = void (*)(void*, bool, bool); using GetSubIdFn = uint64_t (*)(void*); constexpr size_t kLeaveFlags = 0x6E0; -constexpr size_t kLeaveFlag = 0x6E5; TickFn gClientTick = nullptr; GetSubIdFn gGetSubId = nullptr; std::atomic gClientBySub[4] = {}; @@ -643,14 +642,26 @@ bool hookClientLeave(Module const& game) { vt[i] - game.base, describeFunction(game, vt[303])); gClientTick = reinterpret_cast(vt[i]); gGetSubId = reinterpret_cast(vt[303]); - // functions that set the leave flag: one of them is how the game asks a player to leave - constexpr uint32_t kStrbLeaveFlag = 0x39000000 | (kLeaveFlag << 10); // strb wN, [xM, #0x6E5] + // 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; ++n) - if ((fn[n] & 0xFFFFFC00) == kStrbLeaveFlag && (fn[n] & 31) != 31) { - note("leave: slot {} (+0x{:X}) sets +0x6E5 at +0x{:X}", w, vt[w] - game.base, n * 4); + 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; } } diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp index 52f52c9..54cba13 100644 --- a/android/src/VTrace.cpp +++ b/android/src/VTrace.cpp @@ -27,6 +27,7 @@ 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); @@ -34,7 +35,8 @@ std::string offset(uintptr_t p) { } // namespace -bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t first, char const* name, LogFn log) { +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); @@ -44,6 +46,7 @@ bool traceVtable(Module const& game, uintptr_t* vtable, size_t slots, size_t fir 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)); @@ -74,6 +77,11 @@ void startTracing(int seconds) { 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]; + gLog(fmt::format("trace: {} called from {} (x1=0x{:X} w1={} w2={} w3={})", gNames[slot], offset(ret), regs[1], + int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]))); + } if (!gTracing.load(std::memory_order_relaxed)) return; ++gCounts[slot]; if (gSeen[slot].exchange(true)) return; diff --git a/android/src/VTrace.h b/android/src/VTrace.h index b2a1c0b..be8a459 100644 --- a/android/src/VTrace.h +++ b/android/src/VTrace.h @@ -10,7 +10,7 @@ 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); +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); From 1ef330109e15831ee32a65dc57b9215c25430702 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:29:20 +0000 Subject: [PATCH 53/61] Android 0.9.7: trace ClientInstance's functions (instead of MinecraftGame's) to find the one the pause menu's Save & Leave calls Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 13f7c99..63af9ab 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.6" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.7" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index f01d047..19a470e 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -481,6 +481,7 @@ using TickFn = void (*)(void*, bool, bool); using GetSubIdFn = uint64_t (*)(void*); constexpr size_t kLeaveFlags = 0x6E0; TickFn gClientTick = nullptr; +uintptr_t* gClientVt = nullptr; GetSubIdFn gGetSubId = nullptr; std::atomic gClientBySub[4] = {}; std::atomic gLeaveWanted[4] = {}; @@ -641,6 +642,7 @@ bool hookClientLeave(Module const& game) { 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; 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 @@ -758,7 +760,15 @@ void enableJoining(Module const& game) { 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); }); + // ClientInstance's functions (instead of MinecraftGame's): which one the pause menu's Save & Leave calls (the + // Windows version calls requestLeaveGameAsync; on Android it sets the leave bytes later, from another place). + if (gClientVt) { + size_t n = countSlots(game, gClientVt, 600); + traceVtable(game, gClientVt, std::min(n, 316), 0, "ClientInstance", [](std::string const& l) { note("{}", l); }); + if (n > 316) + traceVtable(game, gClientVt + 316, std::min(n - 316, 101), 474, "ClientInstance+316", + [](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. for (auto const& c : findClasses(game, "21PauseScreenController")) From 46a52bc769bc97adb3abb5d9d610ce6fda71a151 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:33:30 +0000 Subject: [PATCH 54/61] Android 0.9.8: players leave through the game's own requestLeaveGame (ClientInstance virtual 15, what the pause menu's Save & Leave calls), not by setting its leave bytes (crash) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 43 +++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 63af9ab..a158b3d 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.7" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.8" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 19a470e..1e7b4f3 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -482,6 +482,8 @@ 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] = {}; @@ -505,12 +507,18 @@ uint64_t leaveFlags(void* client) { 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 game sets when a player leaves from the pause menu (0.6.3 log: 000000000000 -> 000100000101): - // +0x6E0, +0x6E1 (leave requested) and +0x6E4. Its next frame sets +0x6E3 and +0x6E5 and clears +0x6E1, and - // the frame after removes the player. Setting +0x6E5 alone skipped the closing and crashed. - 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; + // 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); @@ -543,10 +551,10 @@ void leaveSplitScreen(int sub) { void logLine(std::string const& line) { note("{}", line); } -// The player leaves once its last menu is 6 seconds old (a player removed soon after a menu took the game down). +// 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) < 6000) return leaveWhenQuiet(sub); + if (msSinceMenu(sub) < 1500) return leaveWhenQuiet(sub); leaveSplitScreen(sub); }, std::chrono::milliseconds(500)); } @@ -571,12 +579,10 @@ void requestLeave(int sub) { if (!gPlaying.contains(sub) || !gLeaving.insert(sub).second) return; } cancelMenu(sub); - // A leaving player gets no more input: a button press that reached the player while it was being removed made the - // game throw (0.7.3 and 0.8.1 crashes, "terminating"). + // 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 - // (3 s: with the resource pack's menu look, 1.5 s was not enough) + // player leaves once its screen has closed. leaveWhenQuiet(sub); } @@ -643,6 +649,9 @@ bool hookClientLeave(Module const& game) { 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 @@ -760,15 +769,7 @@ void enableJoining(Module const& game) { 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 - // ClientInstance's functions (instead of MinecraftGame's): which one the pause menu's Save & Leave calls (the - // Windows version calls requestLeaveGameAsync; on Android it sets the leave bytes later, from another place). - if (gClientVt) { - size_t n = countSlots(game, gClientVt, 600); - traceVtable(game, gClientVt, std::min(n, 316), 0, "ClientInstance", [](std::string const& l) { note("{}", l); }); - if (n > 316) - traceVtable(game, gClientVt + 316, std::min(n - 316, 101), 474, "ClientInstance+316", - [](std::string const& l) { note("{}", l); }); - } + 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. for (auto const& c : findClasses(game, "21PauseScreenController")) From aac5b5208f5d4d836aa2b39a858a3aab9eafc5d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 14:38:23 +0000 Subject: [PATCH 55/61] Android 0.9.9: log the pause menu controller's calls with what their argument points to (to find the Split screen button's press) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 2 +- android/src/VTrace.cpp | 13 +++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index a158b3d..8da3969 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.8" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.9" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 1e7b4f3..80d5025 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -775,7 +775,7 @@ void enableJoining(Module const& game) { for (auto const& c : findClasses(game, "21PauseScreenController")) if (c.name == "21PauseScreenController" && !c.vtables.empty()) traceVtable(game, c.vtables.front(), std::min(countSlots(game, c.vtables.front(), 400), 150), 420, - "PauseScreenController", [](std::string const& l) { note("{}", l); }); + "PauseScreenController", [](std::string const& l) { note("{}", l); }, 40); size_t listenerBase = 575; for (auto const& c : findClasses(game, "SplitscreenJoinListener")) for (auto* lvt : c.vtables) { diff --git a/android/src/VTrace.cpp b/android/src/VTrace.cpp index 54cba13..5f8624a 100644 --- a/android/src/VTrace.cpp +++ b/android/src/VTrace.cpp @@ -1,5 +1,8 @@ #include "VTrace.h" +#include +#include + #include #include #include @@ -79,8 +82,14 @@ 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]; - gLog(fmt::format("trace: {} called from {} (x1=0x{:X} w1={} w2={} w3={})", gNames[slot], offset(ret), regs[1], - int32_t(regs[1]), int32_t(regs[2]), int32_t(regs[3]))); + // 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]; From e82375b4243b9619b57c54b64f9df7cdff4ee034 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:25:34 +0000 Subject: [PATCH 56/61] Android 0.9.10: find the pause menu's button names and the code that sets up their handlers (for the Split screen button) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 8da3969..6148ede 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.9" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.10" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 80d5025..cd0c129 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -852,6 +852,54 @@ void probe() { } } } + // 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); From 842fffe0da657db259ce4e31217dbd801e72ed03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:29:12 +0000 Subject: [PATCH 57/61] Android 0.9.11: log the pause menu's screen events (all but pointer moves) to find the Split screen button's press Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 6148ede..6ad4d82 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.10" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.9.11" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index cd0c129..75295f8 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -688,6 +688,22 @@ bool hookClientLeave(Module const& game) { // 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}; + +uint64_t pauseEventDetour(void* self, void* event) { + uint32_t words[8] = {}; + std::memcpy(words, event, sizeof words); + uint64_t result = gPauseEvent(self, event); + if (words[0] != 5 && gPauseEventsLogged < 300) { + ++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 = © @@ -772,10 +788,14 @@ void enableJoining(Module const& game) { 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()) - traceVtable(game, c.vtables.front(), std::min(countSlots(game, c.vtables.front(), 400), 150), 420, - "PauseScreenController", [](std::string const& l) { note("{}", l); }, 40); + 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) { From 581a518020da4f941f6c97e9e2b3f6ae2d0f84e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:35:27 +0000 Subject: [PATCH 58/61] Android 0.10.0: the pause menu's Split screen button opens the Split screen menu (its press is found by the FNV-1a hash of button.splitscreen_menu), as on Windows Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/ControllerSplit.cpp | 6 ++++++ android/src/ControllerSplit.h | 3 +++ android/src/SplitScreen.cpp | 21 ++++++++++++++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 6ad4d82..90829b7 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.9.11" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.10.0" 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") diff --git a/android/src/ControllerSplit.cpp b/android/src/ControllerSplit.cpp index f799629..f5f5f27 100644 --- a/android/src/ControllerSplit.cpp +++ b/android/src/ControllerSplit.cpp @@ -255,6 +255,7 @@ 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) { @@ -293,6 +294,9 @@ uint64_t dispatchDetour(uint64_t a0, uint64_t a1, uint64_t variant, uint64_t a3, 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); @@ -457,6 +461,8 @@ bool controllerConnected(int controller) { return false; } +int lastPresser() { return gLastPresser; } + void blockController(int controller, bool blocked) { if (controller >= 0 && controller < kMaxControllers) gBlocked[controller] = blocked; } diff --git a/android/src/ControllerSplit.h b/android/src/ControllerSplit.h index 032bb61..e6a6162 100644 --- a/android/src/ControllerSplit.h +++ b/android/src/ControllerSplit.h @@ -47,6 +47,9 @@ 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 75295f8..126f4e8 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -692,11 +692,30 @@ 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 with the hash, then the state (1 = pressed). As on Windows, the pause menu closes (16: what Resume +// answers) 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 uint64_t kCloseScreen = 16; + uint64_t pauseEventDetour(void* self, void* event) { uint32_t words[8] = {}; std::memcpy(words, event, sizeof words); + if (words[0] == 1 && words[1] == kSplitScreenButton) { + if ((words[3] & 0xFF) != 1) return 0; + int who = lastPresser(); + note("pause menu: Split screen pressed by player {}", who + 1); + runOnInputThread([who] { menus::onSettingsButton(who); }, std::chrono::milliseconds(300)); + return kCloseScreen; + } uint64_t result = gPauseEvent(self, event); - if (words[0] != 5 && gPauseEventsLogged < 300) { + 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); From 336cf533f238a67f6e2b2ea5691e9c03bf0053a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:43:36 +0000 Subject: [PATCH 59/61] Android 0.10.1: read the pause menu button's hash and state at their real offsets (+8, +16) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 90829b7..5cbc1f1 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.0" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.10.1" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 126f4e8..dd39a24 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -694,7 +694,7 @@ 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 with the hash, then the state (1 = pressed). As on Windows, the pause menu closes (16: what Resume +// is type 1, then (at +8) the hash, then (at +16) the state (1 = pressed). As on Windows, the pause menu closes (16: what Resume // answers) and the player who pressed it gets the Split screen menu. constexpr uint32_t fnv1a(std::string_view s) { uint32_t h = 0x811C9DC5; @@ -707,8 +707,8 @@ constexpr uint64_t kCloseScreen = 16; uint64_t pauseEventDetour(void* self, void* event) { uint32_t words[8] = {}; std::memcpy(words, event, sizeof words); - if (words[0] == 1 && words[1] == kSplitScreenButton) { - if ((words[3] & 0xFF) != 1) return 0; + if (words[0] == 1 && words[2] == kSplitScreenButton) { // type, (padding), hash, (?), state + if ((words[4] & 0xFF) != 1) return 0; int who = lastPresser(); note("pause menu: Split screen pressed by player {}", who + 1); runOnInputThread([who] { menus::onSettingsButton(who); }, std::chrono::milliseconds(300)); From 9df9e8b859bf829e6eceb5ad5fbd11d105265fba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:48:06 +0000 Subject: [PATCH 60/61] Android 0.10.2: the pause menu's Split screen button pressed by touch is player 1's Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/SplitScreen.cpp | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 5cbc1f1..53dc0ce 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.1" CACHE STRING "LeviLauncher mod version") +set(MOD_VERSION "0.10.2" 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") diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index dd39a24..46c438c 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -702,6 +702,7 @@ constexpr uint32_t fnv1a(std::string_view s) { return h; } constexpr uint32_t kSplitScreenButton = fnv1a("button.splitscreen_menu"); +constexpr uint32_t kTouchPress = fnv1a("button.menu_select"); constexpr uint64_t kCloseScreen = 16; uint64_t pauseEventDetour(void* self, void* event) { @@ -709,7 +710,9 @@ uint64_t pauseEventDetour(void* self, void* event) { std::memcpy(words, event, sizeof words); if (words[0] == 1 && words[2] == kSplitScreenButton) { // type, (padding), hash, (?), state if ((words[4] & 0xFF) != 1) return 0; - int who = lastPresser(); + // 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)); return kCloseScreen; From 38e3bff837c32b79cb296035c8f31ed18a131072 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:56:45 +0000 Subject: [PATCH 61/61] Android 0.10.3: the pause menu's Split screen button closes the pause menu like Resume; open Split screen menus are closed and opened again when the players or their controllers change, as on Windows Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011wX3aSVRyAv4YE8yrCQa4d --- android/CMakeLists.txt | 2 +- android/src/FormProbe.cpp | 14 +++++++++++ android/src/FormProbe.h | 3 +++ android/src/Menus.cpp | 48 +++++++++++++++++++++++++++++++++++++ android/src/SplitScreen.cpp | 11 +++++---- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 53dc0ce..e1a54db 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -12,7 +12,7 @@ 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.2" CACHE STRING "LeviLauncher mod version") +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") diff --git a/android/src/FormProbe.cpp b/android/src/FormProbe.cpp index e1b3a44..9ce94de 100644 --- a/android/src/FormProbe.cpp +++ b/android/src/FormProbe.cpp @@ -176,7 +176,17 @@ struct Pending { 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; @@ -328,6 +338,10 @@ bool menuOpen(int sub) { 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); diff --git a/android/src/FormProbe.h b/android/src/FormProbe.h index 0c802a8..e92c26f 100644 --- a/android/src/FormProbe.h +++ b/android/src/FormProbe.h @@ -22,6 +22,9 @@ void showForm(int sub, std::string const& title, std::string const& content, std 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); diff --git a/android/src/Menus.cpp b/android/src/Menus.cpp index 3b54e42..c4bdd65 100644 --- a/android/src/Menus.cpp +++ b/android/src/Menus.cpp @@ -99,6 +99,12 @@ void showMenu(int who, std::string const& text, std::vector const& } 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 @@ -252,7 +258,15 @@ void onSettingsButton(int who) { 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]; @@ -307,6 +321,11 @@ void onControllerConnected(int sub) { 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); @@ -315,6 +334,35 @@ void onPlayerLeft(int 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); diff --git a/android/src/SplitScreen.cpp b/android/src/SplitScreen.cpp index 46c438c..9fae464 100644 --- a/android/src/SplitScreen.cpp +++ b/android/src/SplitScreen.cpp @@ -694,8 +694,8 @@ 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 (16: what Resume -// answers) and the player who pressed it gets the Split screen menu. +// 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; @@ -703,7 +703,7 @@ constexpr uint32_t fnv1a(std::string_view s) { } constexpr uint32_t kSplitScreenButton = fnv1a("button.splitscreen_menu"); constexpr uint32_t kTouchPress = fnv1a("button.menu_select"); -constexpr uint64_t kCloseScreen = 16; +constexpr uint32_t kResumeButton = fnv1a("button.menu_continue"); uint64_t pauseEventDetour(void* self, void* event) { uint32_t words[8] = {}; @@ -715,7 +715,10 @@ uint64_t pauseEventDetour(void* self, void* event) { 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)); - return kCloseScreen; + // 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) {