Skip to content

Packets

zannagh edited this page Aug 17, 2026 · 2 revisions

Packets

This page documents Armor Hider's wire protocol: the payloads that get exchanged between client and server, in which direction they travel, and when they're sent. It aims at other devs and at anyone curious about how the multiplayer sync actually works. For the user-facing side of things, see Multiplayer Sync.

Two implementations, one protocol

The mod ships a multi-version source tree (managed by Stonecutter), and there are effectively two networking backends behind the same protocol:

  • Modern (>= 1.20.5): uses vanilla's CustomPacketPayload and StreamCodec, no Fabric API involved. This is the path every currently shipped version uses.
  • Legacy (< 1.20.5): raw channel Identifier plus FriendlyByteBuf, handled by LegacyPacketHandler. This code is preprocessed out of the checked-in sources and only compiles for a generated 1.20.1 build.

On top of that, the Paper plugin is a third, fully independent implementation of the exact same wire format, built on Bukkit's plugin messaging. See Paper plugin below.

Rather than going through a loader networking API, the mod injects its payload types straight into vanilla's codec registries via ClientboundCustomPayloadPacketMixin and ServerboundCustomPayloadPacketMixin. PayloadRegistry holds the registered types and their handlers. ArmorHiderPayloadList is a small marker list used to keep things sane when another mod (Carpet, Vivecraft, ...) mixes into the same vanilla codec method.

Channels

There are seven channels. The path names are stable everywhere, only the namespace differs by version:

Path Direction Payload class Namespace
settings_c2s_packet Client to server PlayerConfig switched at 1.21.11
server_wide_settings Client to server ServerWideSettings switched at 1.21.11
combatlog_c2s_packet Client to server CombatLogEventPacket always long
settings_s2c_packet Server to client ServerConfiguration switched at 1.21.11
permissions_s2c_packet Server to client PermissionPacket always long
combatlog_s2c_packet Server to client CombatLogNotificationPacket always long
handshake_s2c_packet Server to client HandshakePacket always long

"Switched at 1.21.11" means the namespace changed from the short armorhider to de.zannagh.armorhider in that version. Three payloads switched, the other four hardcode the long namespace on every version >= 1.20.5. The legacy 1.20.1 path uses the short armorhider namespace for all seven.

This namespace split matters for the Paper plugin, which has to speak to clients from both eras at once - see ClientDialects below.

The handshake

Discovery is one-directional and server-driven. There's no capability negotiation and no minecraft:register exchange. The client decides whether the server runs the mod purely by whether it receives a HandshakePacket. The server, on its side, never actually learns whether a given client has the mod - it just always sends.

When a player joins, the server sends, in order:

  1. the current full ServerConfiguration (settings_s2c_packet)
  2. a PermissionPacket with that player's permission level
  3. a HandshakePacket

On the modded client, receiving the handshake flips an internal SERVER_SUPPORTS_MOD flag to true, and the client then flushes anything it had queued (its own PlayerConfig).

The outgoing gate

The client can't just fire unknown custom payloads at a vanilla server, because some servers will kick a client for sending payloads they don't recognise. So every outgoing packet passes through a gate:

  • server known to support the mod: send immediately
  • server known to be vanilla: drop silently
  • undecided: queue the packet and wait

While undecided, a short-lived waiter thread polls for the handshake for up to 10 seconds. If the handshake arrives, the queued packets are flushed in submission order. If it times out, the server is marked unsupported and all further outgoing traffic is dropped for the rest of that connection. On singleplayer or when hosting a LAN world, the client shortcuts straight to "supported" with permission level 4.

The packets

Every payload is serialized with CompressedJsonCodec (gzipped JSON), not vanilla field-by-field codecs. See Serialization.

HandshakePacket (S2C)

Carries a random sessionId and a timestamp. Both are purely diagnostic - the client only cares that the packet arrived, not what's in it. Sent on join.

PermissionPacket (S2C)

Carries the recipient's own permission level (0 to 4). The server derives it from the player's op/permission level. Sent on join, after an accepted config, and after a server-wide-settings attempt, so the client always knows whether it's currently allowed to act as an admin. See Advanced Settings for what the levels gate.

PlayerConfig (C2S)

The player's own preferences: per-slot opacities, glint toggles, elytra and offhand settings, accessory handling, combat detection, Iris dithering and so on. Sent whenever the user changes something, and on join. It carries a schema version so older configs can be migrated.

Before it goes on the wire, forNetwork() strips the client-only "how I see other people" state - the individual player configurations, the global override and the item exclusion map. Those never get broadcast to peers. The exclusion map in particular is the one field that can grow without a hard bound, and dropping it keeps the serverbound payload comfortably under vanilla's size limit so the client doesn't risk a kick.

ServerWideSettings (C2S, admin)

The four server-wide toggles: force combat detection, force Armor Hider off, force armor while invisible, and allow individual player configs. Only sent by a client whose permission level is at least 3. On the server, these are also embedded inside ServerConfiguration when it goes back out to clients.

ServerConfiguration (S2C, the "library")

The full server-side snapshot: the server-wide settings plus a map of every known player's PlayerConfig. This is what lets a joining player learn everyone else's preferences in a single packet, and what gets re-broadcast when anyone changes their settings.

CombatLogEventPacket (C2S) and CombatLogNotificationPacket (S2C)

Deliberately two packets in opposite directions. A client that detects a combat event sends the C2S event to the server ("combat happened involving this player"). The server discards the client-supplied originator, re-stamps it with the authenticated sender UUID so events can't be forged for other players, and fans the result back out as the S2C notification to every other client. Receiving clients only apply it if combat detection is in effect for that player (either the server forces it, or the player's resolved config has it on). See Combat Detection.

Serialization

CompressedJsonCodec frames every payload as a big-endian int32 length prefix followed by GZIP-compressed UTF-8 JSON. Because the payloads are attacker-controllable, everything is bounded:

  • general clientbound cap of 1 MiB
  • serverbound cap of just under 32 KiB (vanilla's serverbound limit), enforced on encode for PlayerConfig so an oversized config throws locally instead of getting the client kicked
  • a 64 MiB decompressed-size guard against decompression bombs, enforced while inflating
  • the length prefix is validated against the readable bytes before anything is allocated

On decode, a PlayerConfig also runs through the same repair pass it gets when loaded from disk, so a malformed config is healed rather than trusted blindly.

Config sync flow

The server holds a single ServerConfiguration, persisted to config/armor-hider-server.json.

When a player changes their settings, their client sends a PlayerConfig. The server stores it, saves to disk, and broadcasts the whole updated ServerConfiguration to every client except the sender, then sends the sender a fresh PermissionPacket. When a player joins, they get the entire current ServerConfiguration up front, which is how they see everyone who's already online with their configured settings.

Paper plugin

The Paper plugin re-implements the identical wire protocol over Bukkit plugin messaging, without touching any Minecraft internals - it treats payloads as opaque JSON and only reads the few fields it needs (player id, player name, the server-wide booleans). PayloadCodec reproduces CompressedJsonCodec byte for byte, same framing and same size limits.

A couple of Bukkit-specific wrinkles are worth calling out:

  • Because the mod's client writes raw payloads from mixins rather than announcing channels with minecraft:register, Bukkit would normally refuse to deliver anything to it. The plugin works around this by force-subscribing each joining connection to all channels (in both namespaces) via reflection.
  • On join the plugin sends the handshake first, then the config, then permissions, with latches to make sure nothing goes out mid-subscribe.

ClientDialects

Since both namespace aliases get force-subscribed, a naive clientbound send would go out twice, once per namespace. ClientDialects records which namespace each client actually speaks, learned from the client's own inbound traffic, so the plugin can narrow a send to the one matching alias.

Only the two C2S families that switched namespace at 1.21.11 are treated as evidence of a client's dialect. The combat-log C2S channel is explicitly excluded because it uses the long namespace on every version, so trusting it would mislabel an older client.

Narrowing is applied only to ServerConfiguration, which is the one large payload and the only one whose namespace actually tracks the client's era. Everything small (handshake, permissions, combat notifications) is sent on all subscribed aliases, because a client from the 1.21.4 to 1.21.10 range speaks the short namespace for settings but still listens for permissions on the long one. When the dialect isn't known yet, the send goes out on everything - sending twice beats not sending at all.

Clone this wiki locally