Словарь протокола: блоки, формат чанка и игровые пакеты

Протокол до сих пор умел только рукопожатие: поздороваться, доказать
пароль, получить токен и войти. Дальше играть было нечем.

Добавлено:

- Packet, интерфейс, который умеет написать себя сам. Раньше отправитель
  разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта
  ветка росла. Теперь отправитель знает только, что у пакета есть write().
- Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и
  геометрия чанка — это словарь провода, а не внутреннее дело сервера:
  клиент получает чанк байт на блок и обязан знать, что означает каждый
  байт. Один источник правды вместо двух, которые могут разойтись.
- Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange,
  PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove.
- Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это
  воздух, поэтому дефлейт тут не украшение, а разница между «сервер
  принял» и «клиент вошёл».
- Имена всем пакетам в таблице names(), чтобы забытый пакет был виден
  сразу, а не всплывал через месяц.

Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ
битому сжатому блоку и отказ обрезанному.
This commit is contained in:
sasheg dev
2026-10-19 18:58:07 +03:00
parent 620f136361
commit 7cd074e13c
5 changed files with 778 additions and 14 deletions
@@ -14,13 +14,25 @@ import java.util.Map;
* The order of a connection is fixed and short:
*
* <pre>
* client -> Hello protocol version, what the client is
* server -> HelloAck accepted version, server name, motd, online count
* client -> Auth account and proof
* server -> AuthAck session token, or a reason it was refused
* client -> Login the session token
* server -> LoginAck player id and entity id, or a reason
* either -> Disconnect a human readable reason
* client -&gt; Hello protocol version, what the client is
* server -&gt; HelloAck accepted version, server name, motd, online count
* client -&gt; Auth account and proof
* server -&gt; AuthAck session token, or a reason it was refused
* client -&gt; Login the session token
* server -&gt; LoginAck player id and entity id, or a reason
* either -&gt; Disconnect a human readable reason
* </pre>
*
* After that the play phase begins, and it has no fixed order:
*
* <pre>
* client -&gt; JoinWorld ask to be put in the world
* server -&gt; WorldJoin the world, the spawn, the view distance
* server -&gt; ChunkData a chunk, as it becomes visible
* either -&gt; BlockChange one block, changed
* client -&gt; PlayerMove where the player is
* server -&gt; EntityMove where somebody else is
* either -&gt; Ping/Pong is anybody there
* </pre>
*/
public final class Packets {
@@ -53,10 +65,37 @@ public final class Packets {
public static final int AUTH_ACK = 0x04;
public static final int LOGIN = 0x05;
public static final int LOGIN_ACK = 0x06;
/**
* The play phase, once the handshake is done.
*
* Kept apart from the handshake ids so that a packet can never be mistaken
* for one it is not: the handshake happens exactly once, in a fixed order,
* and the play phase happens in any order, for as long as the player stays.
*/
public static final int JOIN_WORLD = 0x10;
public static final int WORLD_JOIN = 0x11;
public static final int CHUNK_DATA = 0x12;
/**
* One block changed.
*
* The same packet in both directions: the client asks for a block to
* change, and the server tells everybody who can see it that it did.
*/
public static final int BLOCK_CHANGE = 0x13;
public static final int PLAYER_MOVE = 0x14;
public static final int ENTITY_MOVE = 0x15;
public static final int PING = 0x16;
public static final int PONG = 0x17;
public static final int ENTITY_SPAWN = 0x18;
public static final int ENTITY_REMOVE = 0x19;
public static final int DISCONNECT = 0x7F;
/** The client introducing itself, and naming the account it wants. */
public static final class Hello {
public static final class Hello implements Packet {
public final int protocolVersion;
public final String account;
public final String clientName;
@@ -85,7 +124,7 @@ public final class Packets {
}
/** The server accepting, and describing itself. */
public static final class HelloAck {
public static final class HelloAck implements Packet {
public final int protocolVersion;
public final String serverName;
public final String motd;
@@ -148,7 +187,7 @@ public final class Packets {
* form of it, and a proof captured from one connection is useless against
* the next because the nonce differs.
*/
public static final class Auth {
public static final class Auth implements Packet {
public final String account;
public final byte[] proof;
@@ -218,7 +257,7 @@ public final class Packets {
}
/** Whether the account was accepted, and on what terms. */
public static final class AuthAck {
public static final class AuthAck implements Packet {
public final boolean accepted;
public final String reason;
public final String token;
@@ -263,7 +302,7 @@ public final class Packets {
}
/** The client redeeming the token it was just handed. */
public static final class Login {
public static final class Login implements Packet {
public final String account;
public final String token;
@@ -285,7 +324,7 @@ public final class Packets {
}
/** Whether the player is in. */
public static final class LoginAck {
public static final class LoginAck implements Packet {
public final boolean accepted;
public final String reason;
public final int playerId;
@@ -328,7 +367,7 @@ public final class Packets {
}
/** A reason, said out loud, before the connection goes away. */
public static final class Disconnect {
public static final class Disconnect implements Packet {
public final String reason;
public Disconnect(String reason) {
@@ -344,6 +383,363 @@ public final class Packets {
}
}
/**
* The client asking to be put in the world.
*
* The position it carries is where the client thinks it is, which is how a
* reconnect puts a player back where it was standing rather than at the
* spawn. A client that has never been here sends no position at all, and
* the server decides where it starts.
*/
public static final class JoinWorld implements Packet {
public final double x;
public final double y;
public final double z;
public final float yaw;
public final float pitch;
public final boolean hasPosition;
public JoinWorld(double x, double y, double z, float yaw, float pitch,
boolean hasPosition) {
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
this.hasPosition = hasPosition;
}
/** A client that has no place to come back to. */
public static JoinWorld fresh() {
return new JoinWorld(0.0D, 0.0D, 0.0D, 0.0F, 0.0F, false);
}
/** A client that knows where it is. */
public static JoinWorld at(double x, double y, double z, float yaw, float pitch) {
return new JoinWorld(x, y, z, yaw, pitch, true);
}
public Buf write() {
Buf buf = new Buf();
buf.writeBoolean(this.hasPosition);
if (this.hasPosition) {
buf.writePosition(this.x, this.y, this.z);
buf.writeFloat(this.yaw);
buf.writeFloat(this.pitch);
}
return buf;
}
public static JoinWorld read(Buf in) {
if (!in.readBoolean()) {
return fresh();
}
double[] at = in.readPosition();
return new JoinWorld(at[0], at[1], at[2], in.readFloat(), in.readFloat(), true);
}
}
/**
* The world the client is in.
*
* The seed goes across so that the client can build the same sky, the same
* colours and the same cloud shapes the server sees, without the server
* having to send any of it.
*/
public static final class WorldJoin implements Packet {
public final int entityId;
public final long seed;
public final double spawnX;
public final double spawnY;
public final double spawnZ;
public final int viewDistance;
public final int worldHeight;
public WorldJoin(int entityId, long seed, double spawnX, double spawnY,
double spawnZ, int viewDistance, int worldHeight) {
this.entityId = entityId;
this.seed = seed;
this.spawnX = spawnX;
this.spawnY = spawnY;
this.spawnZ = spawnZ;
this.viewDistance = viewDistance;
this.worldHeight = worldHeight;
}
public Buf write() {
Buf buf = new Buf();
buf.writeVarInt(this.entityId);
buf.writeVarLong(this.seed);
buf.writePosition(this.spawnX, this.spawnY, this.spawnZ);
buf.writeVarInt(this.viewDistance);
buf.writeVarInt(this.worldHeight);
return buf;
}
public static WorldJoin read(Buf in) {
int entityId = in.readVarInt();
long seed = in.readVarLong();
double[] spawn = in.readPosition();
return new WorldJoin(entityId, seed, spawn[0], spawn[1], spawn[2],
in.readVarInt(), in.readVarInt());
}
}
/**
* One chunk, on its way to the client.
*
* The block data is deflated, because a chunk is sixty five kilobytes of
* which ninety five percent is air, and sending that raw would make joining
* a server a matter of patience.
*/
public static final class ChunkData implements Packet {
public final int chunkX;
public final int chunkZ;
public final byte[] blocks;
public ChunkData(int chunkX, int chunkZ, byte[] blocks) {
if (blocks == null || blocks.length != ChunkFormat.VOLUME) {
throw new IllegalArgumentException("chunk data is "
+ (blocks == null ? "missing" : blocks.length + " bytes")
+ ", expected " + ChunkFormat.VOLUME);
}
this.chunkX = chunkX;
this.chunkZ = chunkZ;
this.blocks = blocks;
}
public Buf write() {
byte[] packed = ChunkFormat.compress(this.blocks);
Buf buf = new Buf(ChunkFormat.MAX_COMPRESSED);
buf.writeVarInt(this.chunkX);
buf.writeVarInt(this.chunkZ);
buf.writeVarInt(packed.length);
buf.writeBytes(packed);
return buf;
}
public static ChunkData read(Buf in) {
int chunkX = in.readVarInt();
int chunkZ = in.readVarInt();
int length = in.readVarInt();
if (length < 0 || length > ChunkFormat.MAX_COMPRESSED) {
throw new ProtocolException("compressed chunk of " + length
+ " bytes is out of range");
}
byte[] packed = in.readBytes(length);
return new ChunkData(chunkX, chunkZ,
ChunkFormat.decompress(packed, ChunkFormat.VOLUME));
}
}
/**
* One block, changed.
*
* The same packet in both directions. The client asks, the server says, and
* the body is the same either way, which is why there is no second class
* for the request.
*/
public static final class BlockChange implements Packet {
public final int x;
public final int y;
public final int z;
public final byte blockId;
public BlockChange(int x, int y, int z, byte blockId) {
this.x = x;
this.y = y;
this.z = z;
this.blockId = blockId;
}
public Buf write() {
Buf buf = new Buf(16);
buf.writeVarInt(this.x);
buf.writeVarInt(this.y);
buf.writeVarInt(this.z);
buf.writeByte(this.blockId);
return buf;
}
public static BlockChange read(Buf in) {
return new BlockChange(in.readVarInt(), in.readVarInt(),
in.readVarInt(), (byte) in.readUnsignedByte());
}
}
/** Where the player is now. */
public static final class PlayerMove implements Packet {
public final double x;
public final double y;
public final double z;
public final float yaw;
public final float pitch;
public final boolean onGround;
public PlayerMove(double x, double y, double z, float yaw, float pitch,
boolean onGround) {
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
this.onGround = onGround;
}
public Buf write() {
Buf buf = new Buf(32);
buf.writePosition(this.x, this.y, this.z);
buf.writeFloat(this.yaw);
buf.writeFloat(this.pitch);
buf.writeBoolean(this.onGround);
return buf;
}
public static PlayerMove read(Buf in) {
double[] at = in.readPosition();
return new PlayerMove(at[0], at[1], at[2], in.readFloat(), in.readFloat(),
in.readBoolean());
}
}
/** Somebody else moved. */
public static final class EntityMove implements Packet {
public final int entityId;
public final double x;
public final double y;
public final double z;
public final float yaw;
public final float pitch;
public EntityMove(int entityId, double x, double y, double z,
float yaw, float pitch) {
this.entityId = entityId;
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
}
public Buf write() {
Buf buf = new Buf(32);
buf.writeVarInt(this.entityId);
buf.writePosition(this.x, this.y, this.z);
buf.writeFloat(this.yaw);
buf.writeFloat(this.pitch);
return buf;
}
public static EntityMove read(Buf in) {
int entityId = in.readVarInt();
double[] at = in.readPosition();
return new EntityMove(entityId, at[0], at[1], at[2],
in.readFloat(), in.readFloat());
}
}
/** A liveness check. */
public static final class Ping implements Packet {
public final long id;
public Ping(long id) {
this.id = id;
}
public Buf write() {
return new Buf(8).writeLong(this.id);
}
public static Ping read(Buf in) {
return new Ping(in.readLong());
}
}
/** The answer to a liveness check. */
public static final class Pong implements Packet {
public final long id;
public Pong(long id) {
this.id = id;
}
public Buf write() {
return new Buf(8).writeLong(this.id);
}
public static Pong read(Buf in) {
return new Pong(in.readLong());
}
}
/**
* Somebody else is here.
*
* Sent when a player arrives, so that the others have something to draw.
* The player id is carried along with the entity id because the two are
* different things: the entity is what the client draws, the player is
* who the server knows the player to be.
*/
public static final class EntitySpawn implements Packet {
public final int entityId;
public final int playerId;
public final String name;
public final double x;
public final double y;
public final double z;
public final float yaw;
public final float pitch;
public EntitySpawn(int entityId, int playerId, String name,
double x, double y, double z, float yaw, float pitch) {
this.entityId = entityId;
this.playerId = playerId;
this.name = name;
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
}
public Buf write() {
Buf buf = new Buf(64);
buf.writeVarInt(this.entityId);
buf.writeVarInt(this.playerId);
buf.writeString(this.name);
buf.writePosition(this.x, this.y, this.z);
buf.writeFloat(this.yaw);
buf.writeFloat(this.pitch);
return buf;
}
public static EntitySpawn read(Buf in) {
int entityId = in.readVarInt();
int playerId = in.readVarInt();
String name = in.readString(MAX_TEXT);
double[] at = in.readPosition();
return new EntitySpawn(entityId, playerId, name, at[0], at[1], at[2],
in.readFloat(), in.readFloat());
}
}
/** Somebody else has left. */
public static final class EntityRemove implements Packet {
public final int entityId;
public EntityRemove(int entityId) {
this.entityId = entityId;
}
public Buf write() {
return new Buf(8).writeVarInt(this.entityId);
}
public static EntityRemove read(Buf in) {
return new EntityRemove(in.readVarInt());
}
}
/**
* Every packet id this build knows, and what it decodes into.
*
@@ -358,6 +754,16 @@ public final class Packets {
map.put(Integer.valueOf(AUTH_ACK), "AuthAck");
map.put(Integer.valueOf(LOGIN), "Login");
map.put(Integer.valueOf(LOGIN_ACK), "LoginAck");
map.put(Integer.valueOf(JOIN_WORLD), "JoinWorld");
map.put(Integer.valueOf(WORLD_JOIN), "WorldJoin");
map.put(Integer.valueOf(CHUNK_DATA), "ChunkData");
map.put(Integer.valueOf(BLOCK_CHANGE), "BlockChange");
map.put(Integer.valueOf(PLAYER_MOVE), "PlayerMove");
map.put(Integer.valueOf(ENTITY_MOVE), "EntityMove");
map.put(Integer.valueOf(PING), "Ping");
map.put(Integer.valueOf(PONG), "Pong");
map.put(Integer.valueOf(ENTITY_SPAWN), "EntitySpawn");
map.put(Integer.valueOf(ENTITY_REMOVE), "EntityRemove");
map.put(Integer.valueOf(DISCONNECT), "Disconnect");
return map;
}