Merge remote-tracking branch 'upstream/dev/4.0.0'

# Conflicts:
#	proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java
This commit is contained in:
yoyosource
2026-08-24 17:03:57 +02:00
39 changed files with 600 additions and 133 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ java {
}
dependencies {
compileOnlyApi(libs.jspecify)
api(libs.jspecify)
api(libs.gson)
api(libs.guava)
@@ -97,6 +97,16 @@ public class PluginAnnotationProcessor extends AbstractProcessor {
}
}
for (String provided : plugin.provides()) {
if (!SerializedPluginDescription.ID_PATTERN.matcher(provided).matches()) {
environment.getMessager().printMessage(Diagnostic.Kind.ERROR,
"Invalid provided ID '" + provided + "' for plugin " + qualifiedName
+ ". IDs must start alphabetically, have lowercase alphanumeric characters, and "
+ "can contain dashes or underscores.");
return false;
}
}
// All good, generate the velocity-plugin.json.
SerializedPluginDescription description = SerializedPluginDescription
.from(plugin, qualifiedName.toString());
@@ -35,11 +35,12 @@ public final class SerializedPluginDescription {
private final @Nullable String url;
private final @Nullable List<String> authors;
private final @Nullable List<Dependency> dependencies;
private final @Nullable List<String> provides;
private final String main;
private SerializedPluginDescription(String id, String name, String version, String description,
String url,
List<String> authors, List<Dependency> dependencies, String main) {
List<String> authors, List<Dependency> dependencies, List<String> provides, String main) {
Preconditions.checkNotNull(id, "id");
Preconditions.checkArgument(ID_PATTERN.matcher(id).matches(), "id is not valid");
this.id = id;
@@ -50,6 +51,7 @@ public final class SerializedPluginDescription {
this.authors = authors == null || authors.isEmpty() ? ImmutableList.of() : authors;
this.dependencies =
dependencies == null || dependencies.isEmpty() ? ImmutableList.of() : dependencies;
this.provides = provides == null || provides.isEmpty() ? ImmutableList.of() : provides;
this.main = Preconditions.checkNotNull(main, "main");
}
@@ -61,7 +63,9 @@ public final class SerializedPluginDescription {
return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(),
plugin.description(), plugin.url(),
Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty())
.collect(Collectors.toList()), dependencies, qualifiedName);
.collect(Collectors.toList()), dependencies,
Arrays.stream(plugin.provides()).filter(provided -> !provided.isEmpty())
.collect(Collectors.toList()), qualifiedName);
}
public String getId() {
@@ -92,6 +96,10 @@ public final class SerializedPluginDescription {
return dependencies == null ? ImmutableList.of() : dependencies;
}
public List<String> getProvides() {
return provides == null ? ImmutableList.of() : provides;
}
public String getMain() {
return main;
}
@@ -112,12 +120,13 @@ public final class SerializedPluginDescription {
&& Objects.equals(url, that.url)
&& Objects.equals(authors, that.authors)
&& Objects.equals(dependencies, that.dependencies)
&& Objects.equals(provides, that.provides)
&& Objects.equals(main, that.main);
}
@Override
public int hashCode() {
return Objects.hash(id, name, version, description, url, authors, dependencies);
return Objects.hash(id, name, version, description, url, authors, dependencies, provides);
}
@Override
@@ -130,6 +139,7 @@ public final class SerializedPluginDescription {
+ ", url='" + url + '\''
+ ", authors=" + authors
+ ", dependencies=" + dependencies
+ ", provides=" + provides
+ ", main='" + main + '\''
+ '}';
}
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2018-2026 Velocity Contributors
*
* The Velocity API is licensed under the terms of the MIT License. For more details,
* reference the LICENSE file in the api top-level directory.
*/
package com.velocitypowered.api.event.player;
import com.google.common.annotations.Beta;
import com.google.common.base.Preconditions;
import com.velocitypowered.api.proxy.Player;
/**
* Called when a player is marked as loaded by the client.
*
* <p>This event is fired once per {@link com.velocitypowered.api.proxy.ServerConnection}
* when the player explicitly notifies the server after loading the world (closing the downloading terrain screen)
*
* @implNote Unlike Paper this event will <u>not</u> fire due to a timeout nor respawning.
* Though plugins can implement a timeout by scheduling a task in {@link ServerPostConnectEvent}
* and checking {@link com.velocitypowered.api.proxy.ServerConnection#isClientLoaded()}.
* @sinceMinecraft 1.21.4
* @since 4.1.0
*/
@Beta
public final class PlayerClientLoadedWorldEvent {
private final Player player;
public PlayerClientLoadedWorldEvent(Player player) {
this.player = Preconditions.checkNotNull(player, "player");
}
public Player getPlayer() {
return player;
}
@Override
public String toString() {
return "PlayerClientLoadedWorldEvent{"
+ "player=" + player
+ '}';
}
}
@@ -72,4 +72,12 @@ public @interface Plugin {
* @return the plugin dependencies
*/
Dependency[] dependencies() default {};
/**
* The plugin IDs this plugin "provides" for. Each ID must match
* {@link SerializedPluginDescription#ID_PATTERN_STRING}.
*
* @return the provided IDs
*/
String[] provides() default {};
}
@@ -100,6 +100,16 @@ public interface PluginDescription {
return Optional.empty();
}
/**
* Gets a {@link Collection} of the provided IDs of the {@link Plugin} within this container.
*
* @return the provided plugins IDs, can be empty
* @see Plugin#provides()
*/
default Collection<String> getProvidedIds() {
return ImmutableSet.of();
}
/**
* Returns the source the plugin was loaded from.
*
@@ -7,6 +7,7 @@
package com.velocitypowered.api.proxy;
import com.google.common.annotations.Beta;
import com.velocitypowered.api.proxy.messages.ChannelMessageSink;
import com.velocitypowered.api.proxy.messages.ChannelMessageSource;
import com.velocitypowered.api.proxy.server.RegisteredServer;
@@ -40,6 +41,17 @@ public interface ServerConnection extends ChannelMessageSource, ChannelMessageSi
*/
ServerInfo getServerInfo();
/**
* Returns whether the client notified this connection of having loaded the world.
*
* @return true if the client has loaded the world
* @implNote This is purely client-dependent; see {@link com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent}.
* @sinceMinecraft 1.21.4
* @since 4.1.0
*/
@Beta
boolean isClientLoaded();
/**
* Returns the player that this connection is associated with.
*
+1 -1
View File
@@ -1,2 +1,2 @@
group=com.velocitypowered
version=4.0.0-SNAPSHOT
version=4.1.0-SNAPSHOT
+16 -16
View File
@@ -2,24 +2,24 @@
configurate3 = "3.7.3"
configurate4 = "4.2.0"
flare = "2.0.1"
log4j = "2.26.0"
log4j = "2.26.1"
netty = "4.2.16.Final"
[plugins]
fill = "io.papermc.fill.gradle:1.0.12"
shadow = "com.gradleup.shadow:9.5.1"
spotless = "com.diffplug.spotless:8.2.0"
shadow = "com.gradleup.shadow:9.6.1"
spotless = "com.diffplug.spotless:8.9.0"
[libraries]
adventure-bom = "net.kyori:adventure-bom:5.2.0"
adventure-text-serializer-json-legacy-impl = "net.kyori:adventure-text-serializer-json-legacy-impl:5.2.0"
asm = "org.ow2.asm:asm:9.9.1"
asm = "org.ow2.asm:asm:9.10.1"
auto-service = "com.google.auto.service:auto-service:1.1.1"
auto-service-annotations = "com.google.auto.service:auto-service-annotations:1.1.1"
brigadier = "com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT"
bstats = "org.bstats:bstats-base:3.1.0"
caffeine = "com.github.ben-manes.caffeine:caffeine:3.2.3"
checker-qual = "org.checkerframework:checker-qual:3.53.0"
bstats = "org.bstats:bstats-base:3.2.1"
caffeine = "com.github.ben-manes.caffeine:caffeine:3.2.4"
checker-qual = "org.checkerframework:checker-qual:4.2.1"
checkstyle = "com.puppycrawl.tools:checkstyle:10.9.3"
completablefutures = "com.spotify:completable-futures:0.3.6"
configurate3-hocon = { module = "org.spongepowered:configurate-hocon", version.ref = "configurate3" }
@@ -29,24 +29,24 @@ configurate4-hocon = { module = "org.spongepowered:configurate-hocon", version.r
configurate4-yaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate4" }
configurate4-gson = { module = "org.spongepowered:configurate-gson", version.ref = "configurate4" }
disruptor = "com.lmax:disruptor:4.0.0"
fastutil = "it.unimi.dsi:fastutil:8.5.18"
fastutil = "it.unimi.dsi:fastutil:8.5.19"
flare-core = { module = "space.vectrix.flare:flare", version.ref = "flare" }
flare-fastutil = { module = "space.vectrix.flare:flare-fastutil", version.ref = "flare" }
jline = "org.jline:jline-terminal-ffm:4.3.1"
jopt = "net.sf.jopt-simple:jopt-simple:5.0.4"
junit = "org.junit.jupiter:junit-jupiter:6.0.3"
jspecify = "org.jspecify:jspecify:1.0.0"
junit = "org.junit.jupiter:junit-jupiter:6.1.2"
jspecify = "org.jspecify:jspecify:1.0.1"
kyori-ansi = "net.kyori:ansi:1.1.1"
guava = "com.google.guava:guava:33.6.0-jre"
gson = "com.google.code.gson:gson:2.14.0"
guice = "com.google.inject:guice:7.0.0"
lmbda = "org.lanternpowered:lmbda:2.0.0"
lmbda = "org.lanternpowered:lmbda:3.0.0"
log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" }
log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" }
log4j-slf4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" }
log4j-iostreams = { module = "org.apache.logging.log4j:log4j-iostreams", version.ref = "log4j" }
log4j-jul = { module = "org.apache.logging.log4j:log4j-jul", version.ref = "log4j" }
mockito = "org.mockito:mockito-core:5.22.0"
mockito = "org.mockito:mockito-core:5.23.0"
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
netty-codec-haproxy = { module = "io.netty:netty-codec-haproxy", version.ref = "netty" }
netty-codec-http = { module = "io.netty:netty-codec-http", version.ref = "netty" }
@@ -54,10 +54,10 @@ netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" }
netty-transport-native-epoll = { module = "io.netty:netty-transport-native-epoll", version.ref = "netty" }
netty-transport-native-kqueue = { module = "io.netty:netty-transport-native-kqueue", version.ref = "netty" }
netty-transport-native-iouring = { module = "io.netty:netty-transport-native-io_uring", version.ref = "netty" }
nightconfig = "com.electronwill.night-config:toml:3.8.3"
slf4j = "org.slf4j:slf4j-api:2.0.17"
snakeyaml = "org.yaml:snakeyaml:2.5"
spotbugs-annotations = "com.github.spotbugs:spotbugs-annotations:4.9.8"
nightconfig = "com.electronwill.night-config:toml:3.9.0"
slf4j = "org.slf4j:slf4j-api:2.0.18"
snakeyaml = "org.yaml:snakeyaml:2.6"
spotbugs-annotations = "com.github.spotbugs:spotbugs-annotations:4.10.3"
terminalconsoleappender = "net.minecrell:terminalconsoleappender:1.3.0"
[bundles]
@@ -5,29 +5,44 @@ set -e
# make sure we're in the correct directory - the top-level `native` directory
cd "$(dirname "$0")/.." || exit 1
# Use docker by default, falling back to podman. Set CONTAINER_ENGINE to pick one explicitly.
if [ -z "$CONTAINER_ENGINE" ]; then
if command -v docker > /dev/null 2>&1; then
CONTAINER_ENGINE=docker
elif command -v podman > /dev/null 2>&1; then
CONTAINER_ENGINE=podman
else
echo "Neither docker nor podman was found on PATH." >&2
exit 1
fi
fi
echo "Using container engine: $CONTAINER_ENGINE"
ARCHS=(x86_64 aarch64)
BASE_DOCKERFILE_VARIANTS=(ubuntu-focal ubuntu-jammy alpine)
COMPRESSION_VARIANTS=(ubuntu-focal alpine)
# Build one image per (variant, arch). Passing several --platform flags to a single tagged build
# does not produce a multi-arch tag - only the last architecture keeps the tag, and the run below
# then sees a platform mismatch, treats the image as missing and tries to pull it from a registry.
for variant in "${BASE_DOCKERFILE_VARIANTS[@]}"; do
docker_platforms=""
for arch in "${ARCHS[@]}"; do
docker_platforms="$docker_platforms --platform linux/${arch}"
echo "Building base build image for $variant on $arch..."
$CONTAINER_ENGINE build -t velocity-native-build:$variant-$arch --platform linux/${arch} \
-f build-support/$variant.Dockerfile .
done
echo "Building base build image for $variant..."
docker build -t velocity-native-build:$variant $docker_platforms -f build-support/$variant.Dockerfile .
done
for arch in "${ARCHS[@]}"; do
for variant in "${BASE_DOCKERFILE_VARIANTS[@]}"; do
echo "Building native crypto for $arch on $variant..."
docker run --rm -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant /bin/bash -c "cd /app && ./build-support/compile-linux-crypto.sh"
$CONTAINER_ENGINE run --rm --pull=never -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant-$arch /bin/bash -c "cd /app && ./build-support/compile-linux-crypto.sh"
done
for variant in "${COMPRESSION_VARIANTS[@]}"; do
echo "Building native compression for $arch on $variant..."
docker run --rm -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant /bin/bash -c "cd /app && ./build-support/compile-linux-compress.sh"
$CONTAINER_ENGINE run --rm --pull=never -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant-$arch /bin/bash -c "cd /app && ./build-support/compile-linux-compress.sh"
done
done
done
+1 -2
View File
@@ -34,8 +34,7 @@ Java_com_velocitypowered_natives_compression_NativeZlibInflate_process(JNIEnv *e
jlong sourceAddress,
jint sourceLength,
jlong destinationAddress,
jint destinationLength,
jlong maximumSize)
jint destinationLength)
{
struct libdeflate_decompressor *decompress = (struct libdeflate_decompressor *) ctx;
enum libdeflate_result result = libdeflate_zlib_decompress(decompress, (void *) sourceAddress,
@@ -56,24 +56,45 @@ public class JavaVelocityCompressor implements VelocityCompressor {
final int origIdx = source.readerIndex();
inflater.setInput(source.nioBuffer());
int totalProduced = 0;
try {
final int readable = source.readableBytes();
while (!inflater.finished() && inflater.getBytesRead() < readable) {
if (totalProduced >= uncompressedSize) {
throw new DataFormatException("Decompressed data exceeds the claimed uncompressed size "
+ "of " + uncompressedSize + " bytes");
}
final int remaining = uncompressedSize - totalProduced;
if (!destination.isWritable()) {
destination.ensureWritable(ZLIB_BUFFER_SIZE);
destination.ensureWritable(Math.min(ZLIB_BUFFER_SIZE, remaining));
}
ByteBuffer destNioBuf = destination.nioBuffer(destination.writerIndex(),
destination.writableBytes());
// Never let a single inflate step write past the claimed size
if (destNioBuf.remaining() > remaining) {
destNioBuf.limit(destNioBuf.position() + remaining);
}
int produced = inflater.inflate(destNioBuf);
if (produced == 0 && !inflater.finished()) {
// Output space was available yet the inflater made no progress: the stream is truncated
// or corrupt (this also covers a peer that over-reported the uncompressed size).
throw new DataFormatException("Received a truncated or malformed deflate stream, "
+ "expected " + uncompressedSize + " bytes");
}
totalProduced += produced;
destination.writerIndex(destination.writerIndex() + produced);
}
if (!inflater.finished()) {
throw new DataFormatException("Received a deflate stream that was too large, wanted "
+ uncompressedSize);
throw new DataFormatException("Received a truncated or malformed deflate stream, expected "
+ uncompressedSize + " bytes");
}
source.readerIndex(origIdx + inflater.getTotalIn());
source.readerIndex(origIdx + (int) inflater.getBytesRead());
} finally {
inflater.reset();
}
@@ -102,7 +123,7 @@ public class JavaVelocityCompressor implements VelocityCompressor {
destination.writerIndex(destination.writerIndex() + produced);
}
source.readerIndex(origIdx + deflater.getTotalIn());
source.readerIndex(origIdx + (int) deflater.getBytesRead());
deflater.reset();
}
@@ -17,6 +17,7 @@
package com.velocitypowered.natives.compression;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -77,6 +78,83 @@ class VelocityCompressorTest {
check(compressor, () -> Unpooled.buffer(TEST_DATA.length + 32));
}
private static final int BOMB_ACTUAL_SIZE = 1 << 20;
private static final int BOMB_LYING_CLAIM = 1024;
@Test
void javaRejectsUnderReportedUncompressedSize() throws DataFormatException {
VelocityCompressor compressor = JavaVelocityCompressor.FACTORY
.create(Deflater.DEFAULT_COMPRESSION);
DataFormatException ex = assertRejectsDecompressionBomb(compressor);
// The Java compressor's size guard names the claimed size in its message, so operators can
// tell an over-size rejection apart from a genuinely corrupt stream.
assertTrue(ex.getMessage().contains(String.valueOf(BOMB_LYING_CLAIM)),
"rejection must originate from the uncompressed-size guard, got: " + ex.getMessage());
}
@Test
@EnabledOnOs({LINUX})
void nativeRejectsUnderReportedUncompressedSize() throws DataFormatException {
VelocityCompressor compressor = Natives.compress.get().create(Deflater.DEFAULT_COMPRESSION);
if (compressor.preferredBufferType() != BufferPreference.DIRECT_REQUIRED) {
compressor.close();
fail("Loaded regular compressor");
}
// libdeflate rejects with its own native-origin message ("uncompressed size is inaccurate"),
// so we only assert the behavioural guarantee here, not the message text.
assertRejectsDecompressionBomb(compressor);
}
/**
* Asserts that a compressor refuses a decompression bomb: a small, valid deflate stream whose
* claimed uncompressed size is far smaller than what it actually inflates to. Verifies the same
* stream round-trips when the claimed size is honest (proving the rejection is caused by the
* under-reported size, not corrupt input) and that no output is written past the claimed size.
* Closes the compressor before returning the exception thrown by the rejected inflate.
*/
private DataFormatException assertRejectsDecompressionBomb(VelocityCompressor compressor)
throws DataFormatException {
// Direct buffers so this works for the native compressor, which requires them.
ByteBuf source = Unpooled.directBuffer(BOMB_ACTUAL_SIZE);
ByteBuf compressed = Unpooled.directBuffer();
try {
source.writeZero(BOMB_ACTUAL_SIZE);
compressor.deflate(source, compressed);
final int compressedSize = compressed.readableBytes();
assertTrue(compressedSize < BOMB_ACTUAL_SIZE / 100,
"sanity: payload really is a decompression bomb (" + compressedSize + " -> "
+ BOMB_ACTUAL_SIZE + ")");
// Positive control: the compressed stream is perfectly valid and round-trips when the peer
// tells the truth about its uncompressed size.
ByteBuf honest = Unpooled.directBuffer();
try {
compressor.inflate(compressed.duplicate(), honest, BOMB_ACTUAL_SIZE);
assertEquals(BOMB_ACTUAL_SIZE, honest.readableBytes(),
"valid stream must fully decompress when the claimed size is honest");
} finally {
honest.release();
}
// Attack: same valid stream, but a tiny claimed size. inflate must refuse rather than grow
// the destination without bound.
ByteBuf decompressed = Unpooled.directBuffer();
try {
DataFormatException ex = assertThrows(DataFormatException.class,
() -> compressor.inflate(compressed.duplicate(), decompressed, BOMB_LYING_CLAIM));
assertTrue(decompressed.writerIndex() <= BOMB_LYING_CLAIM,
"inflate must not write past the claimed uncompressed size");
return ex;
} finally {
decompressed.release();
}
} finally {
source.release();
compressed.release();
compressor.close();
}
}
private void check(VelocityCompressor compressor, Supplier<ByteBuf> bufSupplier)
throws DataFormatException {
ByteBuf source = bufSupplier.get();
+5 -60
View File
@@ -33,65 +33,6 @@ tasks {
transform(Log4j2PluginsCacheFileTransformer::class.java)
// Exclude all the collection types we don"t intend to use
exclude("it/unimi/dsi/fastutil/booleans/**")
exclude("it/unimi/dsi/fastutil/bytes/**")
exclude("it/unimi/dsi/fastutil/chars/**")
exclude("it/unimi/dsi/fastutil/doubles/**")
exclude("it/unimi/dsi/fastutil/floats/**")
exclude("it/unimi/dsi/fastutil/longs/**")
exclude("it/unimi/dsi/fastutil/shorts/**")
// Exclude the fastutil IO utilities - we don"t use them.
exclude("it/unimi/dsi/fastutil/io/**")
// Exclude most of the int types - Object2IntMap have a values() method that returns an
// IntCollection, and we need Int2ObjectMap
exclude("it/unimi/dsi/fastutil/ints/*Int2Boolean*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Byte*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Char*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Double*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Float*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Int*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Long*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Short*")
exclude("it/unimi/dsi/fastutil/ints/*Int2Reference*")
exclude("it/unimi/dsi/fastutil/ints/IntAVL*")
exclude("it/unimi/dsi/fastutil/ints/IntArrayF*")
exclude("it/unimi/dsi/fastutil/ints/IntArrayI*")
exclude("it/unimi/dsi/fastutil/ints/IntArrayL*")
exclude("it/unimi/dsi/fastutil/ints/IntArrayP*")
exclude("it/unimi/dsi/fastutil/ints/IntArraySet*")
exclude("it/unimi/dsi/fastutil/ints/*IntBi*")
exclude("it/unimi/dsi/fastutil/ints/Int*Pair")
exclude("it/unimi/dsi/fastutil/ints/IntLinked*")
exclude("it/unimi/dsi/fastutil/ints/IntList*")
exclude("it/unimi/dsi/fastutil/ints/IntHeap*")
exclude("it/unimi/dsi/fastutil/ints/IntOpen*")
exclude("it/unimi/dsi/fastutil/ints/IntRB*")
exclude("it/unimi/dsi/fastutil/ints/IntSorted*")
exclude("it/unimi/dsi/fastutil/ints/*Priority*")
exclude("it/unimi/dsi/fastutil/ints/*BigList*")
// Try to exclude everything BUT Object2Int{LinkedOpen,Open,CustomOpen}HashMap
exclude("it/unimi/dsi/fastutil/objects/*ObjectArray*")
exclude("it/unimi/dsi/fastutil/objects/*ObjectAVL*")
exclude("it/unimi/dsi/fastutil/objects/*Object*Big*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Boolean*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Byte*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Char*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Double*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Float*")
exclude("it/unimi/dsi/fastutil/objects/*Object2IntArray*")
exclude("it/unimi/dsi/fastutil/objects/*Object2IntAVL*")
exclude("it/unimi/dsi/fastutil/objects/*Object2IntRB*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Long*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Object*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Reference*")
exclude("it/unimi/dsi/fastutil/objects/*Object2Short*")
exclude("it/unimi/dsi/fastutil/objects/*ObjectRB*")
exclude("it/unimi/dsi/fastutil/objects/*Reference*")
// Exclude Checker Framework annotations
exclude("org/checkerframework/checker/**")
@@ -129,9 +70,13 @@ fill {
build {
channel = BuildChannel.STABLE
versionFamily("3.0.0")
versionFamily("4.0.0")
version(projectVersion)
if (versionFamily.get().split(".")[0] != projectVersion.split(".")[0]) {
throw IllegalArgumentException("Version family does not match project version")
}
downloads {
register("server:default") {
file = tasks.shadowJar.flatMap { it.archiveFile }
@@ -221,7 +221,8 @@ public class VelocityServer implements ProxyServer, ForwardingAudience {
PluginDescription description = new VelocityPluginDescription(
"velocity", version.getName(), version.getVersion(), "The Velocity proxy",
version.getName().equals("Velocity") ? VELOCITY_URL : null,
ImmutableList.of(version.getVendor()), Collections.emptyList(), null);
ImmutableList.of(version.getVendor()), Collections.emptyList(),
Collections.emptyList(), null);
VelocityPluginContainer container = new VelocityPluginContainer(description);
container.setInstance(VelocityVirtualPlugin.INSTANCE);
return container;
@@ -161,14 +161,17 @@ public final class VelocityCommand {
.decoration(TextDecoration.BOLD, true)
.color(VELOCITY_COLOR)
.append(Component.text()
.content(version.getVersion())
.decoration(TextDecoration.BOLD, false))
.content(version.getVersion())
.decoration(TextDecoration.BOLD, false))
.hoverEvent(Component.translatable("velocity.command.version-offer-copy-version"))
.clickEvent(ClickEvent.copyToClipboard(version.getName() + " "
+ version.getVersion()))
.build();
final Component copyright = Component
.translatable("velocity.command.version-copyright",
Argument.string("vendor", version.getVendor()),
Argument.string("name", version.getName()),
Argument.component("year", Component.text(LocalDate.now().getYear())));
Argument.string("name", version.getName()),
Argument.component("year", Component.text(LocalDate.now().getYear())));
source.sendMessage(velocity);
source.sendMessage(copyright);
@@ -53,6 +53,7 @@ import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket;
import com.velocitypowered.proxy.protocol.packet.StatusPingPacket;
import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket;
@@ -200,6 +201,10 @@ public interface MinecraftSessionHandler {
return false;
}
default boolean handle(ServerboundPlayerLoadedPacket packet) {
return false;
}
default boolean handle(ServerLoginPacket packet) {
return false;
}
@@ -296,6 +296,19 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler {
return true;
}
// Register and unregister packets are simply forwarded to the client as-is.
if (PluginMessageUtil.isRegister(packet) || PluginMessageUtil.isUnregister(packet)) {
return false;
}
if (PluginMessageUtil.isMcBrand(packet)) {
PluginMessagePacket rewritten = PluginMessageUtil
.rewriteMinecraftBrand(packet,
server.getVersion(), playerConnection.getProtocolVersion());
playerConnection.write(rewritten);
return true;
}
if (serverConn.getPhase().handle(serverConn, serverConn.getPlayer(), packet)) {
// Handled.
return true;
@@ -277,7 +277,6 @@ public class ConfigSessionHandler implements MinecraftSessionHandler {
PluginMessageUtil.rewriteMinecraftBrand(packet, server.getVersion(),
serverConn.getPlayer().getProtocolVersion()));
} else {
byte[] bytes = ByteBufUtil.getBytes(packet.content());
ChannelIdentifier id = this.server.getChannelRegistrar().getFromId(packet.getChannel());
if (id == null) {
@@ -287,6 +286,7 @@ public class ConfigSessionHandler implements MinecraftSessionHandler {
// Handling this stuff async means that we should probably pause
// the connection while we toss this off into another pool
byte[] bytes = ByteBufUtil.getBytes(packet.content());
this.serverConn.getConnection().setAutoReading(false);
this.server.getEventManager()
.fire(new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, bytes))
@@ -68,6 +68,7 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation,
private final VelocityServer server;
private @Nullable MinecraftConnection connection;
private boolean hasCompletedJoin = false;
private boolean clientLoaded = false; // 1.21.4+
private boolean gracefulDisconnect = false;
private BackendConnectionPhase connectionPhase = BackendConnectionPhases.UNKNOWN;
private final Map<Long, Long> pendingPings = new HashMap<>();
@@ -317,6 +318,15 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation,
}
}
public void setClientLoaded(boolean clientLoaded) {
this.clientLoaded = clientLoaded;
}
@Override
public boolean isClientLoaded() {
return clientLoaded;
}
boolean isGracefulDisconnect() {
return gracefulDisconnect;
}
@@ -47,6 +47,7 @@ import com.velocitypowered.proxy.protocol.packet.config.FinishedUpdatePacket;
import com.velocitypowered.proxy.protocol.packet.config.KnownPacksPacket;
import com.velocitypowered.proxy.protocol.util.PluginMessageUtil;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufHolder;
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;
import java.util.concurrent.CompletableFuture;
@@ -135,7 +136,6 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {
} else if (BungeeCordMessageResponder.isBungeeCordMessage(packet)) {
return true;
} else if (serverConn != null) {
byte[] bytes = ByteBufUtil.getBytes(packet.content());
ChannelIdentifier id = this.server.getChannelRegistrar().getFromId(packet.getChannel());
if (id == null) {
@@ -145,6 +145,7 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {
// Handling this stuff async means that we should probably pause
// the connection while we toss this off into another pool
byte[] bytes = ByteBufUtil.getBytes(packet.content());
serverConn.getPlayer().getConnection().setAutoReading(false);
this.server.getEventManager()
.fire(new PluginMessageEvent(serverConn.getPlayer(), serverConn, id, bytes))
@@ -212,8 +213,9 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {
@Override
public boolean handle(ServerboundCustomClickActionPacket packet) {
if (player.getConnectionInFlight() != null) {
player.getConnectionInFlight().ensureConnected().write(packet.retain());
VelocityServerConnection serverConnection = player.getConnectionInFlightOrConnectedServer();
if (serverConnection != null) {
serverConnection.ensureConnected().write(packet.retain());
return true;
}
@@ -240,8 +242,8 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {
MinecraftConnection smc = serverConnection.getConnection();
if (smc != null && serverConnection.getPhase().consideredComplete()) {
if (packet instanceof PluginMessagePacket) {
((PluginMessagePacket) packet).retain();
if (packet instanceof ByteBufHolder bufHolder) {
bufHolder.retain();
}
smc.write(packet);
}
@@ -26,6 +26,7 @@ import com.velocitypowered.api.event.player.CookieReceiveEvent;
import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent;
import com.velocitypowered.api.event.player.PlayerChannelUnregisterEvent;
import com.velocitypowered.api.event.player.PlayerClientBrandEvent;
import com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent;
import com.velocitypowered.api.event.player.TabCompleteEvent;
import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent;
import com.velocitypowered.api.network.ProtocolVersion;
@@ -52,6 +53,7 @@ import com.velocitypowered.proxy.protocol.packet.PluginMessagePacket;
import com.velocitypowered.proxy.protocol.packet.ResourcePackResponsePacket;
import com.velocitypowered.proxy.protocol.packet.RespawnPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteRequestPacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket.Offer;
@@ -243,6 +245,20 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler {
return true; // will forward onto the server
}
@Override
public boolean handle(ServerboundPlayerLoadedPacket packet) {
VelocityServerConnection serverConnection = player.getConnectedServer();
if (serverConnection == null) {
// No server connection yet, probably transitioning - shouldn't be possible with a vanilla client
return true;
}
if (!serverConnection.isClientLoaded()) {
serverConnection.setClientLoaded(true);
server.getEventManager().fireAndForget(new PlayerClientLoadedWorldEvent(player));
}
return false;
}
@Override
public boolean handle(SessionPlayerCommandPacket packet) {
if (player.getCurrentServer().isEmpty()) {
@@ -60,9 +60,7 @@ final class CustomHandlerAdapter<F> {
UntargetedEventHandler buildUntargetedHandler(final Method method)
throws IllegalAccessException {
final MethodHandle methodHandle = methodHandlesLookup.unreflect(method);
final MethodHandles.Lookup defineLookup = MethodHandles.privateLookupIn(
method.getDeclaringClass(), methodHandlesLookup);
final LambdaType<F> lambdaType = functionType.defineClassesWith(defineLookup);
final LambdaType<F> lambdaType = functionType.defineClassesWith(methodHandlesLookup);
final F invokeFunction = LambdaFactory.create(lambdaType, methodHandle);
final BiFunction<Object, Object, EventTask> handlerFunction =
handlerBuilder.apply(invokeFunction);
@@ -243,7 +243,7 @@ public class VelocityEventManager implements EventManager {
} else {
type = untargetedVoidHandlerType;
}
return LambdaFactory.create(type.defineClassesWith(lookup), methodHandle);
return LambdaFactory.create(type.defineClassesWith(methodHandlesLookup), methodHandle);
}
static final class MethodHandlerInfo {
@@ -46,10 +46,12 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -62,6 +64,7 @@ public class VelocityPluginManager implements PluginManager {
private final Map<String, PluginContainer> pluginsById = new LinkedHashMap<>();
private final Map<Object, PluginContainer> pluginInstances = new IdentityHashMap<>();
private final Set<PluginContainer> plugins = new LinkedHashSet<>();
private final VelocityServer server;
public VelocityPluginManager(VelocityServer server) {
@@ -74,7 +77,9 @@ public class VelocityPluginManager implements PluginManager {
* @param plugin the plugin to register
*/
public void registerPlugin(PluginContainer plugin) {
plugins.add(plugin);
pluginsById.put(plugin.getDescription().getId(), plugin);
plugin.getDescription().getProvidedIds().forEach(id -> pluginsById.put(id, plugin));
Optional<?> instance = plugin.getInstance();
instance.ifPresent(o -> pluginInstances.put(o, plugin));
}
@@ -100,16 +105,34 @@ public class VelocityPluginManager implements PluginManager {
try {
PluginDescription candidate = loader.loadCandidate(path);
// If we found a duplicate candidate (with the same ID), don't load it.
PluginDescription maybeExistingCandidate = foundCandidates.putIfAbsent(
candidate.getId(), candidate);
// A plugin claims its own ID plus every ID it provides. If any of those are already
// claimed by another candidate, don't load this one.
List<String> claimedIds = new ArrayList<>(candidate.getProvidedIds().size() + 1);
claimedIds.add(candidate.getId());
claimedIds.addAll(candidate.getProvidedIds());
if (maybeExistingCandidate != null) {
logger.error("Refusing to load plugin at path {} since we already "
+ "loaded a plugin with the same ID {} from {}",
PluginDescription conflict = null;
String conflictingId = null;
for (String id : claimedIds) {
PluginDescription existing = foundCandidates.get(id);
if (existing != null) {
conflict = existing;
conflictingId = id;
break;
}
}
if (conflict != null) {
logger.error("Refusing to load plugin at path {} since ID {} was already "
+ "claimed by a plugin loaded from {}",
candidate.getSource().map(Objects::toString).orElse("<UNKNOWN>"),
candidate.getId(),
maybeExistingCandidate.getSource().map(Objects::toString).orElse("<UNKNOWN>"));
conflictingId,
conflict.getSource().map(Objects::toString).orElse("<UNKNOWN>"));
continue;
}
for (String id : claimedIds) {
foundCandidates.put(id, candidate);
}
} catch (Throwable e) {
logger.error("Unable to load plugin {}", path, e);
@@ -122,8 +145,10 @@ public class VelocityPluginManager implements PluginManager {
return;
}
// foundCandidates indexes each candidate under its ID and any provided IDs, so dedupe before
// sorting to avoid loading a plugin more than once.
List<PluginDescription> sortedPlugins = PluginDependencyUtils.sortCandidates(
new ArrayList<>(foundCandidates.values()));
new ArrayList<>(new LinkedHashSet<>(foundCandidates.values())));
Map<String, PluginDescription> loadedCandidates = new HashMap<>();
Map<PluginContainer, Module> pluginContainers = new LinkedHashMap<>();
@@ -144,6 +169,7 @@ public class VelocityPluginManager implements PluginManager {
VelocityPluginContainer container = new VelocityPluginContainer(realPlugin);
pluginContainers.put(container, loader.createModule(container));
loadedCandidates.put(realPlugin.getId(), realPlugin);
realPlugin.getProvidedIds().forEach(id -> loadedCandidates.putIfAbsent(id, realPlugin));
} catch (Throwable e) {
logger.error("Can't create module for plugin {}", candidate.getId(), e);
}
@@ -201,7 +227,7 @@ public class VelocityPluginManager implements PluginManager {
@Override
public Collection<PluginContainer> getPlugins() {
return Collections.unmodifiableCollection(pluginsById.values());
return Collections.unmodifiableCollection(plugins);
}
@Override
@@ -21,6 +21,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.velocitypowered.api.plugin.PluginDescription;
import com.velocitypowered.api.plugin.meta.PluginDependency;
@@ -43,6 +44,7 @@ public class VelocityPluginDescription implements PluginDescription {
private final @Nullable String url;
private final List<String> authors;
private final Map<String, PluginDependency> dependencies;
private final Collection<String> providedIds;
private final Path source;
/**
@@ -55,11 +57,13 @@ public class VelocityPluginDescription implements PluginDescription {
* @param url the website for the plugin
* @param authors the authors of this plugin
* @param dependencies the dependencies for this plugin
* @param providedIds the IDs this plugin provides for
* @param source the original source for the plugin
*/
public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source) {
@Nullable List<String> authors, Collection<PluginDependency> dependencies,
@Nullable Collection<String> providedIds, Path source) {
this.id = checkNotNull(id, "id");
this.name = Strings.emptyToNull(name);
this.version = Strings.emptyToNull(version);
@@ -67,6 +71,8 @@ public class VelocityPluginDescription implements PluginDescription {
this.url = Strings.emptyToNull(url);
this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors);
this.dependencies = Maps.uniqueIndex(dependencies, d -> d == null ? null : d.getId());
this.providedIds =
providedIds == null ? ImmutableSet.of() : ImmutableSet.copyOf(providedIds);
this.source = source;
}
@@ -110,6 +116,11 @@ public class VelocityPluginDescription implements PluginDescription {
return Optional.ofNullable(dependencies.get(id));
}
@Override
public Collection<String> getProvidedIds() {
return providedIds;
}
@Override
public Optional<Path> getSource() {
return Optional.ofNullable(source);
@@ -125,6 +136,7 @@ public class VelocityPluginDescription implements PluginDescription {
+ ", url='" + url + '\''
+ ", authors=" + authors
+ ", dependencies=" + dependencies
+ ", providedIds=" + providedIds
+ ", source=" + source
+ '}';
}
@@ -78,6 +78,14 @@ public class JavaPluginLoader implements PluginLoader {
}
}
for (String providedId : pd.getProvides()) {
if (!SerializedPluginDescription.ID_PATTERN.matcher(providedId).matches()) {
throw new InvalidPluginException(
"Provided ID '" + providedId + "' for plugin '" + pd.getId() + "' is invalid."
);
}
}
return createCandidateDescription(pd, source);
}
@@ -181,6 +189,7 @@ public class JavaPluginLoader implements PluginLoader {
description.getUrl(),
description.getAuthors(),
dependencies,
description.getProvides(),
source,
description.getMain()
);
@@ -197,6 +206,7 @@ public class JavaPluginLoader implements PluginLoader {
description.getUrl().orElse(null),
description.getAuthors(),
description.getDependencies(),
description.getProvidedIds(),
description.getSource().orElse(null),
mainClass
);
@@ -32,9 +32,9 @@ class JavaVelocityPluginDescription extends VelocityPluginDescription {
JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source,
Class<?> mainClass) {
super(id, name, version, description, url, authors, dependencies, source);
@Nullable List<String> authors, Collection<PluginDependency> dependencies,
@Nullable Collection<String> providedIds, Path source, Class<?> mainClass) {
super(id, name, version, description, url, authors, dependencies, providedIds, source);
this.mainClass = checkNotNull(mainClass);
}
@@ -32,9 +32,9 @@ class JavaVelocityPluginDescriptionCandidate extends VelocityPluginDescription {
JavaVelocityPluginDescriptionCandidate(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source,
String mainClass) {
super(id, name, version, description, url, authors, dependencies, source);
@Nullable List<String> authors, Collection<PluginDependency> dependencies,
@Nullable Collection<String> providedIds, Path source, String mainClass) {
super(id, name, version, description, url, authors, dependencies, providedIds, source);
this.mainClass = checkNotNull(mainClass);
}
@@ -17,7 +17,6 @@
package com.velocitypowered.proxy.plugin.util;
import com.google.common.collect.Maps;
import com.google.common.graph.Graph;
import com.google.common.graph.GraphBuilder;
import com.google.common.graph.MutableGraph;
@@ -60,8 +59,19 @@ public class PluginDependencyUtils {
.allowsSelfLoops(false)
.expectedNodeCount(sortedCandidates.size())
.build();
Map<String, PluginDescription> candidateMap = Maps.uniqueIndex(sortedCandidates,
PluginDescription::getId);
// Index candidates by their own ID and any IDs they provide, so a dependency can be satisfied
// by a plugin that provides that ID. Real IDs take precedence over provided IDs, and the first
// provider of a given ID wins; upstream loading rejects such conflicts before we get here.
Map<String, PluginDescription> candidateMap = new HashMap<>();
for (PluginDescription description : sortedCandidates) {
candidateMap.putIfAbsent(description.getId(), description);
}
for (PluginDescription description : sortedCandidates) {
for (String provided : description.getProvidedIds()) {
candidateMap.putIfAbsent(provided, description);
}
}
for (PluginDescription description : sortedCandidates) {
graph.addNode(description);
@@ -69,7 +79,8 @@ public class PluginDependencyUtils {
for (PluginDependency dependency : description.getDependencies()) {
PluginDescription in = candidateMap.get(dependency.getId());
if (in != null) {
// Guard against self-loops: a plugin may name an ID it itself provides.
if (in != null && !in.equals(description)) {
graph.putEdge(description, in);
}
}
@@ -88,6 +88,7 @@ import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket;
import com.velocitypowered.proxy.protocol.packet.StatusPingPacket;
import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket;
@@ -345,6 +346,12 @@ public enum StateRegistry {
map(0x13, MINECRAFT_1_21_2, false),
map(0x14, MINECRAFT_1_21_6, false),
map(0x15, MINECRAFT_26_1, false));
serverbound.register(
ServerboundPlayerLoadedPacket.class,
() -> ServerboundPlayerLoadedPacket.INSTANCE,
map(0x2A, MINECRAFT_1_21_4, false),
map(0x2B, MINECRAFT_1_21_6, false),
map(0x2C, MINECRAFT_26_1, false));
serverbound.register(
PluginMessagePacket.class,
PluginMessagePacket::new,
@@ -0,0 +1,50 @@
/*
* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
*/
package com.velocitypowered.proxy.protocol.packet;
import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.protocol.MinecraftPacket;
import com.velocitypowered.proxy.protocol.ProtocolUtils;
import io.netty.buffer.ByteBuf;
public class ServerboundPlayerLoadedPacket implements MinecraftPacket {
public static final ServerboundPlayerLoadedPacket INSTANCE = new ServerboundPlayerLoadedPacket();
private ServerboundPlayerLoadedPacket() {
}
@Override
public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {
}
@Override
public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {
}
@Override
public int decodeExpectedMaxLength(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {
return 0;
}
@Override
public boolean handle(MinecraftSessionHandler handler) {
return handler.handle(this);
}
}
@@ -50,6 +50,7 @@ velocity.command.glist-view-all=To view all players on servers, use /glist all.
velocity.command.reload-success=Velocity configuration successfully reloaded.
velocity.command.reload-failure=Unable to reload your Velocity configuration. Check the console for more details.
velocity.command.version-copyright=Copyright 2018-<arg:2> <arg:0>. <arg:1> is licensed under the terms of the GNU General Public License v3.
velocity.command.version-offer-copy-version=Click to copy version to clipboard
velocity.command.no-plugins=There are no plugins currently installed.
velocity.command.plugins-list=Plugins: <arg:0>
velocity.command.plugin-tooltip-website=Website: <arg:0>
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2018-2026 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 <https://www.gnu.org/licenses/>.
*/
package com.velocitypowered.proxy.connection.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.backend.BackendConnectionPhase;
import com.velocitypowered.proxy.connection.backend.VelocityServerConnection;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.util.ReferenceCountUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ClientConfigSessionHandlerTest {
private VelocityServer server;
private ConnectedPlayer player;
private ClientConfigSessionHandler handler;
@BeforeEach
void setUp() {
server = mock(VelocityServer.class);
player = mock(ConnectedPlayer.class);
handler = new ClientConfigSessionHandler(server, player);
}
@AfterEach
void tearDown() {
// nothing to clean up; each test manages its own ByteBufs
}
private ServerboundCustomClickActionPacket makePacket() {
ByteBuf frame = Unpooled.buffer().writeByte(0);
ServerboundCustomClickActionPacket pkt = new ServerboundCustomClickActionPacket();
pkt.replace(frame.readRetainedSlice(frame.readableBytes()));
return pkt;
}
@Test
void handleForwardsToInFlightServer() {
VelocityServerConnection inFlight = mock(VelocityServerConnection.class);
MinecraftConnection backend = mock(MinecraftConnection.class);
when(player.getConnectionInFlightOrConnectedServer()).thenReturn(inFlight);
when(inFlight.ensureConnected()).thenReturn(backend);
ServerboundCustomClickActionPacket pkt = makePacket();
assertTrue(handler.handle(pkt));
verify(backend).write(pkt);
ReferenceCountUtil.release(pkt);
}
@Test
void handleForwardsToConnectedServerWhenInFlightIsNull() {
VelocityServerConnection connected = mock(VelocityServerConnection.class);
MinecraftConnection backend = mock(MinecraftConnection.class);
when(player.getConnectionInFlightOrConnectedServer()).thenReturn(connected);
when(connected.ensureConnected()).thenReturn(backend);
ServerboundCustomClickActionPacket pkt = makePacket();
assertTrue(handler.handle(pkt));
verify(backend).write(pkt);
ReferenceCountUtil.release(pkt);
}
@Test
void handleReturnsFalseWhenNoServer() {
when(player.getConnectionInFlightOrConnectedServer()).thenReturn(null);
ServerboundCustomClickActionPacket pkt = makePacket();
assertFalse(handler.handle(pkt));
ReferenceCountUtil.release(pkt);
}
@Test
void handleGenericRetainsAndForwards() {
VelocityServerConnection connected = mock(VelocityServerConnection.class);
MinecraftConnection backend = mock(MinecraftConnection.class);
BackendConnectionPhase phase = mock(BackendConnectionPhase.class);
when(player.getConnectedServer()).thenReturn(connected);
when(connected.getConnection()).thenReturn(backend);
when(connected.getPhase()).thenReturn(phase);
when(phase.consideredComplete()).thenReturn(true);
ServerboundCustomClickActionPacket pkt = makePacket();
int refBefore = pkt.refCnt();
handler.handleGeneric(pkt);
// retain() was called (+1) before write
assertEquals(refBefore + 1, pkt.refCnt());
verify(backend).write(pkt);
ReferenceCountUtil.release(pkt);
}
}
@@ -44,6 +44,15 @@ class PluginDependencyUtilsTest {
private static final PluginDescription CIRCULAR_DEPENDENCY_2 = testDescription("oval",
new PluginDependency("circle", "", false));
// "provider" is loaded from a real ID but provides the virtual ID "some-api"; "consumer" and
// "zdependent" depend on it only through that provided ID / a chain that reaches it.
private static final PluginDescription PROVIDES_API = providingDescription("provider",
ImmutableList.of("some-api"));
private static final PluginDescription DEPENDS_ON_PROVIDED = providingDescription("consumer",
ImmutableList.of(), new PluginDependency("some-api", null, false));
private static final PluginDescription DEPENDS_ON_CONSUMER = testDescription("zdependent",
new PluginDependency("consumer", null, false));
@Test
void sortCandidatesTrivial() throws Exception {
List<PluginDescription> descriptionList = new ArrayList<>();
@@ -96,10 +105,31 @@ class PluginDependencyUtilsTest {
assertThrows(IllegalStateException.class, () -> PluginDependencyUtils.sortCandidates(descs));
}
@Test
void sortCandidatesResolvesProvidedDependency() throws Exception {
List<PluginDescription> plugins = ImmutableList.of(DEPENDS_ON_PROVIDED, PROVIDES_API);
List<PluginDescription> expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED);
assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins));
}
@Test
void sortCandidatesResolvesTransitiveProvidedDependency() throws Exception {
List<PluginDescription> plugins = ImmutableList.of(DEPENDS_ON_CONSUMER, DEPENDS_ON_PROVIDED,
PROVIDES_API);
List<PluginDescription> expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED,
DEPENDS_ON_CONSUMER);
assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins));
}
private static PluginDescription testDescription(String id, PluginDependency... dependencies) {
return providingDescription(id, ImmutableList.of(), dependencies);
}
private static PluginDescription providingDescription(String id, List<String> provides,
PluginDependency... dependencies) {
return new VelocityPluginDescription(
id, "tuxed", "0.1", null, null, ImmutableList.of(),
ImmutableList.copyOf(dependencies), null
ImmutableList.copyOf(dependencies), provides, null
);
}
}