From 2836edb5432e2ece6c96232deaaf212b59be4c42 Mon Sep 17 00:00:00 2001 From: wtbdev Date: Fri, 7 Aug 2026 13:50:47 +0800 Subject: [PATCH] feat(network): add isolated packet codec and stream layer --- .../cn/rukkit/network/core/packet/Packet.java | 42 ++++ .../network/core/packet/PacketDecoder.java | 74 +++++++ .../network/core/packet/PacketEncoder.java | 60 ++++++ .../network/core/packet/PacketType.java | 50 +++++ .../cn/rukkit/network/io/GameInputStream.java | 182 ++++++++++++++++++ .../rukkit/network/io/GameOutputStream.java | 157 +++++++++++++++ .../cn/rukkit/network/io/GzipDecoder.java | 30 +++ .../cn/rukkit/network/io/GzipEncoder.java | 61 ++++++ .../network/core/packet/PacketCodecTest.java | 97 ++++++++++ .../cn/rukkit/network/io/GameStreamTest.java | 114 +++++++++++ 10 files changed, 867 insertions(+) create mode 100644 src/main/java/cn/rukkit/network/core/packet/Packet.java create mode 100644 src/main/java/cn/rukkit/network/core/packet/PacketDecoder.java create mode 100644 src/main/java/cn/rukkit/network/core/packet/PacketEncoder.java create mode 100644 src/main/java/cn/rukkit/network/core/packet/PacketType.java create mode 100644 src/main/java/cn/rukkit/network/io/GameInputStream.java create mode 100644 src/main/java/cn/rukkit/network/io/GameOutputStream.java create mode 100644 src/main/java/cn/rukkit/network/io/GzipDecoder.java create mode 100644 src/main/java/cn/rukkit/network/io/GzipEncoder.java create mode 100644 src/test/java/cn/rukkit/network/core/packet/PacketCodecTest.java create mode 100644 src/test/java/cn/rukkit/network/io/GameStreamTest.java 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()); + } +}