From ea34a5b4159440355927fd660647e5ffc330dafa Mon Sep 17 00:00:00 2001 From: SPY_me Date: Wed, 10 Jun 2026 17:52:47 +0300 Subject: [PATCH 1/4] Handle cookie responses inside Limbo Player#requestCookie never completed and CookieReceiveEvent never fired while a player was inside a Limbo: LimboSessionHandlerImpl did not handle ServerboundCookieResponsePacket, so it fell through to handleGeneric and the response was dropped. Override handle(ServerboundCookieResponsePacket) to mirror Velocity's ClientPlaySessionHandler: fire CookieReceiveEvent and, when the result is allowed and a backend connection exists, forward the (possibly rewritten) response to that backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server/LimboSessionHandlerImpl.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java index 61e76037..801b1213 100644 --- a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java +++ b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java @@ -17,10 +17,12 @@ package net.elytrium.limboapi.server; +import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.connection.client.AuthSessionHandler; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; @@ -31,6 +33,7 @@ import com.velocitypowered.proxy.protocol.packet.ClientSettingsPacket; import com.velocitypowered.proxy.protocol.packet.KeepAlivePacket; import com.velocitypowered.proxy.protocol.packet.PluginMessagePacket; +import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket; import com.velocitypowered.proxy.protocol.packet.chat.keyed.KeyedPlayerChatPacket; import com.velocitypowered.proxy.protocol.packet.chat.keyed.KeyedPlayerCommandPacket; import com.velocitypowered.proxy.protocol.packet.chat.legacy.LegacyChatPacket; @@ -65,6 +68,7 @@ import net.elytrium.limboapi.protocol.packets.c2s.MoveRotationOnlyPacket; import net.elytrium.limboapi.protocol.packets.c2s.PlayerChatSessionPacket; import net.elytrium.limboapi.protocol.packets.c2s.TeleportConfirmPacket; +import net.kyori.adventure.key.Key; public class LimboSessionHandlerImpl implements MinecraftSessionHandler { @@ -324,6 +328,34 @@ public boolean handle(SessionPlayerCommandPacket packet) { return this.handleChat("/" + packet.getCommand()); } + @Override + public boolean handle(ServerboundCookieResponsePacket packet) { + // Mirror ClientPlaySessionHandler so that Player#requestCookie keeps working while the + // player is inside a Limbo. The cookie response is decoded fine here (the Limbo registry + // overlays the PLAY registry), but without this override the default handler returns false + // and the packet falls through to handleGeneric, so CookieReceiveEvent is never fired and + // the cookie request silently never completes. + this.plugin.getServer().getEventManager() + .fire(new CookieReceiveEvent(this.player, packet.getKey(), packet.getPayload())) + .thenAcceptAsync(event -> { + if (event.getResult().isAllowed()) { + // A player in a Limbo is usually not attached to a backend server; only forward the + // (possibly rewritten) response when a backend connection exists, exactly like the + // default play handler does. + VelocityServerConnection serverConnection = this.player.getConnectedServer(); + if (serverConnection != null) { + Key resultedKey = event.getResult().getKey() == null + ? event.getOriginalKey() : event.getResult().getKey(); + byte[] resultedData = event.getResult().getData() == null + ? event.getOriginalData() : event.getResult().getData(); + serverConnection.ensureConnected().write(new ServerboundCookieResponsePacket(resultedKey, resultedData)); + } + } + }, this.player.getConnection().eventLoop()); + + return true; + } + private boolean handleChat(String message) { int messageLength = message.length(); if (messageLength > Settings.IMP.MAIN.MAX_CHAT_MESSAGE_LENGTH) { From 1251f2cf652cad1b23cd9fec1e510ddb2ec82414 Mon Sep 17 00:00:00 2001 From: SPY_me Date: Wed, 10 Jun 2026 17:53:19 +0300 Subject: [PATCH 2/4] build: make data generation work for newer Minecraft versions - Drain the data-generator subprocess output (consumeProcessOutput) before waitFor(); otherwise it deadlocks once the child fills the stdout pipe buffer (reproducible on 1.18+ via the bundler's per-library extraction logging; a thread dump shows main stuck in FileOutputStream.writeBytes). - Run each version's generator on a JDK that satisfies its manifest javaVersion.majorVersion (e.g. 26.1 requires Java 25), selecting an installed JDK by scanning sibling JDK dirs; fall back to the current JDK. - Bump the Gradle wrapper to 9.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle/wrapper/gradle-wrapper.properties | 2 +- plugin/build.gradle | 49 ++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 221c4f98..efb903f9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/plugin/build.gradle b/plugin/build.gradle index e6801031..b46387a0 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -276,6 +276,43 @@ File getServerJar(String version) { return jarFile } +// Some Minecraft versions require a newer JDK to run their data generator than the JDK running Gradle +// (e.g. 26.1 needs Java 25). Pick a suitable installed JDK by reading sibling JDKs' "release" files. +@SuppressWarnings('GrMethodMayBeStatic') +File selectJavaHome(int requiredMajor) { + File current = new File(System.getProperty("java.home")) + if (requiredMajor <= Runtime.version().feature()) { + return current + } + + boolean win = System.getProperty("os.name").toLowerCase().contains("win") + File jdksDir = current.getParentFile() + File best = null + int bestMajor = Integer.MAX_VALUE + jdksDir.listFiles()?.each { File dir -> + File release = new File(dir, "release") + File javaExe = new File(dir, win ? "bin/java.exe" : "bin/java") + if (!release.exists() || !javaExe.exists()) { + return + } + String verLine = release.readLines().find { it.startsWith("JAVA_VERSION=") } + if (verLine == null) { + return + } + String ver = verLine.substring(verLine.indexOf('=') + 1).replace('"', '').trim() + int major = ver.startsWith("1.") ? Integer.parseInt(ver.split("\\.")[1]) : Integer.parseInt(ver.split("[._-]")[0]) + if (major >= requiredMajor && major < bestMajor) { + best = dir + bestMajor = major + } + } + if (best == null) { + throw new RuntimeException("No installed JDK satisfies required Java ${requiredMajor} (searched ${jdksDir}). Install JDK ${requiredMajor}+.") + } + this.println("> Using JDK ${bestMajor} at ${best} for data generation (version requires Java ${requiredMajor})") + return best +} + File generateData(MinecraftVersion version) { File cache = getGeneratedCache(version) if (cache != null) { @@ -292,6 +329,10 @@ File generateData(MinecraftVersion version) { // Ignored. } + Object versionManifest = new JsonSlurper().parse(new File(parent, "manifest.json")) + int requiredJava = (versionManifest.javaVersion?.majorVersion ?: Runtime.version().feature()) as int + File javaHome = this.selectJavaHome(requiredJava) + String command if (version >= MinecraftVersion.MINECRAFT_1_18) { command = "\"%s\" -DbundlerMainClass=net.minecraft.data.Main -jar \"${jarFile.getAbsolutePath()}\" --reports --server" @@ -301,13 +342,15 @@ File generateData(MinecraftVersion version) { List commandLine; if (System.getProperty("os.name").toLowerCase().contains("win")) { - File java = new File(System.getProperty("java.home"), "bin/java.exe") + File java = new File(javaHome, "bin/java.exe") commandLine = ["cmd", "/c", String.format(command, java)] } else { - File java = new File(System.getProperty("java.home"), "bin/java") + File java = new File(javaHome, "bin/java") commandLine = ["bash", "-c", String.format(command, java)] } - commandLine.execute([], parent).waitFor() + Process process = commandLine.execute([], parent) + process.consumeProcessOutput() // drain stdout/stderr so the generator can't deadlock on a full pipe buffer + process.waitFor() // Remove/compact files, reduces disk usage from ~2.9gb to ~92mb (or ~9.5mb on a compressed filesystem) jarFile.delete() From dd2fb5820cefe644a3345d18fff4bf5302677e3f Mon Sep 17 00:00:00 2001 From: SPY_me Date: Wed, 10 Jun 2026 19:38:46 +0300 Subject: [PATCH 3/4] Keep cookie responses in the Limbo and replay them later A player in a Limbo usually isn't connected to a backend yet, so the cookie response can't be handled right there. Store the packets like we already do for client settings and brand, then replay them in LoginTasksQueue when the player is sent to a server, so the cookie event fires at the right time. --- .../injection/login/LoginTasksQueue.java | 24 ++++++++++++ .../server/LimboSessionHandlerImpl.java | 38 ++++++------------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/plugin/src/main/java/net/elytrium/limboapi/injection/login/LoginTasksQueue.java b/plugin/src/main/java/net/elytrium/limboapi/injection/login/LoginTasksQueue.java index c312718f..64408aff 100644 --- a/plugin/src/main/java/net/elytrium/limboapi/injection/login/LoginTasksQueue.java +++ b/plugin/src/main/java/net/elytrium/limboapi/injection/login/LoginTasksQueue.java @@ -39,6 +39,7 @@ import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.event.permission.PermissionsSetupEvent; +import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.event.player.GameProfileRequestEvent; import com.velocitypowered.api.event.player.PlayerClientBrandEvent; import com.velocitypowered.api.network.ProtocolVersion; @@ -48,6 +49,7 @@ import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.MinecraftConnection; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.connection.client.AuthSessionHandler; import com.velocitypowered.proxy.connection.client.ClientConfigSessionHandler; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; @@ -55,6 +57,7 @@ import com.velocitypowered.proxy.network.Connections; import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.packet.LegacyPlayerListItemPacket; +import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket; import com.velocitypowered.proxy.protocol.packet.UpsertPlayerInfoPacket; import com.velocitypowered.proxy.protocol.packet.chat.ComponentHolder; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -77,6 +80,7 @@ import net.elytrium.limboapi.injection.login.confirmation.LoginConfirmHandler; import net.elytrium.limboapi.server.LimboSessionHandlerImpl; import net.elytrium.limboapi.utils.LambdaUtil; +import net.kyori.adventure.key.Key; import net.kyori.adventure.text.Component; import org.slf4j.Logger; @@ -272,6 +276,26 @@ private void connectToServer(Logger logger, ConnectedPlayer player, MinecraftCon throw new ReflectionException(e); } } + + for (ServerboundCookieResponsePacket cookie : sessionHandler.getCookies()) { + // Replay cookie responses buffered while in the Limbo, now that the player is being + // handed back to Velocity. Mirrors ClientPlaySessionHandler: fire CookieReceiveEvent + // and forward the (possibly rewritten) response to the backend when one is connected. + this.server.getEventManager() + .fire(new CookieReceiveEvent(this.player, cookie.getKey(), cookie.getPayload())) + .thenAcceptAsync(event -> { + if (event.getResult().isAllowed()) { + VelocityServerConnection serverConnection = this.player.getConnectedServer(); + if (serverConnection != null) { + Key resultedKey = event.getResult().getKey() == null + ? event.getOriginalKey() : event.getResult().getKey(); + byte[] resultedData = event.getResult().getData() == null + ? event.getOriginalData() : event.getResult().getData(); + serverConnection.ensureConnected().write(new ServerboundCookieResponsePacket(resultedKey, resultedData)); + } + } + }, this.player.getConnection().eventLoop()); + } } this.plugin.setActiveSessionHandler(connection, StateRegistry.CONFIG, configHandler); diff --git a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java index 801b1213..f4d62352 100644 --- a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java +++ b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java @@ -17,12 +17,10 @@ package net.elytrium.limboapi.server; -import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; -import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.connection.client.AuthSessionHandler; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; @@ -50,6 +48,8 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; @@ -68,7 +68,6 @@ import net.elytrium.limboapi.protocol.packets.c2s.MoveRotationOnlyPacket; import net.elytrium.limboapi.protocol.packets.c2s.PlayerChatSessionPacket; import net.elytrium.limboapi.protocol.packets.c2s.TeleportConfirmPacket; -import net.kyori.adventure.key.Key; public class LimboSessionHandlerImpl implements MinecraftSessionHandler { @@ -92,6 +91,7 @@ public class LimboSessionHandlerImpl implements MinecraftSessionHandler { private LimboPlayer limboPlayer; private ClientSettingsPacket settings; private String brand; + private final List cookies = new ArrayList<>(); private ScheduledFuture keepAliveTask; private ScheduledFuture chatSessionTimeoutTask; private ScheduledFuture respawnTask; @@ -122,6 +122,7 @@ public LimboSessionHandlerImpl(LimboAPI plugin, LimboImpl limbo, ConnectedPlayer if (originalHandler instanceof LimboSessionHandlerImpl sessionHandler) { this.settings = sessionHandler.getSettings(); this.brand = sessionHandler.getBrand(); + this.cookies.addAll(sessionHandler.getCookies()); } } @@ -330,29 +331,10 @@ public boolean handle(SessionPlayerCommandPacket packet) { @Override public boolean handle(ServerboundCookieResponsePacket packet) { - // Mirror ClientPlaySessionHandler so that Player#requestCookie keeps working while the - // player is inside a Limbo. The cookie response is decoded fine here (the Limbo registry - // overlays the PLAY registry), but without this override the default handler returns false - // and the packet falls through to handleGeneric, so CookieReceiveEvent is never fired and - // the cookie request silently never completes. - this.plugin.getServer().getEventManager() - .fire(new CookieReceiveEvent(this.player, packet.getKey(), packet.getPayload())) - .thenAcceptAsync(event -> { - if (event.getResult().isAllowed()) { - // A player in a Limbo is usually not attached to a backend server; only forward the - // (possibly rewritten) response when a backend connection exists, exactly like the - // default play handler does. - VelocityServerConnection serverConnection = this.player.getConnectedServer(); - if (serverConnection != null) { - Key resultedKey = event.getResult().getKey() == null - ? event.getOriginalKey() : event.getResult().getKey(); - byte[] resultedData = event.getResult().getData() == null - ? event.getOriginalData() : event.getResult().getData(); - serverConnection.ensureConnected().write(new ServerboundCookieResponsePacket(resultedKey, resultedData)); - } - } - }, this.player.getConnection().eventLoop()); - + // The player is usually not attached to a backend while inside a Limbo, so the cookie + // response can't be delivered here. Buffer it (just like ClientSettings/brand) and let + // LoginTasksQueue replay it once the player is handed back to Velocity's session handling. + this.cookies.add(packet); return true; } @@ -533,6 +515,10 @@ public String getBrand() { return this.brand; } + public List getCookies() { + return this.cookies; + } + static { try { TEARDOWN_METHOD = MethodHandles.privateLookupIn(ConnectedPlayer.class, MethodHandles.lookup()) From bf88e921eb11b525c58340da974f34e693667586 Mon Sep 17 00:00:00 2001 From: SPY_me Date: Wed, 10 Jun 2026 20:25:14 +0300 Subject: [PATCH 4/4] Add onCookieResponse hook for the Limbo session handler Lets a Limbo handler read the cookie value while the player is still in the Limbo, not only later through CookieReceiveEvent. handle() now also calls onCookieResponse with the key and data when the client replies. --- .../elytrium/limboapi/api/LimboSessionHandler.java | 14 ++++++++++++++ .../limboapi/server/LimboSessionHandlerImpl.java | 3 +++ 2 files changed, 17 insertions(+) diff --git a/api/src/main/java/net/elytrium/limboapi/api/LimboSessionHandler.java b/api/src/main/java/net/elytrium/limboapi/api/LimboSessionHandler.java index ce0212a4..5f0fc799 100644 --- a/api/src/main/java/net/elytrium/limboapi/api/LimboSessionHandler.java +++ b/api/src/main/java/net/elytrium/limboapi/api/LimboSessionHandler.java @@ -8,6 +8,7 @@ package net.elytrium.limboapi.api; import net.elytrium.limboapi.api.player.LimboPlayer; +import net.kyori.adventure.key.Key; public interface LimboSessionHandler { @@ -50,6 +51,19 @@ default void onGeneric(Object packet) { } + /** + * Called when the client sends a cookie response (reply to {@code Player#requestCookie}) while + * the player is still inside the Limbo. The response is also buffered and replayed as a + * {@code CookieReceiveEvent} when the player is handed back to Velocity, so this hook is meant + * for handlers that need the cookie value during the Limbo session itself. + * + * @param key the cookie key + * @param data the cookie payload + */ + default void onCookieResponse(Key key, byte[] data) { + + } + default void onDisconnect() { } diff --git a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java index f4d62352..d1f0e166 100644 --- a/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java +++ b/plugin/src/main/java/net/elytrium/limboapi/server/LimboSessionHandlerImpl.java @@ -335,6 +335,9 @@ public boolean handle(ServerboundCookieResponsePacket packet) { // response can't be delivered here. Buffer it (just like ClientSettings/brand) and let // LoginTasksQueue replay it once the player is handed back to Velocity's session handling. this.cookies.add(packet); + // Also surface it live to the Limbo handler so plugins can use the cookie value during the + // session (the buffered CookieReceiveEvent only fires later, on hand-back to Velocity). + this.callback.onCookieResponse(packet.getKey(), packet.getPayload()); return true; }