From 7cd074e13cb6090ff9474de5aad90cbba0affa8c Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:58:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0:=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8,=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B0=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Протокол до сих пор умел только рукопожатие: поздороваться, доказать пароль, получить токен и войти. Дальше играть было нечем. Добавлено: - Packet, интерфейс, который умеет написать себя сам. Раньше отправитель разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта ветка росла. Теперь отправитель знает только, что у пакета есть write(). - Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и геометрия чанка — это словарь провода, а не внутреннее дело сервера: клиент получает чанк байт на блок и обязан знать, что означает каждый байт. Один источник правды вместо двух, которые могут разойтись. - Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange, PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove. - Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это воздух, поэтому дефлейт тут не украшение, а разница между «сервер принял» и «клиент вошёл». - Имена всем пакетам в таблице names(), чтобы забытый пакет был виден сразу, а не всплывал через месяц. Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ битому сжатому блоку и отказ обрезанному. --- .../java/net/rustnotmc/protocol/Blocks.java | 64 +++ .../net/rustnotmc/protocol/ChunkFormat.java | 133 ++++++ .../java/net/rustnotmc/protocol/Packet.java | 15 + .../java/net/rustnotmc/protocol/Packets.java | 434 +++++++++++++++++- .../net/rustnotmc/protocol/ProtocolTest.java | 146 ++++++ 5 files changed, 778 insertions(+), 14 deletions(-) create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ChunkFormat.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Packet.java diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java new file mode 100644 index 0000000..fc1d56b --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java @@ -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; + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ChunkFormat.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ChunkFormat.java new file mode 100644 index 0000000..7ccd539 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ChunkFormat.java @@ -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(); + } + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packet.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packet.java new file mode 100644 index 0000000..eabacde --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packet.java @@ -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(); +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index b73b1b7..95c4d46 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -14,13 +14,25 @@ import java.util.Map; * The order of a connection is fixed and short: * *
- * 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 + *+ * + * After that the play phase begins, and it has no fixed order: + * + *
+ * 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 **/ 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; } diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java index 78eb20f..e3edfac 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -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