Cross-Server Visibility: The Trials of Packet Mirroring
Replicating player packets using redis pub-sub to see each other on .
Inter-Server Visibility: The Trials of Packet Mirroring ⚙️
Those who played Wynncraft before might remember one could see players who are on other servers. I'm bringing that feature back, and it's led to a deep dive into low-level networking and packet manipulation.
The Cross-Server Packet Pipeline
To achieve this "cross-server visibility," I've set up a system to intercept a player's action, broadcast the raw network packet, and have the receiving servers "re-inject" it for a mirror entity.
Here's the outgoing packet flow:
- I turn the
ByteBufinto abyte[]. - I flip the most significant bit (MSB) in the first byte to mark whether it's a client or server packet.
- Finally, I publish the packet over Redis Pub/Sub.
Until here, everything works fine. For the Entity Metadata packet, I send and receive [-35, 6, 21, 5, -1]. After undoing the client/server marker, the first byte is 93, the correct Packet ID for Entity Metadata. The data is intact. Awesome!
The Problem: The Unconsumed Packet ID 😫
On the receiving end, I need to construct a Packet-Wrapper of the packet to modify the entity ID to match the "mirror-npc" on the server.
We turn the byte[] back into a ByteBuf, and then pass it into a Wrapper-Factory. The standard process is for a packet wrapper factory to read a VarInt from the buffer (the packet ID) and then create the wrapper object.
My initial implementation attempted to reuse the existing packet wrapper's internal read logic:
public static WrapperPlayServerEntityMetadata createEntityMetadataWrapper(ByteBuf byteBuf) { // Not ideal
try {
var dummy = new WrapperPlayServerEntityMetadata(-1, List.of());
dummy.setBuffer(byteBuf);
dummy.read(); // rely on the existing methods to populate the object
return dummy;
} catch (Exception e) {
log.warn("Failed to read entity metadata: {}", e.getMessage());
return null;
}
}
Here is my problem. The standard networking stack consumes the Packet ID before passing the buffer to the wrapper. My raw buffer from Redis still contains the Packet ID. The wrapper's internal read() method wasn't set up to consume that initial VarInt, leading to critical failures when the player sneaks and unsneaks:
[...] Failed to read: readerIndex(5) + length(1) exceeds writerIndex(5): UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 5, widx: 5, cap: 256)(Attempting to read past the end of the buffer).[...] Failed to read: java.io.IOException: Unknown nbt type id -1(Misinterpreting the packet data).
The Solution: Manually Consuming the Buffer 🛠️
The solution was to stop relying on the wrapper's internal methods and instead use a helper PacketWrapper which is designed to handle a raw buffer. By using PacketWrapper.createUniversalPacketWrapper(buf), we correctly consume the initial **Packet ID** VarInt, leaving the buffer cursor positioned at the **Entity ID**—the first piece of data we actually need for our wrapper.
The clean, working factory method is as follows:
public WrapperPlayServerEntityMetadata createEntityMetadataWrapper(ByteBuf buf) {
PacketWrapper<?> reader = PacketWrapper.createUniversalPacketWrapper(buf);
// Now, we correctly read the first field of the payload: the Entity ID
int entityId = reader.readVarInt();
// And finally, the rest of the payload: the Entity Metadata
List<EntityData<?>> entityMetaData = reader.readEntityMetadata();
return new WrapperPlayServerEntityMetadata(entityId, entityMetaData);
}
This small change ensures the buffer is consumed correctly and in order, resolving the reader index errors and allowing the mirror packet to be constructed successfully.