-
-
Notifications
You must be signed in to change notification settings - Fork 6
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.
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
CustomPacketPayloadandStreamCodec, no Fabric API involved. This is the path every currently shipped version uses. -
Legacy (< 1.20.5): raw channel
IdentifierplusFriendlyByteBuf, handled byLegacyPacketHandler. 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.
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.
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:
- the current full
ServerConfiguration(settings_s2c_packet) - a
PermissionPacketwith that player's permission level - 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 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.
Every payload is serialized with CompressedJsonCodec (gzipped JSON), not vanilla field-by-field codecs. See Serialization.
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.
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.
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.
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.
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.
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.
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
PlayerConfigso 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.
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.
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.
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.