Improve launcher injection and hook reliability

Find Fabric through the render thread's class loader and make bytecode transformations safer for launcher-provided class definitions. Improve hook reporting, process-picker refresh behavior, and self-destruct cleanup; document Dawn and Lunar 1.21.11 setup.
This commit is contained in:
bbldrizzy committed 2026-11-03 11:40:43 +00:00
1 parent 38ff6d387c
commit 036b24f2ae
12 files changed
+186 -48

No files matched your search

+17 -2
View File
@@ -16,7 +16,7 @@ The injector and library must be built for the same architecture as the target J
## How it works
1. **Injector** lists the running Fabric client JVMs, then loads the library into the chosen process.
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
@@ -29,7 +29,8 @@ The injector and library must be built for the same architecture as the target J
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;
- 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
@@ -71,6 +72,20 @@ PrestigeInjector <pid> [lib path] # inject into a specific pid, optionally w
On Windows this is `PrestigeInjector.exe`. By default the library is looked up beside the injector.
### Dawn and Lunar Client 1.21.11
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.
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.
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.
## 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
+11 -6
View File
@@ -221,14 +221,12 @@ fn pick_process() -> Option<u32> {
let mut selected = 0usize;
let mut processes = platform::java_processes();
let mut last_refresh = Instant::now();
draw(&processes, selected);
loop {
if selected >= processes.len() {
selected = processes.len().saturating_sub(1);
}
draw(&processes, selected);
let deadline = Instant::now() + Duration::from_millis(50);
while Instant::now() < deadline {
if let Some(key) = platform::poll_key() {
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)),
@@ -239,12 +237,19 @@ fn pick_process() -> Option<u32> {
}
platform::Key::Escape => return None,
}
draw(&processes, selected);
if selected != previous {
draw(&processes, selected);
}
}
std::thread::sleep(Duration::from_millis(10));
}
if last_refresh.elapsed() >= Duration::from_millis(750) {
processes = platform::java_processes();
let refreshed = platform::java_processes();
if refreshed != processes {
processes = refreshed;
selected = selected.min(processes.len().saturating_sub(1));
draw(&processes, selected);
}
last_refresh = Instant::now();
}
}
@@ -3,6 +3,7 @@
use std::path::PathBuf;
/// A running Minecraft JVM shown in the picker.
#[derive(PartialEq)]
pub struct JavaProcess {
pub pid: u32,
pub title: String,
-3
View File
@@ -56,9 +56,6 @@ impl Jni {
pub unsafe fn exception_clear(&self) {
f!(self, 17, unsafe extern "system" fn(EnvPtr))(self.env)
}
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 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
}
+25 -10
View File
@@ -48,7 +48,7 @@ unsafe fn install() -> Result<(), String> {
log!("payload written to {}", jar.display());
let loader = add_to_knot(&jni, &jar)?;
log!("payload appended to the Fabric Knot class loader");
log!("payload appended to the game class loader: {}", describe(&jni, loader));
register_natives(&jni, loader)?;
log!("natives registered");
@@ -111,7 +111,7 @@ fn write_payload() -> Result<PathBuf, String> {
}
/// 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.
/// Returns a local reference to the Fabric launcher's target class loader.
unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result<JObject, String> {
jni.ensure_local_capacity(4096);
let (base, fabric_loader) = locate_fabric(jni)?;
@@ -149,23 +149,30 @@ unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result<JObject, Strin
if loader.is_null() {
return Err("Knot target class loader is null".into());
}
Ok(jni.new_global_ref(loader))
Ok(loader)
}
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).
/// Prefer the render thread's context loader when a launcher supplies its own loader hierarchy.
/// Fall back to the system loader and then the other thread context loaders.
unsafe fn locate_fabric(jni: &Jni) -> Result<(JClass, JObject), String> {
let (render, loaders) = thread_loaders(jni)?;
if let Some(loader) = render {
if let Ok(class) = class_for_name(jni, FABRIC_BASE, loader, false) {
log!("fabric-loader found through the render thread: {}", describe(jni, loader));
return Ok((class, loader));
}
}
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 render.is_some_and(|r| jni.is_same_object(r, loader)) {
continue;
}
if let Ok(class) = class_for_name(jni, FABRIC_BASE, loader, false) {
log!("fabric-loader found through {}", describe(jni, loader));
return Ok((class, loader));
@@ -180,10 +187,11 @@ unsafe fn locate_fabric(jni: &Jni) -> Result<(JClass, JObject), String> {
}
/// Distinct context class loaders of all live threads (the game threads inherit the launcher's loader).
unsafe fn thread_loaders(jni: &Jni) -> Result<Vec<JObject>, String> {
unsafe fn thread_loaders(jni: &Jni) -> Result<(Option<JObject>, Vec<JObject>), 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 get_name = jni.get_method_id(thread_class, "getName", "()Ljava/lang/String;");
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");
@@ -195,6 +203,7 @@ unsafe fn thread_loaders(jni: &Jni) -> Result<Vec<JObject>, String> {
let threads = jni.call_object(jni_non_null(set)?, to_array, &[]);
jni.check("thread list")?;
let mut loaders: Vec<JObject> = Vec::new();
let mut render = None;
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() {
@@ -204,11 +213,17 @@ unsafe fn thread_loaders(jni: &Jni) -> Result<Vec<JObject>, String> {
if jni.take_exception().is_some() || loader.is_null() {
continue;
}
let name = jni.call_object(thread, get_name, &[]);
if jni.take_exception().is_none() && !name.is_null() {
if matches!(jni.get_string_utf(name).as_str(), "Render thread" | "Client thread") {
render = Some(loader);
}
}
if !loaders.iter().any(|&known| jni.is_same_object(known, loader)) {
loaders.push(loader);
}
}
Ok(loaders)
Ok((render, loaders))
}
unsafe fn jni_non_null(o: JObject) -> Result<JObject, String> {