Sign in Sign up
kretrod/lodestone Public
Branches
master
431 lines (402 loc) · 18.3 KB Raw
//! Lodestone menu — the front end.
//!
//! Runs in its own process and its own window. The game is never told it is
//! there: no injected DLL, no agent, no mod, no hooks in the render loop. A
//! worker thread re-resolves the player from `Minecraft.instance` twenty times
//! a second and writes the fields the switches below ask for.

#![cfg(windows)]
#![windows_subsystem = "windows"]

use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use eframe::egui;
use lodestone::game::{Cheats, Game, Snapshot};

/// Polled by the worker so hotkeys work while the game has focus.
mod hotkey {
    use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;

    pub const F6: i32 = 0x75;
    pub const F7: i32 = 0x76;
    pub const F8: i32 = 0x77;
    pub const F9: i32 = 0x78;

    /// True while the key is physically down.
    pub fn down(vk: i32) -> bool {
        // SAFETY: GetAsyncKeyState takes a virtual-key code and has no
        // preconditions; the high bit means "currently down".
        (unsafe { GetAsyncKeyState(vk) } as u16 & 0x8000) != 0
    }
}

#[derive(Default)]
struct Shared {
    cheats: Cheats,
    snap: Snapshot,
    status: String,
    connected: bool,
}

fn main() -> eframe::Result<()> {
    let shared = Arc::new(Mutex::new(Shared {
        cheats: Cheats::default(),
        status: "looking for Minecraft…".into(),
        ..Default::default()
    }));

    spawn_engine(shared.clone());

    let opts = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([460.0, 700.0])
            .with_min_inner_size([420.0, 480.0])
            .with_title("Lodestone"),
        ..Default::default()
    };
    eframe::run_native("Lodestone", opts, Box::new(|cc| Ok(Box::new(App::new(cc, shared)))))
}

/// The engine thread: attach, then tick forever. Everything that touches the
/// game happens here, so the UI never blocks on a slow read.
fn spawn_engine(shared: Arc<Mutex<Shared>>) {
    std::thread::spawn(move || {
        let mut game: Option<Game> = None;
        let mut prev_keys = [false; 4];
        loop {
            if game.is_none() {
                match Game::attach(None) {
                    Ok(g) => {
                        let pid = g.pid();
                        game = Some(g);
                        let mut s = shared.lock().unwrap();
                        s.connected = true;
                        s.status = format!("attached to javaw.exe ({pid})");
                    }
                    Err(e) => {
                        let mut s = shared.lock().unwrap();
                        s.connected = false;
                        s.status = e;
                        drop(s);
                        std::thread::sleep(Duration::from_millis(1000));
                        continue;
                    }
                }
            }

            // Hotkeys, edge-triggered so holding a key toggles once.
            let keys = [
                hotkey::down(hotkey::F6),
                hotkey::down(hotkey::F7),
                hotkey::down(hotkey::F8),
                hotkey::down(hotkey::F9),
            ];
            {
                let mut s = shared.lock().unwrap();
                if keys[0] && !prev_keys[0] {
                    s.cheats.fly = !s.cheats.fly;
                }
                if keys[1] && !prev_keys[1] {
                    s.cheats.noclip = !s.cheats.noclip;
                }
                if keys[2] && !prev_keys[2] {
                    s.cheats.speed = !s.cheats.speed;
                }
                if keys[3] && !prev_keys[3] {
                    s.cheats.god = !s.cheats.god;
                }
            }
            prev_keys = keys;

            // Copy the wanted state out, tick, copy results back. The lock is
            // never held across the memory reads.
            let mut cheats = { shared.lock().unwrap().cheats.clone() };
            let g = game.as_mut().unwrap();
            if !g.alive() {
                // The game exited (or was restarted): drop the handle and look
                // for it again on the next pass.
                game = None;
                let mut s = shared.lock().unwrap();
                s.connected = false;
                s.snap = Snapshot::default();
                s.status = "lost the game process — waiting".into();
                drop(s);
                std::thread::sleep(Duration::from_millis(500));
                continue;
            }
            let snap = g.tick(&mut cheats);
            {
                let mut s = shared.lock().unwrap();
                // One-shot requests were consumed by the engine; clear them.
                s.cheats.teleport = None;
                s.cheats.set_time = None;
                s.cheats.time_rate = None;
                s.snap = snap;
            }

            std::thread::sleep(Duration::from_millis(50));
        }
    });
}

struct App {
    shared: Arc<Mutex<Shared>>,
    tp: [f64; 3],
    time_input: i64,
    waypoints: Vec<(String, [f64; 3])>,
    wp_name: String,
    last_repaint: Instant,
}

impl App {
    fn new(cc: &eframe::CreationContext<'_>, shared: Arc<Mutex<Shared>>) -> Self {
        let mut style = (*cc.egui_ctx.style()).clone();
        style.visuals = egui::Visuals::dark();
        style.visuals.panel_fill = egui::Color32::from_rgb(16, 17, 21);
        style.visuals.window_fill = egui::Color32::from_rgb(16, 17, 21);
        style.visuals.widgets.noninteractive.bg_stroke.color = egui::Color32::from_rgb(38, 40, 48);
        style.spacing.item_spacing = egui::vec2(8.0, 7.0);
        cc.egui_ctx.set_style(style);
        Self {
            shared,
            tp: [0.0; 3],
            time_input: 1000,
            waypoints: Vec::new(),
            wp_name: String::new(),
            last_repaint: Instant::now(),
        }
    }
}

const ACCENT: egui::Color32 = egui::Color32::from_rgb(120, 200, 140);
const DIM: egui::Color32 = egui::Color32::from_rgb(128, 133, 145);

fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
    ui.add_space(6.0);
    ui.label(egui::RichText::new(title.to_uppercase()).color(DIM).size(11.0).strong());
    egui::Frame::none()
        .fill(egui::Color32::from_rgb(23, 25, 30))
        .rounding(6.0)
        .inner_margin(egui::Margin::symmetric(10.0, 9.0))
        .show(ui, add);
}

fn toggle(ui: &mut egui::Ui, on: &mut bool, label: &str, hint: &str) {
    ui.horizontal(|ui| {
        ui.checkbox(on, egui::RichText::new(label).size(13.0));
        if !hint.is_empty() {
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                ui.label(egui::RichText::new(hint).color(DIM).size(11.0));
            });
        }
    });
}

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // The readout is live, so keep painting.
        if self.last_repaint.elapsed() > Duration::from_millis(80) {
            self.last_repaint = Instant::now();
        }
        ctx.request_repaint_after(Duration::from_millis(80));

        let (snap, status, connected) = {
            let s = self.shared.lock().unwrap();
            (s.snap.clone(), s.status.clone(), s.connected)
        };

        egui::CentralPanel::default().show(ctx, |ui| {
            egui::ScrollArea::vertical().show(ui, |ui| {
                // ---- header ------------------------------------------------
                ui.horizontal(|ui| {
                    ui.label(egui::RichText::new("LODESTONE").size(18.0).strong().color(ACCENT));
                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                        let (txt, col) = if !connected {
                            ("detached", egui::Color32::from_rgb(200, 90, 90))
                        } else if snap.single_player {
                            ("single player", ACCENT)
                        } else if snap.in_world {
                            ("multiplayer — read only", egui::Color32::from_rgb(220, 170, 90))
                        } else {
                            ("no world", DIM)
                        };
                        ui.label(egui::RichText::new(txt).color(col).size(12.0).strong());
                    });
                });
                ui.label(egui::RichText::new(status).color(DIM).size(11.0));
                ui.add_space(4.0);

                // ---- live readout ------------------------------------------
                section(ui, "state", |ui| {
                    let mono = |ui: &mut egui::Ui, k: &str, v: String| {
                        ui.horizontal(|ui| {
                            ui.label(egui::RichText::new(k).color(DIM).size(11.0));
                            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                                ui.label(egui::RichText::new(v).monospace().size(12.0));
                            });
                        });
                    };
                    mono(ui, "position", format!("{:.2}  {:.2}  {:.2}", snap.pos.0, snap.pos.1, snap.pos.2));
                    mono(ui, "facing", format!("yaw {:.1}  pitch {:.1}", snap.yaw, snap.pitch));
                    mono(ui, "on ground", format!("{}", snap.on_ground));
                    mono(ui, "flying", format!("{}", snap.flying));
                    mono(ui, "fall distance", format!("{:.2}", snap.fall_distance));
                    mono(
                        ui,
                        "world time",
                        format!(
                            "{} (day {}, {}{})",
                            snap.day_time,
                            snap.day_time / 24000,
                            snap.day_time.rem_euclid(24000),
                            if snap.time_paused { ", paused" } else { "" }
                        ),
                    );
                    if !snap.note.is_empty() {
                        ui.add_space(2.0);
                        ui.label(egui::RichText::new(&snap.note).color(DIM).size(10.0));
                    }
                });

                let mut s = self.shared.lock().unwrap();
                let writable = snap.single_player;
                ui.add_enabled_ui(writable, |ui| {
                    // ---- movement ------------------------------------------
                    section(ui, "movement", |ui| {
                        toggle(ui, &mut s.cheats.fly, "Flight", "F6");
                        ui.add(
                            egui::Slider::new(&mut s.cheats.fly_speed, 0.01..=1.0)
                                .text("fly speed")
                                .fixed_decimals(2),
                        );
                        toggle(ui, &mut s.cheats.speed, "Walk speed", "F8");
                        ui.add(
                            egui::Slider::new(&mut s.cheats.walk_speed, 0.05..=1.0)
                                .text("walk speed")
                                .fixed_decimals(2),
                        );
                        toggle(ui, &mut s.cheats.noclip, "Noclip", "F7");
                        toggle(ui, &mut s.cheats.nofall, "No fall damage", "");
                    });

                    // ---- player --------------------------------------------
                    section(ui, "player", |ui| {
                        toggle(ui, &mut s.cheats.god, "God mode", "F9");
                        toggle(ui, &mut s.cheats.instabuild, "Instant break", "");
                        toggle(ui, &mut s.cheats.fast_break, "No mining delay", "");
                        toggle(ui, &mut s.cheats.no_hurt_cam, "No hurt camera", "");
                    });

                    // ---- world ---------------------------------------------
                    section(ui, "world", |ui| {
                        ui.add_enabled_ui(snap.has_clock, |ui| {
                            toggle(ui, &mut s.cheats.freeze_time, "Freeze time", "");
                            ui.horizontal(|ui| {
                                ui.add(
                                    egui::DragValue::new(&mut self.time_input)
                                        .speed(50.0)
                                        .range(0..=1_000_000),
                                );
                                if ui.button("set").clicked() {
                                    s.cheats.set_time = Some(self.time_input);
                                }
                                // A day is 24000 ticks; noon and midnight sit
                                // at 6000 and 18000 into it.
                                let day = snap.day_time - snap.day_time.rem_euclid(24000);
                                if ui.button("day").clicked() {
                                    s.cheats.set_time = Some(day + 1000);
                                }
                                if ui.button("noon").clicked() {
                                    s.cheats.set_time = Some(day + 6000);
                                }
                                if ui.button("night").clicked() {
                                    s.cheats.set_time = Some(day + 14000);
                                }
                            });
                            let mut rate = snap.time_rate.max(0.0);
                            if ui
                                .add(
                                    egui::Slider::new(&mut rate, 0.0..=20.0)
                                        .text("time speed")
                                        .fixed_decimals(1),
                                )
                                .changed()
                            {
                                s.cheats.time_rate = Some(rate);
                            }
                        });
                        if !snap.has_clock {
                            ui.label(
                                egui::RichText::new("clock unavailable")
                                    .color(DIM)
                                    .size(10.0),
                            );
                        }
                    });

                    // ---- teleport ------------------------------------------
                    section(ui, "teleport", |ui| {
                        ui.horizontal(|ui| {
                            for (i, label) in ["x", "y", "z"].iter().enumerate() {
                                ui.label(egui::RichText::new(*label).color(DIM).size(11.0));
                                ui.add(egui::DragValue::new(&mut self.tp[i]).speed(0.5));
                            }
                        });
                        ui.horizontal(|ui| {
                            if ui.button("teleport").clicked() {
                                s.cheats.teleport = Some((self.tp[0], self.tp[1], self.tp[2]));
                            }
                            if ui.button("here").clicked() {
                                self.tp = [snap.pos.0, snap.pos.1, snap.pos.2];
                            }
                            if ui.button("up 20").clicked() {
                                s.cheats.teleport =
                                    Some((snap.pos.0, snap.pos.1 + 20.0, snap.pos.2));
                            }
                        });
                        ui.add_space(4.0);
                        ui.horizontal(|ui| {
                            ui.add(
                                egui::TextEdit::singleline(&mut self.wp_name)
                                    .hint_text("waypoint name")
                                    .desired_width(140.0),
                            );
                            if ui.button("save here").clicked() {
                                let name = if self.wp_name.is_empty() {
                                    format!("wp{}", self.waypoints.len() + 1)
                                } else {
                                    self.wp_name.clone()
                                };
                                self.waypoints
                                    .push((name, [snap.pos.0, snap.pos.1, snap.pos.2]));
                                self.wp_name.clear();
                            }
                        });
                        let mut remove = None;
                        for (i, (name, p)) in self.waypoints.iter().enumerate() {
                            ui.horizontal(|ui| {
                                if ui.button(egui::RichText::new(name).size(12.0)).clicked() {
                                    s.cheats.teleport = Some((p[0], p[1], p[2]));
                                }
                                ui.label(
                                    egui::RichText::new(format!(
                                        "{:.0} {:.0} {:.0}",
                                        p[0], p[1], p[2]
                                    ))
                                    .color(DIM)
                                    .size(11.0)
                                    .monospace(),
                                );
                                ui.with_layout(
                                    egui::Layout::right_to_left(egui::Align::Center),
                                    |ui| {
                                        if ui.small_button("x").clicked() {
                                            remove = Some(i);
                                        }
                                    },
                                );
                            });
                        }
                        if let Some(i) = remove {
                            self.waypoints.remove(i);
                        }
                    });
                });

                if !writable && snap.in_world {
                    ui.add_space(6.0);
                    ui.label(
                        egui::RichText::new(
                            "Writes are disabled: this is not a single-player world.",
                        )
                        .color(egui::Color32::from_rgb(220, 170, 90))
                        .size(11.0),
                    );
                }

                ui.add_space(10.0);
                ui.label(
                    egui::RichText::new(
                        "External: no code runs inside the game. Every switch is a \
                         ReadProcessMemory/WriteProcessMemory on fields resolved by name \
                         through HotSpot's own VMStructs table.",
                    )
                    .color(DIM)
                    .size(10.0),
                );
            });
        });
    }
}