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 04e65c84..efe21cd7 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Optional; import java.util.UUID; import java.util.function.UnaryOperator; +import net.kyori.adventure.dialog.DialogLike; import net.kyori.adventure.identity.Identified; import net.kyori.adventure.inventory.Book; import net.kyori.adventure.key.Key; @@ -48,7 +49,7 @@ public interface Player extends /* Fundamental Velocity interfaces */ CommandSource, InboundConnection, ChannelMessageSource, ChannelMessageSink, /* Adventure-specific interfaces */ - Identified, HoverEventSource, Keyed, KeyIdentifiable { + Identified, HoverEventSource, Keyed, KeyIdentifiable, Sound.Emitter { /** * Returns the player's current username. @@ -383,8 +384,12 @@ public interface Player extends /** * {@inheritDoc} * - * This method is not currently implemented in Velocity - * and will not perform any actions. + * + * @apiNote This method is not currently implemented in Velocity + * and will not perform any actions. + * @see #playSound(Sound, Sound.Emitter) + * @see + * Unsupported Adventure Operations */ @Override default void playSound(@NotNull Sound sound) { @@ -393,8 +398,11 @@ public interface Player extends /** * {@inheritDoc} * - * This method is not currently implemented in Velocity - * and will not perform any actions. + * @apiNote This method is not currently implemented in Velocity + * and will not perform any actions. + * @see #playSound(Sound, Sound.Emitter) + * @see + * Unsupported Adventure Operations */ @Override default void playSound(@NotNull Sound sound, double x, double y, double z) { @@ -403,18 +411,28 @@ public interface Player extends /** * {@inheritDoc} * - * This method is not currently implemented in Velocity - * and will not perform any actions. + *

Note: Due to MC-146721, stereo sounds are always played globally in 1.14+. + * + *

Note: Due to MC-138832, the volume and pitch are ignored when using this method in 1.14 to 1.16.5. + * + * @param sound the sound to play + * @param emitter the emitter of the sound; may be another player of this player's server + * @since 3.4.0 + * @sinceMinecraft 1.19.3 + * @apiNote This method is currently only implemented for players on 1.19.3+ + * and requires a present {@link #getCurrentServer} for the emitting player as well as this player. */ @Override - default void playSound(@NotNull Sound sound, Sound.Emitter emitter) { + default void playSound(@NotNull Sound sound, @NotNull Sound.Emitter emitter) { } /** * {@inheritDoc} * - * This method is not currently implemented in Velocity - * and will not perform any actions. + * @param stop the sound and/or a sound source, to stop + * @since 3.4.0 + * @sinceMinecraft 1.19.3 + * @apiNote This method is currently only implemented for players on 1.19.3+. */ @Override default void stopSound(@NotNull SoundStop stop) { @@ -425,11 +443,40 @@ public interface Player extends * * This method is not currently implemented in Velocity * and will not perform any actions. + * + * @see + * Unsupported Adventure Operations */ @Override default void openBook(@NotNull Book book) { } + /** + * {@inheritDoc} + * + * This method is not currently implemented in Velocity + * and will not perform any actions. + * + * @see + * Unsupported Adventure Operations + */ + @Override + default void showDialog(@NotNull DialogLike dialog) { + } + + /** + * {@inheritDoc} + * + * This method is not currently implemented in Velocity + * and will not perform any actions. + * + * @see + * Unsupported Adventure Operations + */ + @Override + default void closeDialog() { + } + /** * Transfers a Player to a host. * 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 94620218..d1101d6a 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.BossBarPacket; 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.DialogClearPacket; import com.velocitypowered.proxy.protocol.packet.DialogShowPacket; @@ -390,4 +392,11 @@ public interface MinecraftSessionHandler { 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 af3f8179..71ebd7bc 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.HashMap; 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 class VelocityServerConnection implements MinecraftConnectionAssociation, 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 4b658cd6..254842cf 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 @@ -575,6 +575,8 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } } + 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 6d32535f..44fe95c5 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 @@ -73,6 +73,8 @@ import com.velocitypowered.proxy.protocol.netty.MinecraftEncoder; 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; @@ -127,6 +129,8 @@ import net.kyori.adventure.pointer.PointersSupplier; 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; @@ -1042,6 +1046,50 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player, this.clientBrand = clientBrand; } + @Override + public void playSound(@NotNull Sound sound, @NotNull Sound.Emitter emitter) { + Preconditions.checkNotNull(sound, "sound"); + Preconditions.checkNotNull(emitter, "emitter"); + VelocityServerConnection soundTargetServerConn = getConnectedServer(); + if (getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_19_3) + || connection.getState() != StateRegistry.PLAY + || soundTargetServerConn == null + || (sound.source() == Sound.Source.UI + && getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_21_5))) { + return; + } + + VelocityServerConnection soundEmitterServerConn; + if (emitter == Sound.Emitter.self()) { + soundEmitterServerConn = soundTargetServerConn; + } else if (emitter instanceof ConnectedPlayer player) { + if ((soundEmitterServerConn = player.getConnectedServer()) == null) { + return; + } + + if (!soundEmitterServerConn.getServer().equals(soundTargetServerConn.getServer())) { + return; + } + } else { + return; + } + + connection.write(new ClientboundSoundEntityPacket(sound, null, soundEmitterServerConn.getEntityId())); + } + + @Override + public void stopSound(@NotNull SoundStop stop) { + Preconditions.checkNotNull(stop, "stop"); + if (getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_19_3) + || connection.getState() != StateRegistry.PLAY + || (stop.source() == Sound.Source.UI + && getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_21_5))) { + return; + } + + 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/ProtocolUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java index 5b54e0f0..f0d1d63b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java @@ -44,6 +44,7 @@ import net.kyori.adventure.nbt.BinaryTagIO; import net.kyori.adventure.nbt.BinaryTagType; import net.kyori.adventure.nbt.BinaryTagTypes; import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.json.JSONOptions; import net.kyori.adventure.text.serializer.json.legacyimpl.NBTLegacyHoverEventSerializer; @@ -316,6 +317,16 @@ public enum ProtocolUtils { writeString(buf, key.asString()); } + /** + * Writes the key to the buffer, dropping the "minecraft:" namespace when present. + * + * @param buf the buffer to write to + * @param key the key to write + */ + public static void writeMinimalKey(ByteBuf buf, Key key) { + writeString(buf, key.asMinimalString()); + } + /** * Reads a standard Mojang Text namespaced:key array from the buffer. * @@ -781,6 +792,40 @@ public enum ProtocolUtils { return new IdentifiedKeyImpl(revision, key, expiry, signature); } + /** + * Reads a {@link Sound.Source} from the buffer. + * + * @param buf the buffer + * @param version the protocol version + * @return the sound source + */ + public static Sound.Source readSoundSource(ByteBuf buf, ProtocolVersion version) { + int ordinal = readVarInt(buf); + + if (version.lessThan(ProtocolVersion.MINECRAFT_1_21_5) + && ordinal == Sound.Source.UI.ordinal()) { + throw new UnsupportedOperationException("UI sound-source is only supported in 1.21.5+"); + } + + return Sound.Source.values()[ordinal]; + } + + /** + * Writes a {@link Sound.Source} to the buffer. + * + * @param buf the buffer + * @param version the protocol version + * @param source the sound source to write + */ + public static void writeSoundSource(ByteBuf buf, ProtocolVersion version, Sound.Source source) { + if (version.lessThan(ProtocolVersion.MINECRAFT_1_21_5) + && source == Sound.Source.UI) { + throw new UnsupportedOperationException("UI sound-source is only supported in 1.21.5+"); + } + + writeVarInt(buf, source.ordinal()); + } + /** * Represents the direction in which a packet flows. */ 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 42d60029..67b01087 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -59,6 +59,8 @@ import com.velocitypowered.proxy.protocol.packet.BossBarPacket; 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.DialogClearPacket; import com.velocitypowered.proxy.protocol.packet.DialogShowPacket; @@ -448,6 +450,26 @@ public enum StateRegistry { ClientboundCookieRequestPacket.class, ClientboundCookieRequestPacket::new, map(0x16, MINECRAFT_1_20_5, false), map(0x15, MINECRAFT_1_21_5, false)); + clientbound.register( + ClientboundSoundEntityPacket.class, ClientboundSoundEntityPacket::new, + map(0x5D, MINECRAFT_1_19_3, true), + map(0x61, MINECRAFT_1_19_4, true), + map(0x63, MINECRAFT_1_20_2, true), + map(0x65, MINECRAFT_1_20_3, true), + map(0x67, MINECRAFT_1_20_5, true), + map(0x6E, MINECRAFT_1_21_2, true), + map(0x6D, MINECRAFT_1_21_5, true), + map(0x72, MINECRAFT_1_21_9, true)); + clientbound.register( + ClientboundStopSoundPacket.class, ClientboundStopSoundPacket::new, + map(0x5F, MINECRAFT_1_19_3, true), + map(0x63, MINECRAFT_1_19_4, true), + map(0x66, MINECRAFT_1_20_2, true), + map(0x68, MINECRAFT_1_20_3, true), + map(0x6A, MINECRAFT_1_20_5, true), + map(0x71, MINECRAFT_1_21_2, true), + map(0x70, MINECRAFT_1_21_5, true), + map(0x75, MINECRAFT_1_21_9, true)); 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 00000000..459f1430 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundSoundEntityPacket.java @@ -0,0 +1,101 @@ +/* + * 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; +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 emitterEntityId; + + public ClientboundSoundEntityPacket() {} + + public ClientboundSoundEntityPacket(Sound sound, @Nullable Float fixedRange, int emitterEntityId) { + this.sound = sound; + this.fixedRange = fixedRange; + this.emitterEntityId = emitterEntityId; + } + + @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.writeMinimalKey(buf, sound.name()); + + buf.writeBoolean(fixedRange != null); + if (fixedRange != null) + buf.writeFloat(fixedRange); + + ProtocolUtils.writeSoundSource(buf, protocolVersion, sound.source()); + + ProtocolUtils.writeVarInt(buf, emitterEntityId); + + 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); + } + + public Sound getSound() { + return sound; + } + + public void setSound(Sound sound) { + this.sound = sound; + } + + public @Nullable Float getFixedRange() { + return fixedRange; + } + + public void setFixedRange(@Nullable Float fixedRange) { + this.fixedRange = fixedRange; + } + + public int getEmitterEntityId() { + return emitterEntityId; + } + + public void setEmitterEntityId(int emitterEntityId) { + this.emitterEntityId = emitterEntityId; + } + +} 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 00000000..3e085d38 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientboundStopSoundPacket.java @@ -0,0 +1,109 @@ +/* + * 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; +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 = ProtocolUtils.readSoundSource(buf, protocolVersion); + } 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.writeSoundSource(buf, protocolVersion, source); + } + + if (soundName != null) { + ProtocolUtils.writeMinimalKey(buf, soundName); + } + } + + @Override + public boolean handle(MinecraftSessionHandler handler) { + return handler.handle(this); + } + + @Nullable + public Sound.Source getSource() { + return source; + } + + public void setSource(@Nullable Sound.Source source) { + this.source = source; + } + + @Nullable + public Key getSoundName() { + return soundName; + } + + public void setSoundName(@Nullable Key soundName) { + this.soundName = soundName; + } + +}