diff --git a/api/src/main/java/com/velocityctd/api/player/ClientWorldSwitches.java b/api/src/main/java/com/velocityctd/api/player/ClientWorldSwitches.java new file mode 100644 index 000000000..3d91dbe32 --- /dev/null +++ b/api/src/main/java/com/velocityctd/api/player/ClientWorldSwitches.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Velocity-CTD 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.velocityctd.api.player; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks the entity ID the client currently uses for its own player, so a coordinating proxy + * plugin can pass it to the destination server for a seamless, world-preserving switch. + */ +public final class ClientWorldSwitches { + + private static final ConcurrentHashMap CLIENT_ENTITY_IDS = new ConcurrentHashMap<>(); + + private ClientWorldSwitches() { + } + + /** + * Returns the entity ID the client currently uses for {@code playerId}, or {@code 0} when the + * player has not finished their initial join. + * + * @param playerId the player whose client entity ID is needed + * @return the client-visible entity ID, or {@code 0} + */ + public static int clientEntityId(UUID playerId) { + return CLIENT_ENTITY_IDS.getOrDefault(playerId, 0); + } + + /** + * Records the entity ID most recently presented to a client. + * + *

This method is used by ApiaryProxy's connection implementation. Coordinating plugins + * should use {@link #clientEntityId(UUID)} instead. + * + * @param playerId the client that received the ID + * @param entityId the entity ID from its join-game packet + */ + public static void rememberClientEntityId(UUID playerId, int entityId) { + if (entityId > 0) { + CLIENT_ENTITY_IDS.put(playerId, entityId); + } + } + + /** + * Clears all switch state for a disconnected player. + * + *

This method is used by ApiaryProxy's connection implementation. + * + * @param playerId the disconnected player + */ + public static void forget(UUID playerId) { + CLIENT_ENTITY_IDS.remove(playerId); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java index 0b4043f4d..3e9ca9873 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java @@ -23,6 +23,7 @@ import com.velocitypowered.proxy.protocol.packet.BundleDelimiterPacket; import com.velocitypowered.proxy.protocol.packet.ClientSettingsPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundCookieRequestPacket; +import com.velocitypowered.proxy.protocol.packet.ClientboundSetPassengersPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundSoundEntityPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundStopSoundPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket; @@ -362,6 +363,10 @@ default boolean handle(ClientboundCookieRequestPacket packet) { return false; } + default boolean handle(ClientboundSetPassengersPacket packet) { + return false; + } + default boolean handle(ServerboundCookieResponsePacket packet) { return false; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index 864240c44..be03fd9f0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableList; import com.mojang.brigadier.suggestion.Suggestion; import com.velocityctd.api.event.player.TabCompleteRequestEvent; +import com.velocityctd.api.player.ClientWorldSwitches; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent; @@ -681,9 +682,9 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect player.getPhase().onFirstJoin(player); rememberClientWorld(joinGame); } else if (canKeepClientWorld(joinGame)) { - // The destination reuses the entity id and dimension the client already has, so the client - // does not need to rebuild its level. Withholding the join game and respawn packets is what - // keeps the terrain loading screen from appearing. + // The destination can preserve the dimension the client already has, so the client does not + // need to rebuild its level. Withholding the join game and respawn packets is what keeps the + // terrain loading screen from appearing. player.getTabList().clearAll(); // Because the client never receives a join game, it never reports that it finished loading @@ -785,6 +786,7 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect private void rememberClientWorld(JoinGamePacket joinGame) { clientEntityId = joinGame.getEntityId(); clientDimension = dimensionKey(joinGame); + ClientWorldSwitches.rememberClientEntityId(player.getUniqueId(), clientEntityId); } private static @Nullable String dimensionKey(JoinGamePacket joinGame) { @@ -799,10 +801,10 @@ private void rememberClientWorld(JoinGamePacket joinGame) { /** * Decides whether the client can stay in the world it already has for this switch. * - *

Both the entity id and the dimension have to match what the client was last told. A backend - * that reuses the entity id is what makes this safe: the client keeps addressing its own entity - * by the same id the destination uses, so no packet rewriting is needed. Anything else falls back - * to the regular switch, which costs a loading screen but is always correct. + *

The dimension has to match what the client was last told, and the destination has to assign + * the entity id the client already has for the player. The destination now assigns that id itself + * via Paper's internal entity-id API, so the proxy no longer rewrites packets for a preserved + * world. */ private boolean canKeepClientWorld(JoinGamePacket joinGame) { if (!server.getConfiguration().isKeepClientWorldOnSwitch()) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index e491083ca..ad1fdb8ad 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -31,6 +31,7 @@ import com.mojang.brigadier.tree.RootCommandNode; import com.velocityctd.api.event.permission.PermissionsChangeEvent; import com.velocityctd.api.permission.PermissionResolver; +import com.velocityctd.api.player.ClientWorldSwitches; import com.velocityctd.api.queue.QueueState; import com.velocityctd.proxy.permission.PermissionUtils; import com.velocityctd.proxy.queue.VelocityQueue; @@ -1478,6 +1479,7 @@ void teardown() { connectedServer.disconnect(); } + ClientWorldSwitches.forget(getUniqueId()); server.getPlayerRegistry().unregisterConnection(this); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java index c46e33767..e55b6d46c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -47,6 +47,7 @@ import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_9; import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_9_4; import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_26_1; +import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_26_2; import static com.velocitypowered.api.network.ProtocolVersion.MINIMUM_VERSION; import static com.velocitypowered.api.network.ProtocolVersion.SUPPORTED_VERSIONS; import static com.velocitypowered.proxy.connection.PlayerDataForwarding.LEGACY_MODERN_FORWARDING; @@ -61,6 +62,7 @@ import com.velocitypowered.proxy.protocol.packet.BundleDelimiterPacket; import com.velocitypowered.proxy.protocol.packet.ClientSettingsPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundCookieRequestPacket; +import com.velocitypowered.proxy.protocol.packet.ClientboundSetPassengersPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundSoundEntityPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundStopSoundPacket; import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket; @@ -890,6 +892,10 @@ public enum StateRegistry { map(0x66, MINECRAFT_1_21_5, false), map(0x6B, MINECRAFT_1_21_9, false), map(0x6D, MINECRAFT_26_1, false)); + clientbound.register( + ClientboundSetPassengersPacket.class, + ClientboundSetPassengersPacket::new, + map(0x6B, MINECRAFT_26_2, false)); } }, diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSetPassengersPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSetPassengersPacket.java new file mode 100644 index 000000000..e1beeffb4 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSetPassengersPacket.java @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +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 final class ClientboundSetPassengersPacket implements MinecraftPacket { + + private int vehicleEntityId; + private int[] passengerEntityIds; + + public ClientboundSetPassengersPacket() { + } + + @Override + public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) { + vehicleEntityId = ProtocolUtils.readVarInt(buf); + passengerEntityIds = ProtocolUtils.readVarIntArray(buf); + } + + @Override + public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) { + ProtocolUtils.writeVarInt(buf, vehicleEntityId); + ProtocolUtils.writeVarIntArray(buf, passengerEntityIds); + } + + @Override + public boolean handle(MinecraftSessionHandler handler) { + return handler.handle(this); + } +}