Словарь протокола: блоки, формат чанка и игровые пакеты
Протокол до сих пор умел только рукопожатие: поздороваться, доказать пароль, получить токен и войти. Дальше играть было нечем. Добавлено: - Packet, интерфейс, который умеет написать себя сам. Раньше отправитель разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта ветка росла. Теперь отправитель знает только, что у пакета есть write(). - Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и геометрия чанка — это словарь провода, а не внутреннее дело сервера: клиент получает чанк байт на блок и обязан знать, что означает каждый байт. Один источник правды вместо двух, которые могут разойтись. - Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange, PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove. - Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это воздух, поэтому дефлейт тут не украшение, а разница между «сервер принял» и «клиент вошёл». - Имена всем пакетам в таблице names(), чтобы забытый пакет был виден сразу, а не всплывал через месяц. Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ битому сжатому блоку и отказ обрезанному.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package net.rustnotmc.protocol;
|
||||
|
||||
/**
|
||||
* The block ids a world is made of.
|
||||
*
|
||||
* These live in the protocol rather than in the server because they are the
|
||||
* vocabulary of the wire: a chunk that arrives at the client is a byte per
|
||||
* block, and both ends have to agree on what each byte means. A server that
|
||||
* invented its own ids would leave the client guessing.
|
||||
*
|
||||
* Kept as plain constants rather than an enum so that a chunk can store them in
|
||||
* a byte array. A world holding an enum per block costs sixteen bytes a block
|
||||
* and a garbage collector problem for every chunk that moves.
|
||||
*
|
||||
* The ids do not have to match anything right now. What matters is that they are
|
||||
* stable, because they go into save files and onto the wire, and that air is
|
||||
* zero, because every "is this chunk generated" shortcut in the code assumes it.
|
||||
*/
|
||||
public final class Blocks {
|
||||
|
||||
public static final byte AIR = 0;
|
||||
public static final byte STONE = 1;
|
||||
public static final byte DIRT = 2;
|
||||
public static final byte GRASS = 3;
|
||||
public static final byte SAND = 4;
|
||||
public static final byte WATER = 5;
|
||||
public static final byte WOOD = 6;
|
||||
public static final byte LEAVES = 7;
|
||||
|
||||
/** Lowest and highest legal block id. */
|
||||
public static final byte MIN = AIR;
|
||||
public static final byte MAX = LEAVES;
|
||||
|
||||
private Blocks() {
|
||||
}
|
||||
|
||||
public static String name(byte id) {
|
||||
switch (id) {
|
||||
case AIR: return "air";
|
||||
case STONE: return "stone";
|
||||
case DIRT: return "dirt";
|
||||
case GRASS: return "grass";
|
||||
case SAND: return "sand";
|
||||
case WATER: return "water";
|
||||
case WOOD: return "wood";
|
||||
case LEAVES: return "leaves";
|
||||
default: return "unknown(" + id + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a player can walk through it. */
|
||||
public static boolean isPassable(byte id) {
|
||||
return id == AIR || id == WATER;
|
||||
}
|
||||
|
||||
/** True when light passes through it. */
|
||||
public static boolean isTransparent(byte id) {
|
||||
return id == AIR || id == WATER;
|
||||
}
|
||||
|
||||
public static boolean isValid(byte id) {
|
||||
return id >= MIN && id <= MAX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package net.rustnotmc.protocol;
|
||||
|
||||
import java.util.zip.DataFormatException;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.Inflater;
|
||||
|
||||
/**
|
||||
* The shape of a chunk, and how it travels.
|
||||
*
|
||||
* A chunk is sixteen by sixteen columns, two hundred and fifty six high, one
|
||||
* byte per block. That is sixty five thousand five hundred and thirty six
|
||||
* bytes, and almost all of it is air.
|
||||
*
|
||||
* Sending that raw would be silly, so a chunk goes over the wire deflated. A
|
||||
* chunk of mostly air is a few hundred bytes compressed against sixty five
|
||||
* kilobytes raw, which is the difference between a player joining in a moment
|
||||
* and a player joining in a minute.
|
||||
*
|
||||
* The shape itself is part of the wire format and so lives here rather than in
|
||||
* the server: the client has to know the height of a chunk to build a mesh out
|
||||
* of it, and it has to know the order the bytes arrive in to put them back
|
||||
* together.
|
||||
*/
|
||||
public final class ChunkFormat {
|
||||
|
||||
/** Blocks along one edge of a chunk. */
|
||||
public static final int WIDTH = 16;
|
||||
|
||||
/** Blocks from the bottom of a chunk to the top. */
|
||||
public static final int HEIGHT = 256;
|
||||
|
||||
/** Blocks in one layer of a chunk. */
|
||||
public static final int AREA = WIDTH * WIDTH;
|
||||
|
||||
/** Blocks in a whole chunk. */
|
||||
public static final int VOLUME = AREA * HEIGHT;
|
||||
|
||||
/**
|
||||
* The most a compressed chunk may be.
|
||||
*
|
||||
* Deflate can make data slightly bigger, so this is not the raw size; it is
|
||||
* the raw size plus room for the worst expansion, and it exists so that a
|
||||
* peer cannot announce a chunk body of any size it likes.
|
||||
*/
|
||||
public static final int MAX_COMPRESSED = VOLUME + 1024;
|
||||
|
||||
private ChunkFormat() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a block sits in a chunk's byte array.
|
||||
*
|
||||
* The sixteen blocks of one layer are next to each other in memory, so a
|
||||
* layer is one run and a chunk is a stack of layers. This is the order a
|
||||
* generator fills a chunk in and the order it gets saved in.
|
||||
*/
|
||||
public static int index(int x, int y, int z) {
|
||||
return (y << 8) | (z << 4) | x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deflates a chunk.
|
||||
*
|
||||
* The fastest level, not the smallest. A chunk is mostly zeros and zeros
|
||||
* deflate to almost nothing at any level, so the extra work of a smaller
|
||||
* result buys nothing, and the server pays this cost once per chunk per
|
||||
* player that sees it.
|
||||
*/
|
||||
public static byte[] compress(byte[] raw) {
|
||||
if (raw == null) {
|
||||
throw new IllegalArgumentException("raw is null");
|
||||
}
|
||||
Deflater deflater = new Deflater(Deflater.BEST_SPEED);
|
||||
try {
|
||||
deflater.setInput(raw);
|
||||
deflater.finish();
|
||||
byte[] out = new byte[Math.max(64, raw.length / 4)];
|
||||
int done = 0;
|
||||
while (!deflater.finished()) {
|
||||
if (done == out.length) {
|
||||
byte[] bigger = new byte[out.length * 2];
|
||||
System.arraycopy(out, 0, bigger, 0, done);
|
||||
out = bigger;
|
||||
}
|
||||
done += deflater.deflate(out, done, out.length - done);
|
||||
}
|
||||
byte[] result = new byte[done];
|
||||
System.arraycopy(out, 0, result, 0, done);
|
||||
return result;
|
||||
} finally {
|
||||
deflater.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a chunk back together.
|
||||
*
|
||||
* The expected length is fixed by the format, not by the peer, so a peer
|
||||
* cannot make this allocate. Anything that does not come out at exactly the
|
||||
* right size is a damaged or hostile chunk, and both are refused.
|
||||
*/
|
||||
public static byte[] decompress(byte[] compressed, int expectedLength) {
|
||||
if (compressed == null) {
|
||||
throw new ProtocolException("chunk data is missing");
|
||||
}
|
||||
if (expectedLength < 0 || expectedLength > VOLUME) {
|
||||
throw new ProtocolException("expected length " + expectedLength
|
||||
+ " is not a chunk size");
|
||||
}
|
||||
Inflater inflater = new Inflater();
|
||||
try {
|
||||
inflater.setInput(compressed);
|
||||
byte[] out = new byte[expectedLength];
|
||||
int done = 0;
|
||||
while (done < expectedLength) {
|
||||
int n = inflater.inflate(out, done, expectedLength - done);
|
||||
if (n == 0) {
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
if (done != expectedLength) {
|
||||
throw new ProtocolException("chunk came out " + done
|
||||
+ " bytes, expected " + expectedLength);
|
||||
}
|
||||
return out;
|
||||
} catch (DataFormatException e) {
|
||||
throw new ProtocolException("chunk data is not valid compressed data", e);
|
||||
} finally {
|
||||
inflater.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package net.rustnotmc.protocol;
|
||||
|
||||
/**
|
||||
* Anything that can go in a frame.
|
||||
*
|
||||
* A packet knows how to write itself. That is the whole contract, and it is
|
||||
* enough for a sender to say "put this on the wire" without knowing anything
|
||||
* about the packet's type, which is what keeps the send path from growing a
|
||||
* branch per packet kind.
|
||||
*/
|
||||
public interface Packet {
|
||||
|
||||
/** The body of the packet, ready to be framed. */
|
||||
Buf write();
|
||||
}
|
||||
@@ -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 -> 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
|
||||
* </pre>
|
||||
*
|
||||
* After that the play phase begins, and it has no fixed order:
|
||||
*
|
||||
* <pre>
|
||||
* client -> JoinWorld ask to be put in the world
|
||||
* server -> WorldJoin the world, the spawn, the view distance
|
||||
* server -> ChunkData a chunk, as it becomes visible
|
||||
* either -> BlockChange one block, changed
|
||||
* client -> PlayerMove where the player is
|
||||
* server -> EntityMove where somebody else is
|
||||
* either -> 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;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,21 @@ public final class ProtocolTest {
|
||||
run("handshake packets round trip", new Runnable() {
|
||||
public void run() { testHandshake(); }
|
||||
});
|
||||
run("play packets round trip", new Runnable() {
|
||||
public void run() { testPlayPackets(); }
|
||||
});
|
||||
run("a chunk survives compression", new Runnable() {
|
||||
public void run() { testChunkCompression(); }
|
||||
});
|
||||
run("a chunk that is not compressed data is refused", new Runnable() {
|
||||
public void run() { testBadChunkData(); }
|
||||
});
|
||||
run("a truncated chunk is refused", new Runnable() {
|
||||
public void run() { testShortChunk(); }
|
||||
});
|
||||
run("every packet has a name", new Runnable() {
|
||||
public void run() { testPacketNames(); }
|
||||
});
|
||||
run("a lying length cannot stall the heap", new Runnable() {
|
||||
public void run() { testLyingLength(); }
|
||||
});
|
||||
@@ -384,6 +399,137 @@ public final class ProtocolTest {
|
||||
eq("max", Integer.valueOf(500), Integer.valueOf(nr.readVarInt()));
|
||||
}
|
||||
|
||||
/** The play phase packets, over a buffer. */
|
||||
private static void testPlayPackets() {
|
||||
Packets.JoinWorld join = Packets.JoinWorld.at(120.5D, 64.0D, -300.25D, 90.0F, -10.0F);
|
||||
Packets.JoinWorld joinBack =
|
||||
Packets.JoinWorld.read(new Buf(join.write().toByteArray()));
|
||||
yes("the join carried a position", joinBack.hasPosition);
|
||||
eq("join x", Double.valueOf(120.5D), Double.valueOf(joinBack.x));
|
||||
eq("join y", Double.valueOf(64.0D), Double.valueOf(joinBack.y));
|
||||
eq("join z", Double.valueOf(-300.25D), Double.valueOf(joinBack.z));
|
||||
eq("join yaw", Float.valueOf(90.0F), Float.valueOf(joinBack.yaw));
|
||||
eq("join pitch", Float.valueOf(-10.0F), Float.valueOf(joinBack.pitch));
|
||||
|
||||
Packets.JoinWorld fresh = Packets.JoinWorld.fresh();
|
||||
Packets.JoinWorld freshBack =
|
||||
Packets.JoinWorld.read(new Buf(fresh.write().toByteArray()));
|
||||
yes("a fresh join carries no position", !freshBack.hasPosition);
|
||||
|
||||
Packets.WorldJoin world =
|
||||
new Packets.WorldJoin(7, 1234567890123L, 0.5D, 64.0D, 0.5D, 8, 256);
|
||||
Packets.WorldJoin worldBack =
|
||||
Packets.WorldJoin.read(new Buf(world.write().toByteArray()));
|
||||
eq("entity id", Integer.valueOf(7), Integer.valueOf(worldBack.entityId));
|
||||
eq("seed", Long.valueOf(1234567890123L), Long.valueOf(worldBack.seed));
|
||||
eq("view distance", Integer.valueOf(8), Integer.valueOf(worldBack.viewDistance));
|
||||
eq("world height", Integer.valueOf(256), Integer.valueOf(worldBack.worldHeight));
|
||||
eq("spawn x", Double.valueOf(0.5D), Double.valueOf(worldBack.spawnX));
|
||||
|
||||
Packets.BlockChange change = new Packets.BlockChange(-100, 70, 250, Blocks.STONE);
|
||||
Packets.BlockChange changeBack =
|
||||
Packets.BlockChange.read(new Buf(change.write().toByteArray()));
|
||||
eq("block x", Integer.valueOf(-100), Integer.valueOf(changeBack.x));
|
||||
eq("block y", Integer.valueOf(70), Integer.valueOf(changeBack.y));
|
||||
eq("block z", Integer.valueOf(250), Integer.valueOf(changeBack.z));
|
||||
eq("block id", Byte.valueOf(Blocks.STONE), Byte.valueOf(changeBack.blockId));
|
||||
|
||||
Packets.PlayerMove move =
|
||||
new Packets.PlayerMove(1.5D, 64.0D, -2.25D, 45.0F, -45.0F, true);
|
||||
Packets.PlayerMove moveBack =
|
||||
Packets.PlayerMove.read(new Buf(move.write().toByteArray()));
|
||||
eq("move x", Double.valueOf(1.5D), Double.valueOf(moveBack.x));
|
||||
eq("move z", Double.valueOf(-2.25D), Double.valueOf(moveBack.z));
|
||||
eq("move yaw", Float.valueOf(45.0F), Float.valueOf(moveBack.yaw));
|
||||
yes("the player is on the ground", moveBack.onGround);
|
||||
|
||||
Packets.EntityMove entity =
|
||||
new Packets.EntityMove(9, 8.0D, 9.0D, 10.0D, 1.0F, 2.0F);
|
||||
Packets.EntityMove entityBack =
|
||||
Packets.EntityMove.read(new Buf(entity.write().toByteArray()));
|
||||
eq("entity id", Integer.valueOf(9), Integer.valueOf(entityBack.entityId));
|
||||
eq("entity x", Double.valueOf(8.0D), Double.valueOf(entityBack.x));
|
||||
eq("entity z", Double.valueOf(10.0D), Double.valueOf(entityBack.z));
|
||||
|
||||
Packets.Ping ping = new Packets.Ping(1234567890123L);
|
||||
eq("ping", Long.valueOf(1234567890123L),
|
||||
Long.valueOf(Packets.Ping.read(new Buf(ping.write().toByteArray())).id));
|
||||
Packets.Pong pong = new Packets.Pong(99L);
|
||||
eq("pong", Long.valueOf(99L),
|
||||
Long.valueOf(Packets.Pong.read(new Buf(pong.write().toByteArray())).id));
|
||||
|
||||
Packets.EntitySpawn spawn = new Packets.EntitySpawn(4, 4, "Player",
|
||||
1.0D, 65.0D, 2.0D, 30.0F, -60.0F);
|
||||
Packets.EntitySpawn spawnBack =
|
||||
Packets.EntitySpawn.read(new Buf(spawn.write().toByteArray()));
|
||||
eq("spawn entity id", Integer.valueOf(4), Integer.valueOf(spawnBack.entityId));
|
||||
eq("spawn player id", Integer.valueOf(4), Integer.valueOf(spawnBack.playerId));
|
||||
eq("spawn name", "Player", spawnBack.name);
|
||||
eq("spawn y", Double.valueOf(65.0D), Double.valueOf(spawnBack.y));
|
||||
|
||||
Packets.EntityRemove remove = new Packets.EntityRemove(11);
|
||||
eq("remove", Integer.valueOf(11), Integer.valueOf(
|
||||
Packets.EntityRemove.read(new Buf(remove.write().toByteArray())).entityId));
|
||||
}
|
||||
|
||||
private static void testChunkCompression() {
|
||||
byte[] raw = new byte[ChunkFormat.VOLUME];
|
||||
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
|
||||
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
|
||||
for (int y = 0; y < 64; y++) {
|
||||
raw[ChunkFormat.index(x, y, z)] = y == 63 ? Blocks.GRASS : Blocks.STONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byte[] packed = ChunkFormat.compress(raw);
|
||||
yes("a chunk of mostly air is small (" + packed.length + " bytes)",
|
||||
packed.length < 4096);
|
||||
eq("the chunk came back", raw, ChunkFormat.decompress(packed, ChunkFormat.VOLUME));
|
||||
|
||||
Packets.ChunkData data = new Packets.ChunkData(-3, 9, raw);
|
||||
Packets.ChunkData back = Packets.ChunkData.read(new Buf(data.write().toByteArray()));
|
||||
eq("chunk x", Integer.valueOf(-3), Integer.valueOf(back.chunkX));
|
||||
eq("chunk z", Integer.valueOf(9), Integer.valueOf(back.chunkZ));
|
||||
eq("the blocks came back", raw, back.blocks);
|
||||
}
|
||||
|
||||
private static void testBadChunkData() {
|
||||
// 0xFF is not a valid deflate block header, so this cannot decode
|
||||
final byte[] rubbish = new byte[64];
|
||||
java.util.Arrays.fill(rubbish, (byte) 0xFF);
|
||||
refuses("a chunk that is not compressed data", new Runnable() {
|
||||
public void run() { ChunkFormat.decompress(rubbish, ChunkFormat.VOLUME); }
|
||||
});
|
||||
}
|
||||
|
||||
private static void testShortChunk() {
|
||||
byte[] raw = new byte[ChunkFormat.VOLUME];
|
||||
byte[] packed = ChunkFormat.compress(raw);
|
||||
final byte[] cut = java.util.Arrays.copyOf(packed, packed.length / 2);
|
||||
refuses("a truncated chunk", new Runnable() {
|
||||
public void run() { ChunkFormat.decompress(cut, ChunkFormat.VOLUME); }
|
||||
});
|
||||
}
|
||||
|
||||
private static void testPacketNames() {
|
||||
java.util.Map<Integer, String> names = Packets.names();
|
||||
int[] ids = {
|
||||
Packets.HELLO, Packets.HELLO_ACK, Packets.AUTH, Packets.AUTH_ACK,
|
||||
Packets.LOGIN, Packets.LOGIN_ACK, Packets.JOIN_WORLD,
|
||||
Packets.WORLD_JOIN, Packets.CHUNK_DATA, Packets.BLOCK_CHANGE,
|
||||
Packets.PLAYER_MOVE, Packets.ENTITY_MOVE, Packets.PING, Packets.PONG,
|
||||
Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE,
|
||||
Packets.DISCONNECT,
|
||||
};
|
||||
for (int i = 0; i < ids.length; i++) {
|
||||
yes("packet " + ids[i] + " has a name",
|
||||
names.containsKey(Integer.valueOf(ids[i])));
|
||||
}
|
||||
eq("no id is used twice", Integer.valueOf(ids.length),
|
||||
Integer.valueOf(names.size()));
|
||||
}
|
||||
|
||||
private static void testLyingLength() {
|
||||
// announces a large but legal body, sends nothing, and keeps quiet.
|
||||
// The decoder must hold it without growing and without pretending the
|
||||
|
||||
Reference in New Issue
Block a user