From 7a91c8a41d89b4fbc9a2dc8513e2712aea64ae80 Mon Sep 17 00:00:00 2001 From: Asmin Studios Date: Sat, 24 Oct 2026 21:14:24 +0200 Subject: [PATCH] Fix missing group members on 2.5 clients --- README.md | 8 ++++-- .../voicechat/command/VoiceChatCommands.java | 6 ++++- .../maxhenkel/voicechat/net/NetManager.java | 15 +++++++++-- .../net/ProtocolChannelCompatibility.java | 27 +++++++++++++++++++ .../voice/server/PlayerStateManager.java | 15 ++++++++--- .../voice/server/ServerVoiceEvents.java | 4 +++ tests/ProtocolCryptoSelfTest.java | 21 ++++++++++++++- 7 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 source/bukkit/src/main/java/de/maxhenkel/voicechat/net/ProtocolChannelCompatibility.java diff --git a/README.md b/README.md index b1397f6..efe9559 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,12 @@ The plugin is compiled to Java 8 bytecode and runs on newer JVMs. The server negotiates the protocol generation during the existing voice-chat handshake and stores it with that player's 16-byte secret. The UDP path then uses AES-CBC for 2.5 clients and AES-GCM for 2.6 clients. Shared packet layouts -are passed through the existing 2.6 voice routing. The fork changes only the -per-client cipher selection and compatibility-version acceptance. +are passed through the existing 2.6 voice routing. State messages are sent on +the channel names expected by each client generation, so 2.5 clients receive +their player and group-member updates as well. For compatibility 18 the server +maps `voicechat:state` and `voicechat:states` to the older +`voicechat:player_state` and `voicechat:player_states` channels. When a player +leaves, it sends the disconnected state because 2.5 has no remove-state packet. The Bukkit plugin source and API source are in `source/bukkit/` and `source/api/`. The cipher and compatibility changes are in the regular source diff --git a/source/bukkit/src/main/java/de/maxhenkel/voicechat/command/VoiceChatCommands.java b/source/bukkit/src/main/java/de/maxhenkel/voicechat/command/VoiceChatCommands.java index 086a3d3..a671856 100644 --- a/source/bukkit/src/main/java/de/maxhenkel/voicechat/command/VoiceChatCommands.java +++ b/source/bukkit/src/main/java/de/maxhenkel/voicechat/command/VoiceChatCommands.java @@ -112,6 +112,8 @@ public class VoiceChatCommands implements CommandExecutor, TabCompleter { return true; } + int clientCompatibilityVersion = Voicechat.SERVER.getClientCompatibilityVersion(player.getUniqueId()); + ClientConnection clientConnection = Voicechat.SERVER.getServer().getConnections().get(player.getUniqueId()); if (clientConnection == null) { @@ -130,6 +132,8 @@ public class VoiceChatCommands implements CommandExecutor, TabCompleter { } else { NetManager.sendMessage(commandSender, "message.voicechat.ping_received_attempt", String.valueOf(pingMilliseconds), String.valueOf(attempts)); } + commandSender.sendMessage("Voice chat connection verified (client compatibility version " + + clientCompatibilityVersion + ")."); } @Override @@ -326,4 +330,4 @@ public class VoiceChatCommands implements CommandExecutor, TabCompleter { return null; } } -} \ No newline at end of file +} diff --git a/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/NetManager.java b/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/NetManager.java index da80ef5..c48cd89 100644 --- a/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/NetManager.java +++ b/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/NetManager.java @@ -38,6 +38,9 @@ public class NetManager implements Listener { registerOutgoingPacket(JoinedGroupPacket.class); registerOutgoingPacket(AddCategoryPacket.class); registerOutgoingPacket(RemoveCategoryPacket.class); + + registerOutgoingChannel(ProtocolChannelCompatibility.LEGACY_PLAYER_STATE); + registerOutgoingChannel(ProtocolChannelCompatibility.LEGACY_PLAYER_STATES); } catch (Exception e) { throw new IllegalStateException("Failed to register voice chat packets", e); } @@ -100,6 +103,10 @@ public class NetManager implements Listener { private > void registerOutgoingPacket(Class packetClass) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { String id = packetClass.getDeclaredConstructor().newInstance().getID().toString(); + registerOutgoingChannel(id); + } + + private void registerOutgoingChannel(String id) { packets.add(id); Bukkit.getMessenger().registerOutgoingPluginChannel(Voicechat.INSTANCE, id); } @@ -114,7 +121,11 @@ public class NetManager implements Listener { p.toBytes(buf); byte[] bytes = new byte[buf.readableBytes()]; buf.readBytes(bytes); - player.sendPluginMessage(Voicechat.INSTANCE, p.getID().toString(), bytes); + int compatibilityVersion = Voicechat.SERVER.getClientCompatibilityVersion(player.getUniqueId()); + String channel = ProtocolChannelCompatibility.outgoingChannel(p.getID().toString(), compatibilityVersion); + if (channel != null) { + player.sendPluginMessage(Voicechat.INSTANCE, channel, bytes); + } } catch (Exception e) { Voicechat.LOGGER.error("Failed to send packet to '{}'", player.getName(), e); } @@ -129,4 +140,4 @@ public class NetManager implements Listener { Voicechat.compatibility.sendStatusMessage(player, key, args); } -} \ No newline at end of file +} diff --git a/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/ProtocolChannelCompatibility.java b/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/ProtocolChannelCompatibility.java new file mode 100644 index 0000000..a14617c --- /dev/null +++ b/source/bukkit/src/main/java/de/maxhenkel/voicechat/net/ProtocolChannelCompatibility.java @@ -0,0 +1,27 @@ +package de.maxhenkel.voicechat.net; + +public final class ProtocolChannelCompatibility { + public static final int LEGACY_COMPATIBILITY_VERSION = 18; + + public static final String LEGACY_PLAYER_STATE = "voicechat:player_state"; + public static final String LEGACY_PLAYER_STATES = "voicechat:player_states"; + + private ProtocolChannelCompatibility() { + } + + public static String outgoingChannel(String channel, int compatibilityVersion) { + if (compatibilityVersion != LEGACY_COMPATIBILITY_VERSION) { + return channel; + } + switch (channel) { + case "voicechat:state": + return LEGACY_PLAYER_STATE; + case "voicechat:states": + return LEGACY_PLAYER_STATES; + case "voicechat:remove_state": + return null; + default: + return channel; + } + } +} diff --git a/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/PlayerStateManager.java b/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/PlayerStateManager.java index 2cc4d1f..04098c4 100644 --- a/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/PlayerStateManager.java +++ b/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/PlayerStateManager.java @@ -56,8 +56,7 @@ public class PlayerStateManager implements Listener { } public void onPlayerHide(PlayerHideEvent event) { - RemovePlayerStatePacket packet = new RemovePlayerStatePacket(event.getHiddenPlayer().getUniqueId()); - NetManager.sendToClient(event.getObserver(), packet); + sendRemoveState(event.getObserver(), event.getHiddenPlayer()); Voicechat.LOGGER.debug("Removing state of {} for {}", event.getHiddenPlayer().getName(), event.getObserver().getName()); } @@ -83,14 +82,22 @@ public class PlayerStateManager implements Listener { } public void broadcastRemoveState(Player stateOwner) { - RemovePlayerStatePacket packet = new RemovePlayerStatePacket(stateOwner.getUniqueId()); for (Player receiver : Voicechat.INSTANCE.getServer().getOnlinePlayers()) { - NetManager.sendToClient(receiver, packet); + sendRemoveState(receiver, stateOwner); } // Send the default disconnected state to the API when disconnecting PluginManager.instance().onPlayerStateChanged(defaultDisconnectedState(stateOwner)); } + private void sendRemoveState(Player receiver, Player stateOwner) { + if (Voicechat.SERVER.getClientCompatibilityVersion(receiver.getUniqueId()) + == de.maxhenkel.voicechat.net.ProtocolChannelCompatibility.LEGACY_COMPATIBILITY_VERSION) { + NetManager.sendToClient(receiver, new PlayerStatePacket(defaultDisconnectedState(stateOwner))); + return; + } + NetManager.sendToClient(receiver, new RemovePlayerStatePacket(stateOwner.getUniqueId())); + } + public void onPlayerCompatibilityCheckSucceeded(Player player) { List stateList = new ArrayList<>(states.size()); for (PlayerState state : states.values()) { diff --git a/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/ServerVoiceEvents.java b/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/ServerVoiceEvents.java index 44bd8da..a690da9 100644 --- a/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/ServerVoiceEvents.java +++ b/source/bukkit/src/main/java/de/maxhenkel/voicechat/voice/server/ServerVoiceEvents.java @@ -83,6 +83,10 @@ public class ServerVoiceEvents implements Listener { return isSupportedCompatibilityVersion(clientCompatibilities.getOrDefault(playerUuid, -1)); } + public int getClientCompatibilityVersion(UUID playerUuid) { + return clientCompatibilities.getOrDefault(playerUuid, -1); + } + public static void sendIncompatibleMessage(Player player, int clientCompatibilityVersion) { if (clientCompatibilityVersion <= 6) { player.sendMessage(String.format(Voicechat.TRANSLATIONS.voicechatNotCompatibleMessage.get(), diff --git a/tests/ProtocolCryptoSelfTest.java b/tests/ProtocolCryptoSelfTest.java index ea68a28..78bccd4 100644 --- a/tests/ProtocolCryptoSelfTest.java +++ b/tests/ProtocolCryptoSelfTest.java @@ -1,4 +1,5 @@ import de.maxhenkel.voicechat.voice.common.Secret; +import de.maxhenkel.voicechat.net.ProtocolChannelCompatibility; import java.nio.ByteBuffer; import java.security.SecureRandom; import java.util.Arrays; @@ -9,6 +10,8 @@ import javax.crypto.spec.SecretKeySpec; public class ProtocolCryptoSelfTest { public static void main(String[] args) throws Exception { + testStateChannelCompatibility(); + byte[] key = new byte[16]; for (int i = 0; i < key.length; i++) key[i] = (byte) (i * 13 + 7); UUID legacyKey = uuidFromBytes(key); @@ -34,7 +37,23 @@ public class ProtocolCryptoSelfTest { throw new AssertionError("Hybrid server AES-GCM round-trip failed"); } } - System.out.println("PASS: 100 CBC client/server interop cycles and 100 GCM round-trips"); + System.out.println("PASS: legacy state-channel mapping, 100 CBC interop cycles and 100 GCM round-trips"); + } + + private static void testStateChannelCompatibility() { + check("voicechat:player_state", ProtocolChannelCompatibility.outgoingChannel("voicechat:state", 18)); + check("voicechat:player_states", ProtocolChannelCompatibility.outgoingChannel("voicechat:states", 18)); + if (ProtocolChannelCompatibility.outgoingChannel("voicechat:remove_state", 18) != null) { + throw new AssertionError("The 2.5 client has no remove-state packet"); + } + check("voicechat:state", ProtocolChannelCompatibility.outgoingChannel("voicechat:state", 20)); + check("voicechat:states", ProtocolChannelCompatibility.outgoingChannel("voicechat:states", 20)); + } + + private static void check(String expected, String actual) { + if (!expected.equals(actual)) { + throw new AssertionError("Expected channel " + expected + ", got " + actual); + } } private static UUID uuidFromBytes(byte[] bytes) {