From c5162368badcf69b1172b9d875ea1f107a22c415 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 15:25:33 +0200 Subject: [PATCH 01/14] Add protocol crate for injector and agent IPC Single source of truth for the localhost socket address and the typed reload command, plus a shared non-panicking logger. Common dependencies are centralized in the workspace manifest. --- Cargo.lock | 9 + Cargo.toml | 18 +- REFACTOR_PLAN.md | 375 ++++++++++++++++++++++++++++++++++++++++ agent_loader/Cargo.toml | 10 +- client/Cargo.toml | 16 +- injector/Cargo.toml | 6 +- protocol/Cargo.toml | 9 + protocol/src/command.rs | 63 +++++++ protocol/src/lib.rs | 18 ++ protocol/src/logging.rs | 36 ++++ 10 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 REFACTOR_PLAN.md create mode 100644 protocol/Cargo.toml create mode 100644 protocol/src/command.rs create mode 100644 protocol/src/lib.rs create mode 100644 protocol/src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index 3b5fb7d..49b1c59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2540,6 +2540,15 @@ dependencies = [ "rustix 0.36.17", ] +[[package]] +name = "protocol" +version = "0.1.0" +dependencies = [ + "log", + "simplelog", + "thiserror 2.0.17", +] + [[package]] name = "ptrace-inject" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index a555363..e92f0c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [workspace] resolver = "2" members = [ + "protocol", "injector", - "client" -, "agent_loader"] + "client", + "agent_loader", +] [workspace.package] version = "0.1.0" @@ -21,7 +23,15 @@ eframe = { version = "0.29", default-features = false, features = [ "accesskit", "default_fonts", "glow", - "persistence" -]} + "persistence", +] } log = "0.4.25" simplelog = "0.12.2" +anyhow = "1.0" +thiserror = "2.0" +libc = "0.2" +jni = "0.21" +serde = { version = "1.0", features = ["derive"] } +sysinfo = "0.37.2" +crossterm = "0.29" +ctor = "0.2.8" diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..cfd4fd6 --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,375 @@ +# DarkClient — Refactor Plan + +> Branch: `refactor/project-cleanup` (from `master`) +> Goal: cleaner, leaner, faster project. Deeper restructure allowed (module +> boundaries, traits and data flow may change). Behavior is preserved — no +> feature is removed. Linux + Windows must keep working; macOS is not +> implemented but every platform seam is designed so it can be added later. + +## Decisions (confirmed with the user) + +| Topic | Decision | +|---|---| +| Mapping access | Global accessor — remove `&Mapping` from **all** constructors and from `FieldType`. | +| Singletons | Refactor: `DarkClient`/`Minecraft`/`Mapping` collapse into **one** explicitly-initialized global `Client` (no `Arc`, no lazy panic). | +| Refactor depth | Deeper restructure — internal APIs may change. | +| Confusing "loader" | The `agent_loader` crate (monolithic `lib.rs`). `mapping/loader.rs` stays. | +| Injector GUI | Full visual redesign **+** code cleanup. Keep the `--tui` mode. | +| macOS | Not implemented now. All `#[cfg]` seams get a `macos` arm (stub returning `Unsupported`). | +| Menu injection | Injecting from the **main menu** (not in-game) must fully work — game state acquired lazily. | +| Testing | Tiered: pure unit tests + an in-process JVM integration framework. Full Minecraft e2e optional / manual. | + +## Working rules + +- Verify each phase with `cargo check` (workspace + per-crate). Full + `--release` builds only at phase boundaries when needed. +- Windows / macOS code is **review-only** — not cross-compiled here. +- Each phase ends in a compiling state with its own commit. Behavior unchanged + (except the deliberate menu-injection fix). +- `cargo fmt` + `cargo clippy` clean at every phase boundary. +- Tests must stay green after the phase that introduces them. + +--- + +## Current pain points (from analysis) + +**Workspace** — `SOCKET_ADDRESS` / the `reload` protocol is duplicated as string +literals across `injector` and `agent_loader`; common deps not centralized; +each crate inits its logger with `File::create(...).unwrap()`. + +**injector** — TCP-reload block copy-pasted in `unix.rs` and `windows.rs`; +platform layer is bare `#[cfg]` re-exports, no trait; `~7` `.unwrap()`/`.expect()` +panic points; GUI status is a single `String`; injection blocks the UI thread +for up to 5 s; process detection is case-sensitive and hard-codes binary names. + +**agent_loader** — everything in one 379-line `lib.rs`; primitive +`splitn(2, ' ')` command parsing; serial blocking server; messy +`format!("{:?}", ...)` + quote-trimming path munging; logger init unguarded; +mutex poisoning unhandled. + +**client** — three singletons (`DarkClient`/`Minecraft`/`Mapping`), each a +`OnceLock>` where the `Arc` is never cloned (dead heap alloc + atomics) +and lazy init `panic!`s at a nondeterministic first-access point; `&Mapping` +threaded through `LocalPlayer/Abilities/World/Window` constructors and +`FieldType::Object(_, &Mapping)` (~15 explicit passes); `FieldType` carries a +lifetime only for that; ~90 `.unwrap()`; no typed errors; module storage +triple-wrapped `Arc>>>>`; `esp.rs` is +1081 lines; frame/GL hook layer is `#[cfg]`-scattered and x86-64 only; +`tick()` `panic!`s if a module fails to stop. + +**Menu-injection bug** — `Minecraft::new()` eagerly builds `LocalPlayer`, +`World` and `MultiPlayerGameMode`. In the main menu `Minecraft.player`, +`.level` and `.gameMode` are all null, so `new()` fails, `Minecraft::instance()` +panics, and modules that get an `Err` from `on_tick` are auto-disabled. +Injection "succeeds" but the log is full of failures. + +--- + +## Target architecture + +### New crate: `protocol/` (lib) + +Single source of truth for injector ⇆ agent_loader IPC. + +``` +protocol/src/lib.rs + pub const SOCKET_ADDR: SocketAddr // 127.0.0.1:7878 + pub enum Command { Reload(PathBuf), Ping, ... } + Command::encode(&self) -> String / decode(&str) -> Result + pub fn init_file_logger(path) -> Result<()> // non-panicking, shared +``` + +Depended on by `injector` and `agent_loader`. Unit-tested (encode/decode round-trip). + +### `injector/` + +``` +injector/src/ + main.rs entry: arg parse, logger, privilege check, GUI/TUI dispatch + app.rs UI-agnostic core: process scan + injection orchestration, + InjectionStatus enum, runs injection on a worker thread + inject.rs high-level flow: platform inject -> protocol reload + platform/ + mod.rs trait Injector + ProcessInfo + cfg-selected impl + discovery.rs cross-platform Minecraft process discovery (sysinfo) + linux.rs ptrace-inject + windows.rs dll-syringe + macos.rs stub -> Err(Unsupported) + gui/ + mod.rs eframe App (thin: renders app.rs state) + theme.rs colors / fonts / spacing + widgets.rs process card, status banner, action button + tui.rs crossterm TUI, rebuilt on app.rs core +``` + +- `trait Injector { fn inject(&self, pid, agent: &Path) -> Result<(), InjectError>; }` +- Async injection: worker thread + `mpsc` channel; GUI polls `InjectionStatus` + (`Idle / Scanning / Injecting / Done / Failed(msg)`). No async runtime added. +- `InjectError` (thiserror): `Privilege / ProcessGone / Attach / Inject / Connect / Protocol`. + +### `agent_loader/` + +``` +agent_loader/src/ + lib.rs #[ctor]/#[dtor], globals, wires the modules together + logging.rs non-panicking logger init (via protocol helper) + jvm.rs get_jvm() + JVM health monitor + server.rs TCP command server loop + command.rs dispatch over protocol::Command + library.rs client lib lifecycle: load / unload / reload (hot-reload) + platform.rs signal handlers — cfg(unix); macos/windows arms +``` + +- `library.rs` keeps the temp-copy hot-reload but uses clean `Path` APIs. +- Mutex poisoning handled (recover, not panic). + +### `client/` — one global `Client` + +The three singletons collapse into a single owned runtime root, explicitly +initialized once, `Arc`-free: + +```rust +// client/src/state.rs +static CLIENT: OnceLock = OnceLock::new(); + +pub struct Client { + pub jvm: JavaVM, + pub mapping: Mapping, + pub minecraft: Minecraft, // window-level handle, always valid once injected + pub modules: ModuleRegistry, +} + +/// Called exactly once, from `initialize_client`. The single, known init point. +pub fn init() -> Result<(), ClientError> { + CLIENT.set(Client::new()?).map_err(|_| ClientError::AlreadyInitialized) +} + +/// Infallible accessor — valid after `init()` succeeded. +#[inline] +pub fn client() -> &'static Client { + CLIENT.get().expect("client() used before init()") +} + +/// Convenience — `&client().mapping`. +#[inline] +pub fn mapping() -> &'static Mapping { &client().mapping } +``` + +- No `Arc`: access = one atomic-acquire load + branch, `#[inline]`d. +- `Mapping` / `Minecraft` become **fields**, not singletons. `&Mapping` removed + from every constructor; `FieldType` loses its lifetime → + `Object(MinecraftClassType)`. +- `GameContext` trait dropped (or reduced to nothing) — replaced by the free + `client()` / `mapping()` functions. +- `new()` no longer `unsafe` — the unsafe JNI calls are wrapped internally. +- Init order is straight-line in `initialize_client`: `state::init()?` → + `register_modules()` → install hooks **last** (so `on_frame` never observes an + uninitialized `Client`; `RUNNING` is set true only after init succeeds). + +### `client/` — menu-safe lazy game state + +`Minecraft.getInstance()` and the game `Window` exist from the main menu +onward. `player`, `level`/`world` and `gameMode` are **world-scoped**: null in +the menu, populated on world join, null again on leave. So they must never be +built in a constructor — only fetched on demand. + +```rust +pub struct Minecraft { + jni_ref: GlobalRef, // Minecraft.getInstance() — valid from menu onward + window: Window, // valid from menu onward +} + +impl Minecraft { + /// `Ok(None)` in the menu / not in a world. `Err` only on a real JNI fault. + pub fn player(&self) -> Result>; + pub fn world(&self) -> Result>; + pub fn game_mode(&self) -> Result>; + pub fn in_world(&self) -> bool; +} +``` + +- `Result>` is honest: `Err` = JNI failure, `Ok(None)` = not in world. +- `player()` keeps the existing cache (`RwLock>`) with the + `is_same_object` staleness check. +- Every world-dependent module's `on_tick` early-returns `Ok(())` when not in + world — "nothing to do", **not** an error, so the module is not disabled: + + ```rust + fn on_tick(&self) -> anyhow::Result<()> { + let Some(player) = client().minecraft.player()? else { return Ok(()) }; + // ... real logic + } + ``` + +Result: injecting from the menu initializes cleanly; modules sit idle until a +world loads, then start working — no log spam, no auto-disable. + +### `client/` — other restructuring + +``` +graphic/ + platform/ + mod.rs trait FrameHook + trait GlLoader, cfg-selected + linux.rs glX/glfw via dlsym + ilhook + windows.rs wgl + ilhook + macos.rs stub -> Err(Unsupported) + esp/ esp.rs (1081 LOC) split: math.rs / gather.rs / render.rs / mod.rs +module/ + registry.rs ModuleRegistry — single Mutex>, not the triple wrapper +``` + +- `ClientError` (thiserror) at mapping/JNI boundaries; `anyhow` stays at the + module-trait boundary. Lock access via a `lock_or_err` helper. +- `DarkClient::tick()` no longer `panic!`s on a failing module — log + disable. + +--- + +## Testing strategy + +A faithful test "framework" **is** feasible without launching Minecraft: the +`jni` crate (`invocation` feature) can create a real in-process JVM, and the +reflected mapping path is plain JNI reflection — it only needs classes named +like Minecraft's, not Minecraft itself. + +**Tier 1 — pure unit tests (fast, CI, no JVM).** Done as a dedicated phase +(Phase T1) after the refactor, so they are reviewed together: +- `protocol`: `Command` encode/decode round-trip. +- `client`: `class.rs` overload scoring (exists), ESP projection math, + `mappings.json` parsing, `FieldType` signature strings. +- `injector`: process-discovery filtering (pure fn over fake process lists). + +**Tier 2 — in-process JVM integration framework (CI-capable, needs a JDK).** +A dedicated test harness, e.g. `client/tests/jvm/`: +- A tiny Java fixture — stub classes (`net/minecraft/client/Minecraft` with a + static `getInstance`, a fake player/world, a custom classloader to emulate + Fabric's `KnotClassLoader`) compiled to a jar. +- Rust tests boot a `JavaVM`, load the fixture, and exercise the **real** + code paths: reflected `Mapping` resolution, `loader::discover_game_loader` + classloader scanning, method-signature reflection, `call_method` overload + resolution, and the **menu vs in-world** transitions (fixture toggles + `player`/`level` between null and set). +- Fixture build wired via a `build.rs` or a `cargo xtask` step (`javac`). + +**Tier 3 — full Minecraft e2e (optional, manual).** A documented `cargo xtask` +that launches a real Minecraft, injects, and asserts over the TCP channel / +logs. Marked `#[ignore]` / not run in CI — too slow and flaky for automation. +Provided as an opt-in harness; not a phase blocker. + +If Tier 2's JDK-at-test-time cost is unwanted in CI, it can be gated behind a +feature flag and Tier 1 alone runs in CI — but Tier 2 is the recommended core. + +--- + +## Phases + +Each phase = one commit, compiles, behavior unchanged (except Phase 5). + +### Phase 0 — Workspace foundation +- New `protocol` crate: `SOCKET_ADDR`, `Command`, encode/decode, shared + non-panicking logger helper. +- Centralize common deps in `[workspace.dependencies]` (`log`, `simplelog`, + `anyhow`, `thiserror`, `libc`, `libloading`, `jni`, `serde`, `sysinfo`, + `crossterm`, `ctor`). +- ✅ `cargo check` workspace. + +### Phase 1 — injector: platform layer + core +- `platform/`: `Injector` trait, `linux.rs` / `windows.rs` / `macos.rs` (stub), + `discovery.rs` (case-insensitive, robust binary-name match). +- Extract duplicated TCP-reload into `inject.rs` using `protocol`. +- `app.rs`: UI-agnostic core + `InjectionStatus`; injection on a worker thread. +- Remove all `.unwrap()`/`.expect()` panic points; `InjectError` type. +- ✅ `cargo check -p injector`. + +### Phase 2 — injector: GUI redesign + TUI +- New `gui/` (theme, widgets, layout): process cards, clear status/progress + states, non-blocking injection wired to `app.rs`. +- Rebuild `tui.rs` on the shared `app.rs` core. +- ✅ `cargo check -p injector`; manual GUI smoke test on Linux. + +### Phase 3 — agent_loader: modularize +- Split `lib.rs` into `logging / jvm / server / command / library / platform`. +- Command dispatch via `protocol::Command`; clean `Path` handling; handle + mutex poisoning; guarded logger init. +- ✅ `cargo check -p agent_loader`. + +### Phase 4 — client: global `Client` state +- Implement `state.rs`: one `OnceLock`, `init()` + `client()` / `mapping()`. +- Collapse `DarkClient` + `Minecraft` + `Mapping` singletons into `Client` + fields; drop the dead `Arc`s; drop `unsafe fn new()`. +- `mapping()` global accessor — remove `&Mapping` from all constructors; drop + `FieldType`'s lifetime. Fix the straight-line init order in `lib.rs`. +- ✅ `cargo check -p client` + `cargo test -p client`. + +### Phase 5 — client: menu-safe lazy game state +- `Minecraft` keeps only `jni_ref` + `window`; `player()` / `world()` / + `game_mode()` become lazy `Result>` accessors. +- `init()` succeeds in the main menu. +- (Module no-op behavior lands in Phase 7.) +- ✅ `cargo check -p client`; manual: inject from menu, no error log. + +### Phase 6 — client: error handling +- `ClientError` (thiserror) at mapping/JNI boundaries; `lock_or_err` helper. +- Remove critical-path `.unwrap()`; `tick()` and `init()` stop panicking. +- ✅ `cargo check -p client` + `cargo test -p client`. + +### Phase 7 — client: module system +- `ModuleRegistry` with a single `Mutex>`; tidy `Module` trait; + keep explicit `register_modules()` (zero-dep, lean). +- Every world-dependent module `on_tick` early-returns `Ok(())` when not in + world — completes the menu-injection fix. +- ✅ `cargo check -p client`. + +### Phase 8 — client: graphic split + platform seam +- Split `esp.rs` into `graphic/esp/{math,gather,render,mod}.rs`. +- `graphic/platform/` with `FrameHook` / `GlLoader` traits + `linux/windows` + impls + `macos` stub. +- ✅ `cargo check -p client`. + +### Phase 9 — T1: pure unit tests (no JVM) +- `protocol`: `Command` encode/decode round-trip. +- `client`: `class.rs` overload scoring (exists), ESP projection math, + `mappings.json` parsing, `FieldType` signature strings. +- `injector`: process-discovery filtering (pure fn over fake process lists). +- Fast, CI-friendly. Reviewed together before moving on. +- ✅ `cargo test` workspace. + +### Phase 10 — T2: JVM integration framework +- Java fixture (stub Minecraft classes + fake Fabric classloader), built via + `xtask`/`build.rs`. +- `client/tests/jvm/`: in-process `JavaVM` tests for reflected `Mapping`, + classloader discovery, overload resolution, menu↔in-world transitions. +- ✅ `cargo test -p client` (with JDK). + +### Phase 11 — T3: full Minecraft e2e (optional / manual) +- `cargo xtask` that launches a real Minecraft, injects, asserts over the TCP + channel / logs. Marked `#[ignore]`, not run in CI. +- ✅ manual run. + +### Phase 12 — polish +- `cargo fmt` + `cargo clippy` clean across the workspace. +- Update `CLAUDE.md` and `README.md` (the README `Module` trait example is + already stale) to match the new structure. +- Final `cargo build --release` (Linux); review Windows/macOS paths. +- Remove this file or move it to `docs/`. + +--- + +## Risks + +- **Phase 4** changes global init — mitigated: one explicit, documented init + point instead of three lazy ones; `cargo test` after. Lower risk than the + original lazy-`OnceLock` design. +- **Phase 5/7** menu-safe state touches every module — mitigated by the + uniform `let Some(..) = ..? else { return Ok(()) }` pattern. +- Frame/GL hooking (`ilhook`) is x86-64 only — the macOS stub compiles but + returns `Unsupported`; real macOS hooking is out of scope. +- Windows code can't be verified here — kept review-only. +- Tier 2 tests need a JDK + `javac` at test time — can be feature-gated if CI + cost is unwanted. + +## Out of scope + +- macOS implementation (only the seams). +- New client features / modules. +- Mapping-format or `conversion.py` changes. +- Replacing `egui`/`ilhook`/`jni`. diff --git a/agent_loader/Cargo.toml b/agent_loader/Cargo.toml index bfdf399..d7fe118 100644 --- a/agent_loader/Cargo.toml +++ b/agent_loader/Cargo.toml @@ -13,9 +13,9 @@ default = ["ctor/used_linker"] used_linker = [] [dependencies] -ctor = "0.2.8" -log = "0.4.25" -simplelog = "0.12.2" +ctor.workspace = true +log.workspace = true +simplelog.workspace = true +jni.workspace = true +libc.workspace = true libloading = "0.8.0" -jni = "0.21" -libc = "0.2" \ No newline at end of file diff --git a/client/Cargo.toml b/client/Cargo.toml index d57d360..ba251d4 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -9,20 +9,18 @@ crate-type = ["cdylib"] [dependencies] egui.workspace = true -#eframe.workspace = true -egui_glow = "0.29.0" -glow = "0.14.0" -#winit = "0.30" log.workspace = true simplelog.workspace = true -jni = "0.21.1" -serde = { version = "1.0.210", features = ["derive"] } +jni.workspace = true +serde.workspace = true +anyhow.workspace = true +libc.workspace = true +egui_glow = "0.29.0" +glow = "0.14.0" serde_json = "1.0.135" -anyhow = "1.0" -libc = "0.2.178" libloading = "0.9.0" ilhook = "2.3.0" lazy_static = "1.4.0" [build-dependencies] -gl_generator = "0.14" \ No newline at end of file +gl_generator = "0.14" diff --git a/injector/Cargo.toml b/injector/Cargo.toml index 3194232..dc17612 100644 --- a/injector/Cargo.toml +++ b/injector/Cargo.toml @@ -8,8 +8,8 @@ egui.workspace = true eframe.workspace = true log.workspace = true simplelog.workspace = true -crossterm = "0.29" -sysinfo = "0.37.2" +crossterm.workspace = true +sysinfo.workspace = true [target.'cfg(target_os = "linux")'.dependencies] ptrace-inject = "0.1.2" @@ -17,4 +17,4 @@ proc-maps = "0.4.0" [target.'cfg(windows)'.dependencies] is_elevated = "0.1.2" -dll-syringe = "0.17.1" \ No newline at end of file +dll-syringe = "0.17.1" diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml new file mode 100644 index 0000000..9bfa287 --- /dev/null +++ b/protocol/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "protocol" +version.workspace = true +edition.workspace = true + +[dependencies] +log.workspace = true +simplelog.workspace = true +thiserror.workspace = true diff --git a/protocol/src/command.rs b/protocol/src/command.rs new file mode 100644 index 0000000..246296a --- /dev/null +++ b/protocol/src/command.rs @@ -0,0 +1,63 @@ +//! The injector → agent_loader command set and its wire encoding. + +use std::path::PathBuf; + +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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Load — or hot-reload — the client library at the given absolute path. + Reload(PathBuf), +} + +/// Failure to parse a command off the wire. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProtocolError { + /// The line carried no verb. + #[error("empty command line")] + Empty, + /// The verb is not one this protocol version understands. + #[error("unknown command verb: {0:?}")] + UnknownVerb(String), + /// A verb that requires an argument was sent without one. + #[error("command {verb:?} is missing its argument")] + MissingArgument { verb: &'static str }, +} + +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()), + } + } + + /// Parses one wire line back into a [`Command`]. + /// + /// The argument runs to the end of the line, so paths containing spaces + /// survive the round trip. + pub fn decode(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Err(ProtocolError::Empty); + } + let (verb, arg) = match line.split_once(' ') { + Some((verb, arg)) => (verb, arg.trim()), + None => (line, ""), + }; + match verb { + "reload" => { + if arg.is_empty() { + Err(ProtocolError::MissingArgument { verb: "reload" }) + } else { + Ok(Command::Reload(PathBuf::from(arg))) + } + } + other => Err(ProtocolError::UnknownVerb(other.to_string())), + } + } +} diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs new file mode 100644 index 0000000..2ac1f8a --- /dev/null +++ b/protocol/src/lib.rs @@ -0,0 +1,18 @@ +//! Shared IPC contract between the `injector` and the `agent_loader`. +//! +//! The two processes speak a tiny line-based protocol over a localhost TCP +//! socket. Keeping the wire format, the socket address and the logger setup +//! in a single crate stops the two ends from silently drifting apart. + +mod command; +mod logging; + +pub use command::{Command, ProtocolError}; +pub use logging::{init_file_logger, LoggerError}; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +/// Address the `agent_loader` command server binds and the `injector` +/// connects to. Localhost-only by design — the channel is never exposed off +/// the machine. +pub const SOCKET_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7878); diff --git a/protocol/src/logging.rs b/protocol/src/logging.rs new file mode 100644 index 0000000..4b22d25 --- /dev/null +++ b/protocol/src/logging.rs @@ -0,0 +1,36 @@ +//! Shared, non-panicking file-logger setup. + +use std::fs::File; +use std::path::Path; + +use log::LevelFilter; +use simplelog::{Config, WriteLogger}; +use thiserror::Error; + +/// Failure to set up file logging. +#[derive(Debug, Error)] +pub enum LoggerError { + /// The log file could not be created. + #[error("could not create log file: {0}")] + CreateFile(#[from] std::io::Error), + /// A global logger was already installed by someone else. + #[error("a logger is already installed")] + AlreadyInstalled(#[from] log::SetLoggerError), +} + +/// Initializes a file logger without ever panicking. +/// +/// A logging problem must never take the host process down, so on failure +/// the error is reported to stderr and returned to the caller instead of +/// unwinding. +pub fn init_file_logger(path: impl AsRef, level: LevelFilter) -> Result<(), LoggerError> { + let result = (|| { + let file = File::create(path.as_ref())?; + WriteLogger::init(level, Config::default(), file)?; + Ok(()) + })(); + if let Err(ref e) = result { + eprintln!("[protocol] file logger init failed: {e}"); + } + result +} From 72eae69773221ec52fc6e996711b22497f6b2ac3 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 15:34:50 +0200 Subject: [PATCH 02/14] Rework the injector platform layer and core Injection sits behind a per-OS trait with a typed error type, and a UI-agnostic core runs the injection on a worker thread so the front-ends never block. --- Cargo.lock | 4 +- injector/Cargo.toml | 6 +- injector/src/app.rs | 154 ++++++++++++++++++++++ injector/src/inject.rs | 106 +++++++++++++++ injector/src/main.rs | 203 +++++++++++++---------------- injector/src/platform/discovery.rs | 71 ++++++++++ injector/src/platform/linux.rs | 56 ++++++++ injector/src/platform/macos.rs | 30 +++++ injector/src/platform/mod.rs | 137 ++++++++++--------- injector/src/platform/unix.rs | 103 --------------- injector/src/platform/windows.rs | 102 ++++----------- injector/src/tui.rs | 35 ++--- 12 files changed, 637 insertions(+), 370 deletions(-) create mode 100644 injector/src/app.rs create mode 100644 injector/src/inject.rs create mode 100644 injector/src/platform/discovery.rs create mode 100644 injector/src/platform/linux.rs create mode 100644 injector/src/platform/macos.rs delete mode 100644 injector/src/platform/unix.rs diff --git a/Cargo.lock b/Cargo.lock index 49b1c59..5698d87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1596,11 +1596,13 @@ dependencies = [ "eframe", "egui", "is_elevated", + "libc", "log", "proc-maps", + "protocol", "ptrace-inject", - "simplelog", "sysinfo", + "thiserror 2.0.17", ] [[package]] diff --git a/injector/Cargo.toml b/injector/Cargo.toml index dc17612..b79b9ca 100644 --- a/injector/Cargo.toml +++ b/injector/Cargo.toml @@ -7,9 +7,13 @@ edition.workspace = true egui.workspace = true eframe.workspace = true log.workspace = true -simplelog.workspace = true crossterm.workspace = true sysinfo.workspace = true +thiserror.workspace = true +protocol = { path = "../protocol" } + +[target.'cfg(unix)'.dependencies] +libc.workspace = true [target.'cfg(target_os = "linux")'.dependencies] ptrace-inject = "0.1.2" diff --git a/injector/src/app.rs b/injector/src/app.rs new file mode 100644 index 0000000..3c1704e --- /dev/null +++ b/injector/src/app.rs @@ -0,0 +1,154 @@ +//! UI-agnostic core shared by the GUI and the TUI front-ends. +//! +//! Both front-ends own an [`InjectorApp`], drive it, and only render its +//! state. Injection runs on a worker thread so neither front-end blocks. + +use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::thread; + +use crate::inject; +use crate::platform::{find_minecraft_processes, InjectError, ProcessInfo}; + +/// Where an injection attempt currently stands. +#[derive(Debug, Clone)] +pub enum InjectionStatus { + /// Idle and ready. + Idle, + /// A process scan is running. + Scanning, + /// An injection into the given pid is in progress. + Injecting(u32), + /// The last injection into the given pid succeeded. + Done(u32), + /// The last action failed, with a human-readable reason. + Failed(String), +} + +impl InjectionStatus { + /// Short human-readable line suitable for a status bar. + pub fn message(&self) -> String { + match self { + InjectionStatus::Idle => "Ready.".to_string(), + InjectionStatus::Scanning => "Scanning for Minecraft…".to_string(), + InjectionStatus::Injecting(pid) => format!("Injecting into process {pid}…"), + InjectionStatus::Done(pid) => format!("Injected into process {pid}."), + InjectionStatus::Failed(reason) => format!("Error: {reason}"), + } + } +} + +/// Front-end-independent injector state machine. +pub struct InjectorApp { + processes: Vec, + selected_pid: Option, + status: InjectionStatus, + /// Result channel of an in-flight injection, with its target pid. + pending: Option<(u32, Receiver>)>, +} + +impl InjectorApp { + /// Creates an idle app with no scan results. + pub fn new() -> Self { + Self { + processes: Vec::new(), + selected_pid: None, + status: InjectionStatus::Idle, + pending: None, + } + } + + /// The processes found by the last [`scan`](Self::scan). + pub fn processes(&self) -> &[ProcessInfo] { + &self.processes + } + + /// The currently selected process id, if any. + pub fn selected_pid(&self) -> Option { + self.selected_pid + } + + /// The current status. + pub fn status(&self) -> &InjectionStatus { + &self.status + } + + /// Whether an injection is currently running. + pub fn is_busy(&self) -> bool { + self.pending.is_some() + } + + /// Selects a process by pid, if it is in the current list. + pub fn select(&mut self, pid: u32) { + if self.processes.iter().any(|p| p.pid == pid) { + self.selected_pid = Some(pid); + } + } + + /// Rescans for Minecraft processes. Keeps the current selection if it is + /// still present, otherwise selects the first result. + pub fn scan(&mut self) { + if self.is_busy() { + return; + } + self.status = InjectionStatus::Scanning; + self.processes = find_minecraft_processes(); + + let kept = self + .selected_pid + .is_some_and(|pid| self.processes.iter().any(|p| p.pid == pid)); + if !kept { + self.selected_pid = self.processes.first().map(|p| p.pid); + } + self.status = InjectionStatus::Idle; + } + + /// Starts injecting into the selected process on a worker thread. A no-op + /// when nothing is selected or an injection is already running. + pub fn start_injection(&mut self) { + if self.is_busy() { + return; + } + let Some(pid) = self.selected_pid else { + return; + }; + + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(inject::inject(pid)); + }); + self.pending = Some((pid, rx)); + self.status = InjectionStatus::Injecting(pid); + } + + /// Polls the injection worker. Front-ends call this once per frame/loop; + /// returns `true` when the status changed so the GUI knows to repaint. + pub fn poll(&mut self) -> bool { + let Some((pid, rx)) = &self.pending else { + return false; + }; + let pid = *pid; + match rx.try_recv() { + Ok(result) => { + self.status = match result { + Ok(()) => InjectionStatus::Done(pid), + Err(e) => InjectionStatus::Failed(e.to_string()), + }; + self.pending = None; + true + } + Err(TryRecvError::Empty) => false, + Err(TryRecvError::Disconnected) => { + self.status = + InjectionStatus::Failed("injection worker stopped unexpectedly".to_string()); + self.pending = None; + true + } + } + } +} + +impl Default for InjectorApp { + fn default() -> Self { + Self::new() + } +} diff --git a/injector/src/inject.rs b/injector/src/inject.rs new file mode 100644 index 0000000..1959d9c --- /dev/null +++ b/injector/src/inject.rs @@ -0,0 +1,106 @@ +//! High-level injection orchestration. +//! +//! Injects the agent into the target process (platform-specific) and then +//! tells it, over the localhost TCP channel, to load or hot-reload the +//! client library. + +use std::io::Write; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use log::info; +use protocol::{Command, SOCKET_ADDR}; + +use crate::platform::{AgentInjector, InjectError, PlatformInjector}; + +/// Base names of the two shared libraries shipped alongside the injector. +const AGENT_BASE: &str = "libagent_loader"; +const CLIENT_BASE: &str = "libclient"; + +/// Total time to keep retrying the connection to the freshly started agent. +const CONNECT_DEADLINE: Duration = Duration::from_secs(5); +/// Timeout of a single connection attempt within that deadline. +const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(500); +/// Pause between connection attempts. +const CONNECT_RETRY_PAUSE: Duration = Duration::from_millis(100); +/// Timeout for writing the command once connected. +const WRITE_TIMEOUT: Duration = Duration::from_secs(2); + +/// Injects the agent into `pid` (unless already present) and triggers a +/// client (re)load. Blocking — callers run it off the UI thread. +pub fn inject(pid: u32) -> Result<(), InjectError> { + let injector = PlatformInjector; + + let agent_file = library_file_name(AGENT_BASE); + let agent_path = locate_library(&agent_file)?; + let client_path = locate_library(&library_file_name(CLIENT_BASE))?; + + if injector.is_agent_loaded(pid, &agent_file) { + info!("agent already present in pid {pid}; reloading client only"); + } else { + injector.inject(pid, &agent_path)?; + } + + send_reload(&client_path) +} + +/// 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)?; + + let mut stream = connect_with_retry()?; + let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT)); + + let command = Command::Reload(absolute).encode(); + info!("sending command: {command}"); + stream + .write_all(command.as_bytes()) + .map_err(InjectError::Send)?; + Ok(()) +} + +/// Repeatedly tries to connect until [`CONNECT_DEADLINE`] elapses — this +/// covers the short window between injecting the agent and its TCP server +/// becoming reachable, without a blind fixed sleep. +fn connect_with_retry() -> Result { + let deadline = Instant::now() + CONNECT_DEADLINE; + loop { + match TcpStream::connect_timeout(&SOCKET_ADDR, CONNECT_ATTEMPT_TIMEOUT) { + Ok(stream) => return Ok(stream), + Err(source) => { + if Instant::now() >= deadline { + return Err(InjectError::Connect { + addr: SOCKET_ADDR, + source, + }); + } + thread::sleep(CONNECT_RETRY_PAUSE); + } + } + } +} + +/// `libfoo` → `libfoo.so` / `libfoo.dll` / `libfoo.dylib` for the host OS. +fn library_file_name(base: &str) -> String { + format!("{base}.{}", std::env::consts::DLL_EXTENSION) +} + +/// Looks for `file_name` next to the injector executable first, then in the +/// current working directory. +fn locate_library(file_name: &str) -> Result { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join(file_name); + if candidate.is_file() { + return Ok(candidate); + } + } + } + let cwd_candidate = PathBuf::from(file_name); + if cwd_candidate.is_file() { + return Ok(cwd_candidate); + } + Err(InjectError::LibraryMissing(file_name.to_string())) +} diff --git a/injector/src/main.rs b/injector/src/main.rs index ec02509..535bb82 100644 --- a/injector/src/main.rs +++ b/injector/src/main.rs @@ -1,156 +1,137 @@ +mod app; +mod inject; mod platform; mod tui; -use crate::platform::ProcessInfo; -use eframe::{CreationContext, Frame}; +use eframe::Frame; use egui::Context; -use log::LevelFilter; -use simplelog::{Config, WriteLogger}; -use std::fs::File; +use log::{error, LevelFilter}; -fn main() { - if !is_elevated() { - #[cfg(target_family = "unix")] - eprintln!("❌ Please run this program with sudo: `sudo ./injector`"); - - #[cfg(target_family = "windows")] - eprintln!( - "❌ Please run this program as Administrator (Right click → Run as administrator)" - ); +use crate::app::InjectorApp; - return; // Exit the program if not elevated +fn main() { + if !platform::is_elevated() { + eprintln!("{}", elevation_hint()); + return; } - // Initialize the logger with a default configuration - WriteLogger::init( - LevelFilter::Debug, - Config::default(), - File::create("app.log").unwrap(), - ) - .unwrap(); + if let Err(e) = protocol::init_file_logger("app.log", LevelFilter::Debug) { + eprintln!("continuing without file logging: {e}"); + } - let args: Vec = std::env::args().collect(); - if args.contains(&"--tui".to_string()) { + if std::env::args().any(|arg| arg == "--tui") { tui::run_tui(); return; } + if let Err(e) = run_gui() { + error!("GUI terminated with an error: {e}"); + eprintln!("Error: {e}"); + } +} + +/// Launches the egui front-end. +fn run_gui() -> Result<(), eframe::Error> { let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([450.0, 320.0]) - .with_min_inner_size([300.0, 220.0]), + .with_inner_size([460.0, 340.0]) + .with_min_inner_size([320.0, 240.0]), ..Default::default() }; - eframe::run_native( "DarkClient Injector", native_options, - Box::new(|creation_context| Ok(Box::new(InjectorGUI::new(creation_context)))), + Box::new(|_cc| Ok(Box::new(InjectorGui::default()))), ) - .expect("Failed to run the GUI"); -} - -pub struct InjectorGUI { - status: String, - found_processes: Vec, - selected_pid: Option, } -impl InjectorGUI { - pub fn new(_creation_context: &CreationContext<'_>) -> Self { - Self { - status: "Ready:".to_owned(), - found_processes: Vec::new(), - selected_pid: None, - } +/// Platform-specific hint shown when the injector lacks the privileges it +/// needs to attach to another process. +fn elevation_hint() -> &'static str { + #[cfg(windows)] + { + "This program must run as Administrator (right click → Run as administrator)." } - - fn scan(&mut self) { - self.found_processes = platform::find_minecraft_processes(); - if self.found_processes.is_empty() { - self.status = String::from("No Minecraft processes found."); - self.selected_pid = None; - } else { - self.status = format!("Found {} processes.", self.found_processes.len()); - if self.selected_pid.is_none() { - self.selected_pid = Some(self.found_processes[0].pid); - } - } + #[cfg(not(windows))] + { + "This program must run with root privileges: sudo ./injector" } } -impl eframe::App for InjectorGUI { +/// Thin egui wrapper around [`InjectorApp`]. The polished layout lands in a +/// dedicated `gui` module in the next refactor phase. +#[derive(Default)] +struct InjectorGui { + app: InjectorApp, +} + +impl eframe::App for InjectorGui { fn update(&mut self, ctx: &Context, _frame: &mut Frame) { + self.app.poll(); + egui::CentralPanel::default().show(ctx, |ui| { ui.heading("DarkClient Injector"); + ui.add_space(8.0); ui.horizontal(|ui| { - if ui.button("🔄 Scan").clicked() { - self.scan(); - } - if !self.found_processes.is_empty() { - ui.label(format!("Found: {}", self.found_processes.len())); + let scan = egui::Button::new("🔄 Scan"); + if ui.add_enabled(!self.app.is_busy(), scan).clicked() { + self.app.scan(); } + ui.label(format!("{} process(es)", self.app.processes().len())); }); - ui.add_space(10.0); - - if !self.found_processes.is_empty() { - egui::ComboBox::from_id_salt("pid_select") - .width(300.0) - .selected_text(match self.selected_pid { - Some(pid) => { - let p = self.found_processes.iter().find(|p| p.pid == pid); - match p { - Some(proc) => format!("PID {}: {}", proc.pid, proc.info), - None => "Select process".to_string(), - } - } - None => "Select process".to_string(), - }) - .show_ui(ui, |ui| { - for proc in &self.found_processes { - ui.selectable_value( - &mut self.selected_pid, - Some(proc.pid), - format!("PID {}: {}", proc.pid, proc.info), - ); - } - }); - } else { - ui.label("No processes found."); - } - - ui.add_space(20.0); - - let btn = ui.add_enabled(self.selected_pid.is_some(), egui::Button::new("💉 INJECT")); - if btn.clicked() { - if let Some(pid) = self.selected_pid { - self.status = format!("Injecting into {}...", pid); - ctx.request_repaint(); + ui.add_space(8.0); + self.process_picker(ui); + ui.add_space(16.0); - match platform::inject(pid) { - Ok(_) => self.status = "✅ Injection Successful!".to_owned(), - Err(e) => self.status = format!("❌ Error: {}", e), - } - } + let can_inject = self.app.selected_pid().is_some() && !self.app.is_busy(); + if ui + .add_enabled(can_inject, egui::Button::new("💉 Inject")) + .clicked() + { + self.app.start_injection(); } ui.separator(); - ui.label(&self.status); + ui.label(self.app.status().message()); }); - } -} -#[cfg(target_family = "unix")] -fn is_elevated() -> bool { - extern "C" { - fn geteuid() -> u32; + // Keep repainting while a worker thread is running so its result is + // picked up promptly. + if self.app.is_busy() { + ctx.request_repaint(); + } } - unsafe { geteuid() == 0 } } -#[cfg(target_family = "windows")] -fn is_elevated() -> bool { - is_elevated::is_elevated() +impl InjectorGui { + /// Renders the process selection combo box. + fn process_picker(&mut self, ui: &mut egui::Ui) { + let selected = self.app.selected_pid(); + let label = selected + .and_then(|pid| self.app.processes().iter().find(|p| p.pid == pid)) + .map(|p| format!("PID {} — {}", p.pid, p.info)) + .unwrap_or_else(|| "Select a process".to_string()); + + let mut picked = selected; + egui::ComboBox::from_id_salt("process") + .width(360.0) + .selected_text(label) + .show_ui(ui, |ui| { + for proc in self.app.processes() { + ui.selectable_value( + &mut picked, + Some(proc.pid), + format!("PID {} — {}", proc.pid, proc.info), + ); + } + }); + + if let Some(pid) = picked { + if Some(pid) != selected { + self.app.select(pid); + } + } + } } diff --git a/injector/src/platform/discovery.rs b/injector/src/platform/discovery.rs new file mode 100644 index 0000000..b024879 --- /dev/null +++ b/injector/src/platform/discovery.rs @@ -0,0 +1,71 @@ +//! Cross-platform discovery of running Minecraft instances. + +use std::path::Path; + +use sysinfo::System; + +/// A Minecraft process the user can inject into. +#[derive(Debug, Clone)] +pub struct ProcessInfo { + /// Operating-system process id. + pub pid: u32, + /// Human-readable label — the game version when it can be parsed off the + /// command line, otherwise a generic description. + pub info: String, +} + +/// Scans running processes and returns every Java process that looks like a +/// Minecraft client, sorted by pid. +pub fn find_minecraft_processes() -> Vec { + let mut sys = System::new_all(); + sys.refresh_all(); + + let mut found: Vec = sys + .processes() + .iter() + .filter_map(|(pid, process)| { + let exe = process.name().to_string_lossy(); + let args: Vec = process + .cmd() + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + classify(&exe, &args).map(|info| ProcessInfo { + pid: pid.as_u32(), + info, + }) + }) + .collect(); + + found.sort_by_key(|p| p.pid); + found +} + +/// Pure classification: given a process executable name and its command-line +/// arguments, decide whether it is a Minecraft client and, if so, produce a +/// display label. Free of `sysinfo` types so it can be unit-tested. +fn classify(exe_name: &str, args: &[String]) -> Option { + if !is_java_executable(exe_name) { + return None; + } + if !args.join(" ").to_lowercase().contains("minecraft") { + return None; + } + Some(extract_version(args).unwrap_or_else(|| "Minecraft instance".to_string())) +} + +/// True if `exe_name` is a Java launcher binary, ignoring case and any +/// platform extension (`java`, `javaw`, `java.exe`, `javaw.exe`). +fn is_java_executable(exe_name: &str) -> bool { + let stem = Path::new(exe_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(exe_name); + stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw") +} + +/// Pulls the value following a `--version` argument, if present. +fn extract_version(args: &[String]) -> Option { + let idx = args.iter().position(|a| a == "--version")?; + args.get(idx + 1).cloned() +} diff --git a/injector/src/platform/linux.rs b/injector/src/platform/linux.rs new file mode 100644 index 0000000..915977d --- /dev/null +++ b/injector/src/platform/linux.rs @@ -0,0 +1,56 @@ +//! Linux agent injection via `ptrace`. + +use std::path::Path; + +use log::{info, warn}; +use proc_maps::get_process_maps; +use ptrace_inject::{Injector, Process}; + +use super::{AgentInjector, InjectError}; + +/// Injects shared libraries into a running process with `ptrace`. +#[derive(Debug, Default)] +pub struct PlatformInjector; + +impl AgentInjector for PlatformInjector { + fn is_agent_loaded(&self, pid: u32, agent_file: &str) -> bool { + match get_process_maps(pid as i32) { + Ok(maps) => maps + .iter() + .filter_map(|m| m.filename()) + .filter_map(|p| p.file_name()) + .any(|name| name == agent_file), + Err(e) => { + warn!("could not read memory maps for pid {pid}: {e}"); + false + } + } + } + + fn inject(&self, pid: u32, agent_path: &Path) -> Result<(), InjectError> { + if !Path::new(&format!("/proc/{pid}")).exists() { + return Err(InjectError::ProcessGone(pid)); + } + + let process = Process::get(pid).map_err(|e| InjectError::Attach { + pid, + source: e.to_string().into(), + })?; + let mut injector = Injector::attach(process).map_err(|e| InjectError::Attach { + pid, + source: e.to_string().into(), + })?; + injector + .inject(agent_path) + .map_err(|e| InjectError::Inject(e.to_string().into()))?; + + info!("agent injected into pid {pid}"); + Ok(()) + } +} + +/// Whether the process runs as root. +pub fn is_elevated() -> bool { + // SAFETY: `geteuid` takes no arguments and never fails. + unsafe { libc::geteuid() == 0 } +} diff --git a/injector/src/platform/macos.rs b/injector/src/platform/macos.rs new file mode 100644 index 0000000..921d3ca --- /dev/null +++ b/injector/src/platform/macos.rs @@ -0,0 +1,30 @@ +//! macOS — and any other non-Linux/Windows target — agent injection stub. +//! +//! Injection is not implemented here yet. The seam exists so that adding +//! real macOS support (for example via `task_for_pid` plus a Mach thread, +//! or `DYLD_INSERT_LIBRARIES` for launch-time injection) means editing only +//! this file; nothing else in the crate is platform-aware. + +use std::path::Path; + +use super::{AgentInjector, InjectError}; + +/// Placeholder injector that reports the platform as unsupported. +#[derive(Debug, Default)] +pub struct PlatformInjector; + +impl AgentInjector for PlatformInjector { + fn is_agent_loaded(&self, _pid: u32, _agent_file: &str) -> bool { + false + } + + fn inject(&self, _pid: u32, _agent_path: &Path) -> Result<(), InjectError> { + Err(InjectError::Unsupported) + } +} + +/// Whether the process runs as root (macOS is a Unix, so `geteuid` applies). +pub fn is_elevated() -> bool { + // SAFETY: `geteuid` takes no arguments and never fails. + unsafe { libc::geteuid() == 0 } +} diff --git a/injector/src/platform/mod.rs b/injector/src/platform/mod.rs index 6ef9e0e..9bc2679 100644 --- a/injector/src/platform/mod.rs +++ b/injector/src/platform/mod.rs @@ -1,61 +1,82 @@ -pub struct ProcessInfo { - pub pid: u32, - pub info: String, // Window Title (Windows) or Partial Arguments (Linux) +//! Platform abstraction for process discovery and agent injection. +//! +//! Each OS provides an [`AgentInjector`] implementation. Linux and Windows +//! are real; macOS is a stub today, but the seam lives here so that adding +//! real macOS support means editing only `macos.rs`. + +mod discovery; + +#[cfg(target_os = "linux")] +#[path = "linux.rs"] +mod imp; +#[cfg(target_os = "windows")] +#[path = "windows.rs"] +mod imp; +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[path = "macos.rs"] +mod imp; + +pub use discovery::{find_minecraft_processes, ProcessInfo}; +pub use imp::PlatformInjector; + +use std::error::Error; +use std::net::SocketAddr; +use std::path::Path; + +use thiserror::Error; + +/// A boxed, thread-safe source error. Lets each platform funnel its own +/// backend error type (ptrace, dll-syringe, …) into [`InjectError`] without +/// the abstraction depending on those crates. +pub type BoxError = Box; + +/// Everything that can go wrong while injecting the agent and triggering a +/// client load. `Send + Sync` so it can be returned from a worker thread. +#[derive(Debug, Error)] +pub enum InjectError { + /// The target process disappeared before injection could start. + #[error("process {0} is no longer running")] + ProcessGone(u32), + /// Attaching to the target process failed. + #[error("could not attach to process {pid}: {source}")] + Attach { pid: u32, source: BoxError }, + /// The platform backend failed to map the agent library in. + #[error("agent injection failed: {0}")] + Inject(BoxError), + /// A required shared library could not be found on disk. + #[error("library not found next to the injector or in the working directory: {0}")] + LibraryMissing(String), + /// The agent's command server could not be reached. + #[error("could not reach the agent on {addr}: {source}")] + Connect { + addr: SocketAddr, + source: std::io::Error, + }, + /// Writing the command to the agent failed. + #[error("failed to send the reload command: {0}")] + Send(std::io::Error), + /// An absolute path could not be resolved. + #[error("could not resolve an absolute library path: {0}")] + Path(std::io::Error), + /// Injection is not implemented for the host platform. + // Constructed only by the macOS / fallback backend, so it reads as dead + // code on Linux and Windows builds. + #[allow(dead_code)] + #[error("agent injection is not supported on this platform yet")] + Unsupported, +} + +/// Platform-specific agent injection. One implementation per OS. +pub trait AgentInjector { + /// Whether a shared library named `agent_file` is already mapped into + /// the process `pid`. + fn is_agent_loaded(&self, pid: u32, agent_file: &str) -> bool; + + /// Injects the agent shared library at `agent_path` into `pid`. + fn inject(&self, pid: u32, agent_path: &Path) -> Result<(), InjectError>; } -pub const AGENT_NAME: &str = "libagent_loader"; -pub const LIBRARY_NAME: &str = "libclient"; -pub const SOCKET_ADDRESS: SocketAddr = - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 7878); - -#[cfg(unix)] -mod unix; - -#[cfg(windows)] -mod windows; - -#[cfg(unix)] -pub use self::unix::inject; -#[cfg(windows)] -pub use self::windows::inject; - -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use sysinfo::System; - -pub fn find_minecraft_processes() -> Vec { - let mut sys = System::new_all(); - sys.refresh_all(); - let mut processes: Vec = Vec::new(); - - for (pid, process) in sys.processes() { - let name = process.name().to_string_lossy().to_lowercase(); - - if name == "java" || name == "javaw" || name == "javaw.exe" || name == "java.exe" { - let cmd: Vec = process - .cmd() - .iter() - .map(|arg| arg.to_string_lossy().to_string()) - .collect(); - let cmd_string = cmd.join(" "); - - if cmd_string.contains("minecraft") { - let info = if let Some(idx) = cmd.iter().position(|r| r.contains("--version")) { - cmd.get(idx + 1) - .cloned() - .unwrap_or_else(|| "Unknown Version".to_string()) - } else { - "Minecraft Instance".to_string() - }; - - processes.push(ProcessInfo { - pid: pid.as_u32(), - info, - }); - } - } - } - - processes.sort_by(|a, b| a.pid.cmp(&b.pid)); - - processes +/// Whether the current process holds the privileges injection requires. +pub fn is_elevated() -> bool { + imp::is_elevated() } diff --git a/injector/src/platform/unix.rs b/injector/src/platform/unix.rs deleted file mode 100644 index eab6892..0000000 --- a/injector/src/platform/unix.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::platform::{AGENT_NAME, LIBRARY_NAME, SOCKET_ADDRESS}; -use log::{error, info}; -use proc_maps::get_process_maps; -use ptrace_inject::{Injector, Process}; -use std::io::{Error, Write}; -use std::net::TcpStream; -use std::path::PathBuf; -use std::time::Duration; -use std::{path, thread}; - -pub fn inject(pid: u32) -> Result<(), Error> { - // First time: load the agent_loader - let loader_path = PathBuf::from(format!("{}.so", AGENT_NAME)); - let lib_path = PathBuf::from(format!("{}.so", LIBRARY_NAME)); - - if !find_library(pid, format!("{}.so", AGENT_NAME).as_str()) { - info!("Loading Agent Loader"); - - let proc = match Process::get(pid) { - Ok(p) => p, - Err(e) => { - error!("Failed to get Process for pid {}: {:?}", pid, e); - return Err(Error::new( - std::io::ErrorKind::Other, - format!("Process::get failed: {:?}", e), - )); - } - }; - - match Injector::attach(proc) { - Ok(mut injector) => match injector.inject(&loader_path) { - Ok(_) => { - info!( - "Successfully injected library: {}", - loader_path.to_string_lossy() - ); - } - Err(e) => { - error!("Injection failed: {:?}", e); - return Err(Error::new(std::io::ErrorKind::Other, e.to_string())); - } - }, - Err(e) => { - error!("Failed to attach to pid {}: {:?}", pid, e); - return Err(Error::new(std::io::ErrorKind::Other, e.to_string())); - } - } - - // Wait a moment for complete initialization - thread::sleep(Duration::from_millis(500)); - } else { - info!("Agent Loader already loaded"); - } - - // Send a reload command to agent_loader - match TcpStream::connect_timeout(&SOCKET_ADDRESS, Duration::from_secs(5)) { - Ok(mut stream) => { - let lib_abs_path = match path::absolute(&lib_path) { - Ok(p) => p, - Err(e) => { - error!("Unable to get absolute path: {:?}", e); - return Err(e); - } - }; - - info!("Connected to {}. Sending reload command", SOCKET_ADDRESS); - - let lib_abs_path = lib_abs_path.to_string_lossy(); - let lib_abs_path = lib_abs_path.trim_matches(|c| c == '"' || c == '\''); - // Send the command with the absolute path of the library - let command = format!("reload {}", lib_abs_path); - info!("Command: {}", command); - - if let Err(e) = stream.write(command.as_bytes()) { - error!("Unable to send reload command: {:?}", e); - } - } - Err(e) => { - error!("Unable to connect to server: {:?}", e); - } - } - - Ok(()) -} - -fn find_library(pid: u32, lib_name: &str) -> bool { - let maps = get_process_maps(pid as i32).ok(); - if maps.is_none() { - error!("Failed to get process maps"); - return false; - } - let maps = maps.unwrap(); - - for map in maps { - if let Some(path) = map.filename() { - if path.ends_with(lib_name) { - // Library loaded - return true; - } - } - } - false -} diff --git a/injector/src/platform/windows.rs b/injector/src/platform/windows.rs index 8ce67f6..672433f 100644 --- a/injector/src/platform/windows.rs +++ b/injector/src/platform/windows.rs @@ -1,86 +1,38 @@ -use crate::platform::{AGENT_NAME, LIBRARY_NAME, SOCKET_ADDRESS}; -use dll_syringe::process::{OwnedProcess, Process}; -use dll_syringe::Syringe; -use log::{error, info}; -use std::io::Write; -use std::net::TcpStream; -use std::path::PathBuf; -use std::process::Command; -use std::time::Duration; -use std::{io, path, thread}; - -pub fn inject(pid: u32) -> Result<(), io::Error> { - let target_process = OwnedProcess::from_pid(pid) - .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e))?; - - let syringe = Syringe::for_process(target_process); - - let loader_dll_name = format!("{}.dll", AGENT_NAME); - let loader_path = PathBuf::from(&loader_dll_name); - let lib_path = PathBuf::from(format!("{}.dll", LIBRARY_NAME)); +//! Windows agent injection via `dll-syringe`. - let abs_loader_path = std::fs::canonicalize(&loader_path) - .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; +use std::path::Path; - let is_loaded = syringe - .process() - .find_module_by_name(&loader_dll_name) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? - .is_some(); +use dll_syringe::process::{OwnedProcess, Process}; +use dll_syringe::Syringe; +use log::info; - // Check if agent_loader is already loaded - if !is_loaded { - info!( - "Injecting {} into PID {}...", - abs_loader_path.display(), - pid - ); +use super::{AgentInjector, InjectError}; - match syringe.inject(&abs_loader_path) { - Ok(module) => { - info!("The DLL is successfully injected! {:?}", module); - } - Err(e) => { - return Err(io::Error::new( - io::ErrorKind::Other, - format!("Injection failed: {}", e), - )); - } - } +/// Injects DLLs into a running process with `dll-syringe`. +#[derive(Debug, Default)] +pub struct PlatformInjector; - // Wait a moment for complete initialization - thread::sleep(Duration::from_millis(1000)); - } else { - info!("Agent Loader already loaded"); +impl AgentInjector for PlatformInjector { + fn is_agent_loaded(&self, pid: u32, agent_file: &str) -> bool { + let Ok(process) = OwnedProcess::from_pid(pid) else { + return false; + }; + matches!(process.find_module_by_name(agent_file), Ok(Some(_))) } - // Send a reload command to agent_loader - match TcpStream::connect_timeout(&SOCKET_ADDRESS, Duration::from_secs(5)) { - Ok(mut stream) => { - let lib_abs_path = match path::absolute(&lib_path) { - Ok(p) => p, - Err(e) => { - error!("Unable to get absolute path: {:?}", e); - return Err(e); - } - }; - - info!("Connected to {}. Sending reload command", SOCKET_ADDRESS); + fn inject(&self, pid: u32, agent_path: &Path) -> Result<(), InjectError> { + let process = OwnedProcess::from_pid(pid).map_err(|_| InjectError::ProcessGone(pid))?; + let syringe = Syringe::for_process(process); + syringe + .inject(agent_path) + .map_err(|e| InjectError::Inject(e.to_string().into()))?; - let lib_abs_path = lib_abs_path.to_string_lossy(); - let lib_abs_path = lib_abs_path.trim_matches(|c| c == '"' || c == '\''); - // Send the command with the absolute path of the library - let command = format!("reload {}", lib_abs_path); - info!("Command: {}", command); - - if let Err(e) = stream.write(command.as_bytes()) { - error!("Unable to send reload command: {:?}", e); - } - } - Err(e) => { - error!("Unable to connect to server: {:?}", e); - } + info!("agent injected into pid {pid}"); + Ok(()) } +} - Ok(()) +/// Whether the process runs with Administrator privileges. +pub fn is_elevated() -> bool { + is_elevated::is_elevated() } diff --git a/injector/src/tui.rs b/injector/src/tui.rs index 0449e0a..2f8faa9 100644 --- a/injector/src/tui.rs +++ b/injector/src/tui.rs @@ -86,31 +86,24 @@ pub fn run_tui() { AppState::Selecting => match key.code { KeyCode::Char('b') => state = AppState::Menu, KeyCode::Char('r') => processes = platform::find_minecraft_processes(), - KeyCode::Up => { - if selected_index > 0 { - selected_index -= 1; - } + KeyCode::Up if selected_index > 0 => { + selected_index -= 1; } - KeyCode::Down => { - if !processes.is_empty() && selected_index < processes.len() - 1 { - selected_index += 1; - } + KeyCode::Down + if !processes.is_empty() && selected_index < processes.len() - 1 => + { + selected_index += 1; } - KeyCode::Enter => { - if !processes.is_empty() { - let pid = processes[selected_index].pid; + KeyCode::Enter if !processes.is_empty() => { + let pid = processes[selected_index].pid; - // Renderizza stato injection - execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)) - .unwrap(); - println!("Injecting into PID {}...", pid); + // Render the injection status. + execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)).unwrap(); + println!("Injecting into PID {}...", pid); - match platform::inject(pid) { - Ok(_) => { - state = AppState::Done(format!("Injected into {}", pid)) - } - Err(e) => state = AppState::Error(e.to_string()), - } + match crate::inject::inject(pid) { + Ok(_) => state = AppState::Done(format!("Injected into {}", pid)), + Err(e) => state = AppState::Error(e.to_string()), } } _ => {} From 8ebeab8fa334484d9823e549be062a0b83059161 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 15:37:20 +0200 Subject: [PATCH 03/14] Redesign the injector GUI and rebuild the TUI A new egui layout with process cards and clear status states; the terminal UI is rebuilt on the shared core. --- injector/src/gui/mod.rs | 107 +++++++++++++++++++ injector/src/gui/theme.rs | 79 ++++++++++++++ injector/src/gui/widgets.rs | 153 +++++++++++++++++++++++++++ injector/src/main.rs | 100 +----------------- injector/src/tui.rs | 202 +++++++++++++++++------------------- 5 files changed, 439 insertions(+), 202 deletions(-) create mode 100644 injector/src/gui/mod.rs create mode 100644 injector/src/gui/theme.rs create mode 100644 injector/src/gui/widgets.rs diff --git a/injector/src/gui/mod.rs b/injector/src/gui/mod.rs new file mode 100644 index 0000000..a97c0e0 --- /dev/null +++ b/injector/src/gui/mod.rs @@ -0,0 +1,107 @@ +//! egui front-end for the injector. +//! +//! Thin layer over [`InjectorApp`]: it renders the app's state and forwards +//! user actions. All injection logic lives in `app`/`inject`. + +mod theme; +mod widgets; + +use eframe::{Frame, NativeOptions}; +use egui::{Align, Context, Layout, RichText, ScrollArea, ViewportBuilder}; + +use crate::app::InjectorApp; + +/// Launches the GUI. Blocks until the window is closed. +pub fn run() -> Result<(), eframe::Error> { + let native_options = NativeOptions { + viewport: ViewportBuilder::default() + .with_inner_size([480.0, 520.0]) + .with_min_inner_size([380.0, 420.0]), + ..Default::default() + }; + eframe::run_native( + "DarkClient Injector", + native_options, + Box::new(|cc| { + theme::apply(&cc.egui_ctx); + let mut app = InjectorApp::new(); + app.scan(); + Ok(Box::new(InjectorGui { app })) + }), + ) +} + +/// The eframe application — owns the [`InjectorApp`] and renders it. +struct InjectorGui { + app: InjectorApp, +} + +impl eframe::App for InjectorGui { + fn update(&mut self, ctx: &Context, _frame: &mut Frame) { + self.app.poll(); + + egui::TopBottomPanel::top("header") + .frame(theme::header_frame()) + .show(ctx, widgets::header); + + egui::TopBottomPanel::bottom("footer") + .frame(theme::footer_frame()) + .show(ctx, |ui| { + widgets::status_banner(ui, self.app.status()); + ui.add_space(8.0); + if widgets::inject_button(ui, &self.app).clicked() { + self.app.start_injection(); + } + }); + + egui::CentralPanel::default().show(ctx, |ui| self.process_list(ui)); + + // Keep repainting while a worker thread runs so its result and the + // status spinner stay live. + if self.app.is_busy() { + ctx.request_repaint(); + } + } +} + +impl InjectorGui { + /// Renders the scan row and the scrollable list of process cards. + fn process_list(&mut self, ui: &mut egui::Ui) { + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::tool_button(ui, "🔄 Scan", !self.app.is_busy()).clicked() { + self.app.scan(); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(format!("{} found", self.app.processes().len())) + .color(theme::Palette::TEXT_DIM), + ); + }); + }); + ui.add_space(8.0); + + if self.app.processes().is_empty() { + widgets::empty_state(ui); + return; + } + + let selected = self.app.selected_pid(); + let mut clicked = None; + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for proc in self.app.processes() { + let is_selected = selected == Some(proc.pid); + if widgets::process_card(ui, proc, is_selected).clicked() { + clicked = Some(proc.pid); + } + ui.add_space(6.0); + } + }); + + if let Some(pid) = clicked { + self.app.select(pid); + } + } +} diff --git a/injector/src/gui/theme.rs b/injector/src/gui/theme.rs new file mode 100644 index 0000000..e858b66 --- /dev/null +++ b/injector/src/gui/theme.rs @@ -0,0 +1,79 @@ +//! Visual theme — colour palette, widget styling and panel frames. + +use egui::{Color32, Context, Frame, Margin, Rounding, Stroke, Vec2}; + +/// The injector colour palette: a dark base with a single indigo accent. +pub struct Palette; + +impl Palette { + /// Window background. + pub const BG: Color32 = Color32::from_rgb(0x14, 0x15, 0x1a); + /// Header / footer panel background. + pub const PANEL: Color32 = Color32::from_rgb(0x1b, 0x1c, 0x24); + /// Resting process-card background. + pub const CARD: Color32 = Color32::from_rgb(0x23, 0x25, 0x2f); + /// Hovered process-card background. + pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2c, 0x2e, 0x3b); + /// Accent — selection, primary action, branding. + pub const ACCENT: Color32 = Color32::from_rgb(0x7c, 0x6c, 0xf6); + /// Tinted background of the selected card. + pub const ACCENT_SOFT: Color32 = Color32::from_rgb(0x2c, 0x2a, 0x48); + /// Primary text. + pub const TEXT: Color32 = Color32::from_rgb(0xe6, 0xe7, 0xee); + /// Secondary / muted text. + pub const TEXT_DIM: Color32 = Color32::from_rgb(0x8a, 0x8d, 0x9c); + /// Success. + pub const OK: Color32 = Color32::from_rgb(0x4a, 0xd2, 0x95); + /// In-progress / caution. + pub const WARN: Color32 = Color32::from_rgb(0xe6, 0xb4, 0x50); + /// Failure. + pub const ERR: Color32 = Color32::from_rgb(0xe5, 0x6b, 0x6b); +} + +/// Installs the dark theme on the egui context. Called once at startup. +pub fn apply(ctx: &Context) { + let mut style = (*ctx.style()).clone(); + let v = &mut style.visuals; + + v.dark_mode = true; + v.panel_fill = Palette::BG; + v.window_fill = Palette::BG; + v.extreme_bg_color = Palette::PANEL; + v.override_text_color = Some(Palette::TEXT); + v.selection.bg_fill = Palette::ACCENT.linear_multiply(0.45); + v.selection.stroke = Stroke::new(1.0, Palette::ACCENT); + v.hyperlink_color = Palette::ACCENT; + + let w = &mut v.widgets; + w.noninteractive.rounding = Rounding::same(8.0); + w.inactive.rounding = Rounding::same(8.0); + w.hovered.rounding = Rounding::same(8.0); + w.active.rounding = Rounding::same(8.0); + w.inactive.bg_fill = Palette::CARD; + w.inactive.weak_bg_fill = Palette::CARD; + w.inactive.fg_stroke = Stroke::new(1.0, Palette::TEXT); + w.hovered.bg_fill = Palette::CARD_HOVER; + w.hovered.weak_bg_fill = Palette::CARD_HOVER; + w.hovered.fg_stroke = Stroke::new(1.0, Palette::TEXT); + w.active.bg_fill = Palette::ACCENT; + w.active.weak_bg_fill = Palette::ACCENT; + + style.spacing.item_spacing = Vec2::new(8.0, 8.0); + style.spacing.button_padding = Vec2::new(14.0, 8.0); + + ctx.set_style(style); +} + +/// Frame for the top header panel. +pub fn header_frame() -> Frame { + Frame::none() + .fill(Palette::PANEL) + .inner_margin(Margin::symmetric(20.0, 16.0)) +} + +/// Frame for the bottom footer panel. +pub fn footer_frame() -> Frame { + Frame::none() + .fill(Palette::PANEL) + .inner_margin(Margin::symmetric(16.0, 14.0)) +} diff --git a/injector/src/gui/widgets.rs b/injector/src/gui/widgets.rs new file mode 100644 index 0000000..64bc4de --- /dev/null +++ b/injector/src/gui/widgets.rs @@ -0,0 +1,153 @@ +//! Reusable egui widgets for the injector GUI. + +use egui::{ + pos2, Align2, Button, Color32, FontId, Frame, Margin, Response, RichText, Sense, Spinner, + Stroke, Ui, Vec2, +}; + +use super::theme::Palette; +use crate::app::{InjectionStatus, InjectorApp}; +use crate::platform::ProcessInfo; + +/// Branding block rendered in the top header panel. +pub fn header(ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label( + RichText::new("DarkClient") + .size(22.0) + .strong() + .color(Palette::TEXT), + ); + ui.label( + RichText::new("INJECTOR") + .size(12.0) + .strong() + .color(Palette::ACCENT), + ); + }); + ui.label( + RichText::new("Pick a Minecraft instance and inject the client.") + .size(12.0) + .color(Palette::TEXT_DIM), + ); +} + +/// A small secondary button (used for "Scan"). +pub fn tool_button(ui: &mut Ui, label: &str, enabled: bool) -> Response { + let button = Button::new(RichText::new(label).color(Palette::TEXT)) + .fill(Palette::CARD) + .min_size(Vec2::new(0.0, 32.0)); + ui.add_enabled(enabled, button) +} + +/// A selectable process card. Returns the click response. +pub fn process_card(ui: &mut Ui, proc: &ProcessInfo, selected: bool) -> Response { + let (rect, response) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click()); + + let bg = if selected { + Palette::ACCENT_SOFT + } else if response.hovered() { + Palette::CARD_HOVER + } else { + Palette::CARD + }; + + let painter = ui.painter(); + painter.rect_filled(rect, 10.0, bg); + if selected { + painter.rect_stroke(rect, 10.0, Stroke::new(1.5, Palette::ACCENT)); + } + + let dot_color = if selected { + Palette::ACCENT + } else { + Palette::TEXT_DIM + }; + painter.circle_filled(rect.left_center() + Vec2::new(18.0, 0.0), 4.0, dot_color); + + let text_x = rect.left() + 34.0; + painter.text( + pos2(text_x, rect.center().y - 9.0), + Align2::LEFT_CENTER, + format!("PID {}", proc.pid), + FontId::proportional(15.0), + Palette::TEXT, + ); + painter.text( + pos2(text_x, rect.center().y + 9.0), + Align2::LEFT_CENTER, + &proc.info, + FontId::proportional(12.0), + Palette::TEXT_DIM, + ); + + response +} + +/// Placeholder shown when no Minecraft processes were found. +pub fn empty_state(ui: &mut Ui) { + ui.add_space(48.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("No Minecraft instances found") + .size(14.0) + .color(Palette::TEXT_DIM), + ); + ui.add_space(4.0); + ui.label( + RichText::new("Start the game, then press Scan.") + .size(12.0) + .color(Palette::TEXT_DIM), + ); + }); +} + +/// The status banner shown in the footer. +pub fn status_banner(ui: &mut Ui, status: &InjectionStatus) { + let (color, icon) = match status { + InjectionStatus::Idle => (Palette::TEXT_DIM, "●"), + InjectionStatus::Scanning => (Palette::ACCENT, "◌"), + InjectionStatus::Injecting(_) => (Palette::WARN, "◌"), + InjectionStatus::Done(_) => (Palette::OK, "✔"), + InjectionStatus::Failed(_) => (Palette::ERR, "✖"), + }; + let busy = matches!( + status, + InjectionStatus::Scanning | InjectionStatus::Injecting(_) + ); + + Frame::none() + .fill(Palette::BG) + .rounding(8.0) + .inner_margin(Margin::symmetric(12.0, 10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + if busy { + ui.add(Spinner::new().size(14.0).color(color)); + } else { + ui.label(RichText::new(icon).size(14.0).color(color)); + } + ui.label(RichText::new(status.message()).color(Palette::TEXT)); + }); + }); +} + +/// The full-width primary "Inject" button. +pub fn inject_button(ui: &mut Ui, app: &InjectorApp) -> Response { + let enabled = app.selected_pid().is_some() && !app.is_busy(); + let label = if app.is_busy() { + "Injecting…" + } else { + "Inject Client" + }; + let button = Button::new( + RichText::new(label) + .size(15.0) + .strong() + .color(Color32::WHITE), + ) + .fill(Palette::ACCENT) + .min_size(Vec2::new(ui.available_width(), 42.0)); + ui.add_enabled(enabled, button) +} diff --git a/injector/src/main.rs b/injector/src/main.rs index 535bb82..02f0ea0 100644 --- a/injector/src/main.rs +++ b/injector/src/main.rs @@ -1,14 +1,11 @@ mod app; +mod gui; mod inject; mod platform; mod tui; -use eframe::Frame; -use egui::Context; use log::{error, LevelFilter}; -use crate::app::InjectorApp; - fn main() { if !platform::is_elevated() { eprintln!("{}", elevation_hint()); @@ -24,27 +21,12 @@ fn main() { return; } - if let Err(e) = run_gui() { + if let Err(e) = gui::run() { error!("GUI terminated with an error: {e}"); eprintln!("Error: {e}"); } } -/// Launches the egui front-end. -fn run_gui() -> Result<(), eframe::Error> { - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([460.0, 340.0]) - .with_min_inner_size([320.0, 240.0]), - ..Default::default() - }; - eframe::run_native( - "DarkClient Injector", - native_options, - Box::new(|_cc| Ok(Box::new(InjectorGui::default()))), - ) -} - /// Platform-specific hint shown when the injector lacks the privileges it /// needs to attach to another process. fn elevation_hint() -> &'static str { @@ -57,81 +39,3 @@ fn elevation_hint() -> &'static str { "This program must run with root privileges: sudo ./injector" } } - -/// Thin egui wrapper around [`InjectorApp`]. The polished layout lands in a -/// dedicated `gui` module in the next refactor phase. -#[derive(Default)] -struct InjectorGui { - app: InjectorApp, -} - -impl eframe::App for InjectorGui { - fn update(&mut self, ctx: &Context, _frame: &mut Frame) { - self.app.poll(); - - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("DarkClient Injector"); - ui.add_space(8.0); - - ui.horizontal(|ui| { - let scan = egui::Button::new("🔄 Scan"); - if ui.add_enabled(!self.app.is_busy(), scan).clicked() { - self.app.scan(); - } - ui.label(format!("{} process(es)", self.app.processes().len())); - }); - - ui.add_space(8.0); - self.process_picker(ui); - ui.add_space(16.0); - - let can_inject = self.app.selected_pid().is_some() && !self.app.is_busy(); - if ui - .add_enabled(can_inject, egui::Button::new("💉 Inject")) - .clicked() - { - self.app.start_injection(); - } - - ui.separator(); - ui.label(self.app.status().message()); - }); - - // Keep repainting while a worker thread is running so its result is - // picked up promptly. - if self.app.is_busy() { - ctx.request_repaint(); - } - } -} - -impl InjectorGui { - /// Renders the process selection combo box. - fn process_picker(&mut self, ui: &mut egui::Ui) { - let selected = self.app.selected_pid(); - let label = selected - .and_then(|pid| self.app.processes().iter().find(|p| p.pid == pid)) - .map(|p| format!("PID {} — {}", p.pid, p.info)) - .unwrap_or_else(|| "Select a process".to_string()); - - let mut picked = selected; - egui::ComboBox::from_id_salt("process") - .width(360.0) - .selected_text(label) - .show_ui(ui, |ui| { - for proc in self.app.processes() { - ui.selectable_value( - &mut picked, - Some(proc.pid), - format!("PID {} — {}", proc.pid, proc.info), - ); - } - }); - - if let Some(pid) = picked { - if Some(pid) != selected { - self.app.select(pid); - } - } - } -} diff --git a/injector/src/tui.rs b/injector/src/tui.rs index 2f8faa9..2d4910b 100644 --- a/injector/src/tui.rs +++ b/injector/src/tui.rs @@ -1,121 +1,115 @@ -use crate::platform; -use crate::platform::ProcessInfo; +//! Terminal fallback front-end (`--tui`), built on the shared [`InjectorApp`]. + +use std::io::{stdout, Stdout}; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode}; use crossterm::style::{Color, ResetColor, SetForegroundColor}; -use crossterm::terminal::{Clear, ClearType}; -use crossterm::{ - cursor, - event::{self, Event, KeyCode}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, -}; -use std::io::stdout; - -enum AppState { - Menu, - Selecting, - Done(String), - Error(String), -} +use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::{cursor, execute}; -pub fn run_tui() { - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen, cursor::Hide).unwrap(); +use crate::app::{InjectionStatus, InjectorApp}; - println!("DarkClient Injector (TUI)"); - println!("Press 'f' to find the PID, 'i' to inject, 'q' to quit."); +/// How long each loop iteration waits for a key before redrawing — also the +/// cadence at which an in-flight injection result is picked up. +const POLL_INTERVAL: Duration = Duration::from_millis(120); - let mut state = AppState::Menu; - let mut processes: Vec = Vec::new(); - let mut selected_index = 0; +/// Runs the text UI until the user quits. +pub fn run_tui() { + let mut out = stdout(); + let _ = execute!(out, EnterAlternateScreen, cursor::Hide); - loop { - // Render Loop - execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)).unwrap(); + let mut app = InjectorApp::new(); + app.scan(); + let mut cursor_row = 0usize; - println!("=== DarkClient Injector (TUI) ==="); + loop { + app.poll(); + clamp_cursor(&app, &mut cursor_row); + render(&mut out, &app, cursor_row); - match &state { - AppState::Menu => { - println!("Press 's' to scan for Minecraft processes."); - println!("Press 'q' to quit."); + match read_key(POLL_INTERVAL) { + Some(KeyCode::Char('q')) | Some(KeyCode::Esc) => break, + Some(KeyCode::Char('s')) | Some(KeyCode::Char('r')) => { + app.scan(); + cursor_row = 0; } - AppState::Selecting => { - if processes.is_empty() { - println!("No processes found. Press 'r' to rescan or 'b' to back."); - } else { - println!("Select a process using Up/Down arrows and Enter:"); - for (i, proc) in processes.iter().enumerate() { - if i == selected_index { - execute!(stdout, SetForegroundColor(Color::Green)).unwrap(); - print!("> "); - } else { - print!(" "); - } - println!("PID: {} | Info: {}", proc.pid, proc.info); - execute!(stdout, ResetColor).unwrap(); - } - } - } - AppState::Done(msg) => { - execute!(stdout, SetForegroundColor(Color::Green)).unwrap(); - println!("SUCCESS: {}", msg); - execute!(stdout, ResetColor).unwrap(); - println!("Press any key to return to menu."); + Some(KeyCode::Up) => cursor_row = cursor_row.saturating_sub(1), + Some(KeyCode::Down) => { + let last = app.processes().len().saturating_sub(1); + cursor_row = (cursor_row + 1).min(last); } - AppState::Error(err) => { - execute!(stdout, SetForegroundColor(Color::Red)).unwrap(); - println!("ERROR: {}", err); - execute!(stdout, ResetColor).unwrap(); - println!("Press any key to return to menu."); + Some(KeyCode::Enter) => { + if let Some(proc) = app.processes().get(cursor_row) { + app.select(proc.pid); + app.start_injection(); + } } + _ => {} } + } - // Event Loop - if event::poll(std::time::Duration::from_millis(100)).unwrap() { - if let Event::Key(key) = event::read().unwrap() { - match state { - AppState::Menu => match key.code { - KeyCode::Char('q') => break, - KeyCode::Char('s') => { - processes = platform::find_minecraft_processes(); - selected_index = 0; - state = AppState::Selecting; - } - _ => {} - }, - AppState::Selecting => match key.code { - KeyCode::Char('b') => state = AppState::Menu, - KeyCode::Char('r') => processes = platform::find_minecraft_processes(), - KeyCode::Up if selected_index > 0 => { - selected_index -= 1; - } - KeyCode::Down - if !processes.is_empty() && selected_index < processes.len() - 1 => - { - selected_index += 1; - } - KeyCode::Enter if !processes.is_empty() => { - let pid = processes[selected_index].pid; - - // Render the injection status. - execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)).unwrap(); - println!("Injecting into PID {}...", pid); - - match crate::inject::inject(pid) { - Ok(_) => state = AppState::Done(format!("Injected into {}", pid)), - Err(e) => state = AppState::Error(e.to_string()), - } - } - _ => {} - }, - AppState::Done(_) | AppState::Error(_) => { - state = AppState::Menu; - } - } + let _ = execute!(out, LeaveAlternateScreen, cursor::Show); + println!("Exited DarkClient Injector."); +} + +/// Keeps the highlighted row within the current process list. +fn clamp_cursor(app: &InjectorApp, cursor_row: &mut usize) { + let len = app.processes().len(); + *cursor_row = (*cursor_row).min(len.saturating_sub(1)); +} + +/// Draws the whole screen. +fn render(out: &mut Stdout, app: &InjectorApp, cursor_row: usize) { + let _ = execute!(out, Clear(ClearType::All), cursor::MoveTo(0, 0)); + + println!("=== DarkClient Injector (TUI) ==="); + println!(); + + if app.processes().is_empty() { + println!(" No Minecraft instances found."); + } else { + println!(" Up/Down to choose, Enter to inject:"); + println!(); + for (row, proc) in app.processes().iter().enumerate() { + if row == cursor_row { + let _ = execute!(out, SetForegroundColor(Color::Green)); + println!(" > PID {} — {}", proc.pid, proc.info); + let _ = execute!(out, ResetColor); + } else { + println!(" PID {} — {}", proc.pid, proc.info); } } } - execute!(stdout, LeaveAlternateScreen, cursor::Show).unwrap(); - println!("Exited TUI."); + println!(); + let (color, line) = status_line(app.status()); + let _ = execute!(out, SetForegroundColor(color)); + println!(" {line}"); + let _ = execute!(out, ResetColor); + + println!(); + println!(" [s] scan [Enter] inject [q] quit"); +} + +/// Maps an [`InjectionStatus`] to a terminal colour and message. +fn status_line(status: &InjectionStatus) -> (Color, String) { + let color = match status { + InjectionStatus::Idle => Color::Grey, + InjectionStatus::Scanning => Color::Cyan, + InjectionStatus::Injecting(_) => Color::Yellow, + InjectionStatus::Done(_) => Color::Green, + InjectionStatus::Failed(_) => Color::Red, + }; + (color, status.message()) +} + +/// Waits up to `timeout` for a key press, returning its code if one arrived. +fn read_key(timeout: Duration) -> Option { + if event::poll(timeout).ok()? { + if let Ok(Event::Key(key)) = event::read() { + return Some(key.code); + } + } + None } From 41fc44ab2e80c31c3bddb1b5ee37caadefae685f Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 15:40:05 +0200 Subject: [PATCH 04/14] Split the agent_loader into focused modules Logging, JVM monitoring, the command server, command dispatch and the client-library lifecycle each move into their own module. --- Cargo.lock | 2 +- agent_loader/Cargo.toml | 6 +- agent_loader/src/command.rs | 36 ++++ agent_loader/src/jvm.rs | 89 ++++++++ agent_loader/src/lib.rs | 398 +++-------------------------------- agent_loader/src/library.rs | 132 ++++++++++++ agent_loader/src/logging.rs | 14 ++ agent_loader/src/platform.rs | 48 +++++ agent_loader/src/server.rs | 58 +++++ 9 files changed, 417 insertions(+), 366 deletions(-) create mode 100644 agent_loader/src/command.rs create mode 100644 agent_loader/src/jvm.rs create mode 100644 agent_loader/src/library.rs create mode 100644 agent_loader/src/logging.rs create mode 100644 agent_loader/src/platform.rs create mode 100644 agent_loader/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 5698d87..8447e3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,7 +133,7 @@ dependencies = [ "libc", "libloading 0.8.6", "log", - "simplelog", + "protocol", ] [[package]] diff --git a/agent_loader/Cargo.toml b/agent_loader/Cargo.toml index d7fe118..a9d9d25 100644 --- a/agent_loader/Cargo.toml +++ b/agent_loader/Cargo.toml @@ -15,7 +15,9 @@ used_linker = [] [dependencies] ctor.workspace = true log.workspace = true -simplelog.workspace = true jni.workspace = true -libc.workspace = true libloading = "0.8.0" +protocol = { path = "../protocol" } + +[target.'cfg(unix)'.dependencies] +libc.workspace = true diff --git a/agent_loader/src/command.rs b/agent_loader/src/command.rs new file mode 100644 index 0000000..086e9c5 --- /dev/null +++ b/agent_loader/src/command.rs @@ -0,0 +1,36 @@ +//! Command parsing and dispatch for a single client connection. + +use std::io::{BufRead, BufReader}; +use std::net::TcpStream; +use std::time::Duration; + +use log::{error, info}; +use protocol::Command; + +use crate::library; + +/// Maximum time spent waiting for a command line before giving up. +const READ_TIMEOUT: Duration = Duration::from_secs(5); + +/// Reads one command off `stream` and executes it. Runs on its own thread, +/// so a slow reload never blocks later connections. +pub fn handle_connection(stream: TcpStream) { + let _ = stream.set_read_timeout(Some(READ_TIMEOUT)); + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if let Err(e) = reader.read_line(&mut line) { + error!("failed to read command: {e}"); + return; + } + + match Command::decode(&line) { + Ok(Command::Reload(path)) => { + info!("reload command received: {}", path.display()); + if let Err(e) = library::reload(&path) { + error!("reload failed: {e}"); + } + } + Err(e) => error!("ignoring invalid command {:?}: {e}", line.trim()), + } +} diff --git a/agent_loader/src/jvm.rs b/agent_loader/src/jvm.rs new file mode 100644 index 0000000..143e653 --- /dev/null +++ b/agent_loader/src/jvm.rs @@ -0,0 +1,89 @@ +//! JVM discovery and health monitoring. + +use std::thread; +use std::time::Duration; + +use jni::sys::{jsize, JNI_GetCreatedJavaVMs, JNI_OK}; +use jni::JavaVM; +use log::info; + +use crate::is_running; + +/// How often to poll for / health-check the JVM. +const POLL_INTERVAL: Duration = Duration::from_millis(500); +/// Consecutive failed health checks before the JVM is declared dead. +const MAX_FAILURES: u32 = 3; + +/// Spawns the JVM health-monitor thread. +pub fn start_monitor() { + thread::spawn(monitor); +} + +/// Waits for the JVM, then polls its health until it disappears or the agent +/// shuts down. A dead JVM triggers [`crate::shutdown`]. +fn monitor() { + info!("jvm monitor started"); + + let Some(jvm) = wait_for_jvm() else { + return; + }; + info!("jvm detected; monitoring health"); + + let mut failures = 0u32; + while is_running() { + thread::sleep(POLL_INTERVAL); + + if jvm_healthy(&jvm) { + failures = 0; + continue; + } + + failures += 1; + info!("jvm health check failed ({failures}/{MAX_FAILURES})"); + if failures >= MAX_FAILURES { + info!("jvm appears to be gone; shutting the agent down"); + crate::shutdown(); + break; + } + } + + info!("jvm monitor stopped"); +} + +/// Blocks until a JVM exists in this process, or the agent shuts down. +fn wait_for_jvm() -> Option { + while is_running() { + if let Some(jvm) = get_jvm() { + return Some(jvm); + } + thread::sleep(POLL_INTERVAL); + } + None +} + +/// Whether the JVM still responds: a non-null pointer, an attachable thread +/// and a resolvable core class. +fn jvm_healthy(jvm: &JavaVM) -> bool { + if jvm.get_java_vm_pointer().is_null() { + return false; + } + match jvm.attach_current_thread_as_daemon() { + Ok(mut env) => env.find_class("java/lang/System").is_ok(), + Err(_) => false, + } +} + +/// Returns a handle to the JVM running in this process, if there is one. +fn get_jvm() -> Option { + let mut raw: *mut jni::sys::JavaVM = std::ptr::null_mut(); + let mut count: jsize = 0; + + // SAFETY: standard JNI invocation-API call; both out-parameters are + // valid for the duration of the call. + unsafe { + if JNI_GetCreatedJavaVMs(&mut raw, 1, &mut count) != JNI_OK || count == 0 { + return None; + } + JavaVM::from_raw(raw).ok() + } +} diff --git a/agent_loader/src/lib.rs b/agent_loader/src/lib.rs index 106afb1..f282b27 100644 --- a/agent_loader/src/lib.rs +++ b/agent_loader/src/lib.rs @@ -1,379 +1,51 @@ -extern crate ctor; -extern crate log; -extern crate simplelog; +//! Agent loader — a `cdylib` injected into the Minecraft JVM. +//! +//! On load it starts a JVM health monitor and a TCP command server, and it +//! owns the lifecycle of the client library (load / hot-reload / unload). + +mod command; +mod jvm; +mod library; +mod logging; +mod platform; +mod server; -use ctor::*; -use jni::sys::{jsize, JNI_GetCreatedJavaVMs, JNI_OK}; -use jni::JavaVM; -use libloading::{Library, Symbol}; -use log::{error, info, LevelFilter}; -use simplelog::{Config, WriteLogger}; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::net::TcpListener; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; -use std::{path, thread}; -// Global variable to keep track of the loaded library -static CLIENT_LIBRARY: OnceLock>> = OnceLock::new(); +use ctor::{ctor, dtor}; +use log::info; + +/// Cleared on shutdown; every background loop watches it to know when to stop. static RUNNING: AtomicBool = AtomicBool::new(true); -static JVM_MONITOR: OnceLock> = OnceLock::new(); -// Function called when the agent is loaded -#[no_mangle] +/// Whether the agent is still running. +pub(crate) fn is_running() -> bool { + RUNNING.load(Ordering::SeqCst) +} + +/// Runs automatically when the agent library is loaded into the JVM process. #[ctor] fn agent_onload() { - // Initialize the logger - WriteLogger::init( - LevelFilter::Debug, - Config::default(), - File::create("agent_loader.log").unwrap(), - ) - .unwrap(); - - info!("Agent Loader initialized"); - - // Initialize the global variable for the library - CLIENT_LIBRARY.get_or_init(|| Mutex::new(None)); + logging::init(); + info!("agent loader initialized"); - // Setup signal handlers for clean shutdown - setup_signal_handlers(); - - // Start monitoring the JVM - start_jvm_monitor(); - - // Start the socket server for commands - start_command_server(); + platform::install_signal_handlers(); + jvm::start_monitor(); + server::start(); } -// Function called when the agent is unloaded -#[no_mangle] +/// Runs automatically when the agent library is unloaded. #[dtor] fn agent_onunload() { - if !RUNNING.load(Ordering::SeqCst) { - return; - } - info!("Agent Loader unloading"); - RUNNING.store(false, Ordering::SeqCst); - - // Unload the client library if necessary - if let Some(mut guard) = CLIENT_LIBRARY.get().and_then(|m| m.lock().ok()) { - *guard = None; - } -} - -// Setup signal handlers to detect process termination -fn setup_signal_handlers() { - use std::sync::atomic::AtomicBool; - - static SIGNAL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); - - if SIGNAL_HANDLER_INSTALLED.swap(true, Ordering::SeqCst) { - return; // Already installed - } - - #[cfg(unix)] - { - extern "C" fn handle_signal(_: libc::c_int) { - info!("Received termination signal - cleaning up"); - agent_onunload(); - std::process::exit(0); - } - - unsafe { - libc::signal( - libc::SIGTERM, - handle_signal as *const () as libc::sighandler_t, - ); - libc::signal( - libc::SIGINT, - handle_signal as *const () as libc::sighandler_t, - ); - } - - info!("Signal handlers installed"); - } -} - -// Monitor the JVM status with multiple detection methods -fn start_jvm_monitor() { - let handle = thread::spawn(|| { - info!("JVM monitor thread started"); - - // Wait for JVM to be available - let jvm = loop { - if !RUNNING.load(Ordering::SeqCst) { - return; - } - - match get_jvm() { - Some(vm) => break vm, - None => { - thread::sleep(Duration::from_millis(500)); - } - } - }; - - info!("JVM detected, monitoring started"); - - // Monitor JVM health with multiple checks - let mut consecutive_failures = 0; - let max_failures = 3; - - while RUNNING.load(Ordering::SeqCst) { - thread::sleep(Duration::from_millis(500)); - - // Method 1: Try to attach to the JVM - let attach_ok = jvm.attach_current_thread_as_daemon().is_ok(); - - // Method 2: Check if we can access Java classes - let classes_ok = if attach_ok { - if let Ok(mut env) = jvm.attach_current_thread_as_daemon() { - env.find_class("java/lang/System").is_ok() - } else { - false - } - } else { - false - }; - - // Method 3: Check if the JVM pointer is still valid - let jvm_valid = { - let jvm_ptr = jvm.get_java_vm_pointer(); - !jvm_ptr.is_null() - }; - - if !attach_ok || !classes_ok || !jvm_valid { - consecutive_failures += 1; - info!( - "JVM health check failed ({}/{}): attach={}, classes={}, valid={}", - consecutive_failures, max_failures, attach_ok, classes_ok, jvm_valid - ); - - if consecutive_failures >= max_failures { - info!("JVM appears to be shutting down or dead"); - on_vm_death(); - break; - } - } else { - consecutive_failures = 0; - } - } - - info!("JVM monitor thread stopped"); - }); - - JVM_MONITOR.set(handle).ok(); + shutdown(); } -fn get_jvm() -> Option { - unsafe { - let mut java_vm: *mut jni::sys::JavaVM = std::ptr::null_mut(); - let mut count: jsize = 0; - - if JNI_GetCreatedJavaVMs(&mut java_vm, 1, &mut count) != JNI_OK || count == 0 { - return None; - } - - JavaVM::from_raw(java_vm).ok() +/// Idempotent shutdown: stops the background loops and drops the client +/// library. Safe to call from a signal handler, the JVM monitor or the dtor. +pub(crate) fn shutdown() { + if !RUNNING.swap(false, Ordering::SeqCst) { + return; // already shut down } -} - -fn on_vm_death() { - info!("VM death detected - initiating cleanup"); - agent_onunload(); -} - -// Function to load the client library -fn load_client_library(lib_path: &str) -> Result<(), Box> { - let client_path = PathBuf::from(lib_path); - - // Verify the path - info!("Verifying library path: {:?}", client_path); - - // Unload first - unload_client_library()?; - - if !client_path.exists() { - error!("Client library does not exist at path: {:?}", client_path); - if let Ok(abs_path) = path::absolute(&client_path) { - error!("Absolute path: {:?}", abs_path); - } - return Err(format!("Client library does not exist at path: {:?}", client_path).into()); - } - - info!("Loading client library: {:?}", client_path); - - // Get the lock on the global variable - let mut lib_guard = CLIENT_LIBRARY.get().unwrap().lock().unwrap(); - - // Unload the previous library if present - if lib_guard.is_some() { - *lib_guard = None; - } - - // Load the new library - let lib = unsafe { Library::new(&client_path)? }; - - // Find and call the initialization function - unsafe { - if let Ok(init_fn) = lib.get::>(b"initialize_client") { - info!("Calling initialization function"); - init_fn(); - } else { - info!("Initialization function not found, assuming self-initialization"); - } - } - - // Store the library - *lib_guard = Some(lib); - - info!("Client library loaded successfully"); - Ok(()) -} - -// Function to unload the client library -fn unload_client_library() -> Result<(), Box> { - info!("Unloading client library"); - - let mut lib_guard = CLIENT_LIBRARY.get().unwrap().lock().unwrap(); - - if let Some(lib) = lib_guard.as_ref() { - // Call the cleanup function if present - unsafe { - if let Ok(cleanup_fn) = lib.get::>(b"cleanup_client") { - info!("Calling cleanup function"); - cleanup_fn(); - } - } - - drop(lib_guard.take()); - - // Unload the library - *lib_guard = None; - - info!("Client library unloaded"); - } else { - info!("No client library loaded"); - } - - Ok(()) -} - -// Function to reload the client library -fn reload_client_library(lib_path: &str) -> Result<(), Box> { - info!("Reloading client library"); - - // Copy the file if necessary to avoid lock issues - let client_path = PathBuf::from(lib_path); - let filename = client_path - .file_name() - .ok_or("Invalid file name")? - .to_str() - .ok_or("Invalid file name (Unicode)")?; - - // Generate a timestamp for the temporary copy - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Temporary file name - let temp_filename = format!("temp_{}_{}", timestamp, filename); - let mut temp_path = std::env::temp_dir(); - temp_path.push(&temp_filename); - - // Copy the file - std::fs::copy(&client_path, &temp_path)?; - info!("Library copied to: {:?}", temp_path); - - let temp_client = - path::absolute(&temp_path).map_err(|e| format!("Unable to get absolute path: {:?}", e))?; - let temp_client = format!("{:?}", temp_client.to_string_lossy()); - let temp_client = temp_client.as_str(); - let temp_client = temp_client.trim_matches(|c| c == '"' || c == '\''); - - // Cleanup of temporary files (optional, can be executed in a separate thread) - thread::spawn(move || { - thread::sleep(Duration::from_secs(5)); // Wait a bit before deleting - if let Err(e) = std::fs::remove_file(&temp_path) { - error!("Unable to delete temporary file: {:?}", e); - } - }); - - // Load the new copy - load_client_library(temp_client)?; - - // Wait a bit to ensure all resources are released - thread::sleep(Duration::from_millis(100)); - - info!("Client library reloaded successfully"); - Ok(()) -} - -// Start a socket server to listen for commands -fn start_command_server() { - thread::spawn(move || { - let addr = "127.0.0.1:7878"; - let listener = match TcpListener::bind(addr) { - Ok(listener) => { - info!("Listening on {}", addr); - listener - } - Err(e) => { - error!("Unable to bind to {}: {}", addr, e); - return; - } - }; - - // Set the socket to non-blocking mode - listener.set_nonblocking(true).unwrap(); - - while RUNNING.load(Ordering::SeqCst) { - // Check for incoming connections - match listener.accept() { - Ok((stream, _)) => { - let mut reader = BufReader::new(stream); - let mut line = String::new(); - - if reader.read_line(&mut line).is_ok() { - let line = line.trim(); - let parts: Vec<&str> = line.splitn(2, ' ').collect(); - - match parts.first() { - Some(&"reload") => { - if let Some(path) = parts.get(1) { - info!("Reload command received with path: {}", path); - - if let Err(e) = reload_client_library(path) { - error!("Error during reload: {}", e); - } - } else { - error!("Reload command received without path!"); - } - } - Some(other) => { - error!("Unknown command: {}", other); - } - None => { - error!("Empty command received"); - } - } - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // No connection available, wait a bit - thread::sleep(Duration::from_millis(100)); - } - Err(e) => { - error!("Error while accepting connection: {}", e); - // Short pause to avoid infinite loops in case of errors - thread::sleep(Duration::from_millis(1000)); - } - } - } - }); + info!("agent loader shutting down"); + library::shutdown(); } diff --git a/agent_loader/src/library.rs b/agent_loader/src/library.rs new file mode 100644 index 0000000..79261d7 --- /dev/null +++ b/agent_loader/src/library.rs @@ -0,0 +1,132 @@ +//! Client-library lifecycle: load, hot-reload and drop. + +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use libloading::{Library, Symbol}; +use log::{error, info, warn}; + +/// Internal result type — agent-side errors are only ever logged. +type Result = std::result::Result>; + +/// The currently loaded client library, if any. +static CLIENT_LIBRARY: OnceLock>> = OnceLock::new(); + +/// How long a temporary library copy is kept before deletion. +const TEMP_CLEANUP_DELAY: Duration = Duration::from_secs(5); + +/// Locks the client-library slot, recovering from a poisoned mutex rather +/// than panicking — a poisoned lock here would otherwise wedge the agent. +fn lock() -> MutexGuard<'static, Option> { + CLIENT_LIBRARY + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| { + error!("client-library mutex was poisoned; recovering"); + poisoned.into_inner() + }) +} + +/// Hot-reloads the client library from `source`. +/// +/// The file is copied to a uniquely named temporary path first, so the +/// original can be rebuilt while a copy stays mapped (this matters on +/// Windows, where a loaded DLL is locked on disk). +pub fn reload(source: &Path) -> Result<()> { + info!("reloading client library from {}", source.display()); + let temp = copy_to_temp(source)?; + schedule_temp_cleanup(temp.clone()); + load(&temp) +} + +/// Drops the client library without calling `cleanup_client`. Used during +/// agent teardown, where the JVM is already going away. +pub fn shutdown() { + if lock().take().is_some() { + info!("client library dropped"); + } +} + +/// Replaces the loaded client library with the one at `path`. +/// +/// The previous client is cleaned up and dropped *before* the new one is +/// initialized, so the old client's hooks are gone before the new client +/// installs its own. The whole swap holds the lock, so concurrent reloads +/// serialize safely. +fn load(path: &Path) -> Result<()> { + if !path.exists() { + return Err(format!("client library not found at {}", path.display()).into()); + } + + let mut slot = lock(); + + if let Some(old) = slot.take() { + // SAFETY: `cleanup_client`, if exported, is an `extern "C" fn()`. + unsafe { call_export(&old, b"cleanup_client") }; + drop(old); + info!("previous client library unloaded"); + } + + info!("loading client library {}", path.display()); + // SAFETY: loading a trusted shared library built by this project. + let library = unsafe { Library::new(path)? }; + // SAFETY: `initialize_client`, if exported, is an `extern "C" fn()`. + unsafe { call_export(&library, b"initialize_client") }; + + *slot = Some(library); + info!("client library loaded"); + Ok(()) +} + +/// Calls an exported `extern "C" fn()` by name, if the library exports it. +/// +/// # Safety +/// The named symbol, if present, must be an `extern "C" fn()`. +unsafe fn call_export(library: &Library, symbol: &[u8]) { + match library.get::>(symbol) { + Ok(func) => { + let name = String::from_utf8_lossy(symbol); + info!("calling {name}"); + func(); + } + Err(_) => { + let name = String::from_utf8_lossy(symbol); + info!("{name} not exported; skipping"); + } + } +} + +/// Copies `source` to a uniquely named file in the system temp directory. +fn copy_to_temp(source: &Path) -> Result { + let file_name = source + .file_name() + .ok_or("client library path has no file name")? + .to_string_lossy() + .into_owned(); + + // Nanosecond stamp keeps reloads within the same second from colliding. + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + + let mut temp = std::env::temp_dir(); + temp.push(format!("dark_client_{stamp}_{file_name}")); + + std::fs::copy(source, &temp)?; + info!("client library copied to {}", temp.display()); + Ok(temp) +} + +/// Spawns a thread that deletes a temporary library copy after a short delay. +fn schedule_temp_cleanup(temp: PathBuf) { + thread::spawn(move || { + thread::sleep(TEMP_CLEANUP_DELAY); + if let Err(e) = std::fs::remove_file(&temp) { + warn!("could not remove temporary library {}: {e}", temp.display()); + } + }); +} diff --git a/agent_loader/src/logging.rs b/agent_loader/src/logging.rs new file mode 100644 index 0000000..059b6ea --- /dev/null +++ b/agent_loader/src/logging.rs @@ -0,0 +1,14 @@ +//! Agent logger setup. + +use log::LevelFilter; + +/// Path of the agent's log file, created in the JVM's working directory. +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}"); + } +} diff --git a/agent_loader/src/platform.rs b/agent_loader/src/platform.rs new file mode 100644 index 0000000..40db5b2 --- /dev/null +++ b/agent_loader/src/platform.rs @@ -0,0 +1,48 @@ +//! Platform-specific agent hooks — currently just Unix signal handling. + +/// Installs handlers that shut the agent down cleanly on process +/// termination. A no-op on platforms without Unix signals. +pub fn install_signal_handlers() { + #[cfg(unix)] + unix::install(); +} + +#[cfg(unix)] +mod unix { + use std::sync::atomic::{AtomicBool, Ordering}; + + use log::info; + + /// Guards against installing the handlers more than once. + static INSTALLED: AtomicBool = AtomicBool::new(false); + + /// Installs `SIGTERM` / `SIGINT` handlers. + pub fn install() { + if INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + + // The handler runs the normal teardown and exits. This mirrors the + // process shutdown path; it is not strictly async-signal-safe, but + // the process is terminating regardless. + extern "C" fn handle_signal(_sig: libc::c_int) { + crate::shutdown(); + std::process::exit(0); + } + + // SAFETY: `signal` is a standard libc call; `handle_signal` has the + // required `extern "C" fn(c_int)` signature. + unsafe { + libc::signal( + libc::SIGTERM, + handle_signal as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGINT, + handle_signal as *const () as libc::sighandler_t, + ); + } + + info!("signal handlers installed"); + } +} diff --git a/agent_loader/src/server.rs b/agent_loader/src/server.rs new file mode 100644 index 0000000..e0ece58 --- /dev/null +++ b/agent_loader/src/server.rs @@ -0,0 +1,58 @@ +//! TCP command server. + +use std::io::ErrorKind; +use std::net::TcpListener; +use std::thread; +use std::time::Duration; + +use log::{error, info}; +use protocol::SOCKET_ADDR; + +use crate::{command, is_running}; + +/// Idle pause between `accept` polls while no client is connecting. +const ACCEPT_IDLE: Duration = Duration::from_millis(100); +/// Back-off after an unexpected `accept` error, to avoid a busy loop. +const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_secs(1); + +/// Spawns the command-server thread. +pub fn start() { + thread::spawn(run); +} + +/// Accepts connections until the agent shuts down, handling each on its own +/// thread. +fn run() { + let listener = match TcpListener::bind(SOCKET_ADDR) { + Ok(listener) => { + info!("command server listening on {SOCKET_ADDR}"); + listener + } + Err(e) => { + error!("could not bind command server to {SOCKET_ADDR}: {e}"); + return; + } + }; + + if let Err(e) = listener.set_nonblocking(true) { + error!("could not make the command socket non-blocking: {e}"); + return; + } + + while is_running() { + match listener.accept() { + Ok((stream, _)) => { + thread::spawn(move || command::handle_connection(stream)); + } + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + thread::sleep(ACCEPT_IDLE); + } + Err(e) => { + error!("command server accept error: {e}"); + thread::sleep(ACCEPT_ERROR_BACKOFF); + } + } + } + + info!("command server stopped"); +} From b5352fe4f2e8cf778cc751aa0a509c2e73537d18 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 15:55:10 +0200 Subject: [PATCH 05/14] Replace the client singletons with one global state Drop the per-type instance() singletons and their dead Arcs; the mapping is reached through free accessors instead of being threaded through every constructor. --- Cargo.lock | 1 + client/Cargo.toml | 1 + client/src/client.rs | 94 ------------ client/src/graphic/esp.rs | 192 +++++++++++++++++++------ client/src/graphic/hook.rs | 22 ++- client/src/graphic/hud.rs | 47 ++++-- client/src/graphic/input.rs | 11 +- client/src/graphic/menu.rs | 52 +++++-- client/src/graphic/notification.rs | 14 +- client/src/graphic/ui_engine.rs | 3 +- client/src/lib.rs | 69 ++++----- client/src/mapping/class.rs | 6 +- client/src/mapping/class_type.rs | 4 +- client/src/mapping/client/gamemode.rs | 9 +- client/src/mapping/client/minecraft.rs | 70 ++++----- client/src/mapping/client/window.rs | 13 +- client/src/mapping/client/world.rs | 22 ++- client/src/mapping/entity/mod.rs | 37 ++--- client/src/mapping/entity/player.rs | 32 ++--- client/src/mapping/java/iterable.rs | 11 +- client/src/mapping/java/iterator.rs | 15 +- client/src/mapping/loader.rs | 6 +- client/src/mapping/mod.rs | 112 +++++++-------- client/src/mapping/reflect.rs | 8 +- client/src/module/combat/aimbot.rs | 9 +- client/src/module/combat/aura.rs | 8 +- client/src/module/movement/fly.rs | 6 +- client/src/state.rs | 128 +++++++++++++++++ 28 files changed, 560 insertions(+), 442 deletions(-) delete mode 100644 client/src/client.rs create mode 100644 client/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 8447e3e..a342c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -673,6 +673,7 @@ dependencies = [ "serde", "serde_json", "simplelog", + "thiserror 2.0.17", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index ba251d4..b060d3e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -14,6 +14,7 @@ simplelog.workspace = true jni.workspace = true serde.workspace = true anyhow.workspace = true +thiserror.workspace = true libc.workspace = true egui_glow = "0.29.0" glow = "0.14.0" diff --git a/client/src/client.rs b/client/src/client.rs deleted file mode 100644 index ed03887..0000000 --- a/client/src/client.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::module::{Module, ModuleType}; -use jni::sys::{jsize, JNI_GetCreatedJavaVMs, JNI_OK}; -use jni::{JNIEnv, JavaVM}; -use log::error; -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; - -#[derive(Debug)] -pub struct DarkClient { - pub(crate) jvm: Arc, - pub(crate) modules: Arc>>>>, -} - -impl DarkClient { - pub fn instance() -> &'static DarkClient { - static INSTANCE: OnceLock> = OnceLock::new(); - - INSTANCE.get_or_init(|| unsafe { - Arc::new(DarkClient::new().unwrap_or_else(|e| { - error!("Failed to create DarkClient: {}", e); - panic!("Failed to create DarkClient: {}", e); - })) - }) - } - - pub unsafe fn new() -> anyhow::Result { - let mut java_vm: *mut jni::sys::JavaVM = std::ptr::null_mut(); - let mut count: jsize = 0; - - if JNI_GetCreatedJavaVMs(&mut java_vm, 1, &mut count) != JNI_OK || count == 0 { - return Err(anyhow::anyhow!("Failed to get Java VMs")); - } - - let java_vm: Arc = Arc::new(match JavaVM::from_raw(java_vm) { - Ok(jvm) => jvm, - Err(_) => return Err(anyhow::anyhow!("Could not get JavaVM")), - }); - - Ok(DarkClient { - jvm: java_vm, - modules: Arc::new(RwLock::new(HashMap::new())), - }) - } - - pub fn get_env(&'_ self) -> jni::errors::Result> { - //self.jvm.attach_current_thread() - self.jvm.attach_current_thread_as_daemon() - } - - pub fn register_module(&self, module: M) - where - M: Module + Send + Sync + 'static, - { - let module: ModuleType = Box::new(module); - let module_name = module.get_module_data().name.clone(); - - self.modules - .write() - .unwrap() - .insert(module_name, Arc::new(Mutex::new(module))); - } - - pub fn tick(&self) { - let modules = self.modules.read().unwrap(); - for module in modules.values() { - let module = module.lock().unwrap(); - if module.get_module_data().enabled { - match module.on_tick() { - Ok(_) => {} - Err(e) => { - error!( - "Failed to tick module {}, disabling. {}", - module.get_module_data().name, - e - ); - match module.on_stop() { - Ok(_) => {} - Err(_) => { - error!( - "Failed to stop module {} after an error when ticking", - module.get_module_data().name - ); - panic!( - "Failed to stop module {} after an error when ticking", - module.get_module_data().name - ); - } - } - } - } - } - } - } -} diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index ff4a40b..f3ceda8 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -17,10 +17,9 @@ //! The chest scan is heavier (it walks loaded chunks) so it runs even rarer, //! every [`CHEST_SCAN_INTERVAL`]. -use crate::client::DarkClient; -use crate::mapping::client::minecraft::Minecraft; use crate::mapping::{FieldType, Mapping, MinecraftClassType as Cls}; use crate::module::ModuleSetting; +use crate::state::{client, mapping, minecraft}; use egui::{ pos2, vec2, Align2, Color32, Context, FontId, Id, LayerId, Order, Painter, Pos2, Rect, Rounding, Stroke, @@ -293,7 +292,7 @@ fn read_config() -> EspConfig { }, }; - let registry = match DarkClient::instance().modules.read() { + let registry = match client().modules.read() { Ok(guard) => guard, Err(_) => return cfg, }; @@ -355,7 +354,10 @@ pub fn draw(ctx: &Context) { } let now = Instant::now(); - if state.last_gather.map_or(true, |t| now - t >= GATHER_INTERVAL) { + if state + .last_gather + .map_or(true, |t| now - t >= GATHER_INTERVAL) + { gather(&mut state, &cfg, now); } @@ -394,7 +396,7 @@ fn interp_factor(state: &EspState, now: Instant) -> f64 { /// Resolves the current camera into a [`View`], caching the JNI handles. fn read_view(state: &mut EspState, ctx: &Context) -> Option { - let mapping = Minecraft::instance().get_mapping(); + let mapping = mapping(); if state.camera.is_none() { match init_camera(mapping) { @@ -421,13 +423,19 @@ fn read_view(state: &mut EspState, ctx: &Context) -> Option { Cls::Camera, cam.as_obj(), "position", - FieldType::Object(Cls::Vec3, mapping), + 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()?, + 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)? @@ -468,7 +476,7 @@ 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::instance(); + let mc = minecraft(); let mut env = mapping.get_env()?; env.with_local_frame(16, |_| -> anyhow::Result { let renderer = mapping @@ -476,7 +484,7 @@ fn init_camera(mapping: &Mapping) -> anyhow::Result { Cls::Minecraft, mc.jni_ref.as_obj(), "gameRenderer", - FieldType::Object(Cls::GameRenderer, mapping), + FieldType::Object(Cls::GameRenderer), )? .l()?; let camera = mapping @@ -503,7 +511,7 @@ fn read_fov(mapping: &Mapping) -> f64 { /// while flying and ≈×1.15 while sprinting — the constants from /// `Player.getFieldOfViewModifier`. fn fov_modifier(mapping: &Mapping) -> f64 { - let player = match Minecraft::instance().get_player() { + let player = match minecraft().get_player() { Ok(player) => player, Err(_) => return 1.0, }; @@ -525,7 +533,12 @@ fn fov_modifier(mapping: &Mapping) -> f64 { } let sprinting = mapping - .call_method(Cls::Entity, player.entity.jni_ref.as_obj(), "isSprinting", &[]) + .call_method( + Cls::Entity, + player.entity.jni_ref.as_obj(), + "isSprinting", + &[], + ) .ok() .and_then(|value| value.z().ok()) .unwrap_or(false); @@ -538,7 +551,7 @@ fn fov_modifier(mapping: &Mapping) -> f64 { /// Reads the raw FOV slider value from the game options. fn read_option_fov(mapping: &Mapping) -> anyhow::Result { - let mc = Minecraft::instance(); + let mc = minecraft(); let mut env = mapping.get_env()?; env.with_local_frame(16, |_| -> anyhow::Result { let options = mapping @@ -546,7 +559,7 @@ fn read_option_fov(mapping: &Mapping) -> anyhow::Result { Cls::Minecraft, mc.jni_ref.as_obj(), "options", - FieldType::Object(Cls::Options, mapping), + FieldType::Object(Cls::Options), )? .l()?; let option = mapping @@ -554,7 +567,7 @@ fn read_option_fov(mapping: &Mapping) -> anyhow::Result { Cls::Options, &options, "fov", - FieldType::Object(Cls::OptionInstance, mapping), + FieldType::Object(Cls::OptionInstance), )? .l()?; let value = mapping @@ -573,7 +586,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(Minecraft::instance().get_mapping()); + state.target_fov = read_fov(mapping()); if cfg.player.enabled || cfg.mob.enabled { let mut range = 0.0_f32; @@ -618,8 +631,8 @@ fn gather_entities( want_player: bool, want_mob: bool, ) -> anyhow::Result> { - let mc = Minecraft::instance(); - let mapping = mc.get_mapping(); + let mc = minecraft(); + let mapping = mapping(); // Carry positions forward so the new snapshot can interpolate from them. let prev_pos: HashMap = previous.iter().map(|e| (e.id, e.pos)).collect(); @@ -634,7 +647,14 @@ fn gather_entities( .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 }) + ( + id, + V3 { + x: pos.0, + y: pos.1, + z: pos.2, + }, + ) }; let level = mapping @@ -642,7 +662,7 @@ fn gather_entities( Cls::Minecraft, mc.jni_ref.as_obj(), "level", - FieldType::Object(Cls::Level, mapping), + FieldType::Object(Cls::Level), )? .l()?; if level.is_null() { @@ -670,8 +690,14 @@ fn gather_entities( .call_method(Cls::Iterator, &iterator, "next", &[])? .l()?; Ok(process_entity( - mapping, &entity, local_id, player_pos, range_sq, want_player, - want_mob, &prev_pos, + mapping, + &entity, + local_id, + player_pos, + range_sq, + want_player, + want_mob, + &prev_pos, )) })?; if let Some(target) = target { @@ -772,9 +798,21 @@ fn read_vec3(mapping: &Mapping, obj: &JObject, method: &str) -> Option { .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()?, + 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()?, }) } @@ -809,8 +847,8 @@ fn read_health(mapping: &Mapping, entity: &JObject) -> anyhow::Result<(f32, f32) /// Scans loaded chunks around the player for container block entities. fn gather_chests() -> anyhow::Result> { - let mc = Minecraft::instance(); - let mapping = mc.get_mapping(); + let mc = minecraft(); + let mapping = mapping(); let mut env = mapping.get_env()?; let mut out: Vec = Vec::new(); @@ -821,7 +859,7 @@ fn gather_chests() -> anyhow::Result> { Cls::Minecraft, mc.jni_ref.as_obj(), "level", - FieldType::Object(Cls::Level, mapping), + FieldType::Object(Cls::Level), )? .l()?; if level.is_null() { @@ -918,7 +956,13 @@ fn block_entity_pos(mapping: &Mapping, block_entity: &JObject) -> Option { .l() .ok()?; let axis = |name: &str| -> Option { - Some(mapping.call_method(Cls::Vec3i, &block_pos, name, &[]).ok()?.i().ok()? as f64) + Some( + mapping + .call_method(Cls::Vec3i, &block_pos, name, &[]) + .ok()? + .i() + .ok()? as f64, + ) }; Some(V3 { x: axis("getX")?, @@ -931,28 +975,74 @@ fn block_entity_pos(mapping: &Mapping, block_entity: &JObject) -> Option { /// The 12 edges of a box, as index pairs into an 8-corner array. const EDGES: [(usize, usize); 12] = [ - (0, 1), (1, 2), (2, 3), (3, 0), // bottom - (4, 5), (5, 6), (6, 7), (7, 4), // top - (0, 4), (1, 5), (2, 6), (3, 7), // verticals + (0, 1), + (1, 2), + (2, 3), + (3, 0), // bottom + (4, 5), + (5, 6), + (6, 7), + (7, 4), // top + (0, 4), + (1, 5), + (2, 6), + (3, 7), // verticals ]; /// The 8 corners of an axis-aligned box `[min, max]`. fn box_corners(min: V3, max: V3) -> [V3; 8] { [ - V3 { x: min.x, y: min.y, z: min.z }, - V3 { x: max.x, y: min.y, z: min.z }, - V3 { x: max.x, y: min.y, z: max.z }, - V3 { x: min.x, y: min.y, z: max.z }, - V3 { x: min.x, y: max.y, z: min.z }, - V3 { x: max.x, y: max.y, z: min.z }, - V3 { x: max.x, y: max.y, z: max.z }, - V3 { x: min.x, y: max.y, z: max.z }, + V3 { + x: min.x, + y: min.y, + z: min.z, + }, + V3 { + x: max.x, + y: min.y, + z: min.z, + }, + V3 { + x: max.x, + y: min.y, + z: max.z, + }, + V3 { + x: min.x, + y: min.y, + z: max.z, + }, + V3 { + x: min.x, + y: max.y, + z: min.z, + }, + V3 { + x: max.x, + y: max.y, + z: min.z, + }, + V3 { + x: max.x, + y: max.y, + z: max.z, + }, + V3 { + x: min.x, + y: max.y, + z: max.z, + }, ] } /// Draws a wireframe box and returns its 2D screen bounds (for label /// placement), or `None` if no corner is in front of the camera. -fn draw_wire_box(painter: &Painter, view: &View, corners: &[V3; 8], color: Color32) -> Option { +fn draw_wire_box( + painter: &Painter, + view: &View, + corners: &[V3; 8], + color: Color32, +) -> Option { let projected: [Option; 8] = std::array::from_fn(|i| view.project(corners[i])); let stroke = Stroke::new(LINE_WIDTH, color); @@ -981,8 +1071,16 @@ fn draw_entity(painter: &Painter, view: &View, entity: &EntityTarget, t: f64, cf let feet = entity.prev.lerp(entity.pos, t); let half = entity.width * 0.5; let corners = box_corners( - V3 { x: feet.x - half, y: feet.y, z: feet.z - half }, - V3 { x: feet.x + half, y: feet.y + entity.height, z: feet.z + half }, + V3 { + x: feet.x - half, + y: feet.y, + z: feet.z - half, + }, + V3 { + x: feet.x + half, + y: feet.y + entity.height, + z: feet.z + half, + }, ); let rect = match draw_wire_box(painter, view, &corners, icfg.color) { @@ -1017,7 +1115,11 @@ fn draw_entity(painter: &Painter, view: &View, entity: &EntityTarget, t: f64, cf fn draw_chest(painter: &Painter, view: &View, chest: &ChestTarget, cfg: &EspConfig) { let corners = box_corners( chest.pos, - V3 { x: chest.pos.x + 1.0, y: chest.pos.y + 1.0, z: chest.pos.z + 1.0 }, + V3 { + x: chest.pos.x + 1.0, + y: chest.pos.y + 1.0, + z: chest.pos.z + 1.0, + }, ); let rect = match draw_wire_box(painter, view, &corners, cfg.chest.color) { Some(rect) => rect, diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index 42dc316..83ac139 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -4,9 +4,7 @@ //! client ticks exactly once per frame. OpenGL entry-point resolution — the //! loader behind the `gl` and `glow` bindings — lives here too. -use crate::client::DarkClient; -use crate::mapping::client::minecraft::Minecraft; -use crate::{gl, RUNNING}; +use crate::{gl, state, RUNNING}; use ilhook::x64::{CallbackOption, HookFlags, HookPoint, HookType, Hooker, Registers}; use log::info; use std::ffi::c_void; @@ -100,7 +98,7 @@ unsafe fn on_frame() { /// Renders the egui overlay for the current frame. unsafe fn render_overlay() { - if Minecraft::instance().get_player().is_err() { + if state::minecraft().get_player().is_err() { return; } @@ -113,15 +111,12 @@ unsafe fn render_overlay() { /// Detects a new game tick by watching the player's tick counter, and ticks /// every enabled module when one is observed. fn check_tick() { - let client = DarkClient::instance(); - // Attach as a daemon so this render thread can make JNI calls. - if client.jvm.attach_current_thread_as_daemon().is_err() { + // Attach this render thread to the JVM so it can make JNI calls. + if state::env().is_err() { return; } - let minecraft = Minecraft::instance(); - - let tick_count = match minecraft.get_player() { + let tick_count = match state::minecraft().get_player() { Ok(player) => match player.entity.get_tick_count() { Ok(count) => count, Err(_) => return, @@ -131,7 +126,7 @@ fn check_tick() { if tick_count > LAST_TICK.load(Ordering::Relaxed) { LAST_TICK.store(tick_count, Ordering::Relaxed); - client.tick(); + state::client().tick(); } } @@ -238,7 +233,10 @@ pub fn install_hooks() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("wglSwapBuffers missing: {}", e))?; let target_addr = *swap_buffers as *const () as usize; - info!("Found wglSwapBuffers in {} at 0x{:x}", LIB_NAME, target_addr); + info!( + "Found wglSwapBuffers in {} at 0x{:x}", + LIB_NAME, target_addr + ); let hook = hooker_for(target_addr) .hook() diff --git a/client/src/graphic/hud.rs b/client/src/graphic/hud.rs index d3d4cd6..8de2a74 100644 --- a/client/src/graphic/hud.rs +++ b/client/src/graphic/hud.rs @@ -3,10 +3,11 @@ //! Everything is drawn straight onto a background [`egui::Painter`] with //! absolute screen coordinates — no layout passes, no per-widget `Area`s. -use crate::client::DarkClient; use crate::graphic::anim::{self, Easing}; use crate::graphic::theme; -use egui::{Align2, Color32, Context, FontId, Id, LayerId, Order, Painter, Rect, Rounding, Stroke, Vec2}; +use egui::{ + Align2, Color32, Context, FontId, Id, LayerId, Order, Painter, Rect, Rounding, Stroke, Vec2, +}; /// Screen-edge padding shared by every HUD element. const MARGIN: f32 = 10.0; @@ -37,19 +38,38 @@ fn draw_watermark(ctx: &Context, painter: &Painter) { let size = Vec2::new(dark_w + client_w + pad.x * 2.0, text_h + pad.y * 2.0); let rect = Rect::from_min_size(egui::pos2(MARGIN, MARGIN), size); - painter.rect_filled(rect, Rounding::same(theme::RADIUS), Color32::from_black_alpha(165)); - painter.rect_stroke(rect, Rounding::same(theme::RADIUS), Stroke::new(1.0_f32, theme::BORDER)); + painter.rect_filled( + rect, + Rounding::same(theme::RADIUS), + Color32::from_black_alpha(165), + ); + painter.rect_stroke( + rect, + Rounding::same(theme::RADIUS), + Stroke::new(1.0_f32, theme::BORDER), + ); // Accent edge on the left side of the chip. let edge = Rect::from_min_size(rect.min, Vec2::new(3.0, rect.height())); painter.rect_filled( edge, - Rounding { nw: theme::RADIUS, sw: theme::RADIUS, ne: 0.0, se: 0.0 }, + Rounding { + nw: theme::RADIUS, + sw: theme::RADIUS, + ne: 0.0, + se: 0.0, + }, theme::ACCENT, ); let anchor = egui::pos2(rect.min.x + pad.x, rect.center().y); - let after = painter.text(anchor, Align2::LEFT_CENTER, "Dark", font.clone(), theme::TEXT); + let after = painter.text( + anchor, + Align2::LEFT_CENTER, + "Dark", + font.clone(), + theme::TEXT, + ); painter.text( egui::pos2(after.max.x, anchor.y), Align2::LEFT_CENTER, @@ -67,7 +87,7 @@ fn draw_arraylist(ctx: &Context, painter: &Painter) { let font = FontId::proportional(14.0); // One lock per module: snapshot just the name and enabled flag. - let snapshot: Vec<(String, bool)> = match DarkClient::instance().modules.read() { + let snapshot: Vec<(String, bool)> = match crate::state::client().modules.read() { Ok(guard) => guard .values() .map(|m| { @@ -83,7 +103,13 @@ fn draw_arraylist(ctx: &Context, painter: &Painter) { // keep a slot while their factor decays toward zero. let mut rows: Vec<(String, f32, f32)> = Vec::new(); // (name, factor, text width) for (name, enabled) in snapshot { - let factor = anim::toggle(ctx, Id::new("arraylist").with(&name), enabled, 0.22, Easing::Out); + let factor = anim::toggle( + ctx, + Id::new("arraylist").with(&name), + enabled, + 0.22, + Easing::Out, + ); if factor <= 0.001 { continue; } @@ -119,7 +145,10 @@ fn draw_arraylist(ctx: &Context, painter: &Painter) { ); // Accent tab welded to the right screen edge. - let tab = Rect::from_min_size(rect.right_top() - Vec2::new(2.0, 0.0), Vec2::new(2.0, ROW_H)); + let tab = Rect::from_min_size( + rect.right_top() - Vec2::new(2.0, 0.0), + Vec2::new(2.0, ROW_H), + ); painter.rect_filled(tab, Rounding::ZERO, theme::with_alpha(theme::ACCENT, eased)); painter.text( diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index 48da313..e8e497d 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -5,8 +5,7 @@ //! Minecraft. The logic is platform-agnostic; only locating the GLFW shared //! library differs between Linux and Windows (see [`open_glfw_library`]). -use crate::client::DarkClient; -use crate::mapping::client::minecraft::Minecraft; +use crate::state::{client, minecraft}; use libloading::Library; use log::info; use std::ffi::c_void; @@ -238,12 +237,12 @@ fn toggle_gui() { /// Toggles any module whose keybind matches `key`, when in-world. fn handle_module_keybind(key: i32) { - let minecraft = Minecraft::instance(); + let minecraft = minecraft(); if !minecraft.current_screen_is_null() || minecraft.get_player().is_err() { return; } - let client = DarkClient::instance(); + let client = client(); let Ok(modules) = client.modules.read() else { return; }; @@ -289,7 +288,9 @@ fn install_glfw_hooks() -> Option { let library = open_glfw_library()?; unsafe { - let get_context = *library.get::(b"glfwGetCurrentContext").ok()?; + let get_context = *library + .get::(b"glfwGetCurrentContext") + .ok()?; let set_mouse_button = *library .get::(b"glfwSetMouseButtonCallback") .ok()?; diff --git a/client/src/graphic/menu.rs b/client/src/graphic/menu.rs index 31cebc3..1c44671 100644 --- a/client/src/graphic/menu.rs +++ b/client/src/graphic/menu.rs @@ -5,7 +5,6 @@ //! per frame; all motion goes through [`anim`], so it is frame-rate //! independent and needs no per-widget state threaded through the call tree. -use crate::client::DarkClient; use crate::graphic::anim::{self, Easing, SpringCfg}; use crate::graphic::input::LAST_KEY_PRESSED; use crate::graphic::notification::{Notification, NotificationType}; @@ -42,7 +41,7 @@ type ModuleMap = HashMap; pub fn draw(ctx: &Context, progress: f32) { draw_backdrop(ctx, progress); - let registry = match DarkClient::instance().modules.read() { + let registry = match crate::state::client().modules.read() { Ok(guard) => guard, Err(_) => return, }; @@ -128,19 +127,17 @@ fn draw_toolbar(ctx: &Context, progress: f32) { ); ui.add_space(16.0); - let panic = Button::new( - RichText::new("Panic").size(12.5).color(theme::DANGER), - ) - .fill(theme::ELEVATED); + let panic = + Button::new(RichText::new("Panic").size(12.5).color(theme::DANGER)) + .fill(theme::ELEVATED); if ui.add(panic).clicked() { std::thread::spawn(crate::graphic::ui_engine::call_panic); } ui.add_space(6.0); - let reset = Button::new( - RichText::new("Reset").size(12.5).color(theme::TEXT_DIM), - ) - .fill(theme::ELEVATED); + 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. ctx.memory_mut(|mem| mem.reset_areas()); @@ -166,7 +163,12 @@ fn draw_panel( // 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)); - let pos = anim::spring_pos(ctx, Id::new("panel_pos").with(name), target, SpringCfg::PANEL); + let pos = anim::spring_pos( + ctx, + Id::new("panel_pos").with(name), + target, + SpringCfg::PANEL, + ); // Spawn animation: slide the panel up into place as the menu opens. let render_pos = pos + Vec2::new(0.0, 16.0 * (1.0 - progress)); @@ -242,8 +244,20 @@ fn draw_module_row(ui: &mut Ui, name: &str, arc: &ModuleArc, registry: &ModuleMa ); let ctx = ui.ctx(); - let hover = anim::toggle(ctx, Id::new("row_hov").with(name), response.hovered(), 0.12, Easing::Out); - let enable = anim::toggle(ctx, Id::new("row_en").with(name), enabled, 0.18, Easing::Out); + let hover = anim::toggle( + ctx, + Id::new("row_hov").with(name), + response.hovered(), + 0.12, + Easing::Out, + ); + let enable = anim::toggle( + ctx, + Id::new("row_en").with(name), + enabled, + 0.18, + Easing::Out, + ); // --- paint base row --- { @@ -295,7 +309,11 @@ fn draw_module_row(ui: &mut Ui, name: &str, arc: &ModuleArc, registry: &ModuleMa 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 } else { theme::TEXT_MUTED }; + let chevron_color = if hovered_arrow { + theme::TEXT + } else { + theme::TEXT_MUTED + }; paint_chevron(ui.painter(), arrow_zone.center(), expand, chevron_color); if expand > 0.001 { @@ -417,7 +435,11 @@ fn keybind_row(ui: &mut Ui, data: &mut ModuleData, arc: &ModuleArc, registry: &M data.key_bind.to_string() }; - let color = if listening { theme::ACCENT } else { theme::TEXT_DIM }; + let color = if listening { + theme::ACCENT + } else { + theme::TEXT_DIM + }; let button = Button::new(RichText::new(caption).size(12.0).color(color)) .fill(theme::ELEVATED) .stroke(Stroke::NONE); diff --git a/client/src/graphic/notification.rs b/client/src/graphic/notification.rs index 411f1d4..20bcb3c 100644 --- a/client/src/graphic/notification.rs +++ b/client/src/graphic/notification.rs @@ -115,7 +115,13 @@ pub fn draw(ctx: &Context) { // Smooth vertical stacking so cards glide up as others expire. let target_y = MARGIN + index as f32 * (HEIGHT + GAP); - let y = anim::ease_to(ctx, Id::new("notif_y").with(n.id), target_y, 0.2, Easing::Out); + let y = anim::ease_to( + ctx, + Id::new("notif_y").with(n.id), + target_y, + 0.2, + Easing::Out, + ); let x = (screen_w - MARGIN - WIDTH) + (WIDTH + MARGIN) * offset; let rect = Rect::from_min_size(Pos2::new(x, y), Vec2::new(WIDTH, HEIGHT)); @@ -128,7 +134,11 @@ fn draw_card(painter: &Painter, rect: Rect, n: &Notification, remaining: f32) { let accent = n.notif_type.color(); let radius = Rounding::same(theme::RADIUS_INNER); - painter.rect_filled(rect, radius, Color32::from_rgba_unmultiplied(16, 17, 21, 240)); + painter.rect_filled( + rect, + radius, + Color32::from_rgba_unmultiplied(16, 17, 21, 240), + ); painter.rect_stroke(rect, radius, Stroke::new(1.0_f32, theme::BORDER)); // Accent rail down the left edge. diff --git a/client/src/graphic/ui_engine.rs b/client/src/graphic/ui_engine.rs index 3a63a3a..ba99cb3 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -1,5 +1,4 @@ use crate::cleanup_client; -use crate::client::DarkClient; use crate::graphic::input::{GUI_OPEN, MOUSE_STATE}; use egui::Context; use egui_glow::Painter; @@ -215,7 +214,7 @@ pub unsafe fn render_egui_ui() { } pub fn call_panic() { - let client = DarkClient::instance(); + let client = crate::state::client(); client.modules.read().unwrap().values().for_each(|module| { let mut module = module.lock().unwrap(); if module.get_module_data().enabled { diff --git a/client/src/lib.rs b/client/src/lib.rs index 5d2a3c8..83b4ab5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -3,43 +3,42 @@ #![allow(dead_code)] extern crate jni; -mod client; mod graphic; mod mapping; mod module; +mod state; pub mod gl { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } -use crate::client::DarkClient; use crate::graphic::hook::{install_hooks, uninstall_hooks}; -use crate::mapping::client::minecraft::Minecraft; +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::render::chest_esp::ChestEspModule; +use crate::module::render::mob_esp::MobEspModule; +use crate::module::render::player_esp::PlayerEspModule; +use crate::state::{client, init}; use log::{error, info, LevelFilter}; -use module::combat::aimbot::AimbotModule; -use module::combat::killaura::KillAuraModule; -use module::movement::fly::FlyModule; -use module::render::chest_esp::ChestEspModule; -use module::render::mob_esp::MobEspModule; -use module::render::player_esp::PlayerEspModule; use simplelog::{Config, WriteLogger}; use std::fs::File; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; -// Flag to control if the client is running +/// Cleared by [`cleanup_client`]; gates the frame hook and background loops. pub static RUNNING: AtomicBool = AtomicBool::new(false); +/// Entry point called by the agent loader once the library is loaded. #[no_mangle] pub extern "C" fn initialize_client() { - // Make sure we can't initialize more than once + // Make sure we can't initialize more than once. if RUNNING.swap(true, Ordering::SeqCst) { info!("Client already initialized"); return; } - // Initialize the logger match WriteLogger::init( LevelFilter::Debug, Config::default(), @@ -49,57 +48,45 @@ pub extern "C" fn initialize_client() { Err(e) => eprintln!("Error during logger initialization: {:?}", e), } - // Set up a custom panic hook to guarantee we release mouse/keyboard hooks + // Custom panic hook: guarantee the input / render hooks are released. let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { - error!("DarkClient Panicked! Attempting to unhook inputs..."); + error!("DarkClient panicked! Restoring input/render hooks…"); cleanup_client(); default_hook(panic_info); })); thread::spawn(|| { - info!("Starting DarkClient..."); - let minecraft = Minecraft::instance(); - + info!("Starting DarkClient…"); + + // Fixed, straight-line startup order: build the global state, then + // register modules, then install the hooks last so the frame hook + // never observes an uninitialized client. + if let Err(e) = init() { + error!("Client initialization failed: {e}"); + return; + } register_modules(); - - // Install hooks if let Err(e) = install_hooks() { - error!("Failed to install hooks: {}", e); - } - - match minecraft.get_player() { - Ok(player) => { - if let Ok(pos) = player.entity.get_position() { - info!("Initial Player position: {:?}", pos); - } - } - Err(_) => info!("Client initialized, but player is not in-world yet."), + error!("Failed to install hooks: {e}"); } + info!("DarkClient started"); }); } -// Cleanup function for agent_loader +/// Cleanup entry point called by the agent loader before unload. #[no_mangle] pub extern "C" fn cleanup_client() { - info!("Client cleanup in progress..."); - - // Set the execution flag to false + info!("Client cleanup in progress…"); RUNNING.store(false, Ordering::SeqCst); - - // Remove the hooks uninstall_hooks(); - - // Unlock GLFW Input / Restore callbacks if GUI was open crate::graphic::input::cleanup(); - - // Clean up other resources if necessary info!("Client cleanup completed"); } +/// Registers the built-in modules with the client. fn register_modules() { - let client = DarkClient::instance(); - + let client = client(); client.register_module(FlyModule::new()); client.register_module(KillAuraModule::new()); client.register_module(MobAuraModule::new()); diff --git a/client/src/mapping/class.rs b/client/src/mapping/class.rs index ceb031a..954c974 100644 --- a/client/src/mapping/class.rs +++ b/client/src/mapping/class.rs @@ -1,4 +1,4 @@ -use crate::client::DarkClient; +use crate::state; use anyhow::anyhow; use jni::objects::{JClass, JObject, JString, JValue, JValueOwned}; use jni::JNIEnv; @@ -369,7 +369,7 @@ impl MinecraftClass { } // Get JNI environment to check actual object type - if let Ok(mut env) = DarkClient::instance().get_env() { + if let Ok(mut env) = state::env() { // Get the actual class of the object if let Ok(obj_class) = env.get_object_class(obj) { // Check for exact class match first @@ -411,7 +411,7 @@ impl MinecraftClass { return SignatureMatch::Compatible; } - if let Ok(mut env) = DarkClient::instance().get_env() { + if let Ok(mut env) = state::env() { // Check if the object is actually an array if let Ok(obj_class) = env.get_object_class(obj) { if let Ok(class_name) = self.get_class_name_from_object(&mut env, &obj_class) { diff --git a/client/src/mapping/class_type.rs b/client/src/mapping/class_type.rs index bf8e113..0101398 100644 --- a/client/src/mapping/class_type.rs +++ b/client/src/mapping/class_type.rs @@ -59,9 +59,7 @@ impl MinecraftClassType { MinecraftClassType::Component => "net/minecraft/network/chat/Component", MinecraftClassType::LevelReader => "net/minecraft/world/level/LevelReader", MinecraftClassType::LevelChunk => "net/minecraft/world/level/chunk/LevelChunk", - MinecraftClassType::BlockEntity => { - "net/minecraft/world/level/block/entity/BlockEntity" - } + MinecraftClassType::BlockEntity => "net/minecraft/world/level/block/entity/BlockEntity", MinecraftClassType::ChestBlockEntity => { "net/minecraft/world/level/block/entity/ChestBlockEntity" } diff --git a/client/src/mapping/client/gamemode.rs b/client/src/mapping/client/gamemode.rs index 56f908c..512e0d3 100644 --- a/client/src/mapping/client/gamemode.rs +++ b/client/src/mapping/client/gamemode.rs @@ -1,6 +1,7 @@ use crate::mapping::entity::player::LocalPlayer; use crate::mapping::entity::Entity; -use crate::mapping::{GameContext, MinecraftClassType}; +use crate::mapping::MinecraftClassType; +use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use std::ops::Deref; @@ -9,17 +10,13 @@ pub struct MultiPlayerGameMode { pub jni_ref: GlobalRef, } -impl GameContext for MultiPlayerGameMode {} - impl MultiPlayerGameMode { pub fn new(jni_ref: GlobalRef) -> Self { Self { jni_ref } } pub fn attack(&self, player: &LocalPlayer, target: &Entity) -> anyhow::Result<()> { - let mapping = self.mapping(); - - mapping.call_method( + mapping().call_method( MinecraftClassType::MultiPlayerGameMode, self.jni_ref.as_obj(), "attack", diff --git a/client/src/mapping/client/minecraft.rs b/client/src/mapping/client/minecraft.rs index 2f71ac3..2b7c109 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -3,16 +3,17 @@ use crate::mapping::client::window::Window; use crate::mapping::client::world::World; use crate::mapping::entity::player::{Abilities, LocalPlayer}; use crate::mapping::entity::Entity; -use crate::mapping::{FieldType, GameContext, Mapping, MinecraftClassType}; +use crate::mapping::{FieldType, MinecraftClassType}; +use crate::state::mapping; use jni::objects::GlobalRef; -use log::error; use std::ops::Deref; -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::RwLock; +/// The running Minecraft client — the `net.minecraft.client.Minecraft` +/// instance plus the game objects reached through it. #[derive(Debug)] pub struct Minecraft { pub jni_ref: GlobalRef, - mapping: Mapping, player: RwLock, #[allow(dead_code)] pub world: World, @@ -20,43 +21,28 @@ pub struct Minecraft { pub game_mode: MultiPlayerGameMode, } -impl GameContext for Minecraft {} - impl Minecraft { - pub fn instance() -> &'static Minecraft { - static INSTANCE: OnceLock> = OnceLock::new(); - - INSTANCE.get_or_init(|| unsafe { - Arc::new(Minecraft::new().unwrap_or_else(|e| { - error!("Failed to initialize Minecraft: {:?}", e); - panic!("Failed to initialize Minecraft"); - })) - }) - } - - unsafe fn new() -> anyhow::Result { - let mapping = Mapping::new()?; - let minecraft = mapping + /// Builds the game wrapper from the live `Minecraft.getInstance()`. + pub fn new() -> anyhow::Result { + let minecraft = mapping() .call_static_method(MinecraftClassType::Minecraft, "getInstance", &[])? .l()?; - if minecraft.is_null() { - error!("Minecraft is null") + return Err(anyhow::anyhow!("Minecraft.getInstance() returned null")); } + let minecraft = mapping().new_global_ref(minecraft)?; - let minecraft = mapping.new_global_ref(minecraft)?; - - let player = LocalPlayer::new(&minecraft, &mapping)?; - let world = World::new(&minecraft, &mapping)?; - let window = Window::new(&minecraft, &mapping)?; + let player = LocalPlayer::new(&minecraft)?; + let world = World::new(&minecraft)?; + let window = Window::new(&minecraft)?; let game_mode = MultiPlayerGameMode::new( - mapping.new_global_ref( - mapping + mapping().new_global_ref( + mapping() .get_field( MinecraftClassType::Minecraft, minecraft.as_obj(), "gameMode", - FieldType::Object(MinecraftClassType::MultiPlayerGameMode, &mapping), + FieldType::Object(MinecraftClassType::MultiPlayerGameMode), )? .l()?, )?, @@ -64,7 +50,6 @@ impl Minecraft { Ok(Minecraft { jni_ref: minecraft, - mapping, player: RwLock::new(player), world, window, @@ -72,18 +57,15 @@ impl Minecraft { }) } - pub fn get_mapping(&self) -> &Mapping { - &self.mapping - } - + /// Returns the local player, refreshing the cache when the underlying + /// JVM object has changed (a new world join produces a new instance). pub fn get_player(&self) -> anyhow::Result { - let player_obj = self - .mapping + let player_obj = mapping() .get_field( MinecraftClassType::Minecraft, self.jni_ref.as_obj(), "player", - FieldType::Object(MinecraftClassType::LocalPlayer, &self.mapping), + FieldType::Object(MinecraftClassType::LocalPlayer), )? .l()?; @@ -96,8 +78,7 @@ impl Minecraft { .player .read() .map_err(|_| anyhow::anyhow!("Lock poisoned"))?; - if self - .mapping + if mapping() .get_env()? .is_same_object(&read_guard.jni_ref, &player_obj)? { @@ -110,21 +91,22 @@ impl Minecraft { .write() .map_err(|_| anyhow::anyhow!("Lock poisoned"))?; - let jni_ref = self.mapping.new_global_ref(player_obj)?; + let jni_ref = mapping().new_global_ref(player_obj)?; *write_guard = LocalPlayer { jni_ref: jni_ref.clone(), - abilities: Abilities::new(jni_ref.clone(), &self.mapping)?, + abilities: Abilities::new(jni_ref.clone())?, entity: Entity::new(jni_ref), }; Ok(write_guard.clone()) } + pub fn current_screen_is_null(&self) -> bool { - if let Ok(screen_obj) = self.mapping.get_field( + if let Ok(screen_obj) = mapping().get_field( MinecraftClassType::Minecraft, self.jni_ref.as_obj(), "screen", - FieldType::Object(MinecraftClassType::Screen, &self.mapping), + FieldType::Object(MinecraftClassType::Screen), ) { if let Ok(l) = screen_obj.l() { return l.is_null(); diff --git a/client/src/mapping/client/window.rs b/client/src/mapping/client/window.rs index 56bb5ea..de9ceb2 100644 --- a/client/src/mapping/client/window.rs +++ b/client/src/mapping/client/window.rs @@ -1,5 +1,6 @@ use crate::mapping::method::MethodName; -use crate::mapping::{GameContext, Mapping, MinecraftClassType}; +use crate::mapping::MinecraftClassType; +use crate::state::mapping; use jni::objects::GlobalRef; use jni::sys::jlong; use std::ops::Deref; @@ -9,11 +10,9 @@ pub struct Window { pub jni_ref: GlobalRef, } -impl GameContext for Window {} - impl Window { - pub fn new(minecraft: &GlobalRef, mapping: &Mapping) -> anyhow::Result { - let window_obj = mapping + pub fn new(minecraft: &GlobalRef) -> anyhow::Result { + let window_obj = mapping() .call_method( MinecraftClassType::Minecraft, minecraft.as_obj(), @@ -23,12 +22,12 @@ impl Window { .l()?; Ok(Window { - jni_ref: mapping.new_global_ref(window_obj)?, + jni_ref: mapping().new_global_ref(window_obj)?, }) } pub fn get_window(&self) -> anyhow::Result { - let mapping = self.mapping(); + let mapping = mapping(); Ok(mapping .call_method( diff --git a/client/src/mapping/client/world.rs b/client/src/mapping/client/world.rs index 6cf2dcc..f698da6 100644 --- a/client/src/mapping/client/world.rs +++ b/client/src/mapping/client/world.rs @@ -1,6 +1,7 @@ use crate::mapping::entity::Entity; use crate::mapping::java::iterable::Iterable; -use crate::mapping::{FieldType, GameContext, Mapping, MinecraftClassType}; +use crate::mapping::{FieldType, MinecraftClassType}; +use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -9,28 +10,24 @@ pub struct World { jni_ref: GlobalRef, } -impl GameContext for World {} - impl World { - pub fn new(minecraft: &GlobalRef, mapping: &Mapping) -> anyhow::Result { - let world_obj = mapping + pub fn new(minecraft: &GlobalRef) -> anyhow::Result { + let world_obj = mapping() .get_field( MinecraftClassType::Minecraft, minecraft.as_obj(), "level", - FieldType::Object(MinecraftClassType::Level, mapping), + FieldType::Object(MinecraftClassType::Level), )? .l()?; Ok(World { - jni_ref: mapping.new_global_ref(world_obj)?, + jni_ref: mapping().new_global_ref(world_obj)?, }) } pub fn get_entities(&self) -> anyhow::Result> { - let mapping = self.mapping(); - - let iterable_obj = mapping + let iterable_obj = mapping() .call_method( MinecraftClassType::Level, self.jni_ref.as_obj(), @@ -40,15 +37,14 @@ impl World { .l()?; let iterable = Iterable { - jni_ref: mapping.new_global_ref(iterable_obj)?, + jni_ref: mapping().new_global_ref(iterable_obj)?, }; let iterator = iterable.iterator()?; let mut entities = Vec::new(); while iterator.has_next()? { - let entity_obj = iterator.next()?; - entities.push(Entity::new(entity_obj)); + entities.push(Entity::new(iterator.next()?)); } Ok(entities) diff --git a/client/src/mapping/entity/mod.rs b/client/src/mapping/entity/mod.rs index b4f2b7f..ba589e3 100644 --- a/client/src/mapping/entity/mod.rs +++ b/client/src/mapping/entity/mod.rs @@ -1,4 +1,5 @@ -use crate::mapping::{FieldType, GameContext, MinecraftClassType}; +use crate::mapping::{FieldType, MinecraftClassType}; +use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use std::ops::Deref; @@ -15,8 +16,6 @@ pub struct Entity { pub jni_ref: GlobalRef, } -impl GameContext for Entity {} - #[allow(dead_code)] impl Entity { pub fn new(jni_ref: GlobalRef) -> Entity { @@ -24,9 +23,7 @@ impl Entity { } pub fn get_position(&self) -> anyhow::Result<(f64, f64, f64)> { - let mapping = self.mapping(); - - let vec3 = mapping + let vec3 = mapping() .call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), @@ -35,15 +32,15 @@ impl Entity { )? .l()?; - let x = mapping + let x = mapping() .get_field(MinecraftClassType::Vec3, &vec3, "x", FieldType::Double)? .d()?; - let y = mapping + let y = mapping() .get_field(MinecraftClassType::Vec3, &vec3, "y", FieldType::Double)? .d()?; - let z = mapping + let z = mapping() .get_field(MinecraftClassType::Vec3, &vec3, "z", FieldType::Double)? .d()?; @@ -51,9 +48,7 @@ impl Entity { } pub fn set_invulnerable(&self, value: bool) -> anyhow::Result<()> { - let mapping = self.mapping(); - - mapping.call_method( + mapping().call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), "setInvulnerable", @@ -64,9 +59,7 @@ impl Entity { } pub fn get_fall_distance(&self) -> anyhow::Result { - let mapping = self.mapping(); - - Ok(mapping + Ok(mapping() .get_field( MinecraftClassType::Entity, self.jni_ref.as_obj(), @@ -77,9 +70,7 @@ impl Entity { } pub fn reset_fall_distance(&self) -> anyhow::Result<()> { - let mapping = self.mapping(); - - Ok(mapping + Ok(mapping() .call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), @@ -90,10 +81,8 @@ impl Entity { } pub fn get_name(&self) -> anyhow::Result { - let mapping = self.mapping(); - - mapping.get_string( - mapping + mapping().get_string( + mapping() .call_method( MinecraftClassType::Entity, self.jni_ref.as_obj(), @@ -105,9 +94,7 @@ impl Entity { } pub fn get_tick_count(&self) -> anyhow::Result { - let mapping = self.mapping(); - - Ok(mapping + Ok(mapping() .get_field( MinecraftClassType::Entity, self.jni_ref.as_obj(), diff --git a/client/src/mapping/entity/player.rs b/client/src/mapping/entity/player.rs index 630fc6d..d8d78a0 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -1,5 +1,6 @@ use crate::mapping::entity::Entity; -use crate::mapping::{FieldType, GameContext, Mapping, MinecraftClassType}; +use crate::mapping::{FieldType, MinecraftClassType}; +use crate::state::mapping; use jni::objects::{GlobalRef, JValue}; use jni::sys::jboolean; use std::ops::Deref; @@ -16,22 +17,19 @@ pub struct Abilities { pub jni_ref: GlobalRef, } -impl GameContext for LocalPlayer {} -impl GameContext for Abilities {} - impl LocalPlayer { - pub fn new(minecraft: &GlobalRef, mapping: &Mapping) -> anyhow::Result { - let player_obj = mapping + pub fn new(minecraft: &GlobalRef) -> anyhow::Result { + let player_obj = mapping() .get_field( MinecraftClassType::Minecraft, minecraft.as_obj(), "player", - FieldType::Object(MinecraftClassType::LocalPlayer, mapping), + FieldType::Object(MinecraftClassType::LocalPlayer), )? .l()?; - let player_ref = mapping.new_global_ref(player_obj)?; - let abilities = Abilities::new(player_ref.clone(), mapping)?; + let player_ref = mapping().new_global_ref(player_obj)?; + let abilities = Abilities::new(player_ref.clone())?; let entity = Entity::new(player_ref.clone()); Ok(Self { @@ -43,21 +41,19 @@ impl LocalPlayer { } impl Abilities { - pub fn new(player: GlobalRef, mapping: &Mapping) -> anyhow::Result { - let jni_ref = mapping + 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)?, + jni_ref: mapping().new_global_ref(jni_ref)?, }) } pub fn fly(&self, value: bool) -> anyhow::Result<()> { - let mapping = self.mapping(); - let value: jboolean = if value { 1 } else { 0 }; - mapping.set_field( + mapping().set_field( MinecraftClassType::Abilities, self.jni_ref.as_obj(), "flying", @@ -65,7 +61,7 @@ impl Abilities { JValue::Bool(value), )?; - mapping.set_field( + mapping().set_field( MinecraftClassType::Abilities, self.jni_ref.as_obj(), "mayfly", @@ -78,9 +74,7 @@ impl Abilities { #[allow(dead_code)] pub fn get_may_fly(&self) -> anyhow::Result { - let mapping = self.mapping(); - - Ok(mapping + Ok(mapping() .get_field( MinecraftClassType::Abilities, self.jni_ref.as_obj(), diff --git a/client/src/mapping/java/iterable.rs b/client/src/mapping/java/iterable.rs index 1b06edf..081f3eb 100644 --- a/client/src/mapping/java/iterable.rs +++ b/client/src/mapping/java/iterable.rs @@ -1,5 +1,6 @@ use crate::mapping::java::iterator::Iterator; -use crate::mapping::{GameContext, MinecraftClassType}; +use crate::mapping::MinecraftClassType; +use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -8,13 +9,9 @@ pub struct Iterable { pub jni_ref: GlobalRef, } -impl GameContext for Iterable {} - impl Iterable { pub fn iterator(&self) -> anyhow::Result { - let mapping = self.mapping(); - - let iterator_obj = mapping + let iterator_obj = mapping() .call_method( MinecraftClassType::Iterable, self.jni_ref.as_obj(), @@ -24,7 +21,7 @@ impl Iterable { .l()?; Ok(Iterator { - jni_ref: mapping.new_global_ref(iterator_obj)?, + jni_ref: mapping().new_global_ref(iterator_obj)?, }) } } diff --git a/client/src/mapping/java/iterator.rs b/client/src/mapping/java/iterator.rs index 093f303..fec0aca 100644 --- a/client/src/mapping/java/iterator.rs +++ b/client/src/mapping/java/iterator.rs @@ -1,4 +1,5 @@ -use crate::mapping::{GameContext, MinecraftClassType}; +use crate::mapping::MinecraftClassType; +use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -7,13 +8,9 @@ pub struct Iterator { pub jni_ref: GlobalRef, } -impl GameContext for Iterator {} - impl Iterator { pub fn has_next(&self) -> anyhow::Result { - let mapping = self.mapping(); - - Ok(mapping + Ok(mapping() .call_method( MinecraftClassType::Iterator, self.jni_ref.as_obj(), @@ -24,9 +21,7 @@ impl Iterator { } pub fn next(&self) -> anyhow::Result { - let mapping = self.mapping(); - - let next_obj = mapping + let next_obj = mapping() .call_method( MinecraftClassType::Iterator, self.jni_ref.as_obj(), @@ -35,7 +30,7 @@ impl Iterator { )? .l()?; - mapping.new_global_ref(next_obj) + mapping().new_global_ref(next_obj) } } diff --git a/client/src/mapping/loader.rs b/client/src/mapping/loader.rs index ec5ba26..202fa2e 100644 --- a/client/src/mapping/loader.rs +++ b/client/src/mapping/loader.rs @@ -137,11 +137,7 @@ fn probe_loader( /// Calls `loader.loadClass(binary_name)`, returning the class on success and /// `None` (with the pending exception cleared) when the loader cannot find it. -fn load_class<'a>( - env: &mut JNIEnv<'a>, - loader: &JObject, - binary_name: &str, -) -> Option> { +fn load_class<'a>(env: &mut JNIEnv<'a>, loader: &JObject, binary_name: &str) -> Option> { let name: JObject = env.new_string(binary_name).ok()?.into(); let result = env.call_method( loader, diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index a4701fe..0f547f6 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,13 +1,11 @@ -use crate::client::DarkClient; use crate::mapping::class::{Method, MethodHandle, MinecraftClass}; pub use crate::mapping::class_type::MinecraftClassType; -use crate::mapping::client::minecraft::Minecraft; use crate::mapping::minecraft_version::MinecraftVersion; use jni::objects::{GlobalRef, JClass, JMethodID, JObject, JString, JValue, JValueOwned}; use jni::signature::{Primitive, ReturnType}; -use jni::sys::jvalue; -use jni::JNIEnv; -use log::{error, info}; +use jni::sys::{jsize, jvalue, JNI_GetCreatedJavaVMs, JNI_OK}; +use jni::{JNIEnv, JavaVM}; +use log::info; use serde::Deserialize; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -22,16 +20,6 @@ mod method; mod minecraft_version; mod reflect; -pub trait GameContext { - fn minecraft(&self) -> &'static Minecraft { - Minecraft::instance() - } - - fn mapping(&self) -> &'static Mapping { - self.minecraft().get_mapping() - } -} - /// On-disk JSON shape. Only obfuscated builds ship one of these. #[derive(Debug, Deserialize)] struct MappingFile { @@ -53,6 +41,8 @@ enum Mode { /// uses, transparently for both obfuscated and unobfuscated Minecraft. #[derive(Debug)] pub struct Mapping { + /// Handle to the host JVM; the source of every [`JNIEnv`] this bridge uses. + jvm: JavaVM, mode: Mode, version: MinecraftVersion, /// In obfuscated mode every class is present up-front; in reflected mode @@ -72,7 +62,7 @@ pub struct Mapping { } #[allow(dead_code)] -pub enum FieldType<'local> { +pub enum FieldType { Boolean, Byte, Char, @@ -82,10 +72,10 @@ pub enum FieldType<'local> { Float, Double, String, - Object(MinecraftClassType, &'local Mapping), + Object(MinecraftClassType), } -impl FieldType<'_> { +impl FieldType { pub fn get_signature(&self) -> anyhow::Result { Ok(match self { FieldType::Boolean => String::from("Z"), @@ -97,8 +87,11 @@ impl FieldType<'_> { FieldType::Float => String::from("F"), FieldType::Double => String::from("D"), FieldType::String => String::from("Ljava/lang/String;"), - FieldType::Object(class_type, mapping) => { - format!("L{};", mapping.runtime_class_name(*class_type)?) + FieldType::Object(class_type) => { + format!( + "L{};", + crate::state::mapping().runtime_class_name(*class_type)? + ) } }) } @@ -109,45 +102,56 @@ impl FieldType<'_> { /// In an unobfuscated build the real Mojmap class name resolves directly; in /// an obfuscated build that class only exists under its scrambled name, so the /// lookup fails (and the resulting pending exception is cleared). -fn is_unobfuscated() -> bool { - match DarkClient::instance().get_env() { - Ok(mut env) => { - let found = env.find_class("net/minecraft/client/Minecraft").is_ok(); - if !found { - let _ = env.exception_clear(); - } - found +fn probe_unobfuscated(env: &mut JNIEnv) -> bool { + let found = env.find_class("net/minecraft/client/Minecraft").is_ok(); + if !found { + let _ = env.exception_clear(); + } + found +} + +/// Obtains a handle to the JVM running in this process. +fn acquire_jvm() -> anyhow::Result { + let mut raw: *mut jni::sys::JavaVM = std::ptr::null_mut(); + let mut count: jsize = 0; + + // SAFETY: standard JNI invocation-API call; both out-parameters are valid. + unsafe { + if JNI_GetCreatedJavaVMs(&mut raw, 1, &mut count) != JNI_OK || count == 0 { + return Err(anyhow::anyhow!("no JVM found in this process")); } - Err(_) => false, + Ok(JavaVM::from_raw(raw)?) } } #[allow(dead_code)] impl Mapping { pub fn new() -> anyhow::Result { + let jvm = acquire_jvm()?; + let mut env = jvm.attach_current_thread_as_daemon()?; + // Discover the loader that runs the game before anything else: on // Fabric/Forge the game lives in an isolated class loader and a plain // `find_class` resolves a dead duplicate of `Minecraft` whose static // `instance` is null — the "Minecraft is null" failure (see `loader`). - let game_loader = DarkClient::instance() - .get_env() - .ok() - .and_then(|mut env| loader::discover_game_loader(&mut env)); + let game_loader = loader::discover_game_loader(&mut env); // Reflected mode applies whenever the real Mojmap names exist at // runtime — proven either by a resolved game loader (vanilla or modded) - // or, as a fallback, by a direct `find_class`. - if game_loader.is_some() || is_unobfuscated() { + // or, as a fallback, by a direct `find_class`. After this the `env` + // borrow of `jvm` ends, so `jvm` can move into the returned `Mapping`. + let reflected = game_loader.is_some() || probe_unobfuscated(&mut env); + + if reflected { match game_loader { Some(_) => info!( "Modded/unobfuscated Minecraft detected — routing class \ resolution through the game class loader" ), - None => info!( - "Unobfuscated Minecraft detected — using runtime reflection mapping" - ), + None => info!("Unobfuscated Minecraft detected — using runtime reflection mapping"), } return Ok(Mapping { + jvm, mode: Mode::Reflected, version: MinecraftVersion::LATEST, classes: RwLock::new(HashMap::new()), @@ -172,6 +176,7 @@ impl Mapping { .collect(); Ok(Mapping { + jvm, mode: Mode::Obfuscated, version: file.version, classes: RwLock::new(classes), @@ -180,12 +185,10 @@ impl Mapping { }) } - fn get_client(&self) -> &DarkClient { - DarkClient::instance() - } - - pub fn get_env(&'_ self) -> anyhow::Result> { - Ok(self.get_client().get_env()?) + /// Attaches the current thread to the JVM and returns a JNI environment. + /// Called on the `'static` global mapping, the environment is `'static`. + pub fn get_env(&self) -> anyhow::Result> { + Ok(self.jvm.attach_current_thread_as_daemon()?) } pub fn get_version(&self) -> MinecraftVersion { @@ -255,11 +258,7 @@ impl Mapping { /// Looks a class up from scratch: through the captured Minecraft class /// loader if available, otherwise through `find_class`. - fn lookup_class<'a>( - &self, - env: &mut JNIEnv<'a>, - jni_name: &str, - ) -> anyhow::Result> { + fn lookup_class<'a>(&self, env: &mut JNIEnv<'a>, jni_name: &str) -> anyhow::Result> { if let Some(loader) = self.class_loader.read().unwrap().clone() { let binary_name = jni_name.replace('/', "."); let name: JObject = env.new_string(binary_name)?.into(); @@ -397,7 +396,11 @@ impl Mapping { let translated_return = self.translate_type_descriptor(&mut return_type_str); - format!("({}) -> {}", translated_params.join(", "), translated_return) + format!( + "({}) -> {}", + translated_params.join(", "), + translated_return + ) } else { signature.to_string() } @@ -611,15 +614,6 @@ impl Mapping { } } -impl Default for Mapping { - fn default() -> Self { - Self::new().unwrap_or_else(|_| { - error!("Failed to load mappings"); - panic!("Failed to load mappings"); - }) - } -} - /// Number of parameters in a JNI method signature, e.g. `(ILjava/lang/String;)V` /// has 2. Used to guard the unchecked call path against arity mismatches. fn signature_arg_count(signature: &str) -> usize { diff --git a/client/src/mapping/reflect.rs b/client/src/mapping/reflect.rs index 4daa30d..84c9722 100644 --- a/client/src/mapping/reflect.rs +++ b/client/src/mapping/reflect.rs @@ -6,7 +6,6 @@ //! [`MinecraftClass`] with identity names and reflected signatures, which the //! rest of the mapping layer then treats exactly like a JSON-parsed entry. -use crate::client::DarkClient; use crate::mapping::class::{Method, MinecraftClass}; use crate::mapping::Mapping; use jni::objects::{JObject, JObjectArray, JString}; @@ -16,7 +15,7 @@ use std::collections::HashMap; /// Reflects every method declared on — or inherited as public by — /// `class_name`, returning it as a [`MinecraftClass`]. pub fn reflect_class(mapping: &Mapping, class_name: &str) -> anyhow::Result { - let mut env = DarkClient::instance().get_env()?; + let mut env = mapping.get_env()?; let jclass: JObject = mapping.resolve_class(&mut env, class_name)?.into(); @@ -28,7 +27,10 @@ pub fn reflect_class(mapping: &Mapping, class_name: &str) -> anyhow::Result anyhow::Result<()> { - let minecraft = Minecraft::instance(); + let minecraft = minecraft(); let player = &minecraft.get_player()?; let world = &minecraft.world; let entities = world.get_entities()?; let range = self.get_range() as f64; - let mapping = minecraft.mapping(); + let mapping = mapping(); let player_pos = player.entity.get_position()?; let mut closest_dist = range; @@ -87,8 +86,6 @@ impl Module for AimbotModule { 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 mapping = player.mapping(); - // Set yaw mapping.set_field( MinecraftClassType::Entity, diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 3ec77a9..5320c22 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -1,6 +1,6 @@ -use crate::mapping::client::minecraft::Minecraft; -use crate::mapping::{GameContext, MinecraftClassType}; +use crate::mapping::MinecraftClassType; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::state::{mapping, minecraft}; #[derive(Debug)] pub struct BaseAura { @@ -51,11 +51,11 @@ impl Module for BaseAura { } fn on_tick(&self) -> anyhow::Result<()> { - let minecraft = Minecraft::instance(); + let minecraft = minecraft(); let player = &minecraft.get_player()?; let world = &minecraft.world; let game_mode = &minecraft.game_mode; - let mapping = minecraft.mapping(); + let mapping = mapping(); let entities = world.get_entities()?; let range = self.get_range() as f64; diff --git a/client/src/module/movement/fly.rs b/client/src/module/movement/fly.rs index 884282f..f4ae7f5 100644 --- a/client/src/module/movement/fly.rs +++ b/client/src/module/movement/fly.rs @@ -1,5 +1,5 @@ -use crate::mapping::client::minecraft::Minecraft; use crate::module::{KeyboardKey, Module, ModuleCategory, ModuleData, ModuleSetting}; +use crate::state::minecraft; #[derive(Debug)] pub struct FlyModule { @@ -36,12 +36,12 @@ impl FlyModule { impl Module for FlyModule { fn on_start(&self) -> anyhow::Result<()> { // Enables flying - Minecraft::instance().get_player()?.abilities.fly(true) + minecraft().get_player()?.abilities.fly(true) } fn on_stop(&self) -> anyhow::Result<()> { // Disables flying - Minecraft::instance().get_player()?.abilities.fly(false) + minecraft().get_player()?.abilities.fly(false) } fn on_tick(&self) -> anyhow::Result<()> { diff --git a/client/src/state.rs b/client/src/state.rs new file mode 100644 index 0000000..804e725 --- /dev/null +++ b/client/src/state.rs @@ -0,0 +1,128 @@ +//! Global client state. +//! +//! Two things live for the whole life of the injected client: the JNI +//! [`Mapping`] bridge and the running game / module [`Client`]. Each is built +//! once by [`init`] and reached afterwards through a free accessor. +//! +//! Initialization order is explicit (see [`init`]): the mapping is built +//! first because the game wrappers resolve their classes through it. The +//! accessors `expect` the state to exist — using one before `init` is a +//! programmer error, not a runtime condition, so it panics with a clear +//! message instead of returning an `Option` every caller would unwrap. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +use jni::JNIEnv; +use log::error; +use thiserror::Error; + +use crate::mapping::client::minecraft::Minecraft; +use crate::mapping::Mapping; +use crate::module::{Module, ModuleType}; + +/// The JNI mapping bridge. Built first; the game wrappers depend on it. +static MAPPING: OnceLock = OnceLock::new(); +/// The running game and module state. +static CLIENT: OnceLock = OnceLock::new(); + +/// Something that went wrong bringing the client up. +#[derive(Debug, Error)] +pub enum ClientError { + /// [`init`] was called more than once. + #[error("the client is already initialized")] + AlreadyInitialized, + /// A construction step failed. + #[error(transparent)] + Init(#[from] anyhow::Error), +} + +/// Initializes the global client state. Must be called exactly once, before +/// any accessor is used: it builds the mapping, then the game/module state. +pub fn init() -> Result<(), ClientError> { + MAPPING + .set(Mapping::new()?) + .map_err(|_| ClientError::AlreadyInitialized)?; + CLIENT + .set(Client::new()?) + .map_err(|_| ClientError::AlreadyInitialized)?; + Ok(()) +} + +/// The JNI mapping bridge. Valid once [`init`] has succeeded. +#[inline] +pub fn mapping() -> &'static Mapping { + MAPPING.get().expect("mapping() used before client init") +} + +/// The global client. Valid once [`init`] has succeeded. +#[inline] +pub fn client() -> &'static Client { + CLIENT.get().expect("client() used before client init") +} + +/// The running Minecraft game. Valid once [`init`] has succeeded. +#[inline] +pub fn minecraft() -> &'static Minecraft { + &client().minecraft +} + +/// Attaches the current thread to the JVM and returns a JNI environment. +pub fn env() -> anyhow::Result> { + mapping().get_env() +} + +/// Registered modules, keyed by name. +type ModuleMap = RwLock>>>; + +/// The running game and module state. +pub struct Client { + minecraft: Minecraft, + /// Registered modules, keyed by name. + pub modules: ModuleMap, +} + +impl Client { + /// Builds the client. The [`MAPPING`] global must already be set. + fn new() -> Result { + Ok(Client { + minecraft: Minecraft::new()?, + modules: RwLock::new(HashMap::new()), + }) + } + + /// Registers a module under its name. + pub fn register_module(&self, module: M) + where + M: Module + Send + Sync + 'static, + { + let module: ModuleType = Box::new(module); + let name = module.get_module_data().name.clone(); + if let Ok(mut modules) = self.modules.write() { + modules.insert(name, Arc::new(Mutex::new(module))); + } + } + + /// Ticks every enabled module once. A module whose tick fails is stopped + /// rather than aborting the whole pass. + pub fn tick(&self) { + let Ok(modules) = self.modules.read() else { + return; + }; + for module in modules.values() { + let Ok(module) = module.lock() else { + continue; + }; + if !module.get_module_data().enabled { + continue; + } + if let Err(e) = module.on_tick() { + 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}"); + } + } + } + } +} From b34f6878f5a11d437824ce2585576237df1c8923 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:07:16 +0200 Subject: [PATCH 06/14] Make the client work when injected from the main menu Player, world and game mode are fetched lazily, so initialization no longer fails when no world is loaded. --- REFACTOR_PLAN.md | 3 +- client/src/graphic/esp.rs | 15 ++- client/src/graphic/hook.rs | 14 ++- client/src/graphic/input.rs | 2 +- client/src/mapping/client/minecraft.rs | 133 ++++++++++++++----------- client/src/mapping/client/world.rs | 18 +--- client/src/mapping/entity/player.rs | 20 +--- client/src/module/combat/aimbot.rs | 5 +- client/src/module/combat/aura.rs | 12 ++- client/src/module/movement/fly.rs | 14 ++- 10 files changed, 122 insertions(+), 114 deletions(-) diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index cfd4fd6..240605e 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -310,10 +310,11 @@ Each phase = one commit, compiles, behavior unchanged (except Phase 5). ### Phase 6 — client: error handling - `ClientError` (thiserror) at mapping/JNI boundaries; `lock_or_err` helper. - Remove critical-path `.unwrap()`; `tick()` and `init()` stop panicking. +- Convert the mapping caches (`classes`, `class_handles`) to `DashMap`. - ✅ `cargo check -p client` + `cargo test -p client`. ### Phase 7 — client: module system -- `ModuleRegistry` with a single `Mutex>`; tidy `Module` trait; +- `ModuleRegistry` backed by `DashMap`; tidy `Module` trait; keep explicit `register_modules()` (zero-dep, lean). - Every world-dependent module `on_tick` early-returns `Ok(())` when not in world — completes the menu-injection fix. diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index f3ceda8..0e8551f 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -511,9 +511,9 @@ fn read_fov(mapping: &Mapping) -> f64 { /// while flying and ≈×1.15 while sprinting — the constants from /// `Player.getFieldOfViewModifier`. fn fov_modifier(mapping: &Mapping) -> f64 { - let player = match minecraft().get_player() { - Ok(player) => player, - Err(_) => return 1.0, + let player = match minecraft().player() { + Ok(Some(player)) => player, + _ => return 1.0, }; let mut modifier = 1.0; @@ -642,7 +642,9 @@ fn gather_entities( env.with_local_frame(32, |env| -> anyhow::Result<()> { let (local_id, player_pos) = { - let player = mc.get_player()?; + let Some(player) = mc.player()? else { + return Ok(()); + }; let id = mapping .call_method(Cls::Entity, player.entity.jni_ref.as_obj(), "getId", &[])? .i()?; @@ -866,7 +868,10 @@ fn gather_chests() -> anyhow::Result> { return Ok(()); } - let player_pos = mc.get_player()?.entity.get_position()?; + 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; diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index 83ac139..4136023 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -98,11 +98,9 @@ unsafe fn on_frame() { /// Renders the egui overlay for the current frame. unsafe fn render_overlay() { - if state::minecraft().get_player().is_err() { - return; - } - - // Install the input hooks lazily — they need the live GLFW window. + // Install the input hooks lazily — they need the live GLFW window. The + // overlay renders whether or not a world is loaded, so the GUI works + // even when the client is injected from the main menu. crate::graphic::input::init(); crate::graphic::ui_engine::render_egui_ui(); @@ -116,12 +114,12 @@ fn check_tick() { return; } - let tick_count = match state::minecraft().get_player() { - Ok(player) => match player.entity.get_tick_count() { + let tick_count = match state::minecraft().player() { + Ok(Some(player)) => match player.entity.get_tick_count() { Ok(count) => count, Err(_) => return, }, - Err(_) => return, + Ok(None) | Err(_) => return, }; if tick_count > LAST_TICK.load(Ordering::Relaxed) { diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index e8e497d..cb879e5 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -238,7 +238,7 @@ fn toggle_gui() { /// Toggles any module whose keybind matches `key`, when in-world. fn handle_module_keybind(key: i32) { let minecraft = minecraft(); - if !minecraft.current_screen_is_null() || minecraft.get_player().is_err() { + if !minecraft.current_screen_is_null() || !minecraft.in_world() { return; } diff --git a/client/src/mapping/client/minecraft.rs b/client/src/mapping/client/minecraft.rs index 2b7c109..06e23e3 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -1,28 +1,31 @@ use crate::mapping::client::gamemode::MultiPlayerGameMode; use crate::mapping::client::window::Window; use crate::mapping::client::world::World; -use crate::mapping::entity::player::{Abilities, LocalPlayer}; -use crate::mapping::entity::Entity; +use crate::mapping::entity::player::LocalPlayer; use crate::mapping::{FieldType, MinecraftClassType}; use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; use std::sync::RwLock; -/// The running Minecraft client — the `net.minecraft.client.Minecraft` -/// instance plus the game objects reached through it. +/// The running Minecraft client. +/// +/// Only the things that exist from the main menu onward are held eagerly: the +/// `Minecraft.getInstance()` handle and the game [`Window`]. The world-scoped +/// 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)] pub struct Minecraft { pub jni_ref: GlobalRef, - player: RwLock, - #[allow(dead_code)] - pub world: World, pub window: Window, - pub game_mode: MultiPlayerGameMode, + /// Cached local player, refreshed when the underlying JVM object changes. + player: RwLock>, } 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", &[])? @@ -30,77 +33,68 @@ impl Minecraft { if minecraft.is_null() { return Err(anyhow::anyhow!("Minecraft.getInstance() returned null")); } - let minecraft = mapping().new_global_ref(minecraft)?; - - let player = LocalPlayer::new(&minecraft)?; - let world = World::new(&minecraft)?; - let window = Window::new(&minecraft)?; - let game_mode = MultiPlayerGameMode::new( - mapping().new_global_ref( - mapping() - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "gameMode", - FieldType::Object(MinecraftClassType::MultiPlayerGameMode), - )? - .l()?, - )?, - ); + let jni_ref = mapping().new_global_ref(minecraft)?; + let window = Window::new(&jni_ref)?; Ok(Minecraft { - jni_ref: minecraft, - player: RwLock::new(player), - world, + jni_ref, window, - game_mode, + player: RwLock::new(None), }) } - /// Returns the local player, refreshing the cache when the underlying - /// JVM object has changed (a new world join produces a new instance). - pub fn get_player(&self) -> anyhow::Result { - let player_obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - "player", - FieldType::Object(MinecraftClassType::LocalPlayer), - )? - .l()?; - - if player_obj.is_null() { - return Err(anyhow::anyhow!("Player is null")); - } + /// The local player, or `Ok(None)` when not in a world. + /// + /// The result is cached and refreshed when the underlying JVM object + /// 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 { + return Ok(None); + }; { - let read_guard = self + let cache = self .player .read() .map_err(|_| anyhow::anyhow!("Lock poisoned"))?; - if mapping() - .get_env()? - .is_same_object(&read_guard.jni_ref, &player_obj)? - { - return Ok(read_guard.clone()); + if let Some(cached) = cache.as_ref() { + if mapping() + .get_env()? + .is_same_object(&cached.jni_ref, &player_ref)? + { + return Ok(Some(cached.clone())); + } } } - let mut write_guard = self + let player = LocalPlayer::new(player_ref)?; + *self .player .write() - .map_err(|_| anyhow::anyhow!("Lock poisoned"))?; + .map_err(|_| anyhow::anyhow!("Lock poisoned"))? = Some(player.clone()); + Ok(Some(player)) + } - let jni_ref = mapping().new_global_ref(player_obj)?; - *write_guard = LocalPlayer { - jni_ref: jni_ref.clone(), - abilities: Abilities::new(jni_ref.clone())?, - entity: Entity::new(jni_ref), - }; + /// The current world / level, or `Ok(None)` when not in a world. + pub fn world(&self) -> anyhow::Result> { + Ok(self + .world_object("level", MinecraftClassType::Level)? + .map(World::new)) + } + + /// The interaction controller, or `Ok(None)` when not in a world. + pub fn game_mode(&self) -> anyhow::Result> { + Ok(self + .world_object("gameMode", MinecraftClassType::MultiPlayerGameMode)? + .map(MultiPlayerGameMode::new)) + } - Ok(write_guard.clone()) + /// Whether a world is currently loaded. + pub fn in_world(&self) -> bool { + matches!(self.player(), Ok(Some(_))) } + /// 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, @@ -114,6 +108,27 @@ impl Minecraft { } true } + + /// Reads a world-scoped object field of `Minecraft`, returning `Ok(None)` + /// when it is null — i.e. when there is no world loaded. + fn world_object( + &self, + field: &str, + class: MinecraftClassType, + ) -> anyhow::Result> { + let obj = mapping() + .get_field( + MinecraftClassType::Minecraft, + self.jni_ref.as_obj(), + field, + FieldType::Object(class), + )? + .l()?; + if obj.is_null() { + return Ok(None); + } + Ok(Some(mapping().new_global_ref(obj)?)) + } } impl Deref for Minecraft { diff --git a/client/src/mapping/client/world.rs b/client/src/mapping/client/world.rs index f698da6..be25a6a 100644 --- a/client/src/mapping/client/world.rs +++ b/client/src/mapping/client/world.rs @@ -1,6 +1,6 @@ use crate::mapping::entity::Entity; use crate::mapping::java::iterable::Iterable; -use crate::mapping::{FieldType, MinecraftClassType}; +use crate::mapping::MinecraftClassType; use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -11,19 +11,9 @@ pub struct World { } impl World { - pub fn new(minecraft: &GlobalRef) -> anyhow::Result { - let world_obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "level", - FieldType::Object(MinecraftClassType::Level), - )? - .l()?; - - Ok(World { - jni_ref: mapping().new_global_ref(world_obj)?, - }) + /// Wraps an existing `Level` JVM object. + pub fn new(jni_ref: GlobalRef) -> World { + World { jni_ref } } pub fn get_entities(&self) -> anyhow::Result> { diff --git a/client/src/mapping/entity/player.rs b/client/src/mapping/entity/player.rs index d8d78a0..fc4f87c 100644 --- a/client/src/mapping/entity/player.rs +++ b/client/src/mapping/entity/player.rs @@ -18,24 +18,12 @@ pub struct Abilities { } impl LocalPlayer { - pub fn new(minecraft: &GlobalRef) -> anyhow::Result { - let player_obj = mapping() - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "player", - FieldType::Object(MinecraftClassType::LocalPlayer), - )? - .l()?; - - let player_ref = mapping().new_global_ref(player_obj)?; - let abilities = Abilities::new(player_ref.clone())?; - let entity = Entity::new(player_ref.clone()); - + /// Wraps an existing `LocalPlayer` JVM object. + pub fn new(player_ref: GlobalRef) -> anyhow::Result { Ok(Self { + abilities: Abilities::new(player_ref.clone())?, + entity: Entity::new(player_ref.clone()), jni_ref: player_ref, - abilities, - entity, }) } } diff --git a/client/src/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index f9d4d19..0286cfc 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -47,8 +47,9 @@ impl Module for AimbotModule { fn on_tick(&self) -> anyhow::Result<()> { let minecraft = minecraft(); - let player = &minecraft.get_player()?; - let world = &minecraft.world; + let (Some(player), Some(world)) = (minecraft.player()?, minecraft.world()?) else { + return Ok(()); // not in a world — nothing to do + }; let entities = world.get_entities()?; let range = self.get_range() as f64; diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index 5320c22..a89dff6 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -52,9 +52,13 @@ impl Module for BaseAura { fn on_tick(&self) -> anyhow::Result<()> { let minecraft = minecraft(); - let player = &minecraft.get_player()?; - let world = &minecraft.world; - let game_mode = &minecraft.game_mode; + let (Some(player), Some(world), Some(game_mode)) = ( + minecraft.player()?, + minecraft.world()?, + minecraft.game_mode()?, + ) else { + return Ok(()); // not in a world — nothing to do + }; let mapping = mapping(); let entities = world.get_entities()?; @@ -79,7 +83,7 @@ impl Module for BaseAura { .sqrt(); if dist <= range { - game_mode.attack(player, &entity)?; + game_mode.attack(&player, &entity)?; } } diff --git a/client/src/module/movement/fly.rs b/client/src/module/movement/fly.rs index f4ae7f5..fa66ef1 100644 --- a/client/src/module/movement/fly.rs +++ b/client/src/module/movement/fly.rs @@ -35,13 +35,19 @@ impl FlyModule { impl Module for FlyModule { fn on_start(&self) -> anyhow::Result<()> { - // Enables flying - minecraft().get_player()?.abilities.fly(true) + // Enable flying, if the player is in a world. + if let Some(player) = minecraft().player()? { + player.abilities.fly(true)?; + } + Ok(()) } fn on_stop(&self) -> anyhow::Result<()> { - // Disables flying - minecraft().get_player()?.abilities.fly(false) + // Disable flying, if the player is in a world. + if let Some(player) = minecraft().player()? { + player.abilities.fly(false)?; + } + Ok(()) } fn on_tick(&self) -> anyhow::Result<()> { From 5d49b0e96f22d434307f01f32bbab600ff08ac4a Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:10:01 +0200 Subject: [PATCH 07/14] Use DashMap for the mapping caches and tidy error handling Sharded caches remove lock contention on the render path, and a real panic in method lookup is fixed. --- Cargo.lock | 30 ++++++++++++++------ Cargo.toml | 1 + client/Cargo.toml | 1 + client/src/mapping/class.rs | 8 +++--- client/src/mapping/mod.rs | 55 +++++++++++++++++++------------------ 5 files changed, 56 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a342c48..0d767af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -660,6 +660,7 @@ name = "client" version = "0.1.0" dependencies = [ "anyhow", + "dashmap", "egui", "egui_glow", "gl_generator", @@ -849,6 +850,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1796,11 +1811,10 @@ checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -2285,9 +2299,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "orbclient" @@ -2335,15 +2349,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.6", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e92f0c1..e094571 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ thiserror = "2.0" libc = "0.2" jni = "0.21" serde = { version = "1.0", features = ["derive"] } +dashmap = "6.1" sysinfo = "0.37.2" crossterm = "0.29" ctor = "0.2.8" diff --git a/client/Cargo.toml b/client/Cargo.toml index b060d3e..39fbfd7 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -15,6 +15,7 @@ jni.workspace = true serde.workspace = true anyhow.workspace = true thiserror.workspace = true +dashmap.workspace = true libc.workspace = true egui_glow = "0.29.0" glow = "0.14.0" diff --git a/client/src/mapping/class.rs b/client/src/mapping/class.rs index 954c974..074eef3 100644 --- a/client/src/mapping/class.rs +++ b/client/src/mapping/class.rs @@ -112,10 +112,10 @@ impl MinecraftClass { } pub fn get_method(&self, name: &str) -> anyhow::Result<&Method> { - match self.methods.get(name).unwrap().first() { - Some(method) => Ok(method), - None => Err(anyhow!("{} method not found", name)), - } + self.methods + .get(name) + .and_then(|overloads| overloads.first()) + .ok_or_else(|| anyhow!("{} method not found", name)) } pub fn get_methods(&self, name: &str) -> anyhow::Result<&Vec> { diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index 0f547f6..aed8659 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; use crate::mapping::minecraft_version::MinecraftVersion; +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}; @@ -46,8 +47,9 @@ pub struct Mapping { mode: Mode, version: MinecraftVersion, /// In obfuscated mode every class is present up-front; in reflected mode - /// classes are discovered and cached on first use. - classes: RwLock>>, + /// classes are discovered and cached on first use. A `DashMap` so the + /// render thread reads it without contending on one global lock. + classes: DashMap>, /// The class loader that runs the game. On modded builds (Fabric / Forge) /// it is discovered up-front by [`loader::discover_game_loader`]; on vanilla /// it is captured the first time a class resolves. `JNIEnv::find_class` is @@ -58,7 +60,7 @@ pub struct Mapping { class_loader: RwLock>, /// Cache of resolved JVM classes — and known-missing ones (`None`) — keyed /// by JNI name, so a class is searched for at most once. - class_handles: RwLock>>, + class_handles: DashMap>, } #[allow(dead_code)] @@ -154,9 +156,9 @@ impl Mapping { jvm, mode: Mode::Reflected, version: MinecraftVersion::LATEST, - classes: RwLock::new(HashMap::new()), + classes: DashMap::new(), class_loader: RwLock::new(game_loader), - class_handles: RwLock::new(HashMap::new()), + class_handles: DashMap::new(), }); } @@ -179,9 +181,9 @@ impl Mapping { jvm, mode: Mode::Obfuscated, version: file.version, - classes: RwLock::new(classes), + classes, class_loader: RwLock::new(None), - class_handles: RwLock::new(HashMap::new()), + class_handles: DashMap::new(), }) } @@ -198,23 +200,25 @@ impl Mapping { /// Resolves a mapped class by its deobfuscated name. In reflected mode the /// class is reflected from the JVM and cached on first request. pub fn get_class(&self, name: &str) -> anyhow::Result> { - if let Some(class) = self.classes.read().unwrap().get(name) { - return Ok(Arc::clone(class)); + if let Some(class) = self.classes.get(name) { + return Ok(Arc::clone(class.value())); } match self.mode { Mode::Obfuscated => Err(anyhow::anyhow!("{} java class not found", name)), Mode::Reflected => { let class = Arc::new(reflect::reflect_class(self, name)?); - self.classes - .write() - .unwrap() - .insert(name.to_owned(), Arc::clone(&class)); + self.classes.insert(name.to_owned(), Arc::clone(&class)); Ok(class) } } } + /// The captured Minecraft class loader, if any — poison-safe. + fn loader(&self) -> Option { + self.class_loader.read().ok().and_then(|slot| slot.clone()) + } + /// Resolves a JVM class by its JNI name, working from any thread. /// /// `JNIEnv::find_class` resolves against the class loader of the calling @@ -231,8 +235,8 @@ impl Mapping { env: &mut JNIEnv<'a>, jni_name: &str, ) -> anyhow::Result> { - if let Some(cached) = self.class_handles.read().unwrap().get(jni_name).cloned() { - return match cached { + if let Some(cached) = self.class_handles.get(jni_name) { + return match cached.value() { Some(handle) => Ok(JClass::from(env.new_local_ref(handle.as_obj())?)), None => Err(anyhow::anyhow!("Class {} not present at runtime", jni_name)), }; @@ -249,17 +253,14 @@ impl Mapping { jni_name ); } - self.class_handles - .write() - .unwrap() - .insert(jni_name.to_owned(), handle); + self.class_handles.insert(jni_name.to_owned(), handle); resolved } /// Looks a class up from scratch: through the captured Minecraft class /// loader if available, otherwise through `find_class`. fn lookup_class<'a>(&self, env: &mut JNIEnv<'a>, jni_name: &str) -> anyhow::Result> { - if let Some(loader) = self.class_loader.read().unwrap().clone() { + if let Some(loader) = self.loader() { let binary_name = jni_name.replace('/', "."); let name: JObject = env.new_string(binary_name)?.into(); return match env.call_method( @@ -292,14 +293,16 @@ impl Mapping { /// Records the class loader of `jclass` as the Minecraft class loader. fn capture_class_loader(&self, env: &mut JNIEnv, jclass: &JClass) { - if self.class_loader.read().unwrap().is_some() { + if self.loader().is_some() { return; } let loader = env.call_method(jclass, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]); match loader.and_then(|value| value.l()) { Ok(obj) if !obj.is_null() => { - if let Ok(global) = env.new_global_ref(obj) { - *self.class_loader.write().unwrap() = Some(global); + if let (Ok(global), Ok(mut slot)) = + (env.new_global_ref(obj), self.class_loader.write()) + { + *slot = Some(global); } } _ => { @@ -336,11 +339,9 @@ impl Mapping { /// Only meaningful in obfuscated mode; used to prettify error messages. fn find_class_by_obfuscated_name(&self, obfuscated_name: &str) -> Option { self.classes - .read() - .unwrap() .iter() - .find(|(_, class)| class.name == obfuscated_name) - .map(|(deobfuscated_name, _)| deobfuscated_name.clone()) + .find(|entry| entry.value().name == obfuscated_name) + .map(|entry| entry.key().clone()) } fn translate_type_descriptor<'a>(&self, descriptor: &mut &'a str) -> String { From 344a1b8f52a8058380a0d3de9313c803012fcef9 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:12:42 +0200 Subject: [PATCH 08/14] Back the module registry with DashMap A dedicated registry type replaces the ad-hoc locked map shared across the UI and the tick loop. --- client/src/graphic/esp.rs | 11 ++--- client/src/graphic/hook.rs | 2 +- client/src/graphic/hud.rs | 21 +++++---- client/src/graphic/input.rs | 11 ++--- client/src/graphic/menu.rs | 5 +-- client/src/graphic/ui_engine.rs | 24 +++++----- client/src/lib.rs | 16 +++---- client/src/module/mod.rs | 1 + client/src/module/registry.rs | 80 +++++++++++++++++++++++++++++++++ client/src/state.rs | 50 +++------------------ 10 files changed, 125 insertions(+), 96 deletions(-) create mode 100644 client/src/module/registry.rs diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index 0e8551f..6139564 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -292,12 +292,9 @@ fn read_config() -> EspConfig { }, }; - let registry = match client().modules.read() { - Ok(guard) => guard, - Err(_) => return cfg, - }; + let modules = &client().modules; - if let Some(arc) = registry.get("Player ESP") { + if let Some(arc) = modules.get("Player ESP") { if let Ok(module) = arc.lock() { let data = module.get_module_data(); cfg.player = EntityCfg { @@ -310,7 +307,7 @@ fn read_config() -> EspConfig { }; } } - if let Some(arc) = registry.get("Mob ESP") { + if let Some(arc) = modules.get("Mob ESP") { if let Ok(module) = arc.lock() { let data = module.get_module_data(); cfg.mob = EntityCfg { @@ -323,7 +320,7 @@ fn read_config() -> EspConfig { }; } } - if let Some(arc) = registry.get("Chest ESP") { + if let Some(arc) = modules.get("Chest ESP") { 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 4136023..5f65165 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -124,7 +124,7 @@ fn check_tick() { if tick_count > LAST_TICK.load(Ordering::Relaxed) { LAST_TICK.store(tick_count, Ordering::Relaxed); - state::client().tick(); + state::client().modules.tick(); } } diff --git a/client/src/graphic/hud.rs b/client/src/graphic/hud.rs index 8de2a74..4cc4a33 100644 --- a/client/src/graphic/hud.rs +++ b/client/src/graphic/hud.rs @@ -87,17 +87,16 @@ fn draw_arraylist(ctx: &Context, painter: &Painter) { let font = FontId::proportional(14.0); // One lock per module: snapshot just the name and enabled flag. - let snapshot: Vec<(String, bool)> = match crate::state::client().modules.read() { - Ok(guard) => guard - .values() - .map(|m| { - let data = m.lock().unwrap(); - let d = data.get_module_data(); - (d.name.clone(), d.enabled) - }) - .collect(), - Err(_) => return, - }; + let snapshot: Vec<(String, bool)> = crate::state::client() + .modules + .handles() + .iter() + .filter_map(|m| { + let module = m.lock().ok()?; + let data = module.get_module_data(); + Some((data.name.clone(), data.enabled)) + }) + .collect(); // Resolve a smooth presence factor for every module. Disabled modules // keep a slot while their factor decays toward zero. diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index cb879e5..6439b7f 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -242,13 +242,10 @@ fn handle_module_keybind(key: i32) { return; } - let client = client(); - let Ok(modules) = client.modules.read() else { - return; - }; - - for module in modules.values() { - let mut module = module.lock().unwrap(); + for handle in client().modules.handles() { + let Ok(mut module) = handle.lock() else { + continue; + }; if module.get_module_data().key_bind as i32 != key { continue; } diff --git a/client/src/graphic/menu.rs b/client/src/graphic/menu.rs index 1c44671..f5dd4b0 100644 --- a/client/src/graphic/menu.rs +++ b/client/src/graphic/menu.rs @@ -41,10 +41,7 @@ type ModuleMap = HashMap; pub fn draw(ctx: &Context, progress: f32) { draw_backdrop(ctx, progress); - let registry = match crate::state::client().modules.read() { - Ok(guard) => guard, - Err(_) => return, - }; + let registry = crate::state::client().modules.by_name(); // Single lock per module: collect the data layout needs, nothing more. let mut entries: Vec<(String, ModuleCategory)> = registry diff --git a/client/src/graphic/ui_engine.rs b/client/src/graphic/ui_engine.rs index ba99cb3..d8c1811 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -214,22 +214,20 @@ pub unsafe fn render_egui_ui() { } pub fn call_panic() { - let client = crate::state::client(); - client.modules.read().unwrap().values().for_each(|module| { - let mut module = module.lock().unwrap(); + for handle in crate::state::client().modules.handles() { + let Ok(mut module) = handle.lock() else { + continue; + }; if module.get_module_data().enabled { module.get_module_data_mut().set_enabled(false); - match module.on_stop() { - Ok(_) => {} - Err(e) => { - log::error!( - "Failed to stop module {} on panic: {}", - module.get_module_data().name, - e - ); - } + if let Err(e) = module.on_stop() { + log::error!( + "Failed to stop module {} on panic: {}", + module.get_module_data().name, + e + ); } } - }); + } cleanup_client(); } diff --git a/client/src/lib.rs b/client/src/lib.rs index 83b4ab5..9b5ce61 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -86,12 +86,12 @@ pub extern "C" fn cleanup_client() { /// Registers the built-in modules with the client. fn register_modules() { - let client = client(); - client.register_module(FlyModule::new()); - client.register_module(KillAuraModule::new()); - client.register_module(MobAuraModule::new()); - client.register_module(AimbotModule::new()); - client.register_module(PlayerEspModule::new()); - client.register_module(MobEspModule::new()); - client.register_module(ChestEspModule::new()); + let modules = &client().modules; + modules.register(FlyModule::new()); + modules.register(KillAuraModule::new()); + modules.register(MobAuraModule::new()); + modules.register(AimbotModule::new()); + modules.register(PlayerEspModule::new()); + modules.register(MobEspModule::new()); + modules.register(ChestEspModule::new()); } diff --git a/client/src/module/mod.rs b/client/src/module/mod.rs index b18518a..221ad17 100644 --- a/client/src/module/mod.rs +++ b/client/src/module/mod.rs @@ -2,6 +2,7 @@ use std::fmt::Debug; pub mod combat; pub mod movement; +pub mod registry; pub mod render; pub type ModuleType = Box; diff --git a/client/src/module/registry.rs b/client/src/module/registry.rs new file mode 100644 index 0000000..59bb787 --- /dev/null +++ b/client/src/module/registry.rs @@ -0,0 +1,80 @@ +//! The module registry — every registered module, keyed by name. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use dashmap::DashMap; +use log::error; + +use crate::module::{Module, ModuleType}; + +/// A shared, lockable handle to one module. +pub type ModuleHandle = Arc>; + +/// 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, +} + +impl ModuleRegistry { + /// Creates an empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Registers a module under its declared name. + 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))); + } + + /// A handle to one module by name. + pub fn get(&self, name: &str) -> Option { + self.modules + .get(name) + .map(|entry| Arc::clone(entry.value())) + } + + /// Handles to every module. Snapshotted, so the caller holds no shard + /// locks while it works with the modules. + pub fn handles(&self) -> Vec { + self.modules + .iter() + .map(|entry| Arc::clone(entry.value())) + .collect() + } + + /// Every module keyed by name — an owned snapshot. + pub fn by_name(&self) -> HashMap { + self.modules + .iter() + .map(|entry| (entry.key().clone(), Arc::clone(entry.value()))) + .collect() + } + + /// Ticks every enabled module once. A module whose tick fails is stopped + /// rather than aborting the whole pass. + pub fn tick(&self) { + for handle in self.handles() { + let Ok(module) = handle.lock() else { + continue; + }; + if !module.get_module_data().enabled { + continue; + } + if let Err(e) = module.on_tick() { + 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}"); + } + } + } + } +} diff --git a/client/src/state.rs b/client/src/state.rs index 804e725..452fc5c 100644 --- a/client/src/state.rs +++ b/client/src/state.rs @@ -10,16 +10,14 @@ //! programmer error, not a runtime condition, so it panics with a clear //! message instead of returning an `Option` every caller would unwrap. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::OnceLock; use jni::JNIEnv; -use log::error; use thiserror::Error; use crate::mapping::client::minecraft::Minecraft; use crate::mapping::Mapping; -use crate::module::{Module, ModuleType}; +use crate::module::registry::ModuleRegistry; /// The JNI mapping bridge. Built first; the game wrappers depend on it. static MAPPING: OnceLock = OnceLock::new(); @@ -72,14 +70,11 @@ pub fn env() -> anyhow::Result> { mapping().get_env() } -/// Registered modules, keyed by name. -type ModuleMap = RwLock>>>; - /// The running game and module state. pub struct Client { minecraft: Minecraft, - /// Registered modules, keyed by name. - pub modules: ModuleMap, + /// Every registered module. + pub modules: ModuleRegistry, } impl Client { @@ -87,42 +82,7 @@ impl Client { fn new() -> Result { Ok(Client { minecraft: Minecraft::new()?, - modules: RwLock::new(HashMap::new()), + modules: ModuleRegistry::new(), }) } - - /// Registers a module under its name. - pub fn register_module(&self, module: M) - where - M: Module + Send + Sync + 'static, - { - let module: ModuleType = Box::new(module); - let name = module.get_module_data().name.clone(); - if let Ok(mut modules) = self.modules.write() { - modules.insert(name, Arc::new(Mutex::new(module))); - } - } - - /// Ticks every enabled module once. A module whose tick fails is stopped - /// rather than aborting the whole pass. - pub fn tick(&self) { - let Ok(modules) = self.modules.read() else { - return; - }; - for module in modules.values() { - let Ok(module) = module.lock() else { - continue; - }; - if !module.get_module_data().enabled { - continue; - } - if let Err(e) = module.on_tick() { - 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}"); - } - } - } - } } From 7c8974ad97d18ae2637b1d9a39a0157166a9c2c9 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:17:09 +0200 Subject: [PATCH 09/14] Add a macOS-ready graphics platform seam OpenGL resolution and the frame hook move behind a per-OS module with Linux, Windows and a macOS stub. --- client/src/graphic/hook.rs | 157 ++++--------------------- client/src/graphic/input.rs | 23 +--- client/src/graphic/mod.rs | 1 + client/src/graphic/platform/linux.rs | 101 ++++++++++++++++ client/src/graphic/platform/macos.rs | 56 +++++++++ client/src/graphic/platform/mod.rs | 45 +++++++ client/src/graphic/platform/windows.rs | 77 ++++++++++++ client/src/graphic/ui_engine.rs | 2 +- 8 files changed, 302 insertions(+), 160 deletions(-) create mode 100644 client/src/graphic/platform/linux.rs create mode 100644 client/src/graphic/platform/macos.rs create mode 100644 client/src/graphic/platform/mod.rs create mode 100644 client/src/graphic/platform/windows.rs diff --git a/client/src/graphic/hook.rs b/client/src/graphic/hook.rs index 5f65165..c1b31c6 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -1,13 +1,13 @@ //! Frame hook. //! //! Intercepts the host's buffer-swap call so the overlay renders and the -//! client ticks exactly once per frame. OpenGL entry-point resolution — the -//! loader behind the `gl` and `glow` bindings — lives here too. +//! client ticks exactly once per frame. Per-platform entry-point resolution +//! lives in [`crate::graphic::platform`]. +use crate::graphic::platform; use crate::{gl, state, RUNNING}; use ilhook::x64::{CallbackOption, HookFlags, HookPoint, HookType, Hooker, Registers}; use log::info; -use std::ffi::c_void; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -29,48 +29,6 @@ fn global_hook() -> &'static Mutex> { GLOBAL_HOOK.get_or_init(|| Mutex::new(None)) } -// --- OpenGL function resolution ------------------------------------------- - -/// Shared library that exports the OpenGL entry points on this platform. -const GL_LIBRARY: &str = if cfg!(target_os = "windows") { - "opengl32.dll" -} else { - "libGL.so.1" -}; - -/// Lazily-opened, process-lifetime handle to the OpenGL library. -fn gl_library() -> Option<&'static libloading::Library> { - static LIB: OnceLock> = OnceLock::new(); - LIB.get_or_init(|| unsafe { libloading::Library::new(GL_LIBRARY).ok() }) - .as_ref() -} - -/// Resolves an OpenGL function pointer by name — the loader fed to `gl` and -/// `glow`. Returns null when the symbol cannot be found. -pub fn get_proc_address(name: &str) -> *const c_void { - let Some(lib) = gl_library() else { - return std::ptr::null(); - }; - unsafe { - // On Windows, modern extension entry points are reachable only through - // wglGetProcAddress; opengl32.dll itself exports just the GL 1.1 core. - #[cfg(target_os = "windows")] - if let Ok(c_name) = std::ffi::CString::new(name) { - type WglGetProcAddress = unsafe extern "system" fn(*const i8) -> *const c_void; - if let Ok(wgl) = lib.get::(b"wglGetProcAddress") { - let ptr = wgl(c_name.as_ptr()); - if !ptr.is_null() { - return ptr; - } - } - } - match lib.get::(name.as_bytes()) { - Ok(symbol) => *symbol as *const c_void, - Err(_) => std::ptr::null(), - } - } -} - // --- Per-frame logic ------------------------------------------------------- /// `ilhook` trampoline for the host's buffer-swap function. @@ -88,7 +46,7 @@ unsafe fn on_frame() { // Resolve the GL function pointers once, on the live render context. if !GL_LOADED.load(Ordering::Relaxed) { - gl::load_with(get_proc_address); + gl::load_with(platform::gl_proc_address); GL_LOADED.store(true, Ordering::Relaxed); } @@ -130,26 +88,8 @@ fn check_tick() { // --- Hook installation ----------------------------------------------------- -/// Finds the on-disk path of a loaded shared object by scanning the process's -/// memory map. Linux-only — used to hook the exact GLFW the host loaded. -#[cfg(target_os = "linux")] -pub fn find_library_path(partial_name: &str) -> Option { - use std::io::{BufRead, BufReader}; - - let file = std::fs::File::open("/proc/self/maps").ok()?; - for line in BufReader::new(file).lines().map_while(Result::ok) { - // Format: address perms offset dev inode PATH - if line.contains(partial_name) && line.contains(".so") { - if let Some(path) = line.split_whitespace().last() { - return Some(path.to_string()); - } - } - } - None -} - -/// Installs the buffer-swap hook that drives the overlay. Idempotent across -/// re-injection: the previous hook is dropped (and thus removed) first. +/// Stores a freshly built hook. Idempotent across re-injection: the previous +/// `HookHandle` drops here, restoring its patched bytes. fn store_hook(hook: HookPoint, label: &str) { let mut guard = global_hook().lock().unwrap(); if guard.is_some() { @@ -170,81 +110,24 @@ fn hooker_for(target_addr: usize) -> Hooker { ) } -#[cfg(target_os = "linux")] +/// Installs the buffer-swap hook that drives the overlay, trying every +/// platform-provided target until one hooks successfully. pub fn install_hooks() -> anyhow::Result<()> { - use std::ffi::CString; - - // Candidate (library, exported swap function) pairs, most specific first. - let mut targets: Vec<(String, &str)> = Vec::new(); - if let Some(path) = find_library_path("libglfw.so") { - info!("Found GLFW library: {}", path); - targets.push((path, "glfwSwapBuffers")); - } else if let Some(path) = find_library_path("liblwjgl.so") { - info!("Found LWJGL library (legacy): {}", path); - targets.push((path, "glXSwapBuffers")); - } else { - info!("No specific library found, falling back to system libGL."); - targets.push(("libGL.so.1".to_string(), "glXSwapBuffers")); - } - - for (lib_path, func_name) in targets { - let c_lib_path = CString::new(lib_path.clone())?; - let c_func_name = CString::new(func_name)?; - - unsafe { - let lib = libc::dlopen(c_lib_path.as_ptr(), libc::RTLD_LAZY); - if lib.is_null() { - continue; - } - let target_addr = libc::dlsym(lib, c_func_name.as_ptr()) as usize; - if target_addr == 0 { - continue; - } - info!("Found {} in {} at 0x{:x}", func_name, lib_path, target_addr); - - match hooker_for(target_addr).hook() { - Ok(hook) => { - store_hook(hook, &lib_path); - return Ok(()); - } - Err(e) => info!("Error installing hook on {}: {:?}", lib_path, e), + for target in platform::frame_hook_targets() { + // SAFETY: `target.address` is a function address resolved by the + // platform layer; `ilhook` patches it in place. + let result = unsafe { hooker_for(target.address).hook() }; + match result { + Ok(hook) => { + store_hook(hook, &target.label); + return Ok(()); } + Err(e) => info!("hook install failed on {}: {e:?}", target.label), } } - - Err(anyhow::anyhow!("Failed to hook any candidate libraries!")) -} - -#[cfg(target_os = "windows")] -pub fn install_hooks() -> anyhow::Result<()> { - use libloading::Library; - - const LIB_NAME: &str = "opengl32.dll"; - const FUNC_NAME: &[u8] = b"wglSwapBuffers"; - - unsafe { - let lib = Library::new(LIB_NAME) - .map_err(|e| anyhow::anyhow!("Failed to load {}: {}", LIB_NAME, e))?; - - let swap_buffers: libloading::Symbol = lib - .get(FUNC_NAME) - .map_err(|e| anyhow::anyhow!("wglSwapBuffers missing: {}", e))?; - - let target_addr = *swap_buffers as *const () as usize; - info!( - "Found wglSwapBuffers in {} at 0x{:x}", - LIB_NAME, target_addr - ); - - let hook = hooker_for(target_addr) - .hook() - .map_err(|e| anyhow::anyhow!("Failed to hook wglSwapBuffers: {:?}", e))?; - store_hook(hook, LIB_NAME); - - // Keep the library handle alive for the process lifetime. - std::mem::forget(lib); - } - Ok(()) + Err(anyhow::anyhow!( + "no buffer-swap target could be hooked on this platform" + )) } /// Removes the active buffer-swap hook, restoring the original bytes. diff --git a/client/src/graphic/input.rs b/client/src/graphic/input.rs index 6439b7f..5addf60 100644 --- a/client/src/graphic/input.rs +++ b/client/src/graphic/input.rs @@ -109,27 +109,6 @@ fn set_cursor_lock(x: f64, y: f64) { CURSOR_LOCK_Y.store(y.to_bits(), Ordering::Relaxed); } -// --- Platform library lookup ---------------------------------------------- - -/// Opens the GLFW shared library the host process loaded. -#[cfg(target_os = "linux")] -fn open_glfw_library() -> Option { - let path = crate::graphic::hook::find_library_path("libglfw.so") - .unwrap_or_else(|| "libglfw.so".to_string()); - unsafe { Library::new(path).ok() } -} - -/// Opens the GLFW shared library, trying the names Minecraft launchers use. -#[cfg(target_os = "windows")] -fn open_glfw_library() -> Option { - unsafe { - Library::new("glfw.dll") - .or_else(|_| Library::new("glfw3.dll")) - .or_else(|_| Library::new("glfw64.dll")) - .ok() - } -} - // --- Callbacks ------------------------------------------------------------- extern "C" fn on_mouse_button(window: *mut c_void, button: i32, action: i32, mods: i32) { @@ -282,7 +261,7 @@ pub fn init() { /// Resolves the GLFW symbols, swaps in our callbacks, and captures the state /// needed to restore them. Returns `None` until the window is ready. fn install_glfw_hooks() -> Option { - let library = open_glfw_library()?; + let library = crate::graphic::platform::open_glfw_library()?; unsafe { let get_context = *library diff --git a/client/src/graphic/mod.rs b/client/src/graphic/mod.rs index b4cf845..7f9db79 100644 --- a/client/src/graphic/mod.rs +++ b/client/src/graphic/mod.rs @@ -6,5 +6,6 @@ pub mod hud; pub mod input; pub mod menu; pub mod notification; +pub mod platform; pub mod theme; pub mod ui_engine; diff --git a/client/src/graphic/platform/linux.rs b/client/src/graphic/platform/linux.rs new file mode 100644 index 0000000..cd6de62 --- /dev/null +++ b/client/src/graphic/platform/linux.rs @@ -0,0 +1,101 @@ +//! Linux graphics platform glue. + +use std::ffi::{c_void, CString}; +use std::sync::OnceLock; + +use libloading::Library; +use log::info; + +use super::HookTarget; + +/// Shared library that exports the OpenGL entry points. +const GL_LIBRARY: &str = "libGL.so.1"; + +/// Lazily-opened, process-lifetime handle to the OpenGL library. +fn gl_library() -> Option<&'static Library> { + static LIB: OnceLock> = OnceLock::new(); + // SAFETY: opening the GL library the host process already maps. + LIB.get_or_init(|| unsafe { Library::new(GL_LIBRARY).ok() }) + .as_ref() +} + +/// Resolves an OpenGL function pointer by name. +pub fn gl_proc_address(name: &str) -> *const c_void { + let Some(lib) = gl_library() else { + return std::ptr::null(); + }; + // SAFETY: resolving an exported symbol of the GL library by name. + unsafe { + match lib.get::(name.as_bytes()) { + Ok(symbol) => *symbol as *const c_void, + Err(_) => std::ptr::null(), + } + } +} + +/// Opens the GLFW shared library the host loaded. +pub fn open_glfw_library() -> Option { + let path = find_library_path("libglfw.so").unwrap_or_else(|| "libglfw.so".to_string()); + // SAFETY: loading the GLFW library the host process already maps. + unsafe { Library::new(path).ok() } +} + +/// The buffer-swap functions to try hooking, most specific first. +pub fn frame_hook_targets() -> Vec { + // (library, exported swap function) candidates, most specific first. + let mut candidates: Vec<(String, &str)> = Vec::new(); + if let Some(path) = find_library_path("libglfw.so") { + info!("found GLFW library: {path}"); + candidates.push((path, "glfwSwapBuffers")); + } else if let Some(path) = find_library_path("liblwjgl.so") { + info!("found LWJGL library (legacy): {path}"); + candidates.push((path, "glXSwapBuffers")); + } else { + info!("no specific GL library found; falling back to system libGL"); + candidates.push((GL_LIBRARY.to_string(), "glXSwapBuffers")); + } + + candidates + .into_iter() + .filter_map(|(lib_path, func)| { + let address = resolve_symbol(&lib_path, func)?; + info!("found {func} in {lib_path} at 0x{address:x}"); + Some(HookTarget { + address, + label: lib_path, + }) + }) + .collect() +} + +/// Resolves the address of `func` exported by `lib_path` via `dlopen`/`dlsym`. +fn resolve_symbol(lib_path: &str, func: &str) -> Option { + let c_lib = CString::new(lib_path).ok()?; + let c_func = CString::new(func).ok()?; + // SAFETY: dlopen/dlsym on a library the host process already maps. + unsafe { + let handle = libc::dlopen(c_lib.as_ptr(), libc::RTLD_LAZY); + if handle.is_null() { + return None; + } + let address = libc::dlsym(handle, c_func.as_ptr()) as usize; + (address != 0).then_some(address) + } +} + +/// Finds the on-disk path of a loaded shared object by scanning the process +/// memory map. +fn find_library_path(partial_name: &str) -> Option { + use std::io::{BufRead, BufReader}; + + let file = std::fs::File::open("/proc/self/maps").ok()?; + for line in BufReader::new(file).lines().map_while(Result::ok) { + // Format: address perms offset dev inode PATH + if line.contains(partial_name) && line.contains(".so") { + if let Some(path) = line.split_whitespace().last() { + return Some(path.to_string()); + } + } + } + None +} diff --git a/client/src/graphic/platform/macos.rs b/client/src/graphic/platform/macos.rs new file mode 100644 index 0000000..c851082 --- /dev/null +++ b/client/src/graphic/platform/macos.rs @@ -0,0 +1,56 @@ +//! macOS — and any other non-Linux/Windows target — graphics platform glue. +//! +//! OpenGL and GLFW library resolution are provided. The buffer-swap hook is +//! not: `ilhook` is x86-64 only, so [`frame_hook_targets`] returns nothing +//! and the overlay does not install on macOS yet. Implementing it (an ARM64 +//! Mach-O hook, or a different interception point) means editing only this +//! file — nothing else in the crate is platform-aware. + +use std::ffi::c_void; +use std::sync::OnceLock; + +use libloading::Library; +use log::warn; + +use super::HookTarget; + +/// The system OpenGL framework binary. +const GL_LIBRARY: &str = "/System/Library/Frameworks/OpenGL.framework/Versions/Current/OpenGL"; + +/// Lazily-opened, process-lifetime handle to the OpenGL framework. +fn gl_library() -> Option<&'static Library> { + static LIB: OnceLock> = OnceLock::new(); + // SAFETY: opening the system OpenGL framework. + LIB.get_or_init(|| unsafe { Library::new(GL_LIBRARY).ok() }) + .as_ref() +} + +/// Resolves an OpenGL function pointer by name. +pub fn gl_proc_address(name: &str) -> *const c_void { + let Some(lib) = gl_library() else { + return std::ptr::null(); + }; + // SAFETY: resolving an exported GL symbol by name. + unsafe { + match lib.get::(name.as_bytes()) { + Ok(symbol) => *symbol as *const c_void, + Err(_) => std::ptr::null(), + } + } +} + +/// Opens the GLFW shared library the host loaded. +pub fn open_glfw_library() -> Option { + // SAFETY: loading the GLFW dylib the host process already maps. + unsafe { + Library::new("libglfw.3.dylib") + .or_else(|_| Library::new("libglfw.dylib")) + .ok() + } +} + +/// No frame-hook target is available on macOS yet — see the module docs. +pub fn frame_hook_targets() -> Vec { + warn!("frame hooking is not implemented on macOS; the overlay will not install"); + Vec::new() +} diff --git a/client/src/graphic/platform/mod.rs b/client/src/graphic/platform/mod.rs new file mode 100644 index 0000000..0333799 --- /dev/null +++ b/client/src/graphic/platform/mod.rs @@ -0,0 +1,45 @@ +//! Platform-specific graphics glue: OpenGL entry-point resolution, the GLFW +//! shared library, and the buffer-swap hook targets. +//! +//! Linux and Windows are real. macOS resolves the libraries but provides no +//! frame-hook target — `ilhook` is x86-64 only — so the overlay does not +//! install there yet. Adding macOS support means editing only `macos.rs`. + +use std::ffi::c_void; + +use libloading::Library; + +#[cfg(target_os = "linux")] +#[path = "linux.rs"] +mod imp; +#[cfg(target_os = "windows")] +#[path = "windows.rs"] +mod imp; +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[path = "macos.rs"] +mod imp; + +/// A candidate location for the buffer-swap hook: a resolved code address and +/// a human-readable label for logging. +pub struct HookTarget { + /// Absolute address of the function to hook. + pub address: usize, + /// Where it came from — shown in logs. + pub label: String, +} + +/// Resolves an OpenGL function pointer by name — the loader fed to `gl` and +/// `glow`. Returns null when the symbol cannot be found. +pub fn gl_proc_address(name: &str) -> *const c_void { + imp::gl_proc_address(name) +} + +/// Opens the GLFW shared library the host process uses, if it can be found. +pub fn open_glfw_library() -> Option { + imp::open_glfw_library() +} + +/// The buffer-swap functions to try hooking, most specific first. +pub fn frame_hook_targets() -> Vec { + imp::frame_hook_targets() +} diff --git a/client/src/graphic/platform/windows.rs b/client/src/graphic/platform/windows.rs new file mode 100644 index 0000000..d599ad5 --- /dev/null +++ b/client/src/graphic/platform/windows.rs @@ -0,0 +1,77 @@ +//! Windows graphics platform glue. + +use std::ffi::{c_void, CString}; +use std::sync::OnceLock; + +use libloading::Library; +use log::info; + +use super::HookTarget; + +/// Shared library that exports the OpenGL entry points. +const GL_LIBRARY: &str = "opengl32.dll"; + +/// Lazily-opened, process-lifetime handle to the OpenGL library. +fn gl_library() -> Option<&'static Library> { + static LIB: OnceLock> = OnceLock::new(); + // SAFETY: opening the GL library the host process already maps. + LIB.get_or_init(|| unsafe { Library::new(GL_LIBRARY).ok() }) + .as_ref() +} + +/// Resolves an OpenGL function pointer by name. +pub fn gl_proc_address(name: &str) -> *const c_void { + let Some(lib) = gl_library() else { + return std::ptr::null(); + }; + // SAFETY: resolving an exported GL symbol by name. + unsafe { + // Modern extension entry points are reachable only through + // wglGetProcAddress; opengl32.dll itself exports just the GL 1.1 core. + if let Ok(c_name) = CString::new(name) { + type WglGetProcAddress = unsafe extern "system" fn(*const i8) -> *const c_void; + if let Ok(wgl) = lib.get::(b"wglGetProcAddress") { + let ptr = wgl(c_name.as_ptr()); + if !ptr.is_null() { + return ptr; + } + } + } + match lib.get::(name.as_bytes()) { + Ok(symbol) => *symbol as *const c_void, + Err(_) => std::ptr::null(), + } + } +} + +/// Opens the GLFW shared library, trying the names Minecraft launchers use. +pub fn open_glfw_library() -> Option { + // SAFETY: loading the GLFW DLL the host process already maps. + unsafe { + Library::new("glfw.dll") + .or_else(|_| Library::new("glfw3.dll")) + .or_else(|_| Library::new("glfw64.dll")) + .ok() + } +} + +/// The buffer-swap function to hook: `wglSwapBuffers` from `opengl32.dll`. +pub fn frame_hook_targets() -> Vec { + // SAFETY: resolving wglSwapBuffers from opengl32.dll by name. + unsafe { + let Ok(library) = Library::new(GL_LIBRARY) else { + return Vec::new(); + }; + let address = match library.get::(b"wglSwapBuffers") { + Ok(symbol) => *symbol as *const () as usize, + Err(_) => return Vec::new(), + }; + info!("found wglSwapBuffers in {GL_LIBRARY} at 0x{address:x}"); + // Keep the library handle alive for the process lifetime. + std::mem::forget(library); + vec![HookTarget { + address, + label: GL_LIBRARY.to_string(), + }] + } +} diff --git a/client/src/graphic/ui_engine.rs b/client/src/graphic/ui_engine.rs index d8c1811..197292c 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -105,7 +105,7 @@ pub unsafe fn render_egui_ui() { if state_guard.is_none() { let gl = glow::Context::from_loader_function(|s| { - crate::graphic::hook::get_proc_address(s) as *const _ + crate::graphic::platform::gl_proc_address(s) as *const _ }); let gl = std::sync::Arc::new(gl); let ctx = egui::Context::default(); From e0bf90ab0da08b38d15f1300e263e61c46f1874c Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:23:55 +0200 Subject: [PATCH 10/14] Add unit tests across the workspace Cover the IPC protocol, process discovery, the mapping helpers and the ESP projection math. --- REFACTOR_PLAN.md | 11 +++--- agent_loader/Cargo.toml | 4 ++ client/src/graphic/esp.rs | 53 +++++++++++++++++++++++++ client/src/mapping/class.rs | 46 ++++++++++++++++++++++ client/src/mapping/mod.rs | 59 ++++++++++++++++++++++++++++ injector/src/platform/discovery.rs | 62 ++++++++++++++++++++++++++++++ protocol/src/command.rs | 49 +++++++++++++++++++++++ 7 files changed, 279 insertions(+), 5 deletions(-) diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 240605e..62ad38f 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -320,11 +320,12 @@ Each phase = one commit, compiles, behavior unchanged (except Phase 5). world — completes the menu-injection fix. - ✅ `cargo check -p client`. -### Phase 8 — client: graphic split + platform seam -- Split `esp.rs` into `graphic/esp/{math,gather,render,mod}.rs`. -- `graphic/platform/` with `FrameHook` / `GlLoader` traits + `linux/windows` - impls + `macos` stub. -- ✅ `cargo check -p client`. +### Phase 8 — client: graphic platform seam +- `graphic/platform/` (`mod` + `linux` / `windows` / `macos`) exposing + `gl_proc_address`, `open_glfw_library`, `frame_hook_targets`. +- `esp.rs` split **dropped** — it is already cleanly sectioned; splitting it + is cosmetic churn on working render code (user decision). +- ✅ `cargo check -p client` + `cargo test -p client`. ### Phase 9 — T1: pure unit tests (no JVM) - `protocol`: `Command` encode/decode round-trip. diff --git a/agent_loader/Cargo.toml b/agent_loader/Cargo.toml index a9d9d25..0172052 100644 --- a/agent_loader/Cargo.toml +++ b/agent_loader/Cargo.toml @@ -7,6 +7,10 @@ build = "build.rs" [lib] name = "agent_loader" crate-type = ["cdylib"] +# No unit tests: the agent only does meaningful work inside a live JVM +# process, so a standalone test binary would just fail to link the JNI +# symbols the host JVM provides at load time. +test = false [features] default = ["ctor/used_linker"] diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index 6139564..1747a7b 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -1183,3 +1183,56 @@ fn draw_label(painter: &Painter, pos: Pos2, anchor: Align2, text: &str, color: C ); painter.text(pos, anchor, text, font, color); } + +#[cfg(test)] +mod tests { + use super::*; + + fn v3(x: f64, y: f64, z: f64) -> V3 { + V3 { x, y, z } + } + + #[test] + fn v3_length_and_dot() { + let a = v3(3.0, 4.0, 0.0); + assert_eq!(a.length(), 5.0); + assert_eq!(a.dot(a), 25.0); + } + + #[test] + fn v3_cross_of_x_and_y_is_z() { + let z = v3(1.0, 0.0, 0.0).cross(v3(0.0, 1.0, 0.0)); + assert!(z.sub(v3(0.0, 0.0, 1.0)).length() < 1e-9); + } + + #[test] + fn v3_lerp_finds_the_midpoint() { + let m = v3(0.0, 0.0, 0.0).lerp(v3(10.0, 20.0, -4.0), 0.5); + assert_eq!((m.x, m.y, m.z), (5.0, 10.0, -2.0)); + } + + #[test] + fn a_point_dead_ahead_projects_to_the_screen_centre() { + // Camera at the origin, yaw/pitch 0 -> looking toward +Z. + let view = build_view(v3(0.0, 0.0, 0.0), 0.0, 0.0, 70.0, 1920.0, 1080.0); + let projected = view + .project(v3(0.0, 0.0, 10.0)) + .expect("a point straight ahead must project"); + assert!((projected.x - 960.0).abs() < 1.0); + assert!((projected.y - 540.0).abs() < 1.0); + } + + #[test] + fn a_point_behind_the_camera_does_not_project() { + let view = build_view(v3(0.0, 0.0, 0.0), 0.0, 0.0, 70.0, 1920.0, 1080.0); + assert!(view.project(v3(0.0, 0.0, -10.0)).is_none()); + } + + #[test] + fn box_corners_span_min_to_max() { + let corners = box_corners(v3(0.0, 0.0, 0.0), v3(1.0, 2.0, 3.0)); + assert_eq!(corners.len(), 8); + assert_eq!((corners[0].x, corners[0].y, corners[0].z), (0.0, 0.0, 0.0)); + assert_eq!((corners[6].x, corners[6].y, corners[6].z), (1.0, 2.0, 3.0)); + } +} diff --git a/client/src/mapping/class.rs b/client/src/mapping/class.rs index 074eef3..9dafde2 100644 --- a/client/src/mapping/class.rs +++ b/client/src/mapping/class.rs @@ -596,4 +596,50 @@ mod tests { SignatureMatch::Incompatible ); } + + #[test] + fn methods_deserialize_from_a_single_object_or_an_array() { + let json = r#"{ + "name": "Obf", + "methods": { + "single": { "name": "a", "signature": "()V" }, + "many": [ + { "name": "b", "signature": "(I)V" }, + { "name": "b", "signature": "(F)V" } + ] + }, + "fields": {} + }"#; + let class: MinecraftClass = serde_json::from_str(json).unwrap(); + assert_eq!(class.get_methods("single").unwrap().len(), 1); + assert_eq!(class.get_methods("many").unwrap().len(), 2); + } + + #[test] + fn overload_resolution_prefers_an_exact_primitive_match() { + let mut methods = HashMap::new(); + methods.insert( + "foo".to_string(), + vec![ + Method { + name: "foo".into(), + signature: "(D)V".into(), + id: OnceLock::new(), + }, + Method { + name: "foo".into(), + signature: "(I)V".into(), + id: OnceLock::new(), + }, + ], + ); + let class = MinecraftClass { + name: "T".into(), + methods, + fields: HashMap::new(), + }; + + let chosen = class.get_method_by_args("foo", &[JValue::Int(7)]).unwrap(); + assert_eq!(chosen.signature, "(I)V"); + } } diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index aed8659..bd6732c 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -663,3 +663,62 @@ fn parse_return_type(signature: &str) -> ReturnType { _ => ReturnType::Primitive(Primitive::Void), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arg_count_counts_primitives_and_objects() { + assert_eq!(signature_arg_count("()V"), 0); + assert_eq!(signature_arg_count("(I)V"), 1); + assert_eq!(signature_arg_count("(ILjava/lang/String;F)V"), 3); + assert_eq!(signature_arg_count("([ILjava/lang/String;)V"), 2); + } + + #[test] + fn arg_count_of_an_unparseable_signature_never_matches() { + assert_eq!(signature_arg_count("garbage"), usize::MAX); + } + + #[test] + fn return_type_is_parsed_from_the_descriptor() { + assert!(matches!( + parse_return_type("()V"), + ReturnType::Primitive(Primitive::Void) + )); + assert!(matches!( + parse_return_type("(I)I"), + ReturnType::Primitive(Primitive::Int) + )); + assert!(matches!( + parse_return_type("()Z"), + ReturnType::Primitive(Primitive::Boolean) + )); + assert!(matches!( + parse_return_type("()Ljava/lang/String;"), + ReturnType::Object + )); + assert!(matches!(parse_return_type("()[I"), ReturnType::Array)); + } + + #[test] + fn primitive_field_types_produce_jni_signatures() { + assert_eq!(FieldType::Boolean.get_signature().unwrap(), "Z"); + assert_eq!(FieldType::Int.get_signature().unwrap(), "I"); + assert_eq!(FieldType::Long.get_signature().unwrap(), "J"); + assert_eq!(FieldType::Double.get_signature().unwrap(), "D"); + assert_eq!( + FieldType::String.get_signature().unwrap(), + "Ljava/lang/String;" + ); + } + + #[test] + fn the_bundled_java_mappings_supplement_parses() { + let java: HashMap = + serde_json::from_str(include_str!("../../../java_mappings.json")) + .expect("java_mappings.json must be valid"); + assert!(!java.is_empty()); + } +} diff --git a/injector/src/platform/discovery.rs b/injector/src/platform/discovery.rs index b024879..77667aa 100644 --- a/injector/src/platform/discovery.rs +++ b/injector/src/platform/discovery.rs @@ -69,3 +69,65 @@ fn extract_version(args: &[String]) -> Option { let idx = args.iter().position(|a| a == "--version")?; args.get(idx + 1).cloned() } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_plain_java_minecraft_process_is_detected() { + assert!(classify( + "java", + &args(&["-cp", "minecraft.jar", "net.minecraft.client.main.Main"]), + ) + .is_some()); + } + + #[test] + fn the_windows_javaw_exe_is_detected() { + assert!(classify( + "javaw.exe", + &args(&["-Xmx2G", "minecraft", "--gameDir", "."]) + ) + .is_some()); + } + + #[test] + fn the_java_binary_match_is_case_insensitive() { + assert!(classify("JavaW", &args(&["net.minecraft.client"])).is_some()); + } + + #[test] + fn the_minecraft_keyword_match_is_case_insensitive() { + assert!(classify("java", &args(&["-jar", "MINECRAFT.jar"])).is_some()); + } + + #[test] + fn a_non_java_process_is_ignored() { + assert!(classify("python", &args(&["minecraft_server.py"])).is_none()); + } + + #[test] + fn a_java_process_without_minecraft_is_ignored() { + assert!(classify("java", &args(&["-jar", "build-tools.jar"])).is_none()); + } + + #[test] + fn the_version_is_extracted_when_the_flag_is_present() { + let label = classify( + "java", + &args(&["--version", "1.21.4", "-cp", "minecraft.jar"]), + ); + assert_eq!(label, Some("1.21.4".to_string())); + } + + #[test] + fn a_generic_label_is_used_without_a_version_flag() { + let label = classify("java", &args(&["-cp", "minecraft.jar"])); + assert_eq!(label, Some("Minecraft instance".to_string())); + } +} diff --git a/protocol/src/command.rs b/protocol/src/command.rs index 246296a..b3e316b 100644 --- a/protocol/src/command.rs +++ b/protocol/src/command.rs @@ -61,3 +61,52 @@ impl Command { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn reload_round_trips() { + let command = Command::Reload(PathBuf::from("/tmp/libclient.so")); + let wire = command.encode(); + assert_eq!(wire, "reload /tmp/libclient.so"); + 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")); + 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"))), + ); + } + + #[test] + fn an_empty_line_is_rejected() { + assert_eq!(Command::decode(" "), Err(ProtocolError::Empty)); + } + + #[test] + fn reload_without_an_argument_is_rejected() { + assert_eq!( + Command::decode("reload"), + Err(ProtocolError::MissingArgument { verb: "reload" }), + ); + } + + #[test] + fn an_unknown_verb_is_rejected() { + assert_eq!( + Command::decode("frobnicate x"), + Err(ProtocolError::UnknownVerb("frobnicate".to_string())), + ); + } +} From 7b68810791aa127bc94f1b0ab35e53fe196ade4e Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:31:47 +0200 Subject: [PATCH 11/14] Add an in-process JVM integration test framework Boots a real JVM with a small Java fixture to exercise the reflected mapping path and the menu/in-world transitions. --- Cargo.lock | 12 ++ client/Cargo.toml | 8 + client/build.rs | 29 ++- client/src/mapping/jvm_test.rs | 199 ++++++++++++++++++ client/src/mapping/mod.rs | 3 + .../java/com/darkclient/fixture/Sample.java | 29 +++ .../com/mojang/blaze3d/platform/Window.java | 4 + .../java/net/minecraft/client/Minecraft.java | 46 ++++ .../minecraft/client/gui/screens/Screen.java | 4 + .../client/multiplayer/ClientLevel.java | 4 + .../multiplayer/MultiPlayerGameMode.java | 4 + .../minecraft/client/player/LocalPlayer.java | 6 + .../world/entity/player/Abilities.java | 7 + .../minecraft/world/entity/player/Player.java | 10 + 14 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 client/src/mapping/jvm_test.rs create mode 100644 client/tests/java/com/darkclient/fixture/Sample.java create mode 100644 client/tests/java/com/mojang/blaze3d/platform/Window.java create mode 100644 client/tests/java/net/minecraft/client/Minecraft.java create mode 100644 client/tests/java/net/minecraft/client/gui/screens/Screen.java create mode 100644 client/tests/java/net/minecraft/client/multiplayer/ClientLevel.java create mode 100644 client/tests/java/net/minecraft/client/multiplayer/MultiPlayerGameMode.java create mode 100644 client/tests/java/net/minecraft/client/player/LocalPlayer.java create mode 100644 client/tests/java/net/minecraft/world/entity/player/Abilities.java create mode 100644 client/tests/java/net/minecraft/world/entity/player/Player.java diff --git a/Cargo.lock b/Cargo.lock index 0d767af..7c99f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "gl_generator", "glow", "ilhook", + "java-locator", "jni", "lazy_static", "libc", @@ -1665,6 +1666,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "java-locator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f" +dependencies = [ + "glob", +] + [[package]] name = "jni" version = "0.21.1" @@ -1674,7 +1684,9 @@ dependencies = [ "cesu8", "cfg-if", "combine", + "java-locator", "jni-sys", + "libloading 0.7.4", "log", "thiserror 1.0.64", "walkdir", diff --git a/client/Cargo.toml b/client/Cargo.toml index 39fbfd7..9a056df 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -24,5 +24,13 @@ libloading = "0.9.0" ilhook = "2.3.0" lazy_static = "1.4.0" +[dev-dependencies] +# The `invocation` feature provides `JavaVM::new`, used by the in-process +# JVM integration tests (client/src/mapping/jvm_test.rs). +jni = { workspace = true, features = ["invocation"] } + [build-dependencies] gl_generator = "0.14" + +[target.'cfg(target_os = "linux")'.build-dependencies] +java-locator = "0.1" diff --git a/client/build.rs b/client/build.rs index 17fc158..fbc2f6f 100644 --- a/client/build.rs +++ b/client/build.rs @@ -1,7 +1,8 @@ // build.rs -// Generates the OpenGL bindings (`bindings.rs`) on every platform. -// On Windows (MSVC) it additionally locates the `jvm.lib` import library -// required to link JNI functions; on Linux the linker uses libjvm.so directly. +// Generates the OpenGL bindings (`bindings.rs`) on every platform, and locates +// the JVM library so JNI symbols link: `jvm.lib` on Windows (MSVC), `libjvm.so` +// on Linux. The injected `cdylib` could resolve those symbols from the host JVM +// at load time, but the `cargo test` executables must have them linked. use gl_generator::{Api, Fallbacks, GlobalGenerator, Profile, Registry}; use std::env; @@ -21,6 +22,28 @@ fn main() { #[cfg(windows)] find_jvm_lib(); + + #[cfg(target_os = "linux")] + link_jvm_linux(); +} + +/// Links `libjvm.so` on Linux by locating it through `java-locator`. +#[cfg(target_os = "linux")] +fn link_jvm_linux() { + println!("cargo:rerun-if-env-changed=JAVA_HOME"); + match java_locator::locate_jvm_dyn_library() { + Ok(dir) => { + println!("cargo:rustc-link-search=native={dir}"); + println!("cargo:rustc-link-lib=dylib=jvm"); + // RPATH so the `cargo test` executables can find libjvm.so at + // run time. Harmless for the injected cdylib — it resolves libjvm + // from the host JVM process, which has it loaded already. + println!("cargo:rustc-link-arg=-Wl,-rpath,{dir}"); + } + Err(e) => { + println!("cargo:warning=libjvm.so could not be located: {e}"); + } + } } #[cfg(windows)] diff --git a/client/src/mapping/jvm_test.rs b/client/src/mapping/jvm_test.rs new file mode 100644 index 0000000..bad2801 --- /dev/null +++ b/client/src/mapping/jvm_test.rs @@ -0,0 +1,199 @@ +//! In-process JVM integration tests for the mapping layer. +//! +//! These boot a real JVM — via the `jni` crate's `invocation` feature — with +//! a small Java fixture on its class path (see `client/tests/java/`) and +//! exercise the reflected mapping path and the menu/in-world transitions +//! against it. A JDK (`javac` on `PATH`) is required to run them. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, OnceLock}; + +use jni::objects::JValue; +use jni::{InitArgsBuilder, JNIVersion, JavaVM}; + +use crate::mapping::Mapping; + +/// JNI name of the fixture class used for the reflection tests. +const SAMPLE: &str = "com/darkclient/fixture/Sample"; + +/// Compiles the Java fixture once and returns the output classes directory. +fn fixture_classes() -> &'static Path { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let sources_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/java"); + let out = std::env::temp_dir().join("darkclient_jvm_fixture"); + std::fs::create_dir_all(&out).expect("create the fixture output directory"); + + let sources = java_sources(&sources_root); + assert!(!sources.is_empty(), "no fixture .java sources found"); + + let status = Command::new("javac") + .arg("-d") + .arg(&out) + .args(&sources) + .status() + .expect("`javac` (a JDK) must be available to run the JVM tests"); + assert!(status.success(), "fixture compilation failed"); + out + }) +} + +/// Recursively collects every `.java` file under `root`. +fn java_sources(root: &Path) -> Vec { + let mut sources = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|ext| ext == "java") { + sources.push(path); + } + } + } + sources +} + +/// The shared in-process JVM, created once with the fixture on its class path. +fn jvm() -> &'static JavaVM { + static JVM: OnceLock = OnceLock::new(); + JVM.get_or_init(|| { + let classpath = format!("-Djava.class.path={}", fixture_classes().display()); + let args = InitArgsBuilder::new() + .version(JNIVersion::V8) + .option(&classpath) + .build() + .expect("build the JVM init arguments"); + JavaVM::new(args).expect("create the in-process JVM") + }) +} + +/// Builds a `Mapping` against the shared JVM. The fixture ships a class named +/// `net/minecraft/client/Minecraft`, so the mapping selects reflected mode. +fn reflected_mapping() -> Mapping { + jvm(); // ensure the JVM exists before `Mapping` probes for it + Mapping::new().expect("Mapping::new against the fixture JVM") +} + +/// Invokes a no-argument `void` static method on the fixture `Minecraft`. +fn call_fixture_static(method: &str) { + let mut env = jvm() + .attach_current_thread_as_daemon() + .expect("attach to the JVM"); + let class = env + .find_class("net/minecraft/client/Minecraft") + .expect("fixture Minecraft class"); + env.call_static_method(class, method, "()V", &[]) + .unwrap_or_else(|e| panic!("calling {method}: {e}")); +} + +#[test] +fn mapping_reflects_a_fixture_class() { + let mapping = reflected_mapping(); + let class = mapping.get_class(SAMPLE).expect("Sample must reflect"); + let methods = class.method_names(); + for expected in ["value", "greet", "sum", "create"] { + assert!( + methods.iter().any(|m| m == expected), + "reflected class is missing method `{expected}`", + ); + } +} + +#[test] +fn reflected_method_signatures_are_built_from_jni_types() { + let mapping = reflected_mapping(); + let class = mapping.get_class(SAMPLE).unwrap(); + assert!( + class + .get_method_by_signature("greet", "(Ljava/lang/String;)Ljava/lang/String;") + .is_ok(), + "greet(String) signature should be reflected", + ); + assert!( + class.get_method_by_signature("sum", "(IJ)J").is_ok(), + "sum(int, long) signature should be reflected", + ); +} + +#[test] +fn an_overloaded_method_reflects_every_overload() { + let mapping = reflected_mapping(); + let class = mapping.get_class(SAMPLE).unwrap(); + assert_eq!( + class.get_methods("value").unwrap().len(), + 2, + "value(int) and value(double) are distinct overloads", + ); +} + +#[test] +fn overload_resolution_uses_reflected_signatures() { + let mapping = reflected_mapping(); + let class = mapping.get_class(SAMPLE).unwrap(); + let chosen = class + .get_method_by_args("value", &[JValue::Int(3)]) + .expect("an int argument must resolve an overload"); + assert_eq!(chosen.signature, "(I)I"); +} + +#[test] +fn get_class_caches_reflected_results() { + let mapping = reflected_mapping(); + let first = mapping.get_class(SAMPLE).unwrap(); + let second = mapping.get_class(SAMPLE).unwrap(); + assert!( + Arc::ptr_eq(&first, &second), + "the second lookup must hit the cache", + ); +} + +#[test] +fn resolving_a_missing_class_fails() { + let mapping = reflected_mapping(); + let mut env = mapping.get_env().unwrap(); + assert!( + mapping + .resolve_class(&mut env, "totally/made/up/Class") + .is_err(), + "a class that does not exist must not resolve", + ); +} + +#[test] +fn client_initializes_and_tracks_the_menu_and_world_states() { + jvm(); + crate::state::init().expect("state::init must succeed against the fixture"); + let minecraft = crate::state::minecraft(); + + // A fresh init — the fixture is in its "main menu" state. + assert!( + minecraft.player().unwrap().is_none(), + "no player in the menu" + ); + assert!(minecraft.world().unwrap().is_none(), "no world in the menu"); + assert!(minecraft.game_mode().unwrap().is_none()); + assert!(!minecraft.in_world()); + + // Join a world through the fixture. + call_fixture_static("enterWorld"); + assert!( + minecraft.player().unwrap().is_some(), + "the player must be present once in a world", + ); + assert!(minecraft.world().unwrap().is_some()); + assert!(minecraft.in_world()); + + // Leave it again. + call_fixture_static("leaveWorld"); + assert!( + minecraft.player().unwrap().is_none(), + "the player must be gone after leaving the world", + ); + assert!(!minecraft.in_world()); +} diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index bd6732c..88d5ac5 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -21,6 +21,9 @@ mod method; mod minecraft_version; mod reflect; +#[cfg(test)] +mod jvm_test; + /// On-disk JSON shape. Only obfuscated builds ship one of these. #[derive(Debug, Deserialize)] struct MappingFile { diff --git a/client/tests/java/com/darkclient/fixture/Sample.java b/client/tests/java/com/darkclient/fixture/Sample.java new file mode 100644 index 0000000..40cfd22 --- /dev/null +++ b/client/tests/java/com/darkclient/fixture/Sample.java @@ -0,0 +1,29 @@ +package com.darkclient.fixture; + +/** + * A plain class with overloads and varied parameter types, used to exercise + * the reflected mapping path against a real JVM. + */ +public class Sample { + public int counter = 7; + + public static Sample create() { + return new Sample(); + } + + public int value(int n) { + return n * 2; + } + + public double value(double n) { + return n * 2.0; + } + + public String greet(String who) { + return "hi " + who; + } + + public long sum(int a, long b) { + return a + b; + } +} diff --git a/client/tests/java/com/mojang/blaze3d/platform/Window.java b/client/tests/java/com/mojang/blaze3d/platform/Window.java new file mode 100644 index 0000000..831ddaa --- /dev/null +++ b/client/tests/java/com/mojang/blaze3d/platform/Window.java @@ -0,0 +1,4 @@ +package com.mojang.blaze3d.platform; + +/** Test stand-in for Minecraft's game window. Exists from the menu onward. */ +public class Window {} diff --git a/client/tests/java/net/minecraft/client/Minecraft.java b/client/tests/java/net/minecraft/client/Minecraft.java new file mode 100644 index 0000000..ce75af0 --- /dev/null +++ b/client/tests/java/net/minecraft/client/Minecraft.java @@ -0,0 +1,46 @@ +package net.minecraft.client; + +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; + +/** + * Test stand-in for Minecraft's client class. + * + * The static instance always exists (the game window is up); the + * world-scoped fields are null until {@link #enterWorld()} is called, which + * mirrors being in the main menu versus being in a world. + */ +public class Minecraft { + private static final Minecraft INSTANCE = new Minecraft(); + + public LocalPlayer player; + public ClientLevel level; + public MultiPlayerGameMode gameMode; + public Screen screen; + private final Window window = new Window(); + + public static Minecraft getInstance() { + return INSTANCE; + } + + public Window getWindow() { + return window; + } + + /** Simulates joining a world. */ + public static void enterWorld() { + INSTANCE.player = new LocalPlayer(); + INSTANCE.level = new ClientLevel(); + INSTANCE.gameMode = new MultiPlayerGameMode(); + } + + /** Simulates leaving a world. */ + public static void leaveWorld() { + INSTANCE.player = null; + INSTANCE.level = null; + INSTANCE.gameMode = null; + } +} diff --git a/client/tests/java/net/minecraft/client/gui/screens/Screen.java b/client/tests/java/net/minecraft/client/gui/screens/Screen.java new file mode 100644 index 0000000..b3afdf0 --- /dev/null +++ b/client/tests/java/net/minecraft/client/gui/screens/Screen.java @@ -0,0 +1,4 @@ +package net.minecraft.client.gui.screens; + +/** Test stand-in for a Minecraft GUI screen. */ +public class Screen {} diff --git a/client/tests/java/net/minecraft/client/multiplayer/ClientLevel.java b/client/tests/java/net/minecraft/client/multiplayer/ClientLevel.java new file mode 100644 index 0000000..b0183cf --- /dev/null +++ b/client/tests/java/net/minecraft/client/multiplayer/ClientLevel.java @@ -0,0 +1,4 @@ +package net.minecraft.client.multiplayer; + +/** Test stand-in for Minecraft's client-side world. */ +public class ClientLevel {} diff --git a/client/tests/java/net/minecraft/client/multiplayer/MultiPlayerGameMode.java b/client/tests/java/net/minecraft/client/multiplayer/MultiPlayerGameMode.java new file mode 100644 index 0000000..4017565 --- /dev/null +++ b/client/tests/java/net/minecraft/client/multiplayer/MultiPlayerGameMode.java @@ -0,0 +1,4 @@ +package net.minecraft.client.multiplayer; + +/** Test stand-in for Minecraft's interaction controller. */ +public class MultiPlayerGameMode {} diff --git a/client/tests/java/net/minecraft/client/player/LocalPlayer.java b/client/tests/java/net/minecraft/client/player/LocalPlayer.java new file mode 100644 index 0000000..e6bd68e --- /dev/null +++ b/client/tests/java/net/minecraft/client/player/LocalPlayer.java @@ -0,0 +1,6 @@ +package net.minecraft.client.player; + +import net.minecraft.world.entity.player.Player; + +/** Test stand-in: a LocalPlayer is a Player. */ +public class LocalPlayer extends Player {} diff --git a/client/tests/java/net/minecraft/world/entity/player/Abilities.java b/client/tests/java/net/minecraft/world/entity/player/Abilities.java new file mode 100644 index 0000000..5353eb7 --- /dev/null +++ b/client/tests/java/net/minecraft/world/entity/player/Abilities.java @@ -0,0 +1,7 @@ +package net.minecraft.world.entity.player; + +/** Test stand-in for Minecraft's player Abilities. */ +public class Abilities { + public boolean flying; + public boolean mayfly; +} diff --git a/client/tests/java/net/minecraft/world/entity/player/Player.java b/client/tests/java/net/minecraft/world/entity/player/Player.java new file mode 100644 index 0000000..f45dcfd --- /dev/null +++ b/client/tests/java/net/minecraft/world/entity/player/Player.java @@ -0,0 +1,10 @@ +package net.minecraft.world.entity.player; + +/** Test stand-in for Minecraft's Player entity. */ +public class Player { + private final Abilities abilities = new Abilities(); + + public Abilities getAbilities() { + return abilities; + } +} From 90acda59899111bd6e957b4e09abf9f2a8c1c8d4 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:35:10 +0200 Subject: [PATCH 12/14] Add an end-to-end test harness An xtask command builds the project and injects into a running game; the injector gains headless list and inject modes. --- .cargo/config.toml | 3 + Cargo.lock | 4 ++ Cargo.toml | 1 + REFACTOR_PLAN.md | 8 ++- injector/src/main.rs | 41 ++++++++++++-- xtask/Cargo.toml | 7 +++ xtask/src/main.rs | 127 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..fa36b33 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +# `cargo xtask ` — workspace developer tasks (see xtask/src/main.rs). +xtask = "run --quiet --package xtask --release --" diff --git a/Cargo.lock b/Cargo.lock index 7c99f7a..be84bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4151,6 +4151,10 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4e2e2f7cba5a093896c1e150fbfe177d1883e7448200efb81d40b9d339ef26" +[[package]] +name = "xtask" +version = "0.1.0" + [[package]] name = "zbus" version = "4.4.0" diff --git a/Cargo.toml b/Cargo.toml index e094571..84dfb45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "injector", "client", "agent_loader", + "xtask", ] [workspace.package] diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 62ad38f..1584607 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -342,9 +342,11 @@ Each phase = one commit, compiles, behavior unchanged (except Phase 5). classloader discovery, overload resolution, menu↔in-world transitions. - ✅ `cargo test -p client` (with JDK). -### Phase 11 — T3: full Minecraft e2e (optional / manual) -- `cargo xtask` that launches a real Minecraft, injects, asserts over the TCP - channel / logs. Marked `#[ignore]`, not run in CI. +### Phase 11 — T3: e2e harness (optional / manual) +- `xtask` crate + `cargo xtask e2e`: builds the workspace, discovers a + running Minecraft and injects into it; the overlay check is manual. +- Headless injector modes `--list` / `--inject ` make injection + scriptable. Not run in CI (needs a running game + root). - ✅ manual run. ### Phase 12 — polish diff --git a/injector/src/main.rs b/injector/src/main.rs index 02f0ea0..2c980ac 100644 --- a/injector/src/main.rs +++ b/injector/src/main.rs @@ -7,16 +7,29 @@ mod tui; use log::{error, LevelFilter}; fn main() { + let args: Vec = std::env::args().collect(); + + // `--list` only reads process info, so it needs no elevation. + if args.iter().any(|a| a == "--list") { + for proc in platform::find_minecraft_processes() { + println!("{}\t{}", proc.pid, proc.info); + } + return; + } + if !platform::is_elevated() { eprintln!("{}", elevation_hint()); - return; + std::process::exit(1); } - if let Err(e) = protocol::init_file_logger("app.log", LevelFilter::Debug) { - eprintln!("continuing without file logging: {e}"); + let _ = protocol::init_file_logger("app.log", LevelFilter::Debug); + + // `--inject ` — headless injection, for scripting and the e2e harness. + if let Some(pid) = injection_target(&args) { + std::process::exit(run_headless_injection(pid)); } - if std::env::args().any(|arg| arg == "--tui") { + if args.iter().any(|a| a == "--tui") { tui::run_tui(); return; } @@ -27,6 +40,26 @@ fn main() { } } +/// Parses a `--inject ` argument pair, if present. +fn injection_target(args: &[String]) -> Option { + let index = args.iter().position(|a| a == "--inject")?; + args.get(index + 1)?.parse().ok() +} + +/// Injects into `pid` without a UI; returns a process exit code. +fn run_headless_injection(pid: u32) -> i32 { + match inject::inject(pid) { + Ok(()) => { + println!("injected into process {pid}"); + 0 + } + Err(e) => { + eprintln!("injection into process {pid} failed: {e}"); + 1 + } + } +} + /// Platform-specific hint shown when the injector lacks the privileges it /// needs to attach to another process. fn elevation_hint() -> &'static str { diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..55ce98e --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..ffde93e --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,127 @@ +//! Workspace developer tasks. Run via `cargo xtask `. +//! +//! Tasks: +//! e2e End-to-end harness — build everything and inject into a running +//! Minecraft. This is the manual Tier-3 test: it is not run in CI. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn main() { + let task = std::env::args().nth(1); + let result = match task.as_deref() { + Some("e2e") => e2e(), + Some(other) => Err(format!("unknown task `{other}` (try: e2e)")), + None => Err("usage: cargo xtask (tasks: e2e)".to_string()), + }; + if let Err(message) = result { + eprintln!("xtask: {message}"); + std::process::exit(1); + } +} + +/// Workspace root — the parent of the `xtask/` crate directory. +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask always has a parent directory") + .to_path_buf() +} + +/// End-to-end harness. +/// +/// Builds the workspace, then injects the client into a running Minecraft. +/// Minecraft must already be running; on Linux this task must run as root +/// (injection uses `ptrace`). Run it with, for example, `sudo -E cargo xtask +/// e2e`. The final overlay check is manual — see the printed instructions. +fn e2e() -> Result<(), String> { + let root = workspace_root(); + + println!("[1/4] building the workspace (release)…"); + build_release(&root)?; + + let release = root.join("target/release"); + let injector = release.join(injector_file_name()); + if !injector.is_file() { + return Err(format!( + "injector binary not found at {}", + injector.display() + )); + } + // libagent_loader / libclient sit next to the injector in target/release, + // which is exactly where the injector looks for them. + println!("[2/4] artifacts staged in {}", release.display()); + + println!("[3/4] discovering running Minecraft instances…"); + let processes = list_minecraft(&injector)?; + let Some((pid, info)) = processes.first() else { + return Err("no running Minecraft found — start the game, then re-run".to_string()); + }; + println!(" found PID {pid} — {info}"); + + println!("[4/4] injecting into PID {pid}…"); + let status = Command::new(&injector) + .arg("--inject") + .arg(pid.to_string()) + .current_dir(&release) + .status() + .map_err(|e| format!("could not run the injector: {e}"))?; + if !status.success() { + return Err("injection failed — see the output above and app.log".to_string()); + } + + println!(); + println!("injection succeeded."); + println!("verify in-game: focus Minecraft and press Right Shift —"); + println!("the DarkClient menu should open."); + Ok(()) +} + +/// `cargo build --release` for the three runtime artifacts. +fn build_release(root: &Path) -> Result<(), String> { + let status = Command::new(env!("CARGO")) + .current_dir(root) + .args([ + "build", + "--release", + "-p", + "agent_loader", + "-p", + "client", + "-p", + "injector", + ]) + .status() + .map_err(|e| format!("could not run cargo: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("release build failed".to_string()) + } +} + +/// Runs `injector --list` and parses its `pidinfo` lines. +fn list_minecraft(injector: &Path) -> Result, String> { + let output = Command::new(injector) + .arg("--list") + .stderr(Stdio::inherit()) + .output() + .map_err(|e| format!("could not run the injector: {e}"))?; + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let (pid, info) = line.split_once('\t')?; + Some((pid.trim().parse().ok()?, info.trim().to_string())) + }) + .collect()) +} + +/// Platform file name of the injector binary. +fn injector_file_name() -> &'static str { + if cfg!(windows) { + "injector.exe" + } else { + "injector" + } +} From c7eca6eb9fa0010e378d9a5f828451f142b938e4 Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:49:12 +0200 Subject: [PATCH 13/14] Clean up clippy warnings and refresh the documentation Resolve the outstanding lint warnings and update the README and project docs to the current architecture. --- .gitignore | 3 +- CLAUDE.md | 70 +++-- README.md | 77 +++-- REFACTOR_PLAN.md | 379 ------------------------ client/src/graphic/esp.rs | 7 +- client/src/graphic/menu.rs | 12 +- client/src/graphic/ui_engine.rs | 14 +- client/src/lib.rs | 3 + client/src/mapping/class.rs | 4 +- client/src/mapping/minecraft_version.rs | 6 +- client/src/mapping/mod.rs | 2 +- client/src/module/combat/aimbot.rs | 2 +- client/src/module/combat/aura.rs | 2 +- client/src/module/mod.rs | 33 ++- client/src/module/movement/fly.rs | 2 +- client/src/module/render/chest_esp.rs | 2 +- client/src/module/render/mob_esp.rs | 2 +- client/src/module/render/player_esp.rs | 2 +- 18 files changed, 142 insertions(+), 480 deletions(-) delete mode 100644 REFACTOR_PLAN.md diff --git a/.gitignore b/.gitignore index 1a05e5d..a2d8114 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.idea -/target \ No newline at end of file +/target +/docs_internal \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b8de142..1693dae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,65 +4,83 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -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 three crates. +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. ## Build & Common Commands ```bash -cargo build --release # build all three crates +cargo build --release # build the workspace cargo build -p client --release # build a single crate -cargo test -p client # tests live only in client/src/mapping/class.rs -cargo test -p client test_type_compatibility # run one test -cargo fmt -cargo clippy -python conversion.py # regenerate mappings.json (needs the `requests` package) +cargo check --workspace # fast check (preferred while iterating) +cargo test --workspace # all tests (needs a JDK — see Tests below) +cargo test -p client overload # run tests matching a name +cargo fmt --all +cargo clippy --workspace +cargo xtask e2e # manual end-to-end harness (see Tests) +python conversion.py # regenerate mappings.json (needs the `requests` package) ``` -- **Always build `--release`.** The release profile (`opt-level = "s"`, `lto = true`) is what CI and runtime expect; debug builds also silence `dead_code` warnings via `lib.rs`. -- **Windows requires the nightly toolchain** (see `.github/workflows/build.yml`) and a discoverable `jvm.lib`. `client/build.rs` and `agent_loader/build.rs` locate it via `JAVA_HOME` or `JVM_LIB_DIR`; Linux links `libjvm.so` directly. JDK 21+ required. -- Running the `injector` needs root (`sudo`) on Linux / Administrator on Windows. `libagent_loader` and `libclient` must sit in the injector's working directory. +- **Always build `--release` for runtime artifacts.** The release profile (`opt-level = "s"`, `lto = true`) is what CI and runtime expect; debug builds also silence `dead_code` warnings via `lib.rs`. +- **Windows requires the nightly toolchain** (see `.github/workflows/build.yml`) and a discoverable `jvm.lib`; `client/build.rs` and `agent_loader/build.rs` locate it via `JAVA_HOME` or `JVM_LIB_DIR`. On Linux `client/build.rs` links `libjvm.so` (located via `java-locator`) so the test executables resolve JNI symbols. JDK 21+ required. +- Running the `injector` needs root (`sudo`) on Linux / Administrator on Windows. `libagent_loader` and `libclient` must sit next to the injector executable or in its working directory. ## Crate Roles -- **`injector/`** — standalone GUI binary (egui/eframe; `--tui` flag for a crossterm TUI). Finds Java processes whose command line contains `minecraft`, injects `agent_loader`, then drives the client. -- **`agent_loader/`** — `cdylib` injected first. A `#[ctor]` runs on load: starts a JVM health monitor and a TCP command server. Owns the lifecycle of the client library (load/unload/hot-reload). +- **`protocol/`** — small library shared by `injector` and `agent_loader`: the localhost socket address (`SOCKET_ADDR`), the typed `Command` enum with `encode`/`decode`, and a non-panicking file-logger helper. +- **`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. +- **`xtask/`** — workspace task runner; `cargo xtask e2e` is the manual Tier-3 test. ## Injection & Hot-Reload Flow -This is the core control flow and spans all three crates: +This is the core control flow and spans `injector`, `protocol`, `agent_loader`, `client`: 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 **`127.0.0.1:7878`** (constant duplicated in `injector/src/platform/mod.rs::SOCKET_ADDRESS`). -3. `injector` connects and sends `reload `. -4. `agent_loader` copies the library to a temp file (avoids file locks), `dlopen`s it, and calls the exported `initialize_client`. -5. Re-injecting repeats step 3 → `reload_client_library` calls `cleanup_client` on the old library before loading the new one. This is the hot-reload path. +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()`. +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. -`client` exposes exactly two `#[no_mangle] extern "C"` symbols: `initialize_client` and `cleanup_client`. `initialize_client` spawns a thread that builds `Minecraft::instance()`, calls `register_modules()`, and installs hooks. +`client` exposes exactly two `#[no_mangle] extern "C"` symbols: `initialize_client` and `cleanup_client`. `initialize_client` spawns a thread that calls `state::init()`, then `register_modules()`, then installs hooks — in that fixed order. ## client/ Internals -**Rendering & ticking** (`graphic/hook.rs`): `install_hooks` hooks `glfwSwapBuffers` (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 `DarkClient::tick()`, which ticks every enabled module. Tick logic runs on the render thread, not a Minecraft thread. +**Global state (`state.rs`)**: the client has no `Type::instance()` singletons. Two things live for the whole session, each built once by `state::init()` and reached through a free accessor: the JNI bridge — `mapping()` — and the running game/module state — `client()`, with `minecraft()` a shortcut for `&client().minecraft` and `env()` for a JNI environment. Accessors `expect` the state to exist (using one before `init()` is a programmer error). `init()` builds the `Mapping` first, then the `Client`. + +**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. -**Module system** (`module/mod.rs`): 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). Note: the trait example in `README.md` is stale — the real trait methods return `anyhow::Result<()>`. +**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`. + +**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`. -**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 by probing `find_class("net/minecraft/client/Minecraft")` and picks one of two modes: +**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: -- **Obfuscated** (`Mode::Obfuscated`): the probe fails. `mappings.json` and `java_mappings.json` (project root, **`include_str!`'d at compile time**) are parsed into a class map; names are translated deobfuscated → obfuscated. -- **Reflected** (`Mode::Reflected`): the probe succeeds (Minecraft 26.1+, unobfuscated). No JSON is used; class/method/field names are identity, and method signatures — still required by JNI — are discovered lazily via `java.lang.Class` reflection in `reflect.rs` and cached. No mapping file is ever needed for new versions. +- **Obfuscated** (`Mode::Obfuscated`): `mappings.json` and `java_mappings.json` (project root, **`include_str!`'d at compile time**) are parsed into a class map; names are translated deobfuscated → obfuscated. +- **Reflected** (`Mode::Reflected`): unobfuscated builds (Minecraft 26.1+). No JSON is used; class/method/field names are identity, and method signatures — still required by JNI — are discovered lazily via `java.lang.Class` reflection in `reflect.rs` and cached. -Both modes share one code path: a `RwLock>>` populated up-front (obfuscated) or lazily by reflection (reflected). `Mapping` wraps all JNI calls (`call_method`, `call_static_method`, `get_field`, `set_field`, etc.); `class.rs` does overload resolution by scoring argument-type compatibility against JNI signatures. +Both modes share one code path: `DashMap`s (`classes`, `class_handles`) populated up-front (obfuscated) or lazily by reflection. `Mapping` owns its `JavaVM` handle and wraps all JNI calls (`call_method`, `call_static_method`, `get_field`, `set_field`, …); `class.rs` does overload resolution by scoring argument-type compatibility against JNI signatures. -**Mod loaders (`loader.rs`)**: Fabric and Forge/NeoForge run the game in an isolated class loader (`KnotClassLoader` / `TransformingClassLoader`), 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 (intermediary/SRG names) is not supported. +**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. -**Lifecycle safety**: the global `RUNNING: AtomicBool` gates `on_frame` and the agent's loops. A panic hook in `initialize_client` calls `cleanup_client` so input/render hooks are always uninstalled and GLFW callbacks restored, even on panic. +**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 `conversion.py` downloads official Mojang mappings for a chosen **obfuscated** Minecraft version (≤ 1.21.11) and writes the custom `mappings.json` format. The committed `mappings.json` is ~18 MB. `java_mappings.json` is a small hand-written supplement for `java.*` classes, merged in at load time. Unobfuscated versions (26.1+) need none of this — they go through the reflected mapping path. The 26.1 runtime requires JDK 25. +## Tests + +Three tiers (`cargo test --workspace` runs T1 + T2): + +- **T1 — unit tests.** Fast, no JVM: `protocol` encode/decode, the injector's process-discovery filter, mapping signature/overload/parse helpers, ESP projection math. +- **T2 — JVM integration** (`client/src/mapping/jvm_test.rs`). Boots an in-process JVM via the `jni` `invocation` feature, with a small Java fixture (`client/tests/java/`, compiled by `javac` at test time) standing in for the game classes. Exercises the reflected mapping path and the `state::init()` menu↔in-world transition. Needs a JDK. +- **T3 — end-to-end** (`cargo xtask e2e`). Builds the workspace, discovers a running Minecraft and injects into it; the overlay check is manual. Needs a running game and root; not run in CI. + ## Logs - `injector` → `app.log` (in its working directory) diff --git a/README.md b/README.md index f76cbe9..59f1cd9 100644 --- a/README.md +++ b/README.md @@ -38,22 +38,27 @@ DarkClient runs on **vanilla Minecraft**, **Fabric**, and **Forge/NeoForge**. It ## 🏗️ Architecture -The project is organized into three main components: +A Cargo workspace of four crates plus an `xtask` helper: -### 1. **Injector** (`injector/`) -A user-friendly GUI application that handles: +### **Protocol** (`protocol/`) +The shared contract between the injector and the agent: the localhost +socket address and the typed command set, defined once so the two ends +cannot drift apart. + +### **Injector** (`injector/`) +The injection tool — a redesigned egui GUI, a `--tui` terminal mode and +`--list` / `--inject ` headless modes — that handles: - Process detection (finding Minecraft instances) -- Library injection into target processes +- Library injection into target processes (per-platform, behind a trait) - Status monitoring and error reporting -### 2. **Agent Loader** (`agent_loader/`) -A JVMTI agent that provides: -- Dynamic library loading capabilities -- TCP command server for hot-reloading -- Process lifecycle management -- Cross-platform injection support +### **Agent Loader** (`agent_loader/`) +A `cdylib` injected into the JVM. On load it provides: +- Dynamic library loading and hot-reloading of the client +- A TCP command server +- A JVM health monitor and clean process lifecycle handling -### 3. **Client Library** (`client/`) +### **Client Library** (`client/`) The core modification framework featuring: - JNI integration with Minecraft's runtime - Module system for game modifications @@ -112,11 +117,12 @@ python conversion.py > [!WARNING] > `libagent_loader` and `libclient` **must** be in the **same directory** where you run the injector. -2. **Start Minecraft** and load into a world +2. **Start Minecraft** — you can inject from the main menu; modules stay + idle until you load a world 3. **In the Injector GUI**: -- Click "Find" to detect the Minecraft process -- Click "Inject" to load the modification framework +- Click "Scan" to detect the Minecraft process +- Select it and click "Inject" to load the modification framework 4. **Use Modules**: - Modules can be toggled using their assigned keybinds @@ -143,34 +149,40 @@ impl Module for CustomModule { &mut self.data } - fn on_start(&self) { - // Called when module is enabled + fn on_start(&self) -> anyhow::Result<()> { + // Called when the module is enabled. + Ok(()) } - fn on_stop(&self) { - // Called when module is disabled + fn on_stop(&self) -> anyhow::Result<()> { + // Called when the module is disabled. + Ok(()) } - fn on_tick(&self) { - // Called every game tick while enabled + fn on_tick(&self) -> anyhow::Result<()> { + // Called every game tick while enabled. + Ok(()) } } ``` + +Register it in `register_modules()` in `client/src/lib.rs`. ```text DarkClient/ -├── 📁 client/ # Core modification library -│ ├── 📁 src/ -│ │ ├── 📄 lib.rs # Main library entry point -│ │ ├── 📄 client.rs # DarkClient core & JVM integration -│ │ ├── 📁 mapping/ # Minecraft mapping system -│ │ └── 📁 module/ # Module framework -├── 📁 injector/ # GUI injection tool +├── 📁 protocol/ # Shared injector ⇆ agent IPC contract +├── 📁 injector/ # Injection tool (GUI / TUI / headless CLI) +├── 📁 agent_loader/ # Injected cdylib: command server + client lifecycle +├── 📁 client/ # Core modification framework │ └── 📁 src/ -│ └── 📄 main.rs # Injector application -├── 📁 agent_loader/ # JVMTI agent for dynamic loading +│ ├── 📄 lib.rs # Entry points (initialize_client / cleanup_client) +│ ├── 📄 state.rs # Global client + mapping state +│ ├── 📁 mapping/ # Minecraft mapping system (obfuscated + reflected) +│ ├── 📁 graphic/ # Overlay, hooks, input, platform seam +│ └── 📁 module/ # Module framework + registry +├── 📁 xtask/ # Workspace task runner (cargo xtask e2e) ├── 📄 mappings.json # Minecraft obfuscation mappings ├── 📄 conversion.py # Mapping conversion utility -└── 📄 Cargo.toml # Workspace configuration +└── 📄 Cargo.toml # Workspace configuration ``` ## 🔧 Configuration @@ -180,9 +192,10 @@ Logs are written to: - - Client library logs `dark_client.log` is located in .minecraft ### Network Settings -The agent loader uses TCP port `7878` for communication. This can be modified in : `platform/mod.rs` +The injector and agent communicate over TCP `127.0.0.1:7878`, defined once in the `protocol` crate: ```rust -pub const SOCKET_ADDRESS: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 7878); +// protocol/src/lib.rs +pub const SOCKET_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7878); ``` ## 🤝 Contributing diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md deleted file mode 100644 index 1584607..0000000 --- a/REFACTOR_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# DarkClient — Refactor Plan - -> Branch: `refactor/project-cleanup` (from `master`) -> Goal: cleaner, leaner, faster project. Deeper restructure allowed (module -> boundaries, traits and data flow may change). Behavior is preserved — no -> feature is removed. Linux + Windows must keep working; macOS is not -> implemented but every platform seam is designed so it can be added later. - -## Decisions (confirmed with the user) - -| Topic | Decision | -|---|---| -| Mapping access | Global accessor — remove `&Mapping` from **all** constructors and from `FieldType`. | -| Singletons | Refactor: `DarkClient`/`Minecraft`/`Mapping` collapse into **one** explicitly-initialized global `Client` (no `Arc`, no lazy panic). | -| Refactor depth | Deeper restructure — internal APIs may change. | -| Confusing "loader" | The `agent_loader` crate (monolithic `lib.rs`). `mapping/loader.rs` stays. | -| Injector GUI | Full visual redesign **+** code cleanup. Keep the `--tui` mode. | -| macOS | Not implemented now. All `#[cfg]` seams get a `macos` arm (stub returning `Unsupported`). | -| Menu injection | Injecting from the **main menu** (not in-game) must fully work — game state acquired lazily. | -| Testing | Tiered: pure unit tests + an in-process JVM integration framework. Full Minecraft e2e optional / manual. | - -## Working rules - -- Verify each phase with `cargo check` (workspace + per-crate). Full - `--release` builds only at phase boundaries when needed. -- Windows / macOS code is **review-only** — not cross-compiled here. -- Each phase ends in a compiling state with its own commit. Behavior unchanged - (except the deliberate menu-injection fix). -- `cargo fmt` + `cargo clippy` clean at every phase boundary. -- Tests must stay green after the phase that introduces them. - ---- - -## Current pain points (from analysis) - -**Workspace** — `SOCKET_ADDRESS` / the `reload` protocol is duplicated as string -literals across `injector` and `agent_loader`; common deps not centralized; -each crate inits its logger with `File::create(...).unwrap()`. - -**injector** — TCP-reload block copy-pasted in `unix.rs` and `windows.rs`; -platform layer is bare `#[cfg]` re-exports, no trait; `~7` `.unwrap()`/`.expect()` -panic points; GUI status is a single `String`; injection blocks the UI thread -for up to 5 s; process detection is case-sensitive and hard-codes binary names. - -**agent_loader** — everything in one 379-line `lib.rs`; primitive -`splitn(2, ' ')` command parsing; serial blocking server; messy -`format!("{:?}", ...)` + quote-trimming path munging; logger init unguarded; -mutex poisoning unhandled. - -**client** — three singletons (`DarkClient`/`Minecraft`/`Mapping`), each a -`OnceLock>` where the `Arc` is never cloned (dead heap alloc + atomics) -and lazy init `panic!`s at a nondeterministic first-access point; `&Mapping` -threaded through `LocalPlayer/Abilities/World/Window` constructors and -`FieldType::Object(_, &Mapping)` (~15 explicit passes); `FieldType` carries a -lifetime only for that; ~90 `.unwrap()`; no typed errors; module storage -triple-wrapped `Arc>>>>`; `esp.rs` is -1081 lines; frame/GL hook layer is `#[cfg]`-scattered and x86-64 only; -`tick()` `panic!`s if a module fails to stop. - -**Menu-injection bug** — `Minecraft::new()` eagerly builds `LocalPlayer`, -`World` and `MultiPlayerGameMode`. In the main menu `Minecraft.player`, -`.level` and `.gameMode` are all null, so `new()` fails, `Minecraft::instance()` -panics, and modules that get an `Err` from `on_tick` are auto-disabled. -Injection "succeeds" but the log is full of failures. - ---- - -## Target architecture - -### New crate: `protocol/` (lib) - -Single source of truth for injector ⇆ agent_loader IPC. - -``` -protocol/src/lib.rs - pub const SOCKET_ADDR: SocketAddr // 127.0.0.1:7878 - pub enum Command { Reload(PathBuf), Ping, ... } - Command::encode(&self) -> String / decode(&str) -> Result - pub fn init_file_logger(path) -> Result<()> // non-panicking, shared -``` - -Depended on by `injector` and `agent_loader`. Unit-tested (encode/decode round-trip). - -### `injector/` - -``` -injector/src/ - main.rs entry: arg parse, logger, privilege check, GUI/TUI dispatch - app.rs UI-agnostic core: process scan + injection orchestration, - InjectionStatus enum, runs injection on a worker thread - inject.rs high-level flow: platform inject -> protocol reload - platform/ - mod.rs trait Injector + ProcessInfo + cfg-selected impl - discovery.rs cross-platform Minecraft process discovery (sysinfo) - linux.rs ptrace-inject - windows.rs dll-syringe - macos.rs stub -> Err(Unsupported) - gui/ - mod.rs eframe App (thin: renders app.rs state) - theme.rs colors / fonts / spacing - widgets.rs process card, status banner, action button - tui.rs crossterm TUI, rebuilt on app.rs core -``` - -- `trait Injector { fn inject(&self, pid, agent: &Path) -> Result<(), InjectError>; }` -- Async injection: worker thread + `mpsc` channel; GUI polls `InjectionStatus` - (`Idle / Scanning / Injecting / Done / Failed(msg)`). No async runtime added. -- `InjectError` (thiserror): `Privilege / ProcessGone / Attach / Inject / Connect / Protocol`. - -### `agent_loader/` - -``` -agent_loader/src/ - lib.rs #[ctor]/#[dtor], globals, wires the modules together - logging.rs non-panicking logger init (via protocol helper) - jvm.rs get_jvm() + JVM health monitor - server.rs TCP command server loop - command.rs dispatch over protocol::Command - library.rs client lib lifecycle: load / unload / reload (hot-reload) - platform.rs signal handlers — cfg(unix); macos/windows arms -``` - -- `library.rs` keeps the temp-copy hot-reload but uses clean `Path` APIs. -- Mutex poisoning handled (recover, not panic). - -### `client/` — one global `Client` - -The three singletons collapse into a single owned runtime root, explicitly -initialized once, `Arc`-free: - -```rust -// client/src/state.rs -static CLIENT: OnceLock = OnceLock::new(); - -pub struct Client { - pub jvm: JavaVM, - pub mapping: Mapping, - pub minecraft: Minecraft, // window-level handle, always valid once injected - pub modules: ModuleRegistry, -} - -/// Called exactly once, from `initialize_client`. The single, known init point. -pub fn init() -> Result<(), ClientError> { - CLIENT.set(Client::new()?).map_err(|_| ClientError::AlreadyInitialized) -} - -/// Infallible accessor — valid after `init()` succeeded. -#[inline] -pub fn client() -> &'static Client { - CLIENT.get().expect("client() used before init()") -} - -/// Convenience — `&client().mapping`. -#[inline] -pub fn mapping() -> &'static Mapping { &client().mapping } -``` - -- No `Arc`: access = one atomic-acquire load + branch, `#[inline]`d. -- `Mapping` / `Minecraft` become **fields**, not singletons. `&Mapping` removed - from every constructor; `FieldType` loses its lifetime → - `Object(MinecraftClassType)`. -- `GameContext` trait dropped (or reduced to nothing) — replaced by the free - `client()` / `mapping()` functions. -- `new()` no longer `unsafe` — the unsafe JNI calls are wrapped internally. -- Init order is straight-line in `initialize_client`: `state::init()?` → - `register_modules()` → install hooks **last** (so `on_frame` never observes an - uninitialized `Client`; `RUNNING` is set true only after init succeeds). - -### `client/` — menu-safe lazy game state - -`Minecraft.getInstance()` and the game `Window` exist from the main menu -onward. `player`, `level`/`world` and `gameMode` are **world-scoped**: null in -the menu, populated on world join, null again on leave. So they must never be -built in a constructor — only fetched on demand. - -```rust -pub struct Minecraft { - jni_ref: GlobalRef, // Minecraft.getInstance() — valid from menu onward - window: Window, // valid from menu onward -} - -impl Minecraft { - /// `Ok(None)` in the menu / not in a world. `Err` only on a real JNI fault. - pub fn player(&self) -> Result>; - pub fn world(&self) -> Result>; - pub fn game_mode(&self) -> Result>; - pub fn in_world(&self) -> bool; -} -``` - -- `Result>` is honest: `Err` = JNI failure, `Ok(None)` = not in world. -- `player()` keeps the existing cache (`RwLock>`) with the - `is_same_object` staleness check. -- Every world-dependent module's `on_tick` early-returns `Ok(())` when not in - world — "nothing to do", **not** an error, so the module is not disabled: - - ```rust - fn on_tick(&self) -> anyhow::Result<()> { - let Some(player) = client().minecraft.player()? else { return Ok(()) }; - // ... real logic - } - ``` - -Result: injecting from the menu initializes cleanly; modules sit idle until a -world loads, then start working — no log spam, no auto-disable. - -### `client/` — other restructuring - -``` -graphic/ - platform/ - mod.rs trait FrameHook + trait GlLoader, cfg-selected - linux.rs glX/glfw via dlsym + ilhook - windows.rs wgl + ilhook - macos.rs stub -> Err(Unsupported) - esp/ esp.rs (1081 LOC) split: math.rs / gather.rs / render.rs / mod.rs -module/ - registry.rs ModuleRegistry — single Mutex>, not the triple wrapper -``` - -- `ClientError` (thiserror) at mapping/JNI boundaries; `anyhow` stays at the - module-trait boundary. Lock access via a `lock_or_err` helper. -- `DarkClient::tick()` no longer `panic!`s on a failing module — log + disable. - ---- - -## Testing strategy - -A faithful test "framework" **is** feasible without launching Minecraft: the -`jni` crate (`invocation` feature) can create a real in-process JVM, and the -reflected mapping path is plain JNI reflection — it only needs classes named -like Minecraft's, not Minecraft itself. - -**Tier 1 — pure unit tests (fast, CI, no JVM).** Done as a dedicated phase -(Phase T1) after the refactor, so they are reviewed together: -- `protocol`: `Command` encode/decode round-trip. -- `client`: `class.rs` overload scoring (exists), ESP projection math, - `mappings.json` parsing, `FieldType` signature strings. -- `injector`: process-discovery filtering (pure fn over fake process lists). - -**Tier 2 — in-process JVM integration framework (CI-capable, needs a JDK).** -A dedicated test harness, e.g. `client/tests/jvm/`: -- A tiny Java fixture — stub classes (`net/minecraft/client/Minecraft` with a - static `getInstance`, a fake player/world, a custom classloader to emulate - Fabric's `KnotClassLoader`) compiled to a jar. -- Rust tests boot a `JavaVM`, load the fixture, and exercise the **real** - code paths: reflected `Mapping` resolution, `loader::discover_game_loader` - classloader scanning, method-signature reflection, `call_method` overload - resolution, and the **menu vs in-world** transitions (fixture toggles - `player`/`level` between null and set). -- Fixture build wired via a `build.rs` or a `cargo xtask` step (`javac`). - -**Tier 3 — full Minecraft e2e (optional, manual).** A documented `cargo xtask` -that launches a real Minecraft, injects, and asserts over the TCP channel / -logs. Marked `#[ignore]` / not run in CI — too slow and flaky for automation. -Provided as an opt-in harness; not a phase blocker. - -If Tier 2's JDK-at-test-time cost is unwanted in CI, it can be gated behind a -feature flag and Tier 1 alone runs in CI — but Tier 2 is the recommended core. - ---- - -## Phases - -Each phase = one commit, compiles, behavior unchanged (except Phase 5). - -### Phase 0 — Workspace foundation -- New `protocol` crate: `SOCKET_ADDR`, `Command`, encode/decode, shared - non-panicking logger helper. -- Centralize common deps in `[workspace.dependencies]` (`log`, `simplelog`, - `anyhow`, `thiserror`, `libc`, `libloading`, `jni`, `serde`, `sysinfo`, - `crossterm`, `ctor`). -- ✅ `cargo check` workspace. - -### Phase 1 — injector: platform layer + core -- `platform/`: `Injector` trait, `linux.rs` / `windows.rs` / `macos.rs` (stub), - `discovery.rs` (case-insensitive, robust binary-name match). -- Extract duplicated TCP-reload into `inject.rs` using `protocol`. -- `app.rs`: UI-agnostic core + `InjectionStatus`; injection on a worker thread. -- Remove all `.unwrap()`/`.expect()` panic points; `InjectError` type. -- ✅ `cargo check -p injector`. - -### Phase 2 — injector: GUI redesign + TUI -- New `gui/` (theme, widgets, layout): process cards, clear status/progress - states, non-blocking injection wired to `app.rs`. -- Rebuild `tui.rs` on the shared `app.rs` core. -- ✅ `cargo check -p injector`; manual GUI smoke test on Linux. - -### Phase 3 — agent_loader: modularize -- Split `lib.rs` into `logging / jvm / server / command / library / platform`. -- Command dispatch via `protocol::Command`; clean `Path` handling; handle - mutex poisoning; guarded logger init. -- ✅ `cargo check -p agent_loader`. - -### Phase 4 — client: global `Client` state -- Implement `state.rs`: one `OnceLock`, `init()` + `client()` / `mapping()`. -- Collapse `DarkClient` + `Minecraft` + `Mapping` singletons into `Client` - fields; drop the dead `Arc`s; drop `unsafe fn new()`. -- `mapping()` global accessor — remove `&Mapping` from all constructors; drop - `FieldType`'s lifetime. Fix the straight-line init order in `lib.rs`. -- ✅ `cargo check -p client` + `cargo test -p client`. - -### Phase 5 — client: menu-safe lazy game state -- `Minecraft` keeps only `jni_ref` + `window`; `player()` / `world()` / - `game_mode()` become lazy `Result>` accessors. -- `init()` succeeds in the main menu. -- (Module no-op behavior lands in Phase 7.) -- ✅ `cargo check -p client`; manual: inject from menu, no error log. - -### Phase 6 — client: error handling -- `ClientError` (thiserror) at mapping/JNI boundaries; `lock_or_err` helper. -- Remove critical-path `.unwrap()`; `tick()` and `init()` stop panicking. -- Convert the mapping caches (`classes`, `class_handles`) to `DashMap`. -- ✅ `cargo check -p client` + `cargo test -p client`. - -### Phase 7 — client: module system -- `ModuleRegistry` backed by `DashMap`; tidy `Module` trait; - keep explicit `register_modules()` (zero-dep, lean). -- Every world-dependent module `on_tick` early-returns `Ok(())` when not in - world — completes the menu-injection fix. -- ✅ `cargo check -p client`. - -### Phase 8 — client: graphic platform seam -- `graphic/platform/` (`mod` + `linux` / `windows` / `macos`) exposing - `gl_proc_address`, `open_glfw_library`, `frame_hook_targets`. -- `esp.rs` split **dropped** — it is already cleanly sectioned; splitting it - is cosmetic churn on working render code (user decision). -- ✅ `cargo check -p client` + `cargo test -p client`. - -### Phase 9 — T1: pure unit tests (no JVM) -- `protocol`: `Command` encode/decode round-trip. -- `client`: `class.rs` overload scoring (exists), ESP projection math, - `mappings.json` parsing, `FieldType` signature strings. -- `injector`: process-discovery filtering (pure fn over fake process lists). -- Fast, CI-friendly. Reviewed together before moving on. -- ✅ `cargo test` workspace. - -### Phase 10 — T2: JVM integration framework -- Java fixture (stub Minecraft classes + fake Fabric classloader), built via - `xtask`/`build.rs`. -- `client/tests/jvm/`: in-process `JavaVM` tests for reflected `Mapping`, - classloader discovery, overload resolution, menu↔in-world transitions. -- ✅ `cargo test -p client` (with JDK). - -### Phase 11 — T3: e2e harness (optional / manual) -- `xtask` crate + `cargo xtask e2e`: builds the workspace, discovers a - running Minecraft and injects into it; the overlay check is manual. -- Headless injector modes `--list` / `--inject ` make injection - scriptable. Not run in CI (needs a running game + root). -- ✅ manual run. - -### Phase 12 — polish -- `cargo fmt` + `cargo clippy` clean across the workspace. -- Update `CLAUDE.md` and `README.md` (the README `Module` trait example is - already stale) to match the new structure. -- Final `cargo build --release` (Linux); review Windows/macOS paths. -- Remove this file or move it to `docs/`. - ---- - -## Risks - -- **Phase 4** changes global init — mitigated: one explicit, documented init - point instead of three lazy ones; `cargo test` after. Lower risk than the - original lazy-`OnceLock` design. -- **Phase 5/7** menu-safe state touches every module — mitigated by the - uniform `let Some(..) = ..? else { return Ok(()) }` pattern. -- Frame/GL hooking (`ilhook`) is x86-64 only — the macOS stub compiles but - returns `Unsupported`; real macOS hooking is out of scope. -- Windows code can't be verified here — kept review-only. -- Tier 2 tests need a JDK + `javac` at test time — can be feature-gated if CI - cost is unwanted. - -## Out of scope - -- macOS implementation (only the seams). -- New client features / modules. -- Mapping-format or `conversion.py` changes. -- Replacing `egui`/`ilhook`/`jni`. diff --git a/client/src/graphic/esp.rs b/client/src/graphic/esp.rs index 1747a7b..eba7bb5 100644 --- a/client/src/graphic/esp.rs +++ b/client/src/graphic/esp.rs @@ -351,10 +351,7 @@ pub fn draw(ctx: &Context) { } let now = Instant::now(); - if state - .last_gather - .map_or(true, |t| now - t >= GATHER_INTERVAL) - { + if state.last_gather.is_none_or(|t| now - t >= GATHER_INTERVAL) { gather(&mut state, &cfg, now); } @@ -607,7 +604,7 @@ fn gather(state: &mut EspState, cfg: &EspConfig, now: Instant) { if cfg.chest.enabled { let due = state .last_chest_scan - .map_or(true, |t| now - t >= CHEST_SCAN_INTERVAL); + .is_none_or(|t| now - t >= CHEST_SCAN_INTERVAL); if due { state.last_chest_scan = Some(now); match gather_chests() { diff --git a/client/src/graphic/menu.rs b/client/src/graphic/menu.rs index f5dd4b0..0937b72 100644 --- a/client/src/graphic/menu.rs +++ b/client/src/graphic/menu.rs @@ -58,11 +58,11 @@ pub fn draw(ctx: &Context, progress: f32) { // Auto-layout: place non-empty categories left-to-right, wrapping rows. let categories = [ - ModuleCategory::COMBAT, - ModuleCategory::MOVEMENT, - ModuleCategory::RENDER, - ModuleCategory::PLAYER, - ModuleCategory::WORLD, + ModuleCategory::Combat, + ModuleCategory::Movement, + ModuleCategory::Render, + ModuleCategory::Player, + ModuleCategory::World, ]; let screen_w = ctx.screen_rect().width(); let mut slot = Pos2::new(ORIGIN_X, ORIGIN_Y); @@ -479,7 +479,7 @@ fn capture_keybind(data: &mut ModuleData, arc: &ModuleArc, registry: &ModuleMap) Notification::send( NotificationType::Warning, "Keybind in use", - &format!("'{}' is bound to {}", owner_name, key.to_string()), + &format!("'{owner_name}' is bound to {key}"), ); return true; } diff --git a/client/src/graphic/ui_engine.rs b/client/src/graphic/ui_engine.rs index 197292c..54e741b 100644 --- a/client/src/graphic/ui_engine.rs +++ b/client/src/graphic/ui_engine.rs @@ -35,11 +35,15 @@ pub fn gather_egui_inputs( screen_height: f32, scale_factor: f32, ) -> egui::RawInput { - let mut raw_input = egui::RawInput::default(); - raw_input.time = Some(elapsed_seconds()); - - let mut viewport_info = egui::ViewportInfo::default(); - viewport_info.native_pixels_per_point = Some(scale_factor); + let mut raw_input = egui::RawInput { + time: Some(elapsed_seconds()), + ..Default::default() + }; + + let viewport_info = egui::ViewportInfo { + native_pixels_per_point: Some(scale_factor), + ..Default::default() + }; raw_input .viewports diff --git a/client/src/lib.rs b/client/src/lib.rs index 9b5ce61..eef9a44 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -8,6 +8,9 @@ mod mapping; mod module; mod state; +/// OpenGL bindings, generated by `gl_generator` in `build.rs`. The generated +/// code is not ours to lint. +#[allow(clippy::all, clippy::pedantic)] pub mod gl { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } diff --git a/client/src/mapping/class.rs b/client/src/mapping/class.rs index 9dafde2..8c49c7c 100644 --- a/client/src/mapping/class.rs +++ b/client/src/mapping/class.rs @@ -254,7 +254,7 @@ impl MinecraftClass { // Object types 'L' => { let mut object_type = String::from("L"); - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { object_type.push(ch); if ch == ';' { break; @@ -271,7 +271,7 @@ impl MinecraftClass { array_type.push(chars.next().unwrap()); } 'L' => { - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { array_type.push(ch); if ch == ';' { break; diff --git a/client/src/mapping/minecraft_version.rs b/client/src/mapping/minecraft_version.rs index efb98d7..98143bd 100644 --- a/client/src/mapping/minecraft_version.rs +++ b/client/src/mapping/minecraft_version.rs @@ -24,9 +24,11 @@ impl MinecraftVersion { patch, } } +} - pub fn to_string(&self) -> String { - format!("{}.{}.{}", self.major, self.minor, self.patch) +impl std::fmt::Display for MinecraftVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } diff --git a/client/src/mapping/mod.rs b/client/src/mapping/mod.rs index 88d5ac5..73e65ff 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -347,7 +347,7 @@ impl Mapping { .map(|entry| entry.key().clone()) } - fn translate_type_descriptor<'a>(&self, descriptor: &mut &'a str) -> String { + fn translate_type_descriptor(&self, descriptor: &mut &str) -> String { let mut array_brackets = String::new(); while descriptor.starts_with('[') { array_brackets.push_str("[]"); diff --git a/client/src/module/combat/aimbot.rs b/client/src/module/combat/aimbot.rs index 0286cfc..08b391c 100644 --- a/client/src/module/combat/aimbot.rs +++ b/client/src/module/combat/aimbot.rs @@ -15,7 +15,7 @@ impl AimbotModule { module: ModuleData { name: "Aimbot".to_string(), description: "Automatically aims at entities".to_string(), - category: ModuleCategory::COMBAT, + category: ModuleCategory::Combat, key_bind: KeyboardKey::KeyC, enabled: false, settings: vec![ModuleSetting::Slider { diff --git a/client/src/module/combat/aura.rs b/client/src/module/combat/aura.rs index a89dff6..835cfc5 100644 --- a/client/src/module/combat/aura.rs +++ b/client/src/module/combat/aura.rs @@ -19,7 +19,7 @@ impl BaseAura { module: ModuleData { name, description, - category: ModuleCategory::COMBAT, + category: ModuleCategory::Combat, key_bind, enabled: false, settings: vec![ModuleSetting::Slider { diff --git a/client/src/module/mod.rs b/client/src/module/mod.rs index 221ad17..d07c2a2 100644 --- a/client/src/module/mod.rs +++ b/client/src/module/mod.rs @@ -10,24 +10,24 @@ pub type ModuleType = Box; #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ModuleCategory { - COMBAT, - MOVEMENT, - RENDER, - PLAYER, - WORLD, - MISC, + Combat, + Movement, + Render, + Player, + World, + Misc, } impl ModuleCategory { #[allow(dead_code)] pub fn display_name(&self) -> &str { match self { - ModuleCategory::COMBAT => "Combat", - ModuleCategory::MOVEMENT => "Movement", - ModuleCategory::RENDER => "Render", - ModuleCategory::PLAYER => "Player", - ModuleCategory::WORLD => "World", - ModuleCategory::MISC => "Misc", + ModuleCategory::Combat => "Combat", + ModuleCategory::Movement => "Movement", + ModuleCategory::Render => "Render", + ModuleCategory::Player => "Player", + ModuleCategory::World => "World", + ModuleCategory::Misc => "Misc", } } } @@ -355,9 +355,11 @@ impl KeyboardKey { _ => KeyboardKey::KeyNone, } } +} - pub fn to_string(&self) -> String { - match self { +impl std::fmt::Display for KeyboardKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { KeyboardKey::KeyNone => str::to_string("None"), KeyboardKey::KeyEscape => str::to_string("ESC"), KeyboardKey::Key1 => str::to_string("1"), @@ -465,6 +467,7 @@ impl KeyboardKey { KeyboardKey::KeyNext => str::to_string("Next"), KeyboardKey::KeyInsert => str::to_string("Insert"), KeyboardKey::KeyDelete => str::to_string("Delete"), - } + }; + f.write_str(&name) } } diff --git a/client/src/module/movement/fly.rs b/client/src/module/movement/fly.rs index fa66ef1..bcf8260 100644 --- a/client/src/module/movement/fly.rs +++ b/client/src/module/movement/fly.rs @@ -12,7 +12,7 @@ impl FlyModule { module: ModuleData { name: "Fly".to_string(), description: "Enables flying".to_string(), - category: ModuleCategory::MOVEMENT, + category: ModuleCategory::Movement, key_bind: KeyboardKey::KeyF, enabled: false, settings: vec![ModuleSetting::Slider { diff --git a/client/src/module/render/chest_esp.rs b/client/src/module/render/chest_esp.rs index 5f934fb..008789a 100644 --- a/client/src/module/render/chest_esp.rs +++ b/client/src/module/render/chest_esp.rs @@ -16,7 +16,7 @@ impl ChestEspModule { module: ModuleData { name: "Chest ESP".to_string(), description: "Draws a 3D box around containers".to_string(), - category: ModuleCategory::RENDER, + category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, enabled: false, settings: vec![ diff --git a/client/src/module/render/mob_esp.rs b/client/src/module/render/mob_esp.rs index 6a2f416..19d0448 100644 --- a/client/src/module/render/mob_esp.rs +++ b/client/src/module/render/mob_esp.rs @@ -15,7 +15,7 @@ impl MobEspModule { module: ModuleData { name: "Mob ESP".to_string(), description: "Draws a 3D box around mobs".to_string(), - category: ModuleCategory::RENDER, + category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, enabled: false, settings: vec![ diff --git a/client/src/module/render/player_esp.rs b/client/src/module/render/player_esp.rs index 343eb2e..83afcdf 100644 --- a/client/src/module/render/player_esp.rs +++ b/client/src/module/render/player_esp.rs @@ -15,7 +15,7 @@ impl PlayerEspModule { module: ModuleData { name: "Player ESP".to_string(), description: "Draws a 3D box around players".to_string(), - category: ModuleCategory::RENDER, + category: ModuleCategory::Render, key_bind: KeyboardKey::KeyNone, enabled: false, settings: vec![ From a664bc6fb90882fc93a1197c191718953a14183b Mon Sep 17 00:00:00 2001 From: TheDarkSword Date: Wed, 20 May 2026 16:51:21 +0200 Subject: [PATCH 14/14] Cache Rust dependencies and build artifacts in CI The build workflow caches the Cargo registry and the target directory so unchanged dependencies are not rebuilt. --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3aa6cac..0c1393d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,12 @@ jobs: toolchain: ${{ matrix.os == 'windows-latest' && 'nightly' || 'stable' }} components: rustfmt, clippy + # Caches the Cargo registry, the git database and the target directory. + # Keyed on the OS, the toolchain and Cargo.lock, so unchanged + # dependencies are not rebuilt on every run. + - name: Cache Rust dependencies and build artifacts + uses: Swatinem/rust-cache@v2 + - name: Install Java uses: actions/setup-java@v4 with: