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/.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: 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/Cargo.lock b/Cargo.lock index 3b5fb7d..be84bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,7 +133,7 @@ dependencies = [ "libc", "libloading 0.8.6", "log", - "simplelog", + "protocol", ] [[package]] @@ -660,11 +660,13 @@ name = "client" version = "0.1.0" dependencies = [ "anyhow", + "dashmap", "egui", "egui_glow", "gl_generator", "glow", "ilhook", + "java-locator", "jni", "lazy_static", "libc", @@ -673,6 +675,7 @@ dependencies = [ "serde", "serde_json", "simplelog", + "thiserror 2.0.17", ] [[package]] @@ -848,6 +851,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" @@ -1596,11 +1613,13 @@ dependencies = [ "eframe", "egui", "is_elevated", + "libc", "log", "proc-maps", + "protocol", "ptrace-inject", - "simplelog", "sysinfo", + "thiserror 2.0.17", ] [[package]] @@ -1647,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" @@ -1656,7 +1684,9 @@ dependencies = [ "cesu8", "cfg-if", "combine", + "java-locator", "jni-sys", + "libloading 0.7.4", "log", "thiserror 1.0.64", "walkdir", @@ -1793,11 +1823,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", ] @@ -2282,9 +2311,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" @@ -2332,15 +2361,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]] @@ -2540,6 +2569,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" @@ -4113,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 a555363..84dfb45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,12 @@ [workspace] resolver = "2" members = [ + "protocol", "injector", - "client" -, "agent_loader"] + "client", + "agent_loader", + "xtask", +] [workspace.package] version = "0.1.0" @@ -21,7 +24,16 @@ 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"] } +dashmap = "6.1" +sysinfo = "0.37.2" +crossterm = "0.29" +ctor = "0.2.8" 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/agent_loader/Cargo.toml b/agent_loader/Cargo.toml index bfdf399..0172052 100644 --- a/agent_loader/Cargo.toml +++ b/agent_loader/Cargo.toml @@ -7,15 +7,21 @@ 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"] used_linker = [] [dependencies] -ctor = "0.2.8" -log = "0.4.25" -simplelog = "0.12.2" +ctor.workspace = true +log.workspace = true +jni.workspace = true libloading = "0.8.0" -jni = "0.21" -libc = "0.2" \ No newline at end of file +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"); +} diff --git a/client/Cargo.toml b/client/Cargo.toml index d57d360..9a056df 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -9,20 +9,28 @@ 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 +thiserror.workspace = true +dashmap.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" +[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" \ No newline at end of file +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/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..eba7bb5 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,12 +292,9 @@ fn read_config() -> EspConfig { }, }; - let registry = match DarkClient::instance().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 { @@ -311,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 { @@ -324,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 { @@ -355,7 +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); } @@ -394,7 +390,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 +417,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 +470,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 +478,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,9 +505,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::instance().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; @@ -525,7 +527,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 +545,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 +553,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 +561,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 +580,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; @@ -597,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() { @@ -618,8 +625,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(); @@ -629,12 +636,21 @@ 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()?; 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 +658,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 +686,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 +794,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 +843,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,14 +855,17 @@ 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() { 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; @@ -918,7 +955,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 +974,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 +1070,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 +1114,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, @@ -1079,3 +1180,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/graphic/hook.rs b/client/src/graphic/hook.rs index 42dc316..c1b31c6 100644 --- a/client/src/graphic/hook.rs +++ b/client/src/graphic/hook.rs @@ -1,15 +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::client::DarkClient; -use crate::mapping::client::minecraft::Minecraft; -use crate::{gl, RUNNING}; +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}; @@ -31,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. @@ -90,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); } @@ -100,11 +56,9 @@ unsafe fn on_frame() { /// Renders the egui overlay for the current frame. unsafe fn render_overlay() { - if Minecraft::instance().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(); @@ -113,50 +67,29 @@ 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() { - 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) { LAST_TICK.store(tick_count, Ordering::Relaxed); - client.tick(); + state::client().modules.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() { @@ -177,78 +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/hud.rs b/client/src/graphic/hud.rs index d3d4cd6..4cc4a33 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,23 +87,28 @@ 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() { - 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. 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 +144,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..5addf60 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; @@ -110,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) { @@ -238,18 +216,15 @@ fn toggle_gui() { /// Toggles any module whose keybind matches `key`, when in-world. fn handle_module_keybind(key: i32) { - let minecraft = Minecraft::instance(); - if !minecraft.current_screen_is_null() || minecraft.get_player().is_err() { + let minecraft = minecraft(); + if !minecraft.current_screen_is_null() || !minecraft.in_world() { return; } - let client = DarkClient::instance(); - 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; } @@ -286,10 +261,12 @@ 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.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..0937b72 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,10 +41,7 @@ type ModuleMap = HashMap; pub fn draw(ctx: &Context, progress: f32) { draw_backdrop(ctx, progress); - let registry = match DarkClient::instance().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 @@ -62,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); @@ -128,19 +124,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 +160,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 +241,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 +306,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 +432,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); @@ -460,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/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/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/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 3a63a3a..54e741b 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; @@ -36,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 raw_input = egui::RawInput { + time: Some(elapsed_seconds()), + ..Default::default() + }; - let mut viewport_info = egui::ViewportInfo::default(); - viewport_info.native_pixels_per_point = Some(scale_factor); + let viewport_info = egui::ViewportInfo { + native_pixels_per_point: Some(scale_factor), + ..Default::default() + }; raw_input .viewports @@ -106,7 +109,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(); @@ -215,22 +218,20 @@ pub unsafe fn render_egui_ui() { } pub fn call_panic() { - let client = DarkClient::instance(); - 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 5d2a3c8..eef9a44 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -3,43 +3,45 @@ #![allow(dead_code)] extern crate jni; -mod client; mod graphic; 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")); } -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,62 +51,50 @@ 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(); - - 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/mapping/class.rs b/client/src/mapping/class.rs index ceb031a..8c49c7c 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; @@ -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> { @@ -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; @@ -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) { @@ -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/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..06e23e3 100644 --- a/client/src/mapping/client/minecraft.rs +++ b/client/src/mapping/client/minecraft.rs @@ -1,130 +1,106 @@ 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::{FieldType, GameContext, Mapping, MinecraftClassType}; +use crate::mapping::entity::player::LocalPlayer; +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. +/// +/// 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, - mapping: Mapping, - 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 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()`. + /// Succeeds whether or not a world is loaded. + pub fn new() -> anyhow::Result { + let minecraft = mapping() .call_static_method(MinecraftClassType::Minecraft, "getInstance", &[])? .l()?; - if minecraft.is_null() { - error!("Minecraft is null") + return Err(anyhow::anyhow!("Minecraft.getInstance() returned null")); } - - 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 game_mode = MultiPlayerGameMode::new( - mapping.new_global_ref( - mapping - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "gameMode", - FieldType::Object(MinecraftClassType::MultiPlayerGameMode, &mapping), - )? - .l()?, - )?, - ); + let jni_ref = mapping().new_global_ref(minecraft)?; + let window = Window::new(&jni_ref)?; Ok(Minecraft { - jni_ref: minecraft, - mapping, - player: RwLock::new(player), - world, + jni_ref, window, - game_mode, + player: RwLock::new(None), }) } - pub fn get_mapping(&self) -> &Mapping { - &self.mapping - } - - pub fn get_player(&self) -> anyhow::Result { - let player_obj = self - .mapping - .get_field( - MinecraftClassType::Minecraft, - self.jni_ref.as_obj(), - "player", - FieldType::Object(MinecraftClassType::LocalPlayer, &self.mapping), - )? - .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 self - .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 = self.mapping.new_global_ref(player_obj)?; - *write_guard = LocalPlayer { - jni_ref: jni_ref.clone(), - abilities: Abilities::new(jni_ref.clone(), &self.mapping)?, - 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) = 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(); @@ -132,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/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..be25a6a 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::MinecraftClassType; +use crate::state::mapping; use jni::objects::GlobalRef; use std::ops::Deref; @@ -9,28 +10,14 @@ 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 - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "level", - FieldType::Object(MinecraftClassType::Level, mapping), - )? - .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> { - let mapping = self.mapping(); - - let iterable_obj = mapping + let iterable_obj = mapping() .call_method( MinecraftClassType::Level, self.jni_ref.as_obj(), @@ -40,15 +27,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..fc4f87c 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,48 +17,31 @@ 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 - .get_field( - MinecraftClassType::Minecraft, - minecraft.as_obj(), - "player", - FieldType::Object(MinecraftClassType::LocalPlayer, mapping), - )? - .l()?; - - let player_ref = mapping.new_global_ref(player_obj)?; - let abilities = Abilities::new(player_ref.clone(), mapping)?; - 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, }) } } 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 +49,7 @@ impl Abilities { JValue::Bool(value), )?; - mapping.set_field( + mapping().set_field( MinecraftClassType::Abilities, self.jni_ref.as_obj(), "mayfly", @@ -78,9 +62,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/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/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/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 a4701fe..73e65ff 100644 --- a/client/src/mapping/mod.rs +++ b/client/src/mapping/mod.rs @@ -1,13 +1,12 @@ -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 dashmap::DashMap; 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,15 +21,8 @@ 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() - } -} +#[cfg(test)] +mod jvm_test; /// On-disk JSON shape. Only obfuscated builds ship one of these. #[derive(Debug, Deserialize)] @@ -53,11 +45,14 @@ 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 - /// 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 @@ -68,11 +63,11 @@ 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)] -pub enum FieldType<'local> { +pub enum FieldType { Boolean, Byte, Char, @@ -82,10 +77,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 +92,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,50 +107,61 @@ 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()), + classes: DashMap::new(), class_loader: RwLock::new(game_loader), - class_handles: RwLock::new(HashMap::new()), + class_handles: DashMap::new(), }); } @@ -172,20 +181,19 @@ impl Mapping { .collect(); Ok(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(), }) } - 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 { @@ -195,23 +203,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 @@ -228,8 +238,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)), }; @@ -246,21 +256,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() { + fn lookup_class<'a>(&self, env: &mut JNIEnv<'a>, jni_name: &str) -> anyhow::Result> { + 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( @@ -293,14 +296,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); } } _ => { @@ -337,14 +342,12 @@ 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 { + fn translate_type_descriptor(&self, descriptor: &mut &str) -> String { let mut array_brackets = String::new(); while descriptor.starts_with('[') { array_brackets.push_str("[]"); @@ -397,7 +400,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 +618,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 { @@ -668,3 +666,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/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 player = &minecraft.get_player()?; - let world = &minecraft.world; + let minecraft = minecraft(); + 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; - let mapping = minecraft.mapping(); + let mapping = mapping(); let player_pos = player.entity.get_position()?; let mut closest_dist = range; @@ -87,8 +87,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..835cfc5 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 { @@ -19,7 +19,7 @@ impl BaseAura { module: ModuleData { name, description, - category: ModuleCategory::COMBAT, + category: ModuleCategory::Combat, key_bind, enabled: false, settings: vec![ModuleSetting::Slider { @@ -51,11 +51,15 @@ impl Module for BaseAura { } fn on_tick(&self) -> anyhow::Result<()> { - let minecraft = Minecraft::instance(); - let player = &minecraft.get_player()?; - let world = &minecraft.world; - let game_mode = &minecraft.game_mode; - let mapping = minecraft.mapping(); + let minecraft = minecraft(); + 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()?; let range = self.get_range() as f64; @@ -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/mod.rs b/client/src/module/mod.rs index b18518a..d07c2a2 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; @@ -9,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", } } } @@ -354,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"), @@ -464,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 884282f..bcf8260 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 { @@ -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 { @@ -35,13 +35,19 @@ impl FlyModule { impl Module for FlyModule { fn on_start(&self) -> anyhow::Result<()> { - // Enables flying - Minecraft::instance().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::instance().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<()> { 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/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![ diff --git a/client/src/state.rs b/client/src/state.rs new file mode 100644 index 0000000..452fc5c --- /dev/null +++ b/client/src/state.rs @@ -0,0 +1,88 @@ +//! 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::sync::OnceLock; + +use jni::JNIEnv; +use thiserror::Error; + +use crate::mapping::client::minecraft::Minecraft; +use crate::mapping::Mapping; +use crate::module::registry::ModuleRegistry; + +/// 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() +} + +/// The running game and module state. +pub struct Client { + minecraft: Minecraft, + /// Every registered module. + pub modules: ModuleRegistry, +} + +impl Client { + /// Builds the client. The [`MAPPING`] global must already be set. + fn new() -> Result { + Ok(Client { + minecraft: Minecraft::new()?, + modules: ModuleRegistry::new(), + }) + } +} 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; + } +} diff --git a/injector/Cargo.toml b/injector/Cargo.toml index 3194232..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 = "0.29" -sysinfo = "0.37.2" +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" @@ -17,4 +21,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/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/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/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..2c980ac 100644 --- a/injector/src/main.rs +++ b/injector/src/main.rs @@ -1,156 +1,74 @@ +mod app; +mod gui; +mod inject; mod platform; mod tui; -use crate::platform::ProcessInfo; -use eframe::{CreationContext, 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`"); + let args: Vec = std::env::args().collect(); - #[cfg(target_family = "windows")] - eprintln!( - "❌ Please run this program as Administrator (Right click → Run as administrator)" - ); + // `--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; + } - return; // Exit the program if not elevated + if !platform::is_elevated() { + eprintln!("{}", elevation_hint()); + std::process::exit(1); } - // Initialize the logger with a default configuration - WriteLogger::init( - LevelFilter::Debug, - Config::default(), - File::create("app.log").unwrap(), - ) - .unwrap(); + let _ = protocol::init_file_logger("app.log", LevelFilter::Debug); - let args: Vec = std::env::args().collect(); - if args.contains(&"--tui".to_string()) { + // `--inject ` — headless injection, for scripting and the e2e harness. + if let Some(pid) = injection_target(&args) { + std::process::exit(run_headless_injection(pid)); + } + + if args.iter().any(|a| a == "--tui") { tui::run_tui(); return; } - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([450.0, 320.0]) - .with_min_inner_size([300.0, 220.0]), - ..Default::default() - }; - - eframe::run_native( - "DarkClient Injector", - native_options, - Box::new(|creation_context| Ok(Box::new(InjectorGUI::new(creation_context)))), - ) - .expect("Failed to run the GUI"); + if let Err(e) = gui::run() { + error!("GUI terminated with an error: {e}"); + eprintln!("Error: {e}"); + } } -pub struct InjectorGUI { - status: String, - found_processes: Vec, - selected_pid: Option, +/// 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() } -impl InjectorGUI { - pub fn new(_creation_context: &CreationContext<'_>) -> Self { - Self { - status: "Ready:".to_owned(), - found_processes: Vec::new(), - selected_pid: None, +/// 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 } - } - - 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); - } + Err(e) => { + eprintln!("injection into process {pid} failed: {e}"); + 1 } } } -impl eframe::App for InjectorGUI { - fn update(&mut self, ctx: &Context, _frame: &mut Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("DarkClient Injector"); - - ui.horizontal(|ui| { - if ui.button("🔄 Scan").clicked() { - self.scan(); - } - if !self.found_processes.is_empty() { - ui.label(format!("Found: {}", self.found_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(); - - match platform::inject(pid) { - Ok(_) => self.status = "✅ Injection Successful!".to_owned(), - Err(e) => self.status = format!("❌ Error: {}", e), - } - } - } - - ui.separator(); - ui.label(&self.status); - }); +/// 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)." } -} - -#[cfg(target_family = "unix")] -fn is_elevated() -> bool { - extern "C" { - fn geteuid() -> u32; + #[cfg(not(windows))] + { + "This program must run with root privileges: sudo ./injector" } - unsafe { geteuid() == 0 } -} - -#[cfg(target_family = "windows")] -fn is_elevated() -> bool { - is_elevated::is_elevated() } diff --git a/injector/src/platform/discovery.rs b/injector/src/platform/discovery.rs new file mode 100644 index 0000000..77667aa --- /dev/null +++ b/injector/src/platform/discovery.rs @@ -0,0 +1,133 @@ +//! 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() +} + +#[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/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..2d4910b 100644 --- a/injector/src/tui.rs +++ b/injector/src/tui.rs @@ -1,128 +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; - - // Renderizza stato injection - 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()), - } - } - } - _ => {} - }, - 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 } 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..b3e316b --- /dev/null +++ b/protocol/src/command.rs @@ -0,0 +1,112 @@ +//! 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())), + } + } +} + +#[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())), + ); + } +} 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 +} 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" + } +}