From 7fa63c4bbe3da81ffad1d44781bc739fac24df06 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 14:36:48 +0200 Subject: [PATCH 1/2] Add Fabric/Forge support --- CLAUDE.md | 2 + client/src/mapping/loader.rs | 171 +++++++++++++++++++++++++++++++++++ client/src/mapping/mod.rs | 38 ++++++-- 3 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 client/src/mapping/loader.rs diff --git a/CLAUDE.md b/CLAUDE.md index 15d788d..b8de142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ This is the core control flow and spans all three crates: Both modes share one code path: a `RwLock>>` 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 diff --git a/client/src/mapping/loader.rs b/client/src/mapping/loader.rs new file mode 100644 index 0000000..ec5ba26 --- /dev/null +++ b/client/src/mapping/loader.rs @@ -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 { + 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> { + 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 = 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 { + 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 { + 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> { + 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> { + 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)) +} diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index 77a0020..a4701fe 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -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; @@ -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>>, - /// 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>, /// Cache of resolved JVM classes — and known-missing ones (`None`) — keyed /// by JNI name, so a class is searched for at most once. @@ -121,13 +125,33 @@ fn is_unobfuscated() -> bool { #[allow(dead_code)] impl Mapping { pub fn new() -> anyhow::Result { - 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()), }); } From 98e96c25841b596d13d791c7bbc84acb4144f0c2 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 14:40:50 +0200 Subject: [PATCH 2/2] Fix CI --- client/build.rs | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/client/build.rs b/client/build.rs index 5752110..17fc158 100644 --- a/client/build.rs +++ b/client/build.rs @@ -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}; @@ -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) {