From 8248567fafebbb0c149fade78bda69b778d139dc Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Thu, 21 May 2026 16:35:57 +0200 Subject: [PATCH 1/6] Partial rework and ordering --- .gitignore | 3 +- client/src/graphic/esp.rs | 488 +++++---------------- client/src/mapping/block_entity.rs | 73 +++ client/src/mapping/client/camera.rs | 80 ++++ client/src/mapping/client/game_renderer.rs | 53 +++ client/src/mapping/client/gamemode.rs | 13 +- client/src/mapping/client/minecraft.rs | 110 +++-- client/src/mapping/client/mod.rs | 3 + client/src/mapping/client/options.rs | 101 +++++ client/src/mapping/client/window.rs | 37 +- client/src/mapping/client/world.rs | 126 +++++- client/src/mapping/component.rs | 52 +++ client/src/mapping/entity/living.rs | 66 +++ client/src/mapping/entity/mob.rs | 41 ++ client/src/mapping/entity/mod.rs | 144 ++++-- client/src/mapping/entity/player.rs | 90 +++- client/src/mapping/java/iterable.rs | 38 +- client/src/mapping/java/iterator.rs | 36 +- client/src/mapping/math.rs | 86 ++++ client/src/mapping/mod.rs | 14 + client/src/mapping/object.rs | 38 ++ client/src/module/combat/aimbot.rs | 45 +- client/src/module/combat/aura.rs | 45 +- client/src/module/combat/killaura.rs | 5 +- client/src/module/combat/mobaura.rs | 5 +- 25 files changed, 1227 insertions(+), 565 deletions(-) create mode 100644 client/src/mapping/block_entity.rs create mode 100644 client/src/mapping/client/camera.rs create mode 100644 client/src/mapping/client/game_renderer.rs create mode 100644 client/src/mapping/client/options.rs create mode 100644 client/src/mapping/component.rs create mode 100644 client/src/mapping/entity/living.rs create mode 100644 client/src/mapping/entity/mob.rs create mode 100644 client/src/mapping/math.rs create mode 100644 client/src/mapping/object.rs 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/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index eba7bb5..7630777 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::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::JavaObject; use crate::module::ModuleSetting; -use crate::state::{client, mapping, minecraft}; +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, @@ -388,12 +404,10 @@ 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 +416,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 +454,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 +497,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 +543,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 +586,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 +625,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)) +fn read_health(entity: &Entity) -> anyhow::Result<(f32, f32)> { + let living = entity.as_living(); + Ok((living.get_health()?, living.get_max_health()?)) } /// 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/mapping/block_entity.rs b/client/src/mapping/block_entity.rs new file mode 100644 index 0000000..115ef65 --- /dev/null +++ b/client/src/mapping/block_entity.rs @@ -0,0 +1,73 @@ +//! Wrapper for Minecraft's `BlockEntity` (chests, barrels, …). + +use crate::mapping::math::BlockPos; +use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// A Minecraft `BlockEntity`. +#[derive(Debug, Clone)] +pub struct BlockEntity { + pub 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 { + mapping().in_frame(|| { + let pos = mapping() + .call_method( + MinecraftClassType::BlockEntity, + self.jni_ref.as_obj(), + "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, + ]; + mapping() + .in_frame(|| { + Ok(KINDS.iter().any(|&kind| { + mapping() + .is_instance_of(kind, self.jni_ref.as_obj()) + .unwrap_or(false) + })) + }) + .unwrap_or(false) + } +} + +impl JavaObject for BlockEntity { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::BlockEntity + } +} + +impl Deref for BlockEntity { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/client/camera.rs b/client/src/mapping/client/camera.rs new file mode 100644 index 0000000..924bee7 --- /dev/null +++ b/client/src/mapping/client/camera.rs @@ -0,0 +1,80 @@ +//! Wrapper for Minecraft's render `Camera`. + +use crate::mapping::math::Vec3; +use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// 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)] +pub struct Camera { + pub 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 { + mapping().in_frame(|| { + let vec3 = mapping() + .get_field( + MinecraftClassType::Camera, + self.jni_ref.as_obj(), + "position", + FieldType::Object(MinecraftClassType::Vec3), + )? + .l()?; + Vec3::read(&vec3) + }) + } + + /// Yaw, in degrees (the `yRot` field). + pub fn yaw(&self) -> anyhow::Result { + Ok(mapping() + .get_field( + MinecraftClassType::Camera, + self.jni_ref.as_obj(), + "yRot", + FieldType::Float, + )? + .f()?) + } + + /// Pitch, in degrees (the `xRot` field). + pub fn pitch(&self) -> anyhow::Result { + Ok(mapping() + .get_field( + MinecraftClassType::Camera, + self.jni_ref.as_obj(), + "xRot", + FieldType::Float, + )? + .f()?) + } +} + +impl JavaObject for Camera { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Camera + } +} + +impl Deref for Camera { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/client/game_renderer.rs b/client/src/mapping/client/game_renderer.rs new file mode 100644 index 0000000..b1330f7 --- /dev/null +++ b/client/src/mapping/client/game_renderer.rs @@ -0,0 +1,53 @@ +//! Wrapper for Minecraft's `GameRenderer`. + +use crate::mapping::client::camera::Camera; +use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// Minecraft's `GameRenderer`. +#[derive(Debug)] +pub struct GameRenderer { + pub 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 { + mapping().in_frame(|| { + let camera = mapping() + .call_method( + MinecraftClassType::GameRenderer, + self.jni_ref.as_obj(), + "getMainCamera", + &[], + )? + .l()?; + Ok(Camera::new(mapping().new_global_ref(camera)?)) + }) + } +} + +impl JavaObject for GameRenderer { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::GameRenderer + } +} + +impl Deref for GameRenderer { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/client/gamemode.rs b/client/src/mapping/client/gamemode.rs index 512e0d3..96774bb 100644 --- a/client/src/mapping/client/gamemode.rs +++ b/client/src/mapping/client/gamemode.rs @@ -1,6 +1,6 @@ use crate::mapping::entity::player::LocalPlayer; use crate::mapping::entity::Entity; -use crate::mapping::MinecraftClassType; +use crate::mapping::{JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use std::ops::Deref; @@ -15,6 +15,7 @@ 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, @@ -30,6 +31,16 @@ impl MultiPlayerGameMode { } } +impl JavaObject for MultiPlayerGameMode { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::MultiPlayerGameMode + } +} + impl Deref for MultiPlayerGameMode { type Target = GlobalRef; diff --git a/client/src/mapping/client/minecraft.rs b/client/src/mapping/client/minecraft.rs index 06e23e3..dcbd833 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -1,8 +1,10 @@ +use crate::mapping::client::game_renderer::GameRenderer; use crate::mapping::client::gamemode::MultiPlayerGameMode; +use crate::mapping::client::options::Options; 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, JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -27,13 +29,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 { @@ -89,6 +93,36 @@ impl Minecraft { .map(MultiPlayerGameMode::new)) } + /// The game renderer. Present from the main menu onward. + pub fn game_renderer(&self) -> anyhow::Result { + mapping().in_frame(|| { + let obj = mapping() + .get_field( + MinecraftClassType::Minecraft, + self.jni_ref.as_obj(), + "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 { + mapping().in_frame(|| { + let obj = mapping() + .get_field( + MinecraftClassType::Minecraft, + self.jni_ref.as_obj(), + "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(_))) @@ -96,17 +130,19 @@ impl Minecraft { /// 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(); - } - } - true + mapping() + .in_frame(|| { + let screen = mapping() + .get_field( + MinecraftClassType::Minecraft, + self.jni_ref.as_obj(), + "screen", + FieldType::Object(MinecraftClassType::Screen), + )? + .l()?; + Ok(screen.is_null()) + }) + .unwrap_or(true) } /// Reads a world-scoped object field of `Minecraft`, returning `Ok(None)` @@ -116,18 +152,30 @@ 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)?)) + mapping().in_frame(|| { + 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 JavaObject for Minecraft { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Minecraft } } diff --git a/client/src/mapping/client/mod.rs b/client/src/mapping/client/mod.rs index 05af714..446d897 100644 --- a/client/src/mapping/client/mod.rs +++ b/client/src/mapping/client/mod.rs @@ -1,4 +1,7 @@ +pub mod camera; +pub mod game_renderer; pub mod gamemode; pub mod minecraft; +pub mod options; 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..d388c3a --- /dev/null +++ b/client/src/mapping/client/options.rs @@ -0,0 +1,101 @@ +//! Wrappers for Minecraft's `Options` and `OptionInstance`. + +use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// Minecraft's game `Options`. +#[derive(Debug)] +pub struct Options { + pub 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 { + mapping().in_frame(|| { + let option = mapping() + .get_field( + MinecraftClassType::Options, + self.jni_ref.as_obj(), + "fov", + FieldType::Object(MinecraftClassType::OptionInstance), + )? + .l()?; + Ok(OptionInstance::new(mapping().new_global_ref(option)?)) + }) + } +} + +impl JavaObject for Options { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Options + } +} + +impl Deref for Options { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} + +/// A single Minecraft `OptionInstance` — one configurable game option. +#[derive(Debug)] +pub struct OptionInstance { + pub 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 { + mapping().in_frame(|| { + let value = mapping() + .call_method( + MinecraftClassType::OptionInstance, + self.jni_ref.as_obj(), + "get", + &[], + )? + .l()?; + Ok(mapping() + .call_method(MinecraftClassType::Integer, &value, "intValue", &[])? + .i()?) + }) + } +} + +impl JavaObject for OptionInstance { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::OptionInstance + } +} + +impl Deref for OptionInstance { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/client/window.rs b/client/src/mapping/client/window.rs index de9ceb2..ba1b4b1 100644 --- a/client/src/mapping/client/window.rs +++ b/client/src/mapping/client/window.rs @@ -1,5 +1,5 @@ use crate::mapping::method::MethodName; -use crate::mapping::MinecraftClassType; +use crate::mapping::{JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use jni::sys::jlong; @@ -12,23 +12,24 @@ pub struct Window { 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, @@ -40,6 +41,16 @@ impl Window { } } +impl JavaObject for Window { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Window + } +} + impl Deref for Window { type Target = GlobalRef; diff --git a/client/src/mapping/client/world.rs b/client/src/mapping/client/world.rs index be25a6a..538444d 100644 --- a/client/src/mapping/client/world.rs +++ b/client/src/mapping/client/world.rs @@ -1,8 +1,9 @@ +use crate::mapping::block_entity::BlockEntity; use crate::mapping::entity::Entity; use crate::mapping::java::iterable::Iterable; -use crate::mapping::MinecraftClassType; +use crate::mapping::{JavaObject, MinecraftClassType}; use crate::state::mapping; -use jni::objects::GlobalRef; +use jni::objects::{GlobalRef, JValue}; use std::ops::Deref; #[derive(Debug)] @@ -16,28 +17,55 @@ 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()?; + mapping().in_frame(|| { + let iterable_obj = mapping() + .call_method( + MinecraftClassType::Level, + self.jni_ref.as_obj(), + "entitiesForRendering", + &[], + )? + .l()?; + let iterable = Iterable::new(mapping().new_global_ref(iterable_obj)?); - 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) + }) + } - let iterator = iterable.iterator()?; - let mut entities = Vec::new(); + /// 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> { + mapping().in_frame(|| { + let chunk = mapping() + .call_method( + MinecraftClassType::LevelReader, + self.jni_ref.as_obj(), + "getChunk", + &[JValue::Int(x), JValue::Int(z)], + )? + .l()?; + if chunk.is_null() { + return Ok(None); + } + Ok(Some(LevelChunk::new(mapping().new_global_ref(chunk)?))) + }) + } +} - while iterator.has_next()? { - entities.push(Entity::new(iterator.next()?)); - } +impl JavaObject for World { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } - Ok(entities) + fn class_type() -> MinecraftClassType { + MinecraftClassType::Level } } @@ -48,3 +76,63 @@ impl Deref for World { &self.jni_ref } } + +/// A loaded `LevelChunk`. +#[derive(Debug)] +pub struct LevelChunk { + pub 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> { + mapping().in_frame(|| { + let map = mapping() + .call_method( + MinecraftClassType::LevelChunk, + self.jni_ref.as_obj(), + "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()?; + + let mut block_entities = Vec::new(); + while iterator.has_next()? { + block_entities.push(BlockEntity::new(iterator.next()?)); + } + Ok(block_entities) + }) + } +} + +impl JavaObject for LevelChunk { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::LevelChunk + } +} + +impl Deref for LevelChunk { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/component.rs b/client/src/mapping/component.rs new file mode 100644 index 0000000..fdce7c7 --- /dev/null +++ b/client/src/mapping/component.rs @@ -0,0 +1,52 @@ +//! Wrapper for Minecraft's `Component` — a piece of rich (chat / display) text. + +use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// A Minecraft `Component`. +#[derive(Debug, Clone)] +pub struct Component { + pub 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 { + mapping().in_frame(|| { + let string = mapping() + .call_method( + MinecraftClassType::Component, + self.jni_ref.as_obj(), + "getString", + &[], + )? + .l()?; + mapping().get_string(string) + }) + } +} + +impl JavaObject for Component { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Component + } +} + +impl Deref for Component { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/entity/living.rs b/client/src/mapping/entity/living.rs new file mode 100644 index 0000000..d7f023d --- /dev/null +++ b/client/src/mapping/entity/living.rs @@ -0,0 +1,66 @@ +//! Wrapper for Minecraft's `LivingEntity`. + +use crate::mapping::entity::Entity; +use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::state::mapping; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// A Minecraft `LivingEntity` — an [`Entity`] that also carries health. +#[derive(Debug, Clone)] +pub struct LivingEntity { + pub 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(mapping() + .call_method( + MinecraftClassType::LivingEntity, + self.jni_ref.as_obj(), + "getHealth", + &[], + )? + .f()?) + } + + /// Maximum health. + pub fn get_max_health(&self) -> anyhow::Result { + Ok(mapping() + .call_method( + MinecraftClassType::LivingEntity, + self.jni_ref.as_obj(), + "getMaxHealth", + &[], + )? + .f()?) + } +} + +impl JavaObject for LivingEntity { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::LivingEntity + } +} + +impl Deref for LivingEntity { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/entity/mob.rs b/client/src/mapping/entity/mob.rs new file mode 100644 index 0000000..7bb0d9d --- /dev/null +++ b/client/src/mapping/entity/mob.rs @@ -0,0 +1,41 @@ +//! Wrapper for Minecraft's `Mob` — a non-player living entity. + +use crate::mapping::entity::Entity; +use crate::mapping::{JavaObject, MinecraftClassType}; +use jni::objects::GlobalRef; +use std::ops::Deref; + +/// A Minecraft `Mob` (non-player living entity). +#[derive(Debug, Clone)] +pub struct Mob { + pub 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, + } + } +} + +impl JavaObject for Mob { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Mob + } +} + +impl Deref for Mob { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} diff --git a/client/src/mapping/entity/mod.rs b/client/src/mapping/entity/mod.rs index ba589e3..6ae7053 100644 --- a/client/src/mapping/entity/mod.rs +++ b/client/src/mapping/entity/mod.rs @@ -1,50 +1,104 @@ -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, JavaObject, MinecraftClassType}; 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)] pub struct Entity { pub 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() + /// Views this entity as a [`LivingEntity`] — valid for players and mobs. + pub fn as_living(&self) -> LivingEntity { + LivingEntity::new(self.jni_ref.clone()) + } + + /// The entity's network id. + pub fn id(&self) -> anyhow::Result { + Ok(mapping() .call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), - "position", + "getId", &[], )? - .l()?; + .i()?) + } - let x = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "x", FieldType::Double)? - .d()?; + /// The entity's world position. + pub fn get_position(&self) -> anyhow::Result { + mapping().in_frame(|| { + let vec3 = mapping() + .call_method( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "position", + &[], + )? + .l()?; + Vec3::read(&vec3) + }) + } - let y = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "y", FieldType::Double)? - .d()?; + /// Squared distance from this entity to a world point. + pub fn distance_to_sqr(&self, x: f64, y: f64, z: f64) -> anyhow::Result { + Ok(mapping() + .call_method( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "distanceToSqr", + &[JValue::Double(x), JValue::Double(y), JValue::Double(z)], + )? + .d()?) + } - let z = mapping() - .get_field(MinecraftClassType::Vec3, &vec3, "z", FieldType::Double)? - .d()?; + /// Collision-box width. + pub fn bb_width(&self) -> anyhow::Result { + Ok(mapping() + .call_method( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "getBbWidth", + &[], + )? + .f()?) + } - Ok((x, y, z)) + /// Collision-box height. + pub fn bb_height(&self) -> anyhow::Result { + Ok(mapping() + .call_method( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "getBbHeight", + &[], + )? + .f()?) + } + + /// Whether the entity is currently sprinting. + pub fn is_sprinting(&self) -> anyhow::Result { + Ok(mapping() + .call_method( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "isSprinting", + &[], + )? + .z()?) } pub fn set_invulnerable(&self, value: bool) -> anyhow::Result<()> { @@ -58,6 +112,28 @@ impl Entity { Ok(()) } + /// Sets the entity's yaw (`yRot`), in degrees. + pub fn set_yaw(&self, yaw: f32) -> anyhow::Result<()> { + mapping().set_field( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "yRot", + FieldType::Float, + JValue::Float(yaw), + ) + } + + /// Sets the entity's pitch (`xRot`), in degrees. + pub fn set_pitch(&self, pitch: f32) -> anyhow::Result<()> { + mapping().set_field( + MinecraftClassType::Entity, + self.jni_ref.as_obj(), + "xRot", + FieldType::Float, + JValue::Float(pitch), + ) + } + pub fn get_fall_distance(&self) -> anyhow::Result { Ok(mapping() .get_field( @@ -80,17 +156,19 @@ impl Entity { .v()?) } - pub fn get_name(&self) -> anyhow::Result { - mapping().get_string( - mapping() + /// The entity's display name, as a [`Component`]. + pub fn get_name(&self) -> anyhow::Result { + mapping().in_frame(|| { + let component = mapping() .call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), "getName", &[], )? - .l()?, - ) + .l()?; + Ok(Component::new(mapping().new_global_ref(component)?)) + }) } pub fn get_tick_count(&self) -> anyhow::Result { @@ -105,15 +183,17 @@ impl Entity { } } -impl Deref for Entity { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { +impl JavaObject for Entity { + fn jni_ref(&self) -> &GlobalRef { &self.jni_ref } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Entity + } } -impl Deref for EntityLivingBase { +impl Deref for Entity { type Target = GlobalRef; fn deref(&self) -> &Self::Target { diff --git a/client/src/mapping/entity/player.rs b/client/src/mapping/entity/player.rs index fc4f87c..f534e33 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -1,10 +1,31 @@ +//! 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, JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use jni::sys::jboolean; use std::ops::Deref; +/// Any Minecraft `Player` entity (local or remote). +#[derive(Debug, Clone)] +pub struct Player { + pub 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)] pub struct LocalPlayer { pub jni_ref: GlobalRef, @@ -30,11 +51,18 @@ impl LocalPlayer { 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)?, + }) }) } @@ -60,6 +88,18 @@ impl Abilities { Ok(()) } + /// Whether the player is currently flying. + pub fn is_flying(&self) -> anyhow::Result { + Ok(mapping() + .get_field( + MinecraftClassType::Abilities, + self.jni_ref.as_obj(), + "flying", + FieldType::Boolean, + )? + .z()?) + } + #[allow(dead_code)] pub fn get_may_fly(&self) -> anyhow::Result { Ok(mapping() @@ -73,6 +113,44 @@ impl Abilities { } } +impl JavaObject for Player { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Player + } +} + +impl JavaObject for LocalPlayer { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::LocalPlayer + } +} + +impl JavaObject for Abilities { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Abilities + } +} + +impl Deref for Player { + type Target = GlobalRef; + + fn deref(&self) -> &Self::Target { + &self.jni_ref + } +} + impl Deref for LocalPlayer { type Target = GlobalRef; diff --git a/client/src/mapping/java/iterable.rs b/client/src/mapping/java/iterable.rs index 081f3eb..c244802 100644 --- a/client/src/mapping/java/iterable.rs +++ b/client/src/mapping/java/iterable.rs @@ -1,5 +1,5 @@ use crate::mapping::java::iterator::Iterator; -use crate::mapping::MinecraftClassType; +use crate::mapping::{JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -10,22 +10,36 @@ pub struct Iterable { } impl Iterable { - pub fn iterator(&self) -> anyhow::Result { - let iterator_obj = mapping() - .call_method( - MinecraftClassType::Iterable, - self.jni_ref.as_obj(), - "iterator", - &[], - )? - .l()?; + /// Wraps an existing `java.lang.Iterable` JVM object. + pub fn new(jni_ref: GlobalRef) -> Iterable { + Iterable { jni_ref } + } - Ok(Iterator { - jni_ref: mapping().new_global_ref(iterator_obj)?, + pub fn iterator(&self) -> anyhow::Result { + mapping().in_frame(|| { + let iterator_obj = mapping() + .call_method( + MinecraftClassType::Iterable, + self.jni_ref.as_obj(), + "iterator", + &[], + )? + .l()?; + Ok(Iterator::new(mapping().new_global_ref(iterator_obj)?)) }) } } +impl JavaObject for Iterable { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } + + fn class_type() -> MinecraftClassType { + MinecraftClassType::Iterable + } +} + impl Deref for Iterable { type Target = GlobalRef; diff --git a/client/src/mapping/java/iterator.rs b/client/src/mapping/java/iterator.rs index fec0aca..d4bbf13 100644 --- a/client/src/mapping/java/iterator.rs +++ b/client/src/mapping/java/iterator.rs @@ -1,4 +1,4 @@ -use crate::mapping::MinecraftClassType; +use crate::mapping::{JavaObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -9,6 +9,11 @@ pub struct Iterator { } impl Iterator { + /// Wraps an existing `java.util.Iterator` JVM object. + pub fn new(jni_ref: GlobalRef) -> Iterator { + Iterator { jni_ref } + } + pub fn has_next(&self) -> anyhow::Result { Ok(mapping() .call_method( @@ -21,16 +26,27 @@ impl Iterator { } pub fn next(&self) -> anyhow::Result { - let next_obj = mapping() - .call_method( - MinecraftClassType::Iterator, - self.jni_ref.as_obj(), - "next", - &[], - )? - .l()?; + mapping().in_frame(|| { + let next_obj = mapping() + .call_method( + MinecraftClassType::Iterator, + self.jni_ref.as_obj(), + "next", + &[], + )? + .l()?; + mapping().new_global_ref(next_obj) + }) + } +} + +impl JavaObject for Iterator { + fn jni_ref(&self) -> &GlobalRef { + &self.jni_ref + } - mapping().new_global_ref(next_obj) + fn class_type() -> MinecraftClassType { + MinecraftClassType::Iterator } } diff --git a/client/src/mapping/math.rs b/client/src/mapping/math.rs new file mode 100644 index 0000000..cda8d50 --- /dev/null +++ b/client/src/mapping/math.rs @@ -0,0 +1,86 @@ +//! 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..ae15996 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,5 +1,6 @@ use crate::mapping::class::{Method, MethodHandle, MinecraftClass}; pub use crate::mapping::class_type::MinecraftClassType; +pub use crate::mapping::object::JavaObject; use crate::mapping::minecraft_version::MinecraftVersion; use dashmap::DashMap; use jni::objects::{GlobalRef, JClass, JMethodID, JObject, JString, JValue, JValueOwned}; @@ -11,11 +12,15 @@ 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; +pub mod math; +pub mod object; mod loader; mod method; mod minecraft_version; @@ -196,6 +201,15 @@ 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()) + } + pub fn get_version(&self) -> MinecraftVersion { self.version } diff --git a/client/src/mapping/object.rs b/client/src/mapping/object.rs new file mode 100644 index 0000000..dc90f95 --- /dev/null +++ b/client/src/mapping/object.rs @@ -0,0 +1,38 @@ +//! The [`JavaObject`] trait — shared behaviour for every Rust wrapper around a +//! live JVM object. +//! +//! It keeps JNI and [`Mapping`](crate::mapping::Mapping) calls inside the +//! `mapping` module: feature code (modules, overlay, …) works through the typed +//! wrappers and these methods, never `mapping()` directly. + +use crate::mapping::MinecraftClassType; +use crate::state::mapping; +use jni::objects::GlobalRef; + +/// A Rust wrapper around a JVM object. +pub trait JavaObject { + /// The wrapped JVM object. + fn jni_ref(&self) -> &GlobalRef; + + /// The Minecraft class this wrapper type corresponds to. + fn class_type() -> MinecraftClassType + where + Self: Sized; + + /// 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. + 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) + } +} diff --git a/client/src/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index 08b391c..f7eb2cc 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -1,8 +1,6 @@ -use crate::mapping::FieldType; -use crate::mapping::MinecraftClassType; +use crate::mapping::JavaObject; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; -use crate::state::{mapping, minecraft}; -use jni::objects::JValue; +use crate::state::minecraft; #[derive(Debug)] pub struct AimbotModule { @@ -51,24 +49,20 @@ impl Module for AimbotModule { 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())? { + for entity in world.get_entities()? { + if entity.is_same(&player.entity) { 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)) + let dist = ((player_pos.x() - entity_pos.x()).powi(2) + + (player_pos.y() - entity_pos.y()).powi(2) + + (player_pos.z() - entity_pos.z()).powi(2)) .sqrt(); if dist <= closest_dist { @@ -79,31 +73,16 @@ impl Module for AimbotModule { 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 dx = target_pos.x() - player_pos.x(); + let dy = target_pos.y() - player_pos.y(); // This is simplistic, usually need eye height + let dz = target_pos.z() - player_pos.z(); 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), - )?; - - // Set pitch - mapping.set_field( - MinecraftClassType::Entity, - player.entity.jni_ref.as_obj(), - "xRot", - FieldType::Float, - JValue::Float(pitch), - )?; + player.entity.set_yaw(yaw)?; + player.entity.set_pitch(pitch)?; } Ok(()) diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 835cfc5..1bdd492 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -1,11 +1,21 @@ -use crate::mapping::MinecraftClassType; +use crate::mapping::entity::mob::Mob; +use crate::mapping::entity::player::Player; +use crate::mapping::entity::Entity; +use crate::mapping::JavaObject; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; -use crate::state::{mapping, minecraft}; +use crate::state::minecraft; + +/// Which entities an aura attacks. +#[derive(Debug, Clone, Copy)] +pub enum AuraTarget { + Players, + Mobs, +} #[derive(Debug)] pub struct BaseAura { pub module: ModuleData, - pub target_type: MinecraftClassType, + pub target: AuraTarget, } impl BaseAura { @@ -13,7 +23,7 @@ impl BaseAura { name: String, description: String, key_bind: KeyboardKey, - target_type: MinecraftClassType, + target: AuraTarget, ) -> Self { Self { module: ModuleData { @@ -29,7 +39,7 @@ impl BaseAura { max: 6.0, }], }, - target_type, + target, } } @@ -39,6 +49,14 @@ impl BaseAura { .and_then(|s| s.get_slider_value()) .unwrap_or(4.0) } + + /// 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::(), + } + } } impl Module for BaseAura { @@ -59,27 +77,22 @@ impl Module for BaseAura { ) else { return Ok(()); // not in a world — nothing to do }; - let mapping = mapping(); - let entities = world.get_entities()?; 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())? { + for entity in world.get_entities()? { + if entity.is_same(&player.entity) { continue; } - - if !mapping.is_instance_of(self.target_type, entity.jni_ref.as_obj())? { + if !self.is_target(&entity) { 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)) + let dist = ((player_pos.x() - entity_pos.x()).powi(2) + + (player_pos.y() - entity_pos.y()).powi(2) + + (player_pos.z() - entity_pos.z()).powi(2)) .sqrt(); if dist <= range { diff --git a/client/src/module/combat/killaura.rs b/client/src/module/combat/killaura.rs index 0f48fb2..0fe76c3 100644 --- a/client/src/module/combat/killaura.rs +++ b/client/src/module/combat/killaura.rs @@ -1,5 +1,4 @@ -use crate::mapping::MinecraftClassType; -use crate::module::combat::aura::BaseAura; +use crate::module::combat::aura::{AuraTarget, BaseAura}; use crate::module::{KeyboardKey, Module, ModuleData}; #[derive(Debug)] @@ -14,7 +13,7 @@ impl KillAuraModule { "KillAura".to_string(), "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..3322232 100644 --- a/client/src/module/combat/mobaura.rs +++ b/client/src/module/combat/mobaura.rs @@ -1,5 +1,4 @@ -use crate::mapping::MinecraftClassType; -use crate::module::combat::aura::BaseAura; +use crate::module::combat::aura::{AuraTarget, BaseAura}; use crate::module::{KeyboardKey, Module, ModuleData}; #[derive(Debug)] @@ -14,7 +13,7 @@ impl MobAuraModule { "MobAura".to_string(), "Automatically attacks mobs".to_string(), KeyboardKey::KeyY, - MinecraftClassType::Mob, + AuraTarget::Mobs, ), } } From 4956cbcb2b770d7005560fb755e140645fd48405 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Thu, 21 May 2026 17:03:36 +0200 Subject: [PATCH 2/6] Full mapping refactor --- Cargo.lock | 9 ++ Cargo.toml | 3 + client/Cargo.toml | 1 + client/src/graphic/esp.rs | 10 +- client/src/lib.rs | 3 + client/src/mapping/block_entity.rs | 54 ++------ client/src/mapping/client/camera.rs | 56 ++------ client/src/mapping/client/game_renderer.rs | 37 +----- client/src/mapping/client/gamemode.rs | 36 +---- client/src/mapping/client/minecraft.rs | 89 +++++-------- client/src/mapping/client/options.rs | 71 ++-------- client/src/mapping/client/window.rs | 37 +----- client/src/mapping/client/world.rs | 80 ++--------- client/src/mapping/component.rs | 37 +----- client/src/mapping/entity/living.rs | 45 +------ client/src/mapping/entity/mob.rs | 26 +--- client/src/mapping/entity/mod.rs | 147 ++++----------------- client/src/mapping/entity/player.rs | 116 +++------------- client/src/mapping/java/iterable.rs | 37 +----- client/src/mapping/java/iterator.rs | 46 +------ client/src/mapping/mod.rs | 15 ++- client/src/mapping/object.rs | 78 +++++++++-- client/src/module/combat/aimbot.rs | 2 +- client/src/module/combat/aura.rs | 2 +- client/src/state.rs | 18 +++ mapping_derive/Cargo.toml | 11 ++ mapping_derive/src/lib.rs | 101 ++++++++++++++ 27 files changed, 399 insertions(+), 768 deletions(-) create mode 100644 mapping_derive/Cargo.toml create mode 100644 mapping_derive/src/lib.rs 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/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/src/graphic/esp.rs b/client/src/graphic/esp.rs index 7630777..d94c8b9 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -23,7 +23,7 @@ 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::JavaObject; +use crate::mapping::MappedObject; use crate::module::ModuleSetting; use crate::state::{client, minecraft}; use egui::{ @@ -638,10 +638,10 @@ fn read_name(entity: &Entity) -> String { } } -/// Reads `(health, maxHealth)` for a living entity. -fn read_health(entity: &Entity) -> anyhow::Result<(f32, f32)> { - let living = entity.as_living(); - Ok((living.get_health()?, living.get_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. diff --git a/client/src/lib.rs b/client/src/lib.rs index eef9a44..ff1865f 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -84,6 +84,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"); } diff --git a/client/src/mapping/block_entity.rs b/client/src/mapping/block_entity.rs index 115ef65..1960a8c 100644 --- a/client/src/mapping/block_entity.rs +++ b/client/src/mapping/block_entity.rs @@ -1,15 +1,15 @@ //! Wrapper for Minecraft's `BlockEntity` (chests, barrels, …). use crate::mapping::math::BlockPos; -use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::mapping::{MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; /// A Minecraft `BlockEntity`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = BlockEntity)] pub struct BlockEntity { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl BlockEntity { @@ -20,15 +20,8 @@ impl BlockEntity { /// The block position this block entity occupies. pub fn get_block_pos(&self) -> anyhow::Result { - mapping().in_frame(|| { - let pos = mapping() - .call_method( - MinecraftClassType::BlockEntity, - self.jni_ref.as_obj(), - "getBlockPos", - &[], - )? - .l()?; + self.in_frame(|| { + let pos = self.call_method("getBlockPos", &[])?.l()?; BlockPos::read(&pos) }) } @@ -42,32 +35,13 @@ impl BlockEntity { MinecraftClassType::BarrelBlockEntity, MinecraftClassType::ShulkerBoxBlockEntity, ]; - mapping() - .in_frame(|| { - Ok(KINDS.iter().any(|&kind| { - mapping() - .is_instance_of(kind, self.jni_ref.as_obj()) - .unwrap_or(false) - })) - }) - .unwrap_or(false) - } -} - -impl JavaObject for BlockEntity { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::BlockEntity - } -} - -impl Deref for BlockEntity { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + 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/client/camera.rs b/client/src/mapping/client/camera.rs index 924bee7..d81aa25 100644 --- a/client/src/mapping/client/camera.rs +++ b/client/src/mapping/client/camera.rs @@ -1,18 +1,17 @@ //! Wrapper for Minecraft's render `Camera`. use crate::mapping::math::Vec3; -use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; -use crate::state::mapping; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; use jni::objects::GlobalRef; -use std::ops::Deref; /// 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)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Camera)] pub struct Camera { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Camera { @@ -23,14 +22,9 @@ impl Camera { /// World-space position of the camera. pub fn position(&self) -> anyhow::Result { - mapping().in_frame(|| { - let vec3 = mapping() - .get_field( - MinecraftClassType::Camera, - self.jni_ref.as_obj(), - "position", - FieldType::Object(MinecraftClassType::Vec3), - )? + self.in_frame(|| { + let vec3 = self + .get_field("position", FieldType::Object(MinecraftClassType::Vec3))? .l()?; Vec3::read(&vec3) }) @@ -38,43 +32,11 @@ impl Camera { /// Yaw, in degrees (the `yRot` field). pub fn yaw(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Camera, - self.jni_ref.as_obj(), - "yRot", - FieldType::Float, - )? - .f()?) + Ok(self.get_field("yRot", FieldType::Float)?.f()?) } /// Pitch, in degrees (the `xRot` field). pub fn pitch(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Camera, - self.jni_ref.as_obj(), - "xRot", - FieldType::Float, - )? - .f()?) - } -} - -impl JavaObject for Camera { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Camera - } -} - -impl Deref for Camera { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + 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 index b1330f7..0896dd8 100644 --- a/client/src/mapping/client/game_renderer.rs +++ b/client/src/mapping/client/game_renderer.rs @@ -1,15 +1,15 @@ //! Wrapper for Minecraft's `GameRenderer`. use crate::mapping::client::camera::Camera; -use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::mapping::MappedObject; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; /// Minecraft's `GameRenderer`. -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = GameRenderer)] pub struct GameRenderer { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl GameRenderer { @@ -20,34 +20,9 @@ impl GameRenderer { /// The main render [`Camera`]. pub fn get_main_camera(&self) -> anyhow::Result { - mapping().in_frame(|| { - let camera = mapping() - .call_method( - MinecraftClassType::GameRenderer, - self.jni_ref.as_obj(), - "getMainCamera", - &[], - )? - .l()?; + self.in_frame(|| { + let camera = self.call_method("getMainCamera", &[])?.l()?; Ok(Camera::new(mapping().new_global_ref(camera)?)) }) } } - -impl JavaObject for GameRenderer { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::GameRenderer - } -} - -impl Deref for GameRenderer { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/client/gamemode.rs b/client/src/mapping/client/gamemode.rs index 96774bb..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::{JavaObject, 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 { @@ -17,34 +16,13 @@ impl MultiPlayerGameMode { /// 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 JavaObject for MultiPlayerGameMode { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::MultiPlayerGameMode - } -} - -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 dcbd833..a06a49b 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -4,10 +4,9 @@ use crate::mapping::client::options::Options; use crate::mapping::client::window::Window; use crate::mapping::client::world::World; use crate::mapping::entity::player::LocalPlayer; -use crate::mapping::{FieldType, JavaObject, 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. @@ -17,9 +16,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>, @@ -53,6 +53,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); }; @@ -64,7 +69,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())); } @@ -95,11 +100,9 @@ impl Minecraft { /// The game renderer. Present from the main menu onward. pub fn game_renderer(&self) -> anyhow::Result { - mapping().in_frame(|| { - let obj = mapping() + self.in_frame(|| { + let obj = self .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), "gameRenderer", FieldType::Object(MinecraftClassType::GameRenderer), )? @@ -110,14 +113,9 @@ impl Minecraft { /// The game options. Present from the main menu onward. pub fn options(&self) -> anyhow::Result { - mapping().in_frame(|| { - let obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - "options", - FieldType::Object(MinecraftClassType::Options), - )? + self.in_frame(|| { + let obj = self + .get_field("options", FieldType::Object(MinecraftClassType::Options))? .l()?; Ok(Options::new(mapping().new_global_ref(obj)?)) }) @@ -128,21 +126,23 @@ impl Minecraft { 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 { - mapping() - .in_frame(|| { - let screen = mapping() - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - "screen", - FieldType::Object(MinecraftClassType::Screen), - )? - .l()?; - Ok(screen.is_null()) - }) - .unwrap_or(true) + self.in_frame(|| { + let screen = self + .get_field("screen", FieldType::Object(MinecraftClassType::Screen))? + .l()?; + Ok(screen.is_null()) + }) + .unwrap_or(true) } /// Reads a world-scoped object field of `Minecraft`, returning `Ok(None)` @@ -152,15 +152,8 @@ impl Minecraft { field: &str, class: MinecraftClassType, ) -> anyhow::Result> { - mapping().in_frame(|| { - let obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - field, - FieldType::Object(class), - )? - .l()?; + self.in_frame(|| { + let obj = self.get_field(field, FieldType::Object(class))?.l()?; if obj.is_null() { return Ok(None); } @@ -168,21 +161,3 @@ impl Minecraft { }) } } - -impl JavaObject for Minecraft { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Minecraft - } -} - -impl Deref for Minecraft { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/client/options.rs b/client/src/mapping/client/options.rs index d388c3a..fd23aaa 100644 --- a/client/src/mapping/client/options.rs +++ b/client/src/mapping/client/options.rs @@ -1,14 +1,14 @@ //! Wrappers for Minecraft's `Options` and `OptionInstance`. -use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; /// Minecraft's game `Options`. -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = Options)] pub struct Options { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Options { @@ -19,42 +19,20 @@ impl Options { /// The field-of-view option. pub fn fov(&self) -> anyhow::Result { - mapping().in_frame(|| { - let option = mapping() - .get_field( - MinecraftClassType::Options, - self.jni_ref.as_obj(), - "fov", - FieldType::Object(MinecraftClassType::OptionInstance), - )? + self.in_frame(|| { + let option = self + .get_field("fov", FieldType::Object(MinecraftClassType::OptionInstance))? .l()?; Ok(OptionInstance::new(mapping().new_global_ref(option)?)) }) } } -impl JavaObject for Options { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Options - } -} - -impl Deref for Options { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} - /// A single Minecraft `OptionInstance` — one configurable game option. -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = OptionInstance)] pub struct OptionInstance { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl OptionInstance { @@ -66,36 +44,11 @@ impl OptionInstance { /// The current value, read as an `int` — the option's boxed value is /// unwrapped through `Integer.intValue()`. pub fn get_int(&self) -> anyhow::Result { - mapping().in_frame(|| { - let value = mapping() - .call_method( - MinecraftClassType::OptionInstance, - self.jni_ref.as_obj(), - "get", - &[], - )? - .l()?; + self.in_frame(|| { + let value = self.call_method("get", &[])?.l()?; Ok(mapping() .call_method(MinecraftClassType::Integer, &value, "intValue", &[])? .i()?) }) } } - -impl JavaObject for OptionInstance { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::OptionInstance - } -} - -impl Deref for OptionInstance { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/client/window.rs b/client/src/mapping/client/window.rs index ba1b4b1..395842f 100644 --- a/client/src/mapping/client/window.rs +++ b/client/src/mapping/client/window.rs @@ -1,13 +1,13 @@ use crate::mapping::method::MethodName; -use crate::mapping::{JavaObject, 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 { @@ -29,32 +29,7 @@ impl Window { /// 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 JavaObject for Window { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Window - } -} - -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 538444d..88f18d2 100644 --- a/client/src/mapping/client/world.rs +++ b/client/src/mapping/client/world.rs @@ -1,12 +1,12 @@ use crate::mapping::block_entity::BlockEntity; use crate::mapping::entity::Entity; use crate::mapping::java::iterable::Iterable; -use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::mapping::{MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; -use std::ops::Deref; -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = Level)] pub struct World { jni_ref: GlobalRef, } @@ -19,15 +19,8 @@ impl World { /// Every entity the client is currently rendering. pub fn get_entities(&self) -> anyhow::Result> { - mapping().in_frame(|| { - let iterable_obj = mapping() - .call_method( - MinecraftClassType::Level, - self.jni_ref.as_obj(), - "entitiesForRendering", - &[], - )? - .l()?; + 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()?; @@ -42,14 +35,9 @@ impl World { /// 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> { - mapping().in_frame(|| { - let chunk = mapping() - .call_method( - MinecraftClassType::LevelReader, - self.jni_ref.as_obj(), - "getChunk", - &[JValue::Int(x), JValue::Int(z)], - )? + self.in_frame(|| { + let chunk = self + .call_method("getChunk", &[JValue::Int(x), JValue::Int(z)])? .l()?; if chunk.is_null() { return Ok(None); @@ -59,28 +47,11 @@ impl World { } } -impl JavaObject for World { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Level - } -} - -impl Deref for World { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} - /// A loaded `LevelChunk`. -#[derive(Debug)] +#[derive(Debug, MappedObject)] +#[mapped(class = LevelChunk)] pub struct LevelChunk { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl LevelChunk { @@ -91,15 +62,8 @@ impl LevelChunk { /// Every block entity currently in this chunk. pub fn get_block_entities(&self) -> anyhow::Result> { - mapping().in_frame(|| { - let map = mapping() - .call_method( - MinecraftClassType::LevelChunk, - self.jni_ref.as_obj(), - "getBlockEntities", - &[], - )? - .l()?; + self.in_frame(|| { + let map = self.call_method("getBlockEntities", &[])?.l()?; if map.is_null() { return Ok(Vec::new()); } @@ -118,21 +82,3 @@ impl LevelChunk { }) } } - -impl JavaObject for LevelChunk { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::LevelChunk - } -} - -impl Deref for LevelChunk { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/component.rs b/client/src/mapping/component.rs index fdce7c7..c19e262 100644 --- a/client/src/mapping/component.rs +++ b/client/src/mapping/component.rs @@ -1,14 +1,14 @@ //! Wrapper for Minecraft's `Component` — a piece of rich (chat / display) text. -use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::mapping::MappedObject; use crate::state::mapping; use jni::objects::GlobalRef; -use std::ops::Deref; /// A Minecraft `Component`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Component)] pub struct Component { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Component { @@ -19,34 +19,9 @@ impl Component { /// The component flattened to plain text. pub fn get_string(&self) -> anyhow::Result { - mapping().in_frame(|| { - let string = mapping() - .call_method( - MinecraftClassType::Component, - self.jni_ref.as_obj(), - "getString", - &[], - )? - .l()?; + self.in_frame(|| { + let string = self.call_method("getString", &[])?.l()?; mapping().get_string(string) }) } } - -impl JavaObject for Component { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Component - } -} - -impl Deref for Component { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/entity/living.rs b/client/src/mapping/entity/living.rs index d7f023d..2c35223 100644 --- a/client/src/mapping/entity/living.rs +++ b/client/src/mapping/entity/living.rs @@ -1,15 +1,14 @@ //! Wrapper for Minecraft's `LivingEntity`. use crate::mapping::entity::Entity; -use crate::mapping::{JavaObject, MinecraftClassType}; -use crate::state::mapping; +use crate::mapping::MappedObject; use jni::objects::GlobalRef; -use std::ops::Deref; /// A Minecraft `LivingEntity` — an [`Entity`] that also carries health. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = LivingEntity)] pub struct LivingEntity { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, pub entity: Entity, } @@ -24,43 +23,11 @@ impl LivingEntity { /// Current health. pub fn get_health(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::LivingEntity, - self.jni_ref.as_obj(), - "getHealth", - &[], - )? - .f()?) + Ok(self.call_method("getHealth", &[])?.f()?) } /// Maximum health. pub fn get_max_health(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::LivingEntity, - self.jni_ref.as_obj(), - "getMaxHealth", - &[], - )? - .f()?) - } -} - -impl JavaObject for LivingEntity { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::LivingEntity - } -} - -impl Deref for LivingEntity { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + Ok(self.call_method("getMaxHealth", &[])?.f()?) } } diff --git a/client/src/mapping/entity/mob.rs b/client/src/mapping/entity/mob.rs index 7bb0d9d..4764a91 100644 --- a/client/src/mapping/entity/mob.rs +++ b/client/src/mapping/entity/mob.rs @@ -1,14 +1,14 @@ //! Wrapper for Minecraft's `Mob` — a non-player living entity. use crate::mapping::entity::Entity; -use crate::mapping::{JavaObject, MinecraftClassType}; +use crate::mapping::MappedObject; use jni::objects::GlobalRef; -use std::ops::Deref; /// A Minecraft `Mob` (non-player living entity). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Mob)] pub struct Mob { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, pub entity: Entity, } @@ -21,21 +21,3 @@ impl Mob { } } } - -impl JavaObject for Mob { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Mob - } -} - -impl Deref for Mob { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/entity/mod.rs b/client/src/mapping/entity/mod.rs index 6ae7053..459eec9 100644 --- a/client/src/mapping/entity/mod.rs +++ b/client/src/mapping/entity/mod.rs @@ -1,18 +1,18 @@ use crate::mapping::component::Component; use crate::mapping::entity::living::LivingEntity; use crate::mapping::math::Vec3; -use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; +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; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Entity)] pub struct Entity { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, } impl Entity { @@ -21,44 +21,30 @@ impl Entity { Entity { jni_ref } } - /// Views this entity as a [`LivingEntity`] — valid for players and mobs. - pub fn as_living(&self) -> LivingEntity { - LivingEntity::new(self.jni_ref.clone()) + /// 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())) } /// The entity's network id. pub fn id(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "getId", - &[], - )? - .i()?) + Ok(self.call_method("getId", &[])?.i()?) } /// The entity's world position. pub fn get_position(&self) -> anyhow::Result { - mapping().in_frame(|| { - let vec3 = mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "position", - &[], - )? - .l()?; + self.in_frame(|| { + let vec3 = self.call_method("position", &[])?.l()?; Vec3::read(&vec3) }) } /// Squared distance from this entity to a world point. pub fn distance_to_sqr(&self, x: f64, y: f64, z: f64) -> anyhow::Result { - Ok(mapping() + Ok(self .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), "distanceToSqr", &[JValue::Double(x), JValue::Double(y), JValue::Double(z)], )? @@ -67,136 +53,51 @@ impl Entity { /// Collision-box width. pub fn bb_width(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "getBbWidth", - &[], - )? - .f()?) + Ok(self.call_method("getBbWidth", &[])?.f()?) } /// Collision-box height. pub fn bb_height(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "getBbHeight", - &[], - )? - .f()?) + Ok(self.call_method("getBbHeight", &[])?.f()?) } /// Whether the entity is currently sprinting. pub fn is_sprinting(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "isSprinting", - &[], - )? - .z()?) + Ok(self.call_method("isSprinting", &[])?.z()?) } pub fn set_invulnerable(&self, value: bool) -> anyhow::Result<()> { - mapping().call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "setInvulnerable", - &[JValue::from(value)], - )?; - + self.call_method("setInvulnerable", &[JValue::from(value)])?; Ok(()) } /// Sets the entity's yaw (`yRot`), in degrees. pub fn set_yaw(&self, yaw: f32) -> anyhow::Result<()> { - mapping().set_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "yRot", - FieldType::Float, - JValue::Float(yaw), - ) + self.set_field("yRot", FieldType::Float, JValue::Float(yaw)) } /// Sets the entity's pitch (`xRot`), in degrees. pub fn set_pitch(&self, pitch: f32) -> anyhow::Result<()> { - mapping().set_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "xRot", - FieldType::Float, - JValue::Float(pitch), - ) + self.set_field("xRot", FieldType::Float, JValue::Float(pitch)) } pub fn get_fall_distance(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "fallDistance", - FieldType::Double, - )? - .d()?) + Ok(self.get_field("fallDistance", FieldType::Double)?.d()?) } pub fn reset_fall_distance(&self) -> anyhow::Result<()> { - Ok(mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "resetFallDistance", - &[], - )? - .v()?) + Ok(self.call_method("resetFallDistance", &[])?.v()?) } /// The entity's display name, as a [`Component`]. pub fn get_name(&self) -> anyhow::Result { - mapping().in_frame(|| { - let component = mapping() - .call_method( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "getName", - &[], - )? - .l()?; + self.in_frame(|| { + let component = self.call_method("getName", &[])?.l()?; Ok(Component::new(mapping().new_global_ref(component)?)) }) } pub fn get_tick_count(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Entity, - self.jni_ref.as_obj(), - "tickCount", - FieldType::Int, - )? - .i()?) - } -} - -impl JavaObject for Entity { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Entity - } -} - -impl Deref for Entity { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + 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 f534e33..f53efa1 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -2,16 +2,16 @@ //! `Abilities` they carry. use crate::mapping::entity::Entity; -use crate::mapping::{FieldType, JavaObject, MinecraftClassType}; +use crate::mapping::{FieldType, MappedObject, MinecraftClassType}; use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use jni::sys::jboolean; -use std::ops::Deref; /// Any Minecraft `Player` entity (local or remote). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, MappedObject)] +#[mapped(class = Player)] pub struct Player { - pub jni_ref: GlobalRef, + jni_ref: GlobalRef, pub entity: Entity, } @@ -26,16 +26,18 @@ impl Player { } /// The client's own `LocalPlayer`. -#[derive(Debug, Clone)] +#[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 { @@ -53,12 +55,7 @@ impl Abilities { pub fn new(player: GlobalRef) -> anyhow::Result { mapping().in_frame(|| { let jni_ref = mapping() - .call_method( - MinecraftClassType::Player, - &player, - "getAbilities", - &[], - )? + .call_method(MinecraftClassType::Player, &player, "getAbilities", &[])? .l()?; Ok(Self { jni_ref: mapping().new_global_ref(jni_ref)?, @@ -68,101 +65,18 @@ impl Abilities { 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(()) } /// Whether the player is currently flying. pub fn is_flying(&self) -> anyhow::Result { - Ok(mapping() - .get_field( - MinecraftClassType::Abilities, - self.jni_ref.as_obj(), - "flying", - FieldType::Boolean, - )? - .z()?) + Ok(self.get_field("flying", FieldType::Boolean)?.z()?) } #[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()?) - } -} - -impl JavaObject for Player { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Player - } -} - -impl JavaObject for LocalPlayer { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::LocalPlayer - } -} - -impl JavaObject for Abilities { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Abilities - } -} - -impl Deref for Player { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} - -impl Deref for LocalPlayer { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} - -impl Deref for Abilities { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref + 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 c244802..e3898cb 100644 --- a/client/src/mapping/java/iterable.rs +++ b/client/src/mapping/java/iterable.rs @@ -1,12 +1,12 @@ use crate::mapping::java::iterator::Iterator; -use crate::mapping::{JavaObject, 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 { @@ -16,34 +16,9 @@ impl Iterable { } pub fn iterator(&self) -> anyhow::Result { - mapping().in_frame(|| { - let iterator_obj = mapping() - .call_method( - MinecraftClassType::Iterable, - self.jni_ref.as_obj(), - "iterator", - &[], - )? - .l()?; + self.in_frame(|| { + let iterator_obj = self.call_method("iterator", &[])?.l()?; Ok(Iterator::new(mapping().new_global_ref(iterator_obj)?)) }) } } - -impl JavaObject for Iterable { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Iterable - } -} - -impl Deref for Iterable { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/java/iterator.rs b/client/src/mapping/java/iterator.rs index d4bbf13..d377816 100644 --- a/client/src/mapping/java/iterator.rs +++ b/client/src/mapping/java/iterator.rs @@ -1,11 +1,11 @@ -use crate::mapping::{JavaObject, 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 { @@ -15,45 +15,13 @@ impl Iterator { } pub fn has_next(&self) -> anyhow::Result { - Ok(mapping() - .call_method( - MinecraftClassType::Iterator, - self.jni_ref.as_obj(), - "hasNext", - &[], - )? - .z()?) + Ok(self.call_method("hasNext", &[])?.z()?) } pub fn next(&self) -> anyhow::Result { - mapping().in_frame(|| { - let next_obj = mapping() - .call_method( - MinecraftClassType::Iterator, - self.jni_ref.as_obj(), - "next", - &[], - )? - .l()?; + self.in_frame(|| { + let next_obj = self.call_method("next", &[])?.l()?; mapping().new_global_ref(next_obj) }) } } - -impl JavaObject for Iterator { - fn jni_ref(&self) -> &GlobalRef { - &self.jni_ref - } - - fn class_type() -> MinecraftClassType { - MinecraftClassType::Iterator - } -} - -impl Deref for Iterator { - type Target = GlobalRef; - - fn deref(&self) -> &Self::Target { - &self.jni_ref - } -} diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index ae15996..fd64149 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,6 +1,7 @@ use crate::mapping::class::{Method, MethodHandle, MinecraftClass}; pub use crate::mapping::class_type::MinecraftClassType; -pub use crate::mapping::object::JavaObject; +pub use crate::mapping::object::MappedObject; +pub use mapping_derive::MappedObject; use crate::mapping::minecraft_version::MinecraftVersion; use dashmap::DashMap; use jni::objects::{GlobalRef, JClass, JMethodID, JObject, JString, JValue, JValueOwned}; @@ -210,6 +211,18 @@ impl Mapping { 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 } diff --git a/client/src/mapping/object.rs b/client/src/mapping/object.rs index dc90f95..ad4ee6c 100644 --- a/client/src/mapping/object.rs +++ b/client/src/mapping/object.rs @@ -1,38 +1,90 @@ -//! The [`JavaObject`] trait — shared behaviour for every Rust wrapper around a -//! live JVM object. +//! The [`MappedObject`] trait — shared behaviour for every Rust wrapper around +//! a live JVM object. //! -//! It keeps JNI and [`Mapping`](crate::mapping::Mapping) calls inside the -//! `mapping` module: feature code (modules, overlay, …) works through the typed -//! wrappers and these methods, never `mapping()` directly. +//! 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::MinecraftClassType; +use crate::mapping::{FieldType, MinecraftClassType}; use crate::state::mapping; -use jni::objects::GlobalRef; +use jni::objects::{GlobalRef, JValue, JValueOwned}; /// A Rust wrapper around a JVM object. -pub trait JavaObject { +pub trait MappedObject { /// The wrapped JVM object. fn jni_ref(&self) -> &GlobalRef; /// The Minecraft class this wrapper type corresponds to. - fn class_type() -> MinecraftClassType + 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; + 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 { + 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. - fn is_same(&self, other: &T) -> bool { + /// 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/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index f7eb2cc..c991627 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -1,4 +1,4 @@ -use crate::mapping::JavaObject; +use crate::mapping::MappedObject; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; use crate::state::minecraft; diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 1bdd492..124364b 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -1,7 +1,7 @@ use crate::mapping::entity::mob::Mob; use crate::mapping::entity::player::Player; use crate::mapping::entity::Entity; -use crate::mapping::JavaObject; +use crate::mapping::MappedObject; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; use crate::state::minecraft; diff --git a/client/src/state.rs b/client/src/state.rs index 452fc5c..cb8d39b 100644 --- a/client/src/state.rs +++ b/client/src/state.rs @@ -70,6 +70,24 @@ 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() { + 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/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() +} From 92ab52f8628c92286e9e15541e344c7a86382396 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Thu, 21 May 2026 19:36:19 +0200 Subject: [PATCH 3/6] Smooth aura and aimbot --- client/src/graphic/hook.rs | 3 + client/src/mapping/class_type.rs | 24 ++++ client/src/mapping/client/minecraft.rs | 38 +++++- client/src/mapping/client/mod.rs | 1 + client/src/mapping/client/options.rs | 24 ++++ client/src/mapping/client/screen.rs | 31 +++++ client/src/mapping/entity/mod.rs | 38 ++++-- client/src/mapping/entity/player.rs | 29 +++++ client/src/module/combat/aimbot.rs | 117 +++++++++++------- client/src/module/combat/aura.rs | 149 ++++++++++++++++++----- client/src/module/combat/mod.rs | 73 +++++++++++ client/src/module/combat/rotation.rs | 162 +++++++++++++++++++++++++ client/src/module/movement/fly.rs | 28 +++-- 13 files changed, 624 insertions(+), 93 deletions(-) create mode 100644 client/src/mapping/client/screen.rs create mode 100644 client/src/module/combat/rotation.rs diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index c1b31c6..de0876b 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(); } diff --git a/client/src/mapping/class_type.rs b/client/src/mapping/class_type.rs index 0101398..3200069 100644 --- a/client/src/mapping/class_type.rs +++ b/client/src/mapping/class_type.rs @@ -33,6 +33,14 @@ pub enum MinecraftClassType { Options, OptionInstance, Integer, + Double, + InteractionHand, + ChatScreen, + PauseScreen, + AbstractContainerScreen, + InventoryScreen, + CraftingScreen, + CreativeModeInventoryScreen, } impl MinecraftClassType { @@ -78,6 +86,22 @@ 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" + } } } } diff --git a/client/src/mapping/client/minecraft.rs b/client/src/mapping/client/minecraft.rs index a06a49b..3e639ae 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -1,6 +1,7 @@ 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; @@ -136,13 +137,46 @@ impl Minecraft { /// Whether no screen (menu / inventory / …) is currently open. pub fn current_screen_is_null(&self) -> bool { + 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()?; - Ok(screen.is_null()) + if screen.is_null() { + return Ok(Screen::None); + } + + 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(true) + .unwrap_or(Screen::Unknown) } /// Reads a world-scoped object field of `Minecraft`, returning `Ok(None)` diff --git a/client/src/mapping/client/mod.rs b/client/src/mapping/client/mod.rs index 446d897..2838158 100644 --- a/client/src/mapping/client/mod.rs +++ b/client/src/mapping/client/mod.rs @@ -3,5 +3,6 @@ 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 index fd23aaa..195a7a7 100644 --- a/client/src/mapping/client/options.rs +++ b/client/src/mapping/client/options.rs @@ -26,6 +26,19 @@ impl Options { 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. @@ -51,4 +64,15 @@ impl OptionInstance { .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/entity/mod.rs b/client/src/mapping/entity/mod.rs index 459eec9..d26d274 100644 --- a/client/src/mapping/entity/mod.rs +++ b/client/src/mapping/entity/mod.rs @@ -33,7 +33,7 @@ impl Entity { Ok(self.call_method("getId", &[])?.i()?) } - /// The entity's world position. + /// The entity's world position (feet). pub fn get_position(&self) -> anyhow::Result { self.in_frame(|| { let vec3 = self.call_method("position", &[])?.l()?; @@ -41,6 +41,24 @@ impl Entity { }) } + /// 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) + }) + } + + /// The entity's yaw, in degrees. + pub fn get_yaw(&self) -> anyhow::Result { + Ok(self.call_method("getYRot", &[])?.f()?) + } + + /// The entity's pitch, in degrees. + pub fn get_pitch(&self) -> anyhow::Result { + Ok(self.call_method("getXRot", &[])?.f()?) + } + /// 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 @@ -71,14 +89,16 @@ impl Entity { Ok(()) } - /// Sets the entity's yaw (`yRot`), in degrees. - pub fn set_yaw(&self, yaw: f32) -> anyhow::Result<()> { - self.set_field("yRot", FieldType::Float, JValue::Float(yaw)) - } - - /// Sets the entity's pitch (`xRot`), in degrees. - pub fn set_pitch(&self, pitch: f32) -> anyhow::Result<()> { - self.set_field("xRot", FieldType::Float, JValue::Float(pitch)) + /// 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(()) } pub fn get_fall_distance(&self) -> anyhow::Result { diff --git a/client/src/mapping/entity/player.rs b/client/src/mapping/entity/player.rs index f53efa1..3723732 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -49,6 +49,29 @@ 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 { @@ -75,6 +98,12 @@ impl Abilities { Ok(self.get_field("flying", FieldType::Boolean)?.z()?) } + /// 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(()) + } + #[allow(dead_code)] pub fn get_may_fly(&self) -> anyhow::Result { Ok(self.get_field("mayfly", FieldType::Boolean)?.z()?) diff --git a/client/src/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index c991627..bd86baf 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -1,10 +1,16 @@ +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, 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 { @@ -12,25 +18,52 @@ impl AimbotModule { Self { module: ModuleData { name: "Aimbot".to_string(), - description: "Automatically aims at entities".to_string(), + 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) } } @@ -40,50 +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 range = self.get_range() as f64; - let player_pos = player.entity.get_position()?; - let mut closest_dist = range; - let mut target_entity = None; - - for entity in world.get_entities()? { - if entity.is_same(&player.entity) { - continue; - } - - let entity_pos = entity.get_position()?; - let dist = ((player_pos.x() - entity_pos.x()).powi(2) - + (player_pos.y() - entity_pos.y()).powi(2) - + (player_pos.z() - entity_pos.z()).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.x() - player_pos.x(); - let dy = target_pos.y() - player_pos.y(); // This is simplistic, usually need eye height - let dz = target_pos.z() - player_pos.z(); - - 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; + let entities = world.get_entities()?; + 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(()); + }; - player.entity.set_yaw(yaw)?; - player.entity.set_pitch(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 124364b..2b8838c 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -1,9 +1,12 @@ use crate::mapping::entity::mob::Mob; -use crate::mapping::entity::player::Player; +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, ModuleSetting}; use crate::state::minecraft; +use std::sync::Mutex; +use std::time::{Duration, Instant}; /// Which entities an aura attacks. #[derive(Debug, Clone, Copy)] @@ -12,10 +15,20 @@ pub enum AuraTarget { 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: AuraTarget, + state: Mutex, } impl BaseAura { @@ -32,22 +45,70 @@ impl BaseAura { 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, + 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. @@ -57,6 +118,25 @@ impl BaseAura { 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) + } } impl Module for BaseAura { @@ -65,39 +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 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 player_pos = player.entity.get_position()?; - - for entity in world.get_entities()? { - if entity.is_same(&player.entity) { - continue; - } - if !self.is_target(&entity) { - continue; - } - - let entity_pos = entity.get_position()?; - let dist = ((player_pos.x() - entity_pos.x()).powi(2) - + (player_pos.y() - entity_pos.y()).powi(2) - + (player_pos.z() - entity_pos.z()).powi(2)) - .sqrt(); - - if dist <= range { - game_mode.attack(&player, &entity)?; - } + + 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); + + // 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)?; + + // 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/mod.rs b/client/src/module/combat/mod.rs index 6a59a94..4544303 100644 --- a/client/src/module/combat/mod.rs +++ b/client/src/module/combat/mod.rs @@ -2,3 +2,76 @@ pub mod aimbot; pub mod aura; pub mod killaura; pub mod mobaura; +pub mod rotation; + +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/movement/fly.rs b/client/src/module/movement/fly.rs index bcf8260..4c0e552 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::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, @@ -11,7 +14,7 @@ impl FlyModule { Self { module: ModuleData { name: "Fly".to_string(), - description: "Enables flying".to_string(), + 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(()) } From 6a1eb456c425047ee4125453f79eb50141a572c0 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Thu, 21 May 2026 23:07:06 +0200 Subject: [PATCH 4/6] Implement NoFall --- client/java/DarkChannelHandler.class | Bin 0 -> 1103 bytes client/java/DarkChannelHandler.java | 50 +++ .../netty/channel/ChannelDuplexHandler.java | 11 + .../stub/io/netty/channel/ChannelFuture.java | 5 + .../netty/channel/ChannelHandlerContext.java | 10 + .../stub/io/netty/channel/ChannelPromise.java | 5 + client/src/graphic/esp.rs | 8 +- client/src/graphic/hook.rs | 2 + client/src/graphic/hud.rs | 2 +- client/src/graphic/input.rs | 2 +- client/src/graphic/menu.rs | 27 +- client/src/graphic/ui_engine.rs | 2 +- client/src/lib.rs | 3 + client/src/mapping/mod.rs | 6 + client/src/mapping/reflect.rs | 22 +- client/src/module/combat/aimbot.rs | 4 +- client/src/module/combat/aura.rs | 6 +- client/src/module/combat/killaura.rs | 4 +- client/src/module/combat/mobaura.rs | 4 +- client/src/module/mod.rs | 40 ++- client/src/module/movement/fly.rs | 4 +- client/src/module/movement/mod.rs | 1 + client/src/module/movement/nofall.rs | 58 ++++ client/src/module/registry.rs | 42 ++- client/src/module/render/chest_esp.rs | 4 +- client/src/module/render/mob_esp.rs | 4 +- client/src/module/render/player_esp.rs | 4 +- client/src/net/mod.rs | 326 ++++++++++++++++++ client/src/net/packet/mod.rs | 18 + client/src/net/packet/move_player.rs | 121 +++++++ client/src/state.rs | 3 + 31 files changed, 743 insertions(+), 55 deletions(-) create mode 100644 client/java/DarkChannelHandler.class create mode 100644 client/java/DarkChannelHandler.java create mode 100644 client/java/stub/io/netty/channel/ChannelDuplexHandler.java create mode 100644 client/java/stub/io/netty/channel/ChannelFuture.java create mode 100644 client/java/stub/io/netty/channel/ChannelHandlerContext.java create mode 100644 client/java/stub/io/netty/channel/ChannelPromise.java create mode 100644 client/src/module/movement/nofall.rs create mode 100644 client/src/net/mod.rs create mode 100644 client/src/net/packet/mod.rs create mode 100644 client/src/net/packet/move_player.rs diff --git a/client/java/DarkChannelHandler.class b/client/java/DarkChannelHandler.class new file mode 100644 index 0000000000000000000000000000000000000000..f17971596325aa5cce18d787b975d181151b3e83 GIT binary patch literal 1103 zcmbtU&r{k!6#f-mHtJZJ@v<- zM-Tm*I-R}^5r<+Sbc49onAL z{GHX z6G9$o6lk`KbXp-R$TzyDBJ~@Jib5LC$fQuB6?#^&77*ys9;2Ha5qQ|Xk|e6_+6|Hw u!d{HrV#lcTN$hnBtC6(=4OOaZgJ#yiB3|BN@A}yEpC}|JuwM{b1f{=l6$!Qg literal 0 HcmV?d00001 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/graphic/esp.rs b/client/src/graphic/esp.rs index d94c8b9..90b1961 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -24,7 +24,7 @@ use crate::mapping::entity::player::Player; use crate::mapping::entity::Entity; use crate::mapping::math::Vec3; use crate::mapping::MappedObject; -use crate::module::ModuleSetting; +use crate::module::{ModuleId, ModuleSetting}; use crate::state::{client, minecraft}; use egui::{ pos2, vec2, Align2, Color32, Context, FontId, Id, LayerId, Order, Painter, Pos2, Rect, @@ -310,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 { @@ -323,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 { @@ -336,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 { diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index de0876b..bce0efb 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -85,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..2090e9e 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -232,7 +232,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 { diff --git a/client/src/graphic/menu.rs b/client/src/graphic/menu.rs index 0937b72..b23d91d 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; @@ -420,7 +423,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 +477,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/ui_engine.rs b/client/src/graphic/ui_engine.rs index 54e741b..3eda517 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -227,7 +227,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 ff1865f..0cf703f 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -6,6 +6,7 @@ extern crate jni; mod graphic; mod mapping; mod module; +pub mod net; mod state; /// OpenGL bindings, generated by `gl_generator` in `build.rs`. The generated @@ -20,6 +21,7 @@ use crate::module::combat::aimbot::AimbotModule; use crate::module::combat::killaura::KillAuraModule; use crate::module::combat::mobaura::MobAuraModule; 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; @@ -94,6 +96,7 @@ 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()); diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index fd64149..3bb5013 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -249,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/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 bd86baf..e6c28ff 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -2,7 +2,7 @@ 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, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; use crate::state::minecraft; use std::sync::Mutex; @@ -17,7 +17,7 @@ impl AimbotModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Aimbot".to_string(), + id: ModuleId::Aimbot, description: "Smoothly aims at the nearest entity".to_string(), category: ModuleCategory::Combat, key_bind: KeyboardKey::KeyC, diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 2b8838c..d4ad6ca 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -3,7 +3,7 @@ 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, ModuleSetting}; +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId, ModuleSetting}; use crate::state::minecraft; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -33,14 +33,14 @@ pub struct BaseAura { impl BaseAura { pub fn new( - name: String, + id: ModuleId, description: String, key_bind: KeyboardKey, target: AuraTarget, ) -> Self { Self { module: ModuleData { - name, + id, description, category: ModuleCategory::Combat, key_bind, diff --git a/client/src/module/combat/killaura.rs b/client/src/module/combat/killaura.rs index 0fe76c3..561e615 100644 --- a/client/src/module/combat/killaura.rs +++ b/client/src/module/combat/killaura.rs @@ -1,5 +1,5 @@ use crate::module::combat::aura::{AuraTarget, BaseAura}; -use crate::module::{KeyboardKey, Module, ModuleData}; +use crate::module::{KeyboardKey, Module, ModuleData, ModuleId}; #[derive(Debug)] pub struct KillAuraModule { @@ -10,7 +10,7 @@ impl KillAuraModule { pub fn new() -> Self { Self { aura: BaseAura::new( - "KillAura".to_string(), + ModuleId::KillAura, "Automatically attacks players".to_string(), KeyboardKey::KeyR, AuraTarget::Players, diff --git a/client/src/module/combat/mobaura.rs b/client/src/module/combat/mobaura.rs index 3322232..f5f593e 100644 --- a/client/src/module/combat/mobaura.rs +++ b/client/src/module/combat/mobaura.rs @@ -1,5 +1,5 @@ use crate::module::combat::aura::{AuraTarget, BaseAura}; -use crate::module::{KeyboardKey, Module, ModuleData}; +use crate::module::{KeyboardKey, Module, ModuleData, ModuleId}; #[derive(Debug)] pub struct MobAuraModule { @@ -10,7 +10,7 @@ impl MobAuraModule { pub fn new() -> Self { Self { aura: BaseAura::new( - "MobAura".to_string(), + ModuleId::MobAura, "Automatically attacks mobs".to_string(), KeyboardKey::KeyY, AuraTarget::Mobs, diff --git a/client/src/module/mod.rs b/client/src/module/mod.rs index d07c2a2..7df266b 100644 --- a/client/src/module/mod.rs +++ b/client/src/module/mod.rs @@ -32,9 +32,38 @@ impl ModuleCategory { } } +/// Stable identifier of a module — what it is registered and looked up by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModuleId { + Fly, + NoFall, + KillAura, + MobAura, + Aimbot, + 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::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 +135,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 +158,10 @@ 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; the default ignores every packet. + fn handle_packet(&self, _packet: &mut crate::net::packet::Packet) {} + 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 4c0e552..07fe4b3 100644 --- a/client/src/module/movement/fly.rs +++ b/client/src/module/movement/fly.rs @@ -1,4 +1,4 @@ -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. @@ -13,7 +13,7 @@ impl FlyModule { pub fn new() -> Self { Self { module: ModuleData { - name: "Fly".to_string(), + id: ModuleId::Fly, description: "Enables flight".to_string(), category: ModuleCategory::Movement, key_bind: KeyboardKey::KeyF, 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..b1a2874 --- /dev/null +++ b/client/src/module/movement/nofall.rs @@ -0,0 +1,58 @@ +use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId}; +use crate::net::packet::Packet; + +/// 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) { + // Report the player as on the ground on every movement packet. + // (`if let` filters for the variant we care about — it becomes + // refutable once `Packet` gains more variants.) + #[allow(irrefutable_let_patterns)] + if let Packet::MovePlayer(move_packet) = packet { + move_packet.on_ground = true; + } + } + + 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..e7124c9 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,7 +6,8 @@ use std::sync::{Arc, Mutex}; use dashmap::DashMap; use log::error; -use crate::module::{Module, ModuleType}; +use crate::module::{Module, ModuleId, ModuleType}; +use crate::net::packet::Packet; /// A shared, lockable handle to one module. pub type ModuleHandle = Arc>; @@ -15,7 +16,7 @@ pub type ModuleHandle = Arc>; /// can read it each frame without contending on a single global lock. #[derive(Default)] pub struct ModuleRegistry { - modules: DashMap, + modules: DashMap, } impl ModuleRegistry { @@ -24,21 +25,19 @@ impl ModuleRegistry { Self::default() } - /// Registers a module under its declared name. + /// Registers a module under its [`ModuleId`]. 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 id = module.get_module_data().id; + 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())) + /// 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 +49,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 +68,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 +76,17 @@ impl ModuleRegistry { } } } + + /// Offers `packet` to every enabled module's `handle_packet`. Called from + /// the connection's packet dispatch, on the Netty thread. + pub fn handle_packet(&self, packet: &mut Packet) { + for handle in self.handles() { + let Ok(module) = handle.lock() else { + continue; + }; + if module.get_module_data().enabled { + module.handle_packet(packet); + } + } + } } 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..588ea6a --- /dev/null +++ b/client/src/net/mod.rs @@ -0,0 +1,326 @@ +//! Netty pipeline injection — **Phase 1: plumbing only.** +//! +//! 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. +//! +//! For now the native callbacks only **log** each packet type once and pass +//! the packet through unchanged — this proves the injection path works before +//! the Rust packet structs and the dispatch/modules are built on top (Phase 2). +//! +//! 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::move_player::MovePlayerPacket; +use crate::net::packet::Packet; +use crate::state::{mapping, minecraft}; +use jni::objects::{GlobalRef, JClass, JObject, 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_LAST: &str = + "(Ljava/lang/String;Lio/netty/channel/ChannelHandler;)Lio/netty/channel/ChannelPipeline;"; + +struct NetState { + /// The defined `DarkChannelHandler` class, once registered. + handler_class: Option, + /// The `Connection` our handler is currently installed on. + installed_on: Option, + /// Set if class definition failed, so it is not retried forever. + failed: bool, +} + +static STATE: Mutex = Mutex::new(NetState { + handler_class: None, + installed_on: None, + failed: false, +}); + +/// 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.failed { + return Ok(()); + } + if state.handler_class.is_none() { + match define_handler() { + Ok(class) => { + log::info!("net: DarkChannelHandler defined and bound"); + state.handler_class = Some(class); + } + Err(error) => { + log::warn!("net: handler definition failed, disabling: {error}"); + state.failed = true; + 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(()) +} + +/// `DefineClass` the handler into the game class loader and `RegisterNatives`. +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 = env.define_class(HANDLER_CLASS, loader.as_obj(), HANDLER_BYTECODE)?; + + 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)?; + + Ok(env.new_global_ref(class)?) +} + +/// `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 at the tail of `connection`'s Netty pipeline. +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", &[])?; + env.call_method( + &pipeline, + "addLast", + SIG_PIPELINE_ADD_LAST, + &[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(); + (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 ------------------------------------------------------- + +/// `DarkChannelHandler.onOutbound` — runs the outbound packet transformers and +/// returns the object to forward (the original, or a modified replacement). +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_outbound(env, packet) } { + Ok(Some(replacement)) => replacement, + _ => packet, + }, + )) + .unwrap_or(packet) +} + +/// `DarkChannelHandler.onInbound` — pass-through for now (inbound transformers +/// are a later phase). +unsafe extern "system" fn dark_on_inbound( + _env: *mut jni::sys::JNIEnv, + _class: jni::sys::jclass, + packet: jobject, +) -> jobject { + packet +} + +/// Inspects an outbound packet: wraps it for the modules, lets each enabled +/// module's `handle_packet` modify it, and rebuilds the JVM object if it +/// changed. `Ok(None)` means "forward the original unchanged". +unsafe fn dispatch_outbound( + env: *mut jni::sys::JNIEnv, + packet: jobject, +) -> anyhow::Result> { + if packet.is_null() { + return Ok(None); + } + let mut env = unsafe { JNIEnv::from_raw(env)? }; + let packet_obj = unsafe { JObject::from_raw(packet) }; + + if !is_instance(&mut env, &packet_obj, packet::move_player::CLASS)? { + return Ok(None); + } + + let original = MovePlayerPacket::read(&mut env, &packet_obj)?; + let mut wrapped = Packet::MovePlayer(original); + crate::state::client().modules.handle_packet(&mut wrapped); + + let Packet::MovePlayer(modified) = wrapped; + if modified != original { + return Ok(Some(modified.to_java(&mut env)?)); + } + Ok(None) +} + +/// Whether `object` is an instance of the class named `class_name`. +fn is_instance(env: &mut JNIEnv, object: &JObject, class_name: &str) -> anyhow::Result { + let class = mapping().resolve_class(env, class_name)?; + Ok(env.is_instance_of(object, &class)?) +} diff --git a/client/src/net/packet/mod.rs b/client/src/net/packet/mod.rs new file mode 100644 index 0000000..f863429 --- /dev/null +++ b/client/src/net/packet/mod.rs @@ -0,0 +1,18 @@ +//! Rust value-snapshots of the Minecraft packets DarkClient reads or rewrites. +//! +//! Each packet is read once into a plain Rust struct (`read`), modified freely +//! by the dispatch, then rebuilt into a JVM object (`to_java`). All JNI uses +//! explicit descriptors — no reflection, no overload resolution. + +pub mod move_player; + +use move_player::MovePlayerPacket; + +/// A packet passing through the connection, in a form modules can `match` on. +/// The dispatch wraps the raw JVM packet into this enum, lets each enabled +/// module's `handle_packet` modify it, then rebuilds the JVM object. +#[derive(Debug, Clone, PartialEq)] +pub enum Packet { + /// An outbound `ServerboundMovePlayerPacket`. + MovePlayer(MovePlayerPacket), +} diff --git a/client/src/net/packet/move_player.rs b/client/src/net/packet/move_player.rs new file mode 100644 index 0000000..2f72821 --- /dev/null +++ b/client/src/net/packet/move_player.rs @@ -0,0 +1,121 @@ +//! 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. +//! [`MovePlayerPacket::read`] snapshots any of them; [`MovePlayerPacket::to_java`] +//! rebuilds the matching subclass. + +use crate::state::mapping; +use jni::objects::{JObject, JValue}; +use jni::sys::jobject; +use jni::JNIEnv; + +/// Binary name of the abstract `ServerboundMovePlayerPacket`. +pub const CLASS: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket"; + +const POS: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Pos"; +const POS_ROT: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$PosRot"; +const ROT: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Rot"; +const STATUS_ONLY: &str = + "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$StatusOnly"; + +/// A snapshot of a `ServerboundMovePlayerPacket`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MovePlayerPacket { + 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 MovePlayerPacket { + /// Reads a `ServerboundMovePlayerPacket` JVM object into a snapshot. + pub fn read(env: &mut JNIEnv, packet: &JObject) -> anyhow::Result { + Ok(MovePlayerPacket { + 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, POS_ROT)?; + 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, POS)?; + 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, ROT)?; + 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, STATUS_ONLY)?; + 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 cb8d39b..fb9120c 100644 --- a/client/src/state.rs +++ b/client/src/state.rs @@ -80,6 +80,9 @@ pub fn env() -> anyhow::Result> { /// 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(); } From 784ea4bed1e4a3653d9313d9d8e09394990a4721 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Thu, 21 May 2026 23:44:57 +0200 Subject: [PATCH 5/6] Add Velocity/Anti-Knockback module --- CLAUDE.md | 9 +- client/src/graphic/esp.rs | 5 +- client/src/graphic/input.rs | 14 +- client/src/lib.rs | 2 + client/src/mapping/class_type.rs | 25 ++ client/src/mapping/client/minecraft.rs | 6 +- client/src/mapping/math.rs | 12 +- client/src/mapping/mod.rs | 8 +- client/src/mapping/object.rs | 7 +- client/src/module/combat/mod.rs | 1 + client/src/module/combat/velocity.rs | 106 ++++++++ client/src/module/mod.rs | 13 +- client/src/module/movement/nofall.rs | 13 +- client/src/module/registry.rs | 17 +- client/src/net/mod.rs | 233 +++++++++++++----- .../packet/clientbound_set_entity_motion.rs | 65 +++++ client/src/net/packet/mod.rs | 88 ++++++- ...e_player.rs => serverbound_move_player.rs} | 42 ++-- 18 files changed, 538 insertions(+), 128 deletions(-) create mode 100644 client/src/module/combat/velocity.rs create mode 100644 client/src/net/packet/clientbound_set_entity_motion.rs rename client/src/net/packet/{move_player.rs => serverbound_move_player.rs} (75%) diff --git a/CLAUDE.md b/CLAUDE.md index 1693dae..3725dac 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 @@ -54,9 +55,9 @@ This is the core control flow and spans `injector`, `protocol`, `agent_loader`, **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. -**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`. -**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`. +**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 +68,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 diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index 90b1961..75433b8 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -407,7 +407,10 @@ fn interp_factor(state: &EspState, now: Instant) -> f64 { /// Resolves the current camera into a [`View`], caching the [`Camera`] handle. fn read_view(state: &mut EspState, ctx: &Context) -> Option { if state.camera.is_none() { - match minecraft().game_renderer().and_then(|gr| gr.get_main_camera()) { + 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}"); diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index 2090e9e..dc95c9d 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -52,6 +52,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; @@ -169,12 +170,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; } diff --git a/client/src/lib.rs b/client/src/lib.rs index 0cf703f..b421bf4 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -20,6 +20,7 @@ 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; @@ -100,6 +101,7 @@ fn register_modules() { 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/class_type.rs b/client/src/mapping/class_type.rs index 3200069..d62c897 100644 --- a/client/src/mapping/class_type.rs +++ b/client/src/mapping/class_type.rs @@ -41,6 +41,13 @@ pub enum MinecraftClassType { InventoryScreen, CraftingScreen, CreativeModeInventoryScreen, + // Network packets — the connection layer (`net/`) reads and rewrites these. + ServerboundMovePlayerPacket, + ServerboundMovePlayerPacketPos, + ServerboundMovePlayerPacketPosRot, + ServerboundMovePlayerPacketRot, + ServerboundMovePlayerPacketStatusOnly, + ClientboundSetEntityMotionPacket, } impl MinecraftClassType { @@ -102,6 +109,24 @@ impl MinecraftClassType { 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/minecraft.rs b/client/src/mapping/client/minecraft.rs index 3e639ae..e19433e 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -152,11 +152,7 @@ impl Minecraft { return Ok(Screen::None); } - let is = |class| { - mapping() - .is_instance_of(class, &screen) - .unwrap_or(false) - }; + 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) { diff --git a/client/src/mapping/math.rs b/client/src/mapping/math.rs index cda8d50..f154889 100644 --- a/client/src/mapping/math.rs +++ b/client/src/mapping/math.rs @@ -28,9 +28,15 @@ impl Vec3 { 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()?, + 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()?, }) } diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index 3bb5013..2884319 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,14 +1,14 @@ use crate::mapping::class::{Method, MethodHandle, MinecraftClass}; pub use crate::mapping::class_type::MinecraftClassType; -pub use crate::mapping::object::MappedObject; -pub use mapping_derive::MappedObject; 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}; @@ -20,11 +20,11 @@ pub mod client; pub mod component; pub mod entity; pub mod java; -pub mod math; -pub mod object; mod loader; +pub mod math; mod method; mod minecraft_version; +pub mod object; mod reflect; #[cfg(test)] diff --git a/client/src/mapping/object.rs b/client/src/mapping/object.rs index ad4ee6c..272a9f3 100644 --- a/client/src/mapping/object.rs +++ b/client/src/mapping/object.rs @@ -34,7 +34,12 @@ pub trait MappedObject { where Self: Sized, { - mapping().get_field(Self::class_type(), self.jni_ref().as_obj(), name, field_type) + mapping().get_field( + Self::class_type(), + self.jni_ref().as_obj(), + name, + field_type, + ) } /// Writes an instance field of the wrapped object. diff --git a/client/src/module/combat/mod.rs b/client/src/module/combat/mod.rs index 4544303..e7e95ef 100644 --- a/client/src/module/combat/mod.rs +++ b/client/src/module/combat/mod.rs @@ -3,6 +3,7 @@ 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; 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 7df266b..e92a8e6 100644 --- a/client/src/module/mod.rs +++ b/client/src/module/mod.rs @@ -40,6 +40,7 @@ pub enum ModuleId { KillAura, MobAura, Aimbot, + Velocity, PlayerEsp, MobEsp, ChestEsp, @@ -54,6 +55,7 @@ impl ModuleId { ModuleId::KillAura => "KillAura", ModuleId::MobAura => "MobAura", ModuleId::Aimbot => "Aimbot", + ModuleId::Velocity => "Velocity", ModuleId::PlayerEsp => "Player ESP", ModuleId::MobEsp => "Mob ESP", ModuleId::ChestEsp => "Chest ESP", @@ -159,8 +161,15 @@ pub trait Module: Debug + Send + Sync { fn on_tick(&self) -> anyhow::Result<()>; /// Inspects — and may modify — a packet passing through the connection. - /// Only enabled modules are called; the default ignores every packet. - fn handle_packet(&self, _packet: &mut crate::net::packet::Packet) {} + /// 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/nofall.rs b/client/src/module/movement/nofall.rs index b1a2874..43a1910 100644 --- a/client/src/module/movement/nofall.rs +++ b/client/src/module/movement/nofall.rs @@ -1,5 +1,5 @@ use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleId}; -use crate::net::packet::Packet; +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 @@ -38,14 +38,13 @@ impl Module for NoFallModule { Ok(()) } - fn handle_packet(&self, packet: &mut Packet) { - // Report the player as on the ground on every movement packet. - // (`if let` filters for the variant we care about — it becomes - // refutable once `Packet` gains more variants.) - #[allow(irrefutable_let_patterns)] - if let Packet::MovePlayer(move_packet) = packet { + 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 { diff --git a/client/src/module/registry.rs b/client/src/module/registry.rs index e7124c9..a39c465 100644 --- a/client/src/module/registry.rs +++ b/client/src/module/registry.rs @@ -7,7 +7,7 @@ use dashmap::DashMap; use log::error; use crate::module::{Module, ModuleId, ModuleType}; -use crate::net::packet::Packet; +use crate::net::packet::{Packet, PacketAction}; /// A shared, lockable handle to one module. pub type ModuleHandle = Arc>; @@ -78,15 +78,22 @@ impl ModuleRegistry { } /// Offers `packet` to every enabled module's `handle_packet`. Called from - /// the connection's packet dispatch, on the Netty thread. - pub fn handle_packet(&self, packet: &mut Packet) { + /// 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 { - module.handle_packet(packet); + if !module.get_module_data().enabled { + continue; + } + if module.handle_packet(packet) == PacketAction::Cancel { + return PacketAction::Cancel; } } + PacketAction::Forward } } diff --git a/client/src/net/mod.rs b/client/src/net/mod.rs index 588ea6a..0895a56 100644 --- a/client/src/net/mod.rs +++ b/client/src/net/mod.rs @@ -1,13 +1,14 @@ -//! Netty pipeline injection — **Phase 1: plumbing only.** +//! 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. //! -//! For now the native callbacks only **log** each packet type once and pass -//! the packet through unchanged — this proves the injection path works before -//! the Rust packet structs and the dispatch/modules are built on top (Phase 2). +//! 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 @@ -16,10 +17,9 @@ pub mod packet; use crate::mapping::MappedObject; -use crate::net::packet::move_player::MovePlayerPacket; -use crate::net::packet::Packet; +use crate::net::packet::{Packet, PacketAction}; use crate::state::{mapping, minecraft}; -use jni::objects::{GlobalRef, JClass, JObject, JValue}; +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; use jni::sys::jobject; use jni::{JNIEnv, NativeMethod}; use std::ffi::c_void; @@ -34,29 +34,36 @@ const HANDLER_CLASS: &str = "DarkChannelHandler"; 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_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_LAST: &str = - "(Ljava/lang/String;Lio/netty/channel/ChannelHandler;)Lio/netty/channel/ChannelPipeline;"; +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 defined `DarkChannelHandler` class, once registered. + /// The `DarkChannelHandler` class, once defined and bound. handler_class: Option, /// The `Connection` our handler is currently installed on. installed_on: Option, - /// Set if class definition failed, so it is not retried forever. - failed: bool, + /// Consecutive class-definition failures — capped by `MAX_DEFINE_ATTEMPTS`. + define_attempts: u32, } static STATE: Mutex = Mutex::new(NetState { handler_class: None, installed_on: None, - failed: false, + define_attempts: 0, }); /// Polls the live connection and makes sure our handler sits on its pipeline. @@ -84,18 +91,23 @@ fn ensure_installed_inner() -> anyhow::Result<()> { // Define + bind the handler class once. { let mut state = STATE.lock().unwrap(); - if state.failed { - return Ok(()); - } 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) => { - log::warn!("net: handler definition failed, disabling: {error}"); - state.failed = true; + state.define_attempts += 1; + log::warn!( + "net: handler definition failed (attempt {}/{}): {error}", + state.define_attempts, + MAX_DEFINE_ATTEMPTS + ); return Ok(()); } } @@ -126,14 +138,27 @@ fn ensure_installed_inner() -> anyhow::Result<()> { Ok(()) } -/// `DefineClass` the handler into the game class loader and `RegisterNatives`. +/// 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 = env.define_class(HANDLER_CLASS, loader.as_obj(), HANDLER_BYTECODE)?; + 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 { @@ -147,11 +172,59 @@ fn define_handler() -> anyhow::Result { fn_ptr: dark_on_inbound as *mut c_void, }, ]; - env.register_native_methods(&class, &methods)?; + 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> { @@ -176,7 +249,9 @@ fn current_connection(env: &mut JNIEnv) -> anyhow::Result> { }) } -/// Inserts our handler at the tail of `connection`'s Netty pipeline. +/// 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() @@ -199,11 +274,16 @@ fn install_on(env: &mut JNIEnv, connection: &GlobalRef) -> anyhow::Result<()> { 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, - "addLast", - SIG_PIPELINE_ADD_LAST, - &[JValue::Object(&name), JValue::Object(&handler)], + "addBefore", + SIG_PIPELINE_ADD_BEFORE, + &[ + JValue::Object(&base), + JValue::Object(&name), + JValue::Object(&handler), + ], )?; Ok(()) }) @@ -215,6 +295,8 @@ fn install_on(env: &mut JNIEnv, connection: &GlobalRef) -> anyhow::Result<()> { 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()) }; @@ -250,77 +332,94 @@ pub fn teardown() { /// `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> { +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()?) + Ok(env + .call_method(&channel, "pipeline", SIG_PIPELINE, &[])? + .l()?) } // --- packet dispatch ------------------------------------------------------- -/// `DarkChannelHandler.onOutbound` — runs the outbound packet transformers and -/// returns the object to forward (the original, or a modified replacement). +/// 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_outbound(env, packet) } { - Ok(Some(replacement)) => replacement, - _ => packet, - }, - )) + 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` — pass-through for now (inbound transformers -/// are a later phase). +/// `DarkChannelHandler.onInbound` — dispatches an inbound packet. unsafe extern "system" fn dark_on_inbound( - _env: *mut jni::sys::JNIEnv, + env: *mut jni::sys::JNIEnv, _class: jni::sys::jclass, packet: jobject, ) -> jobject { - packet + 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) } -/// Inspects an outbound packet: wraps it for the modules, lets each enabled -/// module's `handle_packet` modify it, and rebuilds the JVM object if it -/// changed. `Ok(None)` means "forward the original unchanged". -unsafe fn dispatch_outbound( +/// 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, -) -> anyhow::Result> { + inbound: bool, +) -> anyhow::Result { if packet.is_null() { - return Ok(None); + return Ok(Dispatch::Forward); } let mut env = unsafe { JNIEnv::from_raw(env)? }; let packet_obj = unsafe { JObject::from_raw(packet) }; - if !is_instance(&mut env, &packet_obj, packet::move_player::CLASS)? { - return Ok(None); - } - - let original = MovePlayerPacket::read(&mut env, &packet_obj)?; - let mut wrapped = Packet::MovePlayer(original); - crate::state::client().modules.handle_packet(&mut wrapped); + 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 Packet::MovePlayer(modified) = wrapped; - if modified != original { - return Ok(Some(modified.to_java(&mut env)?)); + 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), } - Ok(None) -} - -/// Whether `object` is an instance of the class named `class_name`. -fn is_instance(env: &mut JNIEnv, object: &JObject, class_name: &str) -> anyhow::Result { - let class = mapping().resolve_class(env, class_name)?; - Ok(env.is_instance_of(object, &class)?) } 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 index f863429..71d3080 100644 --- a/client/src/net/packet/mod.rs +++ b/client/src/net/packet/mod.rs @@ -1,18 +1,88 @@ //! Rust value-snapshots of the Minecraft packets DarkClient reads or rewrites. //! -//! Each packet is read once into a plain Rust struct (`read`), modified freely -//! by the dispatch, then rebuilt into a JVM object (`to_java`). All JNI uses -//! explicit descriptors — no reflection, no overload resolution. +//! 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 move_player; +pub mod clientbound_set_entity_motion; +pub mod serverbound_move_player; -use move_player::MovePlayerPacket; +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. -/// The dispatch wraps the raw JVM packet into this enum, lets each enabled -/// module's `handle_packet` modify it, then rebuilds the JVM object. +/// Variant names mirror the Minecraft packet classes. #[derive(Debug, Clone, PartialEq)] pub enum Packet { - /// An outbound `ServerboundMovePlayerPacket`. - MovePlayer(MovePlayerPacket), + /// 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/move_player.rs b/client/src/net/packet/serverbound_move_player.rs similarity index 75% rename from client/src/net/packet/move_player.rs rename to client/src/net/packet/serverbound_move_player.rs index 2f72821..9b22afe 100644 --- a/client/src/net/packet/move_player.rs +++ b/client/src/net/packet/serverbound_move_player.rs @@ -2,26 +2,20 @@ //! //! The packet is an abstract class with four subclasses — `Pos`, `PosRot`, //! `Rot`, `StatusOnly` — chosen by which of position / rotation it carries. -//! [`MovePlayerPacket::read`] snapshots any of them; [`MovePlayerPacket::to_java`] -//! rebuilds the matching subclass. +//! `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; -/// Binary name of the abstract `ServerboundMovePlayerPacket`. -pub const CLASS: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket"; - -const POS: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Pos"; -const POS_ROT: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$PosRot"; -const ROT: &str = "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$Rot"; -const STATUS_ONLY: &str = - "net/minecraft/network/protocol/game/ServerboundMovePlayerPacket$StatusOnly"; +/// Class of the abstract `ServerboundMovePlayerPacket`. +pub const CLASS_TYPE: MinecraftClassType = MinecraftClassType::ServerboundMovePlayerPacket; /// A snapshot of a `ServerboundMovePlayerPacket`. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct MovePlayerPacket { +pub struct ServerboundMovePlayerPacket { pub x: f64, pub y: f64, pub z: f64, @@ -33,10 +27,10 @@ pub struct MovePlayerPacket { pub has_rotation: bool, } -impl MovePlayerPacket { +impl ServerboundMovePlayerPacket { /// Reads a `ServerboundMovePlayerPacket` JVM object into a snapshot. - pub fn read(env: &mut JNIEnv, packet: &JObject) -> anyhow::Result { - Ok(MovePlayerPacket { + 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()?, @@ -69,7 +63,10 @@ impl MovePlayerPacket { let object = match (self.has_position, self.has_rotation) { (true, true) => { - let class = mapping().resolve_class(env, POS_ROT)?; + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketPosRot.get_name(), + )?; env.new_object( &class, "(DDDFFZZ)V", @@ -85,7 +82,10 @@ impl MovePlayerPacket { )? } (true, false) => { - let class = mapping().resolve_class(env, POS)?; + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketPos.get_name(), + )?; env.new_object( &class, "(DDDZZ)V", @@ -99,7 +99,10 @@ impl MovePlayerPacket { )? } (false, true) => { - let class = mapping().resolve_class(env, ROT)?; + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketRot.get_name(), + )?; env.new_object( &class, "(FFZZ)V", @@ -112,7 +115,10 @@ impl MovePlayerPacket { )? } (false, false) => { - let class = mapping().resolve_class(env, STATUS_ONLY)?; + let class = mapping().resolve_class( + env, + MinecraftClassType::ServerboundMovePlayerPacketStatusOnly.get_name(), + )?; env.new_object(&class, "(ZZ)V", &[on_ground, collision])? } }; From 5dd9ef4deb7e5ff8f9c7be47e2370ac402654425 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Fri, 22 May 2026 00:38:56 +0200 Subject: [PATCH 6/6] Save settings and move logs directories to injector directory --- CLAUDE.md | 18 ++- agent_loader/src/command.rs | 19 ++- agent_loader/src/lib.rs | 3 +- agent_loader/src/logging.rs | 22 ++- client/src/config.rs | 250 ++++++++++++++++++++++++++++++++ client/src/graphic/input.rs | 76 +++++++++- client/src/graphic/menu.rs | 179 +++++++++++++++-------- client/src/graphic/theme.rs | 4 + client/src/graphic/ui_engine.rs | 20 ++- client/src/lib.rs | 8 +- client/src/module/mod.rs | 4 +- client/src/module/registry.rs | 44 +++++- injector/src/inject.rs | 8 +- protocol/src/command.rs | 70 +++++++-- 14 files changed, 619 insertions(+), 106 deletions(-) create mode 100644 client/src/config.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3725dac..40e2974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,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. @@ -53,9 +53,11 @@ 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<()>`; 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`. +**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. + +**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. @@ -86,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/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/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/input.rs b/client/src/graphic/input.rs index dc95c9d..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, }; } @@ -63,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); @@ -82,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, } @@ -198,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; }; @@ -213,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); @@ -229,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; @@ -249,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(); } } @@ -282,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()?; @@ -295,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) { @@ -307,6 +356,7 @@ fn install_glfw_hooks() -> Option { original_mouse_button, original_cursor_pos, original_key, + original_scroll, set_input_mode, set_cursor_pos, }) @@ -324,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 { @@ -337,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 b23d91d..70cc220 100644 --- a/client/src/graphic/menu.rs +++ b/client/src/graphic/menu.rs @@ -135,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(); } }); }); @@ -159,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), @@ -184,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(); @@ -235,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( @@ -284,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). @@ -340,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; 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 3eda517..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; diff --git a/client/src/lib.rs b/client/src/lib.rs index b421bf4..645953c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -3,6 +3,7 @@ #![allow(dead_code)] extern crate jni; +mod config; mod graphic; mod mapping; mod module; @@ -45,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), @@ -73,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}"); } diff --git a/client/src/module/mod.rs b/client/src/module/mod.rs index e92a8e6..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, @@ -33,7 +33,7 @@ impl ModuleCategory { } /// Stable identifier of a module — what it is registered and looked up by. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ModuleId { Fly, NoFall, diff --git a/client/src/module/registry.rs b/client/src/module/registry.rs index a39c465..dfaf269 100644 --- a/client/src/module/registry.rs +++ b/client/src/module/registry.rs @@ -6,17 +6,26 @@ use std::sync::{Arc, Mutex}; use dashmap::DashMap; use log::error; -use crate::module::{Module, ModuleId, 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, + /// Factory defaults captured at registration, keyed like `modules`. + defaults: DashMap, } impl ModuleRegistry { @@ -25,16 +34,45 @@ impl ModuleRegistry { Self::default() } - /// Registers a module under its [`ModuleId`]. + /// 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 id = module.get_module_data().id; + 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))); } + /// 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())) 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/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(), + }), ); }