Sign in Sign up
kretrod/lodestone Public
Branches
master
560 lines (511 loc) · 20.5 KB Raw
//! Lodestone client — injected into Minecraft, drawn inside its own window.
//!
//! Flow:
//!   DllMain            spawns a thread so the loader lock is released at once
//!   init               waits for glfw.dll, then hooks glfwSwapBuffers
//!   swap_buffers       runs once per frame, on the game's render thread:
//!                        - first call sets up the window hook, GL and JNI
//!                        - applies the enabled modules through JNI
//!                        - draws the menu into the frame the game just built
//!                        - calls the real glfwSwapBuffers
//!
//! Running on the render thread is what makes the JNI side simple: it is a
//! Java thread, already attached, and the game is not concurrently mutating the
//! objects we touch.

#![cfg(windows)]

pub mod blocks;
pub mod capture;
pub mod config;
pub mod extras;
pub mod cheats;
pub mod hook;
pub mod input;
pub mod jni;
pub mod mc;
pub mod overlay;
pub mod state;

use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;

use windows_sys::Win32::Foundation::{HMODULE, HWND};
use windows_sys::Win32::System::LibraryLoader::{
    DisableThreadLibraryCalls, GetModuleHandleA, GetProcAddress,
};
use windows_sys::Win32::System::Threading::CreateThread;

use crate::jni::Jni;
use crate::mc::{Interact, Mc, TargetKind, World};

static HOOK: OnceLock<hook::Hook> = OnceLock::new();
static MODULE: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(0);
static UNLOADING: AtomicBool = AtomicBool::new(false);
/// How many threads are currently executing inside our detour. The module must
/// not be freed while this is above zero.
static IN_DETOUR: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

// ---------------------------------------------------------------------------
// entry point
// ---------------------------------------------------------------------------

#[no_mangle]
pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> i32 {
    const DLL_PROCESS_ATTACH: u32 = 1;
    if reason == DLL_PROCESS_ATTACH {
        MODULE.store(module as isize, Ordering::SeqCst);
        // SAFETY: both calls are documented as safe from DllMain, and the
        // thread body does all the real work outside the loader lock.
        unsafe {
            DisableThreadLibraryCalls(module);
            CreateThread(
                std::ptr::null(),
                0,
                Some(init),
                std::ptr::null_mut(),
                0,
                std::ptr::null_mut(),
            );
        }
    }
    1
}

/// The folder this DLL was loaded from. Everything the client reads or writes
/// lives beside it, so the pair can be dropped anywhere and still work.
pub fn client_dir() -> std::path::PathBuf {
    use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameW;
    let module = MODULE.load(Ordering::SeqCst);
    let mut buf = [0u16; 520];
    // SAFETY: module is our own handle, stored in DllMain; buf is sized in
    // characters as the call expects.
    let len = unsafe { GetModuleFileNameW(module as HMODULE, buf.as_mut_ptr(), buf.len() as u32) };
    if len == 0 {
        return std::path::PathBuf::from(".");
    }
    let path = std::path::PathBuf::from(String::from_utf16_lossy(&buf[..len as usize]));
    path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from("."))
}

/// ARGB for each kind of thing, for the gizmo renderer.
fn kind_colour(kind: TargetKind) -> i32 {
    (match kind {
        TargetKind::Player => 0xFF_F0606Eu32,
        TargetKind::Mob => 0xFF_ECA054,
        TargetKind::Animal => 0xFF_7ED88C,
        TargetKind::Item => 0xFF_78BEEC,
        TargetKind::Other => 0xFF_AAAAAA,
    }) as i32
}

pub fn log(msg: &str) {
    use std::io::Write;
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(client_dir().join("lodestone.log"))
    {
        let _ = writeln!(f, "{msg}");
    }
}

unsafe extern "system" fn init(_: *mut c_void) -> u32 {
    log("--- lodestone client starting ---");
    // A panic inside the render hook would otherwise vanish into catch_unwind.
    std::panic::set_hook(Box::new(|info| {
        log(&format!("PANIC: {info}"));
    }));

    // The game loads GLFW early, but not necessarily before us.
    let mut glfw = std::ptr::null_mut();
    for _ in 0..600 {
        glfw = GetModuleHandleA(c"glfw.dll".as_ptr() as *const u8);
        if !glfw.is_null() {
            break;
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
    if glfw.is_null() {
        log("glfw.dll never appeared — giving up");
        return 1;
    }

    let Some(swap) = GetProcAddress(glfw, c"glfwSwapBuffers".as_ptr() as *const u8) else {
        log("glfw.dll does not export glfwSwapBuffers");
        return 1;
    };
    log(&format!("glfwSwapBuffers at {:p}", swap as *const c_void));

    let hooked = match hook::install(swap as *mut c_void, swap_buffers as *const c_void) {
        Ok(h) => {
            let _ = HOOK.set(h);
            log("hook installed");
            state::with(|s| s.status = "hooked".into());
            true
        }
        Err(e) => {
            log(&format!("hook failed: {e}"));
            state::with(|s| s.status = format!("hook failed: {e}"));
            false
        }
    };


    // Watchdog: the loader drops a marker file to ask us to leave, which is how
    // a rebuild gets the old copy out of the way without restarting the game.
    loop {
        std::thread::sleep(std::time::Duration::from_millis(250));
        if client_dir().join(UNLOAD_MARKER).exists() {
            if !hooked {
                log("unload marker seen; nothing was hooked");
                return 0;
            }
            // Ask the render thread to unwind the hooks in the right order,
            // and keep asking. Returning after one sighting used to strand a
            // copy for good: if the marker appeared while the game was not
            // drawing, the request was never acted on and there was no longer
            // a thread here to repeat it.
            state::with(|s| s.eject = true);
            if UNLOADING.load(Ordering::SeqCst) {
                return 0;
            }
        }
    }
}

const UNLOAD_MARKER: &str = "unload";

// ---------------------------------------------------------------------------
// per-frame
// ---------------------------------------------------------------------------

/// Everything that may only be touched from the render thread.
struct Render {
    overlay: overlay::Overlay,
    jni: Jni,
    mc: Option<Mc>,
    world: Option<World>,
    blocks: Option<blocks::Blocks>,
    interact: Option<Interact>,
    extras: Option<extras::Extras>,
    blips: Vec<extras::Blip>,
    last_chunk_rate: std::time::Instant,
    last_base_scan: std::time::Instant,
    saved: cheats::Saved,
    window: *mut c_void,
    hwnd: HWND,
    menu_was_open: bool,
    saved_cursor_mode: i32,
}

/// Only ever read or written inside `swap_buffers`, which the game calls from a
/// single thread.
static mut RENDER: Option<Render> = None;

unsafe extern "C" fn swap_buffers(window: *mut c_void) {
    IN_DETOUR.fetch_add(1, Ordering::SeqCst);
    if !UNLOADING.load(Ordering::SeqCst) {
        let n = FRAMES.fetch_add(1, Ordering::Relaxed);
        if n % 600 == 0 {
            log(&format!("frame {n}"));
        }
        // A panic must never unwind into the game's C frame.
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| frame(window)));
    }

    if let Some(h) = HOOK.get() {
        let original: unsafe extern "C" fn(*mut c_void) = std::mem::transmute(h.trampoline);
        original(window);
    }
    IN_DETOUR.fetch_sub(1, Ordering::SeqCst);
}

unsafe fn frame(window: *mut c_void) {
    let slot = &raw mut RENDER;
    if (*slot).is_none() {
        match setup(window) {
            Ok(r) => {
                *slot = Some(r);
                log("render state ready");
            }
            Err(e) => {
                log(&format!("setup failed: {e}"));
                state::with(|s| s.status = e.clone());
                // Try again next frame: the world may not exist yet.
                return;
            }
        }
    }
    let Some(r) = (*slot).as_mut() else { return };

    // Cursor: while the menu is open the game must release the mouse, and get
    // it back exactly as it was when the menu closes.
    let open = state::with(|s| s.menu_open).unwrap_or(false);
    if open != r.menu_was_open {
        if open {
            r.saved_cursor_mode = glfw::get_input_mode(r.window, glfw::CURSOR);
            glfw::set_input_mode(r.window, glfw::CURSOR, glfw::CURSOR_NORMAL);
        } else if r.saved_cursor_mode != 0 {
            glfw::set_input_mode(r.window, glfw::CURSOR, r.saved_cursor_mode);
        }
        r.menu_was_open = open;
    }

    // Modules, inside a local frame so nothing leaks.
    if let Some(mcx) = r.mc.as_ref() {
        if let Some(_guard) = r.jni.frame(256) {
            let cfg = state::with(|s| s.cfg.clone());
            if let Some(cfg) = cfg {
                let mut game = cheats::apply(
                    &r.jni,
                    mcx,
                    r.world.as_ref(),
                    r.blocks.as_ref(),
                    r.interact.as_ref(),
                    &cfg,
                    &mut r.saved,
                );
                // Blocks are scanned incrementally, so this only costs a slice
                // of the volume per frame.
                if let (Some(scan), true) = (r.blocks.as_mut(), game.in_world) {
                    if let Some(instance) = mcx.instance(&r.jni) {
                        if let Some(level) = mcx.level(&r.jni, instance) {
                            // Ores have to be swept; a slice per frame.
                            if cfg.esp.xray {
                                scan.step_ores(
                                    &r.jni,
                                    level,
                                    game.pos,
                                    cfg.esp.block_radius as i32,
                                    &cfg.esp.xray_selected,
                                    // Each position is a Java allocation and
                                    // three calls; this is a per-frame budget,
                                    // so keep it modest.
                                    1024,
                                );
                                game.blocks = scan.ores.clone();
                            }
                            // Block entities come from a list the client keeps,
                            // so this is cheap enough to redo periodically
                            // rather than continuously.
                            if cfg.esp.containers || cfg.esp.base_finder {
                                let due = r.last_base_scan.elapsed().as_secs_f32() > 1.0;
                                if due {
                                    r.last_base_scan = std::time::Instant::now();
                                    scan.scan_block_entities(
                                        &r.jni,
                                        level,
                                        game.pos,
                                        cfg.esp.base_chunk_radius as i32,
                                        cfg.esp.base_finder && !cfg.esp.containers,
                                    );
                                }
                                game.base_hits = scan.entities.clone();
                            }
                        }
                    }
                }
                // ---- the three 26.2 channels --------------------------
                if let (Some(ex), Some(instance)) =
                    (r.extras.as_ref(), mcx.instance(&r.jni))
                {
                    let player = mcx.player(&r.jni, instance);

                    // Claim a higher chunk absorption rate than we measured.
                    // Refreshed on a timer because the game recomputes it from
                    // its own measurements every batch.
                    if let (true, Some(player)) = (cfg.visuals.fast_chunks, player) {
                        if r.last_chunk_rate.elapsed().as_millis() > 250 {
                            r.last_chunk_rate = std::time::Instant::now();
                            ex.set_chunk_rate(&r.jni, player, cfg.visuals.fast_chunks_rate);
                        }
                    }

                    // Players the server tracks for the locator bar.
                    if let (true, Some(player)) = (cfg.esp.player_radar, player) {
                        ex.waypoints(&r.jni, player, &mut r.blips);
                        game.blips = r
                            .blips
                            .iter()
                            .map(|b| (b.x, b.y, b.z, b.coarse))
                            .collect();
                    } else {
                        r.blips.clear();
                    }

                    // Draw through the game's own renderer.
                    if cfg.esp.gizmo_esp && game.in_world {
                        let targets = &game.targets;
                        let blips = &game.blips;
                        ex.with_gizmos(&r.jni, instance, |pen| {
                            for t in targets.iter().take(128) {
                                pen.box_at(t.min, t.max, kind_colour(t.kind), true);
                            }
                            for (x, y, z, coarse) in blips.iter().take(64) {
                                let half = if *coarse { 8.0 } else { 0.4 };
                                let y = if y.is_nan() { game.pos.1 } else { *y };
                                pen.box_at(
                                    (x - half, y - 1.0, z - half),
                                    (x + half, y + 2.0, z + half),
                                    0xFF_FF78FFu32 as i32,
                                    true,
                                );
                            }
                        });
                    }
                }

                state::with(|s| s.game = game);
            }
        }
    }

    let (w, h) = glfw::framebuffer_size(r.window);
    if w > 0 && h > 0 {
        let fps = r.overlay.fps();
        state::with(|s| {
            s.game.fps = fps;
            r.overlay.draw(w, h, s);
        });
    }

    if state::with(|s| s.eject).unwrap_or(false) {
        log("unload requested");
        unload(r);
    }
}

unsafe fn setup(window: *mut c_void) -> Result<Render, String> {
    let hwnd = glfw::win32_window(window);
    if hwnd == 0 {
        return Err("glfwGetWin32Window returned null".into());
    }
    capture::set_window(hwnd as HWND);
    if !input::install(hwnd as HWND) {
        log("window procedure hook failed");
    }

    let overlay = overlay::Overlay::new()?;
    log("gl painter created");

    let vm = jni::java_vm().ok_or("no JVM in this process")?;
    let jni = Jni::current(vm).ok_or("this thread is not attached to the JVM")?;
    log("jni environment acquired");

    let mut world = None;
    let mut block_scan = None;
    let mut interact = None;
    let mut extra = None;
    let mc = match Mc::resolve(&jni) {
        Ok(m) => {
            if m.missing.is_empty() {
                log("all minecraft names resolved");
            } else {
                log(&format!("unresolved: {}", m.missing.join(", ")));
            }
            let mut missing = m.missing.clone();
            world = World::resolve(&jni, &m, &mut missing);
            block_scan = blocks::Blocks::resolve(&jni, &mut missing);
            interact = Interact::resolve(&jni, &m, &mut missing);
            extra = Some(extras::Extras::resolve(&jni, &m, &mut missing));
            if interact.is_none() {
                log("interaction unavailable: placing and inventory are off");
            }
            if block_scan.is_none() {
                log("block scanning unavailable: x-ray and container esp are off");
            }
            if world.is_none() {
                log("world bindings unavailable: combat and ESP are off");
            }
            if !missing.is_empty() {
                log(&format!("unresolved: {}", missing.join(", ")));
            }
            state::with(|s| {
                s.missing = missing;
                s.status = "ready".into();
            });
            Some(m)
        }
        Err(e) => {
            log(&format!("minecraft bindings failed: {e}"));
            state::with(|s| s.status = format!("bindings: {e}"));
            None
        }
    };

    // Pick up settings from the last session, if there are any.
    match config::load_into_shared() {
        Ok(true) => log("config loaded"),
        Ok(false) => {}
        Err(e) => log(&format!("config load failed: {e}")),
    }

    Ok(Render {
        overlay,
        jni,
        mc,
        world,
        blocks: block_scan,
        interact,
        extras: extra,
        blips: Vec::new(),
        last_chunk_rate: std::time::Instant::now(),
        last_base_scan: std::time::Instant::now()
            - std::time::Duration::from_secs(5),
        saved: cheats::Saved::default(),
        window,
        hwnd: hwnd as HWND,
        menu_was_open: false,
        saved_cursor_mode: 0,
    })
}

/// Stop the client: hooks out, game state restored, nothing drawn.
///
/// It deliberately does **not** unmap the module. A DLL that patched a window
/// procedure, installed a panic hook and left Rust thread-locals behind cannot
/// prove that nothing in the process still points into its code, and unmapping
/// it while one pointer survives crashes the game — which is exactly what an
/// earlier version of this did. A few megabytes of idle address space is a
/// much better trade. Restart the game to be rid of it entirely.
unsafe fn unload(r: &mut Render) {
    UNLOADING.store(true, Ordering::SeqCst);

    if let Some(mcx) = r.mc.as_ref() {
        if let Some(_guard) = r.jni.frame(32) {
            cheats::restore(&r.jni, mcx, r.world.as_ref(), &mut r.saved);
        }
    }
    capture::apply(false);
    let restored = input::remove(r.hwnd);
    if r.saved_cursor_mode != 0 {
        glfw::set_input_mode(r.window, glfw::CURSOR, r.saved_cursor_mode);
    }
    if let Some(h) = HOOK.get() {
        h.remove();
    }
    state::with(|s| {
        s.eject = false;
        s.menu_open = false;
        s.status = "unloaded".into();
    });
    log(&format!(
        "unloaded: hooks removed, settings restored, window procedure {} — module stays resident",
        if restored { "restored" } else { "kept (something subclassed after us)" }
    ));
}

// ---------------------------------------------------------------------------
// the few GLFW entry points we need
// ---------------------------------------------------------------------------

mod glfw {
    use std::ffi::c_void;

    use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};

    pub const CURSOR: i32 = 0x0003_3001;
    pub const CURSOR_NORMAL: i32 = 0x0003_4001;

    unsafe fn proc(name: &[u8]) -> Option<unsafe extern "system" fn() -> isize> {
        let glfw = GetModuleHandleA(c"glfw.dll".as_ptr() as *const u8);
        if glfw.is_null() {
            return None;
        }
        GetProcAddress(glfw, name.as_ptr())
    }

    pub unsafe fn win32_window(window: *mut c_void) -> isize {
        match proc(b"glfwGetWin32Window\0") {
            Some(f) => {
                let f: unsafe extern "C" fn(*mut c_void) -> isize = std::mem::transmute(f);
                f(window)
            }
            None => 0,
        }
    }

    pub unsafe fn framebuffer_size(window: *mut c_void) -> (i32, i32) {
        match proc(b"glfwGetFramebufferSize\0") {
            Some(f) => {
                let f: unsafe extern "C" fn(*mut c_void, *mut i32, *mut i32) =
                    std::mem::transmute(f);
                let (mut w, mut h) = (0i32, 0i32);
                f(window, &mut w, &mut h);
                (w, h)
            }
            None => (0, 0),
        }
    }

    pub unsafe fn get_input_mode(window: *mut c_void, mode: i32) -> i32 {
        match proc(b"glfwGetInputMode\0") {
            Some(f) => {
                let f: unsafe extern "C" fn(*mut c_void, i32) -> i32 = std::mem::transmute(f);
                f(window, mode)
            }
            None => 0,
        }
    }

    pub unsafe fn set_input_mode(window: *mut c_void, mode: i32, value: i32) {
        if let Some(f) = proc(b"glfwSetInputMode\0") {
            let f: unsafe extern "C" fn(*mut c_void, i32, i32) = std::mem::transmute(f);
            f(window, mode, value);
        }
    }
}