diff --git a/.gitignore b/.gitignore index a2d8114..b9cff19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.idea /target -/docs_internal \ No newline at end of file +/docs_internal +mcp-reimagined diff --git a/CLAUDE.md b/CLAUDE.md index 1693dae..40e2974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co DarkClient is a Minecraft (Java Edition) modification framework written in Rust. It injects native libraries into a running Minecraft JVM and drives the game through JNI. One build supports both **obfuscated** Minecraft (≤ 1.21.11, via bundled Mojmap mappings) and **unobfuscated** Minecraft (26.1+, via runtime JNI reflection) — see the mapping system below. -It is a Cargo workspace of four crates — `protocol`, `injector`, `agent_loader`, `client` — plus an `xtask` helper. +It is a Cargo workspace of five crates — `protocol`, `injector`, `agent_loader`, `client`, `mapping_derive` — plus an `xtask` helper. ## Build & Common Commands @@ -32,6 +32,7 @@ python conversion.py # regenerate mappings.json (needs the `reques - **`injector/`** — standalone binary. A redesigned egui GUI (`gui/`); `--tui` for a crossterm TUI; `--list` / `--inject ` headless modes. Discovers Minecraft processes, injects `agent_loader`, then drives the client. UI-agnostic core in `app.rs`; injection orchestration in `inject.rs`; the platform layer (`platform/`) is an `AgentInjector` trait with `linux` / `windows` / `macos` implementations. - **`agent_loader/`** — `cdylib` injected first. A `#[ctor]` runs on load. Split into focused modules: `logging`, `jvm` (discovery + health monitor), `server` (TCP accept loop), `command` (dispatch), `library` (client lifecycle), `platform` (signal handlers). - **`client/`** — `cdylib`, the actual mod framework. JNI-driven game interaction, OpenGL overlay, module system. +- **`mapping_derive/`** — `proc-macro` crate. Provides `#[derive(MappedObject)]` for the JVM-object wrappers in `client` (see *Game wrappers* below). - **`xtask/`** — workspace task runner; `cargo xtask e2e` is the manual Tier-3 test. ## Injection & Hot-Reload Flow @@ -40,7 +41,7 @@ This is the core control flow and spans `injector`, `protocol`, `agent_loader`, 1. `injector` injects `libagent_loader.so`/`.dll` into the JVM process — ptrace (`ptrace-inject`) on Linux, `dll-syringe` on Windows. 2. `agent_loader`'s `#[ctor]` `agent_onload()` starts a TCP server on `protocol::SOCKET_ADDR` (**`127.0.0.1:7878`** — defined once, in `protocol`). -3. `injector` connects and sends a `protocol::Command::Reload()`. +3. `injector` connects and sends a `protocol::Command::Reload { library, config_dir }` — the absolute libclient path plus the injector's own working directory. The agent loader exports `config_dir` as the `DARK_CONFIG_DIR` env var so the client knows where to keep its config. 4. `agent_loader`'s `library` module copies the library to a uniquely named temp file (avoids file locks), `dlopen`s it, and calls the exported `initialize_client`. 5. Re-injecting repeats step 3 → `library::reload` cleans up and drops the old library (calling `cleanup_client`) before loading the new one. This is the hot-reload path. @@ -52,11 +53,13 @@ This is the core control flow and spans `injector`, `protocol`, `agent_loader`, **Rendering & ticking** (`graphic/hook.rs`): `install_hooks` hooks the buffer-swap function (`glfwSwapBuffers` / `wglSwapBuffers`, via `ilhook`) so `on_frame` runs every frame — it renders the egui overlay (`ui_engine.rs`) and calls `check_tick`. `check_tick` compares the player's tick count to detect new game ticks and calls `client().modules.tick()`. Tick logic runs on the render thread, not a Minecraft thread. Per-platform GL / hook details live behind `graphic/platform/` (`gl_proc_address`, `open_glfw_library`, `frame_hook_targets`). -**Input** (`graphic/input.rs`): swaps GLFW key/mouse/cursor callbacks. **Right Shift** (key `344`) toggles the GUI; while the GUI is open, input events are consumed instead of forwarded to Minecraft. Module keybinds toggle modules on key press. +**Input** (`graphic/input.rs`): swaps GLFW key/mouse/cursor/scroll callbacks. **Right Shift** (key `344`) toggles the GUI, **Esc** closes it; while the GUI is open, input events (including the scroll wheel, which is forwarded to egui instead) are consumed rather than passed to Minecraft. Module keybinds toggle modules on key press. -**Module system** (`module/`): implement the `Module` trait (`on_start`/`on_stop`/`on_tick`, all returning `anyhow::Result<()>`) plus `ModuleData` accessors. Register new modules in `register_modules()` in `client/src/lib.rs`. Modules carry typed `ModuleSetting`s (Toggle/Slider/Choice/Color). The `ModuleRegistry` (`module/registry.rs`) is backed by a `DashMap`; reach it through `client().modules`. +**Module system** (`module/`): implement the `Module` trait (`on_start`/`on_stop`/`on_tick`, all returning `anyhow::Result<()>`; optional `handle_packet` — see *Packet layer* below) plus `ModuleData` accessors. Register new modules in `register_modules()` in `client/src/lib.rs`. Modules carry typed `ModuleSetting`s (Toggle/Slider/Choice/Color). Each module has a stable identity — the `ModuleId` enum — and is registered/looked up by it (never by a name string). The `ModuleRegistry` (`module/registry.rs`) is a `DashMap`; reach it through `client().modules`. `register()` also snapshots each module's factory defaults, which `ModuleRegistry::reset_settings()` (the GUI's "Reset Settings" button) restores. -**Game wrappers** (`mapping/client/`, `mapping/entity/`): `Minecraft` holds only what exists from the main menu onward — the `getInstance()` handle and the `Window`. The world-scoped objects are lazy accessors — `player()`, `world()`, `game_mode()` return `Result>`, where `Ok(None)` means "not in a world". This lets the client be injected from the main menu; world-dependent modules early-return when `None`. +**Config persistence** (`config.rs`): each module's keybind, setting values and enabled state — plus the GUI layout (category-panel positions and which modules are expanded) — are written to `dark_client_config.json` so they survive a re-injection. The file lives in the **injector's working directory** (passed via `DARK_CONFIG_DIR`) — deliberately *not* in `.minecraft`, to leave no trace in the game directory; it falls back to the process working directory if the variable is unset. `config::save()` runs whenever the user changes something (GUI close, module toggle, "Reset Settings", and before a Panic unload); `config::load()` runs once, right after `register_modules()`, and re-applies the saved state — re-enabling modules that were left on. + +**Game wrappers** (`mapping/client/`, `mapping/entity/`): `Minecraft` holds only what exists from the main menu onward — the `getInstance()` handle and the `Window`. The world-scoped objects are lazy accessors — `player()`, `world()`, `game_mode()` return `Result>`, where `Ok(None)` means "not in a world". This lets the client be injected from the main menu; world-dependent modules early-return when `None`. Each wrapper of a live JVM object derives `MappedObject` (`#[derive(MappedObject)]`, from the `mapping_derive` crate), which gives it `jni_ref()`, `class_type()`, and the `call_method`/`get_field`/`set_field`/`instance_of`/`is_same`/`equals` helpers. Immutable value types (`Vec3`, `BlockPos`, …) are instead read once into plain Rust fields — a value-snapshot, no JNI handle retained. **Mapping system** (`mapping/`): bridges deobfuscated (Mojmap) names — what `MinecraftClassType` and the rest of the code use — to whatever the running JVM actually exposes. `Mapping::new()` auto-detects the build and picks one of two modes: @@ -67,6 +70,8 @@ Both modes share one code path: `DashMap`s (`classes`, `class_handles`) populate **Mod loaders (`mapping/loader.rs`)**: Fabric and Forge/NeoForge run the game in an isolated class loader, 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 is not supported. +**Packet layer** (`net/`): intercepts the Minecraft↔server connection — used by modules that must read or rewrite packets (NoFall, Velocity). A thin Netty bridge handler, `DarkChannelHandler` (Java source + committed `.class` in `client/java/`, bytecode embedded via `include_bytes!`), is `DefineClass`'d into the game class loader, its `native` methods bound with `RegisterNatives`, and an instance inserted into the live `Connection`'s Netty pipeline *before* Minecraft's own `packet_handler` (so it sees inbound and outbound packets before the game does) — pure JNI, no JVMTI. A class name can be `DefineClass`'d only once per loader, so on hot-reload the existing class is reused and only its natives are rebound to the new library. `net::ensure_installed` (polled each tick) keeps it installed; `net::teardown` removes it before unload. The handler calls back into `net::dispatch`, which wraps the JVM packet into a `Packet` value-snapshot (`net/packet/`) and offers it to every enabled module's `Module::handle_packet`. A module may **mutate** the snapshot in place — the dispatch then rebuilds a fresh JVM object that replaces the original — or return `PacketAction::Cancel` to **drop** the packet entirely (the callback returns `null`, which the Java handler discards; works for inbound and outbound alike). Minecraft packets are strictly directional, so `Packet::from_outbound` (`Serverbound*`) and `from_inbound` (`Clientbound*`) only probe their own variants; unhandled types return `None` and pass straight through. Packet class names live in the `MinecraftClassType` enum (`mapping/class_type.rs`), not as string literals — each `net/packet/` module exposes a `CLASS_TYPE` constant. All packet/Netty JNI uses explicit descriptors — no reflection. To support a new packet, add its class(es) to `MinecraftClassType`, a value-snapshot module under `net/packet/`, and a `Packet` variant named after the Java class. + **Lifecycle safety**: the global `RUNNING: AtomicBool` gates `on_frame`. A panic hook in `initialize_client` calls `cleanup_client` so input/render hooks are always uninstalled and GLFW callbacks restored, even on panic. ## Mappings @@ -83,6 +88,10 @@ Three tiers (`cargo test --workspace` runs T1 + T2): ## Logs -- `injector` → `app.log` (in its working directory) -- `agent_loader` → `agent_loader.log` -- `client` → `dark_client.log` (in `.minecraft`) +All three log files — and the client config — are written to the **injector's +working directory** (the agent loader and client receive it via the +`DARK_CONFIG_DIR` env var), so nothing is left in `.minecraft`: + +- `injector` → `app.log` +- `agent_loader` → `agent_loader.log` (set up lazily, on the first command, once the directory is known) +- `client` → `dark_client.log` diff --git a/Cargo.lock b/Cargo.lock index be84bce..75f7d47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,6 +672,7 @@ dependencies = [ "libc", "libloading 0.9.0", "log", + "mapping_derive", "serde", "serde_json", "simplelog", @@ -1845,6 +1846,14 @@ dependencies = [ "libc", ] +[[package]] +name = "mapping_derive" +version = "0.1.0" +dependencies = [ + "quote", + "syn 2.0.87", +] + [[package]] name = "memchr" version = "2.7.4" diff --git a/Cargo.toml b/Cargo.toml index 84dfb45..34d7c47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "client", "agent_loader", "xtask", + "mapping_derive", ] [workspace.package] @@ -37,3 +38,5 @@ dashmap = "6.1" sysinfo = "0.37.2" crossterm = "0.29" ctor = "0.2.8" +syn = "2" +quote = "1" diff --git a/agent_loader/src/command.rs b/agent_loader/src/command.rs index 086e9c5..3773792 100644 --- a/agent_loader/src/command.rs +++ b/agent_loader/src/command.rs @@ -7,7 +7,7 @@ use std::time::Duration; use log::{error, info}; use protocol::Command; -use crate::library; +use crate::{library, logging}; /// Maximum time spent waiting for a command line before giving up. const READ_TIMEOUT: Duration = Duration::from_secs(5); @@ -25,9 +25,20 @@ pub fn handle_connection(stream: TcpStream) { } match Command::decode(&line) { - Ok(Command::Reload(path)) => { - info!("reload command received: {}", path.display()); - if let Err(e) = library::reload(&path) { + Ok(Command::Reload { + library, + config_dir, + }) => { + // The injector's working directory is where both this agent and + // the client keep their files — set up logging there (keeping + // `.minecraft` clean) and hand it to the client, loaded into this + // same process, through the environment. + if !config_dir.as_os_str().is_empty() { + logging::init(&config_dir); + std::env::set_var("DARK_CONFIG_DIR", &config_dir); + } + info!("reload command received: {}", library.display()); + if let Err(e) = library::reload(&library) { error!("reload failed: {e}"); } } diff --git a/agent_loader/src/lib.rs b/agent_loader/src/lib.rs index f282b27..f987ac1 100644 --- a/agent_loader/src/lib.rs +++ b/agent_loader/src/lib.rs @@ -26,7 +26,8 @@ pub(crate) fn is_running() -> bool { /// Runs automatically when the agent library is loaded into the JVM process. #[ctor] fn agent_onload() { - logging::init(); + // File logging is set up once the first command reveals where to write it + // (the injector's directory) — see `command::handle_connection`. info!("agent loader initialized"); platform::install_signal_handlers(); diff --git a/agent_loader/src/logging.rs b/agent_loader/src/logging.rs index 059b6ea..1d0fa64 100644 --- a/agent_loader/src/logging.rs +++ b/agent_loader/src/logging.rs @@ -1,14 +1,22 @@ //! Agent logger setup. use log::LevelFilter; +use std::path::Path; +use std::sync::Once; -/// Path of the agent's log file, created in the JVM's working directory. +/// Name of the agent's log file. const LOG_FILE: &str = "agent_loader.log"; -/// Initializes file logging. Never panics — a logging failure must not stop -/// the agent from loading. -pub fn init() { - if let Err(e) = protocol::init_file_logger(LOG_FILE, LevelFilter::Debug) { - eprintln!("[agent_loader] file logging disabled: {e}"); - } +static INIT: Once = Once::new(); + +/// Initializes file logging in `dir` — the injector's working directory, so +/// nothing is written into `.minecraft`. Runs at most once (later calls are +/// no-ops) and never panics — a logging failure must not stop the agent. +pub fn init(dir: &Path) { + INIT.call_once(|| { + let path = dir.join(LOG_FILE); + if let Err(e) = protocol::init_file_logger(&path, LevelFilter::Debug) { + eprintln!("[agent_loader] file logging disabled: {e}"); + } + }); } diff --git a/client/Cargo.toml b/client/Cargo.toml index 9a056df..2528a40 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -23,6 +23,7 @@ serde_json = "1.0.135" libloading = "0.9.0" ilhook = "2.3.0" lazy_static = "1.4.0" +mapping_derive = { path = "../mapping_derive" } [dev-dependencies] # The `invocation` feature provides `JavaVM::new`, used by the in-process diff --git a/client/java/DarkChannelHandler.class b/client/java/DarkChannelHandler.class new file mode 100644 index 0000000..f179715 Binary files /dev/null and b/client/java/DarkChannelHandler.class differ diff --git a/client/java/DarkChannelHandler.java b/client/java/DarkChannelHandler.java new file mode 100644 index 0000000..4b3ce2e --- /dev/null +++ b/client/java/DarkChannelHandler.java @@ -0,0 +1,50 @@ +/** + * Netty pipeline handler injected into Minecraft's connection by DarkClient. + * + * Thin bridge: every outbound / inbound packet is handed to native (Rust) code + * via {@link #onOutbound} / {@link #onInbound}, which returns the object to + * forward — the same one, a replacement, or {@code null} to drop it. All + * decision logic lives in Rust. + * + * Compiled against the stub Netty types in {@code stub/} (the real Netty + * classes are resolved at runtime from Minecraft's class loader). The compiled + * {@code DarkChannelHandler.class} is committed and embedded in the client + * library. To rebuild after editing: + * + * javac --release 17 -d stub/io/netty/channel/*.java DarkChannelHandler.java + * cp /DarkChannelHandler.class . + */ +public class DarkChannelHandler extends io.netty.channel.ChannelDuplexHandler { + + private static native Object onOutbound(Object packet); + + private static native Object onInbound(Object packet); + + @Override + public void write(io.netty.channel.ChannelHandlerContext ctx, Object msg, + io.netty.channel.ChannelPromise promise) throws Exception { + Object result; + try { + result = onOutbound(msg); + } catch (Throwable ignored) { + result = msg; + } + if (result != null) { + ctx.write(result, promise); + } + } + + @Override + public void channelRead(io.netty.channel.ChannelHandlerContext ctx, Object msg) + throws Exception { + Object result; + try { + result = onInbound(msg); + } catch (Throwable ignored) { + result = msg; + } + if (result != null) { + ctx.fireChannelRead(result); + } + } +} diff --git a/client/java/stub/io/netty/channel/ChannelDuplexHandler.java b/client/java/stub/io/netty/channel/ChannelDuplexHandler.java new file mode 100644 index 0000000..e459f02 --- /dev/null +++ b/client/java/stub/io/netty/channel/ChannelDuplexHandler.java @@ -0,0 +1,11 @@ +// Compile-time stub of io.netty.channel.ChannelDuplexHandler — only the +// methods DarkChannelHandler overrides. Signatures must match real Netty +// exactly, since the runtime links against the real class. +package io.netty.channel; + +public class ChannelDuplexHandler { + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) + throws Exception {} + + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {} +} diff --git a/client/java/stub/io/netty/channel/ChannelFuture.java b/client/java/stub/io/netty/channel/ChannelFuture.java new file mode 100644 index 0000000..ac480bf --- /dev/null +++ b/client/java/stub/io/netty/channel/ChannelFuture.java @@ -0,0 +1,5 @@ +// Compile-time stub of io.netty.channel.ChannelFuture. +// The real Netty class is resolved at runtime from Minecraft's class loader. +package io.netty.channel; + +public interface ChannelFuture {} diff --git a/client/java/stub/io/netty/channel/ChannelHandlerContext.java b/client/java/stub/io/netty/channel/ChannelHandlerContext.java new file mode 100644 index 0000000..b17dbb6 --- /dev/null +++ b/client/java/stub/io/netty/channel/ChannelHandlerContext.java @@ -0,0 +1,10 @@ +// Compile-time stub of io.netty.channel.ChannelHandlerContext — only the +// methods DarkChannelHandler calls. Signatures must match real Netty exactly, +// since the runtime links against the real class. +package io.netty.channel; + +public interface ChannelHandlerContext { + ChannelFuture write(Object msg, ChannelPromise promise); + + ChannelHandlerContext fireChannelRead(Object msg); +} diff --git a/client/java/stub/io/netty/channel/ChannelPromise.java b/client/java/stub/io/netty/channel/ChannelPromise.java new file mode 100644 index 0000000..072584b --- /dev/null +++ b/client/java/stub/io/netty/channel/ChannelPromise.java @@ -0,0 +1,5 @@ +// Compile-time stub of io.netty.channel.ChannelPromise. +// The real Netty class is resolved at runtime from Minecraft's class loader. +package io.netty.channel; + +public interface ChannelPromise {} diff --git a/client/src/config.rs b/client/src/config.rs new file mode 100644 index 0000000..114e4b2 --- /dev/null +++ b/client/src/config.rs @@ -0,0 +1,250 @@ +//! Persistent module configuration. +//! +//! The keybind, setting values and enabled state of every module — plus the +//! GUI layout (panel positions, which modules are expanded) — are written to +//! `dark_client_config.json` so they survive a re-injection. [`load`] runs once +//! after the modules are registered; [`save`] is called whenever the user +//! changes something in the GUI. + +use crate::module::{KeyboardKey, ModuleCategory, ModuleId, ModuleSetting}; +use crate::state::client; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::LazyLock; + +/// Category-panel positions, keyed by category — updated as the user drags a +/// panel, and persisted so the layout survives a re-injection. +static PANEL_POS: LazyLock> = LazyLock::new(DashMap::new); + +/// Which modules have their settings panel expanded in the GUI, keyed by id. +static EXPANDED: LazyLock> = LazyLock::new(DashMap::new); + +/// The saved position of a category panel, if the user has moved it. +pub fn panel_pos(category: ModuleCategory) -> Option<[f32; 2]> { + PANEL_POS.get(&category).map(|entry| *entry) +} + +/// Records a category panel's position. +pub fn set_panel_pos(category: ModuleCategory, pos: [f32; 2]) { + PANEL_POS.insert(category, pos); +} + +/// Whether a module's settings panel is expanded. +pub fn is_expanded(id: ModuleId) -> bool { + EXPANDED.get(&id).map(|entry| *entry).unwrap_or(false) +} + +/// Records a module's expanded state. +pub fn set_expanded(id: ModuleId, expanded: bool) { + EXPANDED.insert(id, expanded); +} + +/// Clears the persisted GUI layout — panel positions and expanded modules. +/// Backs the GUI's "Reset UI" button. +pub fn reset_ui_state() { + PANEL_POS.clear(); + EXPANDED.clear(); +} + +/// Config file name. +const CONFIG_FILE: &str = "dark_client_config.json"; + +/// The directory DarkClient keeps its files in — the config and the log. The +/// injector passes its own working directory through the `DARK_CONFIG_DIR` env +/// var (set by the agent loader); keeping files there, rather than inside +/// `.minecraft`, leaves no trace in the game directory. Falls back to the +/// process working directory if the variable is absent. +pub fn base_dir() -> PathBuf { + match std::env::var_os("DARK_CONFIG_DIR") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => PathBuf::from("."), + } +} + +/// Absolute path of the config file. +fn config_path() -> PathBuf { + base_dir().join(CONFIG_FILE) +} + +#[derive(Serialize, Deserialize, Default)] +struct Config { + modules: Vec, + /// `#[serde(default)]` keeps configs written before panels were saved + /// loadable. + #[serde(default)] + panels: Vec, +} + +#[derive(Serialize, Deserialize)] +struct ModuleConfig { + id: ModuleId, + key_bind: i32, + enabled: bool, + /// Whether the module's settings panel is expanded. `#[serde(default)]` + /// keeps older configs loadable. + #[serde(default)] + expanded: bool, + settings: Vec, +} + +#[derive(Serialize, Deserialize)] +struct PanelConfig { + category: ModuleCategory, + x: f32, + y: f32, +} + +#[derive(Serialize, Deserialize)] +struct SavedSetting { + name: String, + value: SettingValue, +} + +/// The persisted value of a setting — only the value, never the bounds +/// (`min` / `max` / `options` always come from the code defaults). +#[derive(Serialize, Deserialize)] +enum SettingValue { + Toggle(bool), + Slider(f32), + Choice(usize), + Color([f32; 4]), +} + +impl SettingValue { + /// Snapshots a live setting's value. + fn capture(setting: &ModuleSetting) -> SettingValue { + match setting { + ModuleSetting::Toggle { value, .. } => SettingValue::Toggle(*value), + ModuleSetting::Slider { value, .. } => SettingValue::Slider(*value), + ModuleSetting::Choice { value, .. } => SettingValue::Choice(*value), + ModuleSetting::Color { value, .. } => SettingValue::Color(*value), + } + } + + /// Applies the saved value onto a live setting. A type mismatch — the code + /// changed a setting's kind since the file was written — is ignored. + fn apply(&self, setting: &mut ModuleSetting) { + match (self, setting) { + (SettingValue::Toggle(v), ModuleSetting::Toggle { value, .. }) => *value = *v, + ( + SettingValue::Slider(v), + ModuleSetting::Slider { + value, min, max, .. + }, + ) => *value = v.clamp(*min, *max), + (SettingValue::Choice(v), ModuleSetting::Choice { value, options, .. }) + if *v < options.len() => + { + *value = *v; + } + (SettingValue::Color(v), ModuleSetting::Color { value, .. }) => *value = *v, + _ => {} + } + } +} + +/// Writes the current state of every registered module to disk. +pub fn save() { + let mut config = Config::default(); + for handle in client().modules.handles() { + let Ok(module) = handle.lock() else { + continue; + }; + let data = module.get_module_data(); + config.modules.push(ModuleConfig { + id: data.id, + key_bind: data.key_bind as i32, + enabled: data.enabled, + expanded: is_expanded(data.id), + settings: data + .settings + .iter() + .map(|setting| SavedSetting { + name: setting.name().to_string(), + value: SettingValue::capture(setting), + }) + .collect(), + }); + } + + config.panels = PANEL_POS + .iter() + .map(|entry| PanelConfig { + category: *entry.key(), + x: entry.value()[0], + y: entry.value()[1], + }) + .collect(); + + let path = config_path(); + match serde_json::to_string_pretty(&config) { + Ok(json) => { + if let Err(error) = std::fs::write(&path, json) { + log::warn!("config: could not write {}: {error}", path.display()); + } + } + Err(error) => log::warn!("config: could not serialize: {error}"), + } +} + +/// Loads the saved config, if any, and applies it to the registered modules. +/// Must be called after `register_modules()`. A missing file is the normal +/// first-run case and is silently ignored. +pub fn load() { + let path = config_path(); + let json = match std::fs::read_to_string(&path) { + Ok(json) => json, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => { + log::warn!("config: could not read {}: {error}", path.display()); + return; + } + }; + let config: Config = match serde_json::from_str(&json) { + Ok(config) => config, + Err(error) => { + log::warn!("config: could not parse {}: {error}", path.display()); + return; + } + }; + + for handle in client().modules.handles() { + let Ok(mut module) = handle.lock() else { + continue; + }; + let id = module.get_module_data().id; + let Some(saved) = config.modules.iter().find(|entry| entry.id == id) else { + continue; + }; + + { + let data = module.get_module_data_mut(); + data.key_bind = KeyboardKey::from(saved.key_bind); + for setting in &mut data.settings { + if let Some(saved_setting) = saved + .settings + .iter() + .find(|entry| entry.name == setting.name()) + { + saved_setting.value.apply(setting); + } + } + data.set_enabled(saved.enabled); + } + + set_expanded(id, saved.expanded); + + // Re-enter a module that was saved enabled. + if saved.enabled { + if let Err(error) = module.on_start() { + let name = module.get_module_data().name(); + log::warn!("config: '{name}' failed to start: {error}"); + } + } + } + + for panel in &config.panels { + set_panel_pos(panel.category, [panel.x, panel.y]); + } +} diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index eba7bb5..75433b8 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -17,14 +17,19 @@ //! The chest scan is heavier (it walks loaded chunks) so it runs even rarer, //! every [`CHEST_SCAN_INTERVAL`]. -use crate::mapping::{FieldType, Mapping, MinecraftClassType as Cls}; -use crate::module::ModuleSetting; -use crate::state::{client, mapping, minecraft}; +use crate::mapping::client::camera::Camera; +use crate::mapping::client::world::World; +use crate::mapping::entity::mob::Mob; +use crate::mapping::entity::player::Player; +use crate::mapping::entity::Entity; +use crate::mapping::math::Vec3; +use crate::mapping::MappedObject; +use crate::module::{ModuleId, ModuleSetting}; +use crate::state::{client, minecraft}; use egui::{ pos2, vec2, Align2, Color32, Context, FontId, Id, LayerId, Order, Painter, Pos2, Rect, Rounding, Stroke, }; -use jni::objects::{GlobalRef, JObject, JValue}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -84,6 +89,17 @@ impl V3 { } } +/// Converts a JNI-snapshot [`Vec3`] into the projection-math vector. +impl From for V3 { + fn from(v: Vec3) -> V3 { + V3 { + x: v.x(), + y: v.y(), + z: v.z(), + } + } +} + /// A camera, reduced to exactly what world→screen projection needs. struct View { cam: V3, @@ -179,9 +195,9 @@ struct ChestTarget { pos: V3, } -/// Cross-frame ESP state: the cached camera handles and the latest snapshot. +/// Cross-frame ESP state: the cached camera handle and the latest snapshot. struct EspState { - camera: Option, + camera: Option, entities: Vec, chests: Vec, prev_gather: Option, @@ -294,7 +310,7 @@ fn read_config() -> EspConfig { let modules = &client().modules; - if let Some(arc) = modules.get("Player ESP") { + if let Some(arc) = modules.get(ModuleId::PlayerEsp) { if let Ok(module) = arc.lock() { let data = module.get_module_data(); cfg.player = EntityCfg { @@ -307,7 +323,7 @@ fn read_config() -> EspConfig { }; } } - if let Some(arc) = modules.get("Mob ESP") { + if let Some(arc) = modules.get(ModuleId::MobEsp) { if let Ok(module) = arc.lock() { let data = module.get_module_data(); cfg.mob = EntityCfg { @@ -320,7 +336,7 @@ fn read_config() -> EspConfig { }; } } - if let Some(arc) = modules.get("Chest ESP") { + if let Some(arc) = modules.get(ModuleId::ChestEsp) { if let Ok(module) = arc.lock() { let data = module.get_module_data(); cfg.chest = ChestCfg { @@ -388,12 +404,13 @@ fn interp_factor(state: &EspState, now: Instant) -> f64 { // --- camera ---------------------------------------------------------------- -/// Resolves the current camera into a [`View`], caching the JNI handles. +/// Resolves the current camera into a [`View`], caching the [`Camera`] handle. fn read_view(state: &mut EspState, ctx: &Context) -> Option { - let mapping = mapping(); - if state.camera.is_none() { - match init_camera(mapping) { + match minecraft() + .game_renderer() + .and_then(|gr| gr.get_main_camera()) + { Ok(camera) => state.camera = Some(camera), Err(e) => { log::debug!("ESP: camera unavailable: {e}"); @@ -402,43 +419,15 @@ fn read_view(state: &mut EspState, ctx: &Context) -> Option { } } - let cam = state.camera.clone()?; + let camera = state.camera.clone()?; let rect = ctx.screen_rect(); if rect.width() < 1.0 || rect.height() < 1.0 { return None; } - let mut env = mapping.get_env().ok()?; - // The camera state is read from `Camera`'s fields, not getter methods: - // method names churn between versions, the plain fields are far stabler. - let read = env.with_local_frame(32, |_| -> anyhow::Result<(V3, f32, f32)> { - let pos = mapping - .get_field( - Cls::Camera, - cam.as_obj(), - "position", - FieldType::Object(Cls::Vec3), - )? - .l()?; - let cam_pos = V3 { - x: mapping - .get_field(Cls::Vec3, &pos, "x", FieldType::Double)? - .d()?, - y: mapping - .get_field(Cls::Vec3, &pos, "y", FieldType::Double)? - .d()?, - z: mapping - .get_field(Cls::Vec3, &pos, "z", FieldType::Double)? - .d()?, - }; - let yaw = mapping - .get_field(Cls::Camera, cam.as_obj(), "yRot", FieldType::Float)? - .f()?; - let pitch = mapping - .get_field(Cls::Camera, cam.as_obj(), "xRot", FieldType::Float)? - .f()?; - Ok((cam_pos, yaw, pitch)) - }); + let read = (|| -> anyhow::Result<(V3, f32, f32)> { + Ok((camera.position()?.into(), camera.yaw()?, camera.pitch()?)) + })(); let (cam_pos, yaw, pitch) = match read { Ok(values) => values, @@ -468,110 +457,41 @@ fn read_view(state: &mut EspState, ctx: &Context) -> Option { )) } -/// Fetches the (session-stable) `Camera` handle via the game renderer. -fn init_camera(mapping: &Mapping) -> anyhow::Result { - let mc = minecraft(); - let mut env = mapping.get_env()?; - env.with_local_frame(16, |_| -> anyhow::Result { - let renderer = mapping - .get_field( - Cls::Minecraft, - mc.jni_ref.as_obj(), - "gameRenderer", - FieldType::Object(Cls::GameRenderer), - )? - .l()?; - let camera = mapping - .call_method(Cls::GameRenderer, &renderer, "getMainCamera", &[])? - .l()?; - mapping.new_global_ref(camera) - }) -} - /// Reads the vertical field of view, in degrees, Minecraft is rendering with: /// the options value scaled by the flying / sprinting modifiers Minecraft /// itself applies. Without them the box drifts off entities while either is /// active (`GameRenderer.getFov` would give this directly, but its signature /// is not stable across versions). -fn read_fov(mapping: &Mapping) -> f64 { - let base = match read_option_fov(mapping) { +fn read_fov() -> f64 { + let base = match read_option_fov() { Ok(fov) if fov.is_finite() && (1.0..=179.0).contains(&fov) => fov, _ => 70.0, }; - (base * fov_modifier(mapping)).clamp(1.0, 179.0) + (base * fov_modifier()).clamp(1.0, 179.0) } /// The FOV multiplier Minecraft applies on top of the options value: ×1.1 /// while flying and ≈×1.15 while sprinting — the constants from /// `Player.getFieldOfViewModifier`. -fn fov_modifier(mapping: &Mapping) -> f64 { +fn fov_modifier() -> f64 { let player = match minecraft().player() { Ok(Some(player)) => player, _ => return 1.0, }; let mut modifier = 1.0; - - let flying = mapping - .get_field( - Cls::Abilities, - player.abilities.jni_ref.as_obj(), - "flying", - FieldType::Boolean, - ) - .ok() - .and_then(|value| value.z().ok()) - .unwrap_or(false); - if flying { + if player.abilities.is_flying().unwrap_or(false) { modifier *= 1.1; } - - let sprinting = mapping - .call_method( - Cls::Entity, - player.entity.jni_ref.as_obj(), - "isSprinting", - &[], - ) - .ok() - .and_then(|value| value.z().ok()) - .unwrap_or(false); - if sprinting { + if player.entity.is_sprinting().unwrap_or(false) { modifier *= 1.15; } - modifier } /// Reads the raw FOV slider value from the game options. -fn read_option_fov(mapping: &Mapping) -> anyhow::Result { - let mc = minecraft(); - let mut env = mapping.get_env()?; - env.with_local_frame(16, |_| -> anyhow::Result { - let options = mapping - .get_field( - Cls::Minecraft, - mc.jni_ref.as_obj(), - "options", - FieldType::Object(Cls::Options), - )? - .l()?; - let option = mapping - .get_field( - Cls::Options, - &options, - "fov", - FieldType::Object(Cls::OptionInstance), - )? - .l()?; - let value = mapping - .call_method(Cls::OptionInstance, &option, "get", &[])? - .l()?; - let fov = mapping - .call_method(Cls::Integer, &value, "intValue", &[])? - .i()?; - Ok(fov as f64) - }) +fn read_option_fov() -> anyhow::Result { + Ok(minecraft().options()?.fov()?.get_int()? as f64) } // --- gather ---------------------------------------------------------------- @@ -580,7 +500,7 @@ fn read_option_fov(mapping: &Mapping) -> anyhow::Result { fn gather(state: &mut EspState, cfg: &EspConfig, now: Instant) { state.prev_gather = state.last_gather; state.last_gather = Some(now); - state.target_fov = read_fov(mapping()); + state.target_fov = read_fov(); if cfg.player.enabled || cfg.mob.enabled { let mut range = 0.0_f32; @@ -626,93 +546,40 @@ fn gather_entities( want_mob: bool, ) -> anyhow::Result> { let mc = minecraft(); - let mapping = mapping(); + let (Some(world), Some(player)) = (mc.world()?, mc.player()?) else { + return Ok(Vec::new()); + }; + + let local_id = player.entity.id()?; + let player_pos: V3 = player.entity.get_position()?.into(); // Carry positions forward so the new snapshot can interpolate from them. let prev_pos: HashMap = previous.iter().map(|e| (e.id, e.pos)).collect(); - let mut env = mapping.get_env()?; let mut out: Vec = Vec::new(); - - env.with_local_frame(32, |env| -> anyhow::Result<()> { - let (local_id, player_pos) = { - let Some(player) = mc.player()? else { - return Ok(()); - }; - let id = mapping - .call_method(Cls::Entity, player.entity.jni_ref.as_obj(), "getId", &[])? - .i()?; - let pos = player.entity.get_position()?; - ( - id, - V3 { - x: pos.0, - y: pos.1, - z: pos.2, - }, - ) - }; - - let level = mapping - .get_field( - Cls::Minecraft, - mc.jni_ref.as_obj(), - "level", - FieldType::Object(Cls::Level), - )? - .l()?; - if level.is_null() { - return Ok(()); - } - - let iterable = mapping - .call_method(Cls::Level, &level, "entitiesForRendering", &[])? - .l()?; - let iterator = mapping - .call_method(Cls::Iterable, &iterable, "iterator", &[])? - .l()?; - - loop { - if !mapping - .call_method(Cls::Iterator, &iterator, "hasNext", &[])? - .z()? - { - break; - } - // One frame per entity bounds the local-ref table no matter how - // many entities the world contains. - let target = env.with_local_frame(64, |_| -> anyhow::Result> { - let entity = mapping - .call_method(Cls::Iterator, &iterator, "next", &[])? - .l()?; - Ok(process_entity( - mapping, - &entity, - local_id, - player_pos, - range_sq, - want_player, - want_mob, - &prev_pos, - )) - })?; - if let Some(target) = target { - out.push(target); - } + for entity in world.get_entities()? { + if let Some(target) = process_entity( + &entity, + local_id, + player_pos, + range_sq, + want_player, + want_mob, + &prev_pos, + ) { + out.push(target); } - Ok(()) - })?; + } Ok(out) } -/// Turns one entity object into an [`EntityTarget`], or `None` if it is not a +/// Turns one [`Entity`] into an [`EntityTarget`], or `None` if it is not a /// wanted target. Errors are swallowed per-field so one bad entity cannot /// abort the whole gather. #[allow(clippy::too_many_arguments)] fn process_entity( - mapping: &Mapping, - entity: &JObject, + entity: &Entity, local_id: i32, player_pos: V3, range_sq: f64, @@ -722,56 +589,31 @@ fn process_entity( ) -> Option { // Cheap distance gate first — a far entity then costs just this one JNI // call. Skipped entirely if `distanceToSqr` is not exposed by this build. - if let Some(dist_sq) = mapping - .call_method( - Cls::Entity, - entity, - "distanceToSqr", - &[ - JValue::Double(player_pos.x), - JValue::Double(player_pos.y), - JValue::Double(player_pos.z), - ], - ) - .ok() - .and_then(|value| value.d().ok()) - { + if let Ok(dist_sq) = entity.distance_to_sqr(player_pos.x, player_pos.y, player_pos.z) { if dist_sq > range_sq { return None; } } - let kind = if want_player && mapping.is_instance_of(Cls::Player, entity).unwrap_or(false) { + let kind = if want_player && entity.instance_of::() { TargetKind::Player - } else if want_mob && mapping.is_instance_of(Cls::Mob, entity).unwrap_or(false) { + } else if want_mob && entity.instance_of::() { TargetKind::Mob } else { return None; }; - let id = mapping - .call_method(Cls::Entity, entity, "getId", &[]) - .ok()? - .i() - .ok()?; + let id = entity.id().ok()?; if id == local_id { return None; } - let pos = read_vec3(mapping, entity, "position")?; - let width = mapping - .call_method(Cls::Entity, entity, "getBbWidth", &[]) - .ok()? - .f() - .ok()? as f64; - let height = mapping - .call_method(Cls::Entity, entity, "getBbHeight", &[]) - .ok()? - .f() - .ok()? as f64; - - let name = read_name(mapping, entity).unwrap_or_default(); - let (health, max_health) = read_health(mapping, entity).unwrap_or((0.0, 0.0)); + let pos: V3 = entity.get_position().ok()?.into(); + let width = entity.bb_width().ok()? as f64; + let height = entity.bb_height().ok()? as f64; + + let name = read_name(entity); + let (health, max_health) = read_health(entity).unwrap_or((0.0, 0.0)); Some(EntityTarget { id, @@ -786,190 +628,69 @@ fn process_entity( }) } -/// Calls a no-arg `Vec3`-returning method and reads its `x`/`y`/`z`. -fn read_vec3(mapping: &Mapping, obj: &JObject, method: &str) -> Option { - let vec3 = mapping - .call_method(Cls::Entity, obj, method, &[]) - .ok()? - .l() - .ok()?; - Some(V3 { - x: mapping - .get_field(Cls::Vec3, &vec3, "x", FieldType::Double) - .ok()? - .d() - .ok()?, - y: mapping - .get_field(Cls::Vec3, &vec3, "y", FieldType::Double) - .ok()? - .d() - .ok()?, - z: mapping - .get_field(Cls::Vec3, &vec3, "z", FieldType::Double) - .ok()? - .d() - .ok()?, - }) -} - -/// Reads an entity's display name via `getName().getString()`. -fn read_name(mapping: &Mapping, entity: &JObject) -> anyhow::Result { - let component = mapping - .call_method(Cls::Entity, entity, "getName", &[])? - .l()?; - if component.is_null() { - return Ok(String::new()); - } - let string = mapping - .call_method(Cls::Component, &component, "getString", &[])? - .l()?; - let mut name = mapping.get_string(string)?; +/// Reads an entity's display name, truncated to a sane label length. +fn read_name(entity: &Entity) -> String { + let name = entity + .get_name() + .and_then(|component| component.get_string()) + .unwrap_or_default(); if name.chars().count() > 24 { - name = name.chars().take(24).collect(); + name.chars().take(24).collect() + } else { + name } - Ok(name) } -/// Reads `(health, maxHealth)` for a living entity. -fn read_health(mapping: &Mapping, entity: &JObject) -> anyhow::Result<(f32, f32)> { - let health = mapping - .call_method(Cls::LivingEntity, entity, "getHealth", &[])? - .f()?; - let max_health = mapping - .call_method(Cls::LivingEntity, entity, "getMaxHealth", &[])? - .f()?; - Ok((health, max_health)) +/// Reads `(health, maxHealth)`, or `None` if the entity is not living. +fn read_health(entity: &Entity) -> Option<(f32, f32)> { + let living = entity.as_living()?; + Some((living.get_health().ok()?, living.get_max_health().ok()?)) } /// Scans loaded chunks around the player for container block entities. fn gather_chests() -> anyhow::Result> { let mc = minecraft(); - let mapping = mapping(); - - let mut env = mapping.get_env()?; - let mut out: Vec = Vec::new(); + let (Some(world), Some(player)) = (mc.world()?, mc.player()?) else { + return Ok(Vec::new()); + }; - env.with_local_frame(32, |env| -> anyhow::Result<()> { - let level = mapping - .get_field( - Cls::Minecraft, - mc.jni_ref.as_obj(), - "level", - FieldType::Object(Cls::Level), - )? - .l()?; - if level.is_null() { - return Ok(()); - } + let player_pos: V3 = player.entity.get_position()?.into(); + let pcx = (player_pos.x / 16.0).floor() as i32; + let pcz = (player_pos.z / 16.0).floor() as i32; - let Some(player) = mc.player()? else { - return Ok(()); - }; - let player_pos = player.entity.get_position()?; - let pcx = (player_pos.0 / 16.0).floor() as i32; - let pcz = (player_pos.2 / 16.0).floor() as i32; - - for cx in (pcx - CHEST_CHUNK_RADIUS)..=(pcx + CHEST_CHUNK_RADIUS) { - for cz in (pcz - CHEST_CHUNK_RADIUS)..=(pcz + CHEST_CHUNK_RADIUS) { - // One frame per chunk keeps the block-entity locals bounded. - env.with_local_frame(128, |_| -> anyhow::Result<()> { - scan_chunk(mapping, &level, cx, cz, &mut out) - })?; - } + let mut out: Vec = Vec::new(); + for cx in (pcx - CHEST_CHUNK_RADIUS)..=(pcx + CHEST_CHUNK_RADIUS) { + for cz in (pcz - CHEST_CHUNK_RADIUS)..=(pcz + CHEST_CHUNK_RADIUS) { + scan_chunk(&world, cx, cz, &mut out)?; } - Ok(()) - })?; + } Ok(out) } /// Adds every container block entity of one chunk to `out`. -fn scan_chunk( - mapping: &Mapping, - level: &JObject, - cx: i32, - cz: i32, - out: &mut Vec, -) -> anyhow::Result<()> { - let chunk = mapping - .call_method( - Cls::LevelReader, - level, - "getChunk", - &[JValue::Int(cx), JValue::Int(cz)], - )? - .l()?; - if chunk.is_null() { +fn scan_chunk(world: &World, cx: i32, cz: i32, out: &mut Vec) -> anyhow::Result<()> { + let Some(chunk) = world.get_chunk(cx, cz)? else { return Ok(()); - } + }; - let map = mapping - .call_method(Cls::LevelChunk, &chunk, "getBlockEntities", &[])? - .l()?; - if map.is_null() { - return Ok(()); - } - let values = mapping.call_method(Cls::Map, &map, "values", &[])?.l()?; - let iterator = mapping - .call_method(Cls::Iterable, &values, "iterator", &[])? - .l()?; - - loop { - if !mapping - .call_method(Cls::Iterator, &iterator, "hasNext", &[])? - .z()? - { - break; + for block_entity in chunk.get_block_entities()? { + if !block_entity.is_container() { + continue; } - let block_entity = mapping - .call_method(Cls::Iterator, &iterator, "next", &[])? - .l()?; - if is_container(mapping, &block_entity) { - if let Some(pos) = block_entity_pos(mapping, &block_entity) { - out.push(ChestTarget { pos }); - } + if let Ok(pos) = block_entity.get_block_pos() { + out.push(ChestTarget { + pos: V3 { + x: pos.x() as f64, + y: pos.y() as f64, + z: pos.z() as f64, + }, + }); } } Ok(()) } -/// True for chest / trapped chest / ender chest / barrel / shulker box. -fn is_container(mapping: &Mapping, block_entity: &JObject) -> bool { - // `ChestBlockEntity` already covers trapped chests (a subclass). - const KINDS: [Cls; 4] = [ - Cls::ChestBlockEntity, - Cls::EnderChestBlockEntity, - Cls::BarrelBlockEntity, - Cls::ShulkerBoxBlockEntity, - ]; - KINDS - .iter() - .any(|&kind| mapping.is_instance_of(kind, block_entity).unwrap_or(false)) -} - -/// Reads a block entity's `BlockPos` as a [`V3`]. -fn block_entity_pos(mapping: &Mapping, block_entity: &JObject) -> Option { - let block_pos = mapping - .call_method(Cls::BlockEntity, block_entity, "getBlockPos", &[]) - .ok()? - .l() - .ok()?; - let axis = |name: &str| -> Option { - Some( - mapping - .call_method(Cls::Vec3i, &block_pos, name, &[]) - .ok()? - .i() - .ok()? as f64, - ) - }; - Some(V3 { - x: axis("getX")?, - y: axis("getY")?, - z: axis("getZ")?, - }) -} - // --- drawing --------------------------------------------------------------- /// The 12 edges of a box, as index pairs into an 8-corner array. diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index c1b31c6..bce0efb 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -51,6 +51,9 @@ unsafe fn on_frame() { } check_tick(); + // Ease combat-module rotations toward their target every frame — this is + // what makes aiming smooth rather than the visible 20 Hz tick steps. + crate::module::combat::rotation::update(); render_overlay(); } @@ -82,6 +85,8 @@ fn check_tick() { if tick_count > LAST_TICK.load(Ordering::Relaxed) { LAST_TICK.store(tick_count, Ordering::Relaxed); + // Keep our Netty handler on the live connection's pipeline. + crate::net::ensure_installed(); state::client().modules.tick(); } } diff --git a/client/src/graphic/hud.rs b/client/src/graphic/hud.rs index 4cc4a33..a15c732 100644 --- a/client/src/graphic/hud.rs +++ b/client/src/graphic/hud.rs @@ -94,7 +94,7 @@ fn draw_arraylist(ctx: &Context, painter: &Painter) { .filter_map(|m| { let module = m.lock().ok()?; let data = module.get_module_data(); - Some((data.name.clone(), data.enabled)) + Some((data.name().to_string(), data.enabled)) }) .collect(); diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index 5addf60..5d4dcf5 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -32,6 +32,10 @@ pub struct MouseState { pub right_down: bool, pub left_clicked: bool, pub right_clicked: bool, + /// Scroll-wheel delta accumulated since the last frame; drained — and + /// reset — by the UI engine when it builds egui's input. + pub scroll_x: f32, + pub scroll_y: f32, } impl MouseState { @@ -42,6 +46,8 @@ impl MouseState { right_down: false, left_clicked: false, right_clicked: false, + scroll_x: 0.0, + scroll_y: 0.0, }; } @@ -52,6 +58,7 @@ const GLFW_PRESS: i32 = 1; const GLFW_MOUSE_BUTTON_LEFT: i32 = 0; const GLFW_MOUSE_BUTTON_RIGHT: i32 = 1; const GLFW_KEY_RIGHT_SHIFT: i32 = 344; +const GLFW_KEY_ESCAPE: i32 = 256; const GLFW_CURSOR: i32 = 0x0003_3001; const GLFW_CURSOR_NORMAL: i32 = 0x0003_4001; @@ -62,11 +69,13 @@ const GLFW_CURSOR_DISABLED: i32 = 0x0003_4003; type MouseButtonFun = extern "C" fn(*mut c_void, i32, i32, i32); type CursorPosFun = extern "C" fn(*mut c_void, f64, f64); type KeyFun = extern "C" fn(*mut c_void, i32, i32, i32, i32); +type ScrollFun = extern "C" fn(*mut c_void, f64, f64); type GetCurrentContext = extern "C" fn() -> *mut c_void; type SetMouseButtonCallback = extern "C" fn(*mut c_void, MouseButtonFun) -> *mut c_void; type SetCursorPosCallback = extern "C" fn(*mut c_void, CursorPosFun) -> *mut c_void; type SetKeyCallback = extern "C" fn(*mut c_void, KeyFun) -> *mut c_void; +type SetScrollCallback = extern "C" fn(*mut c_void, ScrollFun) -> *mut c_void; type SetInputMode = extern "C" fn(*mut c_void, i32, i32); type SetCursorPos = extern "C" fn(*mut c_void, f64, f64); @@ -81,6 +90,7 @@ struct GlfwHooks { original_mouse_button: *mut c_void, original_cursor_pos: *mut c_void, original_key: *mut c_void, + original_scroll: *mut c_void, set_input_mode: SetInputMode, set_cursor_pos: SetCursorPos, } @@ -169,12 +179,19 @@ extern "C" fn on_key(window: *mut c_void, key: i32, scancode: i32, action: i32, LAST_KEY_PRESSED.store(key, Ordering::Relaxed); } - if key == GLFW_KEY_RIGHT_SHIFT && action == GLFW_PRESS { + let gui_was_open = GUI_OPEN.load(Ordering::Relaxed); + + // Right Shift toggles the GUI; ESC also closes it while it is open. + if action == GLFW_PRESS + && (key == GLFW_KEY_RIGHT_SHIFT || (key == GLFW_KEY_ESCAPE && gui_was_open)) + { toggle_gui(); } - // While the GUI is open, swallow the event instead of forwarding it. - if GUI_OPEN.load(Ordering::Relaxed) { + // Swallow the event — instead of forwarding it — whenever the GUI is open, + // or was open until this very keystroke closed it (so ESC closing the GUI + // does not also reach Minecraft and open its pause menu). + if gui_was_open || GUI_OPEN.load(Ordering::Relaxed) { return; } @@ -190,11 +207,35 @@ extern "C" fn on_key(window: *mut c_void, key: i32, scancode: i32, action: i32, } } +extern "C" fn on_scroll(window: *mut c_void, x_offset: f64, y_offset: f64) { + // While the GUI is open, feed the wheel to egui and swallow it — otherwise + // it would also reach Minecraft and change the selected hotbar slot. + if GUI_OPEN.load(Ordering::Relaxed) { + if let Ok(mut state) = MOUSE_STATE.lock() { + state.scroll_x += x_offset as f32; + state.scroll_y += y_offset as f32; + } + return; + } + + if let Some(hooks) = HOOKS.get() { + if !hooks.original_scroll.is_null() { + let original: ScrollFun = unsafe { std::mem::transmute(hooks.original_scroll) }; + original(window, x_offset, y_offset); + } + } +} + /// Toggles the overlay GUI and the matching cursor-capture mode. fn toggle_gui() { // `fetch_xor` returns the previous value; the new state is its negation. let open = !GUI_OPEN.fetch_xor(true, Ordering::Relaxed); + // Persist anything the user changed in the menu before it closes. + if !open { + crate::config::save(); + } + let Some(hooks) = HOOKS.get() else { return; }; @@ -205,9 +246,14 @@ fn toggle_gui() { if open { // GUI open: release the cursor. (hooks.set_input_mode)(hooks.window, GLFW_CURSOR, GLFW_CURSOR_NORMAL); + } else if minecraft_screen_open() { + // GUI closed onto an open Minecraft screen (chat, inventory, menu, …): + // leave the cursor free, since that screen still needs it. + (hooks.set_input_mode)(hooks.window, GLFW_CURSOR, GLFW_CURSOR_NORMAL); } else { - // GUI closed: restore the cursor where Minecraft last had it, then - // re-capture it — this avoids a camera jump on the next mouse move. + // GUI closed back into the world: restore the cursor where Minecraft + // last had it, then re-capture it — avoids a camera jump on the next + // mouse move. let (lock_x, lock_y) = cursor_lock(); (hooks.set_cursor_pos)(hooks.window, lock_x, lock_y); (hooks.set_input_mode)(hooks.window, GLFW_CURSOR, GLFW_CURSOR_DISABLED); @@ -221,6 +267,7 @@ fn handle_module_keybind(key: i32) { return; } + let mut changed = false; for handle in client().modules.handles() { let Ok(mut module) = handle.lock() else { continue; @@ -232,7 +279,7 @@ fn handle_module_keybind(key: i32) { let enabled = !module.get_module_data().enabled; info!( "{} {}", - module.get_module_data().name, + module.get_module_data().name(), if enabled { "enabled" } else { "disabled" } ); if enabled { @@ -241,6 +288,12 @@ fn handle_module_keybind(key: i32) { let _ = module.on_stop(); } module.get_module_data_mut().set_enabled(enabled); + changed = true; + } + + // Persist the new enabled state. + if changed { + crate::config::save(); } } @@ -274,6 +327,9 @@ fn install_glfw_hooks() -> Option { .get::(b"glfwSetCursorPosCallback") .ok()?; let set_key = *library.get::(b"glfwSetKeyCallback").ok()?; + let set_scroll = *library + .get::(b"glfwSetScrollCallback") + .ok()?; let set_input_mode = *library.get::(b"glfwSetInputMode").ok()?; let set_cursor_pos = *library.get::(b"glfwSetCursorPos").ok()?; @@ -287,6 +343,7 @@ fn install_glfw_hooks() -> Option { let original_mouse_button = set_mouse_button(window, on_mouse_button); let original_cursor_pos = set_cursor_pos_cb(window, on_cursor_pos); let original_key = set_key(window, on_key); + let original_scroll = set_scroll(window, on_scroll); // If the GUI was toggled on before hooks existed, release the cursor. if GUI_OPEN.load(Ordering::Relaxed) { @@ -299,6 +356,7 @@ fn install_glfw_hooks() -> Option { original_mouse_button, original_cursor_pos, original_key, + original_scroll, set_input_mode, set_cursor_pos, }) @@ -316,10 +374,11 @@ pub fn cleanup() { } type RestoreCallback = extern "C" fn(*mut c_void, *mut c_void) -> *mut c_void; - let restorations: [(&[u8], *mut c_void); 3] = [ + let restorations: [(&[u8], *mut c_void); 4] = [ (b"glfwSetMouseButtonCallback", hooks.original_mouse_button), (b"glfwSetCursorPosCallback", hooks.original_cursor_pos), (b"glfwSetKeyCallback", hooks.original_key), + (b"glfwSetScrollCallback", hooks.original_scroll), ]; unsafe { for (name, original) in restorations { @@ -329,7 +388,22 @@ pub fn cleanup() { } } - // Leave Minecraft with its cursor captured, as it expects in-world. - (hooks.set_input_mode)(hooks.window, GLFW_CURSOR, GLFW_CURSOR_DISABLED); + // Restore the cursor to the mode Minecraft itself wants right now: visible + // while one of its screens (chat, inventory, menu, …) is open, captured + // when in-world. Forcing it captured would hide the cursor after a Panic + // triggered from inside a menu. + let cursor_mode = if minecraft_screen_open() { + GLFW_CURSOR_NORMAL + } else { + GLFW_CURSOR_DISABLED + }; + (hooks.set_input_mode)(hooks.window, GLFW_CURSOR, cursor_mode); info!("GLFW input callbacks restored."); } + +/// Whether Minecraft has one of its own screens open — meaning the cursor +/// should be visible. Best-effort: a JNI failure resolves to "screen open", +/// so the safer, recoverable outcome (a visible cursor) wins. +fn minecraft_screen_open() -> bool { + !minecraft().current_screen_is_null() +} diff --git a/client/src/graphic/menu.rs b/client/src/graphic/menu.rs index 0937b72..70cc220 100644 --- a/client/src/graphic/menu.rs +++ b/client/src/graphic/menu.rs @@ -9,7 +9,7 @@ use crate::graphic::anim::{self, Easing, SpringCfg}; use crate::graphic::input::LAST_KEY_PRESSED; use crate::graphic::notification::{Notification, NotificationType}; use crate::graphic::theme; -use crate::module::{KeyboardKey, ModuleCategory, ModuleData, ModuleSetting, ModuleType}; +use crate::module::{KeyboardKey, ModuleCategory, ModuleData, ModuleId, ModuleSetting, ModuleType}; use egui::{ Align, Align2, Button, Color32, Context, FontId, Id, LayerId, Layout, Margin, Order, Painter, Pos2, Rect, RichText, Rounding, Sense, Shape, Stroke, Ui, Vec2, @@ -35,24 +35,23 @@ const ORIGIN_Y: f32 = 58.0; /// A shared handle to one module. type ModuleArc = Arc>; /// The whole module registry, as borrowed from the read guard. -type ModuleMap = HashMap; +type ModuleMap = HashMap; /// Draws the entire ClickGUI. `progress` is the 0..1 open animation factor. pub fn draw(ctx: &Context, progress: f32) { draw_backdrop(ctx, progress); - let registry = crate::state::client().modules.by_name(); + let registry = crate::state::client().modules.by_id(); // Single lock per module: collect the data layout needs, nothing more. - let mut entries: Vec<(String, ModuleCategory)> = registry - .values() - .map(|arc| { + let mut entries: Vec<(ModuleId, ModuleCategory)> = registry + .iter() + .map(|(id, arc)| { let module = arc.lock().unwrap(); - let data = module.get_module_data(); - (data.name.clone(), data.category) + (*id, module.get_module_data().category) }) .collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries.sort_by_key(|(id, _)| id.display_name()); draw_toolbar(ctx, progress); @@ -71,7 +70,11 @@ pub fn draw(ctx: &Context, progress: f32) { let members: Vec<(String, &ModuleArc)> = entries .iter() .filter(|(_, cat)| *cat == category) - .filter_map(|(name, _)| registry.get(name).map(|arc| (name.clone(), arc))) + .filter_map(|(id, _)| { + registry + .get(id) + .map(|arc| (id.display_name().to_string(), arc)) + }) .collect(); if members.is_empty() { continue; @@ -132,13 +135,30 @@ fn draw_toolbar(ctx: &Context, progress: f32) { } ui.add_space(6.0); - let reset = - Button::new(RichText::new("Reset").size(12.5).color(theme::TEXT_DIM)) - .fill(theme::ELEVATED); - if ui.add(reset).clicked() { - // Drop stored panel targets — they spring back home. + let reset_ui = Button::new( + RichText::new("Reset UI").size(12.5).color(theme::TEXT_DIM), + ) + .fill(theme::ELEVATED); + if ui.add(reset_ui).clicked() { + // Drop the saved layout — panels spring home, + // modules collapse — then persist the reset. ctx.memory_mut(|mem| mem.reset_areas()); ctx.data_mut(|data| data.clear()); + crate::config::reset_ui_state(); + crate::config::save(); + } + + ui.add_space(6.0); + let reset_settings = Button::new( + RichText::new("Reset Settings") + .size(12.5) + .color(theme::TEXT_DIM), + ) + .fill(theme::ELEVATED); + if ui.add(reset_settings).clicked() { + // Restore factory defaults, then persist them. + crate::state::client().modules.reset_settings(); + crate::config::save(); } }); }); @@ -156,10 +176,12 @@ fn draw_panel( ) { let name = category.display_name(); - // The drag target persists in egui's data store; the spring smooths the - // rendered position toward it, frame-rate independently. - let target_id = Id::new("panel_target").with(name); - let target = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(target_id, || slot)); + // The panel's drag target lives in the config (persisted); the spring + // smooths the rendered position toward it, frame-rate independently. A + // panel the user has never moved sits at its auto-layout `slot`. + let target = crate::config::panel_pos(category) + .map(|p| Pos2::new(p[0], p[1])) + .unwrap_or(slot); let pos = anim::spring_pos( ctx, Id::new("panel_pos").with(name), @@ -181,26 +203,62 @@ fn draw_panel( .rounding(Rounding::same(theme::RADIUS)) .shadow(ui.style().visuals.window_shadow) .show(ui, |ui| { + // All sizing constraints first: `set_max_*` re-anchors the + // layout cursor, so anything drawn beforehand would be + // overwritten at the panel's top edge. ui.set_min_width(PANEL_W); ui.set_max_width(PANEL_W); - draw_title_bar(ui, category, target_id); - for (module_name, arc) in members { - draw_module_row(ui, module_name, arc, registry); - } - ui.add_space(6.0); + // Cap the panel at the window height. The ui is also given + // room to grow into: an `Area` otherwise offers its content + // only the previous frame's size, which would pin the + // ScrollArea — and the panel — to its collapsed height. The + // Frame still shrinks to the actual content. + let max_rows_h = + (ctx.screen_rect().height() - render_pos.y - TITLE_H - 16.0).max(ROW_H); + ui.set_max_height(max_rows_h + TITLE_H + 8.0); + + draw_title_bar(ui, category); + + // Floating scroll bar, kept slim even when hovered — + // egui's default expands it to an unsightly width. + let mut scroll = egui::style::ScrollStyle::floating(); + scroll.bar_width = 5.0; + ui.style_mut().spacing.scroll = scroll; + // A floating handle borrows `active.fg_stroke` for its + // colour; tint that with the accent so a dragged bar shows + // the brand colour instead of the theme's black. + let active_fg = ui.visuals().widgets.active.fg_stroke.color; + ui.visuals_mut().widgets.active.fg_stroke.color = theme::ACCENT; + egui::ScrollArea::vertical() + .id_salt(name) + .max_height(max_rows_h) + .auto_shrink([false, true]) + .drag_to_scroll(false) + .show(ui, |ui| { + // Restore the normal active foreground for the + // panel's own widgets. + ui.visuals_mut().widgets.active.fg_stroke.color = active_fg; + ui.set_min_width(PANEL_W); + ui.set_max_width(PANEL_W); + for (module_name, arc) in members { + draw_module_row(ui, module_name, arc, registry); + } + ui.add_space(6.0); + }); }); }); } /// Draggable title bar with the category name and an accent underline. -fn draw_title_bar(ui: &mut Ui, category: ModuleCategory, target_id: Id) { +fn draw_title_bar(ui: &mut Ui, category: ModuleCategory) { let (rect, response) = ui.allocate_exact_size(Vec2::new(PANEL_W, TITLE_H), Sense::drag()); if response.dragged() { let delta = ui.ctx().input(|i| i.pointer.delta()); - ui.ctx().data_mut(|d| { - let current = d.get_temp::(target_id).unwrap_or(rect.min); - d.insert_temp(target_id, current + delta); - }); + let current = crate::config::panel_pos(category) + .map(|p| Pos2::new(p[0], p[1])) + .unwrap_or(rect.min); + let next = current + delta; + crate::config::set_panel_pos(category, [next.x, next.y]); } let painter = ui.painter(); @@ -232,13 +290,26 @@ fn draw_title_bar(ui: &mut Ui, category: ModuleCategory, target_id: Id) { fn draw_module_row(ui: &mut Ui, name: &str, arc: &ModuleArc, registry: &ModuleMap) { let mut module = arc.lock().unwrap(); let enabled = module.get_module_data().enabled; - let has_settings = !module.get_module_data().settings.is_empty(); + let id = module.get_module_data().id; let (rect, response) = ui.allocate_exact_size(Vec2::new(PANEL_W, ROW_H), Sense::click()); let arrow_zone = Rect::from_min_size( Pos2::new(rect.max.x - 24.0, rect.min.y), Vec2::new(24.0, ROW_H), ); + // The chevron is its own click target, layered over the row: clicking it + // expands the module, clicking anywhere else on the row toggles it. + let arrow_response = ui.interact(arrow_zone, Id::new("row_arrow").with(name), Sense::click()); + + // egui's CollapsingState animates the settings panel open and closed — a + // smooth, native slide. Its open flag is kept in the config (persisted), + // not in egui's own store. + let expanded = crate::config::is_expanded(id); + let mut collapse = egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + Id::new("row_collapse").with(name), + expanded, + ); let ctx = ui.ctx(); let hover = anim::toggle( @@ -281,42 +352,38 @@ fn draw_module_row(ui: &mut Ui, name: &str, arc: &ModuleArc, registry: &ModuleMa } // --- interaction --- - let expand_id = Id::new("row_exp").with(name); - let mut expanded = ui.data(|d| d.get_temp::(expand_id).unwrap_or(false)); - - if response.clicked() || response.secondary_clicked() { - let pointer = response.interact_pointer_pos().unwrap_or(Pos2::ZERO); - let toggle_settings = - has_settings && (response.secondary_clicked() || arrow_zone.contains(pointer)); - if toggle_settings { - expanded = !expanded; - ui.data_mut(|d| d.insert_temp(expand_id, expanded)); - } else if response.clicked() { - let next = !enabled; - module.get_module_data_mut().set_enabled(next); - let _ = if next { - module.on_start() - } else { - module.on_stop() - }; - } - } - - // --- chevron + settings --- - if has_settings { - let expand = anim::toggle(ui.ctx(), expand_id, expanded, 0.2, Easing::InOut); - let hovered_arrow = arrow_zone.contains(ui.ctx().pointer_hover_pos().unwrap_or(Pos2::ZERO)); - let chevron_color = if hovered_arrow { - theme::TEXT + // The chevron — or a right-click anywhere on the row — expands the module; + // a left-click on the rest of the row toggles it on/off. + if arrow_response.clicked() || response.secondary_clicked() { + crate::config::set_expanded(id, !expanded); + } else if response.clicked() { + let next = !enabled; + module.get_module_data_mut().set_enabled(next); + let _ = if next { + module.on_start() } else { - theme::TEXT_MUTED + module.on_stop() }; - paint_chevron(ui.painter(), arrow_zone.center(), expand, chevron_color); - - if expand > 0.001 { - draw_settings(ui, module.get_module_data_mut(), expand, arc, registry); - } } + + // --- chevron + settings --- + // Re-sync the CollapsingState to the persisted flag — it may have just + // flipped above, or been changed by a config load / "Reset UI". + collapse.set_open(crate::config::is_expanded(id)); + let openness = collapse.openness(ui.ctx()); + let chevron_color = if arrow_response.hovered() { + theme::TEXT + } else { + theme::TEXT_MUTED + }; + paint_chevron(ui.painter(), arrow_zone.center(), openness, chevron_color); + + // The body slides open/closed; `CollapsingState` clips it to the animated + // height, so the panel grows and shrinks smoothly. + collapse.show_body_unindented(ui, |ui| { + draw_settings(ui, module.get_module_data_mut(), arc, registry); + }); + collapse.store(ui.ctx()); } /// Draws a chevron that rotates from ▸ (collapsed) to ▾ (expanded). @@ -337,18 +404,11 @@ fn paint_chevron(painter: &Painter, center: Pos2, open: f32, color: Color32) { } /// Renders the keybind row and every [`ModuleSetting`] of an expanded module. -fn draw_settings( - ui: &mut Ui, - data: &mut ModuleData, - fade: f32, - arc: &ModuleArc, - registry: &ModuleMap, -) { +fn draw_settings(ui: &mut Ui, data: &mut ModuleData, arc: &ModuleArc, registry: &ModuleMap) { egui::Frame::none() .fill(theme::SURFACE) .inner_margin(Margin::symmetric(10.0, 8.0)) .show(ui, |ui| { - ui.set_opacity(fade); ui.set_min_width(PANEL_W - 20.0); ui.set_max_width(PANEL_W - 20.0); ui.spacing_mut().item_spacing.y = 7.0; @@ -420,7 +480,7 @@ fn keybind_row(ui: &mut Ui, data: &mut ModuleData, arc: &ModuleArc, registry: &M ui.horizontal(|ui| { ui.label(label("Bind")); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let bind_id = Id::new("kb_listen").with(data.name.as_str()); + let bind_id = Id::new("kb_listen").with(data.name()); let listening = ui.data(|d| d.get_temp::(bind_id).unwrap_or(false)); let caption = if listening { @@ -474,7 +534,7 @@ fn capture_keybind(data: &mut ModuleData, arc: &ModuleArc, registry: &ModuleMap) } let owner = other.lock().unwrap(); if owner.get_module_data().key_bind == key { - let owner_name = owner.get_module_data().name.clone(); + let owner_name = owner.get_module_data().name().to_string(); drop(owner); Notification::send( NotificationType::Warning, diff --git a/client/src/graphic/theme.rs b/client/src/graphic/theme.rs index 11b7dba..e71647d 100644 --- a/client/src/graphic/theme.rs +++ b/client/src/graphic/theme.rs @@ -103,6 +103,10 @@ pub fn apply(ctx: &Context) { style.spacing.interact_size.y = 16.0; style.spacing.slider_width = 100.0; + // A slightly longer animation than egui's default — the module settings + // panels slide open at this rate, and the brisk default feels abrupt. + style.animation_time = 0.18; + ctx.set_style(style); } diff --git a/client/src/graphic/ui_engine.rs b/client/src/graphic/ui_engine.rs index 54e741b..03463c0 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -61,7 +61,7 @@ pub fn gather_egui_inputs( return raw_input; } - let mouse = MOUSE_STATE.lock().unwrap(); + let mut mouse = MOUSE_STATE.lock().unwrap(); let current_pos = egui::pos2( (mouse.x as f32) / scale_factor, (mouse.y as f32) / scale_factor, @@ -94,6 +94,18 @@ pub fn gather_egui_inputs( state.last_right_down = mouse.right_down; } + // Drain the accumulated scroll-wheel delta into an egui event. + let (scroll_x, scroll_y) = (mouse.scroll_x, mouse.scroll_y); + mouse.scroll_x = 0.0; + mouse.scroll_y = 0.0; + if scroll_x != 0.0 || scroll_y != 0.0 { + raw_input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(scroll_x, scroll_y), + modifiers: Default::default(), + }); + } + raw_input } @@ -218,6 +230,12 @@ pub unsafe fn render_egui_ui() { } pub fn call_panic() { + // Persist the user's setup before tearing it down: Panic does not go + // through the GUI's close handler (which is what normally saves), and the + // modules are about to be force-disabled for the unload — so save now, + // while they still hold the state the user actually chose. + crate::config::save(); + for handle in crate::state::client().modules.handles() { let Ok(mut module) = handle.lock() else { continue; @@ -227,7 +245,7 @@ pub fn call_panic() { if let Err(e) = module.on_stop() { log::error!( "Failed to stop module {} on panic: {}", - module.get_module_data().name, + module.get_module_data().name(), e ); } diff --git a/client/src/lib.rs b/client/src/lib.rs index eef9a44..645953c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -3,9 +3,11 @@ #![allow(dead_code)] extern crate jni; +mod config; mod graphic; mod mapping; mod module; +pub mod net; mod state; /// OpenGL bindings, generated by `gl_generator` in `build.rs`. The generated @@ -19,7 +21,9 @@ use crate::graphic::hook::{install_hooks, uninstall_hooks}; use crate::module::combat::aimbot::AimbotModule; use crate::module::combat::killaura::KillAuraModule; use crate::module::combat::mobaura::MobAuraModule; +use crate::module::combat::velocity::VelocityModule; use crate::module::movement::fly::FlyModule; +use crate::module::movement::nofall::NoFallModule; use crate::module::render::chest_esp::ChestEspModule; use crate::module::render::mob_esp::MobEspModule; use crate::module::render::player_esp::PlayerEspModule; @@ -42,10 +46,13 @@ pub extern "C" fn initialize_client() { return; } + // The log lives alongside the config, in the injector's directory — never + // in `.minecraft` — see `config::base_dir`. + let log_path = config::base_dir().join("dark_client.log"); match WriteLogger::init( LevelFilter::Debug, Config::default(), - File::create("dark_client.log").unwrap(), + File::create(&log_path).unwrap(), ) { Ok(_) => info!("Logger initialized"), Err(e) => eprintln!("Error during logger initialization: {:?}", e), @@ -70,6 +77,8 @@ pub extern "C" fn initialize_client() { return; } register_modules(); + // Restore saved keybinds, settings and enabled state. + config::load(); if let Err(e) = install_hooks() { error!("Failed to install hooks: {e}"); } @@ -84,6 +93,9 @@ pub extern "C" fn cleanup_client() { RUNNING.store(false, Ordering::SeqCst); uninstall_hooks(); crate::graphic::input::cleanup(); + // Hooks are gone and `RUNNING` is clear, so nothing else touches the + // global state — release the JVM references it holds before unload. + crate::state::teardown(); info!("Client cleanup completed"); } @@ -91,9 +103,11 @@ pub extern "C" fn cleanup_client() { fn register_modules() { let modules = &client().modules; modules.register(FlyModule::new()); + modules.register(NoFallModule::new()); modules.register(KillAuraModule::new()); modules.register(MobAuraModule::new()); modules.register(AimbotModule::new()); + modules.register(VelocityModule::new()); modules.register(PlayerEspModule::new()); modules.register(MobEspModule::new()); modules.register(ChestEspModule::new()); diff --git a/client/src/mapping/block_entity.rs b/client/src/mapping/block_entity.rs new file mode 100644 index 0000000..1960a8c --- /dev/null +++ b/client/src/mapping/block_entity.rs @@ -0,0 +1,47 @@ +//! Wrapper for Minecraft's `BlockEntity` (chests, barrels, …). + +use crate::mapping::math::BlockPos; +use crate::mapping::{MappedObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; + +/// A Minecraft `BlockEntity`. +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = BlockEntity)] +pub struct BlockEntity { + jni_ref: GlobalRef, +} + +impl BlockEntity { + /// Wraps an existing `BlockEntity` JVM object. + pub fn new(jni_ref: GlobalRef) -> BlockEntity { + BlockEntity { jni_ref } + } + + /// The block position this block entity occupies. + pub fn get_block_pos(&self) -> anyhow::Result { + self.in_frame(|| { + let pos = self.call_method("getBlockPos", &[])?.l()?; + BlockPos::read(&pos) + }) + } + + /// Whether this block entity is a storage container — chest (incl. trapped + /// chest, a `ChestBlockEntity` subclass), ender chest, barrel or shulker box. + pub fn is_container(&self) -> bool { + const KINDS: [MinecraftClassType; 4] = [ + MinecraftClassType::ChestBlockEntity, + MinecraftClassType::EnderChestBlockEntity, + MinecraftClassType::BarrelBlockEntity, + MinecraftClassType::ShulkerBoxBlockEntity, + ]; + self.in_frame(|| { + Ok(KINDS.iter().any(|&kind| { + mapping() + .is_instance_of(kind, self.jni_ref().as_obj()) + .unwrap_or(false) + })) + }) + .unwrap_or(false) + } +} diff --git a/client/src/mapping/class_type.rs b/client/src/mapping/class_type.rs index 0101398..d62c897 100644 --- a/client/src/mapping/class_type.rs +++ b/client/src/mapping/class_type.rs @@ -33,6 +33,21 @@ pub enum MinecraftClassType { Options, OptionInstance, Integer, + Double, + InteractionHand, + ChatScreen, + PauseScreen, + AbstractContainerScreen, + InventoryScreen, + CraftingScreen, + CreativeModeInventoryScreen, + // Network packets — the connection layer (`net/`) reads and rewrites these. + ServerboundMovePlayerPacket, + ServerboundMovePlayerPacketPos, + ServerboundMovePlayerPacketPosRot, + ServerboundMovePlayerPacketRot, + ServerboundMovePlayerPacketStatusOnly, + ClientboundSetEntityMotionPacket, } impl MinecraftClassType { @@ -78,6 +93,40 @@ impl MinecraftClassType { MinecraftClassType::Options => "net/minecraft/client/Options", MinecraftClassType::OptionInstance => "net/minecraft/client/OptionInstance", MinecraftClassType::Integer => "java/lang/Integer", + MinecraftClassType::Double => "java/lang/Double", + MinecraftClassType::InteractionHand => "net/minecraft/world/InteractionHand", + MinecraftClassType::ChatScreen => "net/minecraft/client/gui/screens/ChatScreen", + MinecraftClassType::PauseScreen => "net/minecraft/client/gui/screens/PauseScreen", + MinecraftClassType::AbstractContainerScreen => { + "net/minecraft/client/gui/screens/inventory/AbstractContainerScreen" + } + MinecraftClassType::InventoryScreen => { + "net/minecraft/client/gui/screens/inventory/InventoryScreen" + } + MinecraftClassType::CraftingScreen => { + "net/minecraft/client/gui/screens/inventory/CraftingScreen" + } + MinecraftClassType::CreativeModeInventoryScreen => { + "net/minecraft/client/gui/screens/inventory/CreativeModeInventoryScreen" + } + MinecraftClassType::ServerboundMovePlayerPacket => { + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket" + } + MinecraftClassType::ServerboundMovePlayerPacketPos => { + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Pos" + } + MinecraftClassType::ServerboundMovePlayerPacketPosRot => { + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$PosRot" + } + MinecraftClassType::ServerboundMovePlayerPacketRot => { + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Rot" + } + MinecraftClassType::ServerboundMovePlayerPacketStatusOnly => { + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$StatusOnly" + } + MinecraftClassType::ClientboundSetEntityMotionPacket => { + "net/minecraft/network/protocol/game/ClientboundSetEntityMotionPacket" + } } } } diff --git a/client/src/mapping/client/camera.rs b/client/src/mapping/client/camera.rs new file mode 100644 index 0000000..d81aa25 --- /dev/null +++ b/client/src/mapping/client/camera.rs @@ -0,0 +1,42 @@ +//! Wrapper for Minecraft's render `Camera`. + +use crate::mapping::math::Vec3; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; +use jni::objects::GlobalRef; + +/// Minecraft's render `Camera`. +/// +/// Position and rotation are read from the plain fields rather than getter +/// methods: field names are far stabler than method names across versions. +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Camera)] +pub struct Camera { + jni_ref: GlobalRef, +} + +impl Camera { + /// Wraps an existing `Camera` JVM object. + pub fn new(jni_ref: GlobalRef) -> Camera { + Camera { jni_ref } + } + + /// World-space position of the camera. + pub fn position(&self) -> anyhow::Result { + self.in_frame(|| { + let vec3 = self + .get_field("position", FieldType::Object(MinecraftClassType::Vec3))? + .l()?; + Vec3::read(&vec3) + }) + } + + /// Yaw, in degrees (the `yRot` field). + pub fn yaw(&self) -> anyhow::Result { + Ok(self.get_field("yRot", FieldType::Float)?.f()?) + } + + /// Pitch, in degrees (the `xRot` field). + pub fn pitch(&self) -> anyhow::Result { + Ok(self.get_field("xRot", FieldType::Float)?.f()?) + } +} diff --git a/client/src/mapping/client/game_renderer.rs b/client/src/mapping/client/game_renderer.rs new file mode 100644 index 0000000..0896dd8 --- /dev/null +++ b/client/src/mapping/client/game_renderer.rs @@ -0,0 +1,28 @@ +//! Wrapper for Minecraft's `GameRenderer`. + +use crate::mapping::client::camera::Camera; +use crate::mapping::MappedObject; +use crate::state::mapping; +use jni::objects::GlobalRef; + +/// Minecraft's `GameRenderer`. +#[derive(Debug, MappedObject)] +#[mapped(class = GameRenderer)] +pub struct GameRenderer { + jni_ref: GlobalRef, +} + +impl GameRenderer { + /// Wraps an existing `GameRenderer` JVM object. + pub fn new(jni_ref: GlobalRef) -> GameRenderer { + GameRenderer { jni_ref } + } + + /// The main render [`Camera`]. + pub fn get_main_camera(&self) -> anyhow::Result { + self.in_frame(|| { + let camera = self.call_method("getMainCamera", &[])?.l()?; + Ok(Camera::new(mapping().new_global_ref(camera)?)) + }) + } +} diff --git a/client/src/mapping/client/gamemode.rs b/client/src/mapping/client/gamemode.rs index 512e0d3..567031f 100644 --- a/client/src/mapping/client/gamemode.rs +++ b/client/src/mapping/client/gamemode.rs @@ -1,13 +1,12 @@ use crate::mapping::entity::player::LocalPlayer; use crate::mapping::entity::Entity; -use crate::mapping::MinecraftClassType; -use crate::state::mapping; +use crate::mapping::MappedObject; use jni::objects::{GlobalRef, JValue}; -use std::ops::Deref; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = MultiPlayerGameMode)] pub struct MultiPlayerGameMode { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl MultiPlayerGameMode { @@ -15,25 +14,15 @@ impl MultiPlayerGameMode { Self { jni_ref } } + /// Attacks `target` on behalf of the local player. pub fn attack(&self, player: &LocalPlayer, target: &Entity) -> anyhow::Result<()> { - mapping().call_method( - MinecraftClassType::MultiPlayerGameMode, - self.jni_ref.as_obj(), + self.call_method( "attack", &[ - JValue::Object(player.jni_ref.as_obj()), - JValue::Object(target.jni_ref.as_obj()), + JValue::Object(player.jni_ref().as_obj()), + JValue::Object(target.jni_ref().as_obj()), ], )?; - Ok(()) } } - -impl Deref for MultiPlayerGameMode { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/client/minecraft.rs b/client/src/mapping/client/minecraft.rs index 06e23e3..e19433e 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -1,11 +1,13 @@ +use crate::mapping::client::game_renderer::GameRenderer; use crate::mapping::client::gamemode::MultiPlayerGameMode; +use crate::mapping::client::options::Options; +use crate::mapping::client::screen::Screen; use crate::mapping::client::window::Window; use crate::mapping::client::world::World; use crate::mapping::entity::player::LocalPlayer; -use crate::mapping::{FieldType, MinecraftClassType}; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; use std::sync::RwLock; /// The running Minecraft client. @@ -15,9 +17,10 @@ use std::sync::RwLock; /// objects — player, level, game mode — are null in the menu and on world /// exit, so they are fetched lazily and reported as `Ok(None)` when absent. /// This is what lets the client be injected from the main menu. -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = Minecraft)] pub struct Minecraft { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, pub window: Window, /// Cached local player, refreshed when the underlying JVM object changes. player: RwLock>, @@ -27,13 +30,15 @@ impl Minecraft { /// Builds the game wrapper from the live `Minecraft.getInstance()`. /// Succeeds whether or not a world is loaded. pub fn new() -> anyhow::Result { - let minecraft = mapping() - .call_static_method(MinecraftClassType::Minecraft, "getInstance", &[])? - .l()?; - if minecraft.is_null() { - return Err(anyhow::anyhow!("Minecraft.getInstance() returned null")); - } - let jni_ref = mapping().new_global_ref(minecraft)?; + let jni_ref = mapping().in_frame(|| { + let minecraft = mapping() + .call_static_method(MinecraftClassType::Minecraft, "getInstance", &[])? + .l()?; + if minecraft.is_null() { + return Err(anyhow::anyhow!("Minecraft.getInstance() returned null")); + } + mapping().new_global_ref(minecraft) + })?; let window = Window::new(&jni_ref)?; Ok(Minecraft { @@ -49,6 +54,11 @@ impl Minecraft { /// changes (a new world join produces a fresh instance). pub fn player(&self) -> anyhow::Result> { let Some(player_ref) = self.world_object("player", MinecraftClassType::LocalPlayer)? else { + // Left the world: drop the cached `LocalPlayer` so its JVM object + // is no longer pinned by a global reference until the next join. + if let Ok(mut cache) = self.player.write() { + *cache = None; + } return Ok(None); }; @@ -60,7 +70,7 @@ impl Minecraft { if let Some(cached) = cache.as_ref() { if mapping() .get_env()? - .is_same_object(&cached.jni_ref, &player_ref)? + .is_same_object(cached.jni_ref(), &player_ref)? { return Ok(Some(cached.clone())); } @@ -89,24 +99,80 @@ impl Minecraft { .map(MultiPlayerGameMode::new)) } + /// The game renderer. Present from the main menu onward. + pub fn game_renderer(&self) -> anyhow::Result { + self.in_frame(|| { + let obj = self + .get_field( + "gameRenderer", + FieldType::Object(MinecraftClassType::GameRenderer), + )? + .l()?; + Ok(GameRenderer::new(mapping().new_global_ref(obj)?)) + }) + } + + /// The game options. Present from the main menu onward. + pub fn options(&self) -> anyhow::Result { + self.in_frame(|| { + let obj = self + .get_field("options", FieldType::Object(MinecraftClassType::Options))? + .l()?; + Ok(Options::new(mapping().new_global_ref(obj)?)) + }) + } + /// Whether a world is currently loaded. pub fn in_world(&self) -> bool { matches!(self.player(), Ok(Some(_))) } + /// Drops the cached local player — a held global reference. Called from + /// `cleanup_client` before the library is unloaded. + pub fn teardown(&self) { + if let Ok(mut cache) = self.player.write() { + *cache = None; + } + } + /// Whether no screen (menu / inventory / …) is currently open. pub fn current_screen_is_null(&self) -> bool { - if let Ok(screen_obj) = mapping().get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - "screen", - FieldType::Object(MinecraftClassType::Screen), - ) { - if let Ok(l) = screen_obj.l() { - return l.is_null(); + self.current_screen() == Screen::None + } + + /// The Minecraft screen currently open. Best-effort: any JNI failure — or + /// an unrecognized screen — reports [`Screen::Unknown`], which callers + /// treat as "a screen is open". + pub fn current_screen(&self) -> Screen { + self.in_frame(|| { + let screen = self + .get_field("screen", FieldType::Object(MinecraftClassType::Screen))? + .l()?; + if screen.is_null() { + return Ok(Screen::None); } - } - true + + let is = |class| mapping().is_instance_of(class, &screen).unwrap_or(false); + // Specific screens first — `InventoryScreen` / `CraftingScreen` + // both extend `AbstractContainerScreen`. + let screen = if is(MinecraftClassType::ChatScreen) { + Screen::Chat + } else if is(MinecraftClassType::CreativeModeInventoryScreen) + || is(MinecraftClassType::InventoryScreen) + { + Screen::Inventory + } else if is(MinecraftClassType::CraftingScreen) { + Screen::Crafting + } else if is(MinecraftClassType::AbstractContainerScreen) { + Screen::Container + } else if is(MinecraftClassType::PauseScreen) { + Screen::Menu + } else { + Screen::Unknown + }; + Ok(screen) + }) + .unwrap_or(Screen::Unknown) } /// Reads a world-scoped object field of `Minecraft`, returning `Ok(None)` @@ -116,25 +182,12 @@ impl Minecraft { field: &str, class: MinecraftClassType, ) -> anyhow::Result> { - let obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - field, - FieldType::Object(class), - )? - .l()?; - if obj.is_null() { - return Ok(None); - } - Ok(Some(mapping().new_global_ref(obj)?)) - } -} - -impl Deref for Minecraft { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + self.in_frame(|| { + let obj = self.get_field(field, FieldType::Object(class))?.l()?; + if obj.is_null() { + return Ok(None); + } + Ok(Some(mapping().new_global_ref(obj)?)) + }) } } diff --git a/client/src/mapping/client/mod.rs b/client/src/mapping/client/mod.rs index 05af714..2838158 100644 --- a/client/src/mapping/client/mod.rs +++ b/client/src/mapping/client/mod.rs @@ -1,4 +1,8 @@ +pub mod camera; +pub mod game_renderer; pub mod gamemode; pub mod minecraft; +pub mod options; +pub mod screen; pub mod window; pub mod world; diff --git a/client/src/mapping/client/options.rs b/client/src/mapping/client/options.rs new file mode 100644 index 0000000..195a7a7 --- /dev/null +++ b/client/src/mapping/client/options.rs @@ -0,0 +1,78 @@ +//! Wrappers for Minecraft's `Options` and `OptionInstance`. + +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; + +/// Minecraft's game `Options`. +#[derive(Debug, MappedObject)] +#[mapped(class = Options)] +pub struct Options { + jni_ref: GlobalRef, +} + +impl Options { + /// Wraps an existing `Options` JVM object. + pub fn new(jni_ref: GlobalRef) -> Options { + Options { jni_ref } + } + + /// The field-of-view option. + pub fn fov(&self) -> anyhow::Result { + self.in_frame(|| { + let option = self + .get_field("fov", FieldType::Object(MinecraftClassType::OptionInstance))? + .l()?; + Ok(OptionInstance::new(mapping().new_global_ref(option)?)) + }) + } + + /// The mouse-sensitivity option. + pub fn sensitivity(&self) -> anyhow::Result { + self.in_frame(|| { + let option = self + .get_field( + "sensitivity", + FieldType::Object(MinecraftClassType::OptionInstance), + )? + .l()?; + Ok(OptionInstance::new(mapping().new_global_ref(option)?)) + }) + } +} + +/// A single Minecraft `OptionInstance` — one configurable game option. +#[derive(Debug, MappedObject)] +#[mapped(class = OptionInstance)] +pub struct OptionInstance { + jni_ref: GlobalRef, +} + +impl OptionInstance { + /// Wraps an existing `OptionInstance` JVM object. + pub fn new(jni_ref: GlobalRef) -> OptionInstance { + OptionInstance { jni_ref } + } + + /// The current value, read as an `int` — the option's boxed value is + /// unwrapped through `Integer.intValue()`. + pub fn get_int(&self) -> anyhow::Result { + self.in_frame(|| { + let value = self.call_method("get", &[])?.l()?; + Ok(mapping() + .call_method(MinecraftClassType::Integer, &value, "intValue", &[])? + .i()?) + }) + } + + /// The current value, read as a `double` — the option's boxed value is + /// unwrapped through `Double.doubleValue()`. + pub fn get_double(&self) -> anyhow::Result { + self.in_frame(|| { + let value = self.call_method("get", &[])?.l()?; + Ok(mapping() + .call_method(MinecraftClassType::Double, &value, "doubleValue", &[])? + .d()?) + }) + } +} diff --git a/client/src/mapping/client/screen.rs b/client/src/mapping/client/screen.rs new file mode 100644 index 0000000..dc511d5 --- /dev/null +++ b/client/src/mapping/client/screen.rs @@ -0,0 +1,31 @@ +//! The Minecraft screen currently open. +//! +//! Combat modules query [`Minecraft::current_screen`](super::minecraft::Minecraft::current_screen) +//! and stand down while a screen is open — the player cannot fight with an +//! inventory, chest, crafting table or chat in front of them. + +/// Which Minecraft screen is currently open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + /// No screen — the player is in the world. + None, + /// The chat input screen. + Chat, + /// The player inventory (survival or creative). + Inventory, + /// A container UI — chest, barrel, furnace, … + Container, + /// A crafting-table UI. + Crafting, + /// A non-gameplay menu — pause, options, … + Menu, + /// A screen is open but its kind was not recognized — treated as open. + Unknown, +} + +impl Screen { + /// Whether a screen is open, i.e. modules should stand down. + pub fn is_open(self) -> bool { + self != Screen::None + } +} diff --git a/client/src/mapping/client/window.rs b/client/src/mapping/client/window.rs index de9ceb2..395842f 100644 --- a/client/src/mapping/client/window.rs +++ b/client/src/mapping/client/window.rs @@ -1,49 +1,35 @@ use crate::mapping::method::MethodName; -use crate::mapping::MinecraftClassType; +use crate::mapping::{MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use jni::sys::jlong; -use std::ops::Deref; -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = Window)] pub struct Window { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Window { pub fn new(minecraft: &GlobalRef) -> anyhow::Result { - let window_obj = mapping() - .call_method( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "getWindow", - &[], - )? - .l()?; - - Ok(Window { - jni_ref: mapping().new_global_ref(window_obj)?, + mapping().in_frame(|| { + let window_obj = mapping() + .call_method( + MinecraftClassType::Minecraft, + minecraft.as_obj(), + "getWindow", + &[], + )? + .l()?; + Ok(Window { + jni_ref: mapping().new_global_ref(window_obj)?, + }) }) } + /// The native GLFW window handle. pub fn get_window(&self) -> anyhow::Result { - let mapping = mapping(); - - Ok(mapping - .call_method( - MinecraftClassType::Window, - self.jni_ref.as_obj(), - MethodName::WindowGetWindow.get_name(mapping.get_version()), - &[], - )? - .j()?) - } -} - -impl Deref for Window { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + let name = MethodName::WindowGetWindow.get_name(mapping().get_version()); + Ok(self.call_method(name, &[])?.j()?) } } diff --git a/client/src/mapping/client/world.rs b/client/src/mapping/client/world.rs index be25a6a..88f18d2 100644 --- a/client/src/mapping/client/world.rs +++ b/client/src/mapping/client/world.rs @@ -1,11 +1,12 @@ +use crate::mapping::block_entity::BlockEntity; use crate::mapping::entity::Entity; use crate::mapping::java::iterable::Iterable; -use crate::mapping::MinecraftClassType; +use crate::mapping::{MappedObject, MinecraftClassType}; use crate::state::mapping; -use jni::objects::GlobalRef; -use std::ops::Deref; +use jni::objects::{GlobalRef, JValue}; -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = Level)] pub struct World { jni_ref: GlobalRef, } @@ -16,35 +17,68 @@ impl World { World { jni_ref } } + /// Every entity the client is currently rendering. pub fn get_entities(&self) -> anyhow::Result> { - let iterable_obj = mapping() - .call_method( - MinecraftClassType::Level, - self.jni_ref.as_obj(), - "entitiesForRendering", - &[], - )? - .l()?; - - let iterable = Iterable { - jni_ref: mapping().new_global_ref(iterable_obj)?, - }; - - let iterator = iterable.iterator()?; - let mut entities = Vec::new(); - - while iterator.has_next()? { - entities.push(Entity::new(iterator.next()?)); - } - - Ok(entities) + self.in_frame(|| { + let iterable_obj = self.call_method("entitiesForRendering", &[])?.l()?; + let iterable = Iterable::new(mapping().new_global_ref(iterable_obj)?); + + let iterator = iterable.iterator()?; + let mut entities = Vec::new(); + while iterator.has_next()? { + entities.push(Entity::new(iterator.next()?)); + } + Ok(entities) + }) + } + + /// The chunk at chunk-grid coordinates `(x, z)`, or `Ok(None)` when it is + /// not loaded. + pub fn get_chunk(&self, x: i32, z: i32) -> anyhow::Result> { + self.in_frame(|| { + let chunk = self + .call_method("getChunk", &[JValue::Int(x), JValue::Int(z)])? + .l()?; + if chunk.is_null() { + return Ok(None); + } + Ok(Some(LevelChunk::new(mapping().new_global_ref(chunk)?))) + }) } } -impl Deref for World { - type Target = GlobalRef; +/// A loaded `LevelChunk`. +#[derive(Debug, MappedObject)] +#[mapped(class = LevelChunk)] +pub struct LevelChunk { + jni_ref: GlobalRef, +} + +impl LevelChunk { + /// Wraps an existing `LevelChunk` JVM object. + pub fn new(jni_ref: GlobalRef) -> LevelChunk { + LevelChunk { jni_ref } + } + + /// Every block entity currently in this chunk. + pub fn get_block_entities(&self) -> anyhow::Result> { + self.in_frame(|| { + let map = self.call_method("getBlockEntities", &[])?.l()?; + if map.is_null() { + return Ok(Vec::new()); + } + + let values = mapping() + .call_method(MinecraftClassType::Map, &map, "values", &[])? + .l()?; + let iterable = Iterable::new(mapping().new_global_ref(values)?); + let iterator = iterable.iterator()?; - fn deref(&self) -> &Self::Target { - &self.jni_ref + let mut block_entities = Vec::new(); + while iterator.has_next()? { + block_entities.push(BlockEntity::new(iterator.next()?)); + } + Ok(block_entities) + }) } } diff --git a/client/src/mapping/component.rs b/client/src/mapping/component.rs new file mode 100644 index 0000000..c19e262 --- /dev/null +++ b/client/src/mapping/component.rs @@ -0,0 +1,27 @@ +//! Wrapper for Minecraft's `Component` — a piece of rich (chat / display) text. + +use crate::mapping::MappedObject; +use crate::state::mapping; +use jni::objects::GlobalRef; + +/// A Minecraft `Component`. +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Component)] +pub struct Component { + jni_ref: GlobalRef, +} + +impl Component { + /// Wraps an existing `Component` JVM object. + pub fn new(jni_ref: GlobalRef) -> Component { + Component { jni_ref } + } + + /// The component flattened to plain text. + pub fn get_string(&self) -> anyhow::Result { + self.in_frame(|| { + let string = self.call_method("getString", &[])?.l()?; + mapping().get_string(string) + }) + } +} diff --git a/client/src/mapping/entity/living.rs b/client/src/mapping/entity/living.rs new file mode 100644 index 0000000..2c35223 --- /dev/null +++ b/client/src/mapping/entity/living.rs @@ -0,0 +1,33 @@ +//! Wrapper for Minecraft's `LivingEntity`. + +use crate::mapping::entity::Entity; +use crate::mapping::MappedObject; +use jni::objects::GlobalRef; + +/// A Minecraft `LivingEntity` — an [`Entity`] that also carries health. +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = LivingEntity)] +pub struct LivingEntity { + jni_ref: GlobalRef, + pub entity: Entity, +} + +impl LivingEntity { + /// Wraps an existing `LivingEntity` JVM object. + pub fn new(jni_ref: GlobalRef) -> LivingEntity { + LivingEntity { + entity: Entity::new(jni_ref.clone()), + jni_ref, + } + } + + /// Current health. + pub fn get_health(&self) -> anyhow::Result { + Ok(self.call_method("getHealth", &[])?.f()?) + } + + /// Maximum health. + pub fn get_max_health(&self) -> anyhow::Result { + Ok(self.call_method("getMaxHealth", &[])?.f()?) + } +} diff --git a/client/src/mapping/entity/mob.rs b/client/src/mapping/entity/mob.rs new file mode 100644 index 0000000..4764a91 --- /dev/null +++ b/client/src/mapping/entity/mob.rs @@ -0,0 +1,23 @@ +//! Wrapper for Minecraft's `Mob` — a non-player living entity. + +use crate::mapping::entity::Entity; +use crate::mapping::MappedObject; +use jni::objects::GlobalRef; + +/// A Minecraft `Mob` (non-player living entity). +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Mob)] +pub struct Mob { + jni_ref: GlobalRef, + pub entity: Entity, +} + +impl Mob { + /// Wraps an existing `Mob` JVM object. + pub fn new(jni_ref: GlobalRef) -> Mob { + Mob { + entity: Entity::new(jni_ref.clone()), + jni_ref, + } + } +} diff --git a/client/src/mapping/entity/mod.rs b/client/src/mapping/entity/mod.rs index ba589e3..d26d274 100644 --- a/client/src/mapping/entity/mod.rs +++ b/client/src/mapping/entity/mod.rs @@ -1,122 +1,123 @@ -use crate::mapping::{FieldType, MinecraftClassType}; +use crate::mapping::component::Component; +use crate::mapping::entity::living::LivingEntity; +use crate::mapping::math::Vec3; +use crate::mapping::{FieldType, MappedObject}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; -use std::ops::Deref; +pub mod living; +pub mod mob; pub mod player; -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct EntityLivingBase { - pub jni_ref: GlobalRef, -} - -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Entity)] pub struct Entity { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } -#[allow(dead_code)] impl Entity { + /// Wraps an existing `Entity` JVM object. pub fn new(jni_ref: GlobalRef) -> Entity { Entity { jni_ref } } - pub fn get_position(&self) -> anyhow::Result<(f64, f64, f64)> { - let vec3 = mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "position", - &[], - )? - .l()?; - - let x = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "x", FieldType::Double)? - .d()?; + /// Views this entity as a [`LivingEntity`], or `None` when it is not a + /// living entity — calling health methods on a non-living entity would fail. + pub fn as_living(&self) -> Option { + self.instance_of::() + .then(|| LivingEntity::new(self.jni_ref().clone())) + } - let y = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "y", FieldType::Double)? - .d()?; + /// The entity's network id. + pub fn id(&self) -> anyhow::Result { + Ok(self.call_method("getId", &[])?.i()?) + } - let z = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "z", FieldType::Double)? - .d()?; + /// The entity's world position (feet). + pub fn get_position(&self) -> anyhow::Result { + self.in_frame(|| { + let vec3 = self.call_method("position", &[])?.l()?; + Vec3::read(&vec3) + }) + } - Ok((x, y, z)) + /// The entity's eye position — the origin to aim rotations from. + pub fn get_eye_position(&self) -> anyhow::Result { + self.in_frame(|| { + let vec3 = self.call_method("getEyePosition", &[])?.l()?; + Vec3::read(&vec3) + }) } - pub fn set_invulnerable(&self, value: bool) -> anyhow::Result<()> { - mapping().call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "setInvulnerable", - &[JValue::from(value)], - )?; + /// The entity's yaw, in degrees. + pub fn get_yaw(&self) -> anyhow::Result { + Ok(self.call_method("getYRot", &[])?.f()?) + } - Ok(()) + /// The entity's pitch, in degrees. + pub fn get_pitch(&self) -> anyhow::Result { + Ok(self.call_method("getXRot", &[])?.f()?) } - pub fn get_fall_distance(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "fallDistance", - FieldType::Double, + /// Squared distance from this entity to a world point. + pub fn distance_to_sqr(&self, x: f64, y: f64, z: f64) -> anyhow::Result { + Ok(self + .call_method( + "distanceToSqr", + &[JValue::Double(x), JValue::Double(y), JValue::Double(z)], )? .d()?) } - pub fn reset_fall_distance(&self) -> anyhow::Result<()> { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "resetFallDistance", - &[], - )? - .v()?) + /// Collision-box width. + pub fn bb_width(&self) -> anyhow::Result { + Ok(self.call_method("getBbWidth", &[])?.f()?) } - pub fn get_name(&self) -> anyhow::Result { - mapping().get_string( - mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "getName", - &[], - )? - .l()?, - ) + /// Collision-box height. + pub fn bb_height(&self) -> anyhow::Result { + Ok(self.call_method("getBbHeight", &[])?.f()?) } - pub fn get_tick_count(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "tickCount", - FieldType::Int, - )? - .i()?) + /// Whether the entity is currently sprinting. + pub fn is_sprinting(&self) -> anyhow::Result { + Ok(self.call_method("isSprinting", &[])?.z()?) } -} -impl Deref for Entity { - type Target = GlobalRef; + pub fn set_invulnerable(&self, value: bool) -> anyhow::Result<()> { + self.call_method("setInvulnerable", &[JValue::from(value)])?; + Ok(()) + } - fn deref(&self) -> &Self::Target { - &self.jni_ref + /// Sets the entity's yaw and pitch, in degrees. The previous-tick rotation + /// (`yRotO` / `xRotO`) is written too, so Minecraft renders the camera + /// exactly at this rotation instead of interpolating toward it — the + /// per-frame rotation system supplies the smoothing itself. + pub fn set_rotation(&self, yaw: f32, pitch: f32) -> anyhow::Result<()> { + self.call_method("setYRot", &[JValue::Float(yaw)])?; + self.call_method("setXRot", &[JValue::Float(pitch)])?; + self.set_field("yRotO", FieldType::Float, JValue::Float(yaw))?; + self.set_field("xRotO", FieldType::Float, JValue::Float(pitch))?; + Ok(()) } -} -impl Deref for EntityLivingBase { - type Target = GlobalRef; + pub fn get_fall_distance(&self) -> anyhow::Result { + Ok(self.get_field("fallDistance", FieldType::Double)?.d()?) + } + + pub fn reset_fall_distance(&self) -> anyhow::Result<()> { + Ok(self.call_method("resetFallDistance", &[])?.v()?) + } + + /// The entity's display name, as a [`Component`]. + pub fn get_name(&self) -> anyhow::Result { + self.in_frame(|| { + let component = self.call_method("getName", &[])?.l()?; + Ok(Component::new(mapping().new_global_ref(component)?)) + }) + } - fn deref(&self) -> &Self::Target { - &self.jni_ref + pub fn get_tick_count(&self) -> anyhow::Result { + Ok(self.get_field("tickCount", FieldType::Int)?.i()?) } } diff --git a/client/src/mapping/entity/player.rs b/client/src/mapping/entity/player.rs index fc4f87c..3723732 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -1,20 +1,43 @@ +//! Wrappers for Minecraft's player classes: `Player`, `LocalPlayer` and the +//! `Abilities` they carry. + use crate::mapping::entity::Entity; -use crate::mapping::{FieldType, MinecraftClassType}; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use jni::sys::jboolean; -use std::ops::Deref; -#[derive(Debug, Clone)] +/// Any Minecraft `Player` entity (local or remote). +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Player)] +pub struct Player { + jni_ref: GlobalRef, + pub entity: Entity, +} + +impl Player { + /// Wraps an existing `Player` JVM object. + pub fn new(jni_ref: GlobalRef) -> Player { + Player { + entity: Entity::new(jni_ref.clone()), + jni_ref, + } + } +} + +/// The client's own `LocalPlayer`. +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = LocalPlayer)] pub struct LocalPlayer { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, pub abilities: Abilities, pub entity: Entity, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Abilities)] pub struct Abilities { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl LocalPlayer { @@ -26,65 +49,63 @@ impl LocalPlayer { jni_ref: player_ref, }) } + + /// The melee attack strength, `0.0..=1.0` — `1.0` once the attack cooldown + /// has fully recharged. Below `1.0` the next hit deals reduced damage. + pub fn attack_strength_scale(&self) -> anyhow::Result { + Ok(self + .call_method("getAttackStrengthScale", &[JValue::Float(0.5)])? + .f()?) + } + + /// Plays the main-hand swing animation (and sends it to the server). + pub fn swing(&self) -> anyhow::Result<()> { + self.in_frame(|| { + let hand = mapping() + .get_static_field( + MinecraftClassType::InteractionHand, + "MAIN_HAND", + FieldType::Object(MinecraftClassType::InteractionHand), + )? + .l()?; + self.call_method("swing", &[JValue::Object(&hand)])?; + Ok(()) + }) + } } impl Abilities { pub fn new(player: GlobalRef) -> anyhow::Result { - let jni_ref = mapping() - .call_method(MinecraftClassType::Player, &player, "getAbilities", &[])? - .l()?; - Ok(Self { - jni_ref: mapping().new_global_ref(jni_ref)?, + mapping().in_frame(|| { + let jni_ref = mapping() + .call_method(MinecraftClassType::Player, &player, "getAbilities", &[])? + .l()?; + Ok(Self { + jni_ref: mapping().new_global_ref(jni_ref)?, + }) }) } pub fn fly(&self, value: bool) -> anyhow::Result<()> { let value: jboolean = if value { 1 } else { 0 }; - - mapping().set_field( - MinecraftClassType::Abilities, - self.jni_ref.as_obj(), - "flying", - FieldType::Boolean, - JValue::Bool(value), - )?; - - mapping().set_field( - MinecraftClassType::Abilities, - self.jni_ref.as_obj(), - "mayfly", - FieldType::Boolean, - JValue::Bool(value), - )?; - + self.set_field("flying", FieldType::Boolean, JValue::Bool(value))?; + self.set_field("mayfly", FieldType::Boolean, JValue::Bool(value))?; Ok(()) } - #[allow(dead_code)] - pub fn get_may_fly(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Abilities, - self.jni_ref.as_obj(), - "mayfly", - FieldType::Boolean, - )? - .z()?) + /// Whether the player is currently flying. + pub fn is_flying(&self) -> anyhow::Result { + Ok(self.get_field("flying", FieldType::Boolean)?.z()?) } -} -impl Deref for LocalPlayer { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + /// Sets the creative-fly speed (vanilla default `0.05`). + pub fn set_flying_speed(&self, speed: f32) -> anyhow::Result<()> { + self.call_method("setFlyingSpeed", &[JValue::Float(speed)])?; + Ok(()) } -} -impl Deref for Abilities { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + #[allow(dead_code)] + pub fn get_may_fly(&self) -> anyhow::Result { + Ok(self.get_field("mayfly", FieldType::Boolean)?.z()?) } } diff --git a/client/src/mapping/java/iterable.rs b/client/src/mapping/java/iterable.rs index 081f3eb..e3898cb 100644 --- a/client/src/mapping/java/iterable.rs +++ b/client/src/mapping/java/iterable.rs @@ -1,35 +1,24 @@ use crate::mapping::java::iterator::Iterator; -use crate::mapping::MinecraftClassType; +use crate::mapping::MappedObject; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Iterable)] pub struct Iterable { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Iterable { - pub fn iterator(&self) -> anyhow::Result { - let iterator_obj = mapping() - .call_method( - MinecraftClassType::Iterable, - self.jni_ref.as_obj(), - "iterator", - &[], - )? - .l()?; - - Ok(Iterator { - jni_ref: mapping().new_global_ref(iterator_obj)?, - }) + /// Wraps an existing `java.lang.Iterable` JVM object. + pub fn new(jni_ref: GlobalRef) -> Iterable { + Iterable { jni_ref } } -} -impl Deref for Iterable { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + pub fn iterator(&self) -> anyhow::Result { + self.in_frame(|| { + let iterator_obj = self.call_method("iterator", &[])?.l()?; + Ok(Iterator::new(mapping().new_global_ref(iterator_obj)?)) + }) } } diff --git a/client/src/mapping/java/iterator.rs b/client/src/mapping/java/iterator.rs index fec0aca..d377816 100644 --- a/client/src/mapping/java/iterator.rs +++ b/client/src/mapping/java/iterator.rs @@ -1,43 +1,27 @@ -use crate::mapping::MinecraftClassType; +use crate::mapping::MappedObject; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Iterator)] pub struct Iterator { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Iterator { - pub fn has_next(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Iterator, - self.jni_ref.as_obj(), - "hasNext", - &[], - )? - .z()?) + /// Wraps an existing `java.util.Iterator` JVM object. + pub fn new(jni_ref: GlobalRef) -> Iterator { + Iterator { jni_ref } } - pub fn next(&self) -> anyhow::Result { - let next_obj = mapping() - .call_method( - MinecraftClassType::Iterator, - self.jni_ref.as_obj(), - "next", - &[], - )? - .l()?; - - mapping().new_global_ref(next_obj) + pub fn has_next(&self) -> anyhow::Result { + Ok(self.call_method("hasNext", &[])?.z()?) } -} - -impl Deref for Iterator { - type Target = GlobalRef; - fn deref(&self) -> &Self::Target { - &self.jni_ref + pub fn next(&self) -> anyhow::Result { + self.in_frame(|| { + let next_obj = self.call_method("next", &[])?.l()?; + mapping().new_global_ref(next_obj) + }) } } diff --git a/client/src/mapping/math.rs b/client/src/mapping/math.rs new file mode 100644 index 0000000..f154889 --- /dev/null +++ b/client/src/mapping/math.rs @@ -0,0 +1,92 @@ +//! Plain-value wrappers for Minecraft's immutable coordinate types. +//! +//! `Vec3` and `BlockPos` are immutable value classes in Minecraft, so they are +//! read **once** into plain Rust fields rather than kept as live JNI handles: +//! the accessors are then infallible, allocate no JNI references and cannot go +//! stale mid-use. Re-read from the source object whenever a fresh value is +//! needed. + +use crate::mapping::{FieldType, MinecraftClassType as Cls}; +use crate::state::mapping; +use jni::objects::JObject; + +/// An immutable 3D vector — a snapshot of Minecraft's `Vec3`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Vec3 { + x: f64, + y: f64, + z: f64, +} + +impl Vec3 { + /// A vector from explicit components. + pub const fn new(x: f64, y: f64, z: f64) -> Vec3 { + Vec3 { x, y, z } + } + + /// Snapshots a JVM `Vec3` object by reading its `x` / `y` / `z` fields. + pub fn read(obj: &JObject) -> anyhow::Result { + let mapping = mapping(); + Ok(Vec3 { + x: mapping + .get_field(Cls::Vec3, obj, "x", FieldType::Double)? + .d()?, + y: mapping + .get_field(Cls::Vec3, obj, "y", FieldType::Double)? + .d()?, + z: mapping + .get_field(Cls::Vec3, obj, "z", FieldType::Double)? + .d()?, + }) + } + + pub const fn x(&self) -> f64 { + self.x + } + + pub const fn y(&self) -> f64 { + self.y + } + + pub const fn z(&self) -> f64 { + self.z + } +} + +/// An immutable block coordinate — a snapshot of Minecraft's `BlockPos` +/// (a `Vec3i`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockPos { + x: i32, + y: i32, + z: i32, +} + +impl BlockPos { + /// A block position from explicit components. + pub const fn new(x: i32, y: i32, z: i32) -> BlockPos { + BlockPos { x, y, z } + } + + /// Snapshots a JVM `BlockPos` via its `getX` / `getY` / `getZ` accessors. + pub fn read(obj: &JObject) -> anyhow::Result { + let mapping = mapping(); + Ok(BlockPos { + x: mapping.call_method(Cls::BlockPos, obj, "getX", &[])?.i()?, + y: mapping.call_method(Cls::BlockPos, obj, "getY", &[])?.i()?, + z: mapping.call_method(Cls::BlockPos, obj, "getZ", &[])?.i()?, + }) + } + + pub const fn x(&self) -> i32 { + self.x + } + + pub const fn y(&self) -> i32 { + self.y + } + + pub const fn z(&self) -> i32 { + self.z + } +} diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index 73e65ff..2884319 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,24 +1,30 @@ use crate::mapping::class::{Method, MethodHandle, MinecraftClass}; pub use crate::mapping::class_type::MinecraftClassType; use crate::mapping::minecraft_version::MinecraftVersion; +pub use crate::mapping::object::MappedObject; use dashmap::DashMap; use jni::objects::{GlobalRef, JClass, JMethodID, JObject, JString, JValue, JValueOwned}; use jni::signature::{Primitive, ReturnType}; use jni::sys::{jsize, jvalue, JNI_GetCreatedJavaVMs, JNI_OK}; use jni::{JNIEnv, JavaVM}; use log::info; +pub use mapping_derive::MappedObject; use serde::Deserialize; use std::collections::HashMap; use std::sync::{Arc, RwLock}; +pub mod block_entity; pub mod class; pub mod class_type; pub mod client; +pub mod component; pub mod entity; pub mod java; mod loader; +pub mod math; mod method; mod minecraft_version; +pub mod object; mod reflect; #[cfg(test)] @@ -196,6 +202,27 @@ impl Mapping { Ok(self.jvm.attach_current_thread_as_daemon()?) } + /// Runs `f` inside a fresh JNI local-reference frame: every local reference + /// `f` creates is released when it returns, while the value it yields (a + /// plain value or a `GlobalRef`-backed wrapper) survives. This lets each + /// wrapper method bound its own JNI garbage, so no caller manages frames. + pub fn in_frame(&self, f: impl FnOnce() -> anyhow::Result) -> anyhow::Result { + let mut env = self.get_env()?; + env.with_local_frame(16, |_| f()) + } + + /// Releases the JVM global references this mapping holds — every resolved + /// class handle and the captured game class loader. Called from + /// `cleanup_client` before the library is unloaded; afterwards lookups + /// would simply re-resolve lazily. + pub fn teardown(&self) { + self.classes.clear(); + self.class_handles.clear(); + if let Ok(mut loader) = self.class_loader.write() { + *loader = None; + } + } + pub fn get_version(&self) -> MinecraftVersion { self.version } @@ -222,6 +249,12 @@ impl Mapping { self.class_loader.read().ok().and_then(|slot| slot.clone()) } + /// The captured game class loader — used to `DefineClass` new classes (the + /// Netty bridge handler) so they can see Minecraft and Netty types. + pub fn game_class_loader(&self) -> Option { + self.loader() + } + /// Resolves a JVM class by its JNI name, working from any thread. /// /// `JNIEnv::find_class` resolves against the class loader of the calling diff --git a/client/src/mapping/object.rs b/client/src/mapping/object.rs new file mode 100644 index 0000000..272a9f3 --- /dev/null +++ b/client/src/mapping/object.rs @@ -0,0 +1,95 @@ +//! The [`MappedObject`] trait — shared behaviour for every Rust wrapper around +//! a live JVM object. +//! +//! Implemented for every wrapper via `#[derive(MappedObject)]`. It keeps JNI +//! and [`Mapping`](crate::mapping::Mapping) calls inside the `mapping` module: +//! a wrapper method calls `self.call_method(...)` — the Minecraft class is +//! filled in from the wrapper's own type — and feature code only ever sees the +//! typed wrappers and the high-level helpers (`instance_of`, `is_same`, +//! `equals`). + +use crate::mapping::{FieldType, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::{GlobalRef, JValue, JValueOwned}; + +/// A Rust wrapper around a JVM object. +pub trait MappedObject { + /// The wrapped JVM object. + fn jni_ref(&self) -> &GlobalRef; + + /// The Minecraft class this wrapper type corresponds to. + fn class_type() -> MinecraftClassType; + + /// Calls an instance method on the wrapped object, resolved against this + /// wrapper's [`class_type`](MappedObject::class_type). + fn call_method(&self, name: &str, args: &[JValue]) -> anyhow::Result> + where + Self: Sized, + { + mapping().call_method(Self::class_type(), self.jni_ref().as_obj(), name, args) + } + + /// Reads an instance field of the wrapped object. + fn get_field(&self, name: &str, field_type: FieldType) -> anyhow::Result> + where + Self: Sized, + { + mapping().get_field( + Self::class_type(), + self.jni_ref().as_obj(), + name, + field_type, + ) + } + + /// Writes an instance field of the wrapped object. + fn set_field(&self, name: &str, field_type: FieldType, value: JValue) -> anyhow::Result<()> + where + Self: Sized, + { + mapping().set_field( + Self::class_type(), + self.jni_ref().as_obj(), + name, + field_type, + value, + ) + } + + /// Runs `f` inside a fresh JNI local-reference frame — see + /// [`Mapping::in_frame`](crate::mapping::Mapping::in_frame). + fn in_frame(&self, f: impl FnOnce() -> anyhow::Result) -> anyhow::Result { + mapping().in_frame(f) + } + + /// Whether the wrapped object is an instance of the Minecraft class that + /// `T` corresponds to — e.g. `entity.instance_of::()`. + fn instance_of(&self) -> bool { + mapping() + .in_frame(|| mapping().is_instance_of(T::class_type(), self.jni_ref().as_obj())) + .unwrap_or(false) + } + + /// Whether this and `other` wrap the very same JVM object (JNI identity). + fn is_same(&self, other: &T) -> bool { + let Ok(env) = mapping().get_env() else { + return false; + }; + env.is_same_object(self.jni_ref().as_obj(), other.jni_ref().as_obj()) + .unwrap_or(false) + } + + /// Java `Object.equals` between this and `other`. A failed JNI call — or a + /// JVM that is unreachable — yields `false`. + fn equals(&self, other: &T) -> bool + where + Self: Sized, + { + self.in_frame(|| { + Ok(self + .call_method("equals", &[JValue::Object(other.jni_ref().as_obj())])? + .z()?) + }) + .unwrap_or(false) + } +} diff --git a/client/src/mapping/reflect.rs b/client/src/mapping/reflect.rs index 84c9722..6bad0bf 100644 --- a/client/src/mapping/reflect.rs +++ b/client/src/mapping/reflect.rs @@ -24,7 +24,13 @@ pub fn reflect_class(mapping: &Mapping, class_name: &str) -> anyhow::Result anyhow::Result<_> { + let described = env.with_local_frame(64, |env| -> anyhow::Result<(String, String)> { let method = env.get_object_array_element(&array, index)?; describe_method(env, &method) - })?; + }); + let (name, signature) = match described { + Ok(method) => method, + // A method whose parameter / return types cannot be resolved at + // runtime — e.g. an overload referencing a class this build does + // not expose — is skipped. Clear the pending exception, move on. + Err(_) => { + let _ = env.exception_clear(); + continue; + } + }; let overloads = out.entry(name.clone()).or_default(); if !overloads.iter().any(|m| m.signature == signature) { diff --git a/client/src/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index 08b391c..e6c28ff 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -1,38 +1,69 @@ -use crate::mapping::FieldType; -use crate::mapping::MinecraftClassType; -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; -use crate::state::{mapping, minecraft}; -use jni::objects::JValue; +use crate::mapping::entity::mob::Mob; +use crate::mapping::entity::player::Player; +use crate::mapping::MappedObject; +use crate::module::combat::{look_at, pick_target}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; +use crate::state::minecraft; +use std::sync::Mutex; #[derive(Debug)] pub struct AimbotModule { pub module: ModuleData, + /// Network id of the locked target, if any. + target: Mutex>, } impl AimbotModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Aimbot".to_string(), - description: "Automatically aims at entities".to_string(), + id: ModuleId::Aimbot, + description: "Smoothly aims at the nearest entity".to_string(), category: ModuleCategory::Combat, key_bind: KeyboardKey::KeyC, enabled: false, - settings: vec![ModuleSetting::Slider { - name: "Range".to_string(), - value: 4.0, - min: 1.0, - max: 6.0, - }], + settings: vec![ + ModuleSetting::Slider { + name: "Range".to_string(), + value: 4.0, + min: 2.0, + max: 8.0, + }, + ModuleSetting::Slider { + name: "FOV".to_string(), + value: 100.0, + min: 10.0, + max: 180.0, + }, + ModuleSetting::Slider { + name: "Speed".to_string(), + value: 7.0, + min: 2.0, + max: 20.0, + }, + ], }, + target: Mutex::new(None), } } - pub fn get_range(&self) -> f32 { + fn slider(&self, name: &str, fallback: f32) -> f32 { self.module - .get_setting("Range") - .and_then(|s| s.get_slider_value()) - .unwrap_or(4.0) + .get_setting(name) + .and_then(|setting| setting.get_slider_value()) + .unwrap_or(fallback) + } + + fn range(&self) -> f32 { + self.slider("Range", 4.0) + } + + fn fov(&self) -> f32 { + self.slider("FOV", 100.0) + } + + fn speed(&self) -> f32 { + self.slider("Speed", 7.0) } } @@ -42,69 +73,44 @@ impl Module for AimbotModule { } fn on_stop(&self) -> anyhow::Result<()> { + *self.target.lock().unwrap() = None; Ok(()) } fn on_tick(&self) -> anyhow::Result<()> { let minecraft = minecraft(); + // Stand down while a menu (inventory, chest, crafting, chat, …) is open. + if minecraft.current_screen().is_open() { + return Ok(()); + } let (Some(player), Some(world)) = (minecraft.player()?, minecraft.world()?) else { + *self.target.lock().unwrap() = None; return Ok(()); // not in a world — nothing to do }; let entities = world.get_entities()?; - let range = self.get_range() as f64; - let mapping = mapping(); - - let player_pos = player.entity.get_position()?; - let mut closest_dist = range; - let mut target_entity = None; - let env = mapping.get_env()?; - - for entity in entities { - if env.is_same_object(entity.jni_ref.as_obj(), player.entity.jni_ref.as_obj())? { - continue; - } - - let entity_pos = entity.get_position()?; - let dist = ((player_pos.0 - entity_pos.0).powi(2) - + (player_pos.1 - entity_pos.1).powi(2) - + (player_pos.2 - entity_pos.2).powi(2)) - .sqrt(); - - if dist <= closest_dist { - closest_dist = dist; - target_entity = Some(entity); - } - } - - if let Some(target) = target_entity { - let target_pos = target.get_position()?; - let dx = target_pos.0 - player_pos.0; - let dy = target_pos.1 - player_pos.1; // This is simplistic, usually need eye height - let dz = target_pos.2 - player_pos.2; - - let dist = (dx * dx + dz * dz).sqrt(); - let yaw = (dz.atan2(dx) * 180.0 / std::f64::consts::PI) as f32 - 90.0; - let pitch = (-(dy.atan2(dist)) * 180.0 / std::f64::consts::PI) as f32; - - // Set yaw - mapping.set_field( - MinecraftClassType::Entity, - player.entity.jni_ref.as_obj(), - "yRot", - FieldType::Float, - JValue::Float(yaw), - )?; + let eye = player.entity.get_eye_position()?; + let self_id = player.entity.id()?; + let range = self.range() as f64; + let locked = *self.target.lock().unwrap(); + + let Some((target_id, target)) = + pick_target(&entities, eye, range * range, self_id, locked, |entity| { + entity.instance_of::() || entity.instance_of::() + }) + else { + *self.target.lock().unwrap() = None; + return Ok(()); + }; - // Set pitch - mapping.set_field( - MinecraftClassType::Entity, - player.entity.jni_ref.as_obj(), - "xRot", - FieldType::Float, - JValue::Float(pitch), - )?; - } + let angle = look_at(&player, &target, self.speed(), self.fov())?; + // A returned angle past the FOV means the target was out of view and + // nothing was rotated — release the lock so a better one can be picked. + *self.target.lock().unwrap() = if angle > self.fov() { + None + } else { + Some(target_id) + }; Ok(()) } diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 835cfc5..d4ad6ca 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -1,43 +1,141 @@ -use crate::mapping::MinecraftClassType; -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; -use crate::state::{mapping, minecraft}; +use crate::mapping::entity::mob::Mob; +use crate::mapping::entity::player::{LocalPlayer, Player}; +use crate::mapping::entity::Entity; +use crate::mapping::MappedObject; +use crate::module::combat::{look_at, pick_target}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; +use crate::state::minecraft; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Which entities an aura attacks. +#[derive(Debug, Clone, Copy)] +pub enum AuraTarget { + Players, + Mobs, +} + +/// Cross-tick combat state. +#[derive(Debug, Default)] +struct AuraState { + /// Network id of the locked target, if any. + target: Option, + /// When the last hit landed — drives the CPS limiter. + last_attack: Option, +} #[derive(Debug)] pub struct BaseAura { pub module: ModuleData, - pub target_type: MinecraftClassType, + pub target: AuraTarget, + state: Mutex, } impl BaseAura { pub fn new( - name: String, + id: ModuleId, description: String, key_bind: KeyboardKey, - target_type: MinecraftClassType, + target: AuraTarget, ) -> Self { Self { module: ModuleData { - name, + id, description, category: ModuleCategory::Combat, key_bind, enabled: false, - settings: vec![ModuleSetting::Slider { - name: "Range".to_string(), - value: 4.0, - min: 1.0, - max: 6.0, - }], + settings: vec![ + ModuleSetting::Slider { + name: "Range".to_string(), + value: 3.5, + min: 3.0, + max: 6.0, + }, + ModuleSetting::Slider { + name: "Speed".to_string(), + value: 7.0, + min: 2.0, + max: 20.0, + }, + ModuleSetting::Slider { + name: "Attack Angle".to_string(), + value: 50.0, + min: 10.0, + max: 180.0, + }, + ModuleSetting::Slider { + name: "CPS".to_string(), + value: 12.0, + min: 1.0, + max: 20.0, + }, + ModuleSetting::Toggle { + name: "Cooldown".to_string(), + value: true, + }, + ], }, - target_type, + target, + state: Mutex::new(AuraState::default()), } } + fn slider(&self, name: &str, fallback: f32) -> f32 { + self.module + .get_setting(name) + .and_then(|setting| setting.get_slider_value()) + .unwrap_or(fallback) + } + pub fn get_range(&self) -> f32 { + self.slider("Range", 3.5) + } + + fn speed(&self) -> f32 { + self.slider("Speed", 7.0) + } + + fn attack_angle(&self) -> f32 { + self.slider("Attack Angle", 50.0) + } + + fn cps(&self) -> f32 { + self.slider("CPS", 12.0) + } + + fn respect_cooldown(&self) -> bool { self.module - .get_setting("Range") - .and_then(|s| s.get_slider_value()) - .unwrap_or(4.0) + .get_setting("Cooldown") + .and_then(|setting| setting.get_toggle_value()) + .unwrap_or(true) + } + + /// Whether `entity` is the kind of entity this aura attacks. + fn is_target(&self, entity: &Entity) -> bool { + match self.target { + AuraTarget::Players => entity.instance_of::(), + AuraTarget::Mobs => entity.instance_of::(), + } + } + + /// Whether a new hit is allowed now — the CPS limiter, plus the optional + /// 1.9+ attack-cooldown check. + fn can_attack(&self, player: &LocalPlayer) -> anyhow::Result { + let interval = Duration::from_secs_f32(1.0 / self.cps()); + let cps_ready = self + .state + .lock() + .unwrap() + .last_attack + .is_none_or(|last| last.elapsed() >= interval); + if !cps_ready { + return Ok(false); + } + if self.respect_cooldown() && player.attack_strength_scale()? < 1.0 { + return Ok(false); + } + Ok(true) } } @@ -47,44 +145,50 @@ impl Module for BaseAura { } fn on_stop(&self) -> anyhow::Result<()> { + *self.state.lock().unwrap() = AuraState::default(); Ok(()) } fn on_tick(&self) -> anyhow::Result<()> { let minecraft = minecraft(); + // Stand down while a menu (inventory, chest, crafting, chat, …) is open. + if minecraft.current_screen().is_open() { + return Ok(()); + } let (Some(player), Some(world), Some(game_mode)) = ( minecraft.player()?, minecraft.world()?, minecraft.game_mode()?, ) else { + *self.state.lock().unwrap() = AuraState::default(); return Ok(()); // not in a world — nothing to do }; - let mapping = mapping(); let entities = world.get_entities()?; + let eye = player.entity.get_eye_position()?; + let self_id = player.entity.id()?; let range = self.get_range() as f64; - let env = mapping.get_env()?; - let player_pos = player.entity.get_position()?; - - for entity in entities { - if env.is_same_object(entity.jni_ref.as_obj(), player.entity.jni_ref.as_obj())? { - continue; - } - - if !mapping.is_instance_of(self.target_type, entity.jni_ref.as_obj())? { - continue; - } + let locked = self.state.lock().unwrap().target; + let Some((target_id, target)) = + pick_target(&entities, eye, range * range, self_id, locked, |entity| { + self.is_target(entity) + }) + else { + self.state.lock().unwrap().target = None; + return Ok(()); + }; + self.state.lock().unwrap().target = Some(target_id); - let entity_pos = entity.get_position()?; - let dist = ((player_pos.0 - entity_pos.0).powi(2) - + (player_pos.1 - entity_pos.1).powi(2) - + (player_pos.2 - entity_pos.2).powi(2)) - .sqrt(); + // Aim smoothly toward the target (the rotation controller eases the + // camera there frame by frame). + let angle = look_at(&player, &target, self.speed(), 180.0)?; - if dist <= range { - game_mode.attack(&player, &entity)?; - } + // Attack once roughly aligned and the timers allow it. + if angle <= self.attack_angle() && self.can_attack(&player)? { + game_mode.attack(&player, &target)?; + player.swing()?; + self.state.lock().unwrap().last_attack = Some(Instant::now()); } Ok(()) diff --git a/client/src/module/combat/killaura.rs b/client/src/module/combat/killaura.rs index 0f48fb2..561e615 100644 --- a/client/src/module/combat/killaura.rs +++ b/client/src/module/combat/killaura.rs @@ -1,6 +1,5 @@ -use crate::mapping::MinecraftClassType; -use crate::module::combat::aura::BaseAura; -use crate::module::{KeyboardKey, Module, ModuleData}; +use crate::module::combat::aura::{AuraTarget, BaseAura}; +use crate::module::{KeyboardKey, Module, ModuleData, ModuleId}; #[derive(Debug)] pub struct KillAuraModule { @@ -11,10 +10,10 @@ impl KillAuraModule { pub fn new() -> Self { Self { aura: BaseAura::new( - "KillAura".to_string(), + ModuleId::KillAura, "Automatically attacks players".to_string(), KeyboardKey::KeyR, - MinecraftClassType::Player, + AuraTarget::Players, ), } } diff --git a/client/src/module/combat/mobaura.rs b/client/src/module/combat/mobaura.rs index 8fcfa02..f5f593e 100644 --- a/client/src/module/combat/mobaura.rs +++ b/client/src/module/combat/mobaura.rs @@ -1,6 +1,5 @@ -use crate::mapping::MinecraftClassType; -use crate::module::combat::aura::BaseAura; -use crate::module::{KeyboardKey, Module, ModuleData}; +use crate::module::combat::aura::{AuraTarget, BaseAura}; +use crate::module::{KeyboardKey, Module, ModuleData, ModuleId}; #[derive(Debug)] pub struct MobAuraModule { @@ -11,10 +10,10 @@ impl MobAuraModule { pub fn new() -> Self { Self { aura: BaseAura::new( - "MobAura".to_string(), + ModuleId::MobAura, "Automatically attacks mobs".to_string(), KeyboardKey::KeyY, - MinecraftClassType::Mob, + AuraTarget::Mobs, ), } } diff --git a/client/src/module/combat/mod.rs b/client/src/module/combat/mod.rs index 6a59a94..e7e95ef 100644 --- a/client/src/module/combat/mod.rs +++ b/client/src/module/combat/mod.rs @@ -2,3 +2,77 @@ pub mod aimbot; pub mod aura; pub mod killaura; pub mod mobaura; +pub mod rotation; +pub mod velocity; + +use crate::mapping::entity::player::LocalPlayer; +use crate::mapping::entity::Entity; +use crate::mapping::math::Vec3; +use rotation::Rotation; + +/// Aims the camera at `target` — eased, through the shared rotation controller +/// — when the target is within `max_fov` degrees of the current look. Returns +/// the angle, in degrees, currently between the look direction and the target, +/// which combat modules use to decide when they are aligned enough to attack. +pub fn look_at( + player: &LocalPlayer, + target: &Entity, + speed: f32, + max_fov: f32, +) -> anyhow::Result { + let eye = player.entity.get_eye_position()?; + let feet = target.get_position()?; + let height = target.bb_height()? as f64; + // Aim at the upper body — reliable hit registration, natural-looking. + let aim = Vec3::new(feet.x(), feet.y() + height * 0.7, feet.z()); + let target_rotation = Rotation::towards(eye, aim); + + let current = Rotation::new(player.entity.get_yaw()?, player.entity.get_pitch()?); + let angle = current.angle_to(target_rotation); + if angle <= max_fov { + rotation::aim(target_rotation, speed); + } + Ok(angle) +} + +/// Picks a combat target from `entities`: keeps the `locked` target while it is +/// still a valid in-range candidate, otherwise the nearest one. `accept` +/// filters by kind; `exclude_id` drops the player's own entity. Returns the +/// chosen entity's network id together with the entity. +pub fn pick_target( + entities: &[Entity], + eye: Vec3, + range_sq: f64, + exclude_id: i32, + locked: Option, + accept: impl Fn(&Entity) -> bool, +) -> Option<(i32, Entity)> { + let mut candidates: Vec<(i32, Entity, f64)> = Vec::new(); + for entity in entities { + let distance = match entity.distance_to_sqr(eye.x(), eye.y(), eye.z()) { + Ok(distance) if distance <= range_sq => distance, + _ => continue, + }; + if !accept(entity) { + continue; + } + let Ok(id) = entity.id() else { + continue; + }; + if id == exclude_id { + continue; + } + candidates.push((id, entity.clone(), distance)); + } + + // Keep the locked target while it is still in the candidate set. + if let Some(locked) = locked { + if let Some((id, entity, _)) = candidates.iter().find(|(id, _, _)| *id == locked) { + return Some((*id, entity.clone())); + } + } + candidates + .into_iter() + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|(id, entity, _)| (id, entity)) +} diff --git a/client/src/module/combat/rotation.rs b/client/src/module/combat/rotation.rs new file mode 100644 index 0000000..1939c0a --- /dev/null +++ b/client/src/module/combat/rotation.rs @@ -0,0 +1,162 @@ +//! Shared rotation system for the combat modules. +//! +//! Combat modules pick a target rotation each game tick (~20 Hz); the global +//! [`RotationController`] then eases the player's camera toward it **every +//! rendered frame** (see [`update`], driven from the frame hook). Per-frame +//! exponential smoothing — frame-rate independent — is what makes the motion +//! fluid, instead of the visible 20 Hz jumps of a per-tick approach. + +use crate::mapping::math::Vec3; +use crate::state::minecraft; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// A target is dropped if no module refreshes it within this long — so a +/// module releases the camera simply by no longer calling [`aim`]. +const TARGET_TIMEOUT: Duration = Duration::from_millis(150); + +/// Frame time is clamped to this, so a render hitch cannot snap the camera. +const MAX_FRAME_DT: f32 = 0.1; + +/// A yaw/pitch pair, in degrees. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rotation { + pub yaw: f32, + pub pitch: f32, +} + +impl Rotation { + pub const fn new(yaw: f32, pitch: f32) -> Rotation { + Rotation { yaw, pitch } + } + + /// The rotation that looks from `from` to `to`. + pub fn towards(from: Vec3, to: Vec3) -> Rotation { + let dx = to.x() - from.x(); + let dy = to.y() - from.y(); + let dz = to.z() - from.z(); + let ground = (dx * dx + dz * dz).sqrt(); + Rotation { + yaw: wrap_degrees((dz.atan2(dx).to_degrees() - 90.0) as f32), + pitch: (-dy.atan2(ground).to_degrees() as f32).clamp(-90.0, 90.0), + } + } + + /// Total angular distance to `other`, in degrees. + pub fn angle_to(self, other: Rotation) -> f32 { + let dyaw = wrap_degrees(other.yaw - self.yaw); + let dpitch = other.pitch - self.pitch; + (dyaw * dyaw + dpitch * dpitch).sqrt() + } + + /// Moves a fraction `alpha` of the way toward `target`, taking the shortest + /// way around for the yaw. + fn lerp_towards(self, target: Rotation, alpha: f32) -> Rotation { + let dyaw = wrap_degrees(target.yaw - self.yaw); + let dpitch = target.pitch - self.pitch; + Rotation { + yaw: self.yaw + dyaw * alpha, + pitch: (self.pitch + dpitch * alpha).clamp(-90.0, 90.0), + } + } +} + +/// Normalizes an angle to `[-180, 180)`. +pub fn wrap_degrees(mut angle: f32) -> f32 { + angle %= 360.0; + if angle >= 180.0 { + angle -= 360.0; + } else if angle < -180.0 { + angle += 360.0; + } + angle +} + +/// What the camera is being eased toward. +#[derive(Debug, Clone, Copy)] +struct Target { + rotation: Rotation, + /// Easing rate — larger converges faster (see [`ease_camera`]). + speed: f32, + /// When a module last refreshed this target. + refreshed: Instant, +} + +/// Eases the player's camera toward a target rotation, frame by frame. +struct RotationController { + target: Option, + last_frame: Option, +} + +impl RotationController { + const fn new() -> RotationController { + RotationController { + target: None, + last_frame: None, + } + } +} + +static CONTROLLER: Mutex = Mutex::new(RotationController::new()); + +/// Aims the camera toward `rotation`, easing at `speed`. A combat module calls +/// this every tick while it has a target; it releases the camera simply by +/// stopping (the target expires after [`TARGET_TIMEOUT`]). +pub fn aim(rotation: Rotation, speed: f32) { + if let Ok(mut controller) = CONTROLLER.lock() { + controller.target = Some(Target { + rotation, + speed, + refreshed: Instant::now(), + }); + } +} + +/// Advances the camera one frame toward the active target. Driven from the +/// frame hook; does nothing — and touches no JNI — while no target is set. +pub fn update() { + let step = { + let Ok(mut controller) = CONTROLLER.lock() else { + return; + }; + let now = Instant::now(); + let dt = controller + .last_frame + .map_or(0.0, |last| (now - last).as_secs_f32()) + .min(MAX_FRAME_DT); + controller.last_frame = Some(now); + + match controller.target { + Some(target) if target.refreshed.elapsed() <= TARGET_TIMEOUT => Some((target, dt)), + _ => { + controller.target = None; + None + } + } + }; + + let Some((target, dt)) = step else { + return; + }; + if dt <= 0.0 { + return; + } + // A failed JNI call here must never abort the frame. + let _ = ease_camera(target, dt); +} + +/// Reads the player's current rotation, eases it one step toward `target`, and +/// writes it back. +fn ease_camera(target: Target, dt: f32) -> anyhow::Result<()> { + let Some(player) = minecraft().player()? else { + return Ok(()); + }; + let entity = &player.entity; + + let current = Rotation::new(entity.get_yaw()?, entity.get_pitch()?); + // Exponential smoothing — frame-rate independent, eased by construction. + let alpha = 1.0 - (-target.speed * dt).exp(); + let next = current.lerp_towards(target.rotation, alpha); + + entity.set_rotation(next.yaw, next.pitch) +} diff --git a/client/src/module/combat/velocity.rs b/client/src/module/combat/velocity.rs new file mode 100644 index 0000000..9a2d678 --- /dev/null +++ b/client/src/module/combat/velocity.rs @@ -0,0 +1,106 @@ +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; +use crate::net::packet::{Packet, PacketAction}; +use crate::state::minecraft; +use std::sync::atomic::{AtomicI32, Ordering}; + +/// Network-id sentinel meaning "the local player's id is not known yet". +const UNKNOWN_ID: i32 = i32::MIN; + +/// Velocity — scales, or fully cancels, the knockback the server applies to the +/// player. The server pushes the player by sending a +/// `ClientboundSetEntityMotionPacket` for the player's own entity; this module +/// intercepts that packet and multiplies the motion by the configured +/// percentages. `Horizontal` / `Vertical` at 0 % is full anti-knockback; at +/// 100 % the motion passes through unchanged. The work happens in +/// [`VelocityModule::handle_packet`] — this module is packet-driven. +#[derive(Debug)] +pub struct VelocityModule { + pub module: ModuleData, + /// Network id of the local player, cached every tick so `handle_packet` + /// (which runs on the Netty thread and does no JNI of its own) can match + /// the motion packet against it. + local_id: AtomicI32, +} + +impl VelocityModule { + pub fn new() -> Self { + Self { + module: ModuleData { + id: ModuleId::Velocity, + description: "Reduces or cancels server knockback".to_string(), + category: ModuleCategory::Combat, + key_bind: KeyboardKey::KeyV, + enabled: false, + settings: vec![ + ModuleSetting::Slider { + name: "Horizontal".to_string(), + value: 0.0, + min: 0.0, + max: 100.0, + }, + ModuleSetting::Slider { + name: "Vertical".to_string(), + value: 0.0, + min: 0.0, + max: 100.0, + }, + ], + }, + local_id: AtomicI32::new(UNKNOWN_ID), + } + } + + /// A percentage slider as a 0.0–1.0 multiplier. + fn factor(&self, name: &str) -> f64 { + self.module + .get_setting(name) + .and_then(|setting| setting.get_slider_value()) + .unwrap_or(0.0) as f64 + / 100.0 + } +} + +impl Module for VelocityModule { + fn on_start(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn on_stop(&self) -> anyhow::Result<()> { + self.local_id.store(UNKNOWN_ID, Ordering::Relaxed); + Ok(()) + } + + fn on_tick(&self) -> anyhow::Result<()> { + // Refresh the cached player id; `handle_packet` reads it cross-thread. + let id = match minecraft().player()? { + Some(player) => player.entity.id()?, + None => UNKNOWN_ID, + }; + self.local_id.store(id, Ordering::Relaxed); + Ok(()) + } + + fn handle_packet(&self, packet: &mut Packet) -> PacketAction { + let Packet::ClientboundSetEntityMotion(motion) = packet else { + return PacketAction::Forward; + }; + // Only neuter knockback aimed at the local player. + if motion.entity_id != self.local_id.load(Ordering::Relaxed) { + return PacketAction::Forward; + } + let horizontal = self.factor("Horizontal"); + let vertical = self.factor("Vertical"); + motion.x *= horizontal; + motion.y *= vertical; + motion.z *= horizontal; + PacketAction::Forward + } + + fn get_module_data(&self) -> &ModuleData { + &self.module + } + + fn get_module_data_mut(&mut self) -> &mut ModuleData { + &mut self.module + } +} diff --git a/client/src/module/mod.rs b/client/src/module/mod.rs index d07c2a2..2b71114 100644 --- a/client/src/module/mod.rs +++ b/client/src/module/mod.rs @@ -8,7 +8,7 @@ pub mod render; pub type ModuleType = Box; #[allow(dead_code)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ModuleCategory { Combat, Movement, @@ -32,9 +32,40 @@ impl ModuleCategory { } } +/// Stable identifier of a module — what it is registered and looked up by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ModuleId { + Fly, + NoFall, + KillAura, + MobAura, + Aimbot, + Velocity, + PlayerEsp, + MobEsp, + ChestEsp, +} + +impl ModuleId { + /// Human-readable name, shown in the UI. + pub fn display_name(self) -> &'static str { + match self { + ModuleId::Fly => "Fly", + ModuleId::NoFall => "NoFall", + ModuleId::KillAura => "KillAura", + ModuleId::MobAura => "MobAura", + ModuleId::Aimbot => "Aimbot", + ModuleId::Velocity => "Velocity", + ModuleId::PlayerEsp => "Player ESP", + ModuleId::MobEsp => "Mob ESP", + ModuleId::ChestEsp => "Chest ESP", + } + } +} + #[derive(Debug, Clone)] pub struct ModuleData { - pub name: String, + pub id: ModuleId, #[allow(dead_code)] pub description: String, #[allow(dead_code)] @@ -106,6 +137,11 @@ impl ModuleSetting { } impl ModuleData { + /// The module's display name. + pub fn name(&self) -> &'static str { + self.id.display_name() + } + pub fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; } @@ -124,6 +160,17 @@ pub trait Module: Debug + Send + Sync { fn on_stop(&self) -> anyhow::Result<()>; fn on_tick(&self) -> anyhow::Result<()>; + /// Inspects — and may modify — a packet passing through the connection. + /// Only enabled modules are called. Mutate `packet` in place to rewrite it; + /// return [`PacketAction::Cancel`] to drop it entirely. The default ignores + /// every packet and forwards it untouched. + fn handle_packet( + &self, + _packet: &mut crate::net::packet::Packet, + ) -> crate::net::packet::PacketAction { + crate::net::packet::PacketAction::Forward + } + fn get_module_data(&self) -> &ModuleData; fn get_module_data_mut(&mut self) -> &mut ModuleData; } diff --git a/client/src/module/movement/fly.rs b/client/src/module/movement/fly.rs index bcf8260..07fe4b3 100644 --- a/client/src/module/movement/fly.rs +++ b/client/src/module/movement/fly.rs @@ -1,6 +1,9 @@ -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; use crate::state::minecraft; +/// Vanilla creative-fly speed — restored when Fly is turned off. +const VANILLA_FLY_SPEED: f32 = 0.05; + #[derive(Debug)] pub struct FlyModule { pub module: ModuleData, @@ -10,8 +13,8 @@ impl FlyModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Fly".to_string(), - description: "Enables flying".to_string(), + id: ModuleId::Fly, + description: "Enables flight".to_string(), category: ModuleCategory::Movement, key_bind: KeyboardKey::KeyF, enabled: false, @@ -25,33 +28,42 @@ impl FlyModule { } } - pub fn get_speed(&self) -> f32 { - self.module + /// The configured speed as an `Abilities.flyingSpeed` value (the slider is + /// a multiplier over the vanilla speed). + fn fly_speed(&self) -> f32 { + let multiplier = self + .module .get_setting("Speed") - .and_then(|s| s.get_slider_value()) - .unwrap_or(1.0) + .and_then(|setting| setting.get_slider_value()) + .unwrap_or(1.0); + VANILLA_FLY_SPEED * multiplier } } impl Module for FlyModule { fn on_start(&self) -> anyhow::Result<()> { - // Enable flying, if the player is in a world. if let Some(player) = minecraft().player()? { player.abilities.fly(true)?; + player.abilities.set_flying_speed(self.fly_speed())?; } Ok(()) } fn on_stop(&self) -> anyhow::Result<()> { - // Disable flying, if the player is in a world. if let Some(player) = minecraft().player()? { player.abilities.fly(false)?; + player.abilities.set_flying_speed(VANILLA_FLY_SPEED)?; } Ok(()) } fn on_tick(&self) -> anyhow::Result<()> { - // No operation + // Re-assert each tick so a server-sent abilities update cannot quietly + // disable flight or reset the speed. + if let Some(player) = minecraft().player()? { + player.abilities.fly(true)?; + player.abilities.set_flying_speed(self.fly_speed())?; + } Ok(()) } diff --git a/client/src/module/movement/mod.rs b/client/src/module/movement/mod.rs index 20b6c63..cacf678 100644 --- a/client/src/module/movement/mod.rs +++ b/client/src/module/movement/mod.rs @@ -1 +1,2 @@ pub mod fly; +pub mod nofall; diff --git a/client/src/module/movement/nofall.rs b/client/src/module/movement/nofall.rs new file mode 100644 index 0000000..43a1910 --- /dev/null +++ b/client/src/module/movement/nofall.rs @@ -0,0 +1,57 @@ +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId}; +use crate::net::packet::{Packet, PacketAction}; + +/// Prevents fall damage. The work happens in [`NoFallModule::handle_packet`]: +/// while enabled, every outbound movement packet reports the player as on the +/// ground, so the server never accumulates the fall distance it would turn +/// into damage. `on_tick` does nothing — this module is packet-driven. +#[derive(Debug)] +pub struct NoFallModule { + pub module: ModuleData, +} + +impl NoFallModule { + pub fn new() -> Self { + Self { + module: ModuleData { + id: ModuleId::NoFall, + description: "Prevents fall damage".to_string(), + category: ModuleCategory::Movement, + key_bind: KeyboardKey::KeyN, + enabled: false, + settings: vec![], + }, + } + } +} + +impl Module for NoFallModule { + fn on_start(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn on_stop(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn on_tick(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn handle_packet(&self, packet: &mut Packet) -> PacketAction { + // Report the player as on the ground on every movement packet, so the + // server never accumulates the fall distance it turns into damage. + if let Packet::ServerboundMovePlayer(move_packet) = packet { + move_packet.on_ground = true; + } + PacketAction::Forward + } + + fn get_module_data(&self) -> &ModuleData { + &self.module + } + + fn get_module_data_mut(&mut self) -> &mut ModuleData { + &mut self.module + } +} diff --git a/client/src/module/registry.rs b/client/src/module/registry.rs index 59bb787..dfaf269 100644 --- a/client/src/module/registry.rs +++ b/client/src/module/registry.rs @@ -1,4 +1,4 @@ -//! The module registry — every registered module, keyed by name. +//! The module registry — every registered module, keyed by [`ModuleId`]. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -6,16 +6,26 @@ use std::sync::{Arc, Mutex}; use dashmap::DashMap; use log::error; -use crate::module::{Module, ModuleType}; +use crate::module::{KeyboardKey, Module, ModuleId, ModuleSetting, ModuleType}; +use crate::net::packet::{Packet, PacketAction}; /// A shared, lockable handle to one module. pub type ModuleHandle = Arc>; +/// A module's factory defaults — the keybind and settings it was registered +/// with. Kept so "Reset Settings" can restore them. +struct ModuleDefaults { + key_bind: KeyboardKey, + settings: Vec, +} + /// Holds every registered module. Backed by a `DashMap` so the render thread /// can read it each frame without contending on a single global lock. #[derive(Default)] pub struct ModuleRegistry { - modules: DashMap, + modules: DashMap, + /// Factory defaults captured at registration, keyed like `modules`. + defaults: DashMap, } impl ModuleRegistry { @@ -24,21 +34,48 @@ impl ModuleRegistry { Self::default() } - /// Registers a module under its declared name. + /// Registers a module under its [`ModuleId`], capturing its factory + /// defaults so they can be restored later. pub fn register(&self, module: M) where M: Module + Send + Sync + 'static, { let module: ModuleType = Box::new(module); - let name = module.get_module_data().name.clone(); - self.modules.insert(name, Arc::new(Mutex::new(module))); + let data = module.get_module_data(); + let id = data.id; + self.defaults.insert( + id, + ModuleDefaults { + key_bind: data.key_bind, + settings: data.settings.clone(), + }, + ); + self.modules.insert(id, Arc::new(Mutex::new(module))); } - /// A handle to one module by name. - pub fn get(&self, name: &str) -> Option { - self.modules - .get(name) - .map(|entry| Arc::clone(entry.value())) + /// Restores every module to its factory defaults: default keybind, default + /// setting values, and disabled. Backs the GUI's "Reset Settings" button. + pub fn reset_settings(&self) { + for entry in self.modules.iter() { + let Some(defaults) = self.defaults.get(entry.key()) else { + continue; + }; + let Ok(mut module) = entry.value().lock() else { + continue; + }; + if module.get_module_data().enabled { + let _ = module.on_stop(); + } + let data = module.get_module_data_mut(); + data.key_bind = defaults.key_bind; + data.settings = defaults.settings.clone(); + data.enabled = false; + } + } + + /// A handle to one module by id. + pub fn get(&self, id: ModuleId) -> Option { + self.modules.get(&id).map(|entry| Arc::clone(entry.value())) } /// Handles to every module. Snapshotted, so the caller holds no shard @@ -50,11 +87,11 @@ impl ModuleRegistry { .collect() } - /// Every module keyed by name — an owned snapshot. - pub fn by_name(&self) -> HashMap { + /// Every module keyed by id — an owned snapshot. + pub fn by_id(&self) -> HashMap { self.modules .iter() - .map(|entry| (entry.key().clone(), Arc::clone(entry.value()))) + .map(|entry| (*entry.key(), Arc::clone(entry.value()))) .collect() } @@ -69,7 +106,7 @@ impl ModuleRegistry { continue; } if let Err(e) = module.on_tick() { - let name = &module.get_module_data().name; + let name = module.get_module_data().name(); error!("module '{name}' tick failed, stopping it: {e}"); if let Err(e) = module.on_stop() { error!("module '{name}' also failed to stop: {e}"); @@ -77,4 +114,24 @@ impl ModuleRegistry { } } } + + /// Offers `packet` to every enabled module's `handle_packet`. Called from + /// the connection's packet dispatch, on the Netty thread. Returns + /// [`PacketAction::Cancel`] as soon as any module asks to drop the packet + /// (the remaining modules are then skipped), otherwise + /// [`PacketAction::Forward`]. + pub fn handle_packet(&self, packet: &mut Packet) -> PacketAction { + for handle in self.handles() { + let Ok(module) = handle.lock() else { + continue; + }; + if !module.get_module_data().enabled { + continue; + } + if module.handle_packet(packet) == PacketAction::Cancel { + return PacketAction::Cancel; + } + } + PacketAction::Forward + } } diff --git a/client/src/module/render/chest_esp.rs b/client/src/module/render/chest_esp.rs index 008789a..faff5db 100644 --- a/client/src/module/render/chest_esp.rs +++ b/client/src/module/render/chest_esp.rs @@ -1,4 +1,4 @@ -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; /// Highlights containers — chests, trapped chests, ender chests, barrels and /// shulker boxes — with a 3D wireframe box. @@ -14,7 +14,7 @@ impl ChestEspModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Chest ESP".to_string(), + id: ModuleId::ChestEsp, description: "Draws a 3D box around containers".to_string(), category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, diff --git a/client/src/module/render/mob_esp.rs b/client/src/module/render/mob_esp.rs index 19d0448..703ccd3 100644 --- a/client/src/module/render/mob_esp.rs +++ b/client/src/module/render/mob_esp.rs @@ -1,4 +1,4 @@ -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; /// Highlights mobs (hostile and passive creatures) with a 3D wireframe box. /// @@ -13,7 +13,7 @@ impl MobEspModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Mob ESP".to_string(), + id: ModuleId::MobEsp, description: "Draws a 3D box around mobs".to_string(), category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, diff --git a/client/src/module/render/player_esp.rs b/client/src/module/render/player_esp.rs index 83afcdf..64f2b6d 100644 --- a/client/src/module/render/player_esp.rs +++ b/client/src/module/render/player_esp.rs @@ -1,4 +1,4 @@ -use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; /// Highlights other players with a 3D wireframe box. /// @@ -13,7 +13,7 @@ impl PlayerEspModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Player ESP".to_string(), + id: ModuleId::PlayerEsp, description: "Draws a 3D box around players".to_string(), category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, diff --git a/client/src/net/mod.rs b/client/src/net/mod.rs new file mode 100644 index 0000000..0895a56 --- /dev/null +++ b/client/src/net/mod.rs @@ -0,0 +1,425 @@ +//! Netty pipeline injection — the packet layer. +//! +//! Defines `DarkChannelHandler` (a thin Netty bridge, bytecode embedded from +//! `client/java/DarkChannelHandler.class`) into Minecraft's class loader, binds +//! its native methods to Rust, and inserts an instance into the live server +//! connection's Netty pipeline. +//! +//! Every packet then flows through [`dispatch`]: it is wrapped into a +//! [`packet::Packet`] value-snapshot (only for the types a module handles), +//! offered to every enabled module's `handle_packet`, and — if a module +//! changed it — rebuilt into a fresh JVM object that replaces the original. +//! +//! All JNI here uses explicit descriptors: the navigated classes are Netty +//! (overload-heavy) and the targets are unobfuscated Minecraft 26.1+, so going +//! through the reflecting `Mapping` layer would be both wasteful and ambiguous. + +pub mod packet; + +use crate::mapping::MappedObject; +use crate::net::packet::{Packet, PacketAction}; +use crate::state::{mapping, minecraft}; +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; +use jni::sys::jobject; +use jni::{JNIEnv, NativeMethod}; +use std::ffi::c_void; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Mutex; + +/// Bytecode of the `DarkChannelHandler` Netty bridge. +const HANDLER_BYTECODE: &[u8] = include_bytes!("../../java/DarkChannelHandler.class"); +/// Binary name of the handler class. +const HANDLER_CLASS: &str = "DarkChannelHandler"; +/// Name of our entry in Minecraft's Netty pipeline. +const PIPELINE_NAME: &str = "dark_handler"; + +// JNI descriptors for the methods/fields this module touches. +const SIG_GET_CLIENT_LISTENER: &str = "()Lnet/minecraft/client/multiplayer/ClientPacketListener;"; +const SIG_GET_CONNECTION: &str = "()Lnet/minecraft/network/Connection;"; +const SIG_CHANNEL_FIELD: &str = "Lio/netty/channel/Channel;"; +const SIG_PIPELINE: &str = "()Lio/netty/channel/ChannelPipeline;"; +const SIG_PIPELINE_GET: &str = "(Ljava/lang/String;)Lio/netty/channel/ChannelHandler;"; +const SIG_PIPELINE_REMOVE: &str = "(Ljava/lang/String;)Lio/netty/channel/ChannelHandler;"; +const SIG_PIPELINE_ADD_BEFORE: &str = "(Ljava/lang/String;Ljava/lang/String;Lio/netty/channel/ChannelHandler;)Lio/netty/channel/ChannelPipeline;"; + +/// Minecraft's own pipeline entry (`Connection`). Our handler must sit *before* +/// it: inbound packets flow head→tail, so to see — and rewrite — a packet +/// before Minecraft processes it, we have to be upstream of this handler. +const MC_PACKET_HANDLER: &str = "packet_handler"; + +/// How many times defining the handler class may fail before giving up — a +/// genuine, repeatable failure should not spam the log forever. +const MAX_DEFINE_ATTEMPTS: u32 = 20; + +struct NetState { + /// The `DarkChannelHandler` class, once defined and bound. + handler_class: Option, + /// The `Connection` our handler is currently installed on. + installed_on: Option, + /// Consecutive class-definition failures — capped by `MAX_DEFINE_ATTEMPTS`. + define_attempts: u32, +} + +static STATE: Mutex = Mutex::new(NetState { + handler_class: None, + installed_on: None, + define_attempts: 0, +}); + +/// Polls the live connection and makes sure our handler sits on its pipeline. +/// Cheap and idempotent — meant to be called once per game tick. +pub fn ensure_installed() { + if let Err(error) = ensure_installed_inner() { + log::debug!("net: install pass failed: {error}"); + } + clear_pending_exception(); +} + +/// Drops any stray pending JNI exception. This code runs inside Minecraft's +/// `glfwSwapBuffers` call — a leaked exception would crash the game the moment +/// control returns to its Java frame. +fn clear_pending_exception() { + if let Ok(env) = mapping().get_env() { + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + log::warn!("net: cleared a stray JNI exception"); + } + } +} + +fn ensure_installed_inner() -> anyhow::Result<()> { + // Define + bind the handler class once. + { + let mut state = STATE.lock().unwrap(); + if state.handler_class.is_none() { + if state.define_attempts >= MAX_DEFINE_ATTEMPTS { + return Ok(()); + } + match define_handler() { + Ok(class) => { + log::info!("net: DarkChannelHandler defined and bound"); + state.handler_class = Some(class); + state.define_attempts = 0; + } + Err(error) => { + state.define_attempts += 1; + log::warn!( + "net: handler definition failed (attempt {}/{}): {error}", + state.define_attempts, + MAX_DEFINE_ATTEMPTS + ); + return Ok(()); + } + } + } + } + + let mut env = mapping().get_env()?; + + // Find the live server connection. + let Some(connection) = current_connection(&mut env)? else { + STATE.lock().unwrap().installed_on = None; + return Ok(()); + }; + + // Already installed on this exact connection? + { + let state = STATE.lock().unwrap(); + if let Some(previous) = &state.installed_on { + if env.is_same_object(previous, &connection)? { + return Ok(()); + } + } + } + + install_on(&mut env, &connection)?; + STATE.lock().unwrap().installed_on = Some(connection); + log::info!("net: handler installed on the connection pipeline"); + Ok(()) +} + +/// Makes the `DarkChannelHandler` class available and binds its native methods +/// to this library's functions. +/// +/// On the first injection the class is `DefineClass`'d into the game class +/// loader. On a **hot-reload** it is already defined there — a class name can +/// be defined only once per loader, and a second `DefineClass` throws a +/// `LinkageError` — so the existing class is reused instead. Either way the +/// native methods are (re)bound: on reload the previous binding points into the +/// now-unloaded old library and *must* be replaced. +fn define_handler() -> anyhow::Result { + let mut env = mapping().get_env()?; + let loader = mapping() + .game_class_loader() + .ok_or_else(|| anyhow::anyhow!("game class loader not captured yet"))?; + + let class = match load_existing_handler(&mut env, &loader) { + Some(existing) => existing, + None => env + .define_class(HANDLER_CLASS, loader.as_obj(), HANDLER_BYTECODE) + .map_err(|error| describe_jni_error(&mut env, "DefineClass", error))?, + }; + + let methods = [ + NativeMethod { + name: "onOutbound".into(), + sig: "(Ljava/lang/Object;)Ljava/lang/Object;".into(), + fn_ptr: dark_on_outbound as *mut c_void, + }, + NativeMethod { + name: "onInbound".into(), + sig: "(Ljava/lang/Object;)Ljava/lang/Object;".into(), + fn_ptr: dark_on_inbound as *mut c_void, + }, + ]; + env.register_native_methods(&class, &methods) + .map_err(|error| describe_jni_error(&mut env, "RegisterNatives", error))?; + + Ok(env.new_global_ref(class)?) +} + +/// Returns the `DarkChannelHandler` class if a previous injection already +/// defined it in `loader` (the hot-reload case), otherwise `None`. +fn load_existing_handler<'a>(env: &mut JNIEnv<'a>, loader: &GlobalRef) -> Option> { + let Ok(name) = env.new_string(HANDLER_CLASS) else { + return None; + }; + let result = env.call_method( + loader.as_obj(), + "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)), + _ => { + // Not defined yet — `loadClass` threw `ClassNotFoundException`. + let _ = env.exception_clear(); + None + } + } +} + +/// Folds the pending Java exception's text into `error`, so a failed JNI call +/// reports *what* the JVM threw rather than the opaque "Java exception thrown". +fn describe_jni_error(env: &mut JNIEnv, what: &str, error: jni::errors::Error) -> anyhow::Error { + if env.exception_check().unwrap_or(false) { + let detail = env + .exception_occurred() + .ok() + .and_then(|throwable| { + let _ = env.exception_clear(); + env.call_method(&throwable, "toString", "()Ljava/lang/String;", &[]) + .ok() + }) + .and_then(|value| value.l().ok()) + .and_then(|obj| { + let jstr = JString::from(obj); + let text = env.get_string(&jstr).ok()?; + Some(text.to_string_lossy().into_owned()) + }); + if let Some(detail) = detail { + return anyhow::anyhow!("{what} failed: {detail}"); + } + } + anyhow::anyhow!("{what} failed: {error}") +} + +/// `minecraft.getConnection().getConnection()` — the live `Connection`, if any. +fn current_connection(env: &mut JNIEnv) -> anyhow::Result> { + env.with_local_frame(8, |env| -> anyhow::Result> { + let listener = env + .call_method( + minecraft().jni_ref().as_obj(), + "getConnection", + SIG_GET_CLIENT_LISTENER, + &[], + )? + .l()?; + if listener.is_null() { + return Ok(None); + } + let connection = env + .call_method(&listener, "getConnection", SIG_GET_CONNECTION, &[])? + .l()?; + if connection.is_null() { + return Ok(None); + } + Ok(Some(env.new_global_ref(connection)?)) + }) +} + +/// Inserts our handler into `connection`'s Netty pipeline, just before +/// Minecraft's own `packet_handler` — so it sees both inbound and outbound +/// packets before the game does. +fn install_on(env: &mut JNIEnv, connection: &GlobalRef) -> anyhow::Result<()> { + let handler_class = STATE + .lock() + .unwrap() + .handler_class + .clone() + .ok_or_else(|| anyhow::anyhow!("handler class missing"))?; + + env.with_local_frame(16, |env| -> anyhow::Result<()> { + let pipeline = pipeline_of(env, connection)?; + let name: JObject = env.new_string(PIPELINE_NAME)?.into(); + + // Idempotent: skip if our handler is already on this pipeline. + let existing = env + .call_method(&pipeline, "get", SIG_PIPELINE_GET, &[JValue::Object(&name)])? + .l()?; + if !existing.is_null() { + return Ok(()); + } + + let class = JClass::from(env.new_local_ref(handler_class.as_obj())?); + let handler = env.new_object(&class, "()V", &[])?; + let base: JObject = env.new_string(MC_PACKET_HANDLER)?.into(); + env.call_method( + &pipeline, + "addBefore", + SIG_PIPELINE_ADD_BEFORE, + &[ + JValue::Object(&base), + JValue::Object(&name), + JValue::Object(&handler), + ], + )?; + Ok(()) + }) +} + +/// Releases everything before the library is unloaded — called from +/// `cleanup_client`. Leaving the handler on the pipeline would crash the JVM +/// (its native methods would point at unmapped memory). +pub fn teardown() { + let (connection, class) = { + let mut state = STATE.lock().unwrap(); + // A fresh injection gets a fresh definition-retry budget. + state.define_attempts = 0; + (state.installed_on.take(), state.handler_class.take()) + }; + + if let (Some(connection), Ok(mut env)) = (connection, mapping().get_env()) { + env.with_local_frame(16, |env| -> anyhow::Result<()> { + let pipeline = pipeline_of(env, &connection)?; + let name: JObject = env.new_string(PIPELINE_NAME)?.into(); + let existing = env + .call_method(&pipeline, "get", SIG_PIPELINE_GET, &[JValue::Object(&name)])? + .l()?; + if !existing.is_null() { + env.call_method( + &pipeline, + "remove", + SIG_PIPELINE_REMOVE, + &[JValue::Object(&name)], + )?; + } + Ok(()) + }) + .unwrap_or_else(|error| log::debug!("net: pipeline cleanup failed: {error}")); + } + + if let Some(class) = class { + if let Ok(mut env) = mapping().get_env() { + if let Ok(local) = env.new_local_ref(class.as_obj()) { + let _ = env.unregister_native_methods(JClass::from(local)); + } + } + } + clear_pending_exception(); +} + +/// `connection.channel.pipeline()` — the Netty pipeline of a `Connection`. +/// Must be called inside an existing local-reference frame. +fn pipeline_of<'a>(env: &mut JNIEnv<'a>, connection: &GlobalRef) -> anyhow::Result> { + let channel = env + .get_field(connection.as_obj(), "channel", SIG_CHANNEL_FIELD)? + .l()?; + if channel.is_null() { + return Err(anyhow::anyhow!("connection has no channel")); + } + Ok(env + .call_method(&channel, "pipeline", SIG_PIPELINE, &[])? + .l()?) +} + +// --- packet dispatch ------------------------------------------------------- + +/// What the dispatch decided the Netty callback should forward. +enum Dispatch { + /// Forward the original packet object, untouched. + Forward, + /// Forward this freshly built object in place of the original. + Replace(jobject), + /// Drop the packet — the callback returns `null` so Netty discards it + /// (the packet is never sent, outbound, nor delivered, inbound). + Drop, +} + +/// `DarkChannelHandler.onOutbound` — dispatches an outbound packet. +unsafe extern "system" fn dark_on_outbound( + env: *mut jni::sys::JNIEnv, + _class: jni::sys::jclass, + packet: jobject, +) -> jobject { + catch_unwind(AssertUnwindSafe(|| { + match unsafe { dispatch(env, packet, false) } { + Ok(Dispatch::Replace(replacement)) => replacement, + Ok(Dispatch::Drop) => std::ptr::null_mut(), + Ok(Dispatch::Forward) | Err(_) => packet, + } + })) + .unwrap_or(packet) +} + +/// `DarkChannelHandler.onInbound` — dispatches an inbound packet. +unsafe extern "system" fn dark_on_inbound( + env: *mut jni::sys::JNIEnv, + _class: jni::sys::jclass, + packet: jobject, +) -> jobject { + catch_unwind(AssertUnwindSafe(|| { + match unsafe { dispatch(env, packet, true) } { + Ok(Dispatch::Replace(replacement)) => replacement, + Ok(Dispatch::Drop) => std::ptr::null_mut(), + Ok(Dispatch::Forward) | Err(_) => packet, + } + })) + .unwrap_or(packet) +} + +/// Wraps a packet for the modules, lets each enabled module's `handle_packet` +/// modify or cancel it, and rebuilds the JVM object if it changed. Every packet +/// type no module handles — and every error — yields [`Dispatch::Forward`], so +/// the connection is never disrupted by this layer. `inbound` selects which +/// `Packet` variants to probe. +unsafe fn dispatch( + env: *mut jni::sys::JNIEnv, + packet: jobject, + inbound: bool, +) -> anyhow::Result { + if packet.is_null() { + return Ok(Dispatch::Forward); + } + let mut env = unsafe { JNIEnv::from_raw(env)? }; + let packet_obj = unsafe { JObject::from_raw(packet) }; + + let built = if inbound { + Packet::from_inbound(&mut env, &packet_obj)? + } else { + Packet::from_outbound(&mut env, &packet_obj)? + }; + let Some(mut wrapped) = built else { + return Ok(Dispatch::Forward); + }; + + let original = wrapped.clone(); + match crate::state::client().modules.handle_packet(&mut wrapped) { + PacketAction::Cancel => Ok(Dispatch::Drop), + PacketAction::Forward if wrapped != original => { + Ok(Dispatch::Replace(wrapped.to_java(&mut env)?)) + } + PacketAction::Forward => Ok(Dispatch::Forward), + } +} diff --git a/client/src/net/packet/clientbound_set_entity_motion.rs b/client/src/net/packet/clientbound_set_entity_motion.rs new file mode 100644 index 0000000..ce50d20 --- /dev/null +++ b/client/src/net/packet/clientbound_set_entity_motion.rs @@ -0,0 +1,65 @@ +//! Rust value-snapshot of `ClientboundSetEntityMotionPacket` — the server +//! telling the client an entity's velocity changed (knockback, explosions, …). +//! +//! A `record (int id, Vec3 movement)`, so `read` / `to_java` are direct. + +use crate::mapping::MinecraftClassType; +use crate::state::mapping; +use jni::objects::{JObject, JValue}; +use jni::sys::jobject; +use jni::JNIEnv; + +/// Class of `ClientboundSetEntityMotionPacket`. +pub const CLASS_TYPE: MinecraftClassType = MinecraftClassType::ClientboundSetEntityMotionPacket; + +/// A snapshot of a `ClientboundSetEntityMotionPacket`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClientboundSetEntityMotionPacket { + /// Network id of the entity whose motion is being set. + pub entity_id: i32, + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl ClientboundSetEntityMotionPacket { + /// Reads a `ClientboundSetEntityMotionPacket` JVM object into a snapshot. + pub fn read( + env: &mut JNIEnv, + packet: &JObject, + ) -> anyhow::Result { + let entity_id = env.call_method(packet, "id", "()I", &[])?.i()?; + let movement = env + .call_method(packet, "movement", "()Lnet/minecraft/world/phys/Vec3;", &[])? + .l()?; + Ok(ClientboundSetEntityMotionPacket { + entity_id, + x: env.get_field(&movement, "x", "D")?.d()?, + y: env.get_field(&movement, "y", "D")?.d()?, + z: env.get_field(&movement, "z", "D")?.d()?, + }) + } + + /// Builds the `ClientboundSetEntityMotionPacket` JVM object from this + /// snapshot. + pub fn to_java(self, env: &mut JNIEnv) -> anyhow::Result { + let vec3_class = mapping().resolve_class(env, MinecraftClassType::Vec3.get_name())?; + let movement = env.new_object( + &vec3_class, + "(DDD)V", + &[ + JValue::Double(self.x), + JValue::Double(self.y), + JValue::Double(self.z), + ], + )?; + + let packet_class = mapping().resolve_class(env, CLASS_TYPE.get_name())?; + let packet = env.new_object( + &packet_class, + "(ILnet/minecraft/world/phys/Vec3;)V", + &[JValue::Int(self.entity_id), JValue::Object(&movement)], + )?; + Ok(packet.into_raw()) + } +} diff --git a/client/src/net/packet/mod.rs b/client/src/net/packet/mod.rs new file mode 100644 index 0000000..71d3080 --- /dev/null +++ b/client/src/net/packet/mod.rs @@ -0,0 +1,88 @@ +//! Rust value-snapshots of the Minecraft packets DarkClient reads or rewrites. +//! +//! Each packet is read once into a plain Rust struct (`read`), wrapped in the +//! [`Packet`] enum, modified freely by the modules' `handle_packet`, then — if +//! changed — rebuilt into a JVM object (`to_java`). A module may also drop a +//! packet outright by returning [`PacketAction::Cancel`]. All JNI uses explicit +//! descriptors and the [`MinecraftClassType`] class table — no reflection, no +//! overload resolution, no string literals. +//! +//! Minecraft packet classes are strictly directional — `Serverbound*` are +//! outbound, `Clientbound*` inbound — so every [`Packet`] variant has a fixed +//! direction, and the dispatch only probes the variants of the right one +//! ([`Packet::from_outbound`] / [`Packet::from_inbound`]). + +pub mod clientbound_set_entity_motion; +pub mod serverbound_move_player; + +use crate::mapping::MinecraftClassType; +use crate::state::mapping; +use clientbound_set_entity_motion::ClientboundSetEntityMotionPacket; +use jni::objects::JObject; +use jni::sys::jobject; +use jni::JNIEnv; +use serverbound_move_player::ServerboundMovePlayerPacket; + +/// A packet passing through the connection, in a form modules can `match` on. +/// Variant names mirror the Minecraft packet classes. +#[derive(Debug, Clone, PartialEq)] +pub enum Packet { + /// Outbound `ServerboundMovePlayerPacket`. + ServerboundMovePlayer(ServerboundMovePlayerPacket), + /// Inbound `ClientboundSetEntityMotionPacket`. + ClientboundSetEntityMotion(ClientboundSetEntityMotionPacket), +} + +/// What should happen to a packet after the modules have seen it. Returned by +/// `Module::handle_packet`; the default is [`PacketAction::Forward`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PacketAction { + /// Forward the packet — possibly after a module mutated it in place. + #[default] + Forward, + /// Drop the packet entirely: it is never sent (outbound) nor delivered + /// (inbound). One module asking to cancel is enough — the rest are skipped. + Cancel, +} + +impl Packet { + /// Builds a `Packet` from an outbound (`Serverbound`) JVM packet, or `None` + /// when it is not a type any module handles. + pub fn from_outbound(env: &mut JNIEnv, packet: &JObject) -> anyhow::Result> { + if is_instance(env, packet, serverbound_move_player::CLASS_TYPE)? { + return Ok(Some(Packet::ServerboundMovePlayer( + ServerboundMovePlayerPacket::read(env, packet)?, + ))); + } + Ok(None) + } + + /// Builds a `Packet` from an inbound (`Clientbound`) JVM packet, or `None` + /// when it is not a type any module handles. + pub fn from_inbound(env: &mut JNIEnv, packet: &JObject) -> anyhow::Result> { + if is_instance(env, packet, clientbound_set_entity_motion::CLASS_TYPE)? { + return Ok(Some(Packet::ClientboundSetEntityMotion( + ClientboundSetEntityMotionPacket::read(env, packet)?, + ))); + } + Ok(None) + } + + /// Rebuilds the JVM packet object from this (possibly modified) snapshot. + pub fn to_java(self, env: &mut JNIEnv) -> anyhow::Result { + match self { + Packet::ServerboundMovePlayer(packet) => packet.to_java(env), + Packet::ClientboundSetEntityMotion(packet) => packet.to_java(env), + } + } +} + +/// Whether `object` is an instance of the given mapped class. +fn is_instance( + env: &mut JNIEnv, + object: &JObject, + class: MinecraftClassType, +) -> anyhow::Result { + let jclass = mapping().resolve_class(env, class.get_name())?; + Ok(env.is_instance_of(object, &jclass)?) +} diff --git a/client/src/net/packet/serverbound_move_player.rs b/client/src/net/packet/serverbound_move_player.rs new file mode 100644 index 0000000..9b22afe --- /dev/null +++ b/client/src/net/packet/serverbound_move_player.rs @@ -0,0 +1,127 @@ +//! Rust value-snapshot of `ServerboundMovePlayerPacket`. +//! +//! The packet is an abstract class with four subclasses — `Pos`, `PosRot`, +//! `Rot`, `StatusOnly` — chosen by which of position / rotation it carries. +//! `read` snapshots any of them; `to_java` rebuilds the matching subclass. + +use crate::mapping::MinecraftClassType; +use crate::state::mapping; +use jni::objects::{JObject, JValue}; +use jni::sys::jobject; +use jni::JNIEnv; + +/// Class of the abstract `ServerboundMovePlayerPacket`. +pub const CLASS_TYPE: MinecraftClassType = MinecraftClassType::ServerboundMovePlayerPacket; + +/// A snapshot of a `ServerboundMovePlayerPacket`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ServerboundMovePlayerPacket { + pub x: f64, + pub y: f64, + pub z: f64, + pub y_rot: f32, + pub x_rot: f32, + pub on_ground: bool, + pub horizontal_collision: bool, + pub has_position: bool, + pub has_rotation: bool, +} + +impl ServerboundMovePlayerPacket { + /// Reads a `ServerboundMovePlayerPacket` JVM object into a snapshot. + pub fn read(env: &mut JNIEnv, packet: &JObject) -> anyhow::Result { + Ok(ServerboundMovePlayerPacket { + x: env + .call_method(packet, "getX", "(D)D", &[JValue::Double(0.0)])? + .d()?, + y: env + .call_method(packet, "getY", "(D)D", &[JValue::Double(0.0)])? + .d()?, + z: env + .call_method(packet, "getZ", "(D)D", &[JValue::Double(0.0)])? + .d()?, + y_rot: env + .call_method(packet, "getYRot", "(F)F", &[JValue::Float(0.0)])? + .f()?, + x_rot: env + .call_method(packet, "getXRot", "(F)F", &[JValue::Float(0.0)])? + .f()?, + on_ground: env.call_method(packet, "isOnGround", "()Z", &[])?.z()?, + horizontal_collision: env + .call_method(packet, "horizontalCollision", "()Z", &[])? + .z()?, + has_position: env.call_method(packet, "hasPosition", "()Z", &[])?.z()?, + has_rotation: env.call_method(packet, "hasRotation", "()Z", &[])?.z()?, + }) + } + + /// Builds the matching `ServerboundMovePlayerPacket` subclass from this + /// snapshot, returning the new JVM object. + pub fn to_java(self, env: &mut JNIEnv) -> anyhow::Result { + let on_ground = JValue::Bool(self.on_ground as u8); + let collision = JValue::Bool(self.horizontal_collision as u8); + + let object = match (self.has_position, self.has_rotation) { + (true, true) => { + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketPosRot.get_name(), + )?; + env.new_object( + &class, + "(DDDFFZZ)V", + &[ + JValue::Double(self.x), + JValue::Double(self.y), + JValue::Double(self.z), + JValue::Float(self.y_rot), + JValue::Float(self.x_rot), + on_ground, + collision, + ], + )? + } + (true, false) => { + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketPos.get_name(), + )?; + env.new_object( + &class, + "(DDDZZ)V", + &[ + JValue::Double(self.x), + JValue::Double(self.y), + JValue::Double(self.z), + on_ground, + collision, + ], + )? + } + (false, true) => { + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketRot.get_name(), + )?; + env.new_object( + &class, + "(FFZZ)V", + &[ + JValue::Float(self.y_rot), + JValue::Float(self.x_rot), + on_ground, + collision, + ], + )? + } + (false, false) => { + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketStatusOnly.get_name(), + )?; + env.new_object(&class, "(ZZ)V", &[on_ground, collision])? + } + }; + Ok(object.into_raw()) + } +} diff --git a/client/src/state.rs b/client/src/state.rs index 452fc5c..fb9120c 100644 --- a/client/src/state.rs +++ b/client/src/state.rs @@ -70,6 +70,27 @@ pub fn env() -> anyhow::Result> { mapping().get_env() } +/// Releases the JVM resources the global state holds — called from +/// `cleanup_client` before the library is unloaded. +/// +/// The `MAPPING` / `CLIENT` statics are `OnceLock`s and are never dropped, so +/// the handful of global references kept in plain fields (`Minecraft` and +/// `Window`) outlive a hot-reload. Those point at session-lifetime singletons, +/// so only a couple of global-ref table slots leak per reload; everything +/// sizable — the class-handle cache, the game class loader, the cached player +/// — is released here. +pub fn teardown() { + // Remove the Netty pipeline handler first — leaving it in place would + // crash the JVM once this library is unloaded. + crate::net::teardown(); + if let Some(client) = CLIENT.get() { + client.minecraft.teardown(); + } + if let Some(mapping) = MAPPING.get() { + mapping.teardown(); + } +} + /// The running game and module state. pub struct Client { minecraft: Minecraft, diff --git a/injector/src/inject.rs b/injector/src/inject.rs index 1959d9c..87d6ae5 100644 --- a/injector/src/inject.rs +++ b/injector/src/inject.rs @@ -49,11 +49,17 @@ pub fn inject(pid: u32) -> Result<(), InjectError> { /// Connects to the agent's command server and sends a [`Command::Reload`]. fn send_reload(client_lib: &Path) -> Result<(), InjectError> { let absolute = std::path::absolute(client_lib).map_err(InjectError::Path)?; + // The client keeps its config in the directory the injector runs from. + let config_dir = std::env::current_dir().map_err(InjectError::Path)?; let mut stream = connect_with_retry()?; let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT)); - let command = Command::Reload(absolute).encode(); + let command = Command::Reload { + library: absolute, + config_dir, + } + .encode(); info!("sending command: {command}"); stream .write_all(command.as_bytes()) diff --git a/mapping_derive/Cargo.toml b/mapping_derive/Cargo.toml new file mode 100644 index 0000000..9221026 --- /dev/null +++ b/mapping_derive/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mapping_derive" +version.workspace = true +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn.workspace = true +quote.workspace = true diff --git a/mapping_derive/src/lib.rs b/mapping_derive/src/lib.rs new file mode 100644 index 0000000..3fe9b54 --- /dev/null +++ b/mapping_derive/src/lib.rs @@ -0,0 +1,101 @@ +//! Derive macro for `MappedObject`. +//! +//! Generates, for a Rust wrapper around a JVM object, the `MappedObject` trait +//! impl plus `PartialEq` / `Eq` (value equality via Java `Object.equals`). + +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Data, DeriveInput, Error}; + +/// Derives `MappedObject` for a wrapper struct. +/// +/// The struct must have a `jni_ref` field and a `#[mapped(class = )]` +/// attribute naming its `MinecraftClassType`: +/// +/// ```ignore +/// #[derive(MappedObject)] +/// #[mapped(class = Entity)] +/// pub struct Entity { +/// jni_ref: GlobalRef, +/// } +/// ``` +#[proc_macro_derive(MappedObject, attributes(mapped))] +pub fn derive_mapped_object(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + // The struct must carry a `jni_ref` field — that is what the impl reads. + match &input.data { + Data::Struct(data) => { + let has_jni_ref = data + .fields + .iter() + .any(|field| field.ident.as_ref().is_some_and(|id| id == "jni_ref")); + if !has_jni_ref { + return Error::new_spanned( + name, + "#[derive(MappedObject)] requires a `jni_ref` field", + ) + .to_compile_error() + .into(); + } + } + _ => { + return Error::new_spanned(name, "#[derive(MappedObject)] supports structs only") + .to_compile_error() + .into(); + } + } + + // Extract the class variant from `#[mapped(class = ...)]`. + let mut class = None; + for attr in &input.attrs { + if !attr.path().is_ident("mapped") { + continue; + } + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("class") { + class = Some(meta.value()?.parse::()?); + Ok(()) + } else { + Err(meta.error("expected `class = `")) + } + }); + if let Err(error) = parsed { + return error.to_compile_error().into(); + } + } + + let class = match class { + Some(class) => class, + None => { + return Error::new_spanned( + name, + "#[derive(MappedObject)] requires #[mapped(class = )]", + ) + .to_compile_error() + .into(); + } + }; + + quote! { + impl crate::mapping::MappedObject for #name { + fn jni_ref(&self) -> &::jni::objects::GlobalRef { + &self.jni_ref + } + + fn class_type() -> crate::mapping::MinecraftClassType { + crate::mapping::MinecraftClassType::#class + } + } + + impl ::core::cmp::PartialEq for #name { + fn eq(&self, other: &Self) -> bool { + crate::mapping::MappedObject::equals(self, other) + } + } + + impl ::core::cmp::Eq for #name {} + } + .into() +} diff --git a/protocol/src/command.rs b/protocol/src/command.rs index b3e316b..5a0ee82 100644 --- a/protocol/src/command.rs +++ b/protocol/src/command.rs @@ -6,12 +6,21 @@ use thiserror::Error; /// A single command sent from the injector to the agent loader. /// -/// The wire form is one UTF-8 line: a lowercase verb, optionally followed by -/// a single space and one argument that runs to the end of the line. +/// The wire form is one UTF-8 line: a lowercase verb followed by a space and +/// its arguments. `Reload` carries two paths — the client library and the +/// injector's working directory — separated by a tab, so each path may still +/// contain spaces and survive the round trip. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { - /// Load — or hot-reload — the client library at the given absolute path. - Reload(PathBuf), + /// Load — or hot-reload — the client library, and tell the client where + /// to keep its config (the injector's working directory). + Reload { + /// Absolute path of the client library. + library: PathBuf, + /// Directory the injector was started in — where the client config + /// file is read from and written to. + config_dir: PathBuf, + }, } /// Failure to parse a command off the wire. @@ -32,7 +41,10 @@ impl Command { /// Serializes the command into its single-line wire form (no newline). pub fn encode(&self) -> String { match self { - Command::Reload(path) => format!("reload {}", path.display()), + Command::Reload { + library, + config_dir, + } => format!("reload {}\t{}", library.display(), config_dir.display()), } } @@ -52,10 +64,20 @@ impl Command { match verb { "reload" => { if arg.is_empty() { - Err(ProtocolError::MissingArgument { verb: "reload" }) - } else { - Ok(Command::Reload(PathBuf::from(arg))) + return Err(ProtocolError::MissingArgument { verb: "reload" }); } + // The library path and the config directory are tab-separated; + // an old-style line without a tab still decodes (no config dir). + let (library, config_dir) = match arg.split_once('\t') { + Some((library, config_dir)) => { + (PathBuf::from(library), PathBuf::from(config_dir)) + } + None => (PathBuf::from(arg), PathBuf::new()), + }; + Ok(Command::Reload { + library, + config_dir, + }) } other => Err(ProtocolError::UnknownVerb(other.to_string())), } @@ -69,23 +91,43 @@ mod tests { #[test] fn reload_round_trips() { - let command = Command::Reload(PathBuf::from("/tmp/libclient.so")); + let command = Command::Reload { + library: PathBuf::from("/tmp/libclient.so"), + config_dir: PathBuf::from("/home/work"), + }; let wire = command.encode(); - assert_eq!(wire, "reload /tmp/libclient.so"); + assert_eq!(wire, "reload /tmp/libclient.so\t/home/work"); assert_eq!(Command::decode(&wire), Ok(command)); } #[test] - fn reload_path_with_spaces_survives_the_round_trip() { - let command = Command::Reload(PathBuf::from("/home/My Games/libclient.so")); + fn reload_paths_with_spaces_survive_the_round_trip() { + let command = Command::Reload { + library: PathBuf::from("/home/My Games/libclient.so"), + config_dir: PathBuf::from("/home/My Games/cfg dir"), + }; assert_eq!(Command::decode(&command.encode()), Ok(command)); } #[test] fn decode_ignores_surrounding_whitespace() { assert_eq!( - Command::decode(" reload /a/b.so\n"), - Ok(Command::Reload(PathBuf::from("/a/b.so"))), + Command::decode(" reload /a/b.so\t/c\n"), + Ok(Command::Reload { + library: PathBuf::from("/a/b.so"), + config_dir: PathBuf::from("/c"), + }), + ); + } + + #[test] + fn reload_without_a_config_dir_decodes_with_an_empty_one() { + assert_eq!( + Command::decode("reload /a/b.so"), + Ok(Command::Reload { + library: PathBuf::from("/a/b.so"), + config_dir: PathBuf::new(), + }), ); }