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 ;
use ;
;
# async
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)
- 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 (transactionfor 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. Behavior::tick(ctx)(startfirst on the first tick; not called again onceBehavior::running()isfalse). 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_flyingonly record intent.- Held-slot sync (
ClientPlayerInteractionManager.tick) → key processing (hands: held buttons) →set_flying'sabilities→ 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 → sprintentity_action→ sneakentity_action→ exactly one offlying/position/look/position_look(moved > 0.03, or the position refresh: 1.8 checksticksSinceLastPositionPacketSent >= 20before 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:
settingsthen 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_slotchanges 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_evententity 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 theghast-worldchunk store and produces the tick's sprint / sneak / abilities packets and the movement packet. Server teleports, abilities, food (sprint), ourentity_velocityand explosion pushes are fed to it; the world border (moving borders advanced 50 ms per tick) reaches the collision code through the world.Controlsare 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 (flyingonly 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_digstart (abort of a previous block), damage fromrelative_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_placeon the face (activation or placing swings) every 4 ticks, else the item use packet; usable items (food, potion, milk, bow, sword) setusing_itemuntil right is released (block_digrelease), 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 unlessctx.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_placethen 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) withEntity.getRotationVectorfrom the float sine table and eyes = feet +1.62F(−0.08Fsneaking); ray shapes fromghast_physics::v1_8::ray_shape(ghast-data's oracle-verifiedselection()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 thatcollides()(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_entityattack; 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
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() == falsestops 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 intests/fixtures/traces/(every tick identical), seePARITY.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 byghast-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.