Skip to content

Commit 6f98762

Browse files
Add PlayerConfigurationResourcePackEvent to apply resource packs during configuration (#992)
1 parent ef29bf8 commit 6f98762

5 files changed

Lines changed: 272 additions & 1 deletion

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (C) 2026 Velocity-CTD Contributors
3+
*
4+
* The Velocity API is licensed under the terms of the MIT License. For more details,
5+
* reference the LICENSE file in the api top-level directory.
6+
*/
7+
8+
package com.velocityctd.api.event.player.configuration;
9+
10+
import com.google.common.base.Preconditions;
11+
import com.velocitypowered.api.event.annotation.AwaitingEvent;
12+
import com.velocitypowered.api.proxy.Player;
13+
import com.velocitypowered.api.proxy.ServerConnection;
14+
import net.kyori.adventure.resource.ResourcePackRequest;
15+
import net.kyori.adventure.resource.ResourcePackRequestLike;
16+
import org.checkerframework.checker.nullness.qual.Nullable;
17+
18+
/**
19+
* Fired while a player is in the configuration state, allowing plugins to apply resource packs
20+
* before the player enters play.
21+
*
22+
* <p>If a resource pack is set, the player is held in configuration until every pack reaches a
23+
* terminal status. The event is fired on every (re)configuration; use {@link #isFirstJoin()} to
24+
* tell the initial post-login configuration (e.g. network-wide packs) from a server-switch
25+
* reconfiguration (e.g. per-server packs).</p>
26+
*
27+
* @since Minecraft 1.20.2
28+
*/
29+
@AwaitingEvent
30+
public final class PlayerConfigurationResourcePackEvent {
31+
32+
private final Player player;
33+
private final ServerConnection server;
34+
private final boolean firstJoin;
35+
36+
private @Nullable ResourcePackRequest resourcePack;
37+
38+
/**
39+
* Constructs a new {@link PlayerConfigurationResourcePackEvent}.
40+
*
41+
* @param player the player being configured
42+
* @param server the server (re-)configuring the player
43+
* @param firstJoin whether this is the initial configuration following login
44+
*/
45+
public PlayerConfigurationResourcePackEvent(Player player,
46+
ServerConnection server,
47+
boolean firstJoin) {
48+
this.player = Preconditions.checkNotNull(player, "player");
49+
this.server = server;
50+
this.firstJoin = firstJoin;
51+
}
52+
53+
/**
54+
* Gets the player being configured.
55+
*
56+
* @return the player
57+
*/
58+
public Player getPlayer() {
59+
return this.player;
60+
}
61+
62+
/**
63+
* Gets the server (re-)configuring the player.
64+
*
65+
* @return the configuring server connection
66+
*/
67+
public ServerConnection getServer() {
68+
return this.server;
69+
}
70+
71+
/**
72+
* Gets whether this is the initial configuration following login, rather than a server-switch
73+
* reconfiguration.
74+
*
75+
* @return {@code true} if this is the player's first configuration this session
76+
*/
77+
public boolean isFirstJoin() {
78+
return this.firstJoin;
79+
}
80+
81+
/**
82+
* Gets the resource pack request to apply during this configuration, if any.
83+
*
84+
* @return the resource pack request, or {@code null} if none has been set
85+
*/
86+
public @Nullable ResourcePackRequest getResourcePack() {
87+
return this.resourcePack;
88+
}
89+
90+
/**
91+
* Sets the resource pack(s) to apply during this configuration.
92+
*
93+
* <p>Accepts a single {@link com.velocitypowered.api.proxy.player.ResourcePackInfo} or a
94+
* {@link ResourcePackRequest}; pass {@code null} to apply none.</p>
95+
*
96+
* @param resourcePack the resource pack request to apply, or {@code null} to apply none
97+
*/
98+
public void setResourcePack(@Nullable ResourcePackRequestLike resourcePack) {
99+
this.resourcePack = resourcePack == null ? null : resourcePack.asResourcePackRequest();
100+
}
101+
102+
@Override
103+
public String toString() {
104+
return "PlayerConfigurationResourcePackEvent{"
105+
+ "player=" + player
106+
+ ", server=" + server
107+
+ ", firstJoin=" + firstJoin
108+
+ ", resourcePack=" + resourcePack
109+
+ '}';
110+
}
111+
}

proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
import java.net.InetSocketAddress;
6565
import java.util.concurrent.CompletableFuture;
6666
import net.kyori.adventure.key.Key;
67+
import net.kyori.adventure.text.Component;
68+
import net.kyori.adventure.text.format.NamedTextColor;
6769
import org.apache.logging.log4j.LogManager;
6870
import org.apache.logging.log4j.Logger;
6971

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

399401
@Override
400402
public void disconnected() {
403+
ConnectedPlayer player = serverConn.getPlayer();
404+
if (player.getConnection().getActiveSessionHandler() instanceof ClientConfigSessionHandler configHandler
405+
&& configHandler.isAwaitingConfigurationResourcePack()) {
406+
player.disconnect(Component.translatable("velocity.error.resource-pack-configuration-timeout", NamedTextColor.RED));
407+
}
408+
401409
resultFuture.complete(ConnectionRequestResults.forDisconnect(
402410
ConnectionMessages.INTERNAL_SERVER_CONNECTION_ERROR, serverConn.getServer()));
403411
}

proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package com.velocitypowered.proxy.connection.client;
1919

20+
import com.velocityctd.api.event.player.configuration.PlayerConfigurationResourcePackEvent;
2021
import com.velocitypowered.api.event.connection.PluginMessageEvent;
2122
import com.velocitypowered.api.event.player.CookieReceiveEvent;
2223
import com.velocitypowered.api.event.player.PlayerClientBrandEvent;
@@ -50,12 +51,15 @@
5051
import io.netty.buffer.ByteBufUtil;
5152
import io.netty.buffer.Unpooled;
5253
import java.util.concurrent.CompletableFuture;
54+
import java.util.concurrent.ScheduledFuture;
5355
import java.util.concurrent.TimeUnit;
5456
import net.kyori.adventure.key.Key;
57+
import net.kyori.adventure.resource.ResourcePackRequest;
5558
import net.kyori.adventure.text.Component;
5659
import net.kyori.adventure.text.format.NamedTextColor;
5760
import org.apache.logging.log4j.LogManager;
5861
import org.apache.logging.log4j.Logger;
62+
import org.checkerframework.checker.nullness.qual.Nullable;
5963

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

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

73+
// Backends don't send keepalives during configuration, so the proxy pings the client itself
74+
// while holding it to apply a pack.
75+
private static final long RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS = 1L;
76+
77+
// Advance an unsettled optional pack to PLAY before the backend's ~15s config timeout. Kept below
78+
// that budget minus the following PlayerFinishConfigurationEvent (up to 5s) and the client ack.
79+
private static final long OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS = 9L;
80+
6981
private final VelocityServer server;
7082

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

7789
private CompletableFuture<Void> configSwitchFuture;
7890

91+
private boolean configuredOnce;
92+
93+
// Active resource pack hold and its keepalive task, or null when no hold is in progress.
94+
private volatile @Nullable CompletableFuture<Void> resourcePackHold;
95+
private @Nullable ScheduledFuture<?> resourcePackKeepAlive;
96+
7997
/**
8098
* Constructs a client config session handler.
8199
*
@@ -268,6 +286,12 @@ public void handleUnknown(ByteBuf buf) {
268286

269287
@Override
270288
public void disconnected() {
289+
stopResourcePackKeepAlive();
290+
CompletableFuture<Void> hold = this.resourcePackHold;
291+
if (hold != null) {
292+
hold.complete(null);
293+
}
294+
271295
player.teardown();
272296
}
273297

@@ -347,8 +371,17 @@ public CompletableFuture<Void> handleBackendFinishUpdate(VelocityServerConnectio
347371
smc.write(brandPacket);
348372
}
349373

350-
callConfigurationEvent().thenCompose(v -> server.getEventManager().fire(new PlayerFinishConfigurationEvent(player, serverConn))
374+
boolean firstJoin = !configuredOnce;
375+
configuredOnce = true;
376+
377+
callConfigurationEvent()
378+
.thenCompose(v -> applyConfigurationResourcePack(serverConn, firstJoin))
379+
.thenCompose(v -> server.getEventManager().fire(new PlayerFinishConfigurationEvent(player, serverConn))
351380
.completeOnTimeout(null, 5, TimeUnit.SECONDS)).thenRunAsync(() -> {
381+
if (player.getConnection().isClosed()) {
382+
return;
383+
}
384+
352385
player.getConnection().write(FinishedUpdatePacket.INSTANCE);
353386
player.getConnection().getChannel().pipeline().get(MinecraftEncoder.class).setState(StateRegistry.PLAY);
354387
server.getEventManager().fireAndForget(new PlayerFinishedConfigurationEvent(player, serverConn));
@@ -359,4 +392,65 @@ public CompletableFuture<Void> handleBackendFinishUpdate(VelocityServerConnectio
359392

360393
return configSwitchFuture;
361394
}
395+
396+
/**
397+
* Fires the {@link PlayerConfigurationResourcePackEvent} and, if a pack was set, holds the player
398+
* in configuration until it settles. A {@link ResourcePackRequest#required() required} pack is
399+
* held indefinitely until the client acks; an optional pack is held only until
400+
* {@link #OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS}, then advances to play as if declined.
401+
*
402+
* @param serverConn the server (re-)configuring the player
403+
* @param firstJoin whether this is the initial configuration following login
404+
* @return a future completing once the pack(s) settle, or immediately if none were set
405+
*/
406+
private CompletableFuture<Void> applyConfigurationResourcePack(VelocityServerConnection serverConn,
407+
boolean firstJoin) {
408+
PlayerConfigurationResourcePackEvent event =
409+
new PlayerConfigurationResourcePackEvent(player, serverConn, firstJoin);
410+
return server.getEventManager().fire(event).thenComposeAsync(result -> {
411+
ResourcePackRequest request = result.getResourcePack();
412+
if (request == null || player.getConnection().isClosed()) {
413+
return CompletableFuture.completedFuture(null);
414+
}
415+
416+
CompletableFuture<Void> hold = player.resourcePackHandler().queueResourcePackAndAwait(request);
417+
this.resourcePackHold = hold;
418+
startResourcePackKeepAlive();
419+
420+
CompletableFuture<Void> gate = request.required()
421+
? hold
422+
: hold.completeOnTimeout(null, OPTIONAL_RESOURCE_PACK_HOLD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
423+
424+
return gate.whenCompleteAsync((v, t) -> {
425+
stopResourcePackKeepAlive();
426+
this.resourcePackHold = null;
427+
}, player.getConnection().eventLoop()).exceptionally(t -> {
428+
LOGGER.error("Couldn't apply configuration resource pack for {}", player, t);
429+
return null;
430+
});
431+
}, player.getConnection().eventLoop());
432+
}
433+
434+
public boolean isAwaitingConfigurationResourcePack() {
435+
return resourcePackHold != null;
436+
}
437+
438+
private void startResourcePackKeepAlive() {
439+
if (resourcePackKeepAlive == null) {
440+
resourcePackKeepAlive = player.getConnection().eventLoop().scheduleAtFixedRate(
441+
() -> {
442+
player.sendKeepAlive();
443+
},
444+
RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS,
445+
RESOURCE_PACK_KEEP_ALIVE_INTERVAL_SECONDS,
446+
TimeUnit.SECONDS);
447+
}
448+
}
449+
450+
private void stopResourcePackKeepAlive() {
451+
if (resourcePackKeepAlive != null) {
452+
resourcePackKeepAlive.cancel(false);
453+
resourcePackKeepAlive = null;
454+
}
455+
}
362456
}

proxy/src/main/java/com/velocitypowered/proxy/connection/player/resourcepack/handler/ResourcePackHandler.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.netty.buffer.ByteBufUtil;
3232
import java.util.Collection;
3333
import java.util.Map;
34+
import java.util.Set;
3435
import java.util.UUID;
3536
import java.util.concurrent.CompletableFuture;
3637
import java.util.concurrent.ConcurrentHashMap;
@@ -60,6 +61,8 @@ public abstract sealed class ResourcePackHandler permits LegacyResourcePackHandl
6061

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

64+
private final Set<PackAwait> packAwaits = ConcurrentHashMap.newKeySet();
65+
6366
protected ResourcePackHandler(ConnectedPlayer player, VelocityServer server) {
6467
this.player = player;
6568
this.server = server;
@@ -139,6 +142,36 @@ public void queueResourcePack(@NotNull ResourcePackRequest request) {
139142
}
140143
}
141144

145+
/**
146+
* Queues a resource-pack request and returns a future that completes once every pack has reached
147+
* a terminal status, or completes exceptionally if the packs could not be queued.
148+
*
149+
* @param request the resource pack request to queue
150+
* @return a future completing when all packs reach a terminal status
151+
*/
152+
public CompletableFuture<Void> queueResourcePackAndAwait(@NotNull ResourcePackRequest request) {
153+
Set<UUID> remaining = ConcurrentHashMap.newKeySet();
154+
for (net.kyori.adventure.resource.ResourcePackInfo pack : request.packs()) {
155+
remaining.add(pack.id());
156+
}
157+
158+
CompletableFuture<Void> future = new CompletableFuture<>();
159+
if (remaining.isEmpty()) {
160+
future.complete(null);
161+
return future;
162+
}
163+
164+
PackAwait await = new PackAwait(remaining, future);
165+
packAwaits.add(await);
166+
future.whenComplete((v, t) -> packAwaits.remove(await));
167+
try {
168+
queueResourcePack(request);
169+
} catch (RuntimeException e) {
170+
future.completeExceptionally(e);
171+
}
172+
return future;
173+
}
174+
142175
protected void sendResourcePackRequestPacket(@NotNull ResourcePackInfo queued) {
143176
ResourcePackRequestPacket request = new ResourcePackRequestPacket();
144177
request.setId(queued.getId());
@@ -224,6 +257,12 @@ protected CompletableFuture<Void> dispatchPackCallback(@Nullable UUID uuid,
224257
return CompletableFuture.completedFuture(null);
225258
}
226259

260+
if (!status.isIntermediate()) {
261+
for (PackAwait await : packAwaits) {
262+
await.resolve(uuid);
263+
}
264+
}
265+
227266
ResourcePackCallback callback = status.isIntermediate()
228267
? packCallbacks.get(uuid)
229268
: packCallbacks.remove(uuid);
@@ -253,4 +292,22 @@ public void checkAlreadyAppliedPack(byte[] hash) {
253292
throw new IllegalStateException("Cannot apply a resource pack already applied");
254293
}
255294
}
295+
296+
private static final class PackAwait {
297+
298+
private final Set<UUID> remaining;
299+
300+
private final CompletableFuture<Void> future;
301+
302+
private PackAwait(Set<UUID> remaining, CompletableFuture<Void> future) {
303+
this.remaining = remaining;
304+
this.future = future;
305+
}
306+
307+
private void resolve(UUID uuid) {
308+
if (remaining.remove(uuid) && remaining.isEmpty()) {
309+
future.complete(null);
310+
}
311+
}
312+
}
256313
}

proxy/src/main/resources/com/velocitypowered/proxy/l10n/messages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ velocity.error.logging-in-too-fast=<red>You are logging in too fast, try again l
4747
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.
4848
velocity.error.player-connection-error=<red>An internal error occurred in your connection.
4949
velocity.error.plugin-message-overflow=<red>You sent too many plugin messages before completing the connection.
50+
velocity.error.resource-pack-configuration-timeout=<red>Timed out while applying the resource pack. Please reconnect and try again.
5051
velocity.error.modern-forwarding-needs-new-client=<red>This server is only compatible with <arg:0> to <arg:1>.
5152
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.
5253
velocity.error.moved-to-new-server=<white>You were kicked from <yellow><arg:0><white>: <arg:1>

0 commit comments

Comments
 (0)