Files
minecraft_protocol/03-handshake.md
T
claude-timemachine cf6aa978d0 verify pass 3: FML token correction + BungeeGuard/cmd-providers from live source
archive.org Wayback was network-blocked; pivoted to live GitHub. Key fix: FML
host token is \0FML\0 (not \0FML2/3\0 — those are FMLNETVERSION ints on the
fml:handshake channel); modern Forge uses \0FORGE/\0FORGEn. Confirmed BungeeGuard
backend token check (lucko source), 4 cmd suggestion providers, 26.2 interval_select
worldgen-only, corrected 1.14 villager metadata layout.

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

13 KiB
Raw Blame History

03 — Handshake Packet

State: Handshaking → Login or Status
Direction: Client → Server
Packet ID: 0x00
Stable since: 1.7.2

The Handshake packet is the first packet every modern Minecraft client sends. It is sent exactly once per TCP connection, transitions the protocol state, and carries three pieces of metadata that have been overloaded with proxy and modloader signalling data over the years.


Packet fields

# Field Type Notes
1 Protocol Version VarInt Negotiated version; e.g. 47 = 1.8, 340 = 1.12.2, 765 = 1.20.4, 766 = 1.20.5, 775 = 26.1
2 Server Address String (255) Hostname/IP the client used to connect — subject to all the overloads below
3 Server Port Unsigned Short Default 25565
4 Intent (Next State) VarInt enum 1 = Status, 2 = Login, 3 = Transfer (1.20.5 / protocol 766+)

Source: minecraft.wiki/w/Java_Edition_protocol/Packets (Handshaking, C→S, 0x00); field definitions confirmed in node-minecraft-protocol/src/client/setProtocol.js:21-26 (protocolVersion, serverHost, serverPort, nextState); packet schema in minecraft-data/data/pc/1.8/protocol.json (packet_set_protocol).

Intent / Next State values

Value Meaning Introduced
1 Status ping 1.7
2 Login (play) 1.7
3 Transfer login 1.20.5 (protocol 766)

Intent 3 indicates the client arrived via a Transfer packet from another server (cookie-based server transfer introduced in 1.20.5). Both 2 and 3 transition into the Login protocol state; 3 lets the receiving server decide to accept or reject transfers. BungeeCord sets transferred = true at InitialHandler.java:391 when intent is 3.


SRV record resolution

Before opening the TCP connection the client queries _minecraft._tcp.<domain> for a DNS SRV record. If found, the resolved hostname and port replace the raw user input:

  • options.host and options.port are updated from the SRV response before setSocket() is called.
  • The Server Address field sent in the Handshake packet is the SRV-resolved hostname, not the original domain the user typed.

Source: node-minecraft-protocol/src/client/tcp_dns.js:21-33

// tcp_dns.js:21-33
dns.resolveSrv('_minecraft._tcp.' + options.host, (err, addresses) => {
  if (addresses && addresses.length > 0) {
    options.host = addresses[0].name   // <-- replaces host
    options.port = addresses[0].port   // <-- replaces port
    client.setSocket(net.connect(addresses[0].port, addresses[0].name))
  }
})

Practical consequence: if play.example.com has an SRV record pointing to mc1.example.com:19132, the Handshake's Server Address field will contain mc1.example.com, not play.example.com. Proxies doing virtual-host routing must be SRV-aware.

BungeeCord also strips a trailing . that DNS resolvers sometimes append: InitialHandler.java:362-365.


Server Address field overloads

The Server Address field is a plain UTF-8 string with a 255-character limit. Three separate systems have abused it by appending NUL-delimited (\0) data after the hostname.

Parse order matters

raw Server Address field
       │
       ├── contains \0? ──yes──▶ split on first \0
       │                           left  = actual hostname (virtualHost)
       │                           right = extra payload (FML tag or BC forward data)
       └── no ──▶ use as-is

BungeeCord implements this at InitialHandler.java:355-359:

if ( handshake.getHost().contains( "\0" ) ) {
    String[] split = handshake.getHost().split( "\0", 2 );
    handshake.setHost( split[0] );
    extraDataInHandshake = "\0" + split[1];   // re-prepends \0
}

Overload 1 — Forge / FML client detection

Forge appends a NUL-delimited marker to signal that the connecting client has Forge loaded. The marker sits immediately after the hostname, before any BungeeCord forwarding data.

Era Marker (literal) Forge versions
FML / Legacy (1.71.12.2) \0FML\0 Forge for MC 1.7.x 1.12.x
FML2 (1.131.19) \0FML\0 (same wire token, different channel) Forge for MC 1.131.19.x — the "FML2" label comes from NetworkConstants.FMLNETVERSION=2 in early-1.13 Forge builds; the handshake host token remains \0FML\0 (Velocity LegacyForgeConstants.HANDSHAKE_HOSTNAME_TOKEN = "\0FML\0", confirmed from PaperMC/Velocity LegacyForgeConstants.java, fetched 2026-06-19). The FML2 vs FML3 version number is negotiated over the fml:handshake login-plugin channel, not the host field.
Modern (1.20.2+) \0FORGE or \0FORGEn NeoForge / Forge 1.20.2+ — confirmed from Velocity: ModernForgeConstants.MODERN_FORGE_TOKEN = "FORGE" (string literal); ModernForgeConnectionType.getModernToken() returns "\0FORGE" (no version suffix) or "\0FORGE" + natVersion (e.g. "\0FORGE3") when a version number is present in the host. This path is gated on protocol ≥ 1.20.2 per HandshakeSessionHandler.java:166167. Source: PaperMC/Velocity ModernForgeConstants.java + ModernForgeConnectionType.java (fetched 2026-06-19).

BungeeCord source defines only \0FML\0 as FML_HANDSHAKE_TOKEN (ForgeConstants.java:20). The comment in InitialHandler.java:351-354 reads:

"Starting with FML 1.8, a \0FML\0 token is appended to the handshake. This interferes with Bungee's IP forwarding, so we detect it, and remove it from the host string, for now. We know FML appends \00FML\00. However, we need to also consider that other systems might add their own data to the end of the string. So, we just take everything from the \0 character and save it for later."

The Forge handler checks extraDataInHandshake.contains(ForgeConstants.FML_HANDSHAKE_TOKEN) to set fmlTokenInHandshake (ForgeClientHandler.java:41-46, UserConnection.java).

Wire layout (Forge client, no proxy):

hostname\0FML\0

Example: mc.example.com\0FML\0

The FML-tag-breaks-naive-proxies gotcha: Any proxy or server plugin that does a naive split on \0 expecting exactly the BungeeCord forwarding format will get confused when a Forge client connects without going through BungeeCord. The first field after the split is FML (or FML2, FML3), not a client IP address. Always check whether the first extra token is a known FML marker before attempting to parse it as IP-forwarding data.


Overload 2 — BungeeCord legacy IP forwarding

When ip_forward: true in BungeeCord's config, BungeeCord rewrites the Handshake packet it sends to the backend server to embed the real client IP, UUID, and texture properties. This happens in ServerConnector.java:114-123.

Authoritative source — ServerConnector.java:116-121:

String newHost = copiedHandshake.getHost() + "\00" + AddressUtil.sanitizeAddress( user.getAddress() ) + "\00" + user.getUUID();

LoginResult profile = user.getPendingConnection().getLoginProfile();
if ( profile != null && profile.getProperties() != null && profile.getProperties().length > 0 ) {
    newHost += "\00" + LoginResult.GSON.toJson( profile.getProperties() );
}

user.getUUID() returns the UUID without dashes (InitialHandler.java:801-803):

public String getUUID() {
    return uniqueId.toString().replace( "-", "" );
}

Exact wire layout:

<hostname>\0<clientIP>\0<uuidNoDashes>\0<propertiesJSON>
Segment Content Notes
<hostname> Original virtual host (pre-stripped of FML tag) e.g. mc.example.com
\0 NUL byte (0x00) delimiter
<clientIP> AddressUtil.sanitizeAddress() output IPv4 dotted-decimal or IPv6 without scope ID
\0 NUL byte delimiter
<uuidNoDashes> Player UUID, 32 hex chars, no dashes e.g. 069a79f444e94726a5befca90e38aaf5
\0 NUL byte delimiter (only present when properties follow)
<propertiesJSON> LoginResult.GSON.toJson(profile.getProperties()) Texture properties array; omitted entirely if profile == null or properties array is empty

Full example with textures:

mc.example.com\0203.0.113.42\0069a79f444e94726a5befca90e38aaf5\0[{"name":"textures","value":"eyJ0...","signature":"abc..."}]

Full example without textures (offline-mode or missing profile):

mc.example.com\0203.0.113.42\0069a79f444e94726a5befca90e38aaf5

Backend servers (e.g. Paper with settings.bungeecord: true) re-parse this field on login to reconstruct the real player identity.

Note on FML + BungeeCord interaction: When ip_forward is enabled, BungeeCord does NOT re-append the extraDataInHandshake (the FML tag). The code path is mutually exclusive (ServerConnector.java:124-128):

} else if ( !user.getExtraDataInHandshake().isEmpty() ) {
    // Only restore the extra data if IP forwarding is off.
    // TODO: Add support for this data with IP forwarding.
    copiedHandshake.setHost( copiedHandshake.getHost() + user.getExtraDataInHandshake() );
}

This means: with BungeeCord ip_forward enabled, Forge mod detection via the handshake tag is broken at the backend. Confirmed in BungeeCord HEAD (commit f56d37f, 2026-06-18): ServerConnector.java:124128 still has the TODO: Add support for this data with IP forwarding comment and the else if branch is unchanged.


Overload 3 — Velocity modern forwarding (does NOT use this field)

Velocity's modern forwarding mode does not overload the Server Address field. Instead it uses a Login Plugin Message exchange during the Login state on the backend connection (channel velocity:player_info). See proxy-forwarding/velocity-modern.md for details.

Velocity's legacy/BungeeCord-compat mode (ip-forwarding-mode = LEGACY) uses the same \0-delimited format described in Overload 2 above, for compatibility with servers that already support BungeeCord forwarding.


State-machine diagram

flowchart LR
    C["Client"] -->|"Handshake\n(0x00)"| P["Proxy / Server"]
    P -->|"Intent=1"| S["Status state"]
    P -->|"Intent=2"| L["Login state"]
    P -->|"Intent=3 (1.20.5+)"| L
    S --> Ping["Status / Ping\n(see 04-status-ping.md)"]
    L --> Login["Login sequence\n(see 05-login.md)"]

Version history summary

Version Change
1.7.2 Handshake packet introduced in this form; fields stable since
1.71.12.2 Forge appends \0FML\0 for modded clients
1.8+ BungeeCord ip_forward writes host\0ip\0uuid[\0props] to backends
1.131.19.x Forge host-field token remains \0FML\0; FML version number (2 or 3) negotiated over fml:handshake login-plugin channel
1.20.2+ Modern Forge / NeoForge switches to \0FORGE or \0FORGEn host-field token (Velocity ModernForgeConnectionType)
1.20.5 (protocol 766) Intent value 3 (Transfer) added

Key sources

Source What it confirms
BungeeCord/proxy/…/connection/InitialHandler.java:355-359 FML tag stripping logic, extraDataInHandshake
BungeeCord/proxy/…/connection/InitialHandler.java:801-803 UUID stripped of dashes
BungeeCord/proxy/…/ServerConnector.java:114-123 Authoritative ip_forward write path
BungeeCord/proxy/…/forge/ForgeConstants.java:20 FML_HANDSHAKE_TOKEN = "\0FML\0"
PaperMC/Velocity/proxy/…/forge/legacy/LegacyForgeConstants.java HANDSHAKE_HOSTNAME_TOKEN = "\0FML\0" for 1.81.12.2 legacy Forge
PaperMC/Velocity/proxy/…/forge/modern/ModernForgeConstants.java MODERN_FORGE_TOKEN = "FORGE" (modern, 1.20.2+)
PaperMC/Velocity/proxy/…/forge/modern/ModernForgeConnectionType.java getModernToken()"\0FORGE" or "\0FORGEn"
MinecraftForge/src/…/network/NetworkConstants.java (1.18.x1.19.x) FMLNETVERSION=3, NETVERSION="FML3" — version negotiated over channel, not host field
node-minecraft-protocol/src/client/setProtocol.js:21-26 Client-side field names; tagHost extension point
node-minecraft-protocol/src/client/tcp_dns.js:21-33 SRV resolution overwrites host/port before connect
minecraft-data/data/pc/1.8/protocol.json packet_set_protocol field schema (varint, string, u16, varint)
minecraft.wiki/w/Java_Edition_protocol/Packets Field names, String(255) limit, Intent=3 description