ghast-inventory 0.2.2

Inventory and container windows for the ghast Minecraft Java Edition client library: vanilla client-side click prediction, the window confirm protocol and lazy-NBT item stacks (1.8.9 and 1.16.5)
Documentation

ghast-inventory

Inventory and container windows for the ghast client library: the player inventory, cursor and open window as the vanilla client holds them, vanilla's client-side click prediction and the window confirm protocol, for 1.8.9 (protocol 47) and 1.16.5 (protocol 754). Pure and sync: packets in, packets out; no I/O, no clock.

use ghast_inventory::{Click, Inventory, Outgoing, Version};

let mut inv = Inventory::new(Version::V1_8);
// inbound, on arrival: Some(..) is a confirm answer to write at once
// if let Some(out) = inv.apply_v1_8(&packet) { writer.queue(&out.to_v1_8())?; }
inv.tick();                                  // once per client tick
let open = inv.open_inventory();             // 1.8: Ok(Some(OpenInventory)) = C16
inv.tick();
match inv.click(Click::QuickMove { slot: 36, right: false }) {
    Ok(out) => { /* write out.to_v1_8() before this tick's movement packet */ }
    Err(e) => { /* Pending, Resyncing, SameTick, NoScreen, InvalidSlot, Unpredictable, ... */ }
}

Model

  • Inventory mirrors vanilla's data: PlayerInventory (0–8 hotbar, 9–35 main, 36–39 armor feet→head, 40 offhand), the 2×2 crafting grid and result, the cursor, and the open window's own slots. Screen slot numbers are vanilla's (window0_slot, slot, slot_of_player).
  • Inbound handling follows ClientPlayNetworkHandler: window_items for window 0 or the open window; set_slot -1 = cursor, 1.16.5 -2 = PlayerInventory index, window 0 slots 36–44 always reach window 0, other slots only the current screen (window 0 slot 10 while a chest is open is dropped, like vanilla); close_window ignores its id; window ids are compared after the packet's own (un)signed byte read; login / respawn reset everything (new player entity, action counters back to 0).
  • 1.8 keeps zero-count stacks (vanilla 1.8 does); 1.16.5 treats them and air as empty.
  • Item stacks keep NBT raw (RawNbt); window_items never parses NBT. Equality compares bytes, then (only if they differ) the parsed trees like Java's NbtCompound.equals. NBT is parsed only for the few rules that read it (Unbreakable, 1.16 Damage, binding curse) and only when that rule is reached.

Clicks

click(Click) runs the version's onSlotClick on a scratch copy, then commits it, increments the screen's action counter (vanilla increments before the click; the first click of a container is action 1) and returns the window_click with the stack vanilla's onSlotClick returned (the server compares it with its own result and rejects the click on a mismatch).

Click mode button clicked item
Pickup { slot, right } (slot -999 = drop cursor / one) 0 0 / 1 slot before the click
QuickMove { slot, right } (shift) 1 0 / 1 1.8: first transfer's stack; 1.16.5: last transfer that left the same item in the slot (EMPTY when the slot emptied)
Swap { slot, hotbar } (number keys; 1.16.5 40 = offhand key) 2 0–8, 40 none
Drop { slot, all } (Q / ctrl+Q; slot -999 = GUI click outside with empty cursor) 4 0 / 1 none
PickupAll { slot } (double click) 6 0 none

Reproduced per version: stack limits (incl. 16-stacks, armor slots 1), 1.8 damage-variant rules (the 41 item ids read off the vanilla registry), 1.8 PICKUP needing equal damage, 1.8 insertItem ignoring slot rules vs 1.16.5 checking canInsert and slot limits, the quick-move retry loops, 1.8's number-key swap with a container slot (the hotbar item goes into the inventory via insertStack), 1.16.5 swap limits, 1.16.5 insertStack preferring the selected slot and the offhand, armor validity (1.8 armor ids + pumpkin/skull for the helmet; 1.16.5 preferred equipment slot incl. elytra, carved pumpkin, heads, shield → offhand), binding curse, shulker boxes, double-click's two passes.

Crafting

  • 1.8.9: like the vanilla client, ghast computes the result itself (src/crafting over ghast_data::v1_8::recipes()): the 373 recipes in the client's match order (shaped with mirroring, shapeless, and the special recipes armor dye, fireworks (rocket, star, fade), book cloning, map cloning, repairing), recomputed exactly when vanilla does — whenever a grid slot is set or taken from, not on in-place count changes, and after window_items / grid set_slot (so the client's result wins over the server's). Built result NBT is written in Java HashMap key order (byte-identical packets). Banner recipes and map upscaling (which needs the map's state) make the result unknown.
  • 1.16.5: the server sends results; the client keeps the recipes from declare_recipes ([RecipeBook], apply_v1_16_5 handles the packet) and uses them for the remainders of a taken result. After a grid change the result is unknown until the server's set_slot.
  • Taking a result (click, shift-click — 1.8 repeats while the result stays the same item —, number key, Q) consumes one of each ingredient and hands back the recipe's remainders (buckets, 1.16.5 glass bottles; book cloning keeps the written book).

Refused instead of guessed (ClickError::Unpredictable, nothing sent, nothing changed): shift-click from the player part of a furnace (smelting recipes, fuel), any interaction that reads a fuel / brewing / enchanting / anvil / beacon / trading / horse-equipment slot, a crafting-result click while the result is unknown, taking a result whose grid matches no recipe ghast models (1.8 banners, map upscaling; 1.16.5 special recipes, a written book or banner in the grid, or no recipe at all — vanilla then aliases the grid stacks).

Window types

ghast WindowKind 1.8 inventoryType 1.16.5 menu id container slots shift-click
Chest { rows } minecraft:chest, minecraft:container, unknown types with slots (slotCount / 9 rows) 0–5 (generic_9x1..6) rows × 9 yes
Generic3x3 minecraft:dispenser, minecraft:dropper 6 9 yes
Hopper minecraft:hopper 15 5 yes
ShulkerBox — 19 27 yes
Crafting minecraft:crafting_table (0 slots) 11 10 (result + 3×3) yes (1.16: into the grid first), not from the result
Furnace minecraft:furnace 13, 9 (blast), 21 (smoker) 3 from furnace slots only
BrewingStand, Enchanting, Anvil, Beacon, Merchant, Horse, Grindstone, Lectern, Loom, Smithing, Cartography, Stonecutter as vanilla as vanilla as vanilla no (player-part clicks of other modes work)

Confirm protocol and timing

  • One click in flight: further clicks get Pending until the confirm with the click's action number arrives (ClickOutcome::Accepted), or TimedOut after 40 ticks (set_click_timeout).
  • Reject → resync: on a rejected confirm the server marks the window, sends window_items and the cursor; clicks get Resyncing until that window_items arrives. The answer to every rejected confirm (window 0 or the open window; Grim's pings included) is on_transaction's return value — vanilla's accepted = true echo, produced exactly once. Route every confirm through it and answer nowhere else (answer S32 once).
  • Per-tick packet order (call tick() at each tick start): at most one click per tick (Grim PacketOrderA flags pickup and shift-click in one tick); no click or close in the tick open_inventory sent the 1.8 C16 and no C16 after a click/close in the same tick (PacketOrderK).
  • Clicks in window 0 need the player GUI open (open_inventory; vanilla cannot click without it). close() drops the cursor client-side and on 1.8 also empties the 2×2 grid, like vanilla.
  • Only click while still: the server and anticheats judge clicks against the last sent pose; the client (TickContext) must not click while turning.

Tests

cargo test -p ghast-inventory
  • tests/clicks.rs: every click mode on window 0, chest, 3×3, crafting table, furnace, shulker box, both versions, against expectations traced by hand through the vanilla 1.8.9 and 1.16.5 onSlotClick / transferSlot / insertItem / insertStack code; the confirm protocol (pending, same-tick guard, reject → resync, foreign confirms, timeout), screen handling and set_slot routing.
  • tests/oracle_clicks.rs: replays vanilla's own click handling (oracle clicks command, oracle/src/mc_<ver>/…/ClickDump.java: the real ClientPlayerInteractionManager.clickSlot on real screen handlers — window 0, chests of 1–6 rows, 3×3, hopper, crafting table, furnace, 1.16.5 shulker box — with random contents incl. NBT, damaged / over-stacked / zero-count stacks, random cursor, armor and selected slot, 1–10 random clicks of modes 0/1/2/4/6 per case). For every click ghast predicts, the encoded window_click body must equal vanilla's bytes and every slot, the cursor and the player inventory must equal vanilla's state afterwards; a click ghast refuses ends the case (vanilla used an action number ghast did not). Committed: tests/golden/<ver>/clicks_seed1.jsonl.gz (3000 cases). Big runs: oracle/run.sh clicks --seed 2 --count 30000 then GHAST_CLICK_VECTORS_1_8=$PWD/oracle/cache/1.8.9/clicks/clicks-seed2-n30000.jsonl.gz cargo test --release -p ghast-inventory --test oracle_clicks. Crafting vectors (clicks --craft true, clicks_craft_seed1.jsonl.gz): grids filled from real recipes and the special recipes' ingredients, 40 % of the clicks on the result; 1.16.5 with the vanilla data pack's recipes synced through a real declare_recipes packet (its bytes head the file and are decoded by ghast-protocol) and the server's result emulated after each grid change. 25 000 cases per version: 1.8.9 131 542 predicted clicks (11 207 taking a result), 0 differences, all byte-identical; 1.16.5 138 224 (6 313 taking a result), 0 differences. 2026-11-04, 30 000 cases per version: 1.8.9 154 347 predicted clicks, 1.16.5 154 584, 0 differences (1.8.9 packets byte-identical; on 1.16.5 vanilla's own NbtCompound.copy reorders compound keys, so NBT is compared as trees there — as the server does). Refused: ~1.5 %, furnace shift-clicks from the player part and clicks on unknown crafting results. The oracle found three vanilla details the hand-derived tests missed: SimpleInventory.setStack caps a container slot at 64 (the excess of an over-stacked stack is lost), 1.8 double click keeps a zero-count cursor, and 1.16.5 insertStack into an empty slot uses AIR's stack limit (64) for the new zero-count stack.
  • tests/robustness.rs: proptest sequences of random packets (wild ids, counts -3..130, oversized/short window_items, random window types) and clicks never panic; 1.16.5 never stores empty stacks; clicks that only move items (all but drops) conserve every item.
  • examples/live_clicks.rs (lab tool, not run by cargo test): a minimal standing-still client opens a chest with a real block interaction, sends 15 clicks of every mode in the chest and 15 in window 0 (armor, crafting grid, 16-stacks, number keys, Q, double click, outside), then reopens the chest and compares the server's window_items with the prediction. 2026-11-04, local WSL labs (Paper 1.8.8 / Paper 1.16.5, both + Grim 2.3.73), account GhastBot: 1.8 30/30 clicks accepted, 0 differing slots; 1.16.5 30/30 accepted, 0 differing slots (incl. 1.16.5 shift-clicks carrying EMPTY); 0 Grim flags on both.
    lab/console.sh 1.8 'setblock 0 4 2 chest 2 replace {Items:[{Slot:0b,id:"minecraft:stone",Count:64b},{Slot:1b,id:"minecraft:dirt",Count:5b},{Slot:2b,id:"minecraft:wool",Damage:1s,Count:10b},{Slot:3b,id:"minecraft:planks",Count:12b}]}'
    # after the bot joined: gamemode survival, clear, tp 0.5 4 0.5 0 0, give stone 30, dirt 60, diamond_helmet 1, ender_pearl 10
    cargo run -p ghast-inventory --example live_clicks -- --version 1.8 --port 25618 --user GhastBot
    
    (1.16.5: minecraft:chest[facing=north]{Items:[... orange_wool, oak_planks ...]}, --version 1.16 --port 25616.)
  • The same script through the full client (crates/ghast/examples/inventory_probe.rs: TickContext click / open_inventory / close_window, place on the chest after a GCD-stepped aim, the client's real movement stream), account GhastBot3, 2026-11-04: 1.8.9 30/30 accepted, 0 differing slots; 1.16.5 30/30, 0 differing; 0 Grim flags on both. cargo run -p ghast --example inventory_probe -- --host 127.0.0.1 --port 25618 --username GhastBot3 --version 1.8.9
  • src/**: NBT tree equality (key order, root name, duplicates, NaN), NBT rule helpers, item facts per version, window type tables.