diff --git a/src/main/java/cn/rukkit/network/core/packet/Packet.java b/src/main/java/cn/rukkit/network/core/packet/Packet.java
new file mode 100644
index 0000000..18b2f40
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/packet/Packet.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.packet;
+
+/**
+ * A length-prefixed game packet payload and its protocol type.
+ *
+ *
The wire format is a four-byte payload length, followed by a four-byte
+ * packet type and the payload bytes. The class intentionally keeps the fields
+ * mutable because packet builders in the existing network stack fill the
+ * payload after constructing the packet.
+ */
+public class Packet {
+ /** Sentinel used when the framed protocol should not impose a payload limit. */
+ public static final int NO_MAX_FRAME_LENGTH = Integer.MAX_VALUE;
+
+ /** The first migration stage deliberately leaves the payload size unlimited. */
+ public static final int DEFAULT_MAX_FRAME_LENGTH = NO_MAX_FRAME_LENGTH;
+
+ public byte[] bytes;
+ public int type;
+
+ public Packet(int type) {
+ this.type = type;
+ }
+
+ public Packet(int type, byte[] bytes) {
+ this.type = type;
+ this.bytes = bytes;
+ }
+
+ public Packet() {
+ this(0);
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/packet/PacketDecoder.java b/src/main/java/cn/rukkit/network/core/packet/PacketDecoder.java
new file mode 100644
index 0000000..2ed399e
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/packet/PacketDecoder.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.packet;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.ByteToMessageDecoder;
+import io.netty.handler.codec.CorruptedFrameException;
+import io.netty.handler.codec.TooLongFrameException;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Decodes the length-prefixed packet wire format. */
+public class PacketDecoder extends ByteToMessageDecoder {
+ private static final int HEADER_SIZE = Integer.BYTES * 2;
+ private static final Logger LOG = LoggerFactory.getLogger(PacketDecoder.class);
+
+ private final int maxFrameLength;
+
+ public PacketDecoder() {
+ this(Packet.DEFAULT_MAX_FRAME_LENGTH);
+ }
+
+ public PacketDecoder(int maxFrameLength) {
+ if (maxFrameLength <= 0) {
+ throw new IllegalArgumentException("maxFrameLength must be positive");
+ }
+ this.maxFrameLength = maxFrameLength;
+ }
+
+ public int getMaxFrameLength() {
+ return maxFrameLength;
+ }
+
+ @Override
+ protected void decode(ChannelHandlerContext context, ByteBuf in, List out) {
+ if (in.readableBytes() < HEADER_SIZE) {
+ return;
+ }
+
+ in.markReaderIndex();
+ int length = in.readInt();
+ int type = in.readInt();
+
+ if (length < 0) {
+ throw new CorruptedFrameException("negative packet payload length: " + length);
+ }
+ if (length > maxFrameLength) {
+ throw new TooLongFrameException(
+ "packet payload length " + length + " exceeds " + maxFrameLength);
+ }
+ if (in.readableBytes() < length) {
+ in.resetReaderIndex();
+ return;
+ }
+
+ byte[] bytes = new byte[length];
+ in.readBytes(bytes);
+
+ Packet packet = new Packet(type, bytes);
+ if (LOG.isTraceEnabled() && (type != PacketType.TICK || length > 20)) {
+ LOG.trace("Received packet type={} size={}", type, length);
+ }
+ out.add(packet);
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/packet/PacketEncoder.java b/src/main/java/cn/rukkit/network/core/packet/PacketEncoder.java
new file mode 100644
index 0000000..ff66a9c
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/packet/PacketEncoder.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.core.packet;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.EncoderException;
+import io.netty.handler.codec.MessageToByteEncoder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Encodes a {@link Packet} as length, type and payload. */
+public class PacketEncoder extends MessageToByteEncoder {
+ private static final Logger LOG = LoggerFactory.getLogger(PacketEncoder.class);
+
+ private final int maxFrameLength;
+
+ public PacketEncoder() {
+ this(Packet.DEFAULT_MAX_FRAME_LENGTH);
+ }
+
+ public PacketEncoder(int maxFrameLength) {
+ if (maxFrameLength <= 0) {
+ throw new IllegalArgumentException("maxFrameLength must be positive");
+ }
+ this.maxFrameLength = maxFrameLength;
+ }
+
+ public int getMaxFrameLength() {
+ return maxFrameLength;
+ }
+
+ @Override
+ protected void encode(ChannelHandlerContext context, Packet packet, ByteBuf out) {
+ if (packet == null) {
+ throw new EncoderException("packet must not be null");
+ }
+ if (packet.bytes == null) {
+ throw new EncoderException("packet payload must not be null");
+ }
+ if (packet.bytes.length > maxFrameLength) {
+ throw new EncoderException("packet payload exceeds maxFrameLength: " + packet.bytes.length);
+ }
+
+ if (LOG.isTraceEnabled() && (packet.type != PacketType.TICK || packet.bytes.length > 20)) {
+ LOG.trace("Sending packet type={} size={}", packet.type, packet.bytes.length);
+ }
+
+ out.writeInt(packet.bytes.length);
+ out.writeInt(packet.type);
+ out.writeBytes(packet.bytes);
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/core/packet/PacketType.java b/src/main/java/cn/rukkit/network/core/packet/PacketType.java
new file mode 100644
index 0000000..b8af848
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/core/packet/PacketType.java
@@ -0,0 +1,50 @@
+package cn.rukkit.network.core.packet;
+
+/** Packet type identifiers used by the game protocol. */
+public final class PacketType {
+ private PacketType() {
+ }
+
+ // Server commands
+ public static final int REGISTER_CONNECTION = 161;
+ public static final int TEAM_LIST = 115;
+ public static final int HEART_BEAT = 108;
+ public static final int SEND_CHAT = 141;
+ public static final int SERVER_INFO = 106;
+ public static final int START_GAME = 120;
+ public static final int QUESTION = 117;
+ public static final int QUESTION_RESPONCE = 118;
+ public static final int QUESTION_RESPONSE = QUESTION_RESPONCE;
+ public static final int KICK = 150;
+ public static final int RETURN_TO_BATTLEROOM = 122;
+
+ // Client commands
+ public static final int PREREGISTER_CONNECTION = 160;
+ public static final int HEART_BEAT_RESPONSE = 109;
+ public static final int ADD_CHAT = 140;
+ public static final int PLAYER_INFO = 110;
+ public static final int DISCONNECT = 111;
+ public static final int READY = 112;
+
+ // Game commands
+ public static final int ADD_GAMECOMMAND = 20;
+ public static final int TICK = 10;
+ public static final int SYNC_CHECKSUM = 30;
+ public static final int SYNC_CHECKSUM_RESPONCE = 31;
+ public static final int SYNC_CHECKSUM_RESPONSE = SYNC_CHECKSUM_RESPONCE;
+ public static final int SYNC = 35;
+
+ // Relay commands
+ public static final int RELAY_117 = 117;
+ public static final int RELAY_118_117_RETURN = 118;
+ public static final int RELAY_POW = 151;
+ public static final int RELAY_POW_RECEIVE = 152;
+ public static final int RELAY_VERSION_INFO = 163;
+ public static final int RELAY_BECOME_SERVER = 170;
+ public static final int FORWARD_CLIENT_ADD = 172;
+ public static final int FORWARD_CLIENT_REMOVE = 173;
+ public static final int PACKET_FORWARD_CLIENT_FROM = 174;
+ public static final int PACKET_FORWARD_CLIENT_TO = 175;
+ public static final int PACKET_FORWARD_CLIENT_TO_REPEATED = 176;
+ public static final int PACKET_RECONNECT_TO = 178;
+}
diff --git a/src/main/java/cn/rukkit/network/io/GameInputStream.java b/src/main/java/cn/rukkit/network/io/GameInputStream.java
new file mode 100644
index 0000000..392fde8
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/io/GameInputStream.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.io;
+
+import cn.rukkit.network.core.packet.Packet;
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.util.LinkedList;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Data reader for the game's big-endian primitive and block format.
+ *
+ * A block is encoded as a modified-UTF name, a four-byte length and the
+ * block bytes. Compressed blocks contain GZIP data. The reader keeps the
+ * stream fields public for compatibility with the existing packet handlers.
+ */
+public class GameInputStream {
+ public static final int DEFAULT_MAX_BLOCK_LENGTH = 16 * 1024 * 1024;
+
+ public final ByteArrayInputStream buffer;
+ public final DataInputStream CurrentStream;
+ public DataInputStream stream;
+ public final LinkedList blockQuere = new LinkedList<>();
+
+ private static final Logger LOG = LoggerFactory.getLogger(GameInputStream.class);
+ private final int maxBlockLength;
+
+ public GameInputStream(Packet packet) {
+ this(requirePayload(packet));
+ }
+
+ public GameInputStream(byte[] bytes) {
+ this(bytes, DEFAULT_MAX_BLOCK_LENGTH);
+ }
+
+ public GameInputStream(byte[] bytes, int maxBlockLength) {
+ if (bytes == null) {
+ throw new IllegalArgumentException("bytes must not be null");
+ }
+ validateMaxBlockLength(maxBlockLength);
+ this.maxBlockLength = maxBlockLength;
+ this.buffer = new ByteArrayInputStream(bytes);
+ this.CurrentStream = new DataInputStream(this.buffer);
+ this.stream = this.CurrentStream;
+ }
+
+ public GameInputStream(DataInputStream stream) {
+ if (stream == null) {
+ throw new IllegalArgumentException("stream must not be null");
+ }
+ this.maxBlockLength = DEFAULT_MAX_BLOCK_LENGTH;
+ this.buffer = null;
+ this.CurrentStream = stream;
+ this.stream = stream;
+ }
+
+ private static byte[] requirePayload(Packet packet) {
+ if (packet == null || packet.bytes == null) {
+ throw new IllegalArgumentException("packet payload must not be null");
+ }
+ return packet.bytes;
+ }
+
+ private static void validateMaxBlockLength(int maxBlockLength) {
+ if (maxBlockLength <= 0) {
+ throw new IllegalArgumentException("maxBlockLength must be positive");
+ }
+ }
+
+ public short readShort() throws IOException {
+ return stream.readShort();
+ }
+
+ public byte readByte() throws IOException {
+ return stream.readByte();
+ }
+
+ public boolean readBoolean() throws IOException {
+ return stream.readBoolean();
+ }
+
+ public int readInt() throws IOException {
+ return stream.readInt();
+ }
+
+ public float readFloat() throws IOException {
+ return stream.readFloat();
+ }
+
+ public long readLong() throws IOException {
+ return stream.readLong();
+ }
+
+ public String readIsString() throws IOException {
+ return readBoolean() ? readString() : "";
+ }
+
+ public String readString() throws IOException {
+ return stream.readUTF();
+ }
+
+ public byte[] readStreamBytes() throws IOException {
+ int length = readInt();
+ if (length < 0 || length > maxBlockLength) {
+ throw new IOException("invalid block length: " + length);
+ }
+ byte[] bytes = new byte[length];
+ stream.readFully(bytes);
+ return bytes;
+ }
+
+ public void skip(int count) {
+ if (count < 0) {
+ throw new IllegalArgumentException("count must not be negative");
+ }
+ try {
+ stream.skipBytes(count);
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to skip bytes", e);
+ }
+ }
+
+ public DataInputStream getUnDecodeStream() throws IOException {
+ readString();
+ return new DataInputStream(new ByteArrayInputStream(readStreamBytes()));
+ }
+
+ public DataInputStream getDecodeStream() throws IOException {
+ readString();
+ return new GzipDecoder(readStreamBytes()).stream;
+ }
+
+ /** Reads a named block and returns its uncompressed bytes. */
+ public byte[] getDecodeBytes() throws IOException {
+ readString();
+ return readStreamBytes();
+ }
+
+ public > T readEnum(Class enumClass) throws IOException {
+ int ordinal = readInt();
+ T[] constants = enumClass.getEnumConstants();
+ if (ordinal < 0 || ordinal >= constants.length) {
+ throw new IOException("invalid enum ordinal: " + ordinal);
+ }
+ return constants[ordinal];
+ }
+
+ public boolean readMark() throws IOException {
+ short mark = readShort();
+ if (mark != 12345) {
+ LOG.error("Failed to readMark: {} != 12345", mark);
+ return false;
+ }
+ return true;
+ }
+
+ /** Starts reading a nested block from the current stream. */
+ public void startBlock(boolean compressed) throws IOException {
+ DataInputStream parent = stream;
+ DataInputStream block = compressed ? getDecodeStream() : getUnDecodeStream();
+ blockQuere.addLast(parent);
+ stream = block;
+ }
+
+ /** Returns to the stream that contained the most recently opened block. */
+ public void endBlock() {
+ if (blockQuere.isEmpty()) {
+ throw new IllegalStateException("no open block");
+ }
+ stream = blockQuere.removeLast();
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/io/GameOutputStream.java b/src/main/java/cn/rukkit/network/io/GameOutputStream.java
new file mode 100644
index 0000000..98cf708
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/io/GameOutputStream.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.io;
+
+import cn.rukkit.network.core.packet.Packet;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.LinkedList;
+
+/** Data writer for the game's big-endian primitive and block format. */
+public class GameOutputStream {
+ public final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ public DataOutputStream stream;
+ public DataOutputStream currentStream;
+ public final LinkedList blockQuere = new LinkedList<>();
+
+ private final LinkedList parentStreams = new LinkedList<>();
+
+ public GameOutputStream() {
+ stream = new DataOutputStream(buffer);
+ currentStream = stream;
+ }
+
+ public Packet createPacket(int type) {
+ try {
+ while (!blockQuere.isEmpty()) {
+ endBlock();
+ }
+ stream.flush();
+ buffer.flush();
+ return new Packet(type, buffer.toByteArray());
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to create packet", e);
+ }
+ }
+
+ public void writeByte(int value) throws IOException {
+ stream.writeByte(value);
+ }
+
+ public void writeBoolean(boolean value) throws IOException {
+ stream.writeBoolean(value);
+ }
+
+ public void writeInt(int value) throws IOException {
+ stream.writeInt(value);
+ }
+
+ public void writeFloat(float value) throws IOException {
+ stream.writeFloat(value);
+ }
+
+ public void writeLong(long value) throws IOException {
+ stream.writeLong(value);
+ }
+
+ public void writeShort(short value) throws IOException {
+ stream.writeShort(value);
+ }
+
+ public void writeIsString(String value) throws IOException {
+ if (value == null || value.trim().isEmpty()) {
+ writeBoolean(false);
+ } else {
+ writeBoolean(true);
+ writeString(value);
+ }
+ }
+
+ public void writeString(String value) throws IOException {
+ stream.writeUTF(value);
+ }
+
+ public void write(byte[] value) throws IOException {
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ stream.write(value);
+ }
+
+ public GzipEncoder getEncodeStream(String key, boolean compressed) throws IOException {
+ GzipEncoder encoder = new GzipEncoder(compressed);
+ encoder.str = key;
+ return encoder;
+ }
+
+ public void writeFile(FileInputStream input) throws IOException {
+ if (input == null) {
+ throw new IllegalArgumentException("input must not be null");
+ }
+ ByteArrayOutputStream fileBytes = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int read;
+ while ((read = input.read(chunk)) != -1) {
+ fileBytes.write(chunk, 0, read);
+ }
+ byte[] bytes = fileBytes.toByteArray();
+ writeInt(bytes.length);
+ write(bytes);
+ }
+
+ /** Writes a separately-created named block to the current stream. */
+ public void flushEncodeData(GzipEncoder encoder) throws IOException {
+ if (encoder == null) {
+ throw new IllegalArgumentException("encoder must not be null");
+ }
+ encoder.flush();
+ stream.writeUTF(encoder.str);
+ stream.writeInt(encoder.buffer.size());
+ encoder.buffer.writeTo(stream);
+ }
+
+ public void writeEnum(Enum> value) throws IOException {
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ writeInt(value.ordinal());
+ }
+
+ /** Starts a named block. Its contents are isolated until {@link #endBlock()}. */
+ public void startBlock(String blockName, boolean compressed) throws IOException {
+ if (blockName == null) {
+ throw new IllegalArgumentException("blockName must not be null");
+ }
+ GzipEncoder encoder = getEncodeStream(blockName, compressed);
+ parentStreams.addLast(stream);
+ blockQuere.addLast(encoder);
+ currentStream = stream;
+ stream = encoder.stream;
+ }
+
+ /** Finishes the most recent block and appends it to its parent stream. */
+ public void endBlock() throws IOException {
+ if (blockQuere.isEmpty() || parentStreams.isEmpty()) {
+ throw new IllegalStateException("no open block");
+ }
+
+ GzipEncoder encoder = blockQuere.removeLast();
+ DataOutputStream parent = parentStreams.removeLast();
+ encoder.flush();
+ parent.writeUTF(encoder.str);
+ parent.writeInt(encoder.buffer.size());
+ encoder.buffer.writeTo(parent);
+ parent.flush();
+ stream = parent;
+ currentStream = parent;
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/io/GzipDecoder.java b/src/main/java/cn/rukkit/network/io/GzipDecoder.java
new file mode 100644
index 0000000..956d753
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/io/GzipDecoder.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.io;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.util.zip.GZIPInputStream;
+
+/** Reads a GZIP-compressed protocol block from memory. */
+public class GzipDecoder {
+ public final ByteArrayInputStream buffer;
+ public final DataInputStream stream;
+
+ public GzipDecoder(byte[] bytes) throws IOException {
+ if (bytes == null) {
+ throw new IllegalArgumentException("bytes must not be null");
+ }
+ buffer = new ByteArrayInputStream(bytes);
+ stream = new DataInputStream(new BufferedInputStream(new GZIPInputStream(buffer)));
+ }
+}
diff --git a/src/main/java/cn/rukkit/network/io/GzipEncoder.java b/src/main/java/cn/rukkit/network/io/GzipEncoder.java
new file mode 100644
index 0000000..8f8aad9
--- /dev/null
+++ b/src/main/java/cn/rukkit/network/io/GzipEncoder.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020-2022 RukkitDev Team and contributors.
+ *
+ * This project uses GNU Affero General Public License v3.0.You can find this license in the following link.
+ * 本项目使用 GNU Affero General Public License v3.0 许可证,你可以在下方链接查看:
+ *
+ * https://github.com/RukkitDev/Rukkit/blob/master/LICENSE
+ */
+
+package cn.rukkit.network.io;
+
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Collects a protocol block in memory, optionally applying GZIP compression.
+ * The public fields are retained for compatibility with the existing packet
+ * builders, which write directly to {@link #stream}.
+ */
+public class GzipEncoder {
+ public final GZIPOutputStream gzipStream;
+ public final BufferedOutputStream bufferedStream;
+ public final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ public final DataOutputStream stream;
+ public String str;
+
+ private boolean finished;
+
+ public GzipEncoder(boolean compressed) throws IOException {
+ OutputStream output;
+ if (compressed) {
+ gzipStream = new GZIPOutputStream(buffer);
+ bufferedStream = new BufferedOutputStream(gzipStream);
+ output = bufferedStream;
+ } else {
+ gzipStream = null;
+ bufferedStream = null;
+ output = buffer;
+ }
+ stream = new DataOutputStream(output);
+ }
+
+ /** Flushes all data and finishes the GZIP stream when compression is used. */
+ public void flush() throws IOException {
+ if (finished) {
+ return;
+ }
+ stream.flush();
+ if (bufferedStream != null) {
+ bufferedStream.flush();
+ }
+ if (gzipStream != null) {
+ gzipStream.finish();
+ }
+ finished = true;
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/core/packet/PacketCodecTest.java b/src/test/java/cn/rukkit/network/core/packet/PacketCodecTest.java
new file mode 100644
index 0000000..4815266
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/core/packet/PacketCodecTest.java
@@ -0,0 +1,97 @@
+package cn.rukkit.network.core.packet;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.EncoderException;
+import io.netty.handler.codec.TooLongFrameException;
+import org.junit.jupiter.api.Test;
+
+class PacketCodecTest {
+ @Test
+ void encoderWritesLengthTypeAndPayload() {
+ EmbeddedChannel channel = new EmbeddedChannel(new PacketEncoder(16));
+
+ assertTrue(channel.writeOutbound(new Packet(7, new byte[] {1, 2, 3})));
+ ByteBuf encoded = channel.readOutbound();
+ assertEquals(3, encoded.readInt());
+ assertEquals(7, encoded.readInt());
+ byte[] payload = new byte[3];
+ encoded.readBytes(payload);
+ assertArrayEquals(new byte[] {1, 2, 3}, payload);
+ assertFalse(encoded.isReadable());
+ encoded.release();
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ void defaultCodecDoesNotLimitPayloadLength() {
+ byte[] payload = new byte[8193];
+ EmbeddedChannel encoder = new EmbeddedChannel(new PacketEncoder());
+
+ assertTrue(encoder.writeOutbound(new Packet(7, payload)));
+ ByteBuf encoded = encoder.readOutbound();
+ assertEquals(payload.length, encoded.readInt());
+ encoded.skipBytes(Integer.BYTES);
+ encoded.release();
+ encoder.finishAndReleaseAll();
+
+ EmbeddedChannel decoder = new EmbeddedChannel(new PacketDecoder());
+ ByteBuf frame = Unpooled.buffer().writeInt(payload.length).writeInt(7).writeBytes(payload);
+ assertTrue(decoder.writeInbound(frame));
+ Packet decoded = decoder.readInbound();
+ assertEquals(payload.length, decoded.bytes.length);
+ decoder.finishAndReleaseAll();
+ }
+
+ @Test
+ void decoderHandlesFragmentedFrames() {
+ EmbeddedChannel channel = new EmbeddedChannel(new PacketDecoder(16));
+ ByteBuf first = Unpooled.buffer();
+ first.writeInt(4).writeInt(9).writeBytes(new byte[] {1, 2});
+
+ assertFalse(channel.writeInbound(first));
+ assertNull(channel.readInbound());
+
+ ByteBuf second = Unpooled.buffer().writeBytes(new byte[] {3, 4});
+ assertTrue(channel.writeInbound(second));
+ Packet decoded = channel.readInbound();
+ assertEquals(9, decoded.type);
+ assertArrayEquals(new byte[] {1, 2, 3, 4}, decoded.bytes);
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ void decoderRejectsNegativeLength() {
+ EmbeddedChannel channel = new EmbeddedChannel(new PacketDecoder(16));
+ ByteBuf frame = Unpooled.buffer().writeInt(-1).writeInt(9);
+
+ assertThrows(Exception.class, () -> channel.writeInbound(frame));
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ void decoderRejectsOversizedLengthBeforeAllocation() {
+ EmbeddedChannel channel = new EmbeddedChannel(new PacketDecoder(16));
+ ByteBuf frame = Unpooled.buffer().writeInt(17).writeInt(9);
+
+ assertThrows(TooLongFrameException.class, () -> channel.writeInbound(frame));
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ void encoderRejectsOversizedPayload() {
+ EmbeddedChannel channel = new EmbeddedChannel(new PacketEncoder(2));
+
+ assertThrows(EncoderException.class,
+ () -> channel.writeOutbound(new Packet(9, new byte[] {1, 2, 3})));
+ channel.finishAndReleaseAll();
+ }
+}
diff --git a/src/test/java/cn/rukkit/network/io/GameStreamTest.java b/src/test/java/cn/rukkit/network/io/GameStreamTest.java
new file mode 100644
index 0000000..29fd632
--- /dev/null
+++ b/src/test/java/cn/rukkit/network/io/GameStreamTest.java
@@ -0,0 +1,114 @@
+package cn.rukkit.network.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import cn.rukkit.network.core.packet.Packet;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class GameStreamTest {
+ private enum TestAction {
+ FIRST,
+ SECOND
+ }
+
+ @Test
+ void roundTripsPrimitiveValues() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.writeBoolean(true);
+ output.writeByte(3);
+ output.writeShort((short) 4);
+ output.writeInt(5);
+ output.writeLong(6L);
+ output.writeFloat(7.5f);
+ output.writeString("hello");
+ output.writeIsString("");
+ output.writeEnum(TestAction.SECOND);
+
+ Packet packet = output.createPacket(1);
+ GameInputStream input = new GameInputStream(packet);
+ assertTrue(input.readBoolean());
+ assertEquals(3, input.readByte());
+ assertEquals(4, input.readShort());
+ assertEquals(5, input.readInt());
+ assertEquals(6L, input.readLong());
+ assertEquals(7.5f, input.readFloat());
+ assertEquals("hello", input.readString());
+ assertEquals("", input.readIsString());
+ assertEquals(TestAction.SECOND, input.readEnum(TestAction.class));
+ }
+
+ @Test
+ void roundTripsUncompressedBlock() throws IOException {
+ assertBlockRoundTrip(false);
+ }
+
+ @Test
+ void roundTripsCompressedBlock() throws IOException {
+ assertBlockRoundTrip(true);
+ }
+
+ @Test
+ void roundTripsNestedBlocks() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.startBlock("outer", false);
+ output.writeInt(1);
+ output.startBlock("inner", true);
+ output.writeString("nested");
+ output.endBlock();
+ output.endBlock();
+
+ GameInputStream input = new GameInputStream(output.createPacket(1));
+ input.startBlock(false);
+ assertEquals(1, input.readInt());
+ input.startBlock(true);
+ assertEquals("nested", input.readString());
+ input.endBlock();
+ input.endBlock();
+ }
+
+ @Test
+ void flushesStandaloneCompressedBlock() throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ GzipEncoder encoder = output.getEncodeStream("payload", true);
+ encoder.stream.writeInt(42);
+ output.flushEncodeData(encoder);
+
+ GameInputStream input = new GameInputStream(output.createPacket(1));
+ input.startBlock(true);
+ assertEquals(42, input.readInt());
+ input.endBlock();
+ }
+
+ @Test
+ void rejectsInvalidBlockLength() throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ DataOutputStream data = new DataOutputStream(bytes);
+ data.writeUTF("bad");
+ data.writeInt(-1);
+ data.flush();
+
+ GameInputStream input = new GameInputStream(bytes.toByteArray());
+ assertThrows(IOException.class, () -> input.startBlock(false));
+ }
+
+ private static void assertBlockRoundTrip(boolean compressed) throws IOException {
+ GameOutputStream output = new GameOutputStream();
+ output.startBlock("block", compressed);
+ output.writeInt(42);
+ output.writeString("content");
+ output.endBlock();
+
+ GameInputStream input = new GameInputStream(output.createPacket(1));
+ input.startBlock(compressed);
+ assertEquals(42, input.readInt());
+ assertEquals("content", input.readString());
+ input.endBlock();
+ assertFalse(input.blockQuere.iterator().hasNext());
+ }
+}