Files
claude-timemachine a3d5f64ef5 verify pass: resolve VERIFY flags (corrections + citations + honest UNCONFIRMED)
Corrected real errors: several 1.7.x release dates, resource_pack_send version,
config packet ordering, structured-component count (56), PLAYER_LOADED (1.21.4),
entity_sound_effect field order. Confirmed+cited the rest; remaining ~19 items
re-marked UNCONFIRMED (third-party/ViaLegacy/26.2 internals unreachable from refs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:03:44 +02:00

269 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Minecraft Java Edition Protocol — Overview
> **Scope:** Java Edition only. Protocol details change with each release; version-specific differences are called out explicitly. "Latest" means 1.21.x as of mid-2026 unless noted.
>
> **Primary sources:**
> - `node-minecraft-protocol` v1.66.2 (`/tmp/mcproto-refs/node-minecraft-protocol/`)
> - minecraft.wiki/w/Java_Edition_protocol (formerly wiki.vg) — [Packet format](https://minecraft.wiki/w/Java_Edition_protocol) / [Data types](https://minecraft.wiki/w/Java_Edition_protocol/Data_types)
---
## 1. Transport
- **TCP**, no UDP (voice-chat plugins add their own UDP layer out-of-band).
- Default port: **25565** (configurable).
- All data is **big-endian** except VarInt/VarLong, which use little-endian 7-bit groups (see `01-data-types.md`).
- **Encryption** is applied after the Login handshake (online-mode servers). The cipher is AES-128-CFB8, using the shared secret as both key and IV. Encryption wraps the raw TCP byte stream; packet framing runs on top of the encrypted layer.
- Source: `node-minecraft-protocol/src/transforms/encryption.js:6-9` (`aes-128-cfb8`, key=IV=shared secret).
---
## 2. Packet Framing
Every packet on the wire is length-prefixed. There are two modes: **uncompressed** (before the server sends `Set Compression`) and **compressed** (after).
### 2.1 Uncompressed packet format
| Field | Type | Notes |
|------------|---------|------------------------------------------------|
| Length | VarInt | Byte count of everything that follows (Packet ID + Data). |
| Packet ID | VarInt | State- and direction-scoped identifier. |
| Data | bytes | Payload; structure defined per packet. |
The `Framer` prepends the length:
```js
// node-minecraft-protocol/src/transforms/framing.js:16-21
const varIntSize = sizeOfVarInt(chunk.length)
const buffer = Buffer.alloc(varIntSize + chunk.length)
writeVarInt(chunk.length, buffer, 0)
chunk.copy(buffer, varIntSize)
```
The `Splitter` reads length, then slices exactly that many bytes — buffering partial reads across TCP segments (framing.js:48-73).
### 2.2 Compressed packet format
Enabled by the serverbound `Set Compression` packet (Login state). After that, **every** subsequent packet uses:
| Field | Type | Notes |
|----------------|---------|-----------------------------------------------------------------------------|
| Packet Length | VarInt | Byte count of `Data Length` field + compressed payload. |
| Data Length | VarInt | Uncompressed size of (Packet ID + Data). **0 = not compressed.** |
| Packet ID | VarInt | } zlib-deflated together when `Data Length > 0` |
| Data | bytes | } |
**Threshold rule:** if `len(Packet ID + Data) >= compressionThreshold`, compress and set `Data Length` to the uncompressed size. Otherwise send uncompressed with `Data Length = 0`.
```js
// node-minecraft-protocol/src/transforms/compression.js:22-40
if (chunk.length >= this.compressionThreshold) {
const newChunk = zlib.deflateSync(chunk)
// prepend VarInt(chunk.length), then compressed bytes
} else {
// prepend VarInt(0), then raw bytes
}
```
**Historical note:** Compression was added in **1.8** (protocol 47). Per `server/login.js:179`, node-minecraft-protocol gates compression on `protocolVersion >= 27` (snapshot 14w28a), which predates the 1.8 release — whole-protocol compression replaced earlier per-packet compression in that snapshot.
### 2.3 Packet size limits
- Uncompressed maximum: **2²¹ 1 = 2,097,151 bytes** (the `Length` VarInt may not exceed 3 bytes on the wire). — [minecraft.wiki](https://minecraft.wiki/w/Java_Edition_protocol)
- Serverbound compressed: uncompressed (Packet ID + Data) must be ≤ 2²³ bytes (8,388,608). Source: [minecraft.wiki/w/Java_Edition_protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets) — "For serverbound packets, the uncompressed length of (Packet ID + Data) must not be greater than 2^23 or 8388608 bytes." (node-minecraft-protocol's decompressor does not enforce this cap; Velocity's `MinecraftCompressDecoder` caps serverbound at 2 MiB for security, vanilla at 8 MiB clientbound — `MinecraftCompressDecoder.java:39-40`.)
- Plugin message unrecognised-channel data: vanilla client caps at 1,048,576 bytes. Source: [minecraft.wiki/w/Java_Edition_protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets); corroborated by Velocity `PluginMessagePacket.java:65` (`MAX_PAYLOAD_SIZE_CLIENTBOUND = 1048576`, comment: "the vanilla expected limit").
---
## 3. Connection State Machine
The protocol is divided into **states**. Each state has its own independent set of packet IDs. Transitions are triggered by specific packets.
```js
// node-minecraft-protocol/src/states.js:3-8
const states = {
HANDSHAKING: 'handshaking',
STATUS: 'status',
LOGIN: 'login',
CONFIGURATION: 'configuration', // added in 1.20.2 (protocol 764)
PLAY: 'play'
}
```
### State diagram
```mermaid
stateDiagram-v2
[*] --> Handshaking : "TCP connect"
Handshaking --> Status : "Handshake (nextState=1)"
Handshaking --> Login : "Handshake (nextState=2)"
Handshaking --> Login : "Handshake (nextState=3, Transfer 1.20.5+)"
Status --> [*] : "Status Response + Ping/Pong"
Login --> Configuration : "Login Success → Login Acknowledged (1.20.2+)"
Login --> Play : "Login Success (pre-1.20.2)"
Configuration --> Play : "Finish Configuration (both sides)"
Play --> Configuration : "Start Configuration (server-initiated, 1.20.2+)"
Play --> [*] : "Disconnect"
```
### 3.1 Handshaking
Initial state on connect. Client sends exactly one packet:
- **Handshake** (0x00, serverbound): carries `protocolVersion`, `serverHost`, `serverPort`, and `nextState` (1 = Status, 2 = Login, 3 = Transfer).
After this packet the server transitions immediately to the indicated next state.
```js
// node-minecraft-protocol/src/server/handshake.js:34-38
if (packet.nextState === 1) {
client.state = states.STATUS
} else if (packet.nextState === 2) {
client.state = states.LOGIN
}
```
`nextState = 3` (Transfer) was added in **1.20.5 (protocol 766)** for server-transfer support. Source: Velocity `HandshakeIntent.java:16` (`TRANSFER(3)`); `ProtocolVersion.java:89` (`MINECRAFT_1_20_5(766, "1.20.5", "1.20.6")`); ViaVersion `InitialBaseProtocol.java:55,133` (`TRANSFER_INTENT = 3`, rejected for servers `olderThan(ProtocolVersion.v1_20_5)`).
### 3.2 Status
Server-list ping. Two round-trips:
1. Client → **Status Request** (0x00)
2. Server → **Status Response** (0x00) — JSON payload with MOTD, player count, favicon
3. Client → **Ping Request** (0x01) — 8-byte timestamp
4. Server → **Pong Response** (0x01) — echoes timestamp
Connection is then closed by the client. The server does not transition to another state.
### 3.3 Login
Authentication and encryption negotiation.
Typical online-mode sequence:
1. C→S: `Login Start` (username, UUID, optional profile-key signature)
2. S→C: `Encryption Request` (server ID, RSA public key, verify token)
3. C→S: `Encryption Response` (RSA-encrypted shared secret + verify token)
4. *(Both sides enable AES-128-CFB8 encryption)*
5. Server calls Mojang session-server to verify join
6. S→C: `Set Compression` (threshold; gates compressed mode) — added in **1.8** / snapshot 14w28a
7. S→C: `Login Success` (UUID, username, properties)
8. C→S: `Login Acknowledged` — triggers transition to Configuration (1.20.2+)
```js
// node-minecraft-protocol/src/server/login.js:179
if (client.protocolVersion >= 27) {
client.write('compress', { threshold: 256 })
client.compressionThreshold = 256
}
```
```js
// node-minecraft-protocol/src/server/login.js:189-193
if (client.supportFeature('hasConfigurationState')) {
client.once('login_acknowledged', onClientLoginAck)
} else {
client.state = states.PLAY
}
```
### 3.4 Configuration (1.20.2+, protocol 764+)
Introduced in **1.20.2**. Entered after Login Acknowledged; used for:
- Registry codec data (dimension types, biomes, etc.)
- Feature flags
- Known packs negotiation
- Server brand plugin channel
Both client and server exchange `Finish Configuration` to exit this state.
```js
// node-minecraft-protocol/src/server/login.js:224-238 (onClientLoginAck)
client.state = states.CONFIGURATION
// ... send registry_data, then finish_configuration
client.once('finish_configuration', () => {
client.state = states.PLAY
})
```
The server can re-enter Configuration from Play at any time by sending `Start Configuration` (serverbound: `Configuration Acknowledged` from client acknowledges the switch back).
```js
// node-minecraft-protocol/src/client/play.js:43-44
client.on('start_configuration', () => enterConfigState())
// ...
if (client.state === states.PLAY) {
client.write('configuration_acknowledged', {})
}
```
### 3.5 Play
Normal gameplay state. The bulk of all packets are Play-state packets. This state persists until disconnect or a server-initiated re-entry into Configuration.
---
## 4. Packet ID Scoping
Packet IDs are **not globally unique**. They are scoped by **both state and direction**:
- **State**: Handshaking / Status / Login / Configuration / Play
- **Direction**: serverbound (client → server) or clientbound (server → client)
A packet ID of `0x00` in Handshaking-serverbound (Handshake packet) is completely unrelated to `0x00` in Status-clientbound (Status Response), and so on. You need all three pieces — state, direction, ID — to unambiguously identify a packet.
The serializer key in node-minecraft-protocol reflects this:
```js
// node-minecraft-protocol/src/transforms/serializer.js:47-51
function createSerializer ({ state = states.HANDSHAKING, isServer = false, version, ... }) {
return new Serializer(
createProtocol(state, !isServer ? 'toServer' : 'toClient', version, ...),
'packet'
)
}
```
Concrete packet ID assignments live in `minecraft-data` (a separate package) and change between protocol versions. Always consult the version-specific protocol data, not hard-coded constants.
---
## 5. Encryption Layer Position
The pipeline (innermost to outermost on send) is:
```
Packet (ID + payload)
→ Compression (optional, zlib deflate)
→ Framing (VarInt length prefix)
→ Encryption (AES-128-CFB8, wraps the byte stream after framing)
→ TCP
```
Framing runs before encryption so the receiver can split frames while decrypting. Both directions use the same shared secret but independent cipher/decipher instances.
Source: `node-minecraft-protocol/src/transforms/encryption.js` — AES-128-CFB8 with `key = IV = sharedSecret`.
---
## 6. Legacy Server List Ping
Clients predating the current framing (pre-1.7) send a `0xFE` byte as the first byte of the TCP stream. node-minecraft-protocol detects this and shims it into the modern VarInt-framed pipeline:
```js
// node-minecraft-protocol/src/transforms/framing.js:36-43
if (this.recognizeLegacyPing && this.buffer[0] === LEGACY_PING_PACKET_ID) {
// Prefix a VarInt-encoded packet ID so the deserializer can handle it
}
```
This is a compatibility shim only; the modern protocol framing is always VarInt-prefixed as described in §2.
---
*See `01-data-types.md` for wire type encodings (VarInt, Position, NBT, etc.).*