Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<UUID, Integer> 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.
*
* <p>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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -799,10 +801,10 @@ private void rememberClientWorld(JoinGamePacket joinGame) {
/**
* Decides whether the client can stay in the world it already has for this switch.
*
* <p>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.
* <p>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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1478,6 +1479,7 @@ void teardown() {
connectedServer.disconnect();
}

ClientWorldSwitches.forget(getUniqueId());
server.getPlayerRegistry().unregisterConnection(this);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <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 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);
}
}
Loading