ghast-client 0.1.3

Headless Minecraft Java Edition client for the ghast library: connection, login, fixed 50 ms tick loop and a version-agnostic bot API (1.8.9 and 1.16.5)
Documentation

ghast-client

Headless Minecraft Java Edition client for the ghast library: connection, login, the fixed 50 ms tick loop and a version-agnostic bot API. Supports 1.8.9 (protocol 47) and 1.16.5 (protocol 754).

use ghast_client::connect::{Connector, ProxyPolicy};
use ghast_client::{Account, Behavior, Client, GameVersion, Interests, TickContext};

struct Afk;
impl Behavior for Afk {
    fn interests(&self) -> Interests { Interests::CHAT }
    fn tick(&mut self, ctx: &mut TickContext<'_>) {
        for m in &ctx.events().chat { println!("{}", m.plain()); }
        if ctx.is_dead() { ctx.respawn(); }
    }
}

# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let connector = Connector::new()
    .with_proxy("socks5h://user:pass@proxy:1080".parse()?)
    .with_policy(ProxyPolicy::RequireProxyExceptLocal);
let client = Client::new("mc.example.net:25565".parse()?, GameVersion::V1_8_9, Account::offline("Bot")?)
    .connector(connector);
let end = client.run(&mut Afk).await?; // Kicked / ServerClosed / Quit / Shutdown
# Ok(()) }

Examples (facade crate): cargo run -p ghast --release --example afk -- --host … --port … --username … --version 1.8.9|1.16.5 [--proxy socks5h://…] [--minutes N] [--reconnect], and --example chat (stdin lines → chat).

Layers

module what
client Client (config + run / run_until), connect → handshake → login → play loop
version ClientVersion (sealed) for V1_8 / V1_16_5: login and play packet mapping, packet filter, teleport rules, chat limit
common version-agnostic Inbound / Outbound packets and the API types (Pose, Controls, ChatMessage, …)
engine the deterministic core (no I/O, no clock): state, Behavior, TickContext, the tick
sim per version: the ghast-world chunk store + the mover (ghast-physics on 1.8.9, stand-still on 1.16.5); WorldView for behaviours
movement vanilla movement-packet choice (MovementSender) and a stand-still mover (no longer used by the client; kept for tools)
hands dig / place / use state machine, vanilla 1.8 World.rayTrace crosshair
record movement recorder in the oracle's replay format (Client::record)
rotation whole-mouse-count rotation (sensitivity GCD, Java float maths)
interest Interests: optional packet groups a behaviour opts into

Dispatch is compile-time: Client::run matches the run-time GameVersion once and runs a session monomorphised for that version; the engine and the behaviour are version-free.

Tick model (1.8.9 MinecraftClient.tick)

  1. Inbound packets are applied on arrival (like vanilla's per-frame task queue, which runs before each tick). Keep-alives, teleports (1.16: teleport_confirm + position_look; 1.8: position_look, on_ground = false) and window confirms (transaction for window 0 or the open window, when not accepted) are answered at once. Before a tick the loop also drains whatever the socket already holds.
  2. Behavior::tick(ctx) (start first on the first tick; not called again once Behavior::running() is false). Actions (chat, select_slot, place, dig, attack, clicks, swing, respawn) are written in call order before this tick's movement packet; set_aim / set_controls / set_buttons / set_flying only record intent.
  3. Held-slot sync (ClientPlayerInteractionManager.tick) → key processing (hands: held buttons) → set_flying's abilities → rotation step (only on ticks the behaviour aimed; held back for one tick when it would take the crosshair off a block dug or placed this tick, see below) → movement step → sprint entity_action → sneak entity_action → exactly one of flying / position / look / position_look (moved > 0.03, or the position refresh: 1.8 checks ticksSinceLastPositionPacketSent >= 20 before incrementing it — every 21st tick — while 1.9+ increments first — every 20th tick; Grim's BadPacketsE flags a 21-tick gap on 1.16.5). ctx.sent() is the pose the server has.

The behaviour only runs once the player has its first server position and its chunk column is loaded (1.16 vanilla does not tick the player outside a loaded column; on 1.8 we wait as well). Nothing is sent before the first position packet.

The tick schedule is a tokio interval with MissedTickBehavior::Skip: after a stall the missed ticks are skipped, never caught up (a burst of movement packets trips timer checks).

Silent-server guard: after silent_hold (1.5 s) without inbound bytes the behaviour is frozen (keys released, events kept) while the vanilla movement packet continues; it resumes when data arrives. After read_timeout (30 s, vanilla's) the session fails with ClientError::Timeout.

Robustness: decoding never panics (ghast-protocol); a packet the core needs that does not decode ends the session with ClientError::Decode (vanilla disconnects too); malformed chunk data (e.g. a section mask the data does not cover) only drops that column, is logged (rate-limited) and play goes on, as vanilla's task runner does — GHAST_DUMP_BAD_CHUNK=<dir> writes the first rejected bodies there for a bug report; a behaviour panic is caught, the behaviour is stopped and the player keeps standing; sessions share no state, the session future is Send for Send behaviours, so many bots can run on one multi-threaded runtime or one bot per process on a current_thread runtime (examples).

Packet filter

The play-state filter is built per version from the core's needs plus the behaviour's Interests (re-read every tick). Everything else — tab list, teams, scoreboards, particles, sounds, entities, time, recipes, tags, commands — is dropped by the frame decoder before it is decoded (after inflating only the id when compressed).

Core set: keep_alive, login, update_health, respawn, position, held_item_slot, game_state_change, open/close window, transaction, abilities, kick_disconnect, the world (ghast_world::v1_*::PACKET_IDS: chunks, block changes, explosion, world border; 1.8 map_chunk_bulk; 1.16 unload_chunk, update_view_position / distance), entity_velocity (1.8 knockback), set_compression (1.8 play), combat_event (1.16 immediate respawn). Interests::CHAT adds chat. Interests::BLOCK_UPDATES copies the block changes the world applies into TickEvents::block_updates (position, raw state; at most 4096 between two behaviour ticks, the rest counted in block_updates_dropped) — no extra packets, only the copy.

Raw tap: Behavior::raw_packets(version) names extra clientbound play packet ids; each such frame — also one the core consumes itself (position, held slot, abilities, …) — is copied undecoded (Bytes clone) into TickEvents::raw (RawInbound { id, body, at }, arrival order, kept while the behaviour is on hold, at most 4096 per tick, the rest counted in raw_dropped). The core's own handling does not change; a tapped id the core handles optionally (chat) is then handled too. Read every tick; a change rebuilds the filter.

Vanilla details reproduced

  • Join: settings then the brand plugin message (MC|Brand / minecraft:brand, vanilla).
  • Teleport: relative flags; absolute axes zero that motion; 1.8 yaw % 360, pitch % 360; 1.16 x/z clamped to ±3e7, pitch clamped to ±90 then % 360. lastReported* untouched, so the next tick sends the position again.
  • A fresh player (join / respawn) has lastReported* = 0.
  • Server held_item_slot changes only the selection; the next sync echoes it (vanilla).
  • Chat truncated to 100 (1.8) / 256 (1.16) UTF-16 units like the vanilla packet constructor.
  • 1.16 immediate respawn (game state 11 / combat_event entity dead) respawns at once.
  • Login plugin requests (1.16) are answered "not understood".

Movement and world

  • 1.8.9: ghast-physics (bit-exact against the vanilla oracle) runs the movement step on the ghast-world chunk store and produces the tick's sprint / sneak / abilities packets and the movement packet. Server teleports, abilities, food (sprint), our entity_velocity and explosion pushes are fed to it; the world border (moving borders advanced 50 ms per tick) reaches the collision code through the world. Controls are the vanilla keys (W/A/S/D, jump, sneak, sprint).
  • 1.16.5: ghast-physics::v1_16_5 (0 diffs against the 1.16.5 oracle) on the 1.16.5 chunk store, fed the same way (teleports with relative flags, abilities, food, velocity). Its packet rules are 1.16's: teleport confirm + position_look, no packet at all on idle ticks (flying only when on-ground changes), a position at least every 20 ticks.
  • ctx.world() (WorldView): loaded columns and raw block states; None = not loaded.

What the physics is fed

Teleports (relative flags), abilities, food, our entity velocity and explosion pushes, the movement speed attribute (update_attributes base + server modifiers, the client's own sprint modifier excluded; 1.8 physics takes only the base so far), status effects (jump boost, blindness; 1.16 also levitation, slow falling, dolphin's grace), boots enchantments (1.16 depth strider / soul speed from the armour NBT) and spectator mode (1.16). Haste / mining fatigue (effects) and aqua affinity (helmet) feed the dig speed.

Hands: dig, place, use (phase 3 groundwork)

hands.rs ports 1.8.9 MinecraftClient.tick key processing and ClientPlayerInteractionManager (verified against recorded lab packet traces); it runs after the behaviour and the held-slot sync and before the movement step, so its packets precede the tick's movement packet.

Turn held on action ticks (Client::hold_turn_on_action, on by default): Grim's RotationPlace re-checks a dig / place with the look of the movement packet that follows it. When this tick's rotation step would take the crosshair off a block dug (start / finish) or placed this tick — full-cube ray from the sent and the current feet, standing or sneaking eyes, old or new yaw, 6 blocks — the look stays for one tick and the turn continues the next time the behaviour aims (ctx.held_turns() counts them).

  • ctx.set_buttons(Buttons { left, right }): left held mines the crosshair block — block_dig start (abort of a previous block), damage from relative_hardness (hardness, harvest tools, tool multiplier + efficiency, haste / fatigue, eyes in water, not on ground ÷5; f32 sums like vanilla), finish at ≥ 1, then 5 ticks of hit delay; an arm swing per click and per damage tick; letting go aborts. Right held: block_place on the face (activation or placing swings) every 4 ticks, else the item use packet; usable items (food, potion, milk, bow, sword) set using_item until right is released (block_dig release), the server's entity status 9, or a slot change. A broken block is predicted air.
  • ctx.dig(pos, face) / ctx.place(support, face, hit): refused unless ctx.target() — the ray from the sent eyes along the sent look, reach 4.5 — is on that block face; dig = start (+ instant break) then swing, place = block_place then swing.
  • ctx.target(), ctx.mining(), ctx.broken(), ctx.using_item().
  • The crosshair is vanilla 1.8's World.rayTrace(eyes, eyes + look × reach, false, false, true) (block walk with the −1e−4 zero-fraction rule, per-block six-plane test, ties to the earlier plane; stairs: the eight half-cube passes, the hit farthest from the ray's end wins) with Entity.getRotationVector from the float sine table and eyes = feet + 1.62F (− 0.08F sneaking); ray shapes from ghast_physics::v1_8::ray_shape (ghast-data's oracle-verified selection() bounds plus the neighbour-dependent fences / panes / walls / doors / chests and the stairs passes). tests/oracle_raytrace.rs: all 2000 of the oracle's rays bit-exact (hit block, face, hit vector, miss block).

1.9+ rules (1.16.5): the left click digs first and swings after (a click on air is a miss); the right click tries the main hand, then the off hand — per hand the block (activation unless sneaking with an item; a block item places and swings), then the item itself (use_item), the first success ending it, so empty hands send a block_place per hand; swords no longer block, shields / bows / crossbows / tridents / potions / milk / food are used; the swing names its hand.

Open: entities are not hit by the crosshair yet; the 1.16 attack cooldown only matters for entity attacks (not implemented); 1.16 ray boxes are approximated (union of collision boxes).

Entities (ghast-entity, opt-in)

With Interests::ENTITIES (and PLAYER_LIST for the tab list) the session creates the version's ghast_entity tracker; its packet ids join the filter, so bots without the interest never decode entity packets. ctx.entities() is the store (get, within, players, …).

  • Crosshair: vanilla GameRenderer.updateTargetedEntity — entities that collides() (living, players, paintings, boats, minecarts, TNT, falling blocks, item frames, armor stands, leash knots, end crystals) whose box, grown by 0.1 on 1.8 (0 on 1.9+), the look ray hits first, within 3 blocks and closer than the block hit. It takes over the buttons: left attacks (use_entity attack; 1.8 swing first, 1.16 swing after and the attack cooldown resets), right interacts (interact-at, then interact, then the held item like vanilla's fall-through for plain entities; 1.16 per hand).
  • ctx.target_entity() (from the sent pose), ctx.attack(id) (refused unless the sent crosshair is on it), ctx.attack_strength(attack_speed) (1.16 cooldown, 1.0 on 1.8).
  • Entity boxes come from ghast-entity (minecraft-data sizes, server positions; vanilla picks the interpolated position). Entity interactions that vanilla handles client-side (villager trading, armor stands) are not predicted.

Inventory (ghast-inventory)

Every window packet is fed to ghast_inventory::Inventory on arrival; the inventory answers window confirms (exactly once — the engine does not). ctx.inventory(), ctx.click(Click) (refused while turning or when the inventory refuses: one click in flight, same-tick order rules, unpredictable results), ctx.open_inventory(), ctx.close_window(); ctx.events().inventory carries change flags and click results. The held stack drives dig speed (tool, efficiency from NBT) and placing; an open GUI releases the mouse buttons.

Grim compatibility (Client::force_step_position, off by default, NOT vanilla)

Grim 2.3.73 sets back two vanilla-exact situations found by the movement course (oracle replays of the exact recordings: ghast == vanilla, states and packets): (1) a landing step-up onto a 1/8-higher block (soul sand → grass) whose move stays under the 0.03-block resend threshold, so vanilla sends only look / flying with on-ground true (both versions); (2) 1.16.5 walking on slime (on-ground alternates every tick; GroundSpoof). With force_step_position(true) the client sends the position on ticks whose feet height changed under the threshold (and updates its sent bookkeeping): (1) disappears on both labs (16 min, 0 setbacks); (2) remains.

Recording (Client::record(path), 1.8.9 and 1.16.5)

Writes the session in the oracle's replay format when it ends: setup = the player at the first movement step, with its exact bounding box (bb; 1.16.5 also pose), so replays need no box rebuild; loaded_chunks / fills = every column loaded within 3 chunks of the player at any step, with its blocks as first seen; one tick line per movement step with the input (keys, yaw, pitch, using item) and the inbound events applied before it (teleports — raw packet values with their flags byte, the reply the client sent in ghast_reply — our velocity, abilities), plus ghast's own state and packets. Compare with oracle/cache/replay_compare.py. Block changes after a column was first seen and explosion pushes are not representable (ghast_note).

Behaviour API reference and trace replay

PARITY.md describes the behaviour contract (Behavior + TickContext) member by member and records the trace replay: wheat plant / harvest traces recorded on a local lab server by a reference mineflayer-based bot, replayed through the engine, give the same packets in the same order on every tick (src/trace_replay.rs). PORTING.md is the guide for porting a mineflayer-style bot to ghast (before / after snippets, Grim settings, a one-process-per-bot runner).

Tests

cargo test -p ghast-client
  • src/engine.rs: tick order (actions before the single movement packet, one per tick), join/teleport replies per version, standing packets (C06, then C03 with a C04 every 21st tick), silent guard, transactions, panicking behaviour, immediate respawn.
  • src/engine.rs (parity): block-change events per version (1.16 section y sign), set_flying (needs "may fly", abilities before the move), running() == false stops the ticks, turn held on a place tick, click tickets.
  • src/engine.rs: 1.8 planting uses the seed at once (not on stone, not for a block item).
  • src/trace_replay.rs: trace replay of the lab fixtures in tests/fixtures/traces/ (every tick identical), see PARITY.md.
  • src/movement.rs, src/rotation.rs: packet choice, GCD steps, Java float maths.
  • src/sim.rs: the 1.8 physics' teleport equals the documented 1.8 rules; standing on and walking over a chunk decoded by ghast-world.
  • tests/fake_server.rs: an in-process fake server, every test for both versions: offline login with compression (and 1.8's mid-stream play compression), keep-alive, transaction, kick reason, login refusal, online mode without an account, server close (play and login), malformed packet, garbage compressed frame, read timeout, wire-level tick order, silent guard, clean shutdown, chat events, two bots on one multi-threaded runtime.
  • tests/cpu.rs (ignored; cargo test -p ghast-client --release --test cpu -- --ignored --nocapture): one bot against a busy fake 1.8 server (per tick a Grim-style transaction, 40 entity moves, 4 entity velocities; time and keep-alive): client thread CPU from /proc/thread-self/stat. WSL2 desktop, 2026-11-03: 50 ms CPU in 20 s = 0.25 % of one core at ~900 inbound packets/s.

Live results: 30-min mixed soaks (local WSL labs, 2026-11-04)

crates/ghast/examples/soak.rs (walk / sprint / sprint-jump / sneak on open grass, dig + place with vanilla timing, inventory open / 4 clicks / close; --grim-compat, --record), 30 min per version, final binary:

1.8.9 (Paper 1.8.8 + Grim) 1.16.5 (Paper 1.16.5 + Grim)
activity 314 walks, 156 digs, 156 places, 208 clicks 306 walks, 153 digs, 153 places, 204 clicks
Grim flags 0 0
setbacks 1 — an external console tp of the account 0
click results all accepted all accepted
CPU (avg / max 10 s) 0.29 % / 0.50 % 0.31 % / 0.50 %
RSS max 59 MB (recording buffered in memory then; now streamed to a .part file) 54 MB (same)

An earlier 1.16 soak exposed the speed-attribute bug (sprint modifier excluded → Grim Simulation .002–.03); fixed before these runs. attack example (entity pick + attacks; 1.16 at full attack strength): 1.8 villager 30+ attacks, 1.16 armor stand 50+ attacks, 0 flags.

Live results: movement course, dig / place / use (local WSL labs, 2026-11-04)

crates/ghast/examples/movement_course.rs (ice, packed ice, soul sand, slime, water strips; slab step-up, block jump, sneak edge, wall and fence pushes, circles; GCD turning; seeded): 1.8.9 ~30 min, 1.16.5 ~25 min. All flags are the two Grim-vs-vanilla cases above; with --grim-compat 0 setbacks on both (1.16 slime GroundSpoof x2 per crossing remains). dig_place (left button dig with vanilla timing, place back; 1.8: stone by hand 151 ticks, 1.16: dirt) and use_item (golden apple eaten while walking: 32 ticks, ended by entity status 9; sword blocking; ×0.2 input) : 0 Grim flags. Found on the way: 1.8 C08 must carry the held stack (Grim BadPacketsU) — fixed; food only starts eating below a full bar — fixed.

Live results (local WSL labs, 2026-11-03)

lab/bench-afk.sh --warmup 30 --secs 600 -- target/.../release/examples/afk --host 127.0.0.1 --port 25618|25616 --username GhastBot2|GhastBot3 --version 1.8.9|1.16.5, both bots at the same time, standing at the superflat spawn:

1.8.9 (Paper 1.8.8 + Grim 2.3.73) 1.16.5 (Paper 1.16.5 + Grim 2.3.73)
connected 630 s, no disconnect 630 s, no disconnect
Grim flags 0 0 (after the 20-tick position refresh fix; before it, BadPacketsE every second)
CPU (one core, 600 s avg / max 10 s) 0.41 % / 0.69 % 0.31 % / 0.86 %
RSS / PSS 3.9 MB / 1.8 MB 3.8 MB / 2.1 MB
threads 1 1

mineflayer AFK baseline on the same 1.8 lab: 1.70 % of one core, ~120 MB RSS, 7 threads.