diff --git a/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java new file mode 100644 index 0000000000..940e19271d --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2018-2025 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. + * + *

This event is fired when the player explicitly notifies the server after loading the world (closing the downloading terrain screen) + * + * @implNote Unlike Paper this event will not fire due to a timeout. + * 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 3.4.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 + + '}'; + } + +} \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java index c408e83058..64f75c54f8 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java @@ -40,6 +40,16 @@ 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 3.4.0 + */ + boolean isClientLoaded(); + /** * Returns the player that this connection is associated with. * 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 d1101d6a5a..00783dbf7f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java @@ -53,6 +53,7 @@ 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 @@ default boolean handle(RespawnPacket packet) { return false; } + default boolean handle(ServerboundPlayerLoadedPacket packet) { + return false; + } + default boolean handle(ServerLoginPacket packet) { return false; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java index 71ebd7bc78..e5c44d8718 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java @@ -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 pendingPings = new HashMap<>(); @@ -318,6 +319,15 @@ public void completeJoin() { } } + public void setClientLoaded(boolean clientLoaded) { + this.clientLoaded = clientLoaded; + } + + @Override + public boolean isClientLoaded() { + return clientLoaded; + } + boolean isGracefulDisconnect() { return gracefulDisconnect; } 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 17eb708b36..160153116b 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 @@ -25,6 +25,7 @@ import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent; 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; @@ -48,6 +49,7 @@ 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; @@ -195,6 +197,20 @@ public boolean handle(ClientSettingsPacket packet) { 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 true; + } + @Override public boolean handle(SessionPlayerCommandPacket packet) { if (player.getCurrentServer().isEmpty()) { 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 67b010879a..6c5b7ee5d8 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -87,6 +87,7 @@ 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; @@ -336,6 +337,11 @@ public enum StateRegistry { map(0x11, MINECRAFT_1_20_5, false), map(0x13, MINECRAFT_1_21_2, false), map(0x14, MINECRAFT_1_21_6, false)); + serverbound.register( + ServerboundPlayerLoadedPacket.class, + () -> ServerboundPlayerLoadedPacket.INSTANCE, + map(0x2A, MINECRAFT_1_21_4, false), + map(0x2B, MINECRAFT_1_21_6, false)); serverbound.register( PluginMessagePacket.class, PluginMessagePacket::new, diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java new file mode 100644 index 0000000000..c3a6f0a641 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java @@ -0,0 +1,48 @@ +/* + * 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 . + */ + +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); + } + +}