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: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/.idea
/target
/docs_internal
/docs_internal
mcp-reimagined
25 changes: 17 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

DarkClient is a Minecraft (Java Edition) modification framework written in Rust. It injects native libraries into a running Minecraft JVM and drives the game through JNI. One build supports both **obfuscated** Minecraft (≤ 1.21.11, via bundled Mojmap mappings) and **unobfuscated** Minecraft (26.1+, via runtime JNI reflection) — see the mapping system below.

It is a Cargo workspace of four crates — `protocol`, `injector`, `agent_loader`, `client` — plus an `xtask` helper.
It is a Cargo workspace of five crates — `protocol`, `injector`, `agent_loader`, `client`, `mapping_derive` — plus an `xtask` helper.

## Build & Common Commands

Expand All @@ -32,6 +32,7 @@ python conversion.py # regenerate mappings.json (needs the `reques
- **`injector/`** — standalone binary. A redesigned egui GUI (`gui/`); `--tui` for a crossterm TUI; `--list` / `--inject <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.
- **`mapping_derive/`** — `proc-macro` crate. Provides `#[derive(MappedObject)]` for the JVM-object wrappers in `client` (see *Game wrappers* below).
- **`xtask/`** — workspace task runner; `cargo xtask e2e` is the manual Tier-3 test.

## Injection & Hot-Reload Flow
Expand All @@ -40,7 +41,7 @@ This is the core control flow and spans `injector`, `protocol`, `agent_loader`,

1. `injector` injects `libagent_loader.so`/`.dll` into the JVM process — ptrace (`ptrace-inject`) on Linux, `dll-syringe` on Windows.
2. `agent_loader`'s `#[ctor]` `agent_onload()` starts a TCP server on `protocol::SOCKET_ADDR` (**`127.0.0.1:7878`** — defined once, in `protocol`).
3. `injector` connects and sends a `protocol::Command::Reload(<absolute-path-to-libclient>)`.
3. `injector` connects and sends a `protocol::Command::Reload { library, config_dir }` — the absolute libclient path plus the injector's own working directory. The agent loader exports `config_dir` as the `DARK_CONFIG_DIR` env var so the client knows where to keep its config.
4. `agent_loader`'s `library` module copies the library to a uniquely named temp file (avoids file locks), `dlopen`s it, and calls the exported `initialize_client`.
5. Re-injecting repeats step 3 → `library::reload` cleans up and drops the old library (calling `cleanup_client`) before loading the new one. This is the hot-reload path.

Expand All @@ -52,11 +53,13 @@ This is the core control flow and spans `injector`, `protocol`, `agent_loader`,

**Rendering & ticking** (`graphic/hook.rs`): `install_hooks` hooks the buffer-swap function (`glfwSwapBuffers` / `wglSwapBuffers`, via `ilhook`) so `on_frame` runs every frame — it renders the egui overlay (`ui_engine.rs`) and calls `check_tick`. `check_tick` compares the player's tick count to detect new game ticks and calls `client().modules.tick()`. Tick logic runs on the render thread, not a Minecraft thread. Per-platform GL / hook details live behind `graphic/platform/` (`gl_proc_address`, `open_glfw_library`, `frame_hook_targets`).

**Input** (`graphic/input.rs`): swaps GLFW key/mouse/cursor callbacks. **Right Shift** (key `344`) toggles the GUI; while the GUI is open, input events are consumed instead of forwarded to Minecraft. Module keybinds toggle modules on key press.
**Input** (`graphic/input.rs`): swaps GLFW key/mouse/cursor/scroll callbacks. **Right Shift** (key `344`) toggles the GUI, **Esc** closes it; while the GUI is open, input events (including the scroll wheel, which is forwarded to egui instead) are consumed rather than passed to Minecraft. Module keybinds toggle modules on key press.

**Module system** (`module/`): implement the `Module` trait (`on_start`/`on_stop`/`on_tick`, all returning `anyhow::Result<()>`) plus `ModuleData` accessors. Register new modules in `register_modules()` in `client/src/lib.rs`. Modules carry typed `ModuleSetting`s (Toggle/Slider/Choice/Color). The `ModuleRegistry` (`module/registry.rs`) is backed by a `DashMap`; reach it through `client().modules`.
**Module system** (`module/`): implement the `Module` trait (`on_start`/`on_stop`/`on_tick`, all returning `anyhow::Result<()>`; optional `handle_packet` — see *Packet layer* below) plus `ModuleData` accessors. Register new modules in `register_modules()` in `client/src/lib.rs`. Modules carry typed `ModuleSetting`s (Toggle/Slider/Choice/Color). Each module has a stable identity — the `ModuleId` enum — and is registered/looked up by it (never by a name string). The `ModuleRegistry` (`module/registry.rs`) is a `DashMap<ModuleId, _>`; reach it through `client().modules`. `register()` also snapshots each module's factory defaults, which `ModuleRegistry::reset_settings()` (the GUI's "Reset Settings" button) restores.

**Game wrappers** (`mapping/client/`, `mapping/entity/`): `Minecraft` holds only what exists from the main menu onward — the `getInstance()` handle and the `Window`. The world-scoped objects are lazy accessors — `player()`, `world()`, `game_mode()` return `Result<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`.
**Config persistence** (`config.rs`): each module's keybind, setting values and enabled state — plus the GUI layout (category-panel positions and which modules are expanded) — are written to `dark_client_config.json` so they survive a re-injection. The file lives in the **injector's working directory** (passed via `DARK_CONFIG_DIR`) — deliberately *not* in `.minecraft`, to leave no trace in the game directory; it falls back to the process working directory if the variable is unset. `config::save()` runs whenever the user changes something (GUI close, module toggle, "Reset Settings", and before a Panic unload); `config::load()` runs once, right after `register_modules()`, and re-applies the saved state — re-enabling modules that were left on.

**Game wrappers** (`mapping/client/`, `mapping/entity/`): `Minecraft` holds only what exists from the main menu onward — the `getInstance()` handle and the `Window`. The world-scoped objects are lazy accessors — `player()`, `world()`, `game_mode()` return `Result<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`. Each wrapper of a live JVM object derives `MappedObject` (`#[derive(MappedObject)]`, from the `mapping_derive` crate), which gives it `jni_ref()`, `class_type()`, and the `call_method`/`get_field`/`set_field`/`instance_of`/`is_same`/`equals` helpers. Immutable value types (`Vec3`, `BlockPos`, …) are instead read once into plain Rust fields — a value-snapshot, no JNI handle retained.

**Mapping system** (`mapping/`): bridges deobfuscated (Mojmap) names — what `MinecraftClassType` and the rest of the code use — to whatever the running JVM actually exposes. `Mapping::new()` auto-detects the build and picks one of two modes:

Expand All @@ -67,6 +70,8 @@ Both modes share one code path: `DashMap`s (`classes`, `class_handles`) populate

**Mod loaders (`mapping/loader.rs`)**: Fabric and Forge/NeoForge run the game in an isolated class loader, so `find_class` from a native thread resolves a dead duplicate of `Minecraft` whose static `instance` is null. `Mapping::new()` calls `loader::discover_game_loader` first — it scans every live thread's context class loader and keeps the one whose `Minecraft.getInstance()` is non-null. That loader is stored in `class_loader` so every later lookup goes through `ClassLoader.loadClass`. This works loader-agnostically for vanilla, Fabric and Forge on unobfuscated builds; obfuscated Minecraft under a mod loader is not supported.

**Packet layer** (`net/`): intercepts the Minecraft↔server connection — used by modules that must read or rewrite packets (NoFall, Velocity). A thin Netty bridge handler, `DarkChannelHandler` (Java source + committed `.class` in `client/java/`, bytecode embedded via `include_bytes!`), is `DefineClass`'d into the game class loader, its `native` methods bound with `RegisterNatives`, and an instance inserted into the live `Connection`'s Netty pipeline *before* Minecraft's own `packet_handler` (so it sees inbound and outbound packets before the game does) — pure JNI, no JVMTI. A class name can be `DefineClass`'d only once per loader, so on hot-reload the existing class is reused and only its natives are rebound to the new library. `net::ensure_installed` (polled each tick) keeps it installed; `net::teardown` removes it before unload. The handler calls back into `net::dispatch`, which wraps the JVM packet into a `Packet` value-snapshot (`net/packet/`) and offers it to every enabled module's `Module::handle_packet`. A module may **mutate** the snapshot in place — the dispatch then rebuilds a fresh JVM object that replaces the original — or return `PacketAction::Cancel` to **drop** the packet entirely (the callback returns `null`, which the Java handler discards; works for inbound and outbound alike). Minecraft packets are strictly directional, so `Packet::from_outbound` (`Serverbound*`) and `from_inbound` (`Clientbound*`) only probe their own variants; unhandled types return `None` and pass straight through. Packet class names live in the `MinecraftClassType` enum (`mapping/class_type.rs`), not as string literals — each `net/packet/` module exposes a `CLASS_TYPE` constant. All packet/Netty JNI uses explicit descriptors — no reflection. To support a new packet, add its class(es) to `MinecraftClassType`, a value-snapshot module under `net/packet/`, and a `Packet` variant named after the Java class.

**Lifecycle safety**: the global `RUNNING: AtomicBool` gates `on_frame`. A panic hook in `initialize_client` calls `cleanup_client` so input/render hooks are always uninstalled and GLFW callbacks restored, even on panic.

## Mappings
Expand All @@ -83,6 +88,10 @@ Three tiers (`cargo test --workspace` runs T1 + T2):

## Logs

- `injector` → `app.log` (in its working directory)
- `agent_loader` → `agent_loader.log`
- `client` → `dark_client.log` (in `.minecraft`)
All three log files — and the client config — are written to the **injector's
working directory** (the agent loader and client receive it via the
`DARK_CONFIG_DIR` env var), so nothing is left in `.minecraft`:

- `injector` → `app.log`
- `agent_loader` → `agent_loader.log` (set up lazily, on the first command, once the directory is known)
- `client` → `dark_client.log`
9 changes: 9 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"client",
"agent_loader",
"xtask",
"mapping_derive",
]

[workspace.package]
Expand Down Expand Up @@ -37,3 +38,5 @@ dashmap = "6.1"
sysinfo = "0.37.2"
crossterm = "0.29"
ctor = "0.2.8"
syn = "2"
quote = "1"
19 changes: 15 additions & 4 deletions agent_loader/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::time::Duration;
use log::{error, info};
use protocol::Command;

use crate::library;
use crate::{library, logging};

/// Maximum time spent waiting for a command line before giving up.
const READ_TIMEOUT: Duration = Duration::from_secs(5);
Expand All @@ -25,9 +25,20 @@ pub fn handle_connection(stream: TcpStream) {
}

match Command::decode(&line) {
Ok(Command::Reload(path)) => {
info!("reload command received: {}", path.display());
if let Err(e) = library::reload(&path) {
Ok(Command::Reload {
library,
config_dir,
}) => {
// The injector's working directory is where both this agent and
// the client keep their files — set up logging there (keeping
// `.minecraft` clean) and hand it to the client, loaded into this
// same process, through the environment.
if !config_dir.as_os_str().is_empty() {
logging::init(&config_dir);
std::env::set_var("DARK_CONFIG_DIR", &config_dir);
}
info!("reload command received: {}", library.display());
if let Err(e) = library::reload(&library) {
error!("reload failed: {e}");
}
}
Expand Down
3 changes: 2 additions & 1 deletion agent_loader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ pub(crate) fn is_running() -> bool {
/// Runs automatically when the agent library is loaded into the JVM process.
#[ctor]
fn agent_onload() {
logging::init();
// File logging is set up once the first command reveals where to write it
// (the injector's directory) — see `command::handle_connection`.
info!("agent loader initialized");

platform::install_signal_handlers();
Expand Down
22 changes: 15 additions & 7 deletions agent_loader/src/logging.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
//! Agent logger setup.

use log::LevelFilter;
use std::path::Path;
use std::sync::Once;

/// Path of the agent's log file, created in the JVM's working directory.
/// Name of the agent's log file.
const LOG_FILE: &str = "agent_loader.log";

/// Initializes file logging. Never panics — a logging failure must not stop
/// the agent from loading.
pub fn init() {
if let Err(e) = protocol::init_file_logger(LOG_FILE, LevelFilter::Debug) {
eprintln!("[agent_loader] file logging disabled: {e}");
}
static INIT: Once = Once::new();

/// Initializes file logging in `dir` — the injector's working directory, so
/// nothing is written into `.minecraft`. Runs at most once (later calls are
/// no-ops) and never panics — a logging failure must not stop the agent.
pub fn init(dir: &Path) {
INIT.call_once(|| {
let path = dir.join(LOG_FILE);
if let Err(e) = protocol::init_file_logger(&path, LevelFilter::Debug) {
eprintln!("[agent_loader] file logging disabled: {e}");
}
});
}
1 change: 1 addition & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ serde_json = "1.0.135"
libloading = "0.9.0"
ilhook = "2.3.0"
lazy_static = "1.4.0"
mapping_derive = { path = "../mapping_derive" }

[dev-dependencies]
# The `invocation` feature provides `JavaVM::new`, used by the in-process
Expand Down
Binary file added client/java/DarkChannelHandler.class
Binary file not shown.
50 changes: 50 additions & 0 deletions client/java/DarkChannelHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Netty pipeline handler injected into Minecraft's connection by DarkClient.
*
* Thin bridge: every outbound / inbound packet is handed to native (Rust) code
* via {@link #onOutbound} / {@link #onInbound}, which returns the object to
* forward — the same one, a replacement, or {@code null} to drop it. All
* decision logic lives in Rust.
*
* Compiled against the stub Netty types in {@code stub/} (the real Netty
* classes are resolved at runtime from Minecraft's class loader). The compiled
* {@code DarkChannelHandler.class} is committed and embedded in the client
* library. To rebuild after editing:
*
* javac --release 17 -d <out> stub/io/netty/channel/*.java DarkChannelHandler.java
* cp <out>/DarkChannelHandler.class .
*/
public class DarkChannelHandler extends io.netty.channel.ChannelDuplexHandler {

private static native Object onOutbound(Object packet);

private static native Object onInbound(Object packet);

@Override
public void write(io.netty.channel.ChannelHandlerContext ctx, Object msg,
io.netty.channel.ChannelPromise promise) throws Exception {
Object result;
try {
result = onOutbound(msg);
} catch (Throwable ignored) {
result = msg;
}
if (result != null) {
ctx.write(result, promise);
}
}

@Override
public void channelRead(io.netty.channel.ChannelHandlerContext ctx, Object msg)
throws Exception {
Object result;
try {
result = onInbound(msg);
} catch (Throwable ignored) {
result = msg;
}
if (result != null) {
ctx.fireChannelRead(result);
}
}
}
11 changes: 11 additions & 0 deletions client/java/stub/io/netty/channel/ChannelDuplexHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Compile-time stub of io.netty.channel.ChannelDuplexHandler — only the
// methods DarkChannelHandler overrides. Signatures must match real Netty
// exactly, since the runtime links against the real class.
package io.netty.channel;

public class ChannelDuplexHandler {
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {}

public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {}
}
5 changes: 5 additions & 0 deletions client/java/stub/io/netty/channel/ChannelFuture.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Compile-time stub of io.netty.channel.ChannelFuture.
// The real Netty class is resolved at runtime from Minecraft's class loader.
package io.netty.channel;

public interface ChannelFuture {}
10 changes: 10 additions & 0 deletions client/java/stub/io/netty/channel/ChannelHandlerContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Compile-time stub of io.netty.channel.ChannelHandlerContext — only the
// methods DarkChannelHandler calls. Signatures must match real Netty exactly,
// since the runtime links against the real class.
package io.netty.channel;

public interface ChannelHandlerContext {
ChannelFuture write(Object msg, ChannelPromise promise);

ChannelHandlerContext fireChannelRead(Object msg);
}
5 changes: 5 additions & 0 deletions client/java/stub/io/netty/channel/ChannelPromise.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Compile-time stub of io.netty.channel.ChannelPromise.
// The real Netty class is resolved at runtime from Minecraft's class loader.
package io.netty.channel;

public interface ChannelPromise {}
Loading
Loading