From 5d7d370f9f23511bf9f7a7fe02c06085d6b5d8be Mon Sep 17 00:00:00 2001 From: Florian Stober Date: Sun, 29 Dec 2024 14:25:56 +0100 Subject: [PATCH] add a new tablist handler for 1.21.4 --- TabOverlayCommon | 2 +- build.gradle | 2 +- .../handler/OrderedTabOverlayHandler.java | 1187 +++++++++++++++++ .../managers/TabViewManager.java | 6 +- .../BungeeProtocolVersionProvider.java | 5 + .../ProtocolSupportVersionProvider.java | 10 + .../version/ProtocolVersionProvider.java | 2 + .../ViaVersionProtocolVersionProvider.java | 5 + minecraft-data-api | 2 +- 9 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 bungee/src/main/java/codecrafter47/bungeetablistplus/handler/OrderedTabOverlayHandler.java diff --git a/TabOverlayCommon b/TabOverlayCommon index 62dbb09a..0170a975 160000 --- a/TabOverlayCommon +++ b/TabOverlayCommon @@ -1 +1 @@ -Subproject commit 62dbb09a5e4201954ea3fe09a549fef265a9f0b1 +Subproject commit 0170a9759ea69c57ce9ded11858833b4b6e98e91 diff --git a/build.gradle b/build.gradle index 5ff3c953..6b4a9b31 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { ext { spigotVersion = '1.11-R0.1-SNAPSHOT' - bungeeVersion = '1.20-R0.2-SNAPSHOT' + bungeeVersion = '1.21-R0.1-SNAPSHOT' spongeVersion = '7.0.0' dataApiVersion = '1.0.2-SNAPSHOT' } diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/handler/OrderedTabOverlayHandler.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/handler/OrderedTabOverlayHandler.java new file mode 100644 index 00000000..fd265f72 --- /dev/null +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/handler/OrderedTabOverlayHandler.java @@ -0,0 +1,1187 @@ +/* + * Copyright (C) 2020 Florian Stober + * + * 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 codecrafter47.bungeetablistplus.handler; + +import codecrafter47.bungeetablistplus.protocol.PacketHandler; +import codecrafter47.bungeetablistplus.protocol.PacketListenerResult; +import codecrafter47.bungeetablistplus.util.BitSet; +import codecrafter47.bungeetablistplus.util.ConcurrentBitSet; +import codecrafter47.bungeetablistplus.util.Property119Handler; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import de.codecrafter47.taboverlay.Icon; +import de.codecrafter47.taboverlay.ProfileProperty; +import de.codecrafter47.taboverlay.config.misc.ChatFormat; +import de.codecrafter47.taboverlay.config.misc.Unchecked; +import de.codecrafter47.taboverlay.handler.*; +import it.unimi.dsi.fastutil.objects.Object2BooleanMap; +import it.unimi.dsi.fastutil.objects.Object2BooleanOpenHashMap; +import lombok.NonNull; +import lombok.val; +import net.md_5.bungee.UserConnection; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.chat.ComponentSerializer; +import net.md_5.bungee.netty.ChannelWrapper; +import net.md_5.bungee.protocol.DefinedPacket; +import net.md_5.bungee.protocol.Either; +import net.md_5.bungee.protocol.Protocol; +import net.md_5.bungee.protocol.packet.*; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class OrderedTabOverlayHandler implements PacketHandler, TabOverlayHandler { + + // some options + private static final boolean OPTION_ENABLE_CUSTOM_SLOT_UUID_COLLISION_CHECK = true; + + private static final BaseComponent EMPTY_TEXT_COMPONENT = new TextComponent(); + protected static final String[][] EMPTY_PROPERTIES_ARRAY = new String[0][]; + + private static final ImmutableMap DIMENSION_TO_USED_SLOTS; + private static final BitSet[] SIZE_TO_USED_SLOTS; + + private static final UUID[] CUSTOM_SLOT_UUID_STEVE; + private static final UUID[] CUSTOM_SLOT_UUID_ALEX; + @Nonnull + private static final Set CUSTOM_SLOT_UUIDS; + + static { + + // build the dimension to used slots map (for the rectangular tab overlay) + val builder = ImmutableMap.builder(); + for (int columns = 1; columns <= 4; columns++) { + for (int rows = 0; rows <= 20; rows++) { + if (columns != 1 && rows != 0 && columns * rows <= (columns - 1) * 20) + continue; + BitSet usedSlots = new BitSet(80); + for (int column = 0; column < columns; column++) { + for (int row = 0; row < rows; row++) { + usedSlots.set(index(column, row)); + } + } + builder.put(new RectangularTabOverlay.Dimension(columns, rows), usedSlots); + } + } + DIMENSION_TO_USED_SLOTS = builder.build(); + + // build the size to used slots map (for the simple tab overlay) + SIZE_TO_USED_SLOTS = new BitSet[81]; + for (int size = 0; size <= 80; size++) { + BitSet usedSlots = new BitSet(80); + usedSlots.set(0, size); + SIZE_TO_USED_SLOTS[size] = usedSlots; + } + + // generate random uuids for our custom slots + CUSTOM_SLOT_UUID_ALEX = new UUID[80]; + CUSTOM_SLOT_UUID_STEVE = new UUID[80]; + UUID base = UUID.randomUUID(); + long msb = base.getMostSignificantBits(); + long lsb = base.getLeastSignificantBits(); + lsb ^= base.hashCode(); + for (int i = 0; i < 80; i++) { + CUSTOM_SLOT_UUID_STEVE[i] = new UUID(msb, lsb ^ (2 * i)); + CUSTOM_SLOT_UUID_ALEX[i] = new UUID(msb, lsb ^ (2 * i + 1)); + } + if (OPTION_ENABLE_CUSTOM_SLOT_UUID_COLLISION_CHECK) { + CUSTOM_SLOT_UUIDS = ImmutableSet.builder() + .add(CUSTOM_SLOT_UUID_ALEX) + .add(CUSTOM_SLOT_UUID_STEVE).build(); + } else { + CUSTOM_SLOT_UUIDS = Collections.emptySet(); + } + } + + private final Logger logger; + private final Executor eventLoopExecutor; + + private final Object2BooleanMap serverPlayerListListed = new Object2BooleanOpenHashMap<>(); + @Nullable + protected BaseComponent serverHeader = null; + @Nullable + protected BaseComponent serverFooter = null; + + private final Queue> nextActiveContentHandlerQueue = new ConcurrentLinkedQueue<>(); + private final Queue> nextActiveHeaderFooterHandlerQueue = new ConcurrentLinkedQueue<>(); + private AbstractContentOperationModeHandler activeContentHandler; + private AbstractHeaderFooterOperationModeHandler activeHeaderFooterHandler; + + private final AtomicBoolean updateScheduledFlag = new AtomicBoolean(false); + private final Runnable updateTask = this::update; + + protected boolean active; + + private boolean logVersionMismatch = false; + + private final ProxiedPlayer player; + + public OrderedTabOverlayHandler(Logger logger, Executor eventLoopExecutor, ProxiedPlayer player) { + this.logger = logger; + this.eventLoopExecutor = eventLoopExecutor; + this.player = player; + this.activeContentHandler = new PassThroughContentHandler(); + this.activeHeaderFooterHandler = new PassThroughHeaderFooterHandler(); + } + + private void sendPacket(DefinedPacket packet) { + if (((packet instanceof PlayerListItemUpdate) || (packet instanceof PlayerListItemRemove)) && (player.getPendingConnection().getVersion() < 761)) { + // error + if (!logVersionMismatch) { + logVersionMismatch = true; + logger.warning("Cannot correctly update tablist for player " + player.getName() + "\nThe client and server versions do not match. Client >= 1.19.3, server < 1.19.3.\nUse ViaVersion on the spigot server for the best experience."); + } + } else if (player.getPendingConnection().getVersion() >= 764) { + // Ensure that unsafe packets are not sent in the config phase + // Why bungee doesn't expose this via api beyond me... + // https://github.com/SpigotMC/BungeeCord/blob/1ef4d27dbea48a1d47501ad2be0d75e42cc2cc12/proxy/src/main/java/net/md_5/bungee/UserConnection.java#L182-L192 + try { + ((UserConnection) player).sendPacketQueued(packet); + } catch (Exception ignored) { + + } + } else { + player.unsafe().sendPacket(packet); + } + } + + @Override + public PacketListenerResult onPlayerListPacket(PlayerListItem packet) { + return PacketListenerResult.PASS; + } + + @Override + public PacketListenerResult onPlayerListUpdatePacket(PlayerListItemUpdate packet) { + + if (!active) { + active = true; + scheduleUpdate(); + } + + if (packet.getActions().contains(PlayerListItemUpdate.Action.ADD_PLAYER)) { + for (PlayerListItem.Item item : packet.getItems()) { + if (OPTION_ENABLE_CUSTOM_SLOT_UUID_COLLISION_CHECK) { + if (CUSTOM_SLOT_UUIDS.contains(item.getUuid())) { + throw new AssertionError("UUID collision " + item.getUuid()); + } + } + serverPlayerListListed.putIfAbsent(item.getUuid(), false); + } + } + if (packet.getActions().contains(PlayerListItemUpdate.Action.UPDATE_LISTED)) { + for (PlayerListItem.Item item : packet.getItems()) { + serverPlayerListListed.put(item.getUuid(), item.getListed().booleanValue()); + } + } + + try { + return this.activeContentHandler.onPlayerListUpdatePacket(packet); + } catch (Throwable th) { + logger.log(Level.SEVERE, "Unexpected error", th); + // try recover + enterContentOperationMode(ContentOperationMode.PASS_TROUGH); + return PacketListenerResult.PASS; + } + } + + @Override + public PacketListenerResult onPlayerListRemovePacket(PlayerListItemRemove packet) { + for (UUID uuid : packet.getUuids()) { + serverPlayerListListed.removeBoolean(uuid); + } + return PacketListenerResult.PASS; + } + + @Override + public PacketListenerResult onTeamPacket(Team packet) { + return PacketListenerResult.PASS; + } + + @Override + public PacketListenerResult onPlayerListHeaderFooterPacket(PlayerListHeaderFooter packet) { + PacketListenerResult result = PacketListenerResult.PASS; + try { + result = this.activeHeaderFooterHandler.onPlayerListHeaderFooterPacket(packet); + if (result == PacketListenerResult.MODIFIED) { + throw new AssertionError("PacketListenerResult.MODIFIED must not be used"); + } + } catch (Throwable th) { + logger.log(Level.SEVERE, "Unexpected error", th); + // try recover + enterHeaderAndFooterOperationMode(HeaderAndFooterOperationMode.PASS_TROUGH); + } + + this.serverHeader = packet.getHeader() != null ? packet.getHeader() : EMPTY_TEXT_COMPONENT; + this.serverFooter = packet.getFooter() != null ? packet.getFooter() : EMPTY_TEXT_COMPONENT; + + return result; + } + + @Override + public void onServerSwitch(boolean is13OrLater) { + + try { + this.activeContentHandler.onServerSwitch(); + } catch (Throwable th) { + logger.log(Level.SEVERE, "Unexpected error", th); + } + try { + this.activeHeaderFooterHandler.onServerSwitch(); + } catch (Throwable th) { + logger.log(Level.SEVERE, "Unexpected error", th); + } + + if (!serverPlayerListListed.isEmpty()) { + PlayerListItemRemove packet = new PlayerListItemRemove(); + packet.setUuids(serverPlayerListListed.keySet().toArray(new UUID[0])); + sendPacket(packet); + } + + serverPlayerListListed.clear(); + if (serverHeader != null) { + serverHeader = EMPTY_TEXT_COMPONENT; + } + if (serverFooter != null) { + serverFooter = EMPTY_TEXT_COMPONENT; + } + + active = false; + } + + @Override + public R enterContentOperationMode(ContentOperationMode operationMode) { + AbstractContentOperationModeHandler handler; + if (operationMode == ContentOperationMode.PASS_TROUGH) { + handler = new PassThroughContentHandler(); + } else if (operationMode == ContentOperationMode.SIMPLE) { + handler = new SimpleOperationModeHandler(); + } else if (operationMode == ContentOperationMode.RECTANGULAR) { + handler = new RectangularSizeHandler(); + } else { + throw new UnsupportedOperationException(); + } + nextActiveContentHandlerQueue.add(handler); + scheduleUpdate(); + return Unchecked.cast(handler.getTabOverlay()); + } + + @Override + public R enterHeaderAndFooterOperationMode(HeaderAndFooterOperationMode operationMode) { + AbstractHeaderFooterOperationModeHandler handler; + if (operationMode == HeaderAndFooterOperationMode.PASS_TROUGH) { + handler = new PassThroughHeaderFooterHandler(); + } else if (operationMode == HeaderAndFooterOperationMode.CUSTOM) { + handler = new CustomHeaderAndFooterOperationModeHandler(); + } else { + throw new UnsupportedOperationException(Objects.toString(operationMode)); + } + nextActiveHeaderFooterHandlerQueue.add(handler); + scheduleUpdate(); + return Unchecked.cast(handler.getTabOverlay()); + } + + private void scheduleUpdate() { + if (this.updateScheduledFlag.compareAndSet(false, true)) { + try { + eventLoopExecutor.execute(updateTask); + } catch (RejectedExecutionException ignored) { + } + } + } + + private void update() { + updateScheduledFlag.set(false); + + ChannelWrapper ch = ((UserConnection) player).getCh(); + if (!active || ch.isClosed() || ch.getEncodeProtocol() != Protocol.GAME) { + return; + } + + // update content handler + AbstractContentOperationModeHandler contentHandler; + while (null != (contentHandler = nextActiveContentHandlerQueue.poll())) { + this.activeContentHandler.invalidate(); + contentHandler.onActivated(this.activeContentHandler); + this.activeContentHandler = contentHandler; + } + this.activeContentHandler.update(); + + // update header and footer handler + AbstractHeaderFooterOperationModeHandler heaerFooterHandler; + while (null != (heaerFooterHandler = nextActiveHeaderFooterHandlerQueue.poll())) { + this.activeHeaderFooterHandler.invalidate(); + heaerFooterHandler.onActivated(this.activeHeaderFooterHandler); + this.activeHeaderFooterHandler = heaerFooterHandler; + } + this.activeHeaderFooterHandler.update(); + } + + private abstract static class AbstractContentOperationModeHandler extends OperationModeHandler { + + /** + * Called when the player receives a {@link PlayerListItem} packet. + *

+ * This method is called after this {@link OrderedTabOverlayHandler} has updated the {@code serverPlayerList}. + */ + abstract PacketListenerResult onPlayerListUpdatePacket(PlayerListItemUpdate packet); + + /** + * Called when the player switches the server. + *

+ * This method is called before this {@link OrderedTabOverlayHandler} executes its own logic to clear the + * server player list info. + */ + abstract void onServerSwitch(); + + abstract void update(); + + final void invalidate() { + getTabOverlay().invalidate(); + onDeactivated(); + } + + /** + * Called when this {@link OperationModeHandler} is deactivated. + *

+ * This method must put the client player list in the state expected by {@link #onActivated(AbstractContentOperationModeHandler)}. It must + * especially remove all custom entries and players must be part of the correct teams. + */ + abstract void onDeactivated(); + + /** + * Called when this {@link OperationModeHandler} becomes the active one. + *

+ * State of the player list when this method is called: + * - there are no custom entries on the client + * - all entries from {@link #serverPlayerListListed} but may not be listed + * - player list header/ footer may be wrong + *

+ * Additional information about the state of the player list may be obtained from the previous handler + * + * @param previous previous handler + */ + abstract void onActivated(AbstractContentOperationModeHandler previous); + } + + private abstract static class AbstractHeaderFooterOperationModeHandler extends OperationModeHandler { + + /** + * Called when the player receives a {@link PlayerListHeaderFooter} packet. + *

+ * This method is called before this {@link OrderedTabOverlayHandler} executes its own logic to update the + * server player list info. + */ + abstract PacketListenerResult onPlayerListHeaderFooterPacket(PlayerListHeaderFooter packet); + + /** + * Called when the player switches the server. + *

+ * This method is called before this {@link OrderedTabOverlayHandler} executes its own logic to clear the + * server player list info. + */ + abstract void onServerSwitch(); + + abstract void update(); + + final void invalidate() { + getTabOverlay().invalidate(); + onDeactivated(); + } + + /** + * Called when this {@link OperationModeHandler} is deactivated. + *

+ * This method must put the client player list in the state expected by {@link #onActivated(AbstractHeaderFooterOperationModeHandler)}. It must + * especially remove all custom entries and players must be part of the correct teams. + */ + abstract void onDeactivated(); + + /** + * Called when this {@link OperationModeHandler} becomes the active one. + *

+ * State of the player list when this method is called: + * - there are no custom entries on the client + * - all entries from {@link #serverPlayerListListed} are known to the client, but might not be listed + * - player list header/ footer may be wrong + *

+ * Additional information about the state of the player list may be obtained from the previous handler + * + * @param previous previous handler + */ + abstract void onActivated(AbstractHeaderFooterOperationModeHandler previous); + } + + private abstract static class AbstractContentTabOverlay implements TabOverlayHandle { + private boolean valid = true; + + @Override + public boolean isValid() { + return valid; + } + + final void invalidate() { + valid = false; + } + } + + private abstract static class AbstractHeaderFooterTabOverlay implements TabOverlayHandle { + private boolean valid = true; + + @Override + public boolean isValid() { + return valid; + } + + final void invalidate() { + valid = false; + } + } + + private final class PassThroughContentHandler extends AbstractContentOperationModeHandler { + + @Override + protected PassThroughContentTabOverlay createTabOverlay() { + return new PassThroughContentTabOverlay(); + } + + @Override + PacketListenerResult onPlayerListUpdatePacket(PlayerListItemUpdate packet) { + return PacketListenerResult.PASS; + } + + @Override + void onServerSwitch() { + sendPacket(new PlayerListHeaderFooter(EMPTY_TEXT_COMPONENT, EMPTY_TEXT_COMPONENT)); + } + + @Override + void update() { + // nothing to do + } + + @Override + void onDeactivated() { + // nothing to do + } + + @Override + void onActivated(AbstractContentOperationModeHandler previous) { + if (previous instanceof PassThroughContentHandler) { + // we're lucky, nothing to do + return; + } + + // update visibility + if (!serverPlayerListListed.isEmpty()) { + List items = new ArrayList<>(serverPlayerListListed.size()); + for (Object2BooleanMap.Entry entry : serverPlayerListListed.object2BooleanEntrySet()) { + PlayerListItem.Item item = new PlayerListItem.Item(); + item.setUuid(entry.getKey()); + item.setListed(entry.getBooleanValue()); + items.add(item); + } + PlayerListItemUpdate packet = new PlayerListItemUpdate(); + packet.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LISTED)); + packet.setItems(items.toArray(new PlayerListItem.Item[0])); + sendPacket(packet); + } + } + } + + private static final class PassThroughContentTabOverlay extends AbstractContentTabOverlay { + + } + + private final class PassThroughHeaderFooterHandler extends AbstractHeaderFooterOperationModeHandler { + + @Override + protected PassThroughHeaderFooterTabOverlay createTabOverlay() { + return new PassThroughHeaderFooterTabOverlay(); + } + + @Override + PacketListenerResult onPlayerListHeaderFooterPacket(PlayerListHeaderFooter packet) { + return PacketListenerResult.PASS; + } + + @Override + void onServerSwitch() { + sendPacket(new PlayerListHeaderFooter(EMPTY_TEXT_COMPONENT, EMPTY_TEXT_COMPONENT)); + } + + @Override + void update() { + // nothing to do + } + + @Override + void onDeactivated() { + // nothing to do + } + + @Override + void onActivated(AbstractHeaderFooterOperationModeHandler previous) { + if (previous instanceof PassThroughHeaderFooterHandler) { + // we're lucky, nothing to do + return; + } + + // fix header/ footer + sendPacket(new PlayerListHeaderFooter(serverHeader != null ? serverHeader : EMPTY_TEXT_COMPONENT, serverFooter != null ? serverFooter : EMPTY_TEXT_COMPONENT)); + } + } + + private static final class PassThroughHeaderFooterTabOverlay extends AbstractHeaderFooterTabOverlay { + + } + + private abstract class CustomContentTabOverlayHandler extends AbstractContentOperationModeHandler { + + @Nonnull + BitSet usedSlots; + BitSet dirtySlots; + final SlotState[] slotState; + /** + * Uuid of the player list entry used for the slot. + */ + final UUID[] slotUuid; + + private final List itemQueueAddPlayer; + private final List itemQueueRemovePlayer; + private final List itemQueueUpdateDisplayName; + private final List itemQueueUpdatePing; + + private CustomContentTabOverlayHandler() { + this.dirtySlots = new BitSet(80); + this.usedSlots = SIZE_TO_USED_SLOTS[0]; + this.slotState = new SlotState[80]; + Arrays.fill(this.slotState, SlotState.UNUSED); + this.slotUuid = new UUID[80]; + this.itemQueueAddPlayer = new ArrayList<>(80); + this.itemQueueRemovePlayer = new ArrayList<>(80); + this.itemQueueUpdateDisplayName = new ArrayList<>(80); + this.itemQueueUpdatePing = new ArrayList<>(80); + } + + @Override + PacketListenerResult onPlayerListUpdatePacket(PlayerListItemUpdate packet) { + + if (packet.getActions().contains(PlayerListItemUpdate.Action.UPDATE_LISTED)) { + for (PlayerListItem.Item item : packet.getItems()) { + item.setListed(false); + } + } + return PacketListenerResult.MODIFIED; + } + + @Override + void onServerSwitch() { + if (player.getPendingConnection().getVersion() >= 764) { + clearCustomSlots(); + } + } + + @Override + void onActivated(AbstractContentOperationModeHandler previous) { + + // make all players unlisted + if (!serverPlayerListListed.isEmpty()) { + List items = new ArrayList<>(serverPlayerListListed.size()); + for (Object2BooleanMap.Entry entry : serverPlayerListListed.object2BooleanEntrySet()) { + PlayerListItem.Item item = new PlayerListItem.Item(); + item.setUuid(entry.getKey()); + item.setListed(false); + items.add(item); + } + PlayerListItemUpdate packet = new PlayerListItemUpdate(); + packet.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LISTED)); + packet.setItems(items.toArray(new PlayerListItem.Item[0])); + sendPacket(packet); + } + } + + @Override + void onDeactivated() { + clearCustomSlots(); + } + + private void clearCustomSlots() { + int customSlots = 0; + for (int index = 0; index < 80; index++) { + if (slotState[index] != SlotState.UNUSED) { + customSlots++; + dirtySlots.set(index); + } + } + + int i = 0; + if (customSlots > 0) { + UUID[] uuids = new UUID[customSlots]; + for (int index = 0; index < 80; index++) { + // switch slot from custom to unused + if (slotState[index] == SlotState.CUSTOM) { + uuids[i++] = slotUuid[index]; + } + } + PlayerListItemRemove packet = new PlayerListItemRemove(); + packet.setUuids(uuids); + sendPacket(packet); + } + } + + @Override + void update() { + + T tabOverlay = getTabOverlay(); + + if (tabOverlay.dirtyFlagSize) { + tabOverlay.dirtyFlagSize = false; + updateSize(); + } + + // update icons + dirtySlots.orAndClear(tabOverlay.dirtyFlagsIcon); + for (int index = dirtySlots.nextSetBit(0); index >= 0; index = dirtySlots.nextSetBit(index + 1)) { + if (slotState[index] == SlotState.CUSTOM) { + // remove item + itemQueueRemovePlayer.add(slotUuid[index]); + slotState[index] = SlotState.UNUSED; + } + + if (usedSlots.get(index)) { + Icon icon = tabOverlay.icon[index]; + UUID customSlotUuid; + if (icon.isAlex()) { + customSlotUuid = CUSTOM_SLOT_UUID_ALEX[index]; + } else { // steve + customSlotUuid = CUSTOM_SLOT_UUID_STEVE[index]; + } + tabOverlay.dirtyFlagsText.clear(index); + tabOverlay.dirtyFlagsPing.clear(index); + slotState[index] = SlotState.CUSTOM; + slotUuid[index] = customSlotUuid; + PlayerListItem.Item item = new PlayerListItem.Item(); + item.setUuid(customSlotUuid); + item.setUsername(""); + Property119Handler.setProperties(item, toPropertiesArray(icon.getTextureProperty())); + item.setDisplayName(tabOverlay.text[index]); + item.setPing(tabOverlay.ping[index]); + item.setGamemode(0); + item.setListed(true); + item.setListOrder(-index); + itemQueueAddPlayer.add(item); + } + } + + // update text + dirtySlots.copyAndClear(tabOverlay.dirtyFlagsText); + for (int index = dirtySlots.nextSetBit(0); index >= 0; index = dirtySlots.nextSetBit(index + 1)) { + if (slotState[index] != SlotState.UNUSED) { + PlayerListItem.Item item = new PlayerListItem.Item(); + item.setUuid(slotUuid[index]); + item.setDisplayName(tabOverlay.text[index]); + itemQueueUpdateDisplayName.add(item); + } + } + + // update ping + dirtySlots.copyAndClear(tabOverlay.dirtyFlagsPing); + for (int index = dirtySlots.nextSetBit(0); index >= 0; index = dirtySlots.nextSetBit(index + 1)) { + if (slotState[index] != SlotState.UNUSED) { + PlayerListItem.Item item = new PlayerListItem.Item(); + item.setUuid(slotUuid[index]); + item.setPing(tabOverlay.ping[index]); + itemQueueUpdatePing.add(item); + } + } + + dirtySlots.clear(); + + // send packets + sendQueuedItems(); + } + + private void sendQueuedItems() { + if (!itemQueueRemovePlayer.isEmpty()) { + PlayerListItemRemove packet = new PlayerListItemRemove(); + packet.setUuids(itemQueueRemovePlayer.toArray(new UUID[0])); + sendPacket(packet); + itemQueueRemovePlayer.clear(); + } + if (!itemQueueAddPlayer.isEmpty()) { + PlayerListItemUpdate packet = new PlayerListItemUpdate(); + packet.setActions(EnumSet.of(PlayerListItemUpdate.Action.ADD_PLAYER, PlayerListItemUpdate.Action.UPDATE_DISPLAY_NAME, PlayerListItemUpdate.Action.UPDATE_LATENCY, PlayerListItemUpdate.Action.UPDATE_LISTED, PlayerListItemUpdate.Action.UPDATE_LIST_ORDER)); + packet.setItems(itemQueueAddPlayer.toArray(new PlayerListItem.Item[0])); + sendPacket(packet); + itemQueueAddPlayer.clear(); + } + if (!itemQueueUpdateDisplayName.isEmpty()) { + PlayerListItemUpdate packet = new PlayerListItemUpdate(); + packet.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_DISPLAY_NAME)); + packet.setItems(itemQueueUpdateDisplayName.toArray(new PlayerListItem.Item[0])); + sendPacket(packet); + itemQueueUpdateDisplayName.clear(); + } + if (!itemQueueUpdatePing.isEmpty()) { + PlayerListItemUpdate packet = new PlayerListItemUpdate(); + packet.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LATENCY)); + packet.setItems(itemQueueUpdatePing.toArray(new PlayerListItem.Item[0])); + sendPacket(packet); + itemQueueUpdatePing.clear(); + } + } + + /** + * Updates the usedSlots BitSet. Sets the {@link #dirtySlots uuid dirty flag} for all added + * and removed slots. + */ + abstract void updateSize(); + } + + private abstract class CustomContentTabOverlay extends AbstractContentTabOverlay implements TabOverlayHandle.BatchModifiable { + final Icon[] icon; + final BaseComponent[] text; + final int[] ping; + + final AtomicInteger batchUpdateRecursionLevel; + volatile boolean dirtyFlagSize; + final ConcurrentBitSet dirtyFlagsIcon; + final ConcurrentBitSet dirtyFlagsText; + final ConcurrentBitSet dirtyFlagsPing; + + private CustomContentTabOverlay() { + this.icon = new Icon[80]; + Arrays.fill(this.icon, Icon.DEFAULT_STEVE); + this.text = new BaseComponent[80]; + Arrays.fill(this.text, EMPTY_TEXT_COMPONENT); + this.ping = new int[80]; + this.batchUpdateRecursionLevel = new AtomicInteger(0); + this.dirtyFlagSize = true; + this.dirtyFlagsIcon = new ConcurrentBitSet(80); + this.dirtyFlagsText = new ConcurrentBitSet(80); + this.dirtyFlagsPing = new ConcurrentBitSet(80); + } + + @Override + public void beginBatchModification() { + if (isValid()) { + if (batchUpdateRecursionLevel.incrementAndGet() < 0) { + throw new AssertionError("Recursion level overflow"); + } + } + } + + @Override + public void completeBatchModification() { + if (isValid()) { + int level = batchUpdateRecursionLevel.decrementAndGet(); + if (level == 0) { + scheduleUpdate(); + } else if (level < 0) { + throw new AssertionError("Recursion level underflow"); + } + } + } + + void scheduleUpdateIfNotInBatch() { + if (batchUpdateRecursionLevel.get() == 0) { + scheduleUpdate(); + } + } + + void setIconInternal(int index, @Nonnull @NonNull Icon icon) { + if (!icon.equals(this.icon[index])) { + this.icon[index] = icon; + dirtyFlagsIcon.set(index); + scheduleUpdateIfNotInBatch(); + } + } + + void setTextInternal(int index, @Nonnull @NonNull String text) { + String jsonText = ChatFormat.formattedTextToJson(text); + BaseComponent component = ComponentSerializer.deserialize(jsonText); + if (!component.equals(this.text[index])) { + this.text[index] = component; + dirtyFlagsText.set(index); + scheduleUpdateIfNotInBatch(); + } + } + + void setPingInternal(int index, int ping) { + if (ping != this.ping[index]) { + this.ping[index] = ping; + dirtyFlagsPing.set(index); + scheduleUpdateIfNotInBatch(); + } + } + } + + private class RectangularSizeHandler extends CustomContentTabOverlayHandler { + + @Override + void updateSize() { + RectangularTabOverlayImpl tabOverlay = getTabOverlay(); + RectangularTabOverlay.Dimension size = tabOverlay.getSize(); + BitSet newUsedSlots = DIMENSION_TO_USED_SLOTS.get(size); + dirtySlots.orXor(usedSlots, newUsedSlots); + usedSlots = newUsedSlots; + } + + @Override + protected RectangularTabOverlayImpl createTabOverlay() { + return new RectangularTabOverlayImpl(); + } + } + + private class RectangularTabOverlayImpl extends CustomContentTabOverlay implements RectangularTabOverlay { + + @Nonnull + private Dimension size; + + private RectangularTabOverlayImpl() { + Optional dimensionZero = getSupportedSizes().stream().filter(size -> size.getSize() == 0).findAny(); + if (!dimensionZero.isPresent()) { + throw new AssertionError(); + } + this.size = dimensionZero.get(); + } + + @Nonnull + @Override + public Dimension getSize() { + return size; + } + + @Override + public Collection getSupportedSizes() { + return DIMENSION_TO_USED_SLOTS.keySet(); + } + + @Override + public void setSize(@Nonnull Dimension size) { + if (!getSupportedSizes().contains(size)) { + throw new IllegalArgumentException("Unsupported size " + size); + } + if (isValid() && !this.size.equals(size)) { + BitSet oldUsedSlots = DIMENSION_TO_USED_SLOTS.get(this.size); + BitSet newUsedSlots = DIMENSION_TO_USED_SLOTS.get(size); + for (int index = newUsedSlots.nextSetBit(0); index >= 0; index = newUsedSlots.nextSetBit(index + 1)) { + if (!oldUsedSlots.get(index)) { + icon[index] = Icon.DEFAULT_STEVE; + text[index] = EMPTY_TEXT_COMPONENT; + ping[index] = 0; + } + } + this.size = size; + this.dirtyFlagSize = true; + scheduleUpdateIfNotInBatch(); + for (int index = oldUsedSlots.nextSetBit(0); index >= 0; index = oldUsedSlots.nextSetBit(index + 1)) { + if (!newUsedSlots.get(index)) { + icon[index] = Icon.DEFAULT_STEVE; + text[index] = EMPTY_TEXT_COMPONENT; + ping[index] = 0; + } + } + } + } + + @Override + public void setSlot(int column, int row, @Nullable UUID uuid, @Nonnull Icon icon, @Nonnull String text, int ping) { + setSlot(column, row, icon, text, ping); + } + + @Override + public void setSlot(int column, int row, @Nonnull Icon icon, @Nonnull String text, int ping) { + if (isValid()) { + Preconditions.checkElementIndex(column, size.getColumns(), "column"); + Preconditions.checkElementIndex(row, size.getRows(), "row"); + beginBatchModification(); + try { + int index = index(column, row); + setIconInternal(index, icon); + setTextInternal(index, text); + setPingInternal(index, ping); + } finally { + completeBatchModification(); + } + } + } + + @Override + public void setUuid(int column, int row, @Nullable UUID uuid) { + // no op + } + + @Override + public void setIcon(int column, int row, @Nonnull Icon icon) { + if (isValid()) { + Preconditions.checkElementIndex(column, size.getColumns(), "column"); + Preconditions.checkElementIndex(row, size.getRows(), "row"); + setIconInternal(index(column, row), icon); + } + } + + @Override + public void setText(int column, int row, @Nonnull String text) { + if (isValid()) { + Preconditions.checkElementIndex(column, size.getColumns(), "column"); + Preconditions.checkElementIndex(row, size.getRows(), "row"); + setTextInternal(index(column, row), text); + } + } + + @Override + public void setPing(int column, int row, int ping) { + if (isValid()) { + Preconditions.checkElementIndex(column, size.getColumns(), "column"); + Preconditions.checkElementIndex(row, size.getRows(), "row"); + setPingInternal(index(column, row), ping); + } + } + } + + private class SimpleOperationModeHandler extends CustomContentTabOverlayHandler { + + private int size = 0; + + @Override + void updateSize() { + int newSize = getTabOverlay().size; + if (newSize > size) { + dirtySlots.set(size, newSize); + } else if (newSize < size) { + dirtySlots.set(newSize, size); + } + usedSlots = SIZE_TO_USED_SLOTS[newSize]; + size = newSize; + } + + @Override + protected SimpleTabOverlayImpl createTabOverlay() { + return new SimpleTabOverlayImpl(); + } + } + + private class SimpleTabOverlayImpl extends CustomContentTabOverlay implements SimpleTabOverlay { + int size = 0; + + @Override + public int getSize() { + return size; + } + + @Override + public int getMaxSize() { + return 80; + } + + @Override + public void setSize(int size) { + if (size < 0 || size > 80) { + throw new IllegalArgumentException("size"); + } + this.size = size; + dirtyFlagSize = true; + scheduleUpdateIfNotInBatch(); + } + + @Override + public void setSlot(int index, @Nullable UUID uuid, @Nonnull Icon icon, @Nonnull String text, int ping) { + if (isValid()) { + Preconditions.checkElementIndex(index, size, "index"); + beginBatchModification(); + try { + setIconInternal(index, icon); + setTextInternal(index, text); + setPingInternal(index, ping); + } finally { + completeBatchModification(); + } + } + } + + @Override + public void setSlot(int index, @Nonnull Icon icon, @Nonnull String text, int ping) { + if (isValid()) { + Preconditions.checkElementIndex(index, size, "index"); + beginBatchModification(); + try { + setIconInternal(index, icon); + setTextInternal(index, text); + setPingInternal(index, ping); + } finally { + completeBatchModification(); + } + } + } + + @Override + public void setUuid(int index, UUID uuid) { + // no op + } + + @Override + public void setIcon(int index, @Nonnull @NonNull Icon icon) { + if (isValid()) { + Preconditions.checkElementIndex(index, size, "index"); + setIconInternal(index, icon); + } + } + + @Override + public void setText(int index, @Nonnull @NonNull String text) { + if (isValid()) { + Preconditions.checkElementIndex(index, size, "index"); + setTextInternal(index, text); + } + } + + @Override + public void setPing(int index, int ping) { + if (isValid()) { + Preconditions.checkElementIndex(index, size, "index"); + setPingInternal(index, ping); + } + } + } + + private final class CustomHeaderAndFooterOperationModeHandler extends AbstractHeaderFooterOperationModeHandler { + + @Override + protected CustomHeaderAndFooterImpl createTabOverlay() { + return new CustomHeaderAndFooterImpl(); + } + + @Override + PacketListenerResult onPlayerListHeaderFooterPacket(PlayerListHeaderFooter packet) { + return PacketListenerResult.CANCEL; + } + + @Override + void onServerSwitch() { + // do nothing + } + + @Override + void onDeactivated() { + //do nothing + } + + @Override + void onActivated(AbstractHeaderFooterOperationModeHandler previous) { + // remove header/ footer + sendPacket(new PlayerListHeaderFooter(EMPTY_TEXT_COMPONENT, EMPTY_TEXT_COMPONENT)); + } + + @Override + void update() { + CustomHeaderAndFooterImpl tabOverlay = getTabOverlay(); + if (tabOverlay.headerOrFooterDirty) { + tabOverlay.headerOrFooterDirty = false; + sendPacket(new PlayerListHeaderFooter(tabOverlay.header, tabOverlay.footer)); + } + } + } + + private final class CustomHeaderAndFooterImpl extends AbstractHeaderFooterTabOverlay implements HeaderAndFooterHandle { + private BaseComponent header = EMPTY_TEXT_COMPONENT; + private BaseComponent footer = EMPTY_TEXT_COMPONENT; + + private volatile boolean headerOrFooterDirty = false; + + final AtomicInteger batchUpdateRecursionLevel = new AtomicInteger(0); + + @Override + public void beginBatchModification() { + if (isValid()) { + if (batchUpdateRecursionLevel.incrementAndGet() < 0) { + throw new AssertionError("Recursion level overflow"); + } + } + } + + @Override + public void completeBatchModification() { + if (isValid()) { + int level = batchUpdateRecursionLevel.decrementAndGet(); + if (level == 0) { + scheduleUpdate(); + } else if (level < 0) { + throw new AssertionError("Recursion level underflow"); + } + } + } + + void scheduleUpdateIfNotInBatch() { + if (batchUpdateRecursionLevel.get() == 0) { + scheduleUpdate(); + } + } + + @Override + public void setHeaderFooter(@Nullable String header, @Nullable String footer) { + this.header = ComponentSerializer.deserialize(ChatFormat.formattedTextToJson(header)); + this.footer = ComponentSerializer.deserialize(ChatFormat.formattedTextToJson(footer)); + headerOrFooterDirty = true; + scheduleUpdateIfNotInBatch(); + } + + @Override + public void setHeader(@Nullable String header) { + this.header = ComponentSerializer.deserialize(ChatFormat.formattedTextToJson(header)); + headerOrFooterDirty = true; + scheduleUpdateIfNotInBatch(); + } + + @Override + public void setFooter(@Nullable String footer) { + this.footer = ComponentSerializer.deserialize(ChatFormat.formattedTextToJson(footer)); + headerOrFooterDirty = true; + scheduleUpdateIfNotInBatch(); + } + } + + private static int index(int column, int row) { + return column * 20 + row; + } + + private static String[][] toPropertiesArray(ProfileProperty textureProperty) { + if (textureProperty == null) { + return EMPTY_PROPERTIES_ARRAY; + } else if (textureProperty.isSigned()) { + return new String[][]{{textureProperty.getName(), textureProperty.getValue(), textureProperty.getSignature()}}; + } else { + return new String[][]{{textureProperty.getName(), textureProperty.getValue()}}; + } + } + + private enum SlotState { + UNUSED, CUSTOM + } +} diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/managers/TabViewManager.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/managers/TabViewManager.java index 50e92de2..393cc250 100644 --- a/bungee/src/main/java/codecrafter47/bungeetablistplus/managers/TabViewManager.java +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/managers/TabViewManager.java @@ -140,7 +140,11 @@ private PlayerTabView createTabView(ProxiedPlayer player) { Logger logger = new ChildLogger(btlp.getLogger(), player.getName()); EventLoop eventLoop = ReflectionUtil.getChannelWrapper(player).getHandle().eventLoop(); - if (protocolVersionProvider.has1193OrLater(player)) { + if (protocolVersionProvider.has1214OrLater(player)) { + OrderedTabOverlayHandler handler = new OrderedTabOverlayHandler(logger, eventLoop, player); + tabOverlayHandler = handler; + packetHandler = new RewriteLogic(new GetGamemodeLogic(handler, (UserConnection) player)); + } else if (protocolVersionProvider.has1193OrLater(player)) { NewTabOverlayHandler handler = new NewTabOverlayHandler(logger, eventLoop, player); tabOverlayHandler = handler; packetHandler = new RewriteLogic(new GetGamemodeLogic(handler, (UserConnection) player)); diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/BungeeProtocolVersionProvider.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/BungeeProtocolVersionProvider.java index 968aeeaf..2cbe8401 100644 --- a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/BungeeProtocolVersionProvider.java +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/BungeeProtocolVersionProvider.java @@ -54,4 +54,9 @@ public boolean has1193OrLater(ProxiedPlayer player) { return player.getPendingConnection().getVersion() >= 761; } + @Override + public boolean has1214OrLater(ProxiedPlayer player) { + return player.getPendingConnection().getVersion() >= 769; + } + } diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolSupportVersionProvider.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolSupportVersionProvider.java index c0cd798e..a8158f41 100644 --- a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolSupportVersionProvider.java +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolSupportVersionProvider.java @@ -71,6 +71,16 @@ public boolean has1193OrLater(ProxiedPlayer player) { } } + @Override + public boolean has1214OrLater(ProxiedPlayer player) { + ProtocolVersion protocolVersion = ProtocolSupportAPI.getProtocolVersion(player); + if (psb12) { + return false; + } else { + return protocolVersion.getId() >= 769; + } + } + @Override public boolean is18(ProxiedPlayer player) { ProtocolVersion protocolVersion = ProtocolSupportAPI.getProtocolVersion(player); diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolVersionProvider.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolVersionProvider.java index b5784a15..248b15c3 100644 --- a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolVersionProvider.java +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ProtocolVersionProvider.java @@ -32,4 +32,6 @@ public interface ProtocolVersionProvider { String getVersion(ProxiedPlayer player); boolean has1193OrLater(ProxiedPlayer player); + + boolean has1214OrLater(ProxiedPlayer player); } diff --git a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ViaVersionProtocolVersionProvider.java b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ViaVersionProtocolVersionProvider.java index 722eb7fd..ab050937 100644 --- a/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ViaVersionProtocolVersionProvider.java +++ b/bungee/src/main/java/codecrafter47/bungeetablistplus/version/ViaVersionProtocolVersionProvider.java @@ -45,6 +45,11 @@ public boolean has1193OrLater(ProxiedPlayer player) { return Via.getAPI().getPlayerVersion(player) >= 761; } + @Override + public boolean has1214OrLater(ProxiedPlayer player) { + return Via.getAPI().getPlayerVersion(player) >= 769; + } + @Override public boolean is18(ProxiedPlayer player) { return Via.getAPI().getPlayerVersion(player) == 47; diff --git a/minecraft-data-api b/minecraft-data-api index 1cfdbf72..f59fb17d 160000 --- a/minecraft-data-api +++ b/minecraft-data-api @@ -1 +1 @@ -Subproject commit 1cfdbf72ed2320c39f23fc1c296e5ee389c6b17a +Subproject commit f59fb17dc3ae1d6b1234aadff2d9546f5119a936