diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..52d913562 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: astei diff --git a/api/build.gradle b/api/build.gradle index a7e2c2298..db1147901 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -76,13 +76,13 @@ javadoc { options.encoding = 'UTF-8' options.charSet = 'UTF-8' options.source = '8' -// options.links( -// 'https://www.slf4j.org/apidocs/', -// 'https://guava.dev/releases/30.0-jre/api/docs/', -// 'https://google.github.io/guice/api-docs/4.2/javadoc/', -// 'https://docs.oracle.com/javase/8/docs/api/', -// 'https://jd.adventure.kyori.net/api/4.0.0/' -// ) + options.links( + 'http://www.slf4j.org/apidocs/', + 'https://google.github.io/guava/releases/30.0-jre/api/docs/', + 'https://google.github.io/guice/api-docs/4.2/javadoc/', + 'https://docs.oracle.com/javase/8/docs/api/', + 'https://jd.adventure.kyori.net/api/4.7.0/' + ) // Disable the crazy super-strict doclint tool in Java 8 options.addStringOption('Xdoclint:none', '-quiet') diff --git a/api/src/main/java/com/velocitypowered/api/proxy/connection/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/connection/Player.java index b7586c781..9aa7daf70 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/connection/Player.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/connection/Player.java @@ -8,7 +8,8 @@ package com.velocitypowered.api.proxy.connection; import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.event.player.PlayerResourcePackStatusEventImpl; +import com.velocitypowered.api.event.player.PlayerResourcePackStatusEvent; +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; import com.velocitypowered.api.proxy.messages.ChannelMessageSink; import com.velocitypowered.api.proxy.messages.ChannelMessageSource; import com.velocitypowered.api.proxy.player.ClientSettings; @@ -123,7 +124,7 @@ public interface Player extends CommandSource, Identified, InboundConnection, /** * Sends the specified resource pack from {@code url} to the user. If at all possible, send the * resource pack using {@link #sendResourcePack(String, byte[])}. To monitor the status of the - * sent resource pack, subscribe to {@link PlayerResourcePackStatusEventImpl}. + * sent resource pack, subscribe to {@link PlayerResourcePackStatusEvent}. * * @param url the URL for the resource pack */ @@ -132,10 +133,22 @@ public interface Player extends CommandSource, Identified, InboundConnection, /** * Sends the specified resource pack from {@code url} to the user, using the specified 20-byte * SHA-1 hash. To monitor the status of the sent resource pack, subscribe to - * {@link PlayerResourcePackStatusEventImpl}. + * {@link PlayerResourcePackStatusEvent}. * * @param url the URL for the resource pack * @param hash the SHA-1 hash value for the resource pack */ void sendResourcePack(String url, byte[] hash); + + /** + * Note that this method does not send a plugin message to the server the player + * is connected to. You should only use this method if you are trying to communicate + * with a mod that is installed on the player's client. To send a plugin message to the server + * from the player, you should use the equivalent method on the instance returned by + * {@link #getCurrentServer()}. + * + * @inheritDoc + */ + @Override + boolean sendPluginMessage(ChannelIdentifier identifier, byte[] data); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java index 787992fe0..f988f49e5 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -42,7 +42,7 @@ import com.velocitypowered.proxy.network.packet.clientbound.ClientboundSetCompre import com.velocitypowered.proxy.network.pipeline.MinecraftCipherDecoder; import com.velocitypowered.proxy.network.pipeline.MinecraftCipherEncoder; import com.velocitypowered.proxy.network.pipeline.MinecraftCompressDecoder; -import com.velocitypowered.proxy.network.pipeline.MinecraftCompressEncoder; +import com.velocitypowered.proxy.network.pipeline.MinecraftCompressorAndLengthEncoder; import com.velocitypowered.proxy.network.pipeline.MinecraftDecoder; import com.velocitypowered.proxy.network.pipeline.MinecraftEncoder; import com.velocitypowered.proxy.util.except.QuietDecoderException; @@ -408,8 +408,8 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { } else { MinecraftCompressDecoder decoder = (MinecraftCompressDecoder) channel.pipeline() .get(COMPRESSION_DECODER); - MinecraftCompressEncoder encoder = (MinecraftCompressEncoder) channel.pipeline() - .get(COMPRESSION_ENCODER); + MinecraftCompressorAndLengthEncoder encoder = + (MinecraftCompressorAndLengthEncoder) channel.pipeline().get(COMPRESSION_ENCODER); if (decoder != null && encoder != null) { decoder.setThreshold(threshold); encoder.setThreshold(threshold); @@ -417,9 +417,10 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { int level = server.configuration().getCompressionLevel(); VelocityCompressor compressor = Natives.compress.get().create(level); - encoder = new MinecraftCompressEncoder(threshold, compressor); + encoder = new MinecraftCompressorAndLengthEncoder(threshold, compressor); decoder = new MinecraftCompressDecoder(threshold, compressor); + channel.pipeline().remove(FRAME_ENCODER); channel.pipeline().addBefore(MINECRAFT_DECODER, COMPRESSION_DECODER, decoder); channel.pipeline().addBefore(MINECRAFT_ENCODER, COMPRESSION_ENCODER, encoder); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index dc3184832..944500e5f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -59,12 +59,16 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { private static final Logger logger = LogManager.getLogger(BackendPlaySessionHandler.class); private static final boolean BACKPRESSURE_LOG = Boolean .getBoolean("velocity.log-server-backpressure"); + private static final int MAXIMUM_PACKETS_TO_FLUSH = Integer + .getInteger("velocity.max-packets-per-flush", 8192); + private final VelocityServer server; private final VelocityServerConnection serverConn; private final ClientPlaySessionHandler playerSessionHandler; private final MinecraftConnection playerConnection; private final BungeeCordMessageResponder bungeecordMessageResponder; private boolean exceptionTriggered = false; + private int packetsFlushed; BackendPlaySessionHandler(VelocityServer server, VelocityServerConnection serverConn) { this.server = server; @@ -274,16 +278,25 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { ((AbstractPluginMessagePacket) packet).retain(); } playerConnection.delayedWrite(packet); + if (++packetsFlushed >= MAXIMUM_PACKETS_TO_FLUSH) { + playerConnection.flush(); + packetsFlushed = 0; + } } @Override public void handleUnknown(ByteBuf buf) { playerConnection.delayedWrite(buf.retain()); + if (++packetsFlushed >= MAXIMUM_PACKETS_TO_FLUSH) { + playerConnection.flush(); + packetsFlushed = 0; + } } @Override public void readCompleted() { playerConnection.flush(); + packetsFlushed = 0; } @Override diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/ProtocolUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ProtocolUtils.java index 8be7316fa..32cb1bf34 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ProtocolUtils.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ProtocolUtils.java @@ -59,7 +59,15 @@ public enum ProtocolUtils { private static final int DEFAULT_MAX_STRING_SIZE = 65536; // 64KiB private static final QuietDecoderException BAD_VARINT_CACHED = - new QuietDecoderException("Bad varint decoded"); + new QuietDecoderException("Bad VarInt decoded"); + private static final int[] VARINT_EXACT_BYTE_LENGTHS = new int[33]; + + static { + for (int i = 0; i <= 32; ++i) { + VARINT_EXACT_BYTE_LENGTHS[i] = (int) Math.ceil((31d - (i - 1)) / 7d); + } + VARINT_EXACT_BYTE_LENGTHS[32] = 1; // Special case for the number 0. + } /** * Reads a Minecraft-style VarInt from the specified {@code buf}. @@ -69,7 +77,7 @@ public enum ProtocolUtils { public static int readVarInt(ByteBuf buf) { int read = readVarIntSafely(buf); if (read == Integer.MIN_VALUE) { - throw MinecraftDecoder.DEBUG ? new CorruptedFrameException("Bad varint decoded") + throw MinecraftDecoder.DEBUG ? new CorruptedFrameException("Bad VarInt decoded") : BAD_VARINT_CACHED; } return read; @@ -95,23 +103,67 @@ public enum ProtocolUtils { return Integer.MIN_VALUE; } + /** + * Returns the exact byte size of {@code value} if it were encoded as a VarInt. + * @param value the value to encode + * @return the byte size of {@code value} if encoded as a VarInt + */ + public static int varIntBytes(int value) { + return VARINT_EXACT_BYTE_LENGTHS[Integer.numberOfLeadingZeros(value)]; + } + /** * Writes a Minecraft-style VarInt to the specified {@code buf}. * @param buf the buffer to read from * @param value the integer to write */ public static void writeVarInt(ByteBuf buf, int value) { - while (true) { - if ((value & 0xFFFFFF80) == 0) { - buf.writeByte(value); - return; - } - - buf.writeByte(value & 0x7F | 0x80); - value >>>= 7; + // Peel the one and two byte count cases explicitly as they are the most common VarInt sizes + // that the proxy will write, to improve inlining. + if ((value & (0xFFFFFFFF << 7)) == 0) { + buf.writeByte(value); + } else if ((value & (0xFFFFFFFF << 14)) == 0) { + int w = (value & 0x7F | 0x80) << 8 | (value >>> 7); + buf.writeShort(w); + } else { + writeVarIntFull(buf, value); } } + private static void writeVarIntFull(ByteBuf buf, int value) { + // See https://steinborn.me/posts/performance/how-fast-can-you-write-a-varint/ + if ((value & (0xFFFFFFFF << 7)) == 0) { + buf.writeByte(value); + } else if ((value & (0xFFFFFFFF << 14)) == 0) { + int w = (value & 0x7F | 0x80) << 8 | (value >>> 7); + buf.writeShort(w); + } else if ((value & (0xFFFFFFFF << 21)) == 0) { + int w = (value & 0x7F | 0x80) << 16 | ((value >>> 7) & 0x7F | 0x80) << 8 | (value >>> 14); + buf.writeMedium(w); + } else if ((value & (0xFFFFFFFF << 28)) == 0) { + int w = (value & 0x7F | 0x80) << 24 | (((value >>> 7) & 0x7F | 0x80) << 16) + | ((value >>> 14) & 0x7F | 0x80) << 8 | (value >>> 21); + buf.writeInt(w); + } else { + int w = (value & 0x7F | 0x80) << 24 | ((value >>> 7) & 0x7F | 0x80) << 16 + | ((value >>> 14) & 0x7F | 0x80) << 8 | ((value >>> 21) & 0x7F | 0x80); + buf.writeInt(w); + buf.writeByte(value >>> 28); + } + } + + /** + * Writes the specified {@code value} as a 21-bit Minecraft VarInt to the specified {@code buf}. + * The upper 11 bits will be discarded. + * @param buf the buffer to read from + * @param value the integer to write + */ + public static void write21BitVarInt(ByteBuf buf, int value) { + // See https://steinborn.me/posts/performance/how-fast-can-you-write-a-varint/ + int w = (value & 0x7F | 0x80) << 16 | ((value >>> 7) & 0x7F | 0x80) << 8 | (value >>> 14); + buf.writeMedium(w); + } + public static String readString(ByteBuf buf) { return readString(buf, DEFAULT_MAX_STRING_SIZE); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressEncoder.java b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressEncoder.java deleted file mode 100644 index b482b7320..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressEncoder.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (C) 2018 Velocity Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.velocitypowered.proxy.network.pipeline; - -import com.velocitypowered.natives.compression.VelocityCompressor; -import com.velocitypowered.natives.util.MoreByteBufUtils; -import com.velocitypowered.proxy.network.ProtocolUtils; -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.MessageToByteEncoder; - -public class MinecraftCompressEncoder extends MessageToByteEncoder { - - private int threshold; - private final VelocityCompressor compressor; - - public MinecraftCompressEncoder(int threshold, VelocityCompressor compressor) { - this.threshold = threshold; - this.compressor = compressor; - } - - @Override - protected void encode(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) throws Exception { - int uncompressed = msg.readableBytes(); - if (uncompressed < threshold) { - // Under the threshold, there is nothing to do. - ProtocolUtils.writeVarInt(out, 0); - out.writeBytes(msg); - } else { - ProtocolUtils.writeVarInt(out, uncompressed); - ByteBuf compatibleIn = MoreByteBufUtils.ensureCompatible(ctx.alloc(), compressor, msg); - try { - compressor.deflate(compatibleIn, out); - } finally { - compatibleIn.release(); - } - } - } - - @Override - protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, ByteBuf msg, boolean preferDirect) - throws Exception { - // We allocate bytes to be compressed plus 1 byte. This covers two cases: - // - // - Compression - // According to https://github.com/ebiggers/libdeflate/blob/master/libdeflate.h#L103, - // if the data compresses well (and we do not have some pathological case) then the maximum - // size the compressed size will ever be is the input size minus one. - // - Uncompressed - // This is fairly obvious - we will then have one more than the uncompressed size. - int initialBufferSize = msg.readableBytes() + 1; - return MoreByteBufUtils.preferredBuffer(ctx.alloc(), compressor, initialBufferSize); - } - - @Override - public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { - compressor.close(); - } - - public void setThreshold(int threshold) { - this.threshold = threshold; - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressorAndLengthEncoder.java b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressorAndLengthEncoder.java new file mode 100644 index 000000000..28d005161 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftCompressorAndLengthEncoder.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2018 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.protocol.netty; + +import static com.velocitypowered.proxy.protocol.netty.MinecraftVarintLengthEncoder.IS_JAVA_CIPHER; + +import com.velocitypowered.natives.compression.VelocityCompressor; +import com.velocitypowered.natives.util.MoreByteBufUtils; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToByteEncoder; +import java.util.zip.DataFormatException; + +public class MinecraftCompressorAndLengthEncoder extends MessageToByteEncoder { + + private static final boolean MUST_USE_SAFE_AND_SLOW_COMPRESSION_HANDLING = + Boolean.getBoolean("velocity.increased-compression-cap"); + + private int threshold; + private final VelocityCompressor compressor; + + public MinecraftCompressorAndLengthEncoder(int threshold, VelocityCompressor compressor) { + this.threshold = threshold; + this.compressor = compressor; + } + + @Override + protected void encode(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) throws Exception { + int uncompressed = msg.readableBytes(); + if (uncompressed < threshold) { + // Under the threshold, there is nothing to do. + ProtocolUtils.writeVarInt(out, uncompressed + 1); + ProtocolUtils.writeVarInt(out, 0); + out.writeBytes(msg); + } else { + if (MUST_USE_SAFE_AND_SLOW_COMPRESSION_HANDLING) { + handleCompressedSafe(ctx, msg, out); + } else { + handleCompressedFast(ctx, msg, out); + } + } + } + + private void handleCompressedFast(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) + throws DataFormatException { + int uncompressed = msg.readableBytes(); + + ProtocolUtils.write21BitVarInt(out, 0); // Dummy packet length + ProtocolUtils.writeVarInt(out, uncompressed); + ByteBuf compatibleIn = MoreByteBufUtils.ensureCompatible(ctx.alloc(), compressor, msg); + + int startCompressed = out.writerIndex(); + try { + compressor.deflate(compatibleIn, out); + } finally { + compatibleIn.release(); + } + int compressedLength = out.writerIndex() - startCompressed; + if (compressedLength >= 1 << 21) { + throw new DataFormatException("The server sent a very large (over 2MiB compressed) packet. " + + "Please restart Velocity with the JVM flag -Dvelocity.increased-compression-cap=true " + + "to fix this issue."); + } + + int writerIndex = out.writerIndex(); + int packetLength = out.readableBytes() - 3; + out.writerIndex(0); + ProtocolUtils.write21BitVarInt(out, packetLength); // Rewrite packet length + out.writerIndex(writerIndex); + } + + private void handleCompressedSafe(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) + throws DataFormatException { + int uncompressed = msg.readableBytes(); + ByteBuf tmpBuf = MoreByteBufUtils.preferredBuffer(ctx.alloc(), compressor, uncompressed - 1); + try { + ProtocolUtils.writeVarInt(tmpBuf, uncompressed); + ByteBuf compatibleIn = MoreByteBufUtils.ensureCompatible(ctx.alloc(), compressor, msg); + try { + compressor.deflate(compatibleIn, tmpBuf); + } finally { + compatibleIn.release(); + } + + ProtocolUtils.writeVarInt(out, tmpBuf.readableBytes()); + out.writeBytes(tmpBuf); + } finally { + tmpBuf.release(); + } + } + + @Override + protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, ByteBuf msg, boolean preferDirect) + throws Exception { + int uncompressed = msg.readableBytes(); + if (uncompressed < threshold) { + int finalBufferSize = uncompressed + 1; + finalBufferSize += ProtocolUtils.varIntBytes(finalBufferSize); + return IS_JAVA_CIPHER + ? ctx.alloc().heapBuffer(finalBufferSize) + : ctx.alloc().directBuffer(finalBufferSize); + } + + // (maximum data length after compression) + packet length varint + uncompressed data varint + int initialBufferSize = (uncompressed - 1) + 3 + ProtocolUtils.varIntBytes(uncompressed); + return MoreByteBufUtils.preferredBuffer(ctx.alloc(), compressor, initialBufferSize); + } + + @Override + public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { + compressor.close(); + } + + public void setThreshold(int threshold) { + this.threshold = threshold; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftDecoder.java index fc568602b..6ef4df737 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftDecoder.java @@ -63,7 +63,7 @@ public class MinecraftDecoder extends ChannelInboundHandlerAdapter { } private void tryDecode(ChannelHandlerContext ctx, ByteBuf buf) throws Exception { - if (!ctx.channel().isActive()) { + if (!ctx.channel().isActive() || !buf.isReadable()) { buf.release(); return; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftVarintLengthEncoder.java b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftVarintLengthEncoder.java index cfd66f505..91645e9ea 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftVarintLengthEncoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/pipeline/MinecraftVarintLengthEncoder.java @@ -29,7 +29,7 @@ import io.netty.handler.codec.MessageToByteEncoder; public class MinecraftVarintLengthEncoder extends MessageToByteEncoder { public static final MinecraftVarintLengthEncoder INSTANCE = new MinecraftVarintLengthEncoder(); - private static final boolean IS_JAVA_CIPHER = Natives.cipher.get() == JavaVelocityCipher.FACTORY; + public static final boolean IS_JAVA_CIPHER = Natives.cipher.get() == JavaVelocityCipher.FACTORY; private MinecraftVarintLengthEncoder() { } @@ -43,7 +43,8 @@ public class MinecraftVarintLengthEncoder extends MessageToByteEncoder @Override protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, ByteBuf msg, boolean preferDirect) throws Exception { - int anticipatedRequiredCapacity = 5 + msg.readableBytes(); + int anticipatedRequiredCapacity = ProtocolUtils.varIntBytes(msg.readableBytes()) + + msg.readableBytes(); return IS_JAVA_CIPHER ? ctx.alloc().heapBuffer(anticipatedRequiredCapacity) : ctx.alloc().directBuffer(anticipatedRequiredCapacity); diff --git a/proxy/src/test/java/com/velocitypowered/proxy/protocol/ProtocolUtilsTest.java b/proxy/src/test/java/com/velocitypowered/proxy/protocol/ProtocolUtilsTest.java new file mode 100644 index 000000000..04b2f3193 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/protocol/ProtocolUtilsTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2018 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.protocol; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; + +public class ProtocolUtilsTest { + + @Test + void negativeVarIntBytes() { + assertEquals(5, ProtocolUtils.varIntBytes(-1)); + assertEquals(5, ProtocolUtils.varIntBytes(Integer.MIN_VALUE)); + } + + @Test + void zeroVarIntBytes() { + assertEquals(1, ProtocolUtils.varIntBytes(0)); + assertEquals(1, ProtocolUtils.varIntBytes(1)); + } + + @Test + void ensureConsistencyAcrossNumberBits() { + for (int i = 0; i <= 31; i++) { + int number = (1 << i) - 1; + assertEquals(conventionalWrittenBytes(number), ProtocolUtils.varIntBytes(number), + "mismatch with " + i + "-bit number"); + } + } + + @Test + void testPositiveOld() { + ByteBuf buf = Unpooled.buffer(5); + for (int i = 0; i >= 0; i += 127) { + writeReadTestOld(buf, i); + } + } + + @Test + void testNegativeOld() { + ByteBuf buf = Unpooled.buffer(5); + for (int i = 0; i <= 0; i -= 127) { + writeReadTestOld(buf, i); + } + } + + private void writeReadTestOld(ByteBuf buf, int test) { + buf.clear(); + writeVarIntOld(buf, test); + assertEquals(test, ProtocolUtils.readVarIntSafely(buf)); + } + + @Test + void test3Bytes() { + ByteBuf buf = Unpooled.buffer(5); + for (int i = 0; i < 2097152; i += 31) { + writeReadTest3Bytes(buf, i); + } + } + + private void writeReadTest3Bytes(ByteBuf buf, int test) { + buf.clear(); + ProtocolUtils.write21BitVarInt(buf, test); + assertEquals(test, ProtocolUtils.readVarInt(buf)); + } + + @Test + void testBytesWrittenAtBitBoundaries() { + ByteBuf varintNew = Unpooled.buffer(5); + ByteBuf varintOld = Unpooled.buffer(5); + + long bytesNew = 0; + long bytesOld = 0; + for (int bit = 0; bit <= 31; bit++) { + int i = (1 << bit) - 1; + + writeVarIntOld(varintOld, i); + ProtocolUtils.writeVarInt(varintNew, i); + assertArrayEquals(ByteBufUtil.getBytes(varintOld), ByteBufUtil.getBytes(varintNew), + "Encoding of " + i + " was invalid"); + + assertEquals(i, oldReadVarIntSafely(varintNew)); + assertEquals(i, ProtocolUtils.readVarIntSafely(varintOld)); + + varintNew.clear(); + varintOld.clear(); + } + assertEquals(bytesNew, bytesOld, "byte sizes differ"); + } + + @Test + void testBytesWritten() { + ByteBuf varintNew = Unpooled.buffer(5); + ByteBuf varintOld = Unpooled.buffer(5); + + long bytesNew = 0; + long bytesOld = 0; + for (int i = 0; i <= 1_000_000; i++) { + ProtocolUtils.writeVarInt(varintNew, i); + writeVarIntOld(varintOld, i); + bytesNew += varintNew.readableBytes(); + bytesOld += varintOld.readableBytes(); + varintNew.clear(); + varintOld.clear(); + } + assertEquals(bytesNew, bytesOld, "byte sizes differ"); + } + + private static int oldReadVarIntSafely(ByteBuf buf) { + int i = 0; + int maxRead = Math.min(5, buf.readableBytes()); + for (int j = 0; j < maxRead; j++) { + int k = buf.readByte(); + i |= (k & 0x7F) << j * 7; + if ((k & 0x80) != 128) { + return i; + } + } + return Integer.MIN_VALUE; + } + + private void writeVarIntOld(ByteBuf buf, int value) { + while (true) { + if ((value & 0xFFFFFF80) == 0) { + buf.writeByte(value); + return; + } + + buf.writeByte(value & 0x7F | 0x80); + value >>>= 7; + } + } + + private int conventionalWrittenBytes(int value) { + int wouldBeWritten = 0; + while (true) { + if ((value & ~0x7FL) == 0) { + wouldBeWritten++; + return wouldBeWritten; + } else { + wouldBeWritten++; + value >>>= 7; + } + } + } +}