Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[alias]
# `cargo xtask <task>` — workspace developer tasks (see xtask/src/main.rs).
xtask = "run --quiet --package xtask --release --"
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/.idea
/target
/target
/docs_internal
70 changes: 44 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid>` 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 <absolute-path-to-libclient>`.
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(<absolute-path-to-libclient>)`.
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<Option<_>>`, 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<HashMap<String, Arc<MinecraftClass>>>` 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)
Expand Down
62 changes: 52 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
[workspace]
resolver = "2"
members = [
"protocol",
"injector",
"client"
, "agent_loader"]
"client",
"agent_loader",
"xtask",
]

[workspace.package]
version = "0.1.0"
Expand All @@ -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"
Loading
Loading