diff --git a/api/src/main/java/com/velocitypowered/api/proxy/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/Player.java
index 04e65c849b..d7527ad590 100644
--- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java
+++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java
@@ -383,8 +383,14 @@ default void clearHeaderAndFooter() {
/**
* {@inheritDoc}
*
- * This method is not currently implemented in Velocity
- * and will not perform any actions.
+ *
Note: This method is currently only implemented for players from version 1.19.3 and above.
+ *
A {@link ServerConnection} is required for this to function, so a {@link #getCurrentServer()}.isPresent() check should be made beforehand.
+ *
+ * @param sound the sound to play
+ * @throws IllegalArgumentException if the player is from a version lower than 1.19.3
+ * @throws IllegalStateException if no server is connected
+ * @since 3.3.0
+ * @sinceMinecraft 1.19.3
*/
@Override
default void playSound(@NotNull Sound sound) {
@@ -413,8 +419,12 @@ default void playSound(@NotNull Sound sound, Sound.Emitter emitter) {
/**
* {@inheritDoc}
*
- * This method is not currently implemented in Velocity
- * and will not perform any actions.
+ *
Note: This method is currently only implemented for players from version 1.19.3 and above.
+ *
+ * @param stop the sound and/or a sound source, to stop
+ * @throws IllegalArgumentException if the player is from a version lower than 1.19.3
+ * @since 3.3.0
+ * @sinceMinecraft 1.19.3
*/
@Override
default void stopSound(@NotNull SoundStop stop) {
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 b36d9f0ab4..6e1dd26b48 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,8 @@
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.ClientboundSoundEntityPacket;
+import com.velocitypowered.proxy.protocol.packet.ClientboundStopSoundPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket;
import com.velocitypowered.proxy.protocol.packet.DisconnectPacket;
import com.velocitypowered.proxy.protocol.packet.EncryptionRequestPacket;
@@ -364,4 +366,12 @@ default boolean handle(ClientboundCustomReportDetailsPacket packet) {
default boolean handle(ClientboundServerLinksPacket packet) {
return false;
}
+
+ default boolean handle(ClientboundSoundEntityPacket packet) {
+ return false;
+ }
+
+ default boolean handle(ClientboundStopSoundPacket 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 af3f817966..71ebd7bc78 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
@@ -53,6 +53,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jetbrains.annotations.NotNull;
@@ -70,6 +71,7 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation,
private boolean gracefulDisconnect = false;
private BackendConnectionPhase connectionPhase = BackendConnectionPhases.UNKNOWN;
private final Map pendingPings = new HashMap<>();
+ private @MonotonicNonNull Integer entityId;
/**
* Initializes a new server connection.
@@ -324,6 +326,14 @@ public Map getPendingPings() {
return pendingPings;
}
+ public Integer getEntityId() {
+ return entityId;
+ }
+
+ public void setEntityId(Integer entityId) {
+ this.entityId = entityId;
+ }
+
/**
* Ensures that this server connection remains "active": the connection is established and not
* closed, the player is still connected to the server, and the player still remains online.
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 b41a24ccfb..9b370af78d 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
@@ -565,6 +565,8 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect
}
}
+ destination.setEntityId(joinGame.getEntityId()); // used for sound api
+
// Remove previous boss bars. These don't get cleared when sending JoinGame, thus the need to
// track them.
for (UUID serverBossBar : serverBossBars) {
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 2b22fc7bc4..56b9fb0899 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
@@ -72,6 +72,8 @@
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.ClientboundSoundEntityPacket;
+import com.velocitypowered.proxy.protocol.packet.ClientboundStopSoundPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket;
import com.velocitypowered.proxy.protocol.packet.DisconnectPacket;
import com.velocitypowered.proxy.protocol.packet.HeaderAndFooterPacket;
@@ -124,6 +126,8 @@
import net.kyori.adventure.resource.ResourcePackInfoLike;
import net.kyori.adventure.resource.ResourcePackRequest;
import net.kyori.adventure.resource.ResourcePackRequestLike;
+import net.kyori.adventure.sound.Sound;
+import net.kyori.adventure.sound.SoundStop;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
@@ -1005,6 +1009,32 @@ void setClientBrand(final @Nullable String clientBrand) {
this.clientBrand = clientBrand;
}
+ @Override
+ public void playSound(@NotNull Sound sound) {
+ Preconditions.checkNotNull(sound, "sound");
+ Preconditions.checkArgument(
+ getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_19_3),
+ "Player version must be 1.19.3 to be able to interact with sounds");
+ if (connection.getState() != StateRegistry.PLAY) {
+ throw new IllegalStateException("Can only interact with sounds in PLAY protocol");
+ }
+
+ connection.write(new ClientboundSoundEntityPacket(sound, null, ensureAndGetCurrentServer().getEntityId()));
+ }
+
+ @Override
+ public void stopSound(@NotNull SoundStop stop) {
+ Preconditions.checkNotNull(stop, "stop");
+ Preconditions.checkArgument(
+ getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_19_3),
+ "Player version must be 1.19.3 to be able to interact with sounds");
+ if (connection.getState() != StateRegistry.PLAY) {
+ throw new IllegalStateException("Can only interact with sounds in PLAY protocol");
+ }
+
+ connection.write(new ClientboundStopSoundPacket(stop));
+ }
+
@Override
public void transferToHost(final InetSocketAddress address) {
Preconditions.checkNotNull(address);
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 41d444a36b..0a91317950 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java
@@ -54,6 +54,8 @@
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.ClientboundSoundEntityPacket;
+import com.velocitypowered.proxy.protocol.packet.ClientboundStopSoundPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket;
import com.velocitypowered.proxy.protocol.packet.DisconnectPacket;
import com.velocitypowered.proxy.protocol.packet.EncryptionRequestPacket;
@@ -399,6 +401,20 @@ public enum StateRegistry {
clientbound.register(
ClientboundCookieRequestPacket.class, ClientboundCookieRequestPacket::new,
map(0x16, MINECRAFT_1_20_5, false));
+ clientbound.register(
+ ClientboundSoundEntityPacket.class, ClientboundSoundEntityPacket::new,
+ map(0x5D, MINECRAFT_1_19_3, false),
+ map(0x61, MINECRAFT_1_19_4, false),
+ map(0x63, MINECRAFT_1_20_2, false),
+ map(0x65, MINECRAFT_1_20_3, false),
+ map(0x67, MINECRAFT_1_20_5, false));
+ clientbound.register(
+ ClientboundStopSoundPacket.class, ClientboundStopSoundPacket::new,
+ map(0x5F, MINECRAFT_1_19_3, false),
+ map(0x63, MINECRAFT_1_19_4, false),
+ map(0x66, MINECRAFT_1_20_2, false),
+ map(0x68, MINECRAFT_1_20_3, false),
+ map(0x6A, MINECRAFT_1_20_5, false));
clientbound.register(
PluginMessagePacket.class,
PluginMessagePacket::new,
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSoundEntityPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSoundEntityPacket.java
new file mode 100644
index 0000000000..1e4972749f
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSoundEntityPacket.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2024 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;
+import net.kyori.adventure.sound.Sound;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Random;
+
+public class ClientboundSoundEntityPacket implements MinecraftPacket {
+
+ private static final Random SEEDS_RANDOM = new Random();
+
+ private Sound sound;
+ private @Nullable Float fixedRange;
+ private int entityId;
+
+ public ClientboundSoundEntityPacket() {}
+
+ public ClientboundSoundEntityPacket(Sound sound, @Nullable Float fixedRange, int entityId) {
+ this.sound = sound;
+ this.fixedRange = fixedRange;
+ this.entityId = entityId;
+ }
+
+ @Override
+ public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
+ throw new UnsupportedOperationException("Decode is not implemented");
+ }
+
+ @Override
+ public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
+ ProtocolUtils.writeVarInt(buf, 0); // version-dependent hardcoded sound id
+
+ ProtocolUtils.writeString(buf, sound.name().asMinimalString()); // not using writeKey, as the client already defaults to the vanilla namespace
+
+ buf.writeBoolean(fixedRange != null);
+ if (fixedRange != null)
+ buf.writeFloat(fixedRange);
+
+ ProtocolUtils.writeVarInt(buf, sound.source().ordinal());
+
+ ProtocolUtils.writeVarInt(buf, entityId);
+
+ buf.writeFloat(sound.volume());
+
+ buf.writeFloat(sound.pitch());
+
+ buf.writeLong(sound.seed().orElse(SEEDS_RANDOM.nextLong()));
+ }
+
+ @Override
+ public boolean handle(MinecraftSessionHandler handler) {
+ return handler.handle(this);
+ }
+
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundStopSoundPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundStopSoundPacket.java
new file mode 100644
index 0000000000..46e7e6df8b
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundStopSoundPacket.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2024 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;
+import net.kyori.adventure.key.Key;
+import net.kyori.adventure.sound.Sound;
+import net.kyori.adventure.sound.SoundStop;
+
+import javax.annotation.Nullable;
+
+public class ClientboundStopSoundPacket implements MinecraftPacket {
+
+ private @Nullable Sound.Source source;
+ private @Nullable Key soundName;
+
+ public ClientboundStopSoundPacket() {}
+
+ public ClientboundStopSoundPacket(SoundStop soundStop) {
+ this(soundStop.source(), soundStop.sound());
+ }
+
+ public ClientboundStopSoundPacket(@Nullable Sound.Source source, @Nullable Key soundName) {
+ this.source = source;
+ this.soundName = soundName;
+ }
+
+ @Override
+ public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
+ int flagsBitmask = buf.readByte();
+
+ if ((flagsBitmask & 1) != 0) {
+ source = Sound.Source.values()[ProtocolUtils.readVarInt(buf)];
+ } else {
+ source = null;
+ }
+
+ if ((flagsBitmask & 2) != 0) {
+ soundName = ProtocolUtils.readKey(buf);
+ } else {
+ soundName = null;
+ }
+ }
+
+ @Override
+ public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
+ int flagsBitmask = 0;
+ if (source != null && soundName == null) {
+ flagsBitmask |= 1;
+ } else if (soundName != null && source == null) {
+ flagsBitmask |= 2;
+ } else if (source != null /*&& sound != null*/) {
+ flagsBitmask |= 3;
+ }
+
+ buf.writeByte(flagsBitmask);
+
+ if (source != null) {
+ ProtocolUtils.writeVarInt(buf, source.ordinal());
+ }
+
+ if (soundName != null) {
+ ProtocolUtils.writeString(buf, soundName.asMinimalString()); // not using writeKey, as the client already defaults to the vanilla namespace
+ }
+ }
+
+ @Override
+ public boolean handle(MinecraftSessionHandler handler) {
+ return handler.handle(this);
+ }
+
+ @Nullable
+ public Sound.Source getSource() {
+ return source;
+ }
+
+ @Nullable
+ public Key getSoundName() {
+ return soundName;
+ }
+
+}