Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ This is the core control flow and spans all three crates:

Both modes share one code path: a `RwLock<HashMap<String, Arc<MinecraftClass>>>` populated up-front (obfuscated) or lazily by reflection (reflected). `Mapping` wraps all JNI calls (`call_method`, `call_static_method`, `get_field`, `set_field`, etc.); `class.rs` does overload resolution by scoring argument-type compatibility against JNI signatures.

**Mod loaders (`loader.rs`)**: Fabric and Forge/NeoForge run the game in an isolated class loader (`KnotClassLoader` / `TransformingClassLoader`), so `find_class` from a native thread resolves a dead duplicate of `Minecraft` whose static `instance` is null. `Mapping::new()` calls `loader::discover_game_loader` first — it scans every live thread's context class loader and keeps the one whose `Minecraft.getInstance()` is non-null. That loader is stored in `class_loader` so every later lookup goes through `ClassLoader.loadClass`. This works loader-agnostically for vanilla, Fabric and Forge on unobfuscated builds; obfuscated Minecraft under a mod loader (intermediary/SRG names) is not supported.

**Lifecycle safety**: the global `RUNNING: AtomicBool` gates `on_frame` and the agent's loops. A panic hook in `initialize_client` calls `cleanup_client` so input/render hooks are always uninstalled and GLFW callbacks restored, even on panic.

## Mappings
Expand Down
36 changes: 21 additions & 15 deletions client/build.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
// build.rs
// This build script is only relevant on Windows with MSVC toolchain.
// It finds the `jvm.lib` import library that is required to link JNI functions.
// On Linux, this is unnecessary because the linker can directly use libjvm.so.
// Generates the OpenGL bindings (`bindings.rs`) on every platform.
// On Windows (MSVC) it additionally locates the `jvm.lib` import library
// required to link JNI functions; on Linux the linker uses libjvm.so directly.

use gl_generator::{Api, Fallbacks, GlobalGenerator, Profile, Registry};
use std::env;
use std::fs::File;
use std::path::Path;

#[cfg(windows)]
fn main() {
// Generate the OpenGL bindings on every platform — `lib.rs` `include!`s
// `bindings.rs` unconditionally.
let dest = env::var("OUT_DIR").unwrap();
let mut file = File::create(Path::new(&dest).join("bindings.rs")).unwrap();

// Ask for OpenGL 3.3 Compatibility so we get VAOs (GenVertexArrays) and modern shader API
Registry::new(Api::Gl, (3, 3), Profile::Compatibility, Fallbacks::All, [])
.write_bindings(GlobalGenerator, &mut file)
.unwrap();

#[cfg(windows)]
find_jvm_lib();
}

#[cfg(windows)]
// Finds the `jvm.lib` import library that is required to link JNI functions on
// the MSVC toolchain. On Linux the linker can use libjvm.so directly.
fn find_jvm_lib() {
use std::path::PathBuf;
use std::{env, fs};

Expand Down Expand Up @@ -73,17 +90,6 @@ fn main() {
);
}

#[cfg(not(windows))]
fn main() {
let dest = env::var("OUT_DIR").unwrap();
let mut file = File::create(&Path::new(&dest).join("bindings.rs")).unwrap();

// Ask for OpenGL 3.3 Compatibility so we get VAOs (GenVertexArrays) and modern shader API
Registry::new(Api::Gl, (3, 3), Profile::Compatibility, Fallbacks::All, [])
.write_bindings(GlobalGenerator, &mut file)
.unwrap();
}

#[cfg(windows)]
// Adds the directory to the linker search path and tells Cargo to link against jvm.lib
fn link_jvm(dir: &std::path::PathBuf) {
Expand Down
171 changes: 171 additions & 0 deletions client/src/mapping/loader.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//! Game class-loader discovery for modded Minecraft (Fabric / Forge / NeoForge).
//!
//! Vanilla Minecraft runs every class through a single class loader, so a JNI
//! `FindClass` from any thread resolves `net.minecraft.*` correctly. Mod
//! loaders break that assumption: Fabric's `KnotClassLoader` and ModLauncher's
//! `TransformingClassLoader` load the game in an *isolated* loader while the
//! launch bootstrap still sits on the system class path. `FindClass` from a
//! native thread then resolves a **second, dead copy** of `Minecraft` whose
//! static `instance` field is null — exactly the "Minecraft is null" failure
//! seen when injecting into a Fabric/Forge instance.
//!
//! This module finds the loader that actually owns the *running* game by
//! scanning every live thread's context class loader and keeping the one whose
//! `Minecraft.getInstance()` returns a non-null instance. Routing every later
//! class lookup through that loader (see [`Mapping::lookup_class`]) makes the
//! reflected mapping path work identically on vanilla, Fabric and Forge.
//!
//! [`Mapping::lookup_class`]: crate::mapping::Mapping

use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::JNIEnv;

/// Binary name of the client entry-point class — identical on every
/// unobfuscated build, vanilla or modded.
const MINECRAFT_CLASS: &str = "net.minecraft.client.Minecraft";

/// Exact JNI descriptor of `Minecraft.getInstance()`. `GetStaticMethodID`
/// matches signatures verbatim, so the real return type is required here.
const GET_INSTANCE_SIG: &str = "()Lnet/minecraft/client/Minecraft;";

/// Name Minecraft gives its main client thread on every modern version.
const RENDER_THREAD: &str = "Render thread";

/// Result of probing one thread's context class loader.
enum LoaderProbe {
/// The loader owns a `Minecraft` whose `getInstance()` is non-null — this
/// is the loader that runs the game.
Live(GlobalRef),
/// The loader belongs to the `Render thread` and can load `Minecraft`, but
/// `getInstance()` was still null (game not finished starting). Kept as a
/// fallback in case no loader reports a live instance.
RenderThread(GlobalRef),
/// The loader cannot load an unobfuscated `Minecraft`.
Unrelated,
}

/// Finds the class loader that owns the live Minecraft instance.
///
/// Returns `None` for vanilla obfuscated builds (no loader exposes an
/// unobfuscated `Minecraft`) and on any JNI error, in which case the caller
/// falls back to plain `FindClass` resolution.
pub fn discover_game_loader(env: &mut JNIEnv) -> Option<GlobalRef> {
match scan_threads(env) {
Ok(loader) => loader,
Err(_) => {
let _ = env.exception_clear();
None
}
}
}

/// Walks `Thread.getAllStackTraces()` and probes each thread's context class
/// loader, returning the first loader proven to run the game.
fn scan_threads(env: &mut JNIEnv) -> anyhow::Result<Option<GlobalRef>> {
let thread_class = env.find_class("java/lang/Thread")?;
let traces = env
.call_static_method(&thread_class, "getAllStackTraces", "()Ljava/util/Map;", &[])?
.l()?;
let threads = env
.call_method(&traces, "keySet", "()Ljava/util/Set;", &[])?
.l()?;
let iter = env
.call_method(&threads, "iterator", "()Ljava/util/Iterator;", &[])?
.l()?;

let mut render_thread_loader: Option<GlobalRef> = None;

while env.call_method(&iter, "hasNext", "()Z", &[])?.z()? {
// Each thread spawns a handful of temporary JNI refs — scope them so a
// process with many threads cannot overflow the local-reference table.
let probe = env.with_local_frame(32, |env| -> anyhow::Result<LoaderProbe> {
let thread = env
.call_method(&iter, "next", "()Ljava/lang/Object;", &[])?
.l()?;
let loader = env
.call_method(
&thread,
"getContextClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
if loader.is_null() {
return Ok(LoaderProbe::Unrelated);
}
probe_loader(env, &thread, &loader)
})?;

match probe {
LoaderProbe::Live(loader) => return Ok(Some(loader)),
LoaderProbe::RenderThread(loader) => render_thread_loader = Some(loader),
LoaderProbe::Unrelated => {}
}
}

Ok(render_thread_loader)
}

/// Classifies `loader` by asking it to load `Minecraft` and, if it can,
/// whether that class already holds a live game instance.
fn probe_loader(
env: &mut JNIEnv,
thread: &JObject,
loader: &JObject,
) -> anyhow::Result<LoaderProbe> {
let class = match load_class(env, loader, MINECRAFT_CLASS) {
Some(class) => class,
None => return Ok(LoaderProbe::Unrelated),
};

let instance = match env.call_static_method(&class, "getInstance", GET_INSTANCE_SIG, &[]) {
Ok(value) => value.l().unwrap_or_else(|_| JObject::null()),
Err(_) => {
let _ = env.exception_clear();
JObject::null()
}
};

if !instance.is_null() {
return Ok(LoaderProbe::Live(env.new_global_ref(loader)?));
}
if thread_name(env, thread)?.as_deref() == Some(RENDER_THREAD) {
return Ok(LoaderProbe::RenderThread(env.new_global_ref(loader)?));
}
Ok(LoaderProbe::Unrelated)
}

/// Calls `loader.loadClass(binary_name)`, returning the class on success and
/// `None` (with the pending exception cleared) when the loader cannot find it.
fn load_class<'a>(
env: &mut JNIEnv<'a>,
loader: &JObject,
binary_name: &str,
) -> Option<JClass<'a>> {
let name: JObject = env.new_string(binary_name).ok()?.into();
let result = env.call_method(
loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&name)],
);
match result.and_then(|value| value.l()) {
Ok(class) if !class.is_null() => Some(JClass::from(class)),
_ => {
let _ = env.exception_clear();
None
}
}
}

/// Reads `Thread.getName()`, returning `None` on any JNI error.
fn thread_name(env: &mut JNIEnv, thread: &JObject) -> anyhow::Result<Option<String>> {
let name = env
.call_method(thread, "getName", "()Ljava/lang/String;", &[])?
.l()?;
Ok(env
.get_string(&JString::from(name))?
.to_str()
.ok()
.map(str::to_owned))
}
38 changes: 31 additions & 7 deletions client/src/mapping/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod class_type;
pub mod client;
pub mod entity;
pub mod java;
mod loader;
mod method;
mod minecraft_version;
mod reflect;
Expand Down Expand Up @@ -57,10 +58,13 @@ pub struct Mapping {
/// In obfuscated mode every class is present up-front; in reflected mode
/// classes are discovered and cached on first use.
classes: RwLock<HashMap<String, Arc<MinecraftClass>>>,
/// The class loader that loaded Minecraft, captured the first time a class
/// resolves. `JNIEnv::find_class` is classloader-sensitive and only works
/// from threads with a Minecraft Java frame on the stack; routing every
/// later lookup through this loader makes resolution thread-independent.
/// The class loader that runs the game. On modded builds (Fabric / Forge)
/// it is discovered up-front by [`loader::discover_game_loader`]; on vanilla
/// it is captured the first time a class resolves. `JNIEnv::find_class` is
/// classloader-sensitive and only works from threads with a Minecraft Java
/// frame on the stack — and on modded builds resolves a dead duplicate of
/// the game classes — so routing every later lookup through this loader
/// makes resolution both thread-independent and mod-loader-correct.
class_loader: RwLock<Option<GlobalRef>>,
/// Cache of resolved JVM classes — and known-missing ones (`None`) — keyed
/// by JNI name, so a class is searched for at most once.
Expand Down Expand Up @@ -121,13 +125,33 @@ fn is_unobfuscated() -> bool {
#[allow(dead_code)]
impl Mapping {
pub fn new() -> anyhow::Result<Mapping> {
if is_unobfuscated() {
info!("Unobfuscated Minecraft detected — using runtime reflection mapping");
// Discover the loader that runs the game before anything else: on
// Fabric/Forge the game lives in an isolated class loader and a plain
// `find_class` resolves a dead duplicate of `Minecraft` whose static
// `instance` is null — the "Minecraft is null" failure (see `loader`).
let game_loader = DarkClient::instance()
.get_env()
.ok()
.and_then(|mut env| loader::discover_game_loader(&mut env));

// Reflected mode applies whenever the real Mojmap names exist at
// runtime — proven either by a resolved game loader (vanilla or modded)
// or, as a fallback, by a direct `find_class`.
if game_loader.is_some() || is_unobfuscated() {
match game_loader {
Some(_) => info!(
"Modded/unobfuscated Minecraft detected — routing class \
resolution through the game class loader"
),
None => info!(
"Unobfuscated Minecraft detected — using runtime reflection mapping"
),
}
return Ok(Mapping {
mode: Mode::Reflected,
version: MinecraftVersion::LATEST,
classes: RwLock::new(HashMap::new()),
class_loader: RwLock::new(None),
class_loader: RwLock::new(game_loader),
class_handles: RwLock::new(HashMap::new()),
});
}
Expand Down
Loading