From 38ff6d387c8483c6fb0e520fd3050a0c78c2acec Mon Sep 17 00:00:00 2001 From: bbldrizzy Date: Tue, 27 Oct 2026 20:30:09 +0000 Subject: [PATCH] UPDATE Add Linux and macOS support, batch class transformations with a disk cache, and add cross-platform release packaging. Also improve runtime namespace handling, keybind editing, and startup behavior. --- .github/workflows/injector.yml | 86 +++ .gitignore | 1 + README.md | 2 +- build-injector.ps1 | 3 +- build-injector.sh | 34 ++ build-release.bat | 86 +++ build.gradle | 5 +- injector/README.md | 86 ++- injector/crates/injector/src/main.rs | 130 ++--- injector/crates/injector/src/platform/mod.rs | 61 ++ .../injector/src/platform/unix_attach.rs | 405 +++++++++++++ .../injector/src/platform/unix_procs.rs | 158 +++++ .../crates/injector/src/platform/unix_term.rs | 152 +++++ .../crates/injector/src/{ => platform}/win.rs | 548 +++++++++--------- injector/crates/loader/src/bridge.rs | 169 +++++- injector/crates/loader/src/jni.rs | 39 +- injector/crates/loader/src/lib.rs | 31 + injector/crates/loader/src/log.rs | 42 +- injector/crates/loader/src/worker.rs | 137 ++++- scripts/package_release.py | 122 ++++ .../prestige/engine/EnginePlugin.java | 69 ++- .../zprestige/prestige/inject/Bootstrap.java | 157 ++++- .../prestige/inject/InPlaceTransformer.java | 4 +- .../prestige/inject/NativeBridge.java | 6 + .../prestige/inject/TransformCache.java | 94 +++ .../prestige/module/misc/SelfDestruct.java | 2 +- .../java/dev/zprestige/prestige/ui/Input.java | 1 + .../zprestige/prestige/ui/KeybindsPage.java | 63 +- .../dev/zprestige/prestige/ui/mc/Skid.java | 32 +- 29 files changed, 2234 insertions(+), 491 deletions(-) create mode 100644 .github/workflows/injector.yml create mode 100644 build-injector.sh create mode 100644 build-release.bat create mode 100644 injector/crates/injector/src/platform/mod.rs create mode 100644 injector/crates/injector/src/platform/unix_attach.rs create mode 100644 injector/crates/injector/src/platform/unix_procs.rs create mode 100644 injector/crates/injector/src/platform/unix_term.rs rename injector/crates/injector/src/{ => platform}/win.rs (94%) create mode 100644 scripts/package_release.py create mode 100644 src/main/java/dev/zprestige/prestige/inject/TransformCache.java diff --git a/.github/workflows/injector.yml b/.github/workflows/injector.yml new file mode 100644 index 0000000..863081a --- /dev/null +++ b/.github/workflows/injector.yml @@ -0,0 +1,86 @@ +name: Injector + +on: + workflow_dispatch: + push: + paths: + - "injector/**" + - "src/**" + - ".github/workflows/injector.yml" + +jobs: + payload: + name: Payload jar + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - name: Build remapped jar + run: | + chmod +x ./gradlew + ./gradlew remapJar --console=plain + - name: Upload jar + uses: actions/upload-artifact@v4 + with: + name: payload-jar # also the Fabric mod: drop prestige-*.jar into mods/ + path: | + build/libs/prestige-*.jar + !build/libs/*-dev.jar + !build/libs/*-sources.jar + if-no-files-found: error + + injector: + name: Injector (${{ matrix.target }}) + needs: payload + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + name: windows-x86_64 + files: | + injector/target/release/PrestigeInjector.exe + injector/target/release/prestige_native.dll + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: linux-x86_64 + files: | + injector/target/release/PrestigeInjector + injector/target/release/libprestige_native.so + - os: macos-13 + target: x86_64-apple-darwin + name: macos-x86_64 + files: | + injector/target/release/PrestigeInjector + injector/target/release/libprestige_native.dylib + - os: macos-14 + target: aarch64-apple-darwin + name: macos-aarch64 + files: | + injector/target/release/PrestigeInjector + injector/target/release/libprestige_native.dylib + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Download payload jar + uses: actions/download-artifact@v4 + with: + name: payload-jar + path: build/libs + - name: Build + run: cargo build --release --manifest-path injector/Cargo.toml + - name: Test + run: cargo test --manifest-path injector/Cargo.toml + - name: Upload injector + uses: actions/upload-artifact@v4 + with: + name: injector-${{ matrix.name }} + path: ${{ matrix.files }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cad6e94..db63b88 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ openvape.zip /dist/ /injector/target/ +/tools diff --git a/README.md b/README.md index 7d04938..9e27125 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A semi deobfuscated, IntelliJ-ready, runnable open sourced version of the Presti

Cracked by Skid Solutions. -Join the Discord: https://discord.gg/skidsolutions +Join the Discord: https://discord.gg/x2FJyGrDNg ## Why? zPrestige couldnt give a fuck it seems about his client. AI slop all over, no time nor thought it seems goes into testing if things actually work. banned in 2 games with the most popular CAS config on mcpvp with countless modules flagging grim on actest. diff --git a/build-injector.ps1 b/build-injector.ps1 index a9f4f89..3c251cd 100644 --- a/build-injector.ps1 +++ b/build-injector.ps1 @@ -10,4 +10,5 @@ cargo build --release --manifest-path injector\Cargo.toml if ($LASTEXITCODE -ne 0) { throw 'cargo build failed' } New-Item -ItemType Directory -Force dist | Out-Null Copy-Item injector\target\release\PrestigeInjector.exe, injector\target\release\prestige_native.dll dist\ -Force -Write-Host 'Done: dist\PrestigeInjector.exe + dist\prestige_native.dll' +Get-ChildItem build\libs\prestige-*.jar | Where-Object { $_.Name -notmatch '-(dev|sources)\.jar$' } | Copy-Item -Destination dist\ -Force +Write-Host 'Done: dist\PrestigeInjector.exe + dist\prestige_native.dll + dist\prestige-*.jar (Fabric mod)' diff --git a/build-injector.sh b/build-injector.sh new file mode 100644 index 0000000..cd99bdf --- /dev/null +++ b/build-injector.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Builds the Prestige payload jar (remapped for the production Fabric runtime), then the Rust injector and loader +# library, and collects PrestigeInjector + libprestige_native.{so,dylib} in dist/. +# Optional: set JAVA_HOME to pick the JDK used by Gradle. +set -eu + +cd "$(dirname "$0")" + +case "$(uname -s)" in + Linux) LIB=libprestige_native.so ;; + Darwin) LIB=libprestige_native.dylib ;; + *) echo "unsupported OS: $(uname -s) (use build-injector.ps1 on Windows)" >&2; exit 1 ;; +esac + +if [ -n "${JAVA_HOME:-}" ]; then + export JAVA_HOME + PATH="$JAVA_HOME/bin:$PATH" + export PATH +fi + +./gradlew remapJar --console=plain +cargo build --release --manifest-path injector/Cargo.toml + +mkdir -p dist +cp -f "injector/target/release/PrestigeInjector" "injector/target/release/$LIB" dist/ +chmod +x dist/PrestigeInjector + +# the same remapped jar is the Fabric mod (drop it into mods/) +for jar in build/libs/prestige-*.jar; do + case "$jar" in *-dev.jar|*-sources.jar) continue ;; esac + cp -f "$jar" dist/ +done + +echo "Done: dist/PrestigeInjector + dist/$LIB + dist/prestige-*.jar (Fabric mod)" diff --git a/build-release.bat b/build-release.bat new file mode 100644 index 0000000..8372f19 --- /dev/null +++ b/build-release.bat @@ -0,0 +1,86 @@ +@echo off +rem Builds release zips for every platform into dist\release\ (run on Windows): +rem prestige--windows-x86_64.zip PrestigeInjector.exe + prestige_native.dll + mod jar +rem prestige--linux-x86_64.zip PrestigeInjector + libprestige_native.so + mod jar (glibc 2.17+, any distro) +rem prestige--macos-universal.zip PrestigeInjector + libprestige_native.dylib + mod jar (Intel + Apple Silicon) +rem prestige-.jar the Fabric mod on its own +rem SHA256SUMS.txt +rem Linux and macOS are cross-compiled with cargo-zigbuild + zig; both are installed on first run if missing. +rem Requires: Rust (rustup), Python 3, JDK 21. +setlocal EnableExtensions +cd /d "%~dp0" +set "SELF=%~f0" + +set "OUT=%CD%\dist\release" +if exist "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot" set "JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot" + +for /f "tokens=1,* delims==" %%a in ('findstr /b /c:"mod_version=" gradle.properties') do set "VERSION=%%b" +if not defined VERSION set "VERSION=dev" +echo === Prestige %VERSION% release build + +rem ---- tools ------------------------------------------------------------- +where cargo >nul 2>&1 || (echo cargo not found: install Rust from https://rustup.rs & goto :fail) +where python >nul 2>&1 || (echo python not found: install Python 3 from https://python.org & goto :fail) +python -m ziglang version >nul 2>&1 || ( + echo === Installing zig ^(pip ziglang^) + python -m pip install --user ziglang || goto :fail +) +where cargo-zigbuild >nul 2>&1 || ( + echo === Installing cargo-zigbuild + cargo install --locked cargo-zigbuild || goto :fail +) +rustup target add x86_64-pc-windows-msvc x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin || goto :fail + +rem zigbuild's linker wrappers break on paths with spaces, so keep its cache on the 8.3 short path +if not exist "%LOCALAPPDATA%\cargo-zigbuild" mkdir "%LOCALAPPDATA%\cargo-zigbuild" +for %%I in ("%LOCALAPPDATA%\cargo-zigbuild") do set "CARGO_ZIGBUILD_CACHE_DIR=%%~sI" + +rem ---- payload jar ------------------------------------------------------- +echo === Building payload jar +call "%CD%\gradlew.bat" remapJar --console=plain || goto :fail +set "JAR=" +for /f "delims=" %%j in ('dir /b /o-d "build\libs\prestige-*.jar" ^| findstr /v /i /c:"-dev.jar" /c:"-sources.jar"') do if not defined JAR set "JAR=%CD%\build\libs\%%j" +if not defined JAR (echo no remapped jar in build\libs & goto :fail) +rem every target embeds this exact jar +set "PRESTIGE_PAYLOAD=%JAR%" + +if exist "%OUT%" rmdir /s /q "%OUT%" +mkdir "%OUT%" || goto :fail + +rem ---- native builds ----------------------------------------------------- +echo === Windows x86_64 +cargo build --release --manifest-path injector\Cargo.toml --target x86_64-pc-windows-msvc || goto :fail +call :package windows-x86_64 x86_64-pc-windows-msvc PrestigeInjector.exe prestige_native.dll || goto :fail + +echo === Linux x86_64 (glibc 2.17+) +cargo zigbuild --release --manifest-path injector\Cargo.toml --target x86_64-unknown-linux-gnu.2.17 || goto :fail +call :package linux-x86_64 x86_64-unknown-linux-gnu PrestigeInjector libprestige_native.so || goto :fail + +echo === macOS universal (x86_64 + arm64) +cargo zigbuild --release --manifest-path injector\Cargo.toml --target universal2-apple-darwin || goto :fail +call :package macos-universal universal2-apple-darwin PrestigeInjector libprestige_native.dylib || goto :fail + +copy /y "%JAR%" "%OUT%\" >nul || goto :fail +python scripts\package_release.py checksums "%OUT%" || goto :fail + +echo. +echo === Done: %OUT% +dir /b "%OUT%" +call :maybe_pause +exit /b 0 + +rem %1 platform name, %2 cargo target dir, %3 injector file, %4 library file +:package +python scripts\package_release.py zip "%OUT%" "%VERSION%" %1 "injector\target\%~2\release\%~3" "injector\target\%~2\release\%~4" "%JAR%" +exit /b %ERRORLEVEL% + +:fail +echo. +echo *** Release build FAILED +call :maybe_pause +exit /b 1 + +rem keep the window open when started by double-click +:maybe_pause +echo %cmdcmdline% | find /i "%SELF%" >nul && pause +exit /b 0 diff --git a/build.gradle b/build.gradle index 4087fc2..ba7f31e 100644 --- a/build.gradle +++ b/build.gradle @@ -52,11 +52,14 @@ dependencies { modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" // Dear ImGui. The binding contains the Java classes, lwjgl3 the GL3/GLFW backend and - // natives-windows the imgui-java64.dll (extracted from the jar at runtime by imgui-java). + // natives-* the imgui-java64 libraries (extracted from the jar at runtime by imgui-java). + // Linux natives are x86_64 only; the macOS dylib is universal (x86_64 + arm64). shade "io.github.spair:imgui-java-binding:${project.imgui_version}" // imgui-java-lwjgl3 pulls LWJGL 3.4.1 transitively, which replaces Minecraft's 3.3.3 jars and breaks world saving shade("io.github.spair:imgui-java-lwjgl3:${project.imgui_version}") { exclude group: 'org.lwjgl' } shade "io.github.spair:imgui-java-natives-windows:${project.imgui_version}" + shade "io.github.spair:imgui-java-natives-linux:${project.imgui_version}" + shade "io.github.spair:imgui-java-natives-macos:${project.imgui_version}" // ClassTransform (net.lenni0451, customised build); engine/EnginePlugin uses it to apply the client's @CTransformer hooks. shade files('libs/classtransform-bundled.jar') diff --git a/injector/README.md b/injector/README.md index 80f5829..c85ba77 100644 --- a/injector/README.md +++ b/injector/README.md @@ -1,15 +1,34 @@ # Prestige injector (Rust) -`PrestigeInjector.exe` loads `prestige_native.dll` into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x, -Fabric API installed). It uses the same overall method as the openvape bundle, written from scratch in Rust with no external crates. +`PrestigeInjector` loads the native loader library (`prestige_native.dll` / `libprestige_native.so` / `libprestige_native.dylib`) +into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x, Fabric API installed). It uses the same overall +method as the openvape bundle, written from scratch in Rust with no external crates. + +## Supported platforms + +| OS | Architectures | Injector binary | Loader library | +|---------|--------------------|-----------------------|-----------------------------| +| Windows | x86_64 | `PrestigeInjector.exe`| `prestige_native.dll` | +| Linux | x86_64 | `PrestigeInjector` | `libprestige_native.so` | +| macOS | x86_64, arm64 | `PrestigeInjector` | `libprestige_native.dylib` | + +The injector and library must be built for the same architecture as the target JVM. ## How it works -1. **Injector** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen - process through `CreateRemoteThread` and confirms the module is mapped. -2. **`prestige_native.dll`** (`DllMain` only spawns a worker thread): - - waits for `jvm.dll`, `JNI_GetCreatedJavaVMs`, attaches, acquires the JVMTI redefine/retransform capabilities; - - writes the embedded payload jar (the remapped `prestige-*.jar`) to `%TEMP%\PrestigeLoader`; +1. **Injector** lists the running Fabric client JVMs, then loads the library into the chosen process. + - **Windows:** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen + process through `CreateRemoteThread` and confirms the module is mapped. + - **Linux / macOS:** uses the HotSpot attach protocol (the same mechanism as `jcmd JVMTI.agent_load`). The injector + creates `.attach_pid` and sends `SIGQUIT` to the JVM, connects to its attach socket (`/tmp/.java_pid`, under + `$TMPDIR` on macOS) and asks it to load the library. The JVM `dlopen`s `libprestige_native` and calls its + `Agent_OnAttach` entry point. No ptrace, no root, no per-architecture shellcode. + Processes are found via `/proc` (Linux) or `ps` (macOS); there are no window titles, so the picker shows the pid and + instance name. +2. **The native library** only spawns a worker thread from its entry point (`DllMain` on Windows, `Agent_OnAttach` on + unix, which also hands over the `JavaVM*`). The worker: + - waits for the JVM (Windows: `jvm.dll`, `JNI_GetCreatedJavaVMs`), attaches, acquires the JVMTI redefine/retransform capabilities; + - writes the embedded payload jar (the remapped `prestige-*.jar`) to the temp directory under `PrestigeLoader`; - `FabricLauncherBase.getLauncher().addToClassPath(jar)` so payload and game classes share the Knot class loader; - registers `NativeBridge.classBytes` / `NativeBridge.redefine` and calls `Bootstrap.start()`. 3. **`Bootstrap`** (Java) waits for the client window, then for every ClassTransform hook target gets the current class bytes @@ -19,14 +38,59 @@ Fabric API installed). It uses the same overall method as the openvape bundle, w helper classes, call sites become `invokestatic`, and private member access goes through `Access` MethodHandles. Finally the Prestige and ClickGUI entrypoints run on the client thread. -Log: `prestige-native.log` beside the DLL, plus `[Prestige-Inject]` lines in the game log. - ## Build +The payload jar is embedded with `include_bytes!`, so each target needs its own build. Both scripts below build for the host OS only and run +`gradlew remapJar`, `cargo build --release`, and copy the results to `dist/`. + ```powershell -.\build-injector.ps1 # gradlew remapJar, cargo build --release, copies to dist\ +.\build-injector.ps1 # Windows: dist\PrestigeInjector.exe + dist\prestige_native.dll ``` +```sh +./build-injector.sh # Linux/macOS: dist/PrestigeInjector + dist/libprestige_native.{so,dylib} +``` + +To build release zips for every platform from Windows, run `build-release.bat`. It cross-compiles Linux (x86_64, +glibc 2.17+, so one build covers every mainstream distro) and macOS (universal x86_64 + arm64) with `cargo-zigbuild`, installing +zig and cargo-zigbuild on first run, and writes `dist\release\prestige--{windows-x86_64,linux-x86_64,macos-universal}.zip`, +the bare mod jar and `SHA256SUMS.txt`. + +Set `JAVA_HOME` beforehand to choose the JDK for Gradle (`build-injector.sh` honours it). The GitHub Actions workflow +`.github/workflows/injector.yml` builds and tests all four targets (Windows x86_64, Linux x86_64, macOS x86_64, macOS arm64) +and uploads them as artifacts. + ## Use -Start a Fabric 1.21.11 instance, then run `dist\PrestigeInjector.exe` (or `PrestigeInjector.exe [dll]`). +Start a Fabric 1.21.11 instance, then run: + +```sh +PrestigeInjector # interactive: pick the game from the list +PrestigeInjector [lib path] # inject into a specific pid, optionally with an explicit library path +``` + +On Windows this is `PrestigeInjector.exe`. By default the library is looked up beside the injector. + +## Install as a mod instead + +The payload jar is also a normal Fabric mod. Put `prestige-*.jar` (from `dist/` or the CI `payload-jar` artifact) in the +`mods/` folder of a Fabric Loader 0.19+ / Fabric API / Minecraft 1.21.11 instance; no injector is needed, on any OS. +Do not use both: if the mod is already loaded, the injector's bootstrap logs "already loaded as a Fabric mod" and skips. + +## Requirements (Linux / macOS) + +- Run the injector as the **same user** as the game. +- The attach mechanism must be enabled: the game must not be started with `-XX:+DisableAttachMechanism`. +- The JVM architecture must match the injector build. On Apple Silicon use an arm64 JVM, or use the x86_64 build for JVMs + running under Rosetta. +- Sandboxed launchers (Flatpak, Snap) hide the attach socket and are not supported. + +## Known limits + +- On Linux/macOS the library stays loaded after self-destruct: hooks are removed and natives unregistered, but the library is + not `dlclose`d (unmapping code the thread is still returning into would crash the JVM). On Windows the library is freed. +- Linux and macOS support is compile-checked in CI; test on a real host before relying on it. + +## Log + +`prestige-native.log` is written beside the loader library, plus `[Prestige-Inject]` lines in the game log. diff --git a/injector/crates/injector/src/main.rs b/injector/crates/injector/src/main.rs index 2d8745a..8d0805f 100644 --- a/injector/crates/injector/src/main.rs +++ b/injector/crates/injector/src/main.rs @@ -1,10 +1,11 @@ -//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads prestige_native.dll into the selected one -//! with the classic remote LoadLibraryW thread. The DLL does everything else once it is inside the JVM; the injector tails -//! the DLL's log so every stage (transforming, injecting, initialising) is visible. +//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads the native library +//! (prestige_native.dll / libprestige_native.so / libprestige_native.dylib) into the selected one: a remote LoadLibraryW +//! thread on Windows, the HotSpot attach protocol on Linux and macOS. The library does everything else once it is inside +//! the JVM; the injector tails its log so every stage (transforming, injecting, initialising) is visible. //! -//! PrestigeInjector.exe interactive process picker (Up/Down, Enter, Esc) -//! PrestigeInjector.exe [dll path] non-interactive -mod win; +//! PrestigeInjector interactive process picker (Up/Down, Enter, Esc) +//! PrestigeInjector [lib path] non-interactive +mod platform; use std::fs; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -12,9 +13,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, Instant}; -const DLL_NAME: &str = "prestige_native.dll"; -const WATERMARK: &str = "Cracked by Skid Solutions"; -const INVITE_URL: &str = "https://discord.gg/M56rd2wwC5"; +/// The one credit line, shown once at the top of the screen. +const CREDIT: &str = "Cracked by Skid Solutions - https://discord.gg/x2FJyGrDNg"; +const INVITE_URL: &str = "https://discord.gg/x2FJyGrDNg"; /// How long to follow the DLL's progress after it has been loaded. const FOLLOW_TIMEOUT: Duration = Duration::from_secs(180); @@ -28,16 +29,18 @@ const RED: &str = "\x1b[91m"; fn default_dll() -> PathBuf { std::env::current_exe() .ok() - .and_then(|exe| exe.parent().map(|dir| dir.join(DLL_NAME))) - .unwrap_or_else(|| PathBuf::from(DLL_NAME)) + .and_then(|exe| exe.parent().map(|dir| dir.join(platform::LIB_NAME))) + .unwrap_or_else(|| PathBuf::from(platform::LIB_NAME)) } -fn banner() { - println!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ "); - println!(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|"); - println!(" | _/ / _|\\__ \\ | | | | (_ | _| "); - println!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector"); - println!("{YELLOW} {WATERMARK}{RESET}\n"); +fn banner() -> String { + let mut out = String::new(); + out.push_str(&format!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ \n")); + out.push_str(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|\n"); + out.push_str(" | _/ / _|\\__ \\ | | | | (_ | _| \n"); + out.push_str(&format!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector\n")); + out.push_str(&format!("{YELLOW} {CREDIT}{RESET}\n\n")); + out } fn step(label: &str, message: &str) { @@ -58,21 +61,26 @@ fn clock() -> String { } fn main() -> ExitCode { - win::enable_ansi(); + let code = real_main(); + platform::restore_terminal(); + code +} + +fn real_main() -> ExitCode { + platform::enable_ansi(); let _ = clock(); let args: Vec = std::env::args().skip(1).collect(); let interactive = args.is_empty(); let (pid, dll): (Option, PathBuf) = match args.as_slice() { [] => { - banner(); - invite_prompt(); + open_invite_once(); (pick_process(), default_dll()) } [pid] => (parse_pid(pid), default_dll()), [pid, dll] => (parse_pid(pid), PathBuf::from(dll)), _ => { - eprintln!("usage: PrestigeInjector.exe [ [dll path]]"); + eprintln!("usage: PrestigeInjector [ [lib path]]"); return ExitCode::from(2); } }; @@ -81,7 +89,7 @@ fn main() -> ExitCode { return if interactive { ExitCode::SUCCESS } else { ExitCode::from(2) }; }; if !interactive { - banner(); + print!("{}", banner()); } let code = run(pid, &dll); @@ -109,16 +117,18 @@ fn run(pid: u32, dll: &Path) -> ExitCode { } }; let dll_display = dll.display().to_string(); - step("loader", &dll_display.trim_start_matches(r"\\?\").to_string()); + #[cfg(windows)] + let dll_display = dll_display.trim_start_matches(r"\\?\").to_string(); + step("loader", &dll_display); - // The DLL appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown. + // The library appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown. let log_path = dll.with_file_name("prestige-native.log"); let log_start = fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0); - step("checking", "64-bit JVM, process access, DLL not already loaded"); - step("injecting", "remote thread LoadLibraryW"); - match win::inject(pid, &dll) { - Ok(()) => ok("injected", "prestige_native.dll is loaded in the game process"), + step("checking", "64-bit JVM, process access, library not already loaded"); + step("injecting", "loading the native library into the JVM"); + match platform::inject(pid, &dll) { + Ok(()) => ok("injected", &format!("{} is loaded in the game process", platform::LIB_NAME)), Err(e) => { fail("failed", &e); return ExitCode::from(1); @@ -130,7 +140,6 @@ fn run(pid: u32, dll: &Path) -> ExitCode { Outcome::Done => { println!(); ok("success", "successfully injected - Prestige is running in the game"); - println!("{YELLOW} {WATERMARK}{RESET} {DIM}{INVITE_URL}{RESET}"); ExitCode::SUCCESS } Outcome::Failed(line) => { @@ -173,7 +182,9 @@ fn follow_log(path: &Path, start: u64) -> Outcome { // lines look like "[unix seconds] message" let message = line.split_once("] ").map(|(_, m)| m).unwrap_or(line); let lower = message.to_ascii_lowercase(); - if lower.contains("failed") || lower.contains("not found") { + // Per-hook failures ("[java] hook failed for ...") are not fatal: Bootstrap keeps the hooks that applied. + let fatal = message.starts_with("FAILED:") || lower.contains("start failed") || lower.contains("client init failed") || lower.contains("could not hook"); + if fatal { fail("loader", message); return Outcome::Failed(message.to_string()); } @@ -189,51 +200,26 @@ fn follow_log(path: &Path, start: u64) -> Outcome { } fn config_path() -> Option { - std::env::var_os("APPDATA").map(|dir| PathBuf::from(dir).join("PrestigeInjector").join("settings.txt")) + platform::config_dir().map(|dir| dir.join("PrestigeInjector").join("settings.txt")) } -/// First launch: open the Discord invite, then ask whether to open it again on later launches. -fn invite_prompt() { +/// Opens the Discord invite on the very first launch only (no prompt); afterwards only the credit line shows it. +fn open_invite_once() { let Some(path) = config_path() else { return }; - let saved = fs::read_to_string(&path).ok(); - let show = match saved.as_deref() { - None => true, // first launch - Some(text) => !text.lines().any(|l| l.trim() == "show_invite=false"), - }; - if !show { + if path.exists() { return; } - let first = saved.is_none(); - step("discord", &format!("opening {INVITE_URL}")); - if !win::open_url(INVITE_URL) { - fail("discord", &format!("could not open a browser, visit {INVITE_URL} manually")); - } - let again = ask_yes_no(if first { - "Show the Discord invite again next time you start the injector? [Y/n] " - } else { - "Keep showing the Discord invite on launch? [Y/n] " - }); + let _ = platform::open_url(INVITE_URL); if let Some(dir) = path.parent() { let _ = fs::create_dir_all(dir); } - let _ = fs::write(&path, format!("show_invite={again}\n")); - println!(); -} - -fn ask_yes_no(question: &str) -> bool { - print!("{question}"); - let _ = io::stdout().flush(); - let mut line = String::new(); - if io::stdin().read_line(&mut line).is_err() { - return true; - } - !matches!(line.trim().to_ascii_lowercase().as_str(), "n" | "no") + let _ = fs::write(&path, "invite_opened=true\n"); } /// Interactive list of Minecraft JVM windows, refreshed every 750 ms. Returns the chosen pid. fn pick_process() -> Option { let mut selected = 0usize; - let mut processes = win::java_windows(); + let mut processes = platform::java_processes(); let mut last_refresh = Instant::now(); loop { if selected >= processes.len() { @@ -242,34 +228,34 @@ fn pick_process() -> Option { draw(&processes, selected); let deadline = Instant::now() + Duration::from_millis(50); while Instant::now() < deadline { - if let Some(key) = win::poll_key() { + if let Some(key) = platform::poll_key() { match key { - win::Key::Up => selected = selected.saturating_sub(1), - win::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)), - win::Key::Enter => { + platform::Key::Up => selected = selected.saturating_sub(1), + platform::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)), + platform::Key::Enter => { if let Some(p) = processes.get(selected) { return Some(p.pid); } } - win::Key::Escape => return None, + platform::Key::Escape => return None, } draw(&processes, selected); } std::thread::sleep(Duration::from_millis(10)); } if last_refresh.elapsed() >= Duration::from_millis(750) { - processes = win::java_windows(); + processes = platform::java_processes(); last_refresh = Instant::now(); } } } -fn draw(processes: &[win::JavaWindow], selected: usize) { +fn draw(processes: &[platform::JavaProcess], selected: usize) { let mut out = String::from("\x1b[2J\x1b[H"); - out.push_str(&format!("{CYAN}Prestige injector{RESET} - Minecraft 1.21.11 (Fabric, 64-bit) {YELLOW}{WATERMARK}{RESET}\n")); - out.push_str("Up/Down select, Enter inject, Esc quit\n\n"); + out.push_str(&banner()); + out.push_str("Minecraft 1.21.11 (Fabric, 64-bit) - Up/Down select, Enter inject, Esc quit\n\n"); if processes.is_empty() { - out.push_str(" waiting for a javaw.exe / java.exe window...\n"); + out.push_str(" waiting for a Minecraft JVM (java/javaw)...\n"); } for (i, p) in processes.iter().enumerate() { let marker = if i == selected { ">" } else { " " }; diff --git a/injector/crates/injector/src/platform/mod.rs b/injector/crates/injector/src/platform/mod.rs new file mode 100644 index 0000000..fb7bd2c --- /dev/null +++ b/injector/crates/injector/src/platform/mod.rs @@ -0,0 +1,61 @@ +//! Platform backends of the injector. Each backend exposes the same functions: +//! enable_ansi, poll_key, java_processes, inject, open_url, restore_terminal. +use std::path::PathBuf; + +/// A running Minecraft JVM shown in the picker. +pub struct JavaProcess { + pub pid: u32, + pub title: String, + pub exe: String, +} + +pub enum Key { + Up, + Down, + Enter, + Escape, +} + +#[cfg(windows)] +mod win; +#[cfg(windows)] +pub use win::*; + +#[cfg(unix)] +mod unix_attach; +#[cfg(unix)] +mod unix_procs; +#[cfg(unix)] +mod unix_term; +#[cfg(unix)] +pub use unix_attach::inject; +#[cfg(unix)] +pub use unix_procs::java_processes; +#[cfg(unix)] +pub use unix_term::{enable_ansi, open_url, poll_key, restore_terminal}; + +#[cfg(windows)] +pub const LIB_NAME: &str = "prestige_native.dll"; +#[cfg(target_os = "macos")] +pub const LIB_NAME: &str = "libprestige_native.dylib"; +#[cfg(all(unix, not(target_os = "macos")))] +pub const LIB_NAME: &str = "libprestige_native.so"; + +/// Per-user configuration directory: APPDATA, $XDG_CONFIG_HOME or ~/.config, ~/Library/Application Support. +pub fn config_dir() -> Option { + #[cfg(windows)] + { + std::env::var_os("APPDATA").map(PathBuf::from) + } + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library").join("Application Support")) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + match std::env::var_os("XDG_CONFIG_HOME") { + Some(dir) if !dir.is_empty() => Some(PathBuf::from(dir)), + _ => std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")), + } + } +} diff --git a/injector/crates/injector/src/platform/unix_attach.rs b/injector/crates/injector/src/platform/unix_attach.rs new file mode 100644 index 0000000..7541a00 --- /dev/null +++ b/injector/crates/injector/src/platform/unix_attach.rs @@ -0,0 +1,405 @@ +//! HotSpot attach-protocol injection for Linux and macOS (what `jcmd JVMTI.agent_load` does). +//! +//! 1. Create `.attach_pid` where the target JVM looks for it and send SIGQUIT. +//! 2. Wait for the UNIX socket `.java_pid`. +//! 3. Send `1\0load\0\0true\0\0` and read the status reply. +//! +//! The JVM then dlopens the library and calls `Agent_OnAttach`. + +#![allow(dead_code)] + +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::MetadataExt; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +extern "C" { + fn kill(pid: i32, sig: i32) -> i32; + fn geteuid() -> u32; +} + +const SIGQUIT: i32 = 3; +const SOCKET_WAIT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(50); +const REPLY_TIMEOUT: Duration = Duration::from_secs(30); + +const IS_MACOS: bool = cfg!(target_os = "macos"); + +pub fn inject(pid: u32, lib: &Path) -> Result<(), String> { + let lib = fs::canonicalize(lib) + .map_err(|e| format!("library '{}' not found: {e}", lib.display()))?; + let lib_str = lib + .to_str() + .ok_or_else(|| "library path is not valid UTF-8".to_string())?; + + preflight_owner(pid)?; + if let Some(msg) = arch_mismatch(pid) { + return Err(msg); + } + + let sockets = socket_candidates(pid); + let existing = sockets.iter().find(|p| is_socket(p)).cloned(); + + let sock_path = match existing { + Some(p) => p, + None => trigger_attach(pid, &sockets)?, + }; + + send_load(&sock_path, lib_str) +} + +// ---------------------------------------------------------------- preflight + +fn current_uid() -> u32 { + unsafe { geteuid() } +} + +fn preflight_owner(pid: u32) -> Result<(), String> { + let uid = if IS_MACOS { + let out = Command::new("ps") + .args(["-o", "uid=", "-p", &pid.to_string()]) + .output() + .map_err(|e| format!("failed to run ps: {e}"))?; + let text = String::from_utf8_lossy(&out.stdout); + let text = text.trim(); + if text.is_empty() { + return Err(format!("process {pid} does not exist")); + } + text.parse::() + .map_err(|_| format!("could not parse owner of process {pid}: '{text}'"))? + } else { + match fs::metadata(format!("/proc/{pid}")) { + Ok(m) => m.uid(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(format!("process {pid} does not exist")) + } + Err(e) => return Err(format!("cannot inspect process {pid}: {e}")), + } + }; + let me = current_uid(); + if uid != me { + return Err(format!( + "process {pid} belongs to uid {uid} but the injector runs as uid {me}; \ + the attach protocol only works for the same user (run the injector as the user that started the game)" + )); + } + Ok(()) +} + +fn host_arch() -> &'static str { + match std::env::consts::ARCH { + "aarch64" | "arm64" => "arm64", + other => other, + } +} + +/// Some(message) only on a definite mismatch; None when matching or undeterminable. +fn arch_mismatch(pid: u32) -> Option { + let target = target_arch(pid)?; + if target == host_arch() { + return None; + } + let hint = if IS_MACOS && target == "x86_64" && host_arch() == "arm64" { + " (Rosetta?)" + } else { + "" + }; + Some(format!( + "target JVM is {target}{hint} but this injector is {}; use a matching JVM or a matching injector build", + host_arch() + )) +} + +fn target_arch(pid: u32) -> Option { + if IS_MACOS { + let out = Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok()?; + let comm = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if comm.is_empty() { + return None; + } + let head = read_head(Path::new(&comm), 64)?; + macho_arch(&head).map(str::to_string) + } else { + // Prefer the libjvm mapped into the process, fall back to the executable. + let mut candidates: Vec = Vec::new(); + if let Ok(maps) = fs::read_to_string(format!("/proc/{pid}/maps")) { + for line in maps.lines() { + if line.contains("libjvm.so") { + if let Some(path) = line.split_whitespace().nth(5) { + candidates.push(PathBuf::from(path)); + break; + } + } + } + } + candidates.push(PathBuf::from(format!("/proc/{pid}/exe"))); + for c in candidates { + if let Some(head) = read_head(&c, 64) { + if let Some(a) = elf_arch(&head) { + return Some(a.to_string()); + } + } + } + None + } +} + +fn read_head(path: &Path, n: usize) -> Option> { + let mut f = fs::File::open(path).ok()?; + let mut buf = vec![0u8; n]; + let mut got = 0; + while got < n { + match f.read(&mut buf[got..]) { + Ok(0) => break, + Ok(k) => got += k, + Err(_) => return None, + } + } + buf.truncate(got); + Some(buf) +} + +/// Architecture name ("x86_64", "arm64", ...) from an ELF header, None if not ELF/unknown. +fn elf_arch(h: &[u8]) -> Option<&'static str> { + if h.len() < 20 || &h[0..4] != b"\x7fELF" { + return None; + } + let machine = match h[5] { + 1 => u16::from_le_bytes([h[18], h[19]]), + 2 => u16::from_be_bytes([h[18], h[19]]), + _ => return None, + }; + Some(match machine { + 3 => "x86", + 40 => "arm", + 62 => "x86_64", + 183 => "arm64", + 243 => "riscv", + 21 => "ppc64", + _ => return None, + }) +} + +/// Architecture from a thin 64-bit Mach-O header. Fat/universal binaries return None +/// (the running slice cannot be determined from the file alone). +fn macho_arch(h: &[u8]) -> Option<&'static str> { + if h.len() < 8 { + return None; + } + let magic = u32::from_le_bytes([h[0], h[1], h[2], h[3]]); + let cputype = match magic { + 0xfeed_facf => u32::from_le_bytes([h[4], h[5], h[6], h[7]]), + 0xcffa_edfe => u32::from_be_bytes([h[4], h[5], h[6], h[7]]), + _ => return None, + }; + match cputype { + 0x0100_0007 => Some("x86_64"), + 0x0100_000c => Some("arm64"), + _ => None, + } +} + +// ------------------------------------------------------------ attach trigger + +fn socket_candidates(pid: u32) -> Vec { + let name = format!(".java_pid{pid}"); + let mut v = Vec::new(); + if IS_MACOS { + if let Some(t) = std::env::var_os("TMPDIR") { + v.push(Path::new(&t).join(&name)); + } + } + let tmp = Path::new("/tmp").join(&name); + if !v.contains(&tmp) { + v.push(tmp); + } + v +} + +fn is_socket(p: &Path) -> bool { + use std::os::unix::fs::FileTypeExt; + fs::symlink_metadata(p) + .map(|m| m.file_type().is_socket()) + .unwrap_or(false) +} + +/// Removes the trigger files when dropped, on every path. +struct TriggerFiles(Vec); + +impl Drop for TriggerFiles { + fn drop(&mut self) { + for p in &self.0 { + let _ = fs::remove_file(p); + } + } +} + +fn trigger_candidates(pid: u32) -> Vec { + let name = format!(".attach_pid{pid}"); + let mut v = Vec::new(); + if !IS_MACOS { + if let Ok(cwd) = fs::read_link(format!("/proc/{pid}/cwd")) { + v.push(cwd.join(&name)); + } + } + v.push(Path::new("/tmp").join(&name)); + if IS_MACOS { + if let Some(t) = std::env::var_os("TMPDIR") { + let p = Path::new(&t).join(&name); + if !v.contains(&p) { + v.push(p); + } + } + } + v +} + +fn trigger_attach(pid: u32, sockets: &[PathBuf]) -> Result { + let mut created = Vec::new(); + for p in trigger_candidates(pid) { + if fs::File::create(&p).is_ok() { + created.push(p); + } + } + if created.is_empty() { + return Err(format!( + "could not create .attach_pid{pid} in the target's working directory or /tmp" + )); + } + let _guard = TriggerFiles(created); + + if unsafe { kill(pid as i32, SIGQUIT) } != 0 { + return Err(format!( + "failed to send SIGQUIT to {pid}: {}", + std::io::Error::last_os_error() + )); + } + + let start = Instant::now(); + while start.elapsed() < SOCKET_WAIT { + if let Some(p) = sockets.iter().find(|p| is_socket(p)) { + return Ok(p.clone()); + } + std::thread::sleep(POLL_INTERVAL); + } + + if let Some(msg) = arch_mismatch(pid) { + return Err(format!("attach socket never appeared; {msg}")); + } + Err(format!( + "the JVM did not create its attach socket within {}s. Possible causes: the JVM was started with \ + -XX:+DisableAttachMechanism or -Xrs, the game runs in a sandbox (Flatpak/Snap) with a private /tmp, \ + or it belongs to a different user", + SOCKET_WAIT.as_secs() + )) +} + +// ------------------------------------------------------------------ protocol + +/// `1\0load\0\0true\0\0`: version, command, then three NUL-terminated args +/// (library path, "true" = absolute path, empty options). +fn build_request(lib: &str) -> Vec { + let mut v = Vec::with_capacity(lib.len() + 16); + v.extend_from_slice(b"1\0load\0"); + v.extend_from_slice(lib.as_bytes()); + v.extend_from_slice(b"\0true\0\0"); + v +} + +/// First line is the integer status (0 = success); the rest is message text. +fn parse_response(data: &[u8]) -> Result<(), String> { + let text = String::from_utf8_lossy(data); + let (first, rest) = match text.split_once('\n') { + Some((a, b)) => (a.trim(), b.trim()), + None => (text.trim(), ""), + }; + if first.is_empty() { + return Err("JVM closed the attach connection without a reply".into()); + } + match first.parse::() { + Ok(0) => Ok(()), + Ok(code) => { + if rest.is_empty() { + Err(format!("JVM rejected the load request (status {code})")) + } else { + Err(format!("JVM rejected the load request (status {code}): {rest}")) + } + } + Err(_) => Err(format!("unexpected reply from JVM: {}", text.trim())), + } +} + +fn send_load(sock: &Path, lib: &str) -> Result<(), String> { + let mut s = UnixStream::connect(sock).map_err(|e| { + format!( + "could not connect to attach socket {}: {e} (stale socket, or the JVM runs as another user)", + sock.display() + ) + })?; + let _ = s.set_read_timeout(Some(REPLY_TIMEOUT)); + let _ = s.set_write_timeout(Some(REPLY_TIMEOUT)); + s.write_all(&build_request(lib)) + .map_err(|e| format!("failed to send attach request: {e}"))?; + + let mut buf = Vec::new(); + match s.read_to_end(&mut buf) { + Ok(_) => {} + // A partial reply is still parseable; a bare timeout with no data is not. + Err(e) if buf.is_empty() => return Err(format!("no reply from JVM: {e}")), + Err(_) => {} + } + parse_response(&buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_format() { + assert_eq!( + build_request("/opt/libprestige_native.so"), + b"1\0load\0/opt/libprestige_native.so\0true\0\0".to_vec() + ); + } + + #[test] + fn response_ok_and_errors() { + assert!(parse_response(b"0\n").is_ok()); + assert!(parse_response(b"0").is_ok()); + let e = parse_response(b"-1\nreturn code: 1\n").unwrap_err(); + assert!(e.contains("-1") && e.contains("return code: 1")); + assert!(parse_response(b"").is_err()); + assert!(parse_response(b"garbage").is_err()); + } + + #[test] + fn elf_headers() { + let mut h = vec![0u8; 64]; + h[..4].copy_from_slice(b"\x7fELF"); + h[5] = 1; + h[18..20].copy_from_slice(&62u16.to_le_bytes()); + assert_eq!(elf_arch(&h), Some("x86_64")); + h[18..20].copy_from_slice(&183u16.to_le_bytes()); + assert_eq!(elf_arch(&h), Some("arm64")); + assert_eq!(elf_arch(b"nope"), None); + } + + #[test] + fn macho_headers() { + let mut h = vec![0xcf, 0xfa, 0xed, 0xfe]; + h.extend_from_slice(&0x0100_000cu32.to_le_bytes()); + assert_eq!(macho_arch(&h), Some("arm64")); + let mut h = vec![0xcf, 0xfa, 0xed, 0xfe]; + h.extend_from_slice(&0x0100_0007u32.to_le_bytes()); + assert_eq!(macho_arch(&h), Some("x86_64")); + // fat binary -> undeterminable + assert_eq!(macho_arch(&[0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2]), None); + } +} diff --git a/injector/crates/injector/src/platform/unix_procs.rs b/injector/crates/injector/src/platform/unix_procs.rs new file mode 100644 index 0000000..224584c --- /dev/null +++ b/injector/crates/injector/src/platform/unix_procs.rs @@ -0,0 +1,158 @@ +//! Lists running Minecraft (Fabric) JVMs on Linux and macOS. std only. +use super::JavaProcess; + +/// Last path component, accepting both separators. +fn file_name(path: &str) -> &str { + path.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or(path) +} + +/// True when `argv0` names a java launcher and the arguments look like a Minecraft/Fabric JVM. +pub(crate) fn is_minecraft_jvm(args: &[String]) -> bool { + let Some(argv0) = args.first() else { return false }; + if !file_name(argv0).to_ascii_lowercase().contains("java") { + return false; + } + args.iter().any(|a| { + let a = a.to_ascii_lowercase(); + a.contains("net.fabricmc.loader") || a.contains("knotclient") || a.contains("minecraft") + }) +} + +/// Friendly instance label: value of --version, else the last component of --gameDir, else "Minecraft". +pub(crate) fn instance_label(args: &[String]) -> String { + fn value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + let prefix = format!("{flag}="); + let mut it = args.iter(); + while let Some(a) = it.next() { + if a == flag { + return it.next().map(String::as_str).filter(|v| !v.is_empty()); + } + if let Some(v) = a.strip_prefix(&prefix) { + if !v.is_empty() { + return Some(v); + } + } + } + None + } + if let Some(v) = value(args, "--version") { + return v.to_string(); + } + if let Some(dir) = value(args, "--gameDir") { + let name = file_name(dir); + if !name.is_empty() { + return name.to_string(); + } + } + "Minecraft".to_string() +} + +fn build(pid: u32, args: &[String]) -> Option { + if !is_minecraft_jvm(args) { + return None; + } + Some(JavaProcess { pid, title: instance_label(args), exe: file_name(&args[0]).to_string() }) +} + +/// Parses one `ps -axo pid=,uid=,command=` line into (pid, uid, argv). +/// ps joins argv with spaces, so argv[0] is taken as everything before the first " -" option. +#[cfg_attr(target_os = "linux", allow(dead_code))] +pub(crate) fn parse_ps_line(line: &str) -> Option<(u32, u32, Vec)> { + let line = line.trim_start(); + let (pid, rest) = line.split_once(char::is_whitespace)?; + let rest = rest.trim_start(); + let (uid, cmd) = rest.split_once(char::is_whitespace)?; + let cmd = cmd.trim(); + let (argv0, tail) = match cmd.find(" -") { + Some(i) => (&cmd[..i], &cmd[i..]), + None => (cmd, ""), + }; + let mut args = vec![argv0.to_string()]; + args.extend(tail.split_whitespace().map(str::to_string)); + Some((pid.parse().ok()?, uid.parse().ok()?, args)) +} + +#[cfg(target_os = "linux")] +pub fn java_processes() -> Vec { + use std::os::unix::fs::MetadataExt; + let own_pid = std::process::id(); + let own_uid = std::fs::metadata("/proc/self").map(|m| m.uid()).ok(); + let mut out = Vec::new(); + let Ok(dir) = std::fs::read_dir("/proc") else { return out }; + for entry in dir.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(|n| n.parse::().ok()) else { continue }; + if pid == own_pid { + continue; + } + if let (Some(me), Ok(meta)) = (own_uid, entry.metadata()) { + if meta.uid() != me { + continue; + } + } + let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue }; + let args: Vec = raw + .split(|&b| b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + if let Some(p) = build(pid, &args) { + out.push(p); + } + } + out.sort_by_key(|p| p.pid); + out +} + +#[cfg(not(target_os = "linux"))] +pub fn java_processes() -> Vec { + let own_pid = std::process::id(); + let Ok(output) = std::process::Command::new("ps").args(["-axo", "pid=,uid=,command="]).output() else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + let rows: Vec<_> = text.lines().filter_map(parse_ps_line).collect(); + let own_uid = rows.iter().find(|r| r.0 == own_pid).map(|r| r.1); + let mut out: Vec = rows + .iter() + .filter(|(pid, uid, _)| *pid != own_pid && own_uid.map_or(true, |u| u == *uid)) + .filter_map(|(pid, _, args)| build(*pid, args)) + .collect(); + out.sort_by_key(|p| p.pid); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn filters_minecraft_jvms() { + assert!(is_minecraft_jvm(&v(&["/usr/lib/jvm/java-21/bin/java", "-cp", "x", "net.fabricmc.loader.impl.launch.knot.KnotClient"]))); + assert!(is_minecraft_jvm(&v(&["java", "-Dminecraft.launcher.brand=x", "Main"]))); + assert!(!is_minecraft_jvm(&v(&["/usr/bin/java", "-jar", "gradle.jar"]))); + assert!(!is_minecraft_jvm(&v(&["/usr/bin/bash", "-c", "minecraft"]))); + assert!(!is_minecraft_jvm(&[])); + } + + #[test] + fn labels() { + assert_eq!(instance_label(&v(&["java", "--version", "fabric-1.21.11"])), "fabric-1.21.11"); + assert_eq!(instance_label(&v(&["java", "--version=1.21"])), "1.21"); + assert_eq!(instance_label(&v(&["java", "--gameDir", "/home/u/.minecraft/instances/Pack/"])), "Pack"); + assert_eq!(instance_label(&v(&["java", "KnotClient"])), "Minecraft"); + } + + #[test] + fn ps_lines() { + let (pid, uid, args) = parse_ps_line(" 123 501 /Users/a/Library/Application Support/x/bin/java -Xmx2G --version 1.21 net.fabricmc.loader").unwrap(); + assert_eq!((pid, uid), (123, 501)); + assert_eq!(args[0], "/Users/a/Library/Application Support/x/bin/java"); + assert!(is_minecraft_jvm(&args)); + assert_eq!(instance_label(&args), "1.21"); + assert!(parse_ps_line("garbage").is_none()); + } +} diff --git a/injector/crates/injector/src/platform/unix_term.rs b/injector/crates/injector/src/platform/unix_term.rs new file mode 100644 index 0000000..e07154e --- /dev/null +++ b/injector/crates/injector/src/platform/unix_term.rs @@ -0,0 +1,152 @@ +//! Terminal input and URL opening on Linux and macOS. std only (no termios structs). +//! +//! Raw mode is entered lazily on the first `poll_key` call via `stty -icanon -echo min 1 time 0` +//! on /dev/tty (ISIG stays on, so Ctrl-C still raises SIGINT). A reader thread blocks on the tty +//! and forwards bytes over a channel; `poll_key` parses them. The original settings (`stty -g`) +//! are restored by `restore_terminal()` and, for Ctrl-C, by a SIGINT handler that only writes to +//! a self-pipe (async-signal-safe); a helper thread then restores the terminal and exits 130. +use super::Key; +use std::fs::File; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +extern "C" { + fn signal(signum: i32, handler: usize) -> usize; + fn pipe(fds: *mut i32) -> i32; + fn write(fd: i32, buf: *const u8, n: usize) -> isize; + fn read(fd: i32, buf: *mut u8, n: usize) -> isize; +} +const SIGINT: i32 = 2; + +static SAVED: Mutex> = Mutex::new(None); +static INPUT: OnceLock>>> = OnceLock::new(); +static SIG_WRITE_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); + +fn input() -> &'static Mutex>> { + INPUT.get_or_init(|| Mutex::new(None)) +} + +fn tty_stdin() -> Stdio { + match File::open("/dev/tty") { + Ok(f) => Stdio::from(f), + Err(_) => Stdio::inherit(), + } +} + +fn stty(args: &[&str]) -> Option { + let out = Command::new("stty").args(args).stdin(tty_stdin()).stderr(Stdio::null()).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Nothing to do on unix (terminals understand ANSI); raw mode is entered lazily by `poll_key`. +pub fn enable_ansi() {} + +extern "C" fn on_sigint(_sig: i32) { + let fd = SIG_WRITE_FD.load(std::sync::atomic::Ordering::Relaxed); + if fd >= 0 { + let b = 1u8; + unsafe { write(fd, &b, 1) }; + } +} + +fn install_sigint() { + let mut fds = [0i32; 2]; + if unsafe { pipe(fds.as_mut_ptr()) } != 0 { + return; + } + SIG_WRITE_FD.store(fds[1], std::sync::atomic::Ordering::Relaxed); + let rfd = fds[0]; + std::thread::spawn(move || { + let mut b = 0u8; + loop { + let n = unsafe { read(rfd, &mut b, 1) }; + if n == 1 { + restore_terminal(); + println!(); + std::process::exit(130); + } + if n == 0 { + return; + } + } + }); + unsafe { signal(SIGINT, on_sigint as extern "C" fn(i32) as usize) }; +} + +fn enter_raw() -> Option> { + let saved = stty(&["-g"])?; + *SAVED.lock().ok()? = Some(saved); + stty(&["-icanon", "-echo", "min", "1", "time", "0"])?; + let mut tty: Box = match File::open("/dev/tty") { + Ok(f) => Box::new(f), + Err(_) => Box::new(std::io::stdin()), + }; + let (tx, rx) = channel(); + std::thread::spawn(move || { + let mut b = [0u8; 1]; + while let Ok(1) = tty.read(&mut b) { + if tx.send(b[0]).is_err() { + break; + } + } + }); + install_sigint(); + Some(rx) +} + +/// Returns a pending key press, if any, without blocking (except ~30ms to disambiguate a lone Esc). +pub fn poll_key() -> Option { + let mut guard = input().lock().ok()?; + if guard.is_none() { + *guard = Some(enter_raw()?); + } + let rx = guard.as_ref()?; + loop { + let b = rx.try_recv().ok()?; + match b { + b'\n' | b'\r' => return Some(Key::Enter), + b'k' => return Some(Key::Up), + b'j' => return Some(Key::Down), + 27 => { + let ms30 = Duration::from_millis(30); + match rx.recv_timeout(ms30) { + Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => return Some(Key::Escape), + Ok(b'[') | Ok(b'O') => match rx.recv_timeout(ms30) { + Ok(b'A') => return Some(Key::Up), + Ok(b'B') => return Some(Key::Down), + _ => {} + }, + Ok(_) => {} + } + } + _ => {} + } + } +} + +/// Restores the terminal settings saved when raw mode was entered. Safe to call repeatedly. +pub fn restore_terminal() { + let saved = SAVED.lock().ok().and_then(|mut s| s.take()); + if let Some(s) = saved { + stty(&[&s]); + } +} + +/// Opens a URL in the default browser (xdg-open / open). Returns whether the spawn succeeded. +pub fn open_url(url: &str) -> bool { + #[cfg(target_os = "macos")] + let opener = "open"; + #[cfg(not(target_os = "macos"))] + let opener = "xdg-open"; + Command::new(opener) + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| true) + .unwrap_or(false) +} diff --git a/injector/crates/injector/src/win.rs b/injector/crates/injector/src/platform/win.rs similarity index 94% rename from injector/crates/injector/src/win.rs rename to injector/crates/injector/src/platform/win.rs index e4330a9..e3c2bb7 100644 --- a/injector/crates/injector/src/win.rs +++ b/injector/crates/injector/src/platform/win.rs @@ -1,278 +1,270 @@ -//! Win32 plumbing of the injector (raw bindings, no external crates). -use std::ffi::c_void; -use std::os::windows::ffi::OsStrExt; -use std::path::Path; -use std::time::Duration; - -type Handle = *mut c_void; - -const PROCESS_CREATE_THREAD: u32 = 0x0002; -const PROCESS_VM_OPERATION: u32 = 0x0008; -const PROCESS_VM_WRITE: u32 = 0x0020; -const PROCESS_VM_READ: u32 = 0x0010; -const PROCESS_QUERY_INFORMATION: u32 = 0x0400; -const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; -const MEM_COMMIT: u32 = 0x1000; -const MEM_RESERVE: u32 = 0x2000; -const MEM_RELEASE: u32 = 0x8000; -const PAGE_READWRITE: u32 = 0x04; -const TH32CS_SNAPMODULE: u32 = 0x8; -const TH32CS_SNAPMODULE32: u32 = 0x10; -const WAIT_OBJECT_0: u32 = 0; - -#[repr(C)] -struct ModuleEntry32W { - size: u32, - module_id: u32, - process_id: u32, - glblcnt_usage: u32, - proccnt_usage: u32, - base_addr: *mut u8, - base_size: u32, - module: Handle, - sz_module: [u16; 256], - sz_exe_path: [u16; 260], -} - -extern "system" { - fn OpenProcess(access: u32, inherit: i32, pid: u32) -> Handle; - fn CloseHandle(h: Handle) -> i32; - fn VirtualAllocEx(process: Handle, address: *mut c_void, size: usize, alloc_type: u32, protect: u32) -> *mut c_void; - fn VirtualFreeEx(process: Handle, address: *mut c_void, size: usize, free_type: u32) -> i32; - fn WriteProcessMemory(process: Handle, address: *mut c_void, buf: *const c_void, size: usize, written: *mut usize) -> i32; - fn CreateRemoteThread(process: Handle, attrs: *mut c_void, stack: usize, start: *mut c_void, param: *mut c_void, flags: u32, id: *mut u32) -> Handle; - fn WaitForSingleObject(h: Handle, ms: u32) -> u32; - fn GetExitCodeThread(h: Handle, code: *mut u32) -> i32; - fn GetModuleHandleW(name: *const u16) -> Handle; - fn GetProcAddress(module: Handle, name: *const u8) -> *mut c_void; - fn IsWow64Process(process: Handle, wow64: *mut i32) -> i32; - fn QueryFullProcessImageNameW(process: Handle, flags: u32, buf: *mut u16, size: *mut u32) -> i32; - fn GetLastError() -> u32; - fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> Handle; - fn Module32FirstW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32; - fn Module32NextW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32; - fn GetStdHandle(id: u32) -> Handle; - fn GetConsoleMode(h: Handle, mode: *mut u32) -> i32; - fn SetConsoleMode(h: Handle, mode: u32) -> i32; -} - -#[link(name = "user32")] -extern "system" { - fn EnumWindows(callback: unsafe extern "system" fn(Handle, isize) -> i32, param: isize) -> i32; - fn IsWindowVisible(hwnd: Handle) -> i32; - fn GetWindowTextW(hwnd: Handle, buf: *mut u16, max: i32) -> i32; - fn GetWindowThreadProcessId(hwnd: Handle, pid: *mut u32) -> u32; -} - -#[link(name = "shell32")] -extern "system" { - fn ShellExecuteW(hwnd: Handle, verb: *const u16, file: *const u16, params: *const u16, dir: *const u16, show: i32) -> isize; -} - -extern "C" { - fn _kbhit() -> i32; - fn _getch() -> i32; -} - -pub struct JavaWindow { - pub pid: u32, - pub title: String, - pub exe: String, -} - -pub enum Key { - Up, - Down, - Enter, - Escape, -} - -pub fn enable_ansi() { - unsafe { - let out = GetStdHandle(-11i32 as u32); - let mut mode = 0; - if GetConsoleMode(out, &mut mode) != 0 { - SetConsoleMode(out, mode | 0x0004); - } - } -} - -/// Non-blocking key read: arrows arrive as 0xE0/0x00 followed by 72 (up) / 80 (down). -pub fn poll_key() -> Option { - unsafe { - if _kbhit() == 0 { - return None; - } - match _getch() { - 13 => Some(Key::Enter), - 27 => Some(Key::Escape), - 0 | 0xE0 => match _getch() { - 72 => Some(Key::Up), - 80 => Some(Key::Down), - _ => None, - }, - _ => None, - } - } -} - -fn wide(path: &Path) -> Vec { - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() -} - -fn image_name(pid: u32) -> Option { - unsafe { - let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if process.is_null() { - return None; - } - let mut buf = [0u16; 1024]; - let mut len = buf.len() as u32; - let ok = QueryFullProcessImageNameW(process, 0, buf.as_mut_ptr(), &mut len); - CloseHandle(process); - if ok == 0 { - return None; - } - Some(String::from_utf16_lossy(&buf[..len as usize])) - } -} - -unsafe extern "system" fn collect(hwnd: Handle, param: isize) -> i32 { - let out = &mut *(param as *mut Vec<(u32, String)>); - if IsWindowVisible(hwnd) == 0 { - return 1; - } - let mut buf = [0u16; 256]; - let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32); - if len <= 0 { - return 1; - } - let mut pid = 0; - GetWindowThreadProcessId(hwnd, &mut pid); - out.push((pid, String::from_utf16_lossy(&buf[..len as usize]))); - 1 -} - -/// Visible top-level windows that belong to java.exe / javaw.exe, one entry per process. -pub fn java_windows() -> Vec { - let mut windows: Vec<(u32, String)> = Vec::new(); - unsafe { - EnumWindows(collect, &mut windows as *mut _ as isize); - } - let mut out: Vec = Vec::new(); - for (pid, title) in windows { - if out.iter().any(|w| w.pid == pid) { - continue; - } - let Some(path) = image_name(pid) else { continue }; - let exe = path.rsplit(['\\', '/']).next().unwrap_or("").to_string(); - if exe.eq_ignore_ascii_case("javaw.exe") || exe.eq_ignore_ascii_case("java.exe") { - out.push(JavaWindow { pid, title, exe }); - } - } - out -} - -fn last_error(what: &str) -> String { - format!("{what} failed (error {})", unsafe { GetLastError() }) -} - -/// True when the module file name is loaded in the process. -fn module_loaded(pid: u32, file_name: &str) -> bool { - unsafe { - let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid); - if snapshot.is_null() || snapshot as isize == -1 { - return false; - } - let mut entry: ModuleEntry32W = std::mem::zeroed(); - entry.size = std::mem::size_of::() as u32; - let mut found = false; - let mut ok = Module32FirstW(snapshot, &mut entry); - while ok != 0 { - let len = entry.sz_module.iter().position(|&c| c == 0).unwrap_or(entry.sz_module.len()); - if String::from_utf16_lossy(&entry.sz_module[..len]).eq_ignore_ascii_case(file_name) { - found = true; - break; - } - ok = Module32NextW(snapshot, &mut entry); - } - CloseHandle(snapshot); - found - } -} - -/// LoadLibraryW(dll) in the target process through CreateRemoteThread. -pub fn inject(pid: u32, dll: &Path) -> Result<(), String> { - unsafe { - let access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; - let process = OpenProcess(access, 0, pid); - if process.is_null() { - return Err(last_error("OpenProcess (run as the same user, or as administrator)")); - } - let result = inject_into(process, pid, dll); - CloseHandle(process); - result - } -} - -unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), String> { - let mut wow64 = 0; - if IsWow64Process(process, &mut wow64) != 0 && wow64 != 0 { - return Err("the target is a 32-bit JVM; only 64-bit JVMs are supported".into()); - } - let file_name = dll.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - if module_loaded(pid, &file_name) { - return Err(format!("{file_name} is already loaded in process {pid}")); - } - - let path = wide(dll); - let bytes = path.len() * 2; - let remote = VirtualAllocEx(process, std::ptr::null_mut(), bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); - if remote.is_null() { - return Err(last_error("VirtualAllocEx")); - } - let cleanup = |err: String| { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - Err(err) - }; - if WriteProcessMemory(process, remote, path.as_ptr() as *const c_void, bytes, std::ptr::null_mut()) == 0 { - return cleanup(last_error("WriteProcessMemory")); - } - - // kernel32 is mapped at the same base in every process of a boot session, so the local address is valid remotely. - let kernel32 = GetModuleHandleW(wide(Path::new("kernel32.dll")).as_ptr()); - let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr() as *const u8); - if load_library.is_null() { - return cleanup(last_error("resolve LoadLibraryW")); - } - let thread = CreateRemoteThread(process, std::ptr::null_mut(), 0, load_library, remote, 0, std::ptr::null_mut()); - if thread.is_null() { - return cleanup(last_error("CreateRemoteThread")); - } - let wait = WaitForSingleObject(thread, 30_000); - let mut exit = 0; - GetExitCodeThread(thread, &mut exit); - CloseHandle(thread); - if wait != WAIT_OBJECT_0 { - // The thread may still be running inside the target; leave the buffer alone. - return Err("remote LoadLibraryW did not finish within 30 seconds".into()); - } - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - - // LoadLibraryW's return value is truncated to 32 bits by GetExitCodeThread, so confirm through the module list. - for _ in 0..20 { - if module_loaded(pid, &file_name) { - return Ok(()); - } - std::thread::sleep(Duration::from_millis(100)); - } - Err(format!("LoadLibraryW returned {exit:#x} but {file_name} is not mapped (missing dependency or blocked by security software?)")) -} - -/// Opens a URL in the default web browser. -pub fn open_url(url: &str) -> bool { - let verb: Vec = "open".encode_utf16().collect(); - let target: Vec = url.encode_utf16().chain(std::iter::once(0)).collect(); - // ShellExecute returns a value greater than 32 on success - unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), target.as_ptr(), std::ptr::null(), std::ptr::null(), 1) > 32 } -} +//! Win32 plumbing of the injector (raw bindings, no external crates). +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::time::Duration; + +use super::{JavaProcess, Key}; + +type Handle = *mut c_void; + +const PROCESS_CREATE_THREAD: u32 = 0x0002; +const PROCESS_VM_OPERATION: u32 = 0x0008; +const PROCESS_VM_WRITE: u32 = 0x0020; +const PROCESS_VM_READ: u32 = 0x0010; +const PROCESS_QUERY_INFORMATION: u32 = 0x0400; +const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; +const MEM_COMMIT: u32 = 0x1000; +const MEM_RESERVE: u32 = 0x2000; +const MEM_RELEASE: u32 = 0x8000; +const PAGE_READWRITE: u32 = 0x04; +const TH32CS_SNAPMODULE: u32 = 0x8; +const TH32CS_SNAPMODULE32: u32 = 0x10; +const WAIT_OBJECT_0: u32 = 0; + +#[repr(C)] +struct ModuleEntry32W { + size: u32, + module_id: u32, + process_id: u32, + glblcnt_usage: u32, + proccnt_usage: u32, + base_addr: *mut u8, + base_size: u32, + module: Handle, + sz_module: [u16; 256], + sz_exe_path: [u16; 260], +} + +extern "system" { + fn OpenProcess(access: u32, inherit: i32, pid: u32) -> Handle; + fn CloseHandle(h: Handle) -> i32; + fn VirtualAllocEx(process: Handle, address: *mut c_void, size: usize, alloc_type: u32, protect: u32) -> *mut c_void; + fn VirtualFreeEx(process: Handle, address: *mut c_void, size: usize, free_type: u32) -> i32; + fn WriteProcessMemory(process: Handle, address: *mut c_void, buf: *const c_void, size: usize, written: *mut usize) -> i32; + fn CreateRemoteThread(process: Handle, attrs: *mut c_void, stack: usize, start: *mut c_void, param: *mut c_void, flags: u32, id: *mut u32) -> Handle; + fn WaitForSingleObject(h: Handle, ms: u32) -> u32; + fn GetExitCodeThread(h: Handle, code: *mut u32) -> i32; + fn GetModuleHandleW(name: *const u16) -> Handle; + fn GetProcAddress(module: Handle, name: *const u8) -> *mut c_void; + fn IsWow64Process(process: Handle, wow64: *mut i32) -> i32; + fn QueryFullProcessImageNameW(process: Handle, flags: u32, buf: *mut u16, size: *mut u32) -> i32; + fn GetLastError() -> u32; + fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> Handle; + fn Module32FirstW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32; + fn Module32NextW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32; + fn GetStdHandle(id: u32) -> Handle; + fn GetConsoleMode(h: Handle, mode: *mut u32) -> i32; + fn SetConsoleMode(h: Handle, mode: u32) -> i32; +} + +#[link(name = "user32")] +extern "system" { + fn EnumWindows(callback: unsafe extern "system" fn(Handle, isize) -> i32, param: isize) -> i32; + fn IsWindowVisible(hwnd: Handle) -> i32; + fn GetWindowTextW(hwnd: Handle, buf: *mut u16, max: i32) -> i32; + fn GetWindowThreadProcessId(hwnd: Handle, pid: *mut u32) -> u32; +} + +#[link(name = "shell32")] +extern "system" { + fn ShellExecuteW(hwnd: Handle, verb: *const u16, file: *const u16, params: *const u16, dir: *const u16, show: i32) -> isize; +} + +extern "C" { + fn _kbhit() -> i32; + fn _getch() -> i32; +} + +/// Nothing to restore on Windows (keys are read with _getch). +pub fn restore_terminal() {} + +pub fn enable_ansi() { + unsafe { + let out = GetStdHandle(-11i32 as u32); + let mut mode = 0; + if GetConsoleMode(out, &mut mode) != 0 { + SetConsoleMode(out, mode | 0x0004); + } + } +} + +/// Non-blocking key read: arrows arrive as 0xE0/0x00 followed by 72 (up) / 80 (down). +pub fn poll_key() -> Option { + unsafe { + if _kbhit() == 0 { + return None; + } + match _getch() { + 13 => Some(Key::Enter), + 27 => Some(Key::Escape), + 0 | 0xE0 => match _getch() { + 72 => Some(Key::Up), + 80 => Some(Key::Down), + _ => None, + }, + _ => None, + } + } +} + +fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +} + +fn image_name(pid: u32) -> Option { + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return None; + } + let mut buf = [0u16; 1024]; + let mut len = buf.len() as u32; + let ok = QueryFullProcessImageNameW(process, 0, buf.as_mut_ptr(), &mut len); + CloseHandle(process); + if ok == 0 { + return None; + } + Some(String::from_utf16_lossy(&buf[..len as usize])) + } +} + +unsafe extern "system" fn collect(hwnd: Handle, param: isize) -> i32 { + let out = &mut *(param as *mut Vec<(u32, String)>); + if IsWindowVisible(hwnd) == 0 { + return 1; + } + let mut buf = [0u16; 256]; + let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32); + if len <= 0 { + return 1; + } + let mut pid = 0; + GetWindowThreadProcessId(hwnd, &mut pid); + out.push((pid, String::from_utf16_lossy(&buf[..len as usize]))); + 1 +} + +/// Visible top-level windows that belong to java.exe / javaw.exe, one entry per process. +pub fn java_processes() -> Vec { + let mut windows: Vec<(u32, String)> = Vec::new(); + unsafe { + EnumWindows(collect, &mut windows as *mut _ as isize); + } + let mut out: Vec = Vec::new(); + for (pid, title) in windows { + if out.iter().any(|w| w.pid == pid) { + continue; + } + let Some(path) = image_name(pid) else { continue }; + let exe = path.rsplit(['\\', '/']).next().unwrap_or("").to_string(); + if exe.eq_ignore_ascii_case("javaw.exe") || exe.eq_ignore_ascii_case("java.exe") { + out.push(JavaProcess { pid, title, exe }); + } + } + out +} + +fn last_error(what: &str) -> String { + format!("{what} failed (error {})", unsafe { GetLastError() }) +} + +/// True when the module file name is loaded in the process. +fn module_loaded(pid: u32, file_name: &str) -> bool { + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid); + if snapshot.is_null() || snapshot as isize == -1 { + return false; + } + let mut entry: ModuleEntry32W = std::mem::zeroed(); + entry.size = std::mem::size_of::() as u32; + let mut found = false; + let mut ok = Module32FirstW(snapshot, &mut entry); + while ok != 0 { + let len = entry.sz_module.iter().position(|&c| c == 0).unwrap_or(entry.sz_module.len()); + if String::from_utf16_lossy(&entry.sz_module[..len]).eq_ignore_ascii_case(file_name) { + found = true; + break; + } + ok = Module32NextW(snapshot, &mut entry); + } + CloseHandle(snapshot); + found + } +} + +/// LoadLibraryW(dll) in the target process through CreateRemoteThread. +pub fn inject(pid: u32, dll: &Path) -> Result<(), String> { + unsafe { + let access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + let process = OpenProcess(access, 0, pid); + if process.is_null() { + return Err(last_error("OpenProcess (run as the same user, or as administrator)")); + } + let result = inject_into(process, pid, dll); + CloseHandle(process); + result + } +} + +unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), String> { + let mut wow64 = 0; + if IsWow64Process(process, &mut wow64) != 0 && wow64 != 0 { + return Err("the target is a 32-bit JVM; only 64-bit JVMs are supported".into()); + } + let file_name = dll.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if module_loaded(pid, &file_name) { + return Err(format!("{file_name} is already loaded in process {pid}")); + } + + let path = wide(dll); + let bytes = path.len() * 2; + let remote = VirtualAllocEx(process, std::ptr::null_mut(), bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if remote.is_null() { + return Err(last_error("VirtualAllocEx")); + } + let cleanup = |err: String| { + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + Err(err) + }; + if WriteProcessMemory(process, remote, path.as_ptr() as *const c_void, bytes, std::ptr::null_mut()) == 0 { + return cleanup(last_error("WriteProcessMemory")); + } + + // kernel32 is mapped at the same base in every process of a boot session, so the local address is valid remotely. + let kernel32 = GetModuleHandleW(wide(Path::new("kernel32.dll")).as_ptr()); + let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr() as *const u8); + if load_library.is_null() { + return cleanup(last_error("resolve LoadLibraryW")); + } + let thread = CreateRemoteThread(process, std::ptr::null_mut(), 0, load_library, remote, 0, std::ptr::null_mut()); + if thread.is_null() { + return cleanup(last_error("CreateRemoteThread")); + } + let wait = WaitForSingleObject(thread, 30_000); + let mut exit = 0; + GetExitCodeThread(thread, &mut exit); + CloseHandle(thread); + if wait != WAIT_OBJECT_0 { + // The thread may still be running inside the target; leave the buffer alone. + return Err("remote LoadLibraryW did not finish within 30 seconds".into()); + } + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + + // LoadLibraryW's return value is truncated to 32 bits by GetExitCodeThread, so confirm through the module list. + for _ in 0..20 { + if module_loaded(pid, &file_name) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(format!("LoadLibraryW returned {exit:#x} but {file_name} is not mapped (missing dependency or blocked by security software?)")) +} + +/// Opens a URL in the default web browser. +pub fn open_url(url: &str) -> bool { + let verb: Vec = "open".encode_utf16().collect(); + let target: Vec = url.encode_utf16().chain(std::iter::once(0)).collect(); + // ShellExecute returns a value greater than 32 on success + unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), target.as_ptr(), std::ptr::null(), std::ptr::null(), 1) > 32 } +} diff --git a/injector/crates/loader/src/bridge.rs b/injector/crates/loader/src/bridge.rs index c61e665..980e058 100644 --- a/injector/crates/loader/src/bridge.rs +++ b/injector/crates/loader/src/bridge.rs @@ -1,14 +1,16 @@ //! JVMTI state and the two natives of dev.zprestige.prestige.inject.NativeBridge. use crate::jni::*; use std::ffi::{c_char, c_void}; -use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicPtr, Ordering}; use std::sync::Mutex; static JVMTI: AtomicPtr<*const *const c_void> = AtomicPtr::new(std::ptr::null_mut()); -/// jclass whose bytes the ClassFileLoadHook should capture during the current RetransformClasses call (0 = none). -static CAPTURE_TARGET: AtomicUsize = AtomicUsize::new(0); -static CAPTURED: Mutex>> = Mutex::new(None); -/// Serialises classBytes calls: CAPTURE_TARGET holds a thread-local jclass reference, so only one thread may capture at a time. +/// jclass references (as addresses) whose bytes the ClassFileLoadHook captures during the current RetransformClasses call. +/// Never held across the JVMTI call: the hook runs on the calling thread and locks it again. +static CAPTURE_TARGETS: Mutex> = Mutex::new(Vec::new()); +/// Captured bytes, index-aligned with CAPTURE_TARGETS. +static CAPTURED: Mutex>>> = Mutex::new(Vec::new()); +/// Serialises classBytes calls: CAPTURE_TARGETS holds thread-local jclass references, so only one thread may capture at a time. static CAPTURE_LOCK: Mutex<()> = Mutex::new(()); /// Acquires the JVMTI capabilities and enables the class file load hook. Called once from the worker thread. @@ -22,7 +24,7 @@ pub unsafe fn init_jvmti(vm: VmPtr) -> Result<(), String> { if rc != 0 { return Err(format!("AddCapabilities failed ({rc})")); } - enable_class_file_load_hook(jvmti, class_file_load_hook)?; + install_class_file_load_hook(jvmti, class_file_load_hook)?; JVMTI.store(jvmti, Ordering::SeqCst); Ok(()) } @@ -40,14 +42,22 @@ unsafe extern "system" fn class_file_load_hook( _new_len: *mut JInt, _new_data: *mut *mut u8, ) { - let target = CAPTURE_TARGET.load(Ordering::SeqCst); - if target == 0 || class_being_redefined.is_null() || data.is_null() { + if class_being_redefined.is_null() || data.is_null() { return; } + let targets: Vec = match CAPTURE_TARGETS.lock() { + Ok(t) if !t.is_empty() => t.clone(), + _ => return, + }; let jni = Jni::new(env); - if jni.is_same_object(class_being_redefined, target as JObject) { - if let Ok(mut slot) = CAPTURED.lock() { - *slot = Some(std::slice::from_raw_parts(data, data_len as usize).to_vec()); + for (i, &target) in targets.iter().enumerate() { + if jni.is_same_object(class_being_redefined, target as JObject) { + if let Ok(mut slots) = CAPTURED.lock() { + if let Some(slot) = slots.get_mut(i) { + *slot = Some(std::slice::from_raw_parts(data, data_len as usize).to_vec()); + } + } + return; } } } @@ -58,22 +68,39 @@ pub unsafe extern "system" fn native_class_bytes(env: EnvPtr, _cls: JClass, targ if jvmti.is_null() { return std::ptr::null_mut(); } - let _serial = CAPTURE_LOCK.lock(); - if let Ok(mut slot) = CAPTURED.lock() { - *slot = None; + match capture(jvmti, &[target]).and_then(|mut v| v.pop().flatten()) { + Some(bytes) => to_byte_array(&Jni::new(env), &bytes), + None => std::ptr::null_mut(), + } +} + +/// Retransforms `classes` in one JVMTI call and returns the captured bytes, index-aligned (None = not captured). +unsafe fn capture(jvmti: JvmtiPtr, classes: &[JClass]) -> Option>>> { + let _serial = CAPTURE_LOCK.lock(); + if let Ok(mut c) = CAPTURED.lock() { + *c = vec![None; classes.len()]; + } + if let Ok(mut t) = CAPTURE_TARGETS.lock() { + *t = classes.iter().map(|&c| c as usize).collect(); + } + let enabled = set_class_file_load_hook_enabled(jvmti, true); + let rc = if enabled.is_ok() { retransform_classes(jvmti, classes) } else { -1 }; + let _ = set_class_file_load_hook_enabled(jvmti, false); + if let Ok(mut t) = CAPTURE_TARGETS.lock() { + t.clear(); + } + if let Err(e) = enabled { + crate::log!("classBytes: {e}"); + return None; } - CAPTURE_TARGET.store(target as usize, Ordering::SeqCst); - let rc = retransform_class(jvmti, target); - CAPTURE_TARGET.store(0, Ordering::SeqCst); if rc != 0 { crate::log!("RetransformClasses failed ({rc})"); - return std::ptr::null_mut(); + return None; } - let bytes = CAPTURED.lock().ok().and_then(|mut slot| slot.take()); - let Some(bytes) = bytes else { - return std::ptr::null_mut(); - }; - let jni = Jni::new(env); + CAPTURED.lock().ok().map(|mut c| std::mem::take(&mut *c)) +} + +unsafe fn to_byte_array(jni: &Jni, bytes: &[u8]) -> JObject { let array = jni.new_byte_array(bytes.len() as JInt); if !array.is_null() { jni.set_byte_array_region(array, 0, bytes.len() as JInt, bytes.as_ptr()); @@ -81,6 +108,80 @@ pub unsafe extern "system" fn native_class_bytes(env: EnvPtr, _cls: JClass, targ array } +/// byte[][] classBytesBatch(Class[]): classBytes for many classes with a single RetransformClasses call (one safepoint, one pass +/// over the other agents' transformers). Entries are null for classes that were not captured; the result is null on failure. +pub unsafe extern "system" fn native_class_bytes_batch(env: EnvPtr, _cls: JClass, targets: JObject) -> JObject { + let jvmti = JVMTI.load(Ordering::SeqCst); + if jvmti.is_null() || targets.is_null() { + return std::ptr::null_mut(); + } + let jni = Jni::new(env); + let n = jni.get_array_length(targets); + if n <= 0 { + return std::ptr::null_mut(); + } + jni.ensure_local_capacity(2 * n + 16); + let classes: Vec = (0..n).map(|i| jni.get_object_array_element(targets, i)).collect(); + let Some(captured) = capture(jvmti, &classes) else { + return std::ptr::null_mut(); + }; + let byte_array_class = jni.find_class("[B"); + let result = jni.new_object_array(n, byte_array_class, std::ptr::null_mut()); + if result.is_null() { + return std::ptr::null_mut(); + } + for (i, bytes) in captured.iter().enumerate() { + if let Some(bytes) = bytes { + let array = to_byte_array(&jni, bytes); + if !array.is_null() { + jni.set_object_array_element(result, i as JInt, array); + } + } + } + result +} + +/// int redefineBatch(Class[], byte[][]): JVMTI RedefineClasses of many classes in one call (one safepoint). It is all or nothing: +/// the JVMTI error code is returned (0 = success) and no class is changed on failure. +pub unsafe extern "system" fn native_redefine_batch(env: EnvPtr, _cls: JClass, targets: JObject, bytes: JObject) -> JInt { + let jvmti = JVMTI.load(Ordering::SeqCst); + if jvmti.is_null() || targets.is_null() || bytes.is_null() { + return -1; + } + let jni = Jni::new(env); + let n = jni.get_array_length(targets); + if n <= 0 || jni.get_array_length(bytes) != n { + return -1; + } + jni.ensure_local_capacity(2 * n + 16); + let mut buffers: Vec> = Vec::with_capacity(n as usize); + let mut classes: Vec = Vec::with_capacity(n as usize); + for i in 0..n { + let array = jni.get_object_array_element(bytes, i); + if array.is_null() { + return -1; + } + let len = jni.get_array_length(array); + if len < 0 { + return -1; + } + let mut buf = vec![0u8; len as usize]; + jni.get_byte_array_region(array, 0, len, buf.as_mut_ptr()); + buffers.push(buf); + classes.push(jni.get_object_array_element(targets, i)); + } + let defs: Vec = classes + .iter() + .zip(&buffers) + .map(|(&klass, buf)| ClassDefinition { klass, class_byte_count: buf.len() as JInt, class_bytes: buf.as_ptr() }) + .collect(); + let rc = redefine_classes(jvmti, &defs); + if rc != 0 { + crate::log!("RedefineClasses (batch of {n}) failed ({rc})"); + } + rc +} + /// int redefine(Class, byte[]): JVMTI RedefineClasses of a single class; returns the JVMTI error code (0 = success). pub unsafe extern "system" fn native_redefine(env: EnvPtr, _cls: JClass, target: JClass, bytes: JObject) -> JInt { let jvmti = JVMTI.load(Ordering::SeqCst); @@ -111,6 +212,7 @@ pub unsafe extern "system" fn native_log(env: EnvPtr, _cls: JClass, message: JOb crate::log!("[java] {text}"); } +#[cfg(windows)] extern "system" { fn FreeLibraryAndExitThread(module: *mut c_void, exit_code: u32) -> !; } @@ -129,11 +231,18 @@ pub unsafe extern "system" fn native_release(env: EnvPtr, cls: JClass) { if rc != JNI_OK { crate::log!("release: UnregisterNatives failed ({rc})"); } - crate::log!("[java] released, unloading the loader"); - let module = crate::MODULE as usize; - // The thread below runs code from this DLL until FreeLibraryAndExitThread; the delay lets in-flight native calls return. - let _ = std::thread::Builder::new().name("prestige-unload".into()).spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(2000)); - FreeLibraryAndExitThread(module as *mut c_void, 0); - }); + #[cfg(windows)] + { + crate::log!("[java] released, unloading the loader"); + let module = crate::MODULE as usize; + // The thread below runs code from this DLL until FreeLibraryAndExitThread; the delay lets in-flight native calls return. + let _ = std::thread::Builder::new().name("prestige-unload".into()).spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(2000)); + FreeLibraryAndExitThread(module as *mut c_void, 0); + }); + } + // On unix the library is deliberately NOT dlclose'd: a thread of ours would return into unmapped code (and the + // JVM may still hold the agent handle). The hook and natives are gone; the code just stays resident. + #[cfg(unix)] + crate::log!("[java] released (library stays resident on this platform)"); } diff --git a/injector/crates/loader/src/jni.rs b/injector/crates/loader/src/jni.rs index bb2ccb0..a2c4051 100644 --- a/injector/crates/loader/src/jni.rs +++ b/injector/crates/loader/src/jni.rs @@ -12,7 +12,6 @@ pub type JBool = u8; pub type EnvPtr = *mut *const *const c_void; pub const JNI_OK: JInt = 0; -pub const JNI_VERSION_1_8: JInt = 0x0001_0008; pub const JVMTI_VERSION_1_2: JInt = 0x3001_0200; #[repr(C)] @@ -60,9 +59,6 @@ impl Jni { pub unsafe fn new_global_ref(&self, o: JObject) -> JObject { f!(self, 21, unsafe extern "system" fn(EnvPtr, JObject) -> JObject)(self.env, o) } - pub unsafe fn delete_local_ref(&self, o: JObject) { - f!(self, 23, unsafe extern "system" fn(EnvPtr, JObject))(self.env, o) - } pub unsafe fn is_same_object(&self, a: JObject, b: JObject) -> bool { f!(self, 24, unsafe extern "system" fn(EnvPtr, JObject, JObject) -> JBool)(self.env, a, b) != 0 } @@ -108,6 +104,15 @@ impl Jni { pub unsafe fn get_array_length(&self, a: JObject) -> JInt { f!(self, 171, unsafe extern "system" fn(EnvPtr, JObject) -> JInt)(self.env, a) } + pub unsafe fn get_object_array_element(&self, a: JObject, i: JInt) -> JObject { + f!(self, 173, unsafe extern "system" fn(EnvPtr, JObject, JInt) -> JObject)(self.env, a, i) + } + pub unsafe fn set_object_array_element(&self, a: JObject, i: JInt, v: JObject) { + f!(self, 174, unsafe extern "system" fn(EnvPtr, JObject, JInt, JObject))(self.env, a, i, v) + } + pub unsafe fn ensure_local_capacity(&self, capacity: JInt) -> JInt { + f!(self, 26, unsafe extern "system" fn(EnvPtr, JInt) -> JInt)(self.env, capacity) + } pub unsafe fn new_object_array(&self, len: JInt, c: JClass, init: JObject) -> JObject { f!(self, 172, unsafe extern "system" fn(EnvPtr, JInt, JClass, JObject) -> JObject)(self.env, len, c, init) } @@ -126,9 +131,6 @@ impl Jni { pub unsafe fn unregister_natives(&self, c: JClass) -> JInt { f!(self, 216, unsafe extern "system" fn(EnvPtr, JClass) -> JInt)(self.env, c) } - pub unsafe fn exception_check(&self) -> bool { - f!(self, 228, unsafe extern "system" fn(EnvPtr) -> JBool)(self.env) != 0 - } /// Returns "ExceptionClass: message" of the pending exception and clears it. pub unsafe fn take_exception(&self) -> Option { @@ -236,8 +238,8 @@ pub unsafe fn add_capabilities(j: JvmtiPtr, caps: &Capabilities) -> JInt { f(j, caps) } -/// Installs the ClassFileLoadHook callback (field 5 of jvmtiEventCallbacks) and enables the event. -pub unsafe fn enable_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook) -> Result<(), String> { +/// Installs the ClassFileLoadHook callback (field 5 of jvmtiEventCallbacks); the event itself stays off until enabled. +pub unsafe fn install_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook) -> Result<(), String> { let mut callbacks = [0usize; 40]; callbacks[4] = hook as usize; let set_callbacks = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, *const usize, JInt) -> JInt>(*(*j).add(121)); @@ -245,17 +247,28 @@ pub unsafe fn enable_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook) if rc != 0 { return Err(format!("SetEventCallbacks failed ({rc})")); } + Ok(()) +} + +/// Turns the ClassFileLoadHook event on or off. It is only on while class bytes are being captured: with the event enabled the +/// JVM hands the bytes of every class it loads to the agent, which slows class loading down for the whole game. +pub unsafe fn set_class_file_load_hook_enabled(j: JvmtiPtr, enabled: bool) -> Result<(), String> { let set_mode = std::mem::transmute::<*const c_void, unsafe extern "C" fn(JvmtiPtr, JInt, JInt, JObject, ...) -> JInt>(*(*j).add(1)); - let rc = set_mode(j, 1, EVENT_CLASS_FILE_LOAD_HOOK, std::ptr::null_mut()); + let rc = set_mode(j, enabled as JInt, EVENT_CLASS_FILE_LOAD_HOOK, std::ptr::null_mut()); if rc != 0 { - return Err(format!("SetEventNotificationMode failed ({rc})")); + return Err(format!("SetEventNotificationMode({enabled}) failed ({rc})")); } Ok(()) } -pub unsafe fn retransform_class(j: JvmtiPtr, class: JClass) -> JInt { +pub unsafe fn retransform_classes(j: JvmtiPtr, classes: &[JClass]) -> JInt { let f = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, JInt, *const JClass) -> JInt>(*(*j).add(151)); - f(j, 1, &class) + f(j, classes.len() as JInt, classes.as_ptr()) +} + +pub unsafe fn redefine_classes(j: JvmtiPtr, defs: &[ClassDefinition]) -> JInt { + let f = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, JInt, *const ClassDefinition) -> JInt>(*(*j).add(86)); + f(j, defs.len() as JInt, defs.as_ptr()) } pub unsafe fn redefine_class(j: JvmtiPtr, def: &ClassDefinition) -> JInt { diff --git a/injector/crates/loader/src/lib.rs b/injector/crates/loader/src/lib.rs index b2229c6..645447c 100644 --- a/injector/crates/loader/src/lib.rs +++ b/injector/crates/loader/src/lib.rs @@ -10,14 +10,18 @@ mod worker; use std::ffi::c_void; +#[cfg(windows)] const DLL_PROCESS_ATTACH: u32 = 1; +#[cfg(windows)] extern "system" { fn DisableThreadLibraryCalls(module: *mut c_void) -> i32; } +#[cfg(windows)] pub(crate) static mut MODULE: *mut c_void = std::ptr::null_mut(); +#[cfg(windows)] #[no_mangle] pub extern "system" fn DllMain(module: *mut c_void, reason: u32, _reserved: *mut c_void) -> i32 { if reason == DLL_PROCESS_ATTACH { @@ -30,3 +34,30 @@ pub extern "system" fn DllMain(module: *mut c_void, reason: u32, _reserved: *mut } 1 } + +/// The JavaVM handed to Agent_OnAttach / Agent_OnLoad by the JVM (unix); read by the worker. +#[cfg(unix)] +pub(crate) static VM: std::sync::atomic::AtomicPtr = std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); + +#[cfg(unix)] +fn start_worker(vm: *mut c_void) -> i32 { + VM.store(vm, std::sync::atomic::Ordering::SeqCst); + match std::thread::Builder::new().name("prestige-loader".into()).spawn(worker::run) { + Ok(_) => 0, + Err(_) => -1, + } +} + +/// Called by HotSpot when the library is loaded through the attach protocol (JVMTI.agent_load). +#[cfg(unix)] +#[no_mangle] +pub extern "C" fn Agent_OnAttach(vm: *mut c_void, _options: *mut std::ffi::c_char, _reserved: *mut c_void) -> i32 { + start_worker(vm) +} + +/// Called by the JVM when the library is given as -agentpath at start-up. +#[cfg(unix)] +#[no_mangle] +pub extern "C" fn Agent_OnLoad(vm: *mut c_void, _options: *mut std::ffi::c_char, _reserved: *mut c_void) -> i32 { + start_worker(vm) +} diff --git a/injector/crates/loader/src/log.rs b/injector/crates/loader/src/log.rs index d934c07..684f787 100644 --- a/injector/crates/loader/src/log.rs +++ b/injector/crates/loader/src/log.rs @@ -1,21 +1,55 @@ use std::ffi::c_void; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(windows)] extern "system" { fn GetModuleFileNameW(module: *mut c_void, buf: *mut u16, size: u32) -> u32; } +#[cfg(unix)] +#[repr(C)] +struct DlInfo { + dli_fname: *const std::ffi::c_char, + dli_fbase: *mut c_void, + dli_sname: *const std::ffi::c_char, + dli_saddr: *mut c_void, +} + +#[cfg(unix)] +extern "C" { + fn dladdr(addr: *const c_void, info: *mut DlInfo) -> i32; +} + +#[cfg(unix)] +fn module_path() -> PathBuf { + fn anchor() {} + let mut info = DlInfo { dli_fname: std::ptr::null(), dli_fbase: std::ptr::null_mut(), dli_sname: std::ptr::null(), dli_saddr: std::ptr::null_mut() }; + let ok = unsafe { dladdr(anchor as fn() as usize as *const c_void, &mut info) }; + if ok != 0 && !info.dli_fname.is_null() { + let name = unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }; + PathBuf::from(std::ffi::OsStr::from_bytes(name.to_bytes())) + } else { + std::env::temp_dir().join("libprestige_native") + } +} + +#[cfg(windows)] +fn module_path() -> PathBuf { + let mut buf = [0u16; 1024]; + let len = unsafe { GetModuleFileNameW(crate::MODULE, buf.as_mut_ptr(), buf.len() as u32) } as usize; + PathBuf::from(String::from_utf16_lossy(&buf[..len])) +} + fn log_path() -> &'static PathBuf { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { - let mut buf = [0u16; 1024]; - let len = unsafe { GetModuleFileNameW(crate::MODULE, buf.as_mut_ptr(), buf.len() as u32) } as usize; - let dll = PathBuf::from(String::from_utf16_lossy(&buf[..len])); - dll.with_file_name("prestige-native.log") + module_path().with_file_name("prestige-native.log") }) } diff --git a/injector/crates/loader/src/worker.rs b/injector/crates/loader/src/worker.rs index c28901e..f50aa7b 100644 --- a/injector/crates/loader/src/worker.rs +++ b/injector/crates/loader/src/worker.rs @@ -1,15 +1,18 @@ //! The loader worker: JVM discovery, payload installation into Fabric's Knot class loader, native registration, bootstrap. -use crate::bridge::{init_jvmti, native_class_bytes, native_log, native_redefine, native_release}; +use crate::bridge::{init_jvmti, native_class_bytes, native_class_bytes_batch, native_log, native_redefine, native_redefine_batch, native_release}; use crate::jni::*; use crate::log; -use std::ffi::{c_char, c_void}; +#[cfg(windows)] +use std::ffi::c_char; +use std::ffi::c_void; use std::path::PathBuf; +#[cfg(windows)] use std::time::Duration; +#[cfg(windows)] extern "system" { fn GetModuleHandleA(name: *const c_char) -> *mut c_void; fn GetProcAddress(module: *mut c_void, name: *const c_char) -> *mut c_void; - fn GetCurrentProcessId() -> u32; } static PAYLOAD: &[u8] = include_bytes!(env!("PRESTIGE_PAYLOAD_JAR")); @@ -18,7 +21,7 @@ const NATIVE_BRIDGE: &str = "dev.zprestige.prestige.inject.NativeBridge"; const BOOTSTRAP: &str = "dev.zprestige.prestige.inject.Bootstrap"; pub fn run() { - log!("worker started, pid {}", unsafe { GetCurrentProcessId() }); + log!("worker started, pid {}", std::process::id()); match unsafe { install() } { Ok(()) => log!("bootstrap returned"), Err(e) => log!("FAILED: {e}"), @@ -50,7 +53,7 @@ unsafe fn install() -> Result<(), String> { register_natives(&jni, loader)?; log!("natives registered"); - let bootstrap = class_for_name(&jni, BOOTSTRAP, loader)?; + let bootstrap = class_for_name(&jni, BOOTSTRAP, loader, true)?; let start = jni.get_static_method_id(bootstrap, "start", "()V"); jni.check("resolve Bootstrap.start")?; // Bootstrap.start waits for the client, installs the hooks and schedules the client initialisation. @@ -59,7 +62,18 @@ unsafe fn install() -> Result<(), String> { Ok(()) } +/// The JavaVM passed to Agent_OnAttach / Agent_OnLoad. +#[cfg(unix)] +unsafe fn find_vm() -> Result { + let vm = crate::VM.load(std::sync::atomic::Ordering::SeqCst) as VmPtr; + if vm.is_null() { + return Err("no JavaVM was passed to the agent".into()); + } + Ok(vm) +} + /// Waits for jvm.dll and a created JavaVM (the injector may run before Minecraft has started its JVM). +#[cfg(windows)] unsafe fn find_vm() -> Result { let mut module = std::ptr::null_mut(); for _ in 0..600 { @@ -91,7 +105,7 @@ unsafe fn find_vm() -> Result { fn write_payload() -> Result { let dir = std::env::temp_dir().join("PrestigeLoader"); std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; - let path = dir.join(format!("prestige-payload-{}.jar", unsafe { GetCurrentProcessId() })); + let path = dir.join(format!("prestige-payload-{}.jar", std::process::id())); std::fs::write(&path, PAYLOAD).map_err(|e| format!("write {}: {e}", path.display()))?; Ok(path) } @@ -99,9 +113,8 @@ fn write_payload() -> Result { /// FabricLauncherBase.getLauncher().addToClassPath(path) so the payload and the game classes share the Knot class loader. /// Returns a global reference to the Knot target class loader. unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result { - // A thread attached from native code resolves classes through the system class loader, which holds fabric-loader. - let base = jni.find_class("net/fabricmc/loader/impl/launch/FabricLauncherBase"); - jni.check("FabricLauncherBase (is this a Fabric instance?)")?; + jni.ensure_local_capacity(4096); + let (base, fabric_loader) = locate_fabric(jni)?; let get_launcher = jni.get_static_method_id(base, "getLauncher", "()Lnet/fabricmc/loader/impl/launch/FabricLauncher;"); jni.check("FabricLauncherBase.getLauncher")?; let launcher = jni.call_static_object(base, get_launcher, &[]); @@ -110,8 +123,7 @@ unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result Result Result { +const FABRIC_BASE: &str = "net.fabricmc.loader.impl.launch.FabricLauncherBase"; + +/// Finds the class loader that can see fabric-loader. The system class loader is tried first (official launcher, most +/// others); launchers that run the game from their own URLClassLoader (Prism/MultiMC, many modpack launchers) keep +/// fabric-loader out of it, so every thread's context class loader is probed too. Returns the FabricLauncherBase class +/// and the loader it was found through (null = FindClass / system loader). +unsafe fn locate_fabric(jni: &Jni) -> Result<(JClass, JObject), String> { + let base = jni.find_class(&FABRIC_BASE.replace('.', "/")); + if jni.take_exception().is_none() && !base.is_null() { + return Ok((base, std::ptr::null_mut())); + } + log!("FabricLauncherBase is not visible to the system class loader, probing thread context class loaders"); + let loaders = thread_loaders(jni)?; + for &loader in &loaders { + if let Ok(class) = class_for_name(jni, FABRIC_BASE, loader, false) { + log!("fabric-loader found through {}", describe(jni, loader)); + return Ok((class, loader)); + } + } + let seen: Vec = loaders.iter().map(|&l| describe(jni, l)).collect(); + Err(format!( + "net.fabricmc.loader is not visible to any class loader ({} probed: {}); is this a Fabric instance?", + seen.len(), + seen.join(" | ") + )) +} + +/// Distinct context class loaders of all live threads (the game threads inherit the launcher's loader). +unsafe fn thread_loaders(jni: &Jni) -> Result, String> { + let thread_class = jni.find_class("java/lang/Thread"); + let get_all = jni.get_static_method_id(thread_class, "getAllStackTraces", "()Ljava/util/Map;"); + let get_context = jni.get_method_id(thread_class, "getContextClassLoader", "()Ljava/lang/ClassLoader;"); + let map_class = jni.find_class("java/util/Map"); + let key_set = jni.get_method_id(map_class, "keySet", "()Ljava/util/Set;"); + let set_class = jni.find_class("java/util/Set"); + let to_array = jni.get_method_id(set_class, "toArray", "()[Ljava/lang/Object;"); + jni.check("Thread/Map/Set lookup")?; + let map = jni.call_static_object(thread_class, get_all, &[]); + jni.check("Thread.getAllStackTraces")?; + let set = jni.call_object(jni_non_null(map)?, key_set, &[]); + let threads = jni.call_object(jni_non_null(set)?, to_array, &[]); + jni.check("thread list")?; + let mut loaders: Vec = Vec::new(); + for i in 0..jni.get_array_length(jni_non_null(threads)?) { + let thread = jni.get_object_array_element(threads, i); + if thread.is_null() { + continue; + } + let loader = jni.call_object(thread, get_context, &[]); + if jni.take_exception().is_some() || loader.is_null() { + continue; + } + if !loaders.iter().any(|&known| jni.is_same_object(known, loader)) { + loaders.push(loader); + } + } + Ok(loaders) +} + +unsafe fn jni_non_null(o: JObject) -> Result { + if o.is_null() { Err("unexpected null from the JVM".into()) } else { Ok(o) } +} + +/// Object.toString() of a class loader for the log. +unsafe fn describe(jni: &Jni, o: JObject) -> String { + let class = jni.get_object_class(o); + let to_string = jni.get_method_id(class, "toString", "()Ljava/lang/String;"); + if to_string.is_null() { + jni.exception_clear(); + return "".into(); + } + let s = jni.call_object(o, to_string, &[]); + if jni.take_exception().is_some() || s.is_null() { "".into() } else { jni.get_string_utf(s) } +} + +/// Loads a fabric-loader class through the loader found by `locate_fabric` (null = system class loader). +unsafe fn load_fabric_class(jni: &Jni, name: &str, loader: JObject) -> Result { + if loader.is_null() { + let class = jni.find_class(&name.replace('.', "/")); + jni.check(name)?; + return Ok(class); + } + class_for_name(jni, name, loader, true) +} + +/// Class.forName(name, initialize, loader) +unsafe fn class_for_name(jni: &Jni, name: &str, loader: JObject, initialize: bool) -> Result { let class_class = jni.find_class("java/lang/Class"); let for_name = jni.get_static_method_id(class_class, "forName", "(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;"); jni.check("Class.forName")?; let name_string = jni.new_string(name); - let class = jni.call_static_object(class_class, for_name, &[obj(name_string), boolean(true), obj(loader)]); + let class = jni.call_static_object(class_class, for_name, &[obj(name_string), boolean(initialize), obj(loader)]); jni.check(&format!("load {name}"))?; if class.is_null() { return Err(format!("{name} not found")); @@ -155,7 +252,7 @@ unsafe fn class_for_name(jni: &Jni, name: &str, loader: JObject) -> Result Result<(), String> { - let class = class_for_name(jni, NATIVE_BRIDGE, loader)?; + let class = class_for_name(jni, NATIVE_BRIDGE, loader, true)?; let methods = [ NativeMethod { name: c"classBytes".as_ptr(), @@ -167,6 +264,16 @@ unsafe fn register_natives(jni: &Jni, loader: JObject) -> Result<(), String> { signature: c"(Ljava/lang/Class;[B)I".as_ptr(), fn_ptr: native_redefine as *const () as usize as *mut c_void, }, + NativeMethod { + name: c"classBytesBatch".as_ptr(), + signature: c"([Ljava/lang/Class;)[[B".as_ptr(), + fn_ptr: native_class_bytes_batch as *const () as usize as *mut c_void, + }, + NativeMethod { + name: c"redefineBatch".as_ptr(), + signature: c"([Ljava/lang/Class;[[B)I".as_ptr(), + fn_ptr: native_redefine_batch as *const () as usize as *mut c_void, + }, NativeMethod { name: c"release".as_ptr(), signature: c"()V".as_ptr(), diff --git a/scripts/package_release.py b/scripts/package_release.py new file mode 100644 index 0000000..98d132c --- /dev/null +++ b/scripts/package_release.py @@ -0,0 +1,122 @@ +"""Packs one platform's release zip, or writes SHA256SUMS.txt for dist/release. Called by release.bat. + + package_release.py zip + package_release.py checksums + +Zips are written with unix permissions so PrestigeInjector stays executable after unzip on Linux/macOS. +""" +import hashlib +import os +import sys +import time +import zipfile + +README = { + "windows": """Prestige {version} - Windows (x86_64) + +Contents + PrestigeInjector.exe injector + prestige_native.dll loader library (keep it next to the injector) + {jar} the same client as a Fabric mod (optional, see below) + +Inject + 1. Start Minecraft 1.21.11 with Fabric Loader 0.19+ and Fabric API. + 2. Run PrestigeInjector.exe and pick the game from the list. + +If SmartScreen blocks the exe: More info -> Run anyway. +""", + "linux": """Prestige {version} - Linux (x86_64) + +Works on any glibc distro from 2012 on (glibc 2.17+): Ubuntu, Debian, Fedora, Arch, Mint, openSUSE, ... +Not for musl distros (Alpine) or ARM machines. + +Contents + PrestigeInjector injector + libprestige_native.so loader library (keep it next to the injector) + {jar} the same client as a Fabric mod (optional, see below) + +Inject + 1. Start Minecraft 1.21.11 with Fabric Loader 0.19+ and Fabric API. + 2. In a terminal in this folder: ./PrestigeInjector + Run it as the same user as the game. The game must not use -XX:+DisableAttachMechanism. +""", + "macos": """Prestige {version} - macOS (universal: Intel + Apple Silicon) + +Contents + PrestigeInjector injector + libprestige_native.dylib loader library (keep it next to the injector) + {jar} the same client as a Fabric mod (optional, see below) + +Inject + 1. Start Minecraft 1.21.11 with Fabric Loader 0.19+ and Fabric API. + 2. In Terminal, in this folder, clear the download quarantine once: + xattr -dr com.apple.quarantine . + 3. Run: ./PrestigeInjector + Run it as the same user as the game. The game must not use -XX:+DisableAttachMechanism. +""", +} + +MOD_NOTE = """ +Install as a mod instead + Put {jar} into the mods/ folder of a Fabric 1.21.11 instance; no injector needed. + Do not use both at once. +""" + +TESTED = "Tested on Prism Launcher with Minecraft 1.21.11 + Fabric.\n" + +FOOTER = """ +Credits + Cracked by Skid Solutions + technix sourcing / Frida scripts + stutheguyy fixing + bynbang / nitrogenoliquido ZKM transformers + dario AI models + +Discord: https://discord.gg/x2FJyGrDNg +""" + + +def add(zf, arcname, data, mode): + info = zipfile.ZipInfo(arcname, time.localtime()[:6]) + info.create_system = 3 # unix, so external_attr carries the permission bits + info.external_attr = (0o100000 | mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, data) + + +def make_zip(out_dir, version, platform, injector, lib, jar): + family = platform.split("-")[0] + name = f"prestige-{version}-{platform}" + jar_name = os.path.basename(jar) + title, body = README[family].format(version=version, jar=jar_name).split("\n", 1) + readme = title + "\n" + TESTED + body + MOD_NOTE.format(jar=jar_name) + FOOTER + if family == "windows": + readme = readme.replace("\n", "\r\n") + out = os.path.join(out_dir, name + ".zip") + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for path, mode in ((injector, 0o755), (lib, 0o644), (jar, 0o644)): + with open(path, "rb") as f: + add(zf, f"{name}/{os.path.basename(path)}", f.read(), mode) + add(zf, f"{name}/README.txt", readme.encode(), 0o644) + print(f" wrote {out} ({os.path.getsize(out) // 1024} KiB)") + + +def checksums(out_dir): + lines = [] + for entry in sorted(os.listdir(out_dir)): + path = os.path.join(out_dir, entry) + if not os.path.isfile(path) or entry == "SHA256SUMS.txt": + continue + with open(path, "rb") as f: + lines.append(f"{hashlib.sha256(f.read()).hexdigest()} {entry}\n") + with open(os.path.join(out_dir, "SHA256SUMS.txt"), "w", newline="\n") as f: + f.writelines(lines) + + +if __name__ == "__main__": + if len(sys.argv) == 8 and sys.argv[1] == "zip": + make_zip(*sys.argv[2:]) + elif len(sys.argv) == 3 and sys.argv[1] == "checksums": + checksums(sys.argv[2]) + else: + sys.exit(__doc__) diff --git a/src/main/java/dev/zprestige/prestige/engine/EnginePlugin.java b/src/main/java/dev/zprestige/prestige/engine/EnginePlugin.java index a3743f7..2334778 100644 --- a/src/main/java/dev/zprestige/prestige/engine/EnginePlugin.java +++ b/src/main/java/dev/zprestige/prestige/engine/EnginePlugin.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -47,11 +48,29 @@ public final class EnginePlugin implements IMixinConfigPlugin { private String namespace = "named"; private int expectedTargets; + /** + * The namespace the running classes are named in, which selects the target list and the mapping used by the transformers. + * Fabric reports it directly. Lunar's Genesis loader reports another namespace, but its classes carry intermediary names + * (its own stack traces show net.minecraft.class_310.method_55608), so intermediary is used there. Without this, the mapper + * resolved some names through the official namespace and others through the named one, which broke the hooks. + */ + public static String runtimeNamespace() { + String forced = System.getProperty("prestige.engine.namespace"); // test override ("named" / "intermediary") + if (forced != null) return forced; + String reported; + try { + reported = FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace(); + } catch (Throwable t) { + return "named"; + } + if (reported.equals("named") || reported.equals("intermediary")) return reported; + System.out.println("[Engine] runtime reports namespace '" + reported + "'; the classes use intermediary names, so using intermediary"); + return "intermediary"; + } + @Override public void onLoad(String mixinPackage) { try { - String forced = System.getProperty("prestige.engine.namespace"); // test override ("named" / "intermediary") - if (forced != null) namespace = forced; - else try { namespace = FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace(); } catch (Throwable t) { namespace = "named"; } + namespace = runtimeNamespace(); List transformers = lines("prestige-engine/transformers.list"); expectedTargets = lines(namespace.equals("named") ? "prestige-engine/targets.named.list" : "prestige-engine/targets.intermediary.list").size(); manager = createManager(namespace, transformers); @@ -68,8 +87,10 @@ public final class EnginePlugin implements IMixinConfigPlugin { public static TransformerManager createManager(String namespace, List transformers) throws IOException { ClassLoader loader = EnginePlugin.class.getClassLoader(); byte[] tiny = namespace.equals("named") ? null : resource("prestige-engine/mappings.tiny"); - TransformerManager manager = new TransformerManager(new ResourceClassProvider(loader, new HashSet<>(transformers)), tiny == null ? null : new ByteArrayInputStream(tiny)); - manager.setFailStrategy(FailStrategy.CANCEL); + TransformerManager manager = new TransformerManager(new ResourceClassProvider(loader, new HashSet<>(transformers), tiny), tiny == null ? null : new ByteArrayInputStream(tiny)); + // A transformer whose target method does not exist in this runtime (Lunar's Genesis loader names some methods differently + // from the bundled mappings) is skipped on its own. CANCEL would drop every hook of the whole class and abort the injection. + manager.setFailStrategy(FailStrategy.CONTINUE); for (String name : transformers) manager.addTransformer(name); return manager; } @@ -78,14 +99,40 @@ public final class EnginePlugin implements IMixinConfigPlugin { private static final class ResourceClassProvider implements IClassProvider { private final ClassLoader loader; private final Set transformers; - ResourceClassProvider(ClassLoader loader, Set transformers) { this.loader = loader; this.transformers = transformers; } + /** Named (Yarn) class name -> intermediary name, from the bundled mapping. Lunar's jar only has the intermediary files. */ + private final Map namedToIntermediary; + ResourceClassProvider(ClassLoader loader, Set transformers, byte[] tiny) { + this.loader = loader; + this.transformers = transformers; + this.namedToIntermediary = classNames(tiny); + } + + private static Map classNames(byte[] tiny) { + Map out = new HashMap<>(); + if (tiny == null) return out; + for (String line : new String(tiny, StandardCharsets.UTF_8).split("\n")) { + String[] f = line.split("\t"); + // header "tiny 2 0 intermediary named"; class lines "c " + if (f.length >= 3 && f[0].equals("c")) out.put(f[2].trim(), f[1].trim()); + } + return out; + } @Override public byte[] getClass(String name) throws ClassNotFoundException { - String path = name.replace('.', '/') + ".class"; - try (InputStream in = loader.getResourceAsStream(path)) { - if (in == null) throw new ClassNotFoundException(name + " (missing resource " + path + ")"); - byte[] bytes = in.readAllBytes(); - return transformers.contains(name.replace('/', '.')) ? gate(bytes) : bytes; + String slashed = name.replace('.', '/'); + InputStream in = loader.getResourceAsStream(slashed + ".class"); + if (in == null) { + // a named name the runtime does not have: load the intermediary class it maps to + String intermediary = namedToIntermediary.get(slashed); + if (intermediary != null) { + slashed = intermediary; + in = loader.getResourceAsStream(slashed + ".class"); + } + } + if (in == null) throw new ClassNotFoundException(name + " (missing resource " + slashed + ".class)"); + try (InputStream stream = in) { + byte[] bytes = stream.readAllBytes(); + return transformers.contains(slashed.replace('/', '.')) ? gate(bytes) : bytes; } catch (IOException e) { throw new ClassNotFoundException(name, e); } diff --git a/src/main/java/dev/zprestige/prestige/inject/Bootstrap.java b/src/main/java/dev/zprestige/prestige/inject/Bootstrap.java index 3da9d28..a5f4940 100644 --- a/src/main/java/dev/zprestige/prestige/inject/Bootstrap.java +++ b/src/main/java/dev/zprestige/prestige/inject/Bootstrap.java @@ -32,12 +32,22 @@ public final class Bootstrap { public static synchronized void start() { if (started) return; started = true; + if (net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("prestige")) { + // the jar is also installed in mods/: Mixin already applied the hooks and Fabric ran the entrypoints + log("Prestige is already loaded as a Fabric mod, skipping injection"); + return; + } try { log("waiting for the Minecraft client"); waitForClient(); - Report report = install(NativeBridge::classBytes, NativeBridge::redefine); + Report report = install(NativeBridge::classBytes, NativeBridge::redefine, nativeBatch()); log("hooks installed: " + report.redefined + " classes, " + report.moved + " handlers moved" + (report.failed.isEmpty() ? "" : ", FAILED " + report.failed)); - if (!report.failed.isEmpty()) throw new IllegalStateException("could not hook " + report.failed); + // Some clients (Lunar) name a few classes and methods differently. Keep going with the hooks that did apply: a missing + // hook only disables the features behind it, while stopping here would leave the client without Prestige at all. + if (!report.failed.isEmpty()) { + if (report.redefined == 0) throw new IllegalStateException("could not hook " + report.failed); + log("continuing with " + report.redefined + " hooked classes; skipped " + report.failed); + } log("initialising Prestige on the client thread"); MinecraftClient.getInstance().execute(Bootstrap::initClient); } catch (Throwable t) { @@ -66,7 +76,39 @@ public final class Bootstrap { * without the native library. */ public static Report install(Function, byte[]> classBytes, ToIntBiFunction, byte[]> redefine) throws Exception { - String namespace = FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace(); + return install(classBytes, redefine, null); + } + + /** Many classes per native call: one RetransformClasses / RedefineClasses (one safepoint each) instead of one per class. */ + public interface Batch { + byte[][] classBytes(Class[] types); + + int redefine(Class[] types, byte[][] bytes); + } + + /** The batch natives of {@link NativeBridge}, or null when the loaded prestige_native.dll predates them. */ + private static Batch nativeBatch() { + try { + NativeBridge.classBytesBatch(new Class[0]); // probes the registration; a missing native throws UnsatisfiedLinkError + } catch (UnsatisfiedLinkError e) { + return null; + } catch (Throwable ignored) { + // anything else means the native exists + } + return new Batch() { + @Override public byte[][] classBytes(Class[] types) { return NativeBridge.classBytesBatch(types); } + @Override public int redefine(Class[] types, byte[][] bytes) { return NativeBridge.redefineBatch(types, bytes); } + }; + } + + /** + * Same as {@link #install(Function, ToIntBiFunction)} with an optional batch path: all original bytes are captured with one + * call, the ClassTransform step reuses cached results where it can, and all hooked classes are redefined with one call. A + * failing batch step falls back to per-class calls, so one bad class only fails itself. + */ + public static Report install(Function, byte[]> classBytes, ToIntBiFunction, byte[]> redefine, Batch batch) throws Exception { + String namespace = EnginePlugin.runtimeNamespace(); + log("runtime namespace " + namespace + " (the loader reports " + FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace() + ")"); List transformers = new ArrayList<>(EnginePlugin.lines("prestige-engine/transformers.list")); transformers.addAll(EnginePlugin.lines("prestige-engine/inject-transformers.list")); TransformerManager manager = EnginePlugin.createManager(namespace, transformers); @@ -75,31 +117,106 @@ public final class Bootstrap { EngineState.installed = true; Report report = new Report(); - int total = manager.getTransformedClasses().size(), done = 0; + List targets = new ArrayList<>(manager.getTransformedClasses()); + int total = targets.size(); log("transforming " + total + " hook targets"); - for (String target : manager.getTransformedClasses()) { - if (done > 0 && done % 10 == 0) log("transformed " + done + "/" + total); - done++; + long start = System.nanoTime(); + + // 1. resolve the target classes + Class[] types = new Class[total]; + for (int i = 0; i < total; i++) { try { - Class type = Class.forName(target, false, loader); - byte[] current = classBytes.apply(type); - if (current == null) throw new IllegalStateException("no class bytes"); - InPlaceTransformer.Result result = transformer.transform(target, current); - for (byte[] helper : result.helpers().values()) Definer.define(helper); - int code = redefine.applyAsInt(type, result.target()); - if (code != 0) throw new IllegalStateException("redefine returned JVMTI error " + code); - ORIGINALS.put(type, current); - report.redefined++; - report.moved += result.movedMethods(); + types[i] = Class.forName(targets.get(i), false, loader); } catch (Throwable t) { - report.failed.add(target); - log("hook failed for " + target + ": " + t); - t.printStackTrace(System.out); + fail(report, targets.get(i), t); } } + + // 2. the bytes each class currently has: one native call for all of them, per-class for any that did not come back + byte[][] originals = new byte[total][]; + if (batch != null) { + Class[] resolved = java.util.Arrays.stream(types).filter(java.util.Objects::nonNull).toArray(Class[]::new); + byte[][] captured = resolved.length == 0 ? null : batch.classBytes(resolved); + if (captured != null && captured.length == resolved.length) { + int next = 0; + for (int i = 0; i < total; i++) if (types[i] != null) originals[i] = captured[next++]; + } + } + for (int i = 0; i < total; i++) { + if (types[i] != null && originals[i] == null) originals[i] = classBytes.apply(types[i]); + } + long captured = System.nanoTime(); + + // 3. ClassTransform, one class at a time (its mapper is not thread-safe: running it on several threads corrupts its state). + // A result that is already in the disk cache skips the transformation, which is where nearly all of the time goes. + InPlaceTransformer.Result[] results = new InPlaceTransformer.Result[total]; + TransformCache cache = TransformCache.open(); + int cacheHits = 0; + for (int i = 0; i < total; i++) { + if (types[i] == null) continue; + String target = targets.get(i); + if (originals[i] == null) { + fail(report, target, new IllegalStateException("no class bytes")); + continue; + } + try { + byte[] hit = cache == null ? null : cache.get(target, originals[i]); + if (hit != null) { + results[i] = new InPlaceTransformer.Result(hit, Map.of(), 0); + cacheHits++; + continue; + } + results[i] = transformer.transform(target, originals[i]); + if (cache != null && results[i].helpers().isEmpty()) cache.put(target, originals[i], results[i].target()); + } catch (Throwable t) { + fail(report, target, t); + } + } + long transformed = System.nanoTime(); + + // 4. helper classes first, then redefine everything in one call (all or nothing); on an error redefine one by one + List ready = new ArrayList<>(); + for (int i = 0; i < total; i++) { + if (results[i] == null) continue; + try { + for (byte[] helper : results[i].helpers().values()) Definer.define(helper); + ready.add(i); + } catch (Throwable t) { + fail(report, targets.get(i), t); + } + } + boolean redefinedAll = false; + if (batch != null && !ready.isEmpty()) { + Class[] batchTypes = ready.stream().map(i -> types[i]).toArray(Class[]::new); + byte[][] batchBytes = ready.stream().map(i -> results[i].target()).toArray(byte[][]::new); + redefinedAll = batch.redefine(batchTypes, batchBytes) == 0; + } + for (int i : ready) { + try { + if (!redefinedAll) { + int code = redefine.applyAsInt(types[i], results[i].target()); + if (code != 0) throw new IllegalStateException("redefine returned JVMTI error " + code); + } + ORIGINALS.put(types[i], originals[i]); + report.redefined++; + report.moved += results[i].movedMethods(); + } catch (Throwable t) { + fail(report, targets.get(i), t); + } + } + long end = System.nanoTime(); + log(String.format("timing: bytes %d ms, transform %d ms (%d of %d from cache), redefine %d ms%s", + (captured - start) / 1_000_000, (transformed - captured) / 1_000_000, cacheHits, total, (end - transformed) / 1_000_000, + batch == null ? " [per-class natives]" : redefinedAll ? "" : " [batch redefine refused, per-class]")); return report; } + private static void fail(Report report, String target, Throwable t) { + report.failed.add(target); + log("hook failed for " + target + ": " + t); + t.printStackTrace(System.out); + } + /** True when this client was injected, i.e. hooks were applied through JVMTI and can be taken out again. */ public static boolean isInjected() { return !ORIGINALS.isEmpty(); diff --git a/src/main/java/dev/zprestige/prestige/inject/InPlaceTransformer.java b/src/main/java/dev/zprestige/prestige/inject/InPlaceTransformer.java index cd3f519..e4bb816 100644 --- a/src/main/java/dev/zprestige/prestige/inject/InPlaceTransformer.java +++ b/src/main/java/dev/zprestige/prestige/inject/InPlaceTransformer.java @@ -39,7 +39,7 @@ public final class InPlaceTransformer { private final TransformerManager manager; private final ClassLoader loader; - private int sequence; + private final java.util.concurrent.atomic.AtomicInteger sequence = new java.util.concurrent.atomic.AtomicInteger(); public InPlaceTransformer(TransformerManager manager, ClassLoader loader) { this.manager = manager; @@ -67,7 +67,7 @@ public final class InPlaceTransformer { if (after.interfaces.size() != before.interfaces.size()) throw new IllegalStateException(dottedName + ": transformer changed the interface list"); - String helperName = HOOK_PACKAGE + "H" + (sequence++) + "_" + after.name.substring(after.name.lastIndexOf('/') + 1).replace('$', '_'); + String helperName = HOOK_PACKAGE + "H" + (sequence.getAndIncrement()) + "_" + after.name.substring(after.name.lastIndexOf('/') + 1).replace('$', '_'); ClassNode helper = new ClassNode(); helper.version = after.version; helper.access = Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER; diff --git a/src/main/java/dev/zprestige/prestige/inject/NativeBridge.java b/src/main/java/dev/zprestige/prestige/inject/NativeBridge.java index d0725fd..3fdbea3 100644 --- a/src/main/java/dev/zprestige/prestige/inject/NativeBridge.java +++ b/src/main/java/dev/zprestige/prestige/inject/NativeBridge.java @@ -7,6 +7,12 @@ public final class NativeBridge { /** Bytes of the loaded class as the JVM currently has it (including changes made by earlier transformers). */ public static native byte[] classBytes(Class type); + /** classBytes for many classes with one RetransformClasses call; entries are null where nothing was captured, null on failure. */ + public static native byte[][] classBytesBatch(Class[] types); + + /** RedefineClasses for many classes in one call, all or nothing; returns the JVMTI error code, 0 on success. */ + public static native int redefineBatch(Class[] types, byte[][] bytes); + /** Appends a line to prestige-native.log, which the injector tails to show progress. */ public static native void log(String message); diff --git a/src/main/java/dev/zprestige/prestige/inject/TransformCache.java b/src/main/java/dev/zprestige/prestige/inject/TransformCache.java new file mode 100644 index 0000000..bd20406 --- /dev/null +++ b/src/main/java/dev/zprestige/prestige/inject/TransformCache.java @@ -0,0 +1,94 @@ +package dev.zprestige.prestige.inject; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.stream.Stream; + +/** + * Disk cache of finished ClassTransform results. The ClassTransform step is by far the slowest part of an injection, and its + * output only depends on the payload jar and on the class bytes it is given, so a repeat injection into the same game setup can + * skip it. Entries are keyed by SHA-256(payload jar, target name, original class bytes); a rebuilt payload or a changed modpack + * simply misses. Only results that need no helper classes are stored. + */ +final class TransformCache { + private static final long MAX_AGE_MILLIS = 14L * 24 * 60 * 60 * 1000; + + private final Path dir; + private final byte[] payloadId; + + private TransformCache(Path dir, byte[] payloadId) { + this.dir = dir; + this.payloadId = payloadId; + } + + /** The cache for the payload jar this class was loaded from, or null when it cannot be used. */ + static TransformCache open() { + try { + Path jar = Path.of(Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (!Files.isRegularFile(jar)) return null; + Path dir = Path.of(System.getProperty("java.io.tmpdir"), "PrestigeLoader", "cache"); + Files.createDirectories(dir); + prune(dir); + return new TransformCache(dir, MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(jar))); + } catch (Throwable t) { + return null; + } + } + + /** Cached transformed bytes of {@code target}, or null. */ + byte[] get(String target, byte[] original) { + try { + Path file = file(target, original); + if (!Files.isRegularFile(file)) return null; + byte[] bytes = Files.readAllBytes(file); + if (bytes.length < 4 || (bytes[0] & 0xFF) != 0xCA || (bytes[1] & 0xFF) != 0xFE || (bytes[2] & 0xFF) != 0xBA || (bytes[3] & 0xFF) != 0xBE) return null; + Files.setLastModifiedTime(file, FileTime.from(Instant.now())); + return bytes; + } catch (Throwable t) { + return null; + } + } + + void put(String target, byte[] original, byte[] transformed) { + try { + Path file = file(target, original); + Path temp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(temp, transformed); + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Throwable ignored) { + // the cache is only an optimisation + } + } + + private Path file(String target, byte[] original) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(payloadId); + digest.update(target.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(original); + StringBuilder name = new StringBuilder(); + for (byte b : digest.digest()) name.append(String.format("%02x", b)); + return dir.resolve(name + ".class"); + } + + /** Drops entries that have not been used for two weeks (every rebuild of the payload starts a fresh set). */ + private static void prune(Path dir) { + try (Stream files = Files.list(dir)) { + long limit = System.currentTimeMillis() - MAX_AGE_MILLIS; + files.forEach(file -> { + try { + if (Files.getLastModifiedTime(file).toMillis() < limit) Files.deleteIfExists(file); + } catch (IOException ignored) { + // leave it + } + }); + } catch (Throwable ignored) { + // leave it + } + } +} diff --git a/src/main/java/dev/zprestige/prestige/module/misc/SelfDestruct.java b/src/main/java/dev/zprestige/prestige/module/misc/SelfDestruct.java index 40a5364..708df28 100644 --- a/src/main/java/dev/zprestige/prestige/module/misc/SelfDestruct.java +++ b/src/main/java/dev/zprestige/prestige/module/misc/SelfDestruct.java @@ -53,7 +53,7 @@ public class SelfDestruct extends Module { error.printStackTrace(); } } - Prestige.moduleManager.setModules(new ArrayList()); + Prestige.moduleManager.setModules(new ArrayList<>()); if (Bootstrap.isInjected()) { // one more tick so no hook of the current tick is still being entered when the original classes come back diff --git a/src/main/java/dev/zprestige/prestige/ui/Input.java b/src/main/java/dev/zprestige/prestige/ui/Input.java index f02a868..9c99475 100644 --- a/src/main/java/dev/zprestige/prestige/ui/Input.java +++ b/src/main/java/dev/zprestige/prestige/ui/Input.java @@ -797,6 +797,7 @@ public final class Input { if (!active) return; syncMouse(); if (WidgetsPage.click(button)) return; + if (Ui.S.page == 10 && KeybindsPage.capturing && button >= 2 && button <= 4) { KeybindsPage.click(button); return; } switch (button) { case 0: lDown(); break; case 1: rDown(); break; diff --git a/src/main/java/dev/zprestige/prestige/ui/KeybindsPage.java b/src/main/java/dev/zprestige/prestige/ui/KeybindsPage.java index 9e8139d..e0706c7 100644 --- a/src/main/java/dev/zprestige/prestige/ui/KeybindsPage.java +++ b/src/main/java/dev/zprestige/prestige/ui/KeybindsPage.java @@ -2,12 +2,15 @@ package dev.zprestige.prestige.ui; import static dev.zprestige.prestige.ui.Lib.*; +import dev.zprestige.prestige.ui.bridge.Bridge; import java.util.ArrayList; import java.util.List; /** * Keybinds page (page 10). Port of clean.c ~1273-1490 (draw), 180145320.c (click / key capture / wheel) and the key chip - * sub_18011F140. Key codes are Windows virtual-key codes like in the original (+216 of the "Bind" setting); 0 = None. + * sub_18011F140. Rows are modules whose Bind is set, read from the live client; edits are written back to that Bind. + * The page works in Windows virtual-key codes like the original (+216 of the module's bind); 0 = None. The client stores GLFW + * key codes (-1 = None) and 9995..9999 for mouse buttons (9999 - button), so values are converted on the way in and out. */ public final class KeybindsPage { private KeybindsPage() {} @@ -17,12 +20,49 @@ public final class KeybindsPage { public String name, desc; public int vk; // Windows VK code (or 9995..9999 for mouse buttons); 0 = none (hidden from the list) public boolean toggle; // Hold / Toggle mode (not shown on this page, kept for the lead) - public Bound(String name, String desc, int vk, boolean toggle) { this.name = name; this.desc = desc; this.vk = vk; this.toggle = toggle; } + public Bridge.LiveSetting live; // the module's live Bind, written on every edit + public Bound(String name, String desc, int vk, boolean toggle, Bridge.LiveSetting live) { + this.name = name; this.desc = desc; this.vk = vk; this.toggle = toggle; this.live = live; + } } - /** The lead fills this from the real modules (only entries with vk != 0 are listed). */ + /** The rows, rebuilt from the live modules every frame the page is shown (only modules with a key set). */ public static final List BOUND = new ArrayList<>(); + /** Rebuilds {@link #BOUND} from the bridge. The original walks its module list and lists each module whose key is not None. */ + static void refresh() { + BOUND.clear(); + for (Bridge.LiveModule m : Bridge.modules()) { + Bridge.LiveSetting bind = m.keybind(); + if (bind == null) continue; + int vk = liveToVk(bind.value()); + if (vk == 0) continue; + BOUND.add(new Bound(m.name, m.desc, vk, bind.toggleMode(), bind)); + } + } + + /** Client key value (GLFW code, -1 = None, 9995..9999 = mouse) -> VK code used by this page (0 = None). */ + static int liveToVk(Object value) { + int g = value instanceof Number n ? n.intValue() : -1; + if (g < 0) return 0; + if (g >= 9995) return g; + return Input.toVk(g); + } + + /** VK code of this page -> client key value (-1 = None). */ + static int vkToLive(int vk) { + if (vk == 0) return -1; + if (vk >= 9995) return vk; + for (int g = 32; g <= 348; g++) if (Input.toVk(g) == vk) return g; + return -1; + } + + /** Sets a row's key on the page and on the module's live Bind. */ + private static void setKey(Bound b, int vk) { + b.vk = vk; + if (b.live != null) b.live.set(vkToLive(vk)); + } + /** dword_1804CCE5C: hovered row (-1 none). dword_1804CCE90 / byte_18070893F: row being captured / capture active. */ public static int hoverRow = -1, captureRow = -1; public static boolean capturing; @@ -118,6 +158,7 @@ public final class KeybindsPage { public static void update(float dt) { hoverRow = -1; if (Ui.S.page != 10) return; + refresh(); int n = visible().size(); float v108 = v108(), w = width(); float y = 110f - scroll; @@ -129,10 +170,16 @@ public final class KeybindsPage { private static float maxScroll(int n) { return Math.max(0f, (n * 70f - 10f) - 575f); } - /** Left click: Edit starts capture, Delete clears the key (sets it to None). */ + /** Click: while capturing, any mouse button becomes the bind (9999 - button, as the client encodes it). Otherwise Edit + * starts capture and Delete clears the key (sets it to None). */ public static boolean click(int button) { - if (button != 0) return false; List list = visible(); + if (capturing) { + if (captureRow >= 0 && captureRow < list.size() && button >= 0 && button <= 4) setKey(list.get(captureRow), 9999 - button); + captureRow = -1; capturing = false; + return true; + } + if (button != 0) return false; if (hoverRow < 0 || hoverRow >= list.size()) return false; float v108 = v108(), w = width(); float rowY = hoverRow * 70f + (110f - scroll); @@ -142,7 +189,7 @@ public final class KeybindsPage { return true; } if (Input.mx >= del && Input.mx <= del + 80f && Input.my >= rowY + 15f && Input.my <= rowY + 45f) { - list.get(hoverRow).vk = 0; + setKey(list.get(hoverRow), 0); captureRow = -1; capturing = false; return true; } @@ -162,8 +209,8 @@ public final class KeybindsPage { List list = visible(); if (captureRow < list.size()) { Bound b = list.get(captureRow); - if (glfwKey == 256 || glfwKey == 261) b.vk = 0; // VK 27 / 46 - else { int vk = glfwToVk(glfwKey); if (vk != 0) b.vk = vk; else return true; } + if (glfwKey == 256 || glfwKey == 261) setKey(b, 0); // Esc / Delete clear the bind (VK 27 / 46) + else { int vk = Input.toVk(glfwKey); if (vk != 0) setKey(b, vk); else return true; } } capturing = false; captureRow = -1; return true; diff --git a/src/main/java/dev/zprestige/prestige/ui/mc/Skid.java b/src/main/java/dev/zprestige/prestige/ui/mc/Skid.java index d7994cb..ec1e73a 100644 --- a/src/main/java/dev/zprestige/prestige/ui/mc/Skid.java +++ b/src/main/java/dev/zprestige/prestige/ui/mc/Skid.java @@ -2,7 +2,6 @@ package dev.zprestige.prestige.ui.mc; import javax.swing.JWindow; import javax.swing.Timer; -import javax.swing.JOptionPane; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.RenderingHints; @@ -24,13 +23,12 @@ import java.io.FileWriter; import java.io.BufferedReader; import java.net.URI; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; public class Skid extends JWindow implements ActionListener, MouseListener, MouseMotionListener { private static final int WIDTH = 440; private static final int HEIGHT = 270; private static final int DURATION = 4000; - private static final String DISCORD_URL = "https://discord.gg/5FB5hqJacr"; + private static final String DISCORD_URL = "https://discord.gg/x2FJyGrDNg"; private final CountDownLatch latch; private final Timer timer; @@ -161,27 +159,15 @@ public class Skid extends JWindow implements ActionListener, MouseListener, Mous return; } - final CountDownLatch latch = new CountDownLatch(1); - Skid ui = new Skid(latch); - ui.startSplash(); - - try { - latch.await(DURATION + 3000, TimeUnit.MILLISECONDS); - } catch (InterruptedException ignored) {} - + // Never block the caller: run() is called on the game's render thread, and a modal Swing dialog there freezes + // Minecraft (the dialog opens behind the game window). The splash runs on the AWT event thread on its own. if ("unset".equals(pref)) { - int res = JOptionPane.showConfirmDialog( - null, - "Do you want to see this splash screen again?", - "Skid Solutions", - JOptionPane.YES_NO_OPTION, - JOptionPane.QUESTION_MESSAGE - ); - if (res == JOptionPane.YES_OPTION) { - savePref("yes"); - } else { - savePref("no"); - } + savePref("yes"); } + java.awt.EventQueue.invokeLater(() -> { + try { + new Skid(new CountDownLatch(1)).startSplash(); + } catch (Throwable ignored) {} + }); } }