Containers and menus
Verified against Minecraft 26.3 · Part VII · A player shift-clicks a stack out of a chest: one packet goes up, the server re-runs the same code against the real container, and nothing comes back down.
A stack of cobblestone sits in the top-left slot of a chest, and a
shift-click lands it in the hotbar before anything has answered. One
ServerboundContainerClickPacket leaves for the server; the server runs the
same method against the real ChestBlockEntity, gets its own answer,
compares the two — and if they agree, sends nothing at all. That is the
steady state: one packet up, zero packets down. There is no transaction
acknowledgement in this protocol; agreement is silence. What makes silence
safe is the part nobody expects. The click packet does not carry the stacks
the client thinks it produced; it carries each changed slot as an item, a count
and a CRC32C hash per component its patch adds, and the server writes those claims into its own per-slot record of what the
client believes before it compares any slot. The record is the server’s, and what a click is allowed to fill it with is a hash and never a stack.
The cast
| class | what it decides | thread |
|---|---|---|
Container | storage, and nothing about who is looking at it — Container.getItem, Container.setItem, Container.stillValid | wherever its owner runs |
AbstractContainerMenu | the slot list, the cursor, the state id, the click path, and two independent records of what has already been reported — one for the advancement listeners, one for the wire | Server and Render |
Slot | GUI policy — Slot.mayPlace, Slot.mayPickup, Slot.getMaxStackSize — and the guarded mutations a click goes through | Server and Render |
Inventory | the player’s own storage, present as slots in nearly every menu that opens — the lectern’s one book slot is the exception | Server and Render |
MenuType / MenuProvider | the registry entry with the screen-side constructor, and the server-side factory a block hands to ServerPlayer.openMenu | Render / Server |
ContainerSynchronizer | the channel from a menu’s diff to the connection, and the writer of every slot update a click causes but one — one per ServerPlayer, shared by every menu that player opens | Server |
RemoteSlot | what the server believes the client is holding in one slot, as either a stack or a hash | Server (RemoteSlot.PLACEHOLDER on the Render thread) |
HashedStack | the client’s claim about one slot after its own click | created on the client, matched on the server |
The chest you see is not the chest
The server’s ChestMenu is built from a MenuProvider that
ChestBlock.getMenuProvider produces and ChestBlock.useWithoutItem hands
to ServerPlayer.openMenu (block
interaction),
which is a fixed sequence: close whatever menu was already open, take the
next container id, ask the provider for a menu, send
ClientboundOpenScreenPacket, and then ServerPlayer.initMenu — which
attaches the listener and the synchronizer, and whose attaching of a
synchronizer is what sends the single ClientboundContainerSetContentPacket
carrying the contents. The screen arrives before the contents do, and both
arrive after the menu object exists on the server. Its Container is the real
ChestBlockEntity — or a
CompoundContainer over both halves, for a double chest, from a separate
anonymous provider that unpacks both halves’ loot tables itself. The client’s ChestMenu is built
from MenuType’s screen-side constructor by MenuScreens, and that
constructor makes a fresh SimpleContainer. The client’s chest contents
have no connection to the block entity beyond the packet stream. Its
AbstractContainerMenu.stillValid would be a lie for the same reason —
SimpleContainer.stillValid returns true unconditionally — but the question is
never put to it: every call site of AbstractContainerMenu.stillValid in the
game is on the server. A client never closes its own menu because it walked
away.
Three asymmetries between the two copies
The second asymmetry, after the fresh container above, is the synchronizer. Only
ServerPlayer.initMenu ever calls
AbstractContainerMenu.setSynchronizer, so a client menu has none, and
every one of its RemoteSlots stays RemoteSlot.PLACEHOLDER, whose
RemoteSlot.matches always answers true, so the wire half of
AbstractContainerMenu.broadcastChanges is inert on the client by
construction. The listener half is not: BeaconScreen, ItemCombinerScreen,
LecternScreen and the creative inventory all register a real
ContainerListener, which is how the anvil’s rename field repopulates itself
after a slot changes.
The last of the asymmetries is the one the other pages of this part lean
on. A block-anchored menu reaches the world through a ContainerLevelAccess,
and the client’s copy holds ContainerLevelAccess.NULL, whose
ContainerLevelAccess.evaluate runs nothing and returns an empty optional,
so its ContainerLevelAccess.execute runs nothing either. Anything a menu does
inside that call — matching a recipe, spending lapis, chipping an anvil — is therefore skipped wholesale on the client, whatever the body itself tests. What the client really runs in those cases
is whatever guard sits in front of the call, which is why a client copy of a
menu can still refuse a click it cannot afford.
Twenty-eight subclasses, one machine
There are twenty-eight AbstractContainerMenu subclasses in world/inventory,
and after this page’s chest, the crafting grid, the anvil and the enchanting
table the rest are the same machine with a different slot list, LoomMenu,
CartographyTableMenu, BeaconMenu, BrewingStandMenu, CrafterMenu,
LecternMenu, DispenserMenu, HopperMenu, ShulkerBoxMenu, MerchantMenu
and the two mount menus among them. A station menu is a Container — the block
entity’s, or a scratch one the menu builds for itself on each side — a list of
Slots carrying the GUI policy, and, for a furnace’s progress or an anvil’s
price, a data slot or a ContainerData. ItemCombinerMenuSlotDefinition is the
small vocabulary the anvil and the smithing table are laid out from; SlotRanges
beside it is not a menu’s at all, but the table of slot names commands and
predicates read.
Three smaller facts that will otherwise trip you
A menu with a null MenuType cannot be opened the ordinary way, because
ServerPlayer.openMenu asks for its type and AbstractContainerMenu.getType throws. That is why the player’s own
InventoryMenu is pinned to InventoryMenu.CONTAINER_ID, zero, and built
independently on both sides, and why the two AbstractMountInventoryMenu
subclasses — the horse’s and the nautilus’s, which pass a null type up to
AbstractContainerMenu explicitly — need a
ClientboundMountScreenOpenPacket of their own instead of
ClientboundOpenScreenPacket. A chest boat is not one of them: it opens an
ordinary ChestMenu through ServerPlayer.openMenu like any other
container. Every other menu takes an id from
ServerPlayer.nextContainerCounter, which cycles 1 to 100 and so never
collides with the player’s own zero.
Slot.mayPlace and Container.canPlaceItem are unrelated questions. The
first is GUI policy and defaults to true without consulting the container at
all; the second is what hopper automation reads. A player and a hopper can
have different rights over the same slot, and neither of them is asking the
other’s question.
Container.getMaxStackSize defaults to 99. The familiar 64 comes only
from the per-stack overload, which takes the minimum with the item’s own
maximum — so a player and an item can disagree about a slot’s capacity the
same way a player and a hopper disagree about its contents.
One shift-click, end to end
A three-row ChestMenu numbers slots 0–26 for the chest, 27–53 for the
player’s three main inventory rows and 54–62 for the hotbar, because
AbstractContainerMenu.addStandardInventorySlots adds the main rows before
the hotbar. Watch what the wire carries, and what it does not.
sequenceDiagram
participant MPGM as MultiPlayer<br/>GameMode
participant ChestM as ChestMenu
participant Wire as the network
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant ACM as Abstract<br/>ContainerMenu
participant RemS as RemoteSlot
MPGM->>MPGM: copy every slot's stack, before touching anything
MPGM->>ChestM: clicked with QUICK_MOVE, on the client's own copy
ChestM->>ChestM: quickMoveStack, then moveItemStackTo backwards
MPGM->>Wire: ServerboundContainerClickPacket, state id plus changed slots as hashes
rect rgba(0, 0, 0, 0.04)
Note over MPGM,RemS: the server tick that drains the packet
Wire->>SGPL: handleContainerClick
SGPL->>ACM: suppressRemoteUpdates, then the same clicked on the real chest
SGPL->>ACM: setRemoteSlotUnsafe per claimed hash, then setRemoteCarried
ACM->>RemS: receive, dropping any concrete stack it held
SGPL->>ACM: resumeRemoteUpdates, then broadcastChanges
ACM->>RemS: matches
RemS->>RemS: the hash agrees, so it becomes a copy of the server's stack
end
Note over Wire,RemS: agreement is silence, and AbstractContainerMenu.synchronizeSlotToRemote sends nothing
One shift-click as one packet up and nothing down. Read the diagonal: the client runs the click, the server re-runs the identical call on the real chest, and the comparison happens only after both — the note at the foot is where a reply would have been.
The press. AbstractContainerScreen.mouseClicked resolves the hovered
slot, sees an empty AbstractContainerMenu.getCarried and a held shift, and
chooses ContainerInput.QUICK_MOVE on the press. Two near neighbours are
release-path instead, from AbstractContainerScreen.mouseReleased: a
shift-click with a non-empty cursor, and the shift-double-click sweep,
which issues a separate ContainerInput.QUICK_MOVE — and a separate packet
— for every matching slot in the same container.
The snapshot, then the prediction.
MultiPlayerGameMode.handleContainerInput copies every slot’s stack before
touching anything, because that copy is what the packet’s diff is built
against. Then it runs AbstractContainerMenu.clicked on the client’s own
menu. The ContainerInput.QUICK_MOVE branch loops
AbstractContainerMenu.quickMoveStack while the clicked slot keeps yielding
the same item, and it runs only for button 0 or 1, a non-negative slot index
and a slot that Slot.mayPickup allows.
The move, and its two passes. ChestMenu.quickMoveStack sees the index
is inside the chest and calls AbstractContainerMenu.moveItemStackTo over
the player’s range with the backwards flag, which scans from the last slot
down — the hotbar first, from the right. That method makes two passes.
The first runs only if the stack is stackable at all, and merges into every
existing compatible stack across the whole range
(ItemStack.isSameItemSameComponents, topped up to Slot.getMaxStackSize).
The second runs only if something is left, finds the first empty slot that
Slot.mayPlace accepts, places what that slot’s cap allows and breaks.
One empty slot per call — which is why the caller loops. Note which pass
consults policy: the merge pass never asks Slot.mayPlace, so a slot
that would refuse the item on placement can still be topped up by a
shift-click when it already holds a matching stack. Note also what
ChestMenu.quickMoveStack never calls: Slot.onTake.
InventoryMenu.quickMoveStack does. Shift-clicking out of a chest and out
of the crafting result are structurally different operations.
The packet. The client diffs the post-click slots against its snapshot,
hashes each changed stack with HashedStack.create, and sends
ServerboundContainerClickPacket with the container id, the client’s
last-known state id, the slot, the button, the ContainerInput, up to
128 changed slots as hashes, and the cursor as one more.
The real move. The server takes the ladder below, then — with
AbstractContainerMenu.suppressRemoteUpdates held — runs the identical
AbstractContainerMenu.clicked path against the real ChestBlockEntity and
the real Inventory. Slot.setChanged reaches BlockEntity.setChanged,
which calls Level.blockEntityChanged to mark the chunk and
Level.updateNeighbourForOutputSignal to re-derive the comparator output
(block entities, and
diodes and observers
for what a comparator does with it). The whole click is wrapped
in a try/catch that builds a crash report category naming the menu class,
the slot and the button.
Installing the claim, then comparing.
AbstractContainerMenu.setRemoteSlotUnsafe writes each hash the client sent
into the matching RemoteSlot through RemoteSlot.receive, and
RemoteSlot.Synchronized discards any concrete stack it was holding and
keeps the hash alone. An out-of-range
index is logged at debug and ignored rather than rejected. Then
AbstractContainerMenu.setRemoteCarried, then
AbstractContainerMenu.resumeRemoteUpdates, then
AbstractContainerMenu.broadcastChanges, which makes one pass over the
slots offering each to AbstractContainerMenu.triggerSlotListeners for
advancements and to AbstractContainerMenu.synchronizeSlotToRemote for the
wire, then the cursor, then the data slots. Where the hash agrees,
RemoteSlot.Synchronized promotes it to a concrete copy of the server’s
own stack and nothing is sent; where it disagrees, that one slot gets a
ClientboundContainerSetSlotPacket of its own with a fresh state id.
The advancement channel sees one state per click, not one per slot touched:
AbstractContainerMenu.triggerSlotListeners runs only from
AbstractContainerMenu.broadcastChanges and
AbstractContainerMenu.broadcastFullState — and for a chest nothing calls
back into the menu mid-click to reach either, a bundle click aside. That is a
fact about the chest, not about menus: several menus call
AbstractContainerMenu.slotsChanged from Container.setChanged or
Slot.setChanged (the anvil’s and the smithing table’s ItemCombinerMenu
containers, the crafter’s CrafterSlot, the grindstone’s, the lectern’s), a bundle click calls it on whatever menu is open,
and the base AbstractContainerMenu.slotsChanged is a bare
AbstractContainerMenu.broadcastChanges, which the menus that override it
reach only if they call up to it. The click’s
own broadcast runs
after AbstractContainerMenu.resumeRemoteUpdates, so suppression is not
in force by then, and ServerPlayer’s ContainerListener filters to slots
that are not a ResultSlot and whose container is the player’s own
Inventory before firing CriteriaTriggers.INVENTORY_CHANGED anyway.
The ladder the server climbs before it believes you
ServerGamePacketListenerImpl.handleContainerClick is four tests and a
fork, and the interesting thing about it is how much of it ends in nothing
sent rather than a correction. The packet reaches the handler on the server
thread through PacketUtils.ensureRunningOnSameThread, and then:
| the test | what a failure does |
|---|---|
does AbstractContainerMenu.containerId match the open menu | dropped in total silence — nothing logged, nothing sent |
| is the player a spectator, or dead or dying | AbstractContainerMenu.sendAllDataToRemote, a full resync, and the click never runs |
AbstractContainerMenu.stillValid | logged at debug, nothing sent, and the menu is not closed here |
AbstractContainerMenu.isValidSlotIndex | logged at debug, nothing sent, nothing corrected |
Past all four the fork begins, and its order is the load-bearing part.
flowchart TD
ST["compare the packet's state id with the menu's, before anything is applied"]:::server
ST --> AP["AbstractContainerMenu.suppressRemoteUpdates, the click, the hashes, resume"]:::server
AP --> Q{"was that state id stale?"}
Q -->|"stale"| FULL["AbstractContainerMenu.broadcastFullState: one set-content packet, fresh state id"]:::server
Q -->|"current"| BC["AbstractContainerMenu.broadcastChanges: every slot against its RemoteSlot"]:::server
The compare happens above the apply and the branch below it. Read the two edges out of the diamond as the whole difference a stale state id makes — not whether the click runs, only how its result is published.
The state id is compared before the click is applied and acted on after, so a click that quotes a stale id still runs, and still runs first. Its result is simply published wholesale instead of diffed.
AbstractContainerMenu.isValidSlotIndex deserves suspicion. It accepts −1,
accepts AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, and otherwise only
asks whether the index is below the slot count — so every negative index
passes it. Each branch of the click that needs a lower bound therefore
tests for one itself. The two that do not, ContainerInput.SWAP and the
painting phase of ContainerInput.QUICK_CRAFT, index the list directly, and
the click’s own
try/catch turns the failure into a ReportedException rather than swallowing
it: what swallows it is PacketProcessor, which logs a game-listener error and
carries on. An out-of-range click is therefore neither corrected nor fatal, but it is loudly
logged, it leaves the menu’s remote updates suppressed until a later click
succeeds (the resume the handler would have run next never runs), and it closes
nothing:
ServerGamePacketListenerImpl.handleContainerClose for its part validates
nothing at all, not even the container id, and goes straight to
ServerPlayer.doCloseContainer.
Why hashes, and why only in one direction
HashedStack.create produces either HashedStack.EMPTY or a
HashedStack.ActualItem of item holder, count and a HashedPatchMap — one
CRC32C integer per added component plus the plain set of removed ones.
Each integer comes from running the component’s own codec into
HashOps.CRC32C_INSTANCE, a DynamicOps whose output is the hash
(codecs, NBT and
JSON owns
that mechanism). Nothing is serialised on the way and there is no intermediate
byte form. HashedPatchMap.matches then checks the removed set, the added
count, and each component’s hash in turn.
Two things follow. The client is asserting a belief, not authoring state —
a hashed component cannot be turned back into its value, so a client that lies
here can only fail to match — and each claimed slot costs an integer per component
rather than a re-encoded DataComponentPatch. The asymmetry is exact: only the client ever calls
HashedStack.create, and only the server ever calls HashedStack.matches.
Hashing is not free-standing on the server either: ServerPlayer’s
ContainerSynchronizer carries a 256-entry component-hash cache shared by
every menu that player opens, and ContainerSynchronizer.createSlot hands
each new RemoteSlot.Synchronized a HashedPatchMap.HashGenerator backed
by it.
The other channel: DataSlot, and why it is never silent
One channel is deliberately outside all of this. DataSlot and
ContainerData — furnace progress, enchanting cost, lectern page — travel
as ClientboundContainerSetDataPacket, whose id and value are written as
shorts, and they carry two independent baselines:
DataSlot.checkAndClearUpdateFlag for the listeners and
AbstractContainerMenu.remoteDataSlots for the network, compared
separately. A slot is a DataSlot.shared view onto an array the menu already keeps, a
DataSlot.standalone int the menu owns, or one entry of a ContainerData
through DataSlot.forContainer, and each one that differs costs its own packet. The network comparison is a plain integer test, so a furnace’s
progress bar is not covered by the hash-agreement silence and is re-sent
every time it changes.
The state id, and the three places it moves
AbstractContainerMenu.incrementStateId is a wrapping 15-bit counter — it
masks to 32767 — and it answers exactly one question: has the client
applied every slot correction I have sent? It has three call sites in the
whole game. Two are inside ServerPlayer’s synchronizer, behind
ContainerSynchronizer.sendInitialData and
ContainerSynchronizer.sendSlotChange;
ContainerSynchronizer.sendCarriedChange and
ContainerSynchronizer.sendDataChange do not bump it — and
ClientboundSetCursorItemPacket carries no id whatever, while
ClientboundContainerSetDataPacket carries the container’s but never a state
id. The third is CraftingMenu.slotChangedCraftingGrid, which bumps the id
on its way past while writing the crafting result’s packet by hand — the one write on the click path that goes around the synchronizer, and the
exception the cast row promised. The client never generates one: AbstractContainerMenu.setItem and
AbstractContainerMenu.initializeContents simply store whatever arrived and
quote it back on the next click. A click quoting a stale id means
corrections are still in flight, and the server stops diffing and resends
everything.
Where in the tick a broadcast happens
Not one place, and the difference is observable. Packets are drained
before any level ticks (the server tick), so
a click and any correction it produces both happen at the top of the tick
and reach the client the same tick. ServerPlayer.tick then calls
AbstractContainerMenu.broadcastChanges again from the level’s entity
phase, which runs before the block-entity phase
(the level tick). And ServerPlayer.doTick
— driven by the connection after every level has finished — repeats the
AbstractContainerMenu.stillValid distance test and broadcasts nothing of its
own; what does broadcast inside it is a pickup, because ServerPlayer.take,
which the player’s own tick reaches for every item, orb or arrow it collects,
calls AbstractContainerMenu.broadcastChanges.
The drain answers more than slot clicks. A menu button click — the
lectern’s page turn, the enchanting table’s offer, the loom’s pattern, the
stonecutter’s choice — is answered inside its own handler the same way:
ServerGamePacketListenerImpl.handleContainerButtonClick calls
AbstractContainerMenu.broadcastChanges directly, the moment
AbstractContainerMenu.clickMenuButton accepts, so the correction leaves on the
same packet drain that brought the click; pick-block and the creative slot
broadcast inside their handlers too.
Back in the tick, the distance test happens twice and only the first brings a broadcast
of its own. A hopper that pushes an item into a chest whose menu is open
therefore runs in the block-entity phase, after that tick’s regular broadcast, and nothing calls back into the menu to say so —
SimpleContainer.setChanged is empty, BlockEntity.setChanged marks the chunk
and re-derives the comparator output and stops there,
Inventory.setChanged only bumps a counter. You see the hopper’s item one tick
late, unless a pickup in the player’s own tick broadcasts first.
What the client does with a correction, and what closing rescues
Almost nothing, is the answer to the first. ClientPacketListener.handleContainerSetSlot
checks the container id, writes the stack into that one slot and stores the
state id that came with it. There is no rollback and nothing to roll back:
the client’s menu is the only copy of its own prediction, so a correction
overwrites it and the stack snaps. Nor, after a click the server ran to its end, is a mispredicted slot ever left standing — the server compares every slot against its record on every
broadcast, not only the ones the click claimed, so a wrong guess is corrected
on that tick or on the next one the menu is broadcast in.
Closing has its own surprise. The cursor belongs to the menu, not the
player, so closing one would destroy it: AbstractContainerMenu.removed
rescues it explicitly, dropping it in the world if the player has been removed for any reason but a
change of dimension, or has disconnected, and calling Inventory.placeItemBackInInventory
otherwise, the whole method gated on being a ServerPlayer, which is what
makes that safe. The rescue sends one ClientboundSetPlayerInventoryPacket
per slot it fills, and runs before AbstractContainerMenu.transferState,
which copies both the listener baseline and the remote beliefs across every
container-and-slot pair the closing menu and InventoryMenu share. For a
chest that is the 36 main and hotbar slots — not armour, not the offhand, not the 2×2 grid, not the crafting result — so changes to any of those are re-sent, and so is every slot the rescue has just filled, which it wrote before
the beliefs were copied.
The seven click kinds
ContainerInput has seven values, and the button number means something different in nearly every one. ClickAction — ClickAction.PRIMARY and
ClickAction.SECONDARY — is not on the wire; it is derived inside
AbstractContainerMenu.doClick on both sides and handed to the item
override hooks ItemStack.overrideStackedOnOther and
ItemStack.overrideOtherStackedOnMe, which is how a BundleItem
intercepts a click before ordinary slot logic runs.
| value | the gesture | what the button number means | packets per gesture |
|---|---|---|---|
ContainerInput.PICKUP | the ordinary click | 0 left, 1 right — and on AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, drop all or drop one | 1 |
ContainerInput.QUICK_MOVE | shift-click | 0 or 1, the same in a real slot — and outside the window, the drop-all and drop-one of ContainerInput.PICKUP | 1, or one per matching slot for the shift-double-click sweep |
ContainerInput.SWAP | a hotbar key, or Inventory.SLOT_OFFHAND for the offhand key | the destination index: 0–8, or 40 | 1 |
ContainerInput.CLONE | creative middle-click | ignored, but the player must pass Player.hasInfiniteMaterials | 1 |
ContainerInput.THROW | Q over a slot, cursor empty — and also a click outside the window with an empty cursor, which the server’s branch then ignores because it demands a non-negative index | 0 drops one, 1 (control-Q) drops the stack and keeps going while the slot yields the same item | 1 |
ContainerInput.QUICK_CRAFT | drag-painting | a packed mask that AbstractContainerMenu.getQuickcraftHeader and AbstractContainerMenu.getQuickcraftType split into a phase (start, continue, end) and a mode (even split, one each, clone) | painted slots plus two — AbstractContainerScreen.quickCraftToSlots sends a start, one continue per slot, and an end |
ContainerInput.PICKUP_ALL | double-click to collect | 0 scans forwards, 1 backwards, over two passes that skip already-full stacks first | 1 |
An unknown id on the wire is not rejected. ContainerInput’s id mapper is
built with a zero-out-of-bounds strategy, so a malformed click decodes as
ContainerInput.PICKUP.
Two paths that are not this protocol at all
Creative mode is a parallel protocol, not a variation
CreativeModeInventoryScreen overrides
AbstractContainerScreen.slotClicked and drives a menu of its own, and its
writes go up as ServerboundSetCreativeModeSlotPacket. That is the only
serverbound packet in the game that carries an ItemStack at all, and the
server takes it verbatim: ServerGamePacketListenerImpl.handleSetCreativeModeSlot
puts the client’s stack into the slot through Slot.setByPlayer, behind only
a Player.hasInfiniteMaterials check, a feature-flag check, a slot range of
1 to 45 and a count cap — then writes the same stack into the server’s record
of the client’s belief with AbstractContainerMenu.setRemoteSlot, to suppress
the echo. (Other packets let a client author text the server keeps — a
rename, a sign, a jigsaw block — but no other lets it author a stack.) A negative
slot number means drop it in the world instead, and that branch is the one
place here with a rate limiter on it.
The crafting result is a second, unsuppressed channel
The result slot’s
packet is written by hand and goes straight down the connection, bumping the
state id on its way past and bypassing ContainerSynchronizer entirely
(recipes). What matters here is that it
fires during a click and is therefore the one path
AbstractContainerMenu.suppressRemoteUpdates does not cover: that flag guards
AbstractContainerMenu.synchronizeSlotToRemote and its two siblings, and
nothing else.
Everything else a menu puts on the wire is bookkeeping around those two —
opening and closing, the full resync, the button clicks, the crafter toggle,
the bundle selection, the hotbar key — and the whole set is one table in
the packet catalogue. Only one of them surprises: ServerboundSetCarriedItemPacket is the hotbar
selection, which despite the name has nothing to do with the cursor; the
packet the server sends for the same selection is ClientboundSetHeldSlotPacket.
Two things about a menu’s opening are worth keeping here. A structure chest
fills itself on the first open, and that is the roll rather than the sync
(loot tables). And
none of the click protocol is data-driven at all: BuiltInRegistries.MENU
supplies the type and nothing else, which is why nothing on this page has a
JSON file behind it.
Where to look
Follow the click in the order the two machines run it.
MultiPlayerGameMode.handleContainerInput is the snapshot and the
prediction; AbstractContainerMenu.doClick is the branch table both sides
share, with AbstractContainerMenu.moveItemStackTo under the shift-click.
ServerGamePacketListenerImpl.handleContainerClick is the ladder, and
AbstractContainerMenu.broadcastChanges is where the silence is decided.
For the belief mechanism, read RemoteSlot and then HashedStack: one is
the server’s record, the other is what a click is allowed to put in it.
HashOps under them is the trick that makes a hash out of a codec with no
bytes in between.
For the object model, Container and Slot are the two halves nobody
expects to be separate, and ChestMenu beside InventoryMenu is the
shortest way to see what a menu subclass adds. MenuType and
MenuProvider are the two ends of opening one, and MenuScreens is where
the client’s copy is built from a constructor the server never runs.
Two doors lead out of this protocol rather than through it:
CraftingMenu.slotChangedCraftingGrid writes the result slot by hand, and
ServerGamePacketListenerImpl.handleSetCreativeModeSlot is the one place a
client’s stack is taken at its word.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.