Build offline OpenPrestige release bundles

Rename the injector and package it with an embedded payload for Windows, Linux, and macOS x64/ARM64. Add a core-only ImGui native build, multi-platform CI and release packaging, and shared per-user storage with legacy config migration.
This commit is contained in:
bbldrizzy committed 2026-11-03 18:45:13 +00:00
1 parent 5152e6e0a6
commit 2d6b2a3cd1
37 files changed
+1284 -451

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
version = 4
[[package]]
name = "prestige-injector"
name = "openprestige-injector"
version = "0.1.0"
[[package]]
+45 -90
View File
@@ -1,111 +1,66 @@
# Prestige injector (Rust)
# OpenPrestige offline injector
`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 the running Minecraft 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 <pid> JVMTI.agent_load`). The injector
creates `.attach_pid<pid>` and sends `SIGQUIT` to the JVM, connects to its attach socket (`/tmp/.java_pid<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`;
- looks for Fabric through the render thread's context class loader, then the system and other thread loaders;
- `FabricLauncherBase.getLauncher().addToClassPath(jar)` so payload and game classes share the launcher's game 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
(JVMTI retransform capture), runs the hooks through `InPlaceTransformer` and redefines the class. The bundled ClassTransform
inlines hook handlers into the target methods, so normally nothing is added to the class and a plain redefine works. If a
transformer ever does add methods (a running JVM cannot), they are moved into generated `dev.zprestige.prestige.hooks.H*`
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.
This repository contains the original CLI injector, native library with embedded payload and game
sources. The graphical launcher and runtime downloader are maintained in the separate Prestige
repository. There is no GUI or HTTP dependency in this injector and no edition build flag.
## 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/`.
Run `build-injector.ps1` on Windows or `build-injector.sh` on Linux/macOS. Both build the Fabric JAR,
then the native library and CLI, collecting the results in `dist/`. The native library always embeds
the exact remapped JAR. The loose JAR is provided for optional installation as a Fabric mod.
```powershell
.\build-injector.ps1 # Windows: dist\PrestigeInjector.exe + dist\prestige_native.dll
Run `build-release.bat` on Windows to build all platforms. `dist/release/` contains:
```text
openprestige-<version>-windows-x86_64.zip
openprestige-<version>-windows-aarch64.zip
openprestige-<version>-linux-x86_64.zip
openprestige-<version>-linux-aarch64.zip
openprestige-<version>-macos-universal.zip
prestige-<version>.jar
SHA256SUMS.txt
```
```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-<ver>-{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.
Each ZIP contains `OpenPrestigeInjector` (or `.exe`), the native library, and a README.
The CI injector workflow builds only OpenPrestige. Build dependencies require internet access;
the resulting release bundle needs no runtime downloads.
## Use
Start a Fabric 1.21.11 instance, then run:
Extract the complete platform ZIP, start Minecraft, then run:
```sh
PrestigeInjector # interactive: pick the game from the list
PrestigeInjector <pid> [lib path] # inject into a specific pid, optionally with an explicit library path
OpenPrestigeInjector # Up/Down selects, Enter injects, Esc quits
OpenPrestigeInjector <pid> [lib path] # optional explicit library path
```
On Windows this is `PrestigeInjector.exe`. By default the library is looked up beside the injector.
Keep the native library beside the executable. The CLI uses that library and never looks in
Prestige's downloaded runtime cache. It has no update checks or `--download` command.
The library writes its embedded JAR locally so the JVM can load it. No external JAR is needed for
injection. Linux/macOS require a terminal, the same user as the game, and JVM attach enabled.
### Dawn and Lunar Client 1.21.11
## Platforms and requirements
Use a 1.21.11 Fabric profile in Dawn or enable Lunar Client's Fabric add-on for 1.21.11. The target
profile needs Fabric Loader 0.19.x and Fabric API. Wait for the title screen before selecting the game
in the injector. The default Prestige menu key is Right Shift, which may overlap with a launcher keybind.
Windows and Linux support x86_64 and ARM64. macOS supports Intel and Apple Silicon.
The injector, library and JVM architectures must match. ARM64 execution needs hardware testing.
The game code currently targets Minecraft 1.21.11 Fabric, Fabric Loader 0.19+ and Fabric API.
For Lunar, the native log should identify `GenesisClassLoader`. Check the game log for `hooks installed`
and `client initialised`. Hook counts refer to classes with changed bytecode. A skipped or failed hook
can leave its feature unavailable even if the menu opens.
Build tools require Rust, Python 3 and JDK 21. On Windows, the full release build also requires
MSVC build tools for x64 and ARM64. `build-release.bat` fetches pinned ImGui sources and builds
the small JNI libraries before building the remapped JAR. It installs Zig and cargo-zigbuild if
needed, then cross-compiles Linux x64/ARM64 and universal macOS.
An offline JVM replay accepted all 42 changed classes from Lunar's installed 1.21.11 cache after
recalculating stack and local limits on the cached classes. Live injection and Dawn's in-game profile
still need smoke testing.
Both repositories currently start from the same game sources and use the compatible `.openprestige`
configuration and log locations. They are independent repositories, without submodules, shared
working files or edition build flags. Game code fixes must be ported between repositories explicitly.
## Install as a mod instead
## User data
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.
| OS | Location |
| --- | --- |
| Windows | `%APPDATA%\.openprestige` |
| macOS | `~/Library/Application Support/.openprestige` |
| Linux | `$XDG_DATA_HOME/.openprestige` or `~/.local/share/.openprestige` |
## 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.
Configs live in `configs/`, logs in `logs/`, and local runtime files in `runtime/`.
+2 -2
View File
@@ -1,8 +1,8 @@
[package]
name = "prestige-injector"
name = "openprestige-injector"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "PrestigeInjector"
name = "OpenPrestigeInjector"
path = "src/main.rs"
+11
View File
@@ -0,0 +1,11 @@
fn main() {
let properties = "../../../gradle.properties";
println!("cargo:rerun-if-changed={properties}");
let contents = std::fs::read_to_string(properties).expect("read gradle.properties");
let version = contents
.lines()
.find_map(|line| line.strip_prefix("mod_version="))
.expect("mod_version is required")
.trim();
println!("cargo:rustc-env=PRESTIGE_VERSION={version}");
}
+160 -79
View File
@@ -1,10 +1,12 @@
//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads the native library
//! OpenPrestigeInjector: 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 interactive process picker (Up/Down, Enter, Esc)
//! PrestigeInjector <pid> [lib path] non-interactive
//! OpenPrestigeInjector interactive process picker (Up/Down, Enter, Esc)
//! OpenPrestigeInjector <pid> [lib path] non-interactive
#[path = "../../paths.rs"]
mod paths;
mod platform;
use std::fs;
@@ -15,7 +17,6 @@ use std::time::{Duration, Instant};
/// 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);
@@ -26,11 +27,15 @@ const GREEN: &str = "\x1b[92m";
const YELLOW: &str = "\x1b[93m";
const RED: &str = "\x1b[91m";
fn default_dll() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(platform::LIB_NAME)))
.unwrap_or_else(|| PathBuf::from(platform::LIB_NAME))
fn prepare_runtime(_offline: bool, _ask_for_updates: bool) -> Result<PathBuf, String> {
let path = std::env::current_exe()
.map_err(|error| error.to_string())?
.with_file_name(platform::LIB_NAME);
if path.is_file() {
Ok(path)
} else {
Err(format!("OpenPrestige requires {} beside the CLI executable. Extract the complete offline bundle.", platform::LIB_NAME))
}
}
fn banner() -> String {
@@ -38,26 +43,53 @@ fn banner() -> String {
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!(
" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector\n"
));
out.push_str(&format!("{YELLOW} {CREDIT}{RESET}\n\n"));
out.push_str("OpenPrestige - offline CLI\n\n");
out
}
fn step(label: &str, message: &str) {
println!("{DIM}[{:>7}]{RESET} {CYAN}{label:<12}{RESET} {message}", clock());
println!(
"{DIM}[{:>7}]{RESET} {CYAN}{label:<12}{RESET} {message}",
clock()
);
log_launcher(label, message);
}
fn ok(label: &str, message: &str) {
println!("{DIM}[{:>7}]{RESET} {GREEN}{label:<12}{RESET} {message}", clock());
println!(
"{DIM}[{:>7}]{RESET} {GREEN}{label:<12}{RESET} {message}",
clock()
);
log_launcher(label, message);
}
fn fail(label: &str, message: &str) {
println!("{DIM}[{:>7}]{RESET} {RED}{label:<12}{RESET} {message}", clock());
println!(
"{DIM}[{:>7}]{RESET} {RED}{label:<12}{RESET} {message}",
clock()
);
log_launcher(label, message);
}
fn log_launcher(label: &str, message: &str) {
let path = paths::root()
.join("logs")
.join(format!("launcher-{}.log", std::process::id()));
if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{}] {label} {message}", clock());
}
}
/// Seconds since the injector started, for the log prefix.
fn clock() -> String {
use std::sync::OnceLock;
static START: OnceLock<Instant> = OnceLock::new();
format!("{:.1}s", START.get_or_init(Instant::now).elapsed().as_secs_f32())
format!(
"{:.1}s",
START.get_or_init(Instant::now).elapsed().as_secs_f32()
)
}
fn main() -> ExitCode {
@@ -70,27 +102,47 @@ fn real_main() -> ExitCode {
platform::enable_ansi();
let _ = clock();
let args: Vec<String> = std::env::args().skip(1).collect();
let interactive = args.is_empty();
if let Err(error) = paths::initialize() {
fail("storage", &error.to_string());
return ExitCode::from(1);
}
let interactive = args.is_empty()
|| matches!(args.as_slice(), [flag] if flag == "--console" || flag == "--offline");
let (pid, dll): (Option<u32>, PathBuf) = match args.as_slice() {
[] => {
open_invite_once();
(pick_process(), default_dll())
let (pid, dll): (Option<u32>, Result<PathBuf, String>) = match args.as_slice() {
_ if interactive => {
let library = prepare_runtime(args.iter().any(|arg| arg == "--offline"), true);
if let Err(error) = &library {
fail("runtime", error);
return ExitCode::from(1);
}
(pick_process(), library)
}
[pid] => (parse_pid(pid), default_dll()),
[pid, dll] => (parse_pid(pid), PathBuf::from(dll)),
[pid] => (parse_pid(pid), prepare_runtime(true, false)),
[pid, dll] => (parse_pid(pid), Ok(PathBuf::from(dll))),
_ => {
eprintln!("usage: PrestigeInjector [<pid> [lib path]]");
eprintln!("usage: OpenPrestigeInjector [--console | --offline | <pid> [lib path]]");
return ExitCode::from(2);
}
};
let Some(pid) = pid else {
// interactive mode quit with Esc, or an unparsable pid was given
return if interactive { ExitCode::SUCCESS } else { ExitCode::from(2) };
return if interactive {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
};
};
if !interactive {
print!("{}", banner());
}
let dll = match dll {
Ok(path) => path,
Err(error) => {
fail("runtime", &error);
return ExitCode::from(1);
}
};
let code = run(pid, &dll);
if interactive {
@@ -121,35 +173,58 @@ fn run(pid: u32, dll: &Path) -> ExitCode {
let dll_display = dll_display.trim_start_matches(r"\\?\").to_string();
step("loader", &dll_display);
// 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);
let log_path = paths::native_log(pid);
let logs = [log_path.clone(), dll.with_file_name("prestige-native.log")];
let starts = logs
.each_ref()
.map(|path| fs::metadata(path).map(|m| m.len()).unwrap_or(0));
step("checking", "64-bit JVM, process access, library not already loaded");
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)),
Ok(()) => ok(
"injected",
&format!("{} is loaded in the game process", platform::LIB_NAME),
),
Err(e) => {
fail("failed", &e);
return ExitCode::from(1);
}
}
step("transforming", "following the loader log (attach, class loader, hook transformer)...");
match follow_log(&log_path, log_start) {
step(
"transforming",
"following the loader log (attach, class loader, hook transformer)...",
);
match follow_log(&logs, starts) {
Outcome::Done => {
println!();
ok("success", "successfully injected - Prestige is running in the game");
ok(
"success",
"successfully injected - Prestige is running in the game",
);
ExitCode::SUCCESS
}
Outcome::Failed(line) => {
fail("failed", &format!("injection did not complete: {line}"));
println!("{DIM} details: {} and the game's latest.log{RESET}", log_path.display());
println!(
"{DIM} details: {} and the game's latest.log{RESET}",
log_path.display()
);
ExitCode::from(1)
}
Outcome::TimedOut => {
fail("timeout", "no completion message from the loader within 3 minutes");
println!("{DIM} check {} and the game's latest.log{RESET}", log_path.display());
fail(
"timeout",
"no completion message from the loader within 3 minutes",
);
println!(
"{DIM} check {} and the game's latest.log{RESET}",
log_path.display()
);
ExitCode::from(1)
}
}
@@ -162,60 +237,61 @@ enum Outcome {
}
/// Prints the lines the loader/bootstrap append to the log until they report success or failure.
fn follow_log(path: &Path, start: u64) -> Outcome {
fn follow_log(paths: &[PathBuf; 2], mut offsets: [u64; 2]) -> Outcome {
let deadline = Instant::now() + FOLLOW_TIMEOUT;
let mut offset = start;
let mut pending = String::new();
let mut pending = [String::new(), String::new()];
while Instant::now() < deadline {
if let Ok(mut file) = fs::File::open(path) {
if file.seek(SeekFrom::Start(offset)).is_ok() {
let mut chunk = String::new();
if let Ok(n) = file.read_to_string(&mut chunk) {
offset += n as u64;
pending.push_str(&chunk);
for (index, path) in paths.iter().enumerate() {
let offset = &mut offsets[index];
let pending = &mut pending[index];
if let Ok(mut file) = fs::File::open(path) {
if file.seek(SeekFrom::Start(*offset)).is_ok() {
let mut chunk = String::new();
if let Ok(n) = file.read_to_string(&mut chunk) {
*offset += n as u64;
pending.push_str(&chunk);
}
}
}
}
while let Some(end) = pending.find('\n') {
let line: String = pending.drain(..=end).collect();
let line = line.trim_end();
// lines look like "[unix seconds] message"
let message = line.split_once("] ").map(|(_, m)| m).unwrap_or(line);
let lower = message.to_ascii_lowercase();
// 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());
while let Some(end) = pending.find('\n') {
let line: String = pending.drain(..=end).collect();
// Keep the output from older runtime bundles in the shared log too.
if index == 1 {
if let Ok(mut log) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&paths[0])
{
if log.write_all(line.as_bytes()).is_ok() {
offsets[0] += line.len() as u64;
}
}
}
let line = line.trim_end();
// lines look like "[unix seconds] message"
let message = line.split_once("] ").map(|(_, m)| m).unwrap_or(line);
let lower = message.to_ascii_lowercase();
// 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());
}
if message.contains("client initialised") {
ok("loader", message);
return Outcome::Done;
}
step("loader", message);
}
if message.contains("client initialised") {
ok("loader", message);
return Outcome::Done;
}
step("loader", message);
}
std::thread::sleep(Duration::from_millis(200));
}
Outcome::TimedOut
}
fn config_path() -> Option<PathBuf> {
platform::config_dir().map(|dir| dir.join("PrestigeInjector").join("settings.txt"))
}
/// 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 };
if path.exists() {
return;
}
let _ = platform::open_url(INVITE_URL);
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
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<u32> {
let mut selected = 0usize;
@@ -229,7 +305,9 @@ fn pick_process() -> Option<u32> {
let previous = selected;
match key {
platform::Key::Up => selected = selected.saturating_sub(1),
platform::Key::Down => selected = (selected + 1).min(processes.len().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);
@@ -264,7 +342,10 @@ fn draw(processes: &[platform::JavaProcess], selected: usize) {
}
for (i, p) in processes.iter().enumerate() {
let marker = if i == selected { ">" } else { " " };
out.push_str(&format!("{marker} [{:>6}] {} ({})\n", p.pid, p.title, p.exe));
out.push_str(&format!(
"{marker} [{:>6}] {} ({})\n",
p.pid, p.title, p.exe
));
}
print!("{out}");
let _ = io::stdout().flush();
+2 -22
View File
@@ -1,6 +1,5 @@
//! 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;
//! enable_ansi, poll_key, java_processes, inject, restore_terminal.
/// A running Minecraft JVM shown in the picker.
#[derive(PartialEq)]
@@ -33,7 +32,7 @@ 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};
pub use unix_term::{enable_ansi, poll_key, restore_terminal};
#[cfg(windows)]
pub const LIB_NAME: &str = "prestige_native.dll";
@@ -41,22 +40,3 @@ pub const LIB_NAME: &str = "prestige_native.dll";
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<PathBuf> {
#[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")),
}
}
}
@@ -1,4 +1,4 @@
//! Terminal input and URL opening on Linux and macOS. std only (no termios structs).
//! Terminal input 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
@@ -37,8 +37,15 @@ fn tty_stdin() -> Stdio {
}
fn stty(args: &[&str]) -> Option<String> {
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())
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`.
@@ -113,7 +120,9 @@ pub fn poll_key() -> Option<Key> {
27 => {
let ms30 = Duration::from_millis(30);
match rx.recv_timeout(ms30) {
Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => return Some(Key::Escape),
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),
@@ -134,19 +143,3 @@ pub fn restore_terminal() {
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)
}
+106 -30
View File
@@ -39,16 +39,43 @@ struct ModuleEntry32W {
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 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 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 IsWow64Process2(process: Handle, process_machine: *mut u16, native_machine: *mut u16)
-> i32;
fn GetCurrentProcess() -> Handle;
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;
@@ -66,11 +93,6 @@ extern "system" {
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;
@@ -109,7 +131,10 @@ pub fn poll_key() -> Option<Key> {
}
fn wide(path: &Path) -> Vec<u16> {
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn image_name(pid: u32) -> Option<String> {
@@ -156,7 +181,9 @@ pub fn java_processes() -> Vec<JavaProcess> {
if out.iter().any(|w| w.pid == pid) {
continue;
}
let Some(path) = image_name(pid) else { 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 });
@@ -181,7 +208,11 @@ fn module_loaded(pid: u32, file_name: &str) -> bool {
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());
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;
@@ -196,10 +227,16 @@ fn module_loaded(pid: u32, file_name: &str) -> bool {
/// 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 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)"));
return Err(last_error(
"OpenProcess (run as the same user, or as administrator)",
));
}
let result = inject_into(process, pid, dll);
CloseHandle(process);
@@ -208,18 +245,50 @@ pub fn inject(pid: u32, dll: &Path) -> Result<(), String> {
}
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());
unsafe fn machine(process: Handle) -> Result<u16, String> {
let mut emulated = 0;
let mut native = 0;
if IsWow64Process2(process, &mut emulated, &mut native) == 0 {
return Err(last_error("IsWow64Process2"));
}
Ok(if emulated == 0 { native } else { emulated })
}
let file_name = dll.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let target = machine(process)?;
let injector = machine(GetCurrentProcess())?;
if target != injector {
return Err(format!("JVM architecture {target:#06x} differs from injector {injector:#06x}. Use matching x64 or ARM64 builds."));
}
let image = std::fs::read(dll).map_err(|error| format!("read loader: {error}"))?;
let pe = image
.get(0x3c..0x40)
.and_then(|bytes| bytes.try_into().ok())
.map(u32::from_le_bytes)
.ok_or("invalid loader PE header")? as usize;
let library = image
.get(pe + 4..pe + 6)
.and_then(|bytes| bytes.try_into().ok())
.map(u16::from_le_bytes)
.ok_or("invalid loader machine")?;
if library != target {
return Err("The loader DLL and JVM architectures differ.".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);
let remote = VirtualAllocEx(
process,
std::ptr::null_mut(),
bytes,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
);
if remote.is_null() {
return Err(last_error("VirtualAllocEx"));
}
@@ -227,7 +296,14 @@ unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), Strin
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 {
if WriteProcessMemory(
process,
remote,
path.as_ptr() as *const c_void,
bytes,
std::ptr::null_mut(),
) == 0
{
return cleanup(last_error("WriteProcessMemory"));
}
@@ -237,7 +313,15 @@ unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), Strin
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());
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"));
}
@@ -260,11 +344,3 @@ unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), Strin
}
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<u16> = "open".encode_utf16().collect();
let target: Vec<u16> = 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 }
}
+15 -4
View File
@@ -10,7 +10,10 @@ fn main() {
Err(_) => newest_remapped_jar(),
};
if !jar.is_file() {
panic!("payload jar not found: {} (run `gradlew remapJar` or set PRESTIGE_PAYLOAD)", jar.display());
panic!(
"payload jar not found: {} (run `gradlew remapJar` or set PRESTIGE_PAYLOAD)",
jar.display()
);
}
println!("cargo:rerun-if-changed={}", jar.display());
println!("cargo:rustc-env=PRESTIGE_PAYLOAD_JAR={}", jar.display());
@@ -22,14 +25,22 @@ fn newest_remapped_jar() -> PathBuf {
if let Ok(entries) = fs::read_dir(&libs) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with("prestige-") || !name.ends_with(".jar") || name.ends_with("-dev.jar") || name.ends_with("-sources.jar") {
if !name.starts_with("prestige-")
|| !name.ends_with(".jar")
|| name.ends_with("-dev.jar")
|| name.ends_with("-sources.jar")
{
continue;
}
let time = entry.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH);
let time = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
if best.as_ref().map_or(true, |(t, _)| time > *t) {
best = Some((time, entry.path()));
}
}
}
best.map(|(_, p)| p).unwrap_or_else(|| libs.join("prestige-0.1.0.jar"))
best.map(|(_, p)| p)
.unwrap_or_else(|| libs.join("prestige-0.1.0.jar"))
}
+2
View File
@@ -6,6 +6,8 @@
mod bridge;
mod jni;
mod log;
#[path = "../../paths.rs"]
mod paths;
mod worker;
use std::ffi::c_void;
+4 -46
View File
@@ -1,61 +1,19 @@
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<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
module_path().with_file_name("prestige-native.log")
})
PATH.get_or_init(|| crate::paths::native_log(std::process::id()))
}
/// Appends a line to prestige-native.log beside the DLL.
pub fn log(message: &str) {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if let Some(parent) = log_path().parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path()) {
let _ = writeln!(file, "[{secs}] {message}");
}
+4 -2
View File
@@ -29,6 +29,7 @@ pub fn run() {
}
unsafe fn install() -> Result<(), String> {
crate::paths::initialize().map_err(|error| format!("create OpenPrestige data directory: {error}"))?;
let vm = find_vm()?;
let env = attach_current_thread(vm)?;
struct Detach(VmPtr);
@@ -45,7 +46,7 @@ unsafe fn install() -> Result<(), String> {
log!("JVMTI ready");
let jar = write_payload()?;
log!("payload written to {}", jar.display());
log!("using payload at {}", jar.display());
let loader = add_to_knot(&jni, &jar)?;
log!("payload appended to the game class loader: {}", describe(&jni, loader));
@@ -103,7 +104,8 @@ unsafe fn find_vm() -> Result<VmPtr, String> {
}
fn write_payload() -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("PrestigeLoader");
let dir = crate::paths::initialize().map_err(|e| format!("create OpenPrestige data directory: {e}"))?
.join("runtime").join("payloads");
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("prestige-payload-{}.jar", std::process::id()));
std::fs::write(&path, PAYLOAD).map_err(|e| format!("write {}: {e}", path.display()))?;
+36
View File
@@ -0,0 +1,36 @@
use std::io;
use std::path::PathBuf;
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
pub fn root() -> PathBuf {
#[cfg(windows)]
let base = env_path("APPDATA")
.or_else(|| env_path("USERPROFILE").map(|home| home.join("AppData/Roaming")));
#[cfg(target_os = "macos")]
let base = env_path("HOME").map(|home| home.join("Library/Application Support"));
#[cfg(all(unix, not(target_os = "macos")))]
let base = env_path("XDG_DATA_HOME")
.filter(|path| path.is_absolute())
.or_else(|| env_path("HOME").map(|home| home.join(".local/share")));
base.unwrap_or_else(std::env::temp_dir)
.join(".openprestige")
}
pub fn initialize() -> io::Result<PathBuf> {
let root = root();
for name in ["configs", "logs", "runtime"] {
std::fs::create_dir_all(root.join(name))?;
}
Ok(root)
}
pub fn native_log(pid: u32) -> PathBuf {
root()
.join("logs")
.join(format!("prestige-native-{pid}.log"))
}