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,111 @@
/*
* 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.event.player.configuration;

import com.google.common.base.Preconditions;
import com.velocitypowered.api.event.annotation.AwaitingEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ServerConnection;
import net.kyori.adventure.resource.ResourcePackRequest;
import net.kyori.adventure.resource.ResourcePackRequestLike;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
* Fired while a player is in the configuration state, allowing plugins to apply resource packs
* before the player enters play.
*
* <p>If a resource pack is set, the player is held in configuration until every pack reaches a
* terminal status. The event is fired on every (re)configuration; use {@link #isFirstJoin()} to
* tell the initial post-login configuration (e.g. network-wide packs) from a server-switch
* reconfiguration (e.g. per-server packs).</p>
*
* @since Minecraft 1.20.2
*/
@AwaitingEvent
public final class PlayerConfigurationResourcePackEvent {

private final Player player;
private final ServerConnection server;
private final boolean firstJoin;

private @Nullable ResourcePackRequest resourcePack;

/**
* Constructs a new {@link PlayerConfigurationResourcePackEvent}.
*
* @param player the player being configured
* @param server the server (re-)configuring the player
* @param firstJoin whether this is the initial configuration following login
*/
public PlayerConfigurationResourcePackEvent(Player player,
ServerConnection server,
boolean firstJoin) {
this.player = Preconditions.checkNotNull(player, "player");
this.server = server;
this.firstJoin = firstJoin;
}

/**
* Gets the player being configured.
*
* @return the player
*/
public Player getPlayer() {
return this.player;
}

/**
* Gets the server (re-)configuring the player.
*
* @return the configuring server connection
*/
public ServerConnection getServer() {
return this.server;
}

/**
* Gets whether this is the initial configuration following login, rather than a server-switch
* reconfiguration.
*
* @return {@code true} if this is the player's first configuration this session
*/
public boolean isFirstJoin() {
return this.firstJoin;
}

/**
* Gets the resource pack request to apply during this configuration, if any.
*
* @return the resource pack request, or {@code null} if none has been set
*/
public @Nullable ResourcePackRequest getResourcePack() {
return this.resourcePack;
}

/**
* Sets the resource pack(s) to apply during this configuration.
*
* <p>Accepts a single {@link com.velocitypowered.api.proxy.player.ResourcePackInfo} or a
* {@link ResourcePackRequest}; pass {@code null} to apply none.</p>
*
* @param resourcePack the resource pack request to apply, or {@code null} to apply none
*/
public void setResourcePack(@Nullable ResourcePackRequestLike resourcePack) {
this.resourcePack = resourcePack == null ? null : resourcePack.asResourcePackRequest();
}

@Override
public String toString() {
return "PlayerConfigurationResourcePackEvent{"
+ "player=" + player
+ ", server=" + server
+ ", firstJoin=" + firstJoin
+ ", resourcePack=" + resourcePack
+ '}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
import java.net.InetSocketAddress;
import java.util.concurrent.CompletableFuture;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

Expand Down Expand Up @@ -398,6 +400,12 @@ public boolean handle(CodeOfConductPacket packet) {

@Override
public void disconnected() {
ConnectedPlayer player = serverConn.getPlayer();
if (player.getConnection().getActiveSessionHandler() instanceof ClientConfigSessionHandler configHandler
&& configHandler.isAwaitingConfigurationResourcePack()) {
player.disconnect(Component.translatable("velocity.error.resource-pack-configuration-timeout", NamedTextColor.RED));
}

resultFuture.complete(ConnectionRequestResults.forDisconnect(
ConnectionMessages.INTERNAL_SERVER_CONNECTION_ERROR, serverConn.getServer()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package com.velocitypowered.proxy.connection.client;

import com.velocityctd.api.event.player.configuration.PlayerConfigurationResourcePackEvent;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.player.CookieReceiveEvent;
import com.velocitypowered.api.event.player.PlayerClientBrandEvent;
Expand Down Expand Up @@ -50,12 +51,15 @@
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.resource.ResourcePackRequest;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
* Handles the client config stage.
Expand All @@ -66,6 +70,14 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {

private static final Logger LOGGER = LogManager.getLogger(ClientConfigSessionHandler.class);

// Backends don't send keepalives during configuration, so the proxy pings the client itself
// while holding it to apply a pack.
private static final long RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS = 1L;

// Advance an unsettled optional pack to PLAY before the backend's ~15s config timeout. Kept below
// that budget minus the following PlayerFinishConfigurationEvent (up to 5s) and the client ack.
private static final long OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS = 9L;

private final VelocityServer server;

private final ConnectedPlayer player;
Expand All @@ -76,6 +88,12 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler {

private CompletableFuture<Void> configSwitchFuture;

private boolean configuredOnce;

// Active resource pack hold and its keepalive task, or null when no hold is in progress.
private volatile @Nullable CompletableFuture<Void> resourcePackHold;
private @Nullable ScheduledFuture<?> resourcePackKeepAlive;

/**
* Constructs a client config session handler.
*
Expand Down Expand Up @@ -268,6 +286,12 @@ public void handleUnknown(ByteBuf buf) {

@Override
public void disconnected() {
stopResourcePackKeepAlive();
CompletableFuture<Void> hold = this.resourcePackHold;
if (hold != null) {
hold.complete(null);
}

player.teardown();
}

Expand Down Expand Up @@ -347,8 +371,17 @@ public CompletableFuture<Void> handleBackendFinishUpdate(VelocityServerConnectio
smc.write(brandPacket);
}

callConfigurationEvent().thenCompose(v -> server.getEventManager().fire(new PlayerFinishConfigurationEvent(player, serverConn))
boolean firstJoin = !configuredOnce;
configuredOnce = true;

callConfigurationEvent()
.thenCompose(v -> applyConfigurationResourcePack(serverConn, firstJoin))
.thenCompose(v -> server.getEventManager().fire(new PlayerFinishConfigurationEvent(player, serverConn))
.completeOnTimeout(null, 5, TimeUnit.SECONDS)).thenRunAsync(() -> {
if (player.getConnection().isClosed()) {
return;
}

player.getConnection().write(FinishedUpdatePacket.INSTANCE);
player.getConnection().getChannel().pipeline().get(MinecraftEncoder.class).setState(StateRegistry.PLAY);
server.getEventManager().fireAndForget(new PlayerFinishedConfigurationEvent(player, serverConn));
Expand All @@ -359,4 +392,65 @@ public CompletableFuture<Void> handleBackendFinishUpdate(VelocityServerConnectio

return configSwitchFuture;
}

/**
* Fires the {@link PlayerConfigurationResourcePackEvent} and, if a pack was set, holds the player
* in configuration until it settles. A {@link ResourcePackRequest#required() required} pack is
* held indefinitely until the client acks; an optional pack is held only until
* {@link #OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS}, then advances to play as if declined.
*
* @param serverConn the server (re-)configuring the player
* @param firstJoin whether this is the initial configuration following login
* @return a future completing once the pack(s) settle, or immediately if none were set
*/
private CompletableFuture<Void> applyConfigurationResourcePack(VelocityServerConnection serverConn,
boolean firstJoin) {
PlayerConfigurationResourcePackEvent event =
new PlayerConfigurationResourcePackEvent(player, serverConn, firstJoin);
return server.getEventManager().fire(event).thenComposeAsync(result -> {
ResourcePackRequest request = result.getResourcePack();
if (request == null || player.getConnection().isClosed()) {
return CompletableFuture.completedFuture(null);
}

CompletableFuture<Void> hold = player.resourcePackHandler().queueResourcePackAndAwait(request);
this.resourcePackHold = hold;
startResourcePackKeepAlive();

CompletableFuture<Void> gate = request.required()
? hold
: hold.completeOnTimeout(null, OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS, TimeUnit.SECONDS);

return gate.whenCompleteAsync((v, t) -> {
stopResourcePackKeepAlive();
this.resourcePackHold = null;
}, player.getConnection().eventLoop()).exceptionally(t -> {
LOGGER.error("Couldn't apply configuration resource pack for {}", player, t);
return null;
});
}, player.getConnection().eventLoop());
}

public boolean isAwaitingConfigurationResourcePack() {
return resourcePackHold != null;
}

private void startResourcePackKeepAlive() {
if (resourcePackKeepAlive == null) {
resourcePackKeepAlive = player.getConnection().eventLoop().scheduleAtFixedRate(
() -> {
player.sendKeepAlive();
},
RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS,
RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS,
TimeUnit.SECONDS);
}
}

private void stopResourcePackKeepAlive() {
if (resourcePackKeepAlive != null) {
resourcePackKeepAlive.cancel(false);
resourcePackKeepAlive = null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import io.netty.buffer.ByteBufUtil;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -60,6 +61,8 @@ public abstract sealed class ResourcePackHandler permits LegacyResourcePackHandl

private final Map<UUID, ResourcePackCallback> packCallbacks = new ConcurrentHashMap<>();

private final Set<PackAwait> packAwaits = ConcurrentHashMap.newKeySet();

protected ResourcePackHandler(ConnectedPlayer player, VelocityServer server) {
this.player = player;
this.server = server;
Expand Down Expand Up @@ -139,6 +142,36 @@ public void queueResourcePack(@NotNull ResourcePackRequest request) {
}
}

/**
* Queues a resource-pack request and returns a future that completes once every pack has reached
* a terminal status, or completes exceptionally if the packs could not be queued.
*
* @param request the resource pack request to queue
* @return a future completing when all packs reach a terminal status
*/
public CompletableFuture<Void> queueResourcePackAndAwait(@NotNull ResourcePackRequest request) {
Set<UUID> remaining = ConcurrentHashMap.newKeySet();
for (net.kyori.adventure.resource.ResourcePackInfo pack : request.packs()) {
Comment thread
R00tB33rMan marked this conversation as resolved.
remaining.add(pack.id());
}

CompletableFuture<Void> future = new CompletableFuture<>();
if (remaining.isEmpty()) {
future.complete(null);
return future;
}

PackAwait await = new PackAwait(remaining, future);
packAwaits.add(await);
future.whenComplete((v, t) -> packAwaits.remove(await));
try {
queueResourcePack(request);
} catch (RuntimeException e) {
future.completeExceptionally(e);
}
return future;
}

protected void sendResourcePackRequestPacket(@NotNull ResourcePackInfo queued) {
ResourcePackRequestPacket request = new ResourcePackRequestPacket();
request.setId(queued.getId());
Expand Down Expand Up @@ -224,6 +257,12 @@ protected CompletableFuture<Void> dispatchPackCallback(@Nullable UUID uuid,
return CompletableFuture.completedFuture(null);
}

if (!status.isIntermediate()) {
for (PackAwait await : packAwaits) {
await.resolve(uuid);
}
}

ResourcePackCallback callback = status.isIntermediate()
? packCallbacks.get(uuid)
: packCallbacks.remove(uuid);
Expand Down Expand Up @@ -253,4 +292,22 @@ public void checkAlreadyAppliedPack(byte[] hash) {
throw new IllegalStateException("Cannot apply a resource pack already applied");
}
}

private static final class PackAwait {

private final Set<UUID> remaining;

private final CompletableFuture<Void> future;

private PackAwait(Set<UUID> remaining, CompletableFuture<Void> future) {
this.remaining = remaining;
this.future = future;
}

private void resolve(UUID uuid) {
if (remaining.remove(uuid) && remaining.isEmpty()) {
future.complete(null);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ velocity.error.logging-in-too-fast=<red>You are logging in too fast, try again l
velocity.error.online-mode-only=<red>You are not logged into your Minecraft account. If you are logged into your Minecraft account, try restarting your Minecraft client.
velocity.error.player-connection-error=<red>An internal error occurred in your connection.
velocity.error.plugin-message-overflow=<red>You sent too many plugin messages before completing the connection.
velocity.error.resource-pack-configuration-timeout=<red>Timed out while applying the resource pack. Please reconnect and try again.
velocity.error.modern-forwarding-needs-new-client=<red>This server is only compatible with <arg:0> to <arg:1>.
velocity.error.modern-forwarding-failed=<red>Your server did not send a forwarding request to the proxy. Make sure the server is configured for Velocity forwarding.
velocity.error.moved-to-new-server=<white>You were kicked from <yellow><arg:0><white>: <arg:1>
Expand Down