Repository: TheComputerGeek2/MagicSpells
Pull request #991: Add GlowSpell
Author: login: TonytheMacaroni
Labels: name: Build PR Jar
Created: 2025-03-01T02:36:02Z | Merged: 2025-03-29T16:15:20Z

Adds `.targeted.GlowSpell`, a replacement for `.targeted.ext.GlowSpell`.

Example:
```yml
glow:
    spell-class: ".targeted.GlowSpell"
    key: example:glow # Specifies a namespaced key for the glow. Glows with the same key override each other. Supports replacement. Optional.
    color: red # Specifies the glow's color. Optional.
    global: true # Specifies whether the glow is only visible to the caster, or all players. Defaults to `true`.
    duration: 200 # Specifies how long the glow should last, in ticks. Only applies when the value is greater than 0. Defaults to `0`.
    priority: 20 # Specifies the priority of the glow. The glow with the highest priority will be applied. Defaults to `0`.

glow/remove:
    spell-class: ".targeted.GlowSpell"
    key: example:glow # Specifies the namespaced key of the glow to remove.
    global: true # Specifies whether to remove a global or per-player glow.
    remove: true # When `true`, the glow specified by `key` will instead be removed.
```

`general.yml` configuration:
```yml
# General options that apply to the scoreboard teams created by `.targeted.GlowSpell`. These correspond to general scoreboard teams options that can affect client behavior. See https://minecraft.wiki/w/Scoreboard#Teams.
glow-spell-scoreboard-teams:
    see-friendly-invisibles: false
    name-tag-visibility: always
    collision-rule: always
```

- No longer requires an external dependency to function. Interfaces with the `packetevents` plugin when available for better compatibility with other plugins.
- Relies on scoreboard teams and packet manipulation. As such, this spell can conflict with other plugins that interact with scoreboards, such as those that use them to manipulate the tab list, or preventing player collisions.
- Has some support for disguises from the Lib's Disguises plugin. There are issues that can be caused when using a `GlowSpell` on a disguised entity; most of these issues will likely not be fixed.



Diff:
diff --git a/NOTICE.md b/NOTICE.md
index 8e8ec8f13..9fc666da2 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -50,3 +50,7 @@ The project includes software developed by third parties. For their full license
 - XGlow:
   - Repository: `Xezard/XGlow`
   - License: **Apache-2.0 License** (See `3rd_party_licenses/LICENSE-Apache_v2`)
+
+- packetevents:
+  - Repository: `retrooper/packetevents`
+  - License: **General Public License v3.0** (See `3rd_party_licenses/LICENSE-GPLv3`)
diff --git a/buildSrc/src/main/java/dev/magicspells/gradle/MSJavaPlugin.java b/buildSrc/src/main/java/dev/magicspells/gradle/MSJavaPlugin.java
index 7c89f6548..cf70d5165 100644
--- a/buildSrc/src/main/java/dev/magicspells/gradle/MSJavaPlugin.java
+++ b/buildSrc/src/main/java/dev/magicspells/gradle/MSJavaPlugin.java
@@ -24,19 +24,20 @@ public void apply(Project target) {
         repositories.mavenCentral();
 
         String[] mavenUrls = new String[] {
-                "https://repo.dmulloy2.net/nexus/repository/public/",
-                "https://repo.md-5.net/content/repositories/releases/",
-                "https://repo.papermc.io/repository/maven-public/",
-                "https://repo.aikar.co/content/groups/aikar/",
-                "https://oss.sonatype.org/content/repositories/central",
-                "https://oss.sonatype.org/content/repositories/snapshots",
-                "https://hub.spigotmc.org/nexus/content/repositories/snapshots/",
-                "https://jitpack.io",
-                "https://repo.codemc.org/repository/maven-public",
-                "https://cdn.rawgit.com/Rayzr522/maven-repo/master/",
-                "https://maven.enginehub.org/repo/",
-                "https://repo.glaremasters.me/repository/towny/",
-                "https://repo.extendedclip.com/content/repositories/placeholderapi"
+            "https://repo.dmulloy2.net/nexus/repository/public/",
+            "https://repo.md-5.net/content/repositories/releases/",
+            "https://repo.papermc.io/repository/maven-public/",
+            "https://repo.aikar.co/content/groups/aikar/",
+            "https://oss.sonatype.org/content/repositories/central",
+            "https://oss.sonatype.org/content/repositories/snapshots",
+            "https://hub.spigotmc.org/nexus/content/repositories/snapshots/",
+            "https://jitpack.io",
+            "https://repo.codemc.org/repository/maven-public",
+            "https://cdn.rawgit.com/Rayzr522/maven-repo/master/",
+            "https://maven.enginehub.org/repo/",
+            "https://repo.glaremasters.me/repository/towny/",
+            "https://repo.extendedclip.com/content/repositories/placeholderapi",
+            "https://repo.md-5.net/content/repositories/snapshots",
         };
         for (String url : mavenUrls) {
             repositories.maven(mavenArtifactRepository -> mavenArtifactRepository.setUrl(url));
diff --git a/core/build.gradle b/core/build.gradle
index d0e17c3a0..37fd5328b 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -15,8 +15,9 @@ dependencies {
     shadow(project(path: ":nms:shared", configuration: "apiElements"))
     shadow(project(path: ":nms:latest")) { transitive = false }
 
+    implementation(group: "com.github.retrooper", name: "packetevents-spigot", version: "2.7.0")
     implementation(group: "com.comphenix.protocol", name: "ProtocolLib", version: "5.3.0") { transitive = false }
-    implementation(group: "com.github.libraryaddict", name: "LibsDisguises", version: "v10.0.25") { transitive = false }
+    implementation(group: "me.libraryaddict.disguises", name: "libsdisguises", version: "10.0.44-SNAPSHOT") { transitive = false }
     implementation(group: "net.milkbowl.vault", name: "VaultAPI", version: "1.7") { transitive = false }
     implementation(group: "me.clip", name: "placeholderapi", version: "2.11.6") { transitive = false }
     implementation(group: "com.github.GriefPrevention", name: "GriefPrevention", version: "17.0.0") { transitive = false }
diff --git a/core/src/main/java/com/nisovin/magicspells/MagicSpells.java b/core/src/main/java/com/nisovin/magicspells/MagicSpells.java
index 3f2862257..59bba02b5 100644
--- a/core/src/main/java/com/nisovin/magicspells/MagicSpells.java
+++ b/core/src/main/java/com/nisovin/magicspells/MagicSpells.java
@@ -19,6 +19,7 @@
 import java.nio.file.Path;
 import java.nio.file.Files;
 
+import com.google.common.collect.Sets;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.LinkedHashMultimap;
 
@@ -1869,9 +1870,13 @@ public static void registerEvents(final Listener listener) {
 
 	public static void registerEvents(final Listener listener, EventPriority customPriority) {
 		if (customPriority == null) customPriority = EventPriority.NORMAL;
-		Method[] methods;
+		Set<Method> methods;
 		try {
-			methods = listener.getClass().getDeclaredMethods();
+			Class<?> listenerClazz = listener.getClass();
+			methods = Sets.union(
+				Set.of(listenerClazz.getMethods()),
+				Set.of(listenerClazz.getDeclaredMethods())
+			);
 		} catch (NoClassDefFoundError e) {
 			DebugHandler.debugNoClassDefFoundError(e);
 			return;
diff --git a/core/src/main/java/com/nisovin/magicspells/Spell.java b/core/src/main/java/com/nisovin/magicspells/Spell.java
index db7dc74ec..e93ada561 100644
--- a/core/src/main/java/com/nisovin/magicspells/Spell.java
+++ b/core/src/main/java/com/nisovin/magicspells/Spell.java
@@ -7,6 +7,7 @@
 
 import net.kyori.adventure.key.Key;
 import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
 
 import java.util.*;
 import java.util.function.Predicate;
@@ -938,6 +939,14 @@ protected <T extends Keyed> ConfigData<T> getConfigDataRegistryEntry(@NotNull St
 		return ConfigDataUtil.getRegistryEntry(config.getMainConfig(), internalKey + key, registry, def);
 	}
 
+	protected ConfigData<NamedTextColor> getConfigDataNamedTextColor(String key, NamedTextColor def) {
+		return ConfigDataUtil.getNamedTextColor(config.getMainConfig(), internalKey + key, def);
+	}
+
+	protected ConfigData<NamespacedKey> getConfigDataNamespacedKey(String key, NamespacedKey def) {
+		return ConfigDataUtil.getNamespacedKey(config.getMainConfig(), internalKey + key, def);
+	}
+
 	/**
 	 * @param key Path for the string or section format SpellFilter to be read from.
 	 */
diff --git a/core/src/main/java/com/nisovin/magicspells/spells/targeted/GlowSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/targeted/GlowSpell.java
new file mode 100644
index 000000000..e7a4cb1f9
--- /dev/null
+++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/GlowSpell.java
@@ -0,0 +1,114 @@
+package com.nisovin.magicspells.spells.targeted;
+
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.LivingEntity;
+
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import com.nisovin.magicspells.MagicSpells;
+import com.nisovin.magicspells.util.SpellData;
+import com.nisovin.magicspells.util.CastResult;
+import com.nisovin.magicspells.util.TargetInfo;
+import com.nisovin.magicspells.util.MagicConfig;
+import com.nisovin.magicspells.spells.TargetedSpell;
+import com.nisovin.magicspells.util.config.ConfigData;
+import com.nisovin.magicspells.util.glow.GlowManager;
+import com.nisovin.magicspells.spells.TargetedEntitySpell;
+import com.nisovin.magicspells.util.glow.impl.PacketEventsGlowManager;
+
+public class GlowSpell extends TargetedSpell implements TargetedEntitySpell {
+
+	private static GlowManager glowManager;
+
+	private final ConfigData<Boolean> global;
+	private final ConfigData<Boolean> remove;
+	private final ConfigData<Integer> duration;
+	private final ConfigData<Integer> priority;
+	private final ConfigData<NamespacedKey> key;
+	private final ConfigData<NamedTextColor> color;
+
+	public GlowSpell(MagicConfig config, String spellName) {
+		super(config, spellName);
+
+		key = getConfigDataNamespacedKey("key", null);
+		color = getConfigDataNamedTextColor("color", null);
+		global = getConfigDataBoolean("global", true);
+		remove = getConfigDataBoolean("remove", false);
+		duration = getConfigDataInt("duration", 0);
+		priority = getConfigDataInt("priority", 0);
+
+		if (glowManager == null) {
+			if (Bukkit.getPluginManager().isPluginEnabled("packetevents")) glowManager = new PacketEventsGlowManager();
+			else glowManager = MagicSpells.getVolatileCodeHandler().getGlowManager();
+
+			glowManager.load();
+		}
+	}
+
+	@Override
+	public CastResult cast(SpellData data) {
+		TargetInfo<LivingEntity> info = getTargetedEntity(data);
+		if (info.noTarget()) return noTarget(info);
+
+		return castAtEntity(info.spellData());
+	}
+
+	@Override
+	public CastResult castAtEntity(SpellData data) {
+		if (global.get(data)) {
+			NamespacedKey key = this.key.get(data);
+
+			if (remove.get(data)) {
+				if (key == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+				glowManager.removeGlow(data.target(), key);
+			} else {
+				glowManager.applyGlow(
+					data.target(),
+					key != null ? key : new NamespacedKey(MagicSpells.getInstance(), UUID.randomUUID().toString()),
+					color.get(data),
+					priority.get(data),
+					duration.get(data)
+				);
+			}
+
+			playSpellEffects(data);
+			return new CastResult(PostCastAction.HANDLE_NORMALLY, data);
+		}
+
+		if (!(data.caster() instanceof Player caster)) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+		NamespacedKey key = this.key.get(data);
+
+		if (remove.get(data)) {
+			if (key == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+			glowManager.removeGlow(caster, data.target(), key);
+		} else {
+			glowManager.applyGlow(
+				caster,
+				data.target(),
+				key != null ? key : new NamespacedKey(MagicSpells.getInstance(), UUID.randomUUID().toString()),
+				color.get(data),
+				priority.get(data),
+				duration.get(data)
+			);
+		}
+
+		playSpellEffects(data);
+		return new CastResult(PostCastAction.HANDLE_NORMALLY, data);
+	}
+
+	@Override
+	protected void turnOff() {
+		if (glowManager == null) return;
+
+		glowManager.unload();
+		glowManager = null;
+	}
+
+}
diff --git a/core/src/main/java/com/nisovin/magicspells/spells/targeted/ext/GlowSpell.java b/core/src/main/java/com/nisovin/magicspells/spells/targeted/ext/GlowSpell.java
index 145cf04cc..9c586fe4a 100644
--- a/core/src/main/java/com/nisovin/magicspells/spells/targeted/ext/GlowSpell.java
+++ b/core/src/main/java/com/nisovin/magicspells/spells/targeted/ext/GlowSpell.java
@@ -25,6 +25,11 @@
 @DependsOn({"ProtocolLib", "XGlow"})
 public class GlowSpell extends TargetedSpell implements TargetedEntitySpell {
 
+	private static final DeprecationNotice DEPRECATION_NOTICE = new DeprecationNotice(
+		"The '.targeted.ext.GlowSpell' spell class does not function, as the XGlow plugin is abandoned.",
+		"Use the '.targeted.GlowSpell' spell class."
+	);
+
 	private final Multimap<UUID, GlowData> glowing;
 
 	private final ConfigData<ChatColor> color;
@@ -43,6 +48,8 @@ public GlowSpell(MagicConfig config, String spellName) {
 		powerAffectsDuration = getConfigDataBoolean("power-affects-duration", true);
 
 		color = getConfigDataEnum("color", ChatColor.class, ChatColor.WHITE);
+
+		MagicSpells.getDeprecationManager().addDeprecation(this, DEPRECATION_NOTICE);
 	}
 
 	@Override
diff --git a/core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java b/core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java
index fe32cb6ee..6f59ba0b2 100644
--- a/core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java
+++ b/core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java
@@ -7,6 +7,7 @@
 import org.jetbrains.annotations.Nullable;
 
 import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
 
 import org.bukkit.*;
 import org.bukkit.util.Vector;
@@ -19,6 +20,7 @@
 import org.bukkit.configuration.ConfigurationSection;
 
 import com.nisovin.magicspells.util.*;
+import com.nisovin.magicspells.MagicSpells;
 import com.nisovin.magicspells.handlers.PotionEffectHandler;
 
 public class ConfigDataUtil {
@@ -758,6 +760,44 @@ public boolean isConstant() {
 		};
 	}
 
+	public static ConfigData<NamedTextColor> getNamedTextColor(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamedTextColor def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamedTextColor val = NamedTextColor.NAMES.value(value.toLowerCase());
+		if (val != null) return data -> val;
+
+		ConfigData<String> supplier = getString(value);
+		if (supplier.isConstant()) return data -> def;
+
+		return (VariableConfigData<NamedTextColor>) data -> {
+			String string = supplier.get(data);
+			if (string == null) return def;
+
+			NamedTextColor color = NamedTextColor.NAMES.value(string.toLowerCase());
+			return color == null ? def : color;
+		};
+	}
+
+	public static ConfigData<NamespacedKey> getNamespacedKey(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamespacedKey def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamespacedKey val = NamespacedKey.fromString(value.toLowerCase());
+		if (val != null) return data -> val;
+
+		ConfigData<String> supplier = getString(value);
+		if (supplier.isConstant()) return data -> def;
+
+		return (VariableConfigData<NamespacedKey>) data -> {
+			String string = supplier.get(data);
+			if (string == null) return def;
+
+			NamespacedKey key = NamespacedKey.fromString(string.toLowerCase());
+			return key == null ? def : key;
+		};
+	}
+
 	public static ConfigData<Angle> getAngle(@NotNull ConfigurationSection config, @NotNull String path, @Nullable Angle def) {
 		if (config.isInt(path) || config.isLong(path) || config.isDouble(path)) {
 			float value = (float) config.getDouble(path);
diff --git a/core/src/main/java/com/nisovin/magicspells/util/glow/impl/PacketEventsGlowManager.java b/core/src/main/java/com/nisovin/magicspells/util/glow/impl/PacketEventsGlowManager.java
new file mode 100644
index 000000000..a6fd4ad7b
--- /dev/null
+++ b/core/src/main/java/com/nisovin/magicspells/util/glow/impl/PacketEventsGlowManager.java
@@ -0,0 +1,241 @@
+package com.nisovin.magicspells.util.glow.impl;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.scoreboard.Scoreboard;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.github.retrooper.packetevents.PacketEvents;
+import com.github.retrooper.packetevents.event.PacketSendEvent;
+import com.github.retrooper.packetevents.wrapper.PacketWrapper;
+import com.github.retrooper.packetevents.event.PacketListenerAbstract;
+import com.github.retrooper.packetevents.event.PacketListenerPriority;
+import com.github.retrooper.packetevents.manager.player.PlayerManager;
+import com.github.retrooper.packetevents.protocol.packettype.PacketType;
+import com.github.retrooper.packetevents.protocol.entity.data.EntityData;
+import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes;
+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams;
+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerTeams.*;
+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata;
+
+import com.nisovin.magicspells.MagicSpells;
+import com.nisovin.magicspells.util.glow.LibsDisguiseHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class PacketEventsGlowManager extends PacketBasedGlowManager<PacketWrapper<?>, WrapperPlayServerEntityMetadata, WrapperPlayServerTeams> {
+
+	private final GlowPacketListener listener;
+
+	public PacketEventsGlowManager() {
+		listener = new GlowPacketListener();
+
+		MagicSpells.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		PacketEvents.getAPI().getEventManager().registerListener(listener);
+	}
+
+	@Override
+	public synchronized void unload() {
+		PacketEvents.getAPI().getEventManager().unregisterListener(listener);
+
+		super.unload();
+	}
+
+	@Override
+	protected Collection<WrapperPlayServerTeams> createAddTeamPackets() {
+		ConfigurationSection config = MagicSpells.getInstance().getMagicConfig().getMainConfig();
+
+		boolean seeFriendlyInvisibles = config.getBoolean("general.glow-spell-scoreboard-teams.see-friendly-invisibles", false);
+		OptionData optionData = seeFriendlyInvisibles ? OptionData.ALL : OptionData.FRIENDLY_FIRE;
+
+		NameTagVisibility visibility = getStringOption("name-tag-visibility", NameTagVisibility.ALWAYS, NameTagVisibility::fromID, config, MagicSpells::error);
+		CollisionRule collisionRule = getStringOption("collision-rule", CollisionRule.ALWAYS, CollisionRule::fromID, config, MagicSpells::error);
+
+		return NamedTextColor.NAMES.values()
+			.stream()
+			.map(color -> {
+				String name = "magicspells:" + color;
+
+				return new WrapperPlayServerTeams(
+					name,
+					TeamMode.CREATE,
+					new WrapperPlayServerTeams.ScoreBoardTeamInfo(
+						Component.text(name),
+						null,
+						null,
+						visibility,
+						collisionRule,
+						color,
+						optionData
+					)
+				);
+			})
+			.toList();
+	}
+
+	@Override
+	protected Collection<WrapperPlayServerTeams> createRemoveTeamPackets() {
+		return NamedTextColor.NAMES.values()
+			.stream()
+			.map(color -> {
+				String name = "magicspells:" + color;
+				return new WrapperPlayServerTeams(name, TeamMode.REMOVE, (WrapperPlayServerTeams.ScoreBoardTeamInfo) null);
+			})
+			.toList();
+	}
+
+	@Override
+	protected WrapperPlayServerEntityMetadata createEntityDataPacket(@NotNull Entity entity, boolean forceGlow) {
+		byte metadata = MagicSpells.getVolatileCodeHandler().getEntityMetadata(entity);
+		if (forceGlow) metadata |= 0x40;
+
+		return new WrapperPlayServerEntityMetadata(
+			entity.getEntityId(),
+			List.of(new EntityData(0, EntityDataTypes.BYTE, metadata))
+		);
+	}
+
+	@Override
+	protected Collection<WrapperPlayServerTeams> createJoinTeamPacket(@NotNull GlowData data) {
+		return data.lastScoreboardEntry().map(entry -> new WrapperPlayServerTeams(
+			"magicspells:" + data.color(),
+			TeamMode.ADD_ENTITIES,
+			(WrapperPlayServerTeams.ScoreBoardTeamInfo) null,
+			entry
+		));
+	}
+
+	@Override
+	protected Collection<WrapperPlayServerTeams> createResetTeamPacket(@NotNull Scoreboard scoreboard, @NotNull GlowData data) {
+		return data.lastScoreboardEntry().map(entry -> {
+			Team team = scoreboard.getEntryTeam(entry);
+			if (team != null) {
+				return new WrapperPlayServerTeams(
+					team.getName(),
+					TeamMode.ADD_ENTITIES,
+					(WrapperPlayServerTeams.ScoreBoardTeamInfo) null,
+					entry
+				);
+			}
+
+			return new WrapperPlayServerTeams(
+				"magicspells:" + data.color(),
+				TeamMode.REMOVE_ENTITIES,
+				(WrapperPlayServerTeams.ScoreBoardTeamInfo) null,
+				entry
+			);
+		});
+	}
+
+	@Override
+	protected void sendPacket(@NotNull Player player, @NotNull PacketWrapper<?> packetWrapper) {
+		PlayerManager manager = PacketEvents.getAPI().getPlayerManager();
+		if (packetWrapper instanceof WrapperPlayServerEntityMetadata) manager.sendPacket(player, packetWrapper);
+		else manager.sendPacketSilently(player, packetWrapper);
+	}
+
+	@Override
+	protected void registerEvents(Listener listener) {
+		MagicSpells.registerEvents(listener);
+	}
+
+	@Override
+	protected void cancelTask(int taskId) {
+		MagicSpells.cancelTask(taskId);
+	}
+
+	@Override
+	public int scheduleDelayedTask(Runnable runnable, long delay) {
+		return MagicSpells.scheduleDelayedTask(runnable, delay);
+	}
+
+	private final class GlowPacketListener extends PacketListenerAbstract {
+
+		public GlowPacketListener() {
+			super(PacketListenerPriority.LOWEST);
+		}
+
+		@Override
+		public void onPacketSend(PacketSendEvent event) {
+			synchronized (PacketEventsGlowManager.this) {
+				if (glows.isEmpty() && perPlayerGlows.isEmpty())
+					return;
+			}
+
+			try {
+				if (event.getPacketType() == PacketType.Play.Server.ENTITY_METADATA) handleEntityData(event);
+				else if (event.getPacketType() == PacketType.Play.Server.TEAMS) handleTeams(event);
+			} catch (Throwable throwable) {
+				MagicSpells.error("Encountered an error while intercepting a packet - cancelling packet send.");
+				throwable.printStackTrace();
+
+				event.setCancelled(true);
+			}
+		}
+
+		private void handleEntityData(PacketSendEvent event) {
+			WrapperPlayServerEntityMetadata packet = new WrapperPlayServerEntityMetadata(event);
+
+			List<EntityData> metadata = packet.getEntityMetadata();
+			if (metadata.isEmpty()) return;
+
+			EntityData entityData = metadata.getFirst();
+			if (entityData.getIndex() != 0) return;
+
+			byte flags = (byte) entityData.getValue();
+			if ((flags & 0x40) > 0) return;
+
+			Player player = event.getPlayer();
+
+			UUID uuid;
+			if (!libsDisguisesLoaded || packet.getEntityId() != LibsDisguiseHelper.getSelfDisguiseId()) {
+				Entity entity = MagicSpells.getVolatileCodeHandler().getEntityFromId(player.getWorld(), packet.getEntityId());
+				if (entity == null) return;
+
+				uuid = entity.getUniqueId();
+			} else uuid = player.getUniqueId();
+
+			GlowData data = getGlowData(player.getUniqueId(), uuid);
+			if (data == null) return;
+
+			flags |= 0x40;
+			entityData.setValue(flags);
+		}
+
+		private void handleTeams(PacketSendEvent event) {
+			WrapperPlayServerTeams packet = new WrapperPlayServerTeams(event);
+
+			TeamMode mode = packet.getTeamMode();
+			if (mode != TeamMode.REMOVE_ENTITIES && mode != TeamMode.ADD_ENTITIES) return;
+
+			Collection<String> entries = packet.getPlayers();
+			if (entries.isEmpty()) return;
+
+			Collection<String> filtered = filterTeamEntries(event.getPlayer(), entries);
+			if (filtered == null) return;
+
+			if (filtered.isEmpty()) {
+				event.setCancelled(true);
+				return;
+			}
+
+			packet.setPlayers(filtered);
+		}
+
+	}
+
+}
diff --git a/core/src/main/java/com/nisovin/magicspells/volatilecode/ManagerVolatile.java b/core/src/main/java/com/nisovin/magicspells/volatilecode/ManagerVolatile.java
index 3ace4ed5e..424349364 100644
--- a/core/src/main/java/com/nisovin/magicspells/volatilecode/ManagerVolatile.java
+++ b/core/src/main/java/com/nisovin/magicspells/volatilecode/ManagerVolatile.java
@@ -3,6 +3,8 @@
 import java.util.Map;
 
 import org.bukkit.Bukkit;
+import org.bukkit.event.Listener;
+import org.bukkit.configuration.file.YamlConfiguration;
 
 import com.nisovin.magicspells.MagicSpells;
 import com.nisovin.magicspells.volatilecode.latest.VolatileCodeLatest;
@@ -24,6 +26,21 @@ public int scheduleDelayedTask(Runnable task, long delay) {
 			return MagicSpells.scheduleDelayedTask(task, delay);
 		}
 
+		@Override
+		public void cancelTask(int id) {
+			MagicSpells.cancelTask(id);
+		}
+
+		@Override
+		public void registerEvents(Listener listener) {
+			MagicSpells.registerEvents(listener);
+		}
+
+		@Override
+		public YamlConfiguration getMainConfig() {
+			return MagicSpells.getInstance().getMagicConfig().getMainConfig();
+		}
+
 	};
 
 	public static VolatileCodeHandle constructVolatileCodeHandler() {
diff --git a/core/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeDisabled.java b/core/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeDisabled.java
index 499c5476e..b81f74142 100644
--- a/core/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeDisabled.java
+++ b/core/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeDisabled.java
@@ -1,6 +1,7 @@
 package com.nisovin.magicspells.volatilecode;
 
 import org.bukkit.Sound;
+import org.bukkit.World;
 import org.bukkit.entity.*;
 import org.bukkit.Location;
 import org.bukkit.util.Vector;
@@ -10,6 +11,8 @@
 
 import io.papermc.paper.advancement.AdvancementDisplay.Frame;
 
+import com.nisovin.magicspells.util.glow.GlowManager;
+
 public class VolatileCodeDisabled extends VolatileCodeHandle {
 
 	public VolatileCodeDisabled() {
@@ -63,4 +66,19 @@ public void clearGameTestMarkers(Player player) {
 
 	}
 
+	@Override
+	public byte getEntityMetadata(Entity entity) {
+		return 0;
+	}
+
+	@Override
+	public Entity getEntityFromId(World world, int id) {
+		return null;
+	}
+
+	@Override
+	public GlowManager getGlowManager() {
+		return null;
+	}
+
 }
diff --git a/core/src/main/resources/general.yml b/core/src/main/resources/general.yml
index 617c99f96..159571020 100644
--- a/core/src/main/resources/general.yml
+++ b/core/src/main/resources/general.yml
@@ -152,3 +152,7 @@ entity-names:
     mooshroom: a deformed cow
     squid: a slimy squid
     iron_golem: a friendly iron golem
+glow-spell-scoreboard-teams:
+    see-friendly-invisibles: false
+    name-tag-visibility: always
+    collision-rule: always
diff --git a/core/src/main/resources/plugin.yml b/core/src/main/resources/plugin.yml
index 81d1f807e..d37180242 100644
--- a/core/src/main/resources/plugin.yml
+++ b/core/src/main/resources/plugin.yml
@@ -13,3 +13,4 @@ softdepend:
     - NoCheatPlus
     - PlaceholderAPI
     - XGlow
+    - packetevents
diff --git a/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java b/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java
index ace6e5f27..6648860c7 100644
--- a/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java
+++ b/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java
@@ -4,9 +4,11 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 
+import org.bukkit.World;
 import org.bukkit.Bukkit;
 import org.bukkit.Location;
 import org.bukkit.util.Vector;
+import org.bukkit.entity.Entity;
 import org.bukkit.entity.Player;
 import org.bukkit.entity.LivingEntity;
 import org.bukkit.inventory.ItemStack;
@@ -14,6 +16,7 @@
 
 import org.bukkit.craftbukkit.CraftWorld;
 import org.bukkit.craftbukkit.CraftServer;
+import org.bukkit.craftbukkit.entity.CraftEntity;
 import org.bukkit.craftbukkit.entity.CraftPlayer;
 import org.bukkit.craftbukkit.entity.CraftTNTPrimed;
 import org.bukkit.craftbukkit.inventory.CraftItemStack;
@@ -25,6 +28,7 @@
 import io.papermc.paper.adventure.PaperAdventure;
 import io.papermc.paper.advancement.AdvancementDisplay;
 
+import com.nisovin.magicspells.util.glow.GlowManager;
 import com.nisovin.magicspells.volatilecode.VolatileCodeHandle;
 import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
 
@@ -55,12 +59,17 @@ public class VolatileCodeLatest extends VolatileCodeHandle {
 
 	private final EntityDataAccessor<List<ParticleOptions>> DATA_EFFECT_PARTICLES;
 	private final EntityDataAccessor<Boolean> DATA_EFFECT_AMBIENCE_ID;
+	private final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID;
 	private final Method UPDATE_EFFECT_PARTICLES;
 
 	@SuppressWarnings("unchecked")
 	public VolatileCodeLatest(VolatileCodeHelper helper) throws Exception {
 		super(helper);
 
+		Field dataSharedFlagsIdField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
+		dataSharedFlagsIdField.setAccessible(true);
+		DATA_SHARED_FLAGS_ID = (EntityDataAccessor<Byte>) dataSharedFlagsIdField.get(null);
+
 		Class<?> nmsEntityClass = net.minecraft.world.entity.LivingEntity.class;
 
 		Field dataEffectParticlesField = nmsEntityClass.getDeclaredField("DATA_EFFECT_PARTICLES");
@@ -211,4 +220,20 @@ public void clearGameTestMarkers(Player player) {
 		((CraftPlayer) player).getHandle().connection.send(new ClientboundCustomPayloadPacket(payload));
 	}
 
+	@Override
+	public byte getEntityMetadata(Entity entity) {
+		return ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+	}
+
+	@Override
+	public Entity getEntityFromId(World world, int id) {
+		var entity = ((CraftWorld) world).getHandle().moonrise$getEntityLookup().get(id);
+		return entity == null ? null : entity.getBukkitEntity();
+	}
+
+	@Override
+	public GlowManager getGlowManager() {
+		return new VolatileGlowManagerLatest(helper);
+	}
+
 }
diff --git a/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java b/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
new file mode 100644
index 000000000..2bd839483
--- /dev/null
+++ b/nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
@@ -0,0 +1,320 @@
+package com.nisovin.magicspells.volatilecode.latest;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.world.scores.PlayerTeam;
+import net.minecraft.world.scores.Scoreboard;
+import net.minecraft.world.scores.Team.Visibility;
+import net.minecraft.world.scores.Team.CollisionRule;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.craftbukkit.entity.CraftEntity;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.nisovin.magicspells.util.glow.LibsDisguiseHelper;
+import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class VolatileGlowManagerLatest extends PacketBasedGlowManager<Packet<?>, ClientboundSetEntityDataPacket, ClientboundSetPlayerTeamPacket> {
+
+	private static final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID = new EntityDataAccessor<>(0, EntityDataSerializers.BYTE);
+
+	private final Set<Packet<?>> handled = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
+	private final MethodHandle teamPacketHandle;
+	private final VolatileCodeHelper helper;
+
+	public VolatileGlowManagerLatest(VolatileCodeHelper helper) {
+		this.helper = helper;
+
+		try {
+			teamPacketHandle = MethodHandles
+				.privateLookupIn(ClientboundSetPlayerTeamPacket.class, MethodHandles.lookup())
+				.findConstructor(
+					ClientboundSetPlayerTeamPacket.class,
+					MethodType.methodType(void.class, String.class, int.class, Optional.class, Collection.class)
+				);
+		} catch (Exception e) {
+			throw new RuntimeException("Encountered an error while initializing VolatileGlowManagerLatest", e);
+		}
+
+		helper.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		Bukkit.getOnlinePlayers().forEach(this::addGlowChannelHandler);
+	}
+
+	@Override
+	public synchronized void unload() {
+		Bukkit.getOnlinePlayers().forEach(this::removeGlowChannelHandler);
+
+		super.unload();
+		handled.clear();
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createAddTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> teamPackets = new ArrayList<>();
+
+		ConfigurationSection config = helper.getMainConfig();
+		boolean seeFriendlyInvisibles = config.getBoolean("general.glow-spell-scoreboard-teams.see-friendly-invisibles", false);
+		CollisionRule collision = getStringOption("collision-rule", CollisionRule.ALWAYS, CollisionRule::byName, config, helper::error);
+		Visibility visibility = getStringOption("name-tag-visibility", Visibility.ALWAYS, Visibility::byName, config, helper::error);
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			team.setSeeFriendlyInvisibles(seeFriendlyInvisibles);
+			team.setNameTagVisibility(visibility);
+			team.setCollisionRule(collision);
+			team.setColor(formatting);
+
+			teamPackets.add(ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(team, true));
+		}
+
+		return teamPackets;
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createRemoveTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> packets = new ArrayList<>();
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			packets.add(ClientboundSetPlayerTeamPacket.createRemovePacket(team));
+		}
+
+		return packets;
+	}
+
+	@Override
+	protected ClientboundSetEntityDataPacket createEntityDataPacket(@NotNull Entity entity, boolean forceGlow) {
+		byte metadata = ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+		if (forceGlow) metadata |= 0x40;
+
+		return new ClientboundSetEntityDataPacket(
+			entity.getEntityId(),
+			list(new SynchedEntityData.DataValue<>(
+				0,
+				EntityDataSerializers.BYTE,
+				metadata
+			))
+		);
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createJoinTeamPacket(@NotNull GlowData data) {
+		return data.lastScoreboardEntry().map(entry -> {
+			try {
+				return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+					"magicspells:" + data.color(),
+					3,
+					Optional.empty(),
+					list(entry)
+				);
+			} catch (Throwable e) {
+				throw new RuntimeException(e);
+			}
+		});
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createResetTeamPacket(org.bukkit.scoreboard.@NotNull Scoreboard scoreboard, @NotNull GlowData data) {
+		return data.lastScoreboardEntry().map(entry -> {
+			try {
+				Team team = scoreboard.getEntryTeam(entry);
+				if (team != null) {
+					return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+						team.getName(),
+						3,
+						Optional.empty(),
+						list(entry)
+					);
+				}
+
+				return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+					"magicspells:" + data.color(),
+					4,
+					Optional.empty(),
+					list(entry)
+				);
+			} catch (Throwable e) {
+				throw new RuntimeException(e);
+			}
+		});
+	}
+
+	@Override
+	protected void sendPacket(@NotNull Player player, @NotNull Packet<?> packet) {
+		if (!(packet instanceof ClientboundSetPlayerTeamPacket teamPacket) || teamPacket.getTeamAction() == null)
+			handled.add(packet);
+
+		ServerGamePacketListenerImpl connection = ((CraftPlayer) player).getHandle().connection;
+		connection.send(packet);
+	}
+
+	@Override
+	protected void registerEvents(Listener listener) {
+		helper.registerEvents(listener);
+	}
+
+	@Override
+	protected void cancelTask(int taskId) {
+		helper.cancelTask(taskId);
+	}
+
+	@Override
+	public int scheduleDelayedTask(Runnable runnable, long delay) {
+		return helper.scheduleDelayedTask(runnable, delay);
+	}
+
+	private void addGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+		pipeline.addBefore("unbundler", "magicspells:glow_channel_handler", new GlowChannelHandler(player));
+	}
+
+	private void removeGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+
+		if (pipeline.get("magicspells:glow_channel_handler") != null)
+			pipeline.remove("magicspells:glow_channel_handler");
+	}
+
+	private <T> List<T> list(T element) {
+		List<T> list = new ArrayList<>(1);
+		list.add(element);
+
+		return list;
+	}
+
+	@Override
+	@EventHandler
+	public void onPlayerJoin(PlayerJoinEvent event) {
+		super.onPlayerJoin(event);
+		addGlowChannelHandler(event.getPlayer());
+	}
+
+	@EventHandler
+	public void onPlayerQuit(PlayerQuitEvent event) {
+		removeGlowChannelHandler(event.getPlayer());
+	}
+
+	private class GlowChannelHandler extends ChannelOutboundHandlerAdapter {
+
+		private final Player player;
+
+		private GlowChannelHandler(Player player) {
+			this.player = player;
+		}
+
+		@Override
+		public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+			if (msg instanceof Packet<?> p && handled.contains(p)) {
+				super.write(ctx, msg, promise);
+				return;
+			}
+
+			synchronized (VolatileGlowManagerLatest.this) {
+				if (glows.isEmpty() && perPlayerGlows.isEmpty()) {
+					super.write(ctx, msg, promise);
+					return;
+				}
+			}
+
+			switch (msg) {
+				case ClientboundSetEntityDataPacket packet -> handleEntityData(packet);
+				case ClientboundSetPlayerTeamPacket packet -> {
+					msg = handleTeamPacket(packet);
+					if (msg == null) return;
+				}
+				default -> {}
+			}
+
+			super.write(ctx, msg, promise);
+		}
+
+		private void handleEntityData(ClientboundSetEntityDataPacket packet) {
+			List<SynchedEntityData.DataValue<?>> packedItems = packet.packedItems();
+			if (packedItems.isEmpty()) return;
+
+			SynchedEntityData.DataValue<?> item = packedItems.getFirst();
+			if (item.id() != 0) return;
+
+			byte flags = (byte) item.value();
+			if ((flags & 0x40) > 0) return;
+
+			UUID uuid;
+			if (!libsDisguisesLoaded || packet.id() != LibsDisguiseHelper.getSelfDisguiseId()) {
+				var entity = ((CraftPlayer) player).getHandle().level().moonrise$getEntityLookup().get(packet.id());
+				if (entity == null) return;
+
+				uuid = entity.getUUID();
+			} else uuid = player.getUniqueId();
+
+			GlowData data = getGlowData(player.getUniqueId(), uuid);
+			if (data == null) return;
+
+			flags |= 0x40;
+			packedItems.set(0, new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, flags));
+		}
+
+		private ClientboundSetPlayerTeamPacket handleTeamPacket(ClientboundSetPlayerTeamPacket packet) {
+			ClientboundSetPlayerTeamPacket.Action playerAction = packet.getPlayerAction();
+			if (playerAction == null || packet.getTeamAction() != null) return packet;
+
+			Collection<String> entries = packet.getPlayers();
+			if (entries.isEmpty()) return packet;
+
+			Collection<String> filtered = filterTeamEntries(player, entries);
+			if (filtered == null) return packet;
+			if (filtered.isEmpty()) return null;
+
+			try {
+				return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+					packet.getName(),
+					playerAction == ClientboundSetPlayerTeamPacket.Action.ADD ? 3 : 4,
+					packet.getParameters(),
+					filtered
+				);
+			} catch (Throwable e) {
+				throw new RuntimeException(e);
+			}
+		}
+
+	}
+
+}
diff --git a/nms/shared/build.gradle b/nms/shared/build.gradle
index e69de29bb..6eeba53ee 100644
--- a/nms/shared/build.gradle
+++ b/nms/shared/build.gradle
@@ -0,0 +1,3 @@
+dependencies {
+    implementation(group: "me.libraryaddict.disguises", name: "libsdisguises", version: "10.0.44-SNAPSHOT") { transitive = false }
+}
diff --git a/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/GlowManager.java b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/GlowManager.java
new file mode 100644
index 000000000..10678fa98
--- /dev/null
+++ b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/GlowManager.java
@@ -0,0 +1,26 @@
+package com.nisovin.magicspells.util.glow;
+
+import org.jetbrains.annotations.Range;
+import org.jetbrains.annotations.NotNull;
+
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+
+public interface GlowManager {
+
+	void load();
+
+	void unload();
+
+	void applyGlow(@NotNull Entity entity, @NotNull NamespacedKey key, @NotNull NamedTextColor color, int priority, @Range(from = 0, to = Integer.MAX_VALUE) int duration);
+
+	void applyGlow(@NotNull Player player, @NotNull Entity entity, @NotNull NamespacedKey key, @NotNull NamedTextColor color, int priority, @Range(from = 0, to = Integer.MAX_VALUE) int duration);
+
+	void removeGlow(@NotNull Entity entity, @NotNull NamespacedKey key);
+
+	void removeGlow(@NotNull Player player, @NotNull Entity entity, @NotNull NamespacedKey key);
+
+}
diff --git a/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/LibsDisguiseHelper.java b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/LibsDisguiseHelper.java
new file mode 100644
index 000000000..1e64fb70f
--- /dev/null
+++ b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/LibsDisguiseHelper.java
@@ -0,0 +1,70 @@
+package com.nisovin.magicspells.util.glow;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+
+import me.libraryaddict.disguise.DisguiseAPI;
+import me.libraryaddict.disguise.events.DisguiseEvent;
+import me.libraryaddict.disguise.disguisetypes.Disguise;
+import me.libraryaddict.disguise.events.UndisguiseEvent;
+import me.libraryaddict.disguise.disguisetypes.PlayerDisguise;
+
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager.ScoreboardEntry;
+
+public class LibsDisguiseHelper {
+
+	public static ScoreboardEntry getDisguisedScoreboardEntry(@Nullable Player player, @NotNull Entity entity) {
+		Disguise disguise = player == null ? DisguiseAPI.getDisguise(entity) : DisguiseAPI.getDisguise(player, entity);
+		if (disguise == null) return new ScoreboardEntry(entity.getScoreboardEntryName());
+
+		if (entity.equals(player) && !disguise.isSelfDisguiseVisible())
+			return new ScoreboardEntry(entity.getScoreboardEntryName());
+
+		return getDisguisedScoreboardEntry(entity, disguise);
+	}
+
+	public static ScoreboardEntry getDisguisedScoreboardEntry(@NotNull Entity entity, @NotNull Disguise disguise) {
+		String entry = disguise instanceof PlayerDisguise pd ? pd.getProfileName() : disguise.getUUID().toString();
+		return new ScoreboardEntry(entity.getScoreboardEntryName(), entry);
+	}
+
+	public static int getSelfDisguiseId() {
+		return DisguiseAPI.getSelfDisguiseId();
+	}
+
+	public static Listener createLibDisguisesListener(PacketBasedGlowManager<?, ?, ?> glowManager) {
+		return new Listener() {
+
+			@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+			public void onDisguise(DisguiseEvent event) {
+				Disguise disguise = event.getDisguise();
+				if (!disguise.isSelfDisguiseVisible() || !(event.getDisguised() instanceof Player player))
+					return;
+
+				// Self-disguising is usually delayed by 2 ticks, at the time of writing. As such, we must delay refreshing
+				// the scoreboard entry to avoid glow colors flickering. Adjust as necessary for disguise delay.
+				glowManager.scheduleDelayedTask(() -> {
+					if (disguise.isDisguiseInUse())
+						glowManager.refreshScoreboardEntry(player, getDisguisedScoreboardEntry(player, disguise));
+				}, 2);
+			}
+
+			@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+			public void onUndisguise(UndisguiseEvent event) {
+				Disguise disguise = event.getDisguise();
+				if (!disguise.isSelfDisguiseVisible() || !(event.getDisguised() instanceof Player player))
+					return;
+
+				glowManager.refreshScoreboardEntry(player, new ScoreboardEntry(player.getScoreboardEntryName()));
+			}
+
+		};
+	}
+
+}
diff --git a/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/PacketBasedGlowManager.java b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/PacketBasedGlowManager.java
new file mode 100644
index 000000000..04c34094a
--- /dev/null
+++ b/nms/shared/src/main/java/com/nisovin/magicspells/util/glow/PacketBasedGlowManager.java
@@ -0,0 +1,587 @@
+package com.nisovin.magicspells.util.glow;
+
+import org.jetbrains.annotations.Range;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.concurrent.ConcurrentHashMap;
+
+import it.unimi.dsi.fastutil.Pair;
+import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
+import it.unimi.dsi.fastutil.objects.ObjectObjectMutablePair;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+
+import com.google.common.collect.Iterables;
+
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.scoreboard.Scoreboard;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.configuration.ConfigurationSection;
+
+import io.papermc.paper.event.player.PlayerTrackEntityEvent;
+import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
+
+public abstract class PacketBasedGlowManager<TPacket, TEntityDataPacket extends TPacket, TTeamPacket extends TPacket> implements GlowManager, Listener {
+
+	protected final Object2ObjectMap<Pair<UUID, UUID>, GlowDataMap> perPlayerGlows = new Object2ObjectOpenHashMap<>();
+	protected final Object2ObjectMap<UUID, GlowDataMap> glows = new Object2ObjectOpenHashMap<>();
+	protected final ConcurrentHashMap<String, UUID> scoreboardNames = new ConcurrentHashMap<>();
+
+	protected Collection<TTeamPacket> addTeamPackets;
+	protected boolean libsDisguisesLoaded = false;
+	protected int sequence = Integer.MIN_VALUE;
+
+	@Override
+	public void load() {
+		addTeamPackets = createAddTeamPackets();
+		Bukkit.getOnlinePlayers().forEach(player -> sendPackets(player, addTeamPackets));
+
+		if (Bukkit.getPluginManager().isPluginEnabled("LibsDisguises")) {
+			libsDisguisesLoaded = true;
+			registerEvents(LibsDisguiseHelper.createLibDisguisesListener(this));
+		}
+	}
+
+	@Override
+	public synchronized void unload() {
+		glows.forEach((key, value) -> {
+			Entity entity = Bukkit.getEntity(key);
+			if (entity == null) return;
+
+			resetGlow(null, entity, value.get());
+		});
+		glows.clear();
+
+		perPlayerGlows.forEach((key, value) -> {
+			Player player = Bukkit.getPlayer(key.left());
+			if (player == null) return;
+
+			Entity entity = Bukkit.getEntity(key.right());
+			if (entity == null) return;
+
+			resetGlow(player, entity, value.get());
+		});
+		perPlayerGlows.clear();
+
+		scoreboardNames.clear();
+
+		Collection<TTeamPacket> removeTeamPackets = createRemoveTeamPackets();
+		Bukkit.getOnlinePlayers().forEach(player -> sendPackets(player, removeTeamPackets));
+	}
+
+	@Override
+	public void applyGlow(@NotNull Entity entity, @NotNull NamespacedKey key, @Nullable NamedTextColor color, int priority, @Range(from = 0, to = Integer.MAX_VALUE) int duration) {
+		GlowData data = new GlowData(priority, sequence++, color);
+		UUID uuid = entity.getUniqueId();
+
+		GlowData prev, curr;
+		synchronized (this) {
+			prev = getGlowData(null, uuid);
+
+			GlowDataMap map = glows.computeIfAbsent(uuid, i -> new GlowDataMap());
+
+			GlowData old = map.put(key, data);
+			if (old != null && old.taskId != -1) cancelTask(old.taskId);
+
+			if (duration > 0) {
+				data.taskId = scheduleDelayedTask(() -> {
+					synchronized (this) {
+						Entity e = Bukkit.getEntity(uuid);
+						if (e == null) {
+							map.remove(key);
+							if (map.isEmpty()) glows.remove(uuid);
+							return;
+						}
+
+						removeGlow(e, key);
+					}
+				}, duration);
+			}
+
+			curr = getGlowData(null, uuid);
+		}
+
+		updateGlow(null, entity, prev, curr);
+	}
+
+	@Override
+	public void applyGlow(@NotNull Player player, @NotNull Entity entity, @NotNull NamespacedKey key, @NotNull NamedTextColor color, int priority, @Range(from = 0, to = Integer.MAX_VALUE) int duration) {
+		Pair<UUID, UUID> pair = Pair.of(player.getUniqueId(), entity.getUniqueId());
+		GlowData data = new GlowData(priority, sequence++, color);
+
+		GlowData prev, curr;
+		synchronized (this) {
+			prev = getGlowData(pair);
+
+			GlowDataMap map = perPlayerGlows.computeIfAbsent(pair, i -> new GlowDataMap());
+
+			GlowData old = map.put(key, data);
+			if (old != null && old.taskId != -1) cancelTask(old.taskId);
+
+			if (duration > 0) {
+				data.taskId = scheduleDelayedTask(() -> {
+					synchronized (this) {
+						Player p = Bukkit.getPlayer(pair.left());
+						if (p == null) {
+							map.remove(key);
+							if (map.isEmpty()) perPlayerGlows.remove(pair);
+							return;
+						}
+
+						Entity e = Bukkit.getEntity(pair.right());
+						if (e == null) {
+							map.remove(key);
+							if (map.isEmpty()) perPlayerGlows.remove(pair);
+							return;
+						}
+
+						removeGlow(p, e, key);
+					}
+				}, duration);
+			}
+
+			curr = getGlowData(pair);
+		}
+
+		updateGlow(player, entity, prev, curr);
+	}
+
+	@Override
+	public void removeGlow(@NotNull Entity entity, @NotNull NamespacedKey key) {
+		UUID uuid = entity.getUniqueId();
+
+		GlowDataMap map = glows.get(uuid);
+		if (map == null) return;
+
+		GlowData prev, curr;
+		synchronized (this) {
+			prev = getGlowData(null, uuid);
+
+			map.remove(key);
+			if (map.isEmpty()) glows.remove(uuid);
+
+			curr = getGlowData(null, uuid);
+		}
+
+		if (curr == null) {
+			resetGlow(null, entity, prev);
+			return;
+		}
+
+		updateGlow(null, entity, prev, curr);
+	}
+
+	@Override
+	public void removeGlow(@NotNull Player player, @NotNull Entity entity, @NotNull NamespacedKey key) {
+		Pair<UUID, UUID> pair = Pair.of(player.getUniqueId(), entity.getUniqueId());
+
+		GlowDataMap map = perPlayerGlows.get(pair);
+		if (map == null) return;
+
+		GlowData prev, curr;
+		synchronized (this) {
+			prev = getGlowData(pair);
+
+			map.remove(key);
+			if (map.isEmpty()) perPlayerGlows.remove(pair);
+
+			curr = getGlowData(pair);
+		}
+
+		if (curr == null) {
+			resetGlow(player, entity, prev);
+			return;
+		}
+
+		updateGlow(player, entity, prev, curr);
+	}
+
+	protected void updateGlow(@Nullable Player player, @NotNull Entity entity, @Nullable GlowData prev, @NotNull GlowData curr) {
+		if (prev == curr) return;
+
+		Set<Player> trackedBy = entity.getTrackedBy();
+		if (player != null && !player.equals(entity) && !trackedBy.contains(player)) return;
+
+		if (prev != null && player != null && prev.color() == curr.color()) {
+			curr.lastScoreboardEntry(prev.lastScoreboardEntry);
+			return;
+		}
+
+		ScoreboardEntry entry = getScoreboardEntry(player, entity);
+
+		Scoreboard mainScoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
+		curr.lastScoreboardEntry(entry);
+
+		TEntityDataPacket entityDataPacket = createEntityDataPacket(entity, true);
+		Collection<TTeamPacket> teamPackets = Collections.emptyList();
+		boolean reset = false;
+		if (curr.color != null) {
+			teamPackets = createJoinTeamPacket(curr);
+			entry.forEach(e -> scoreboardNames.put(e, entity.getUniqueId()));
+		} else if (prev != null) {
+			Scoreboard scoreboard = player == null ? mainScoreboard : player.getScoreboard();
+			teamPackets = createResetTeamPacket(scoreboard, prev);
+
+			entry.forEach(e -> scoreboardNames.put(e, entity.getUniqueId()));
+			reset = true;
+		}
+
+		if (player != null) {
+			sendPackets(player, teamPackets);
+			sendPacket(player, entityDataPacket);
+
+			return;
+		}
+
+		Pair<UUID, UUID> pair = ObjectObjectMutablePair.of(null, entity.getUniqueId());
+		for (Player viewer : getTrackedBy(entity, trackedBy)) {
+			GlowDataMap targetedMap = perPlayerGlows.get(pair.left(viewer.getUniqueId()));
+			if (targetedMap != null) {
+				GlowData perPlayerData = targetedMap.get();
+				if (perPlayerData.compareTo(curr) < 0) {
+					if (prev != null && perPlayerData.compareTo(prev) < 0) continue;
+
+					updateGlow(viewer, entity, prev, perPlayerData);
+					continue;
+				} else if (prev != null && perPlayerData.compareTo(prev) < 0) {
+					updateGlow(viewer, entity, perPlayerData, curr);
+					continue;
+				}
+			}
+
+			if (reset) {
+				Scoreboard scoreboard = viewer.getScoreboard();
+
+				if (scoreboard != mainScoreboard) {
+					sendPackets(viewer, createResetTeamPacket(scoreboard, prev));
+					sendPacket(viewer, entityDataPacket);
+					continue;
+				}
+			}
+
+			sendPackets(viewer, teamPackets);
+			sendPacket(viewer, entityDataPacket);
+		}
+	}
+
+	protected void resetGlow(@Nullable Player player, @NotNull Entity entity, @NotNull GlowData data) {
+		Set<Player> trackedBy = entity.getTrackedBy();
+		if (player != null && !player.equals(entity) && !trackedBy.contains(player)) return;
+
+		Scoreboard mainScoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
+
+		TEntityDataPacket entityDataPacket = createEntityDataPacket(entity, false);
+		Collection<TTeamPacket> teamPackets = Collections.emptyList();
+		if (data.color() != null) {
+			Scoreboard scoreboard = player == null ? mainScoreboard : player.getScoreboard();
+			teamPackets = createResetTeamPacket(scoreboard, data);
+		}
+
+		if (player != null) {
+			sendPackets(player, teamPackets);
+			sendPacket(player, entityDataPacket);
+
+			return;
+		}
+
+		Pair<UUID, UUID> pair = ObjectObjectMutablePair.of(null, entity.getUniqueId());
+		for (Player viewer : getTrackedBy(entity, trackedBy)) {
+			GlowDataMap targetedMap = perPlayerGlows.get(pair.left(viewer.getUniqueId()));
+			if (targetedMap != null) {
+				GlowData perPlayerData = targetedMap.get();
+				if (perPlayerData.compareTo(data) < 0) continue;
+
+				updateGlow(viewer, entity, data, perPlayerData);
+				continue;
+			}
+
+			Scoreboard scoreboard = viewer.getScoreboard();
+			if (scoreboard != mainScoreboard) {
+				sendPackets(viewer, createResetTeamPacket(scoreboard, data));
+				sendPacket(viewer, entityDataPacket);
+
+				continue;
+			}
+
+			sendPackets(viewer, teamPackets);
+			sendPacket(viewer, entityDataPacket);
+		}
+	}
+
+	void refreshScoreboardEntry(@NotNull Player player, @NotNull ScoreboardEntry entry) {
+		UUID uuid = player.getUniqueId();
+
+		GlowData data = getGlowData(uuid, uuid);
+		if (data == null || data.color() == null) return;
+
+		Scoreboard scoreboard = player.getScoreboard();
+
+		Collection<TTeamPacket> resetPackets = createResetTeamPacket(scoreboard, data);
+		sendPackets(player, resetPackets);
+
+		data.lastScoreboardEntry(entry);
+		entry.map(e -> scoreboardNames.put(e, uuid));
+
+		Collection<TTeamPacket> joinPackets = createJoinTeamPacket(data);
+		sendPackets(player, joinPackets);
+	}
+
+	protected GlowData getGlowData(@Nullable UUID player, @NotNull UUID entity) {
+		synchronized (this) {
+			GlowDataMap map = glows.get(entity);
+			GlowData data = map == null ? null : map.get();
+
+			GlowDataMap targetedMap = perPlayerGlows.get(Pair.of(player, entity));
+			if (targetedMap == null) return data;
+
+			GlowData targetedData = targetedMap.get();
+			if (data == null) return targetedData;
+
+			return data.compareTo(targetedData) < 0 ? data : targetedData;
+		}
+	}
+
+	protected GlowData getGlowData(@NotNull Pair<@NotNull UUID, @NotNull UUID> pair) {
+		synchronized (this) {
+			GlowDataMap map = glows.get(pair.right());
+			GlowData data = map == null ? null : map.get();
+
+			GlowDataMap targetedMap = perPlayerGlows.get(pair);
+			if (targetedMap == null) return data;
+
+			GlowData targetedData = targetedMap.get();
+			if (data == null) return targetedData;
+
+			return data.compareTo(targetedData) < 0 ? data : targetedData;
+		}
+	}
+
+	protected void sendPackets(@NotNull Player player, @NotNull Collection<? extends TPacket> packets) {
+		if (packets.isEmpty()) return;
+		for (TPacket packet : packets) sendPacket(player, packet);
+	}
+
+	protected ScoreboardEntry getScoreboardEntry(@Nullable Player player, @NotNull Entity entity) {
+		if (!libsDisguisesLoaded) return new ScoreboardEntry(entity.getScoreboardEntryName());
+		return LibsDisguiseHelper.getDisguisedScoreboardEntry(player, entity);
+	}
+
+	protected Iterable<Player> getTrackedBy(@NotNull Entity entity, @NotNull Set<Player> trackedBy) {
+		if (!(entity instanceof Player player) || trackedBy.contains(player)) return trackedBy;
+		return Iterables.concat(trackedBy, Collections.singleton(player));
+	}
+
+	protected <T> T getStringOption(@NotNull String name, T def, @NotNull Function<String, T> converter, @NotNull ConfigurationSection config, @NotNull Consumer<String> onError) {
+		String string = config.getString("general.glow-spell-scoreboard-teams." + name);
+		if (string == null) return def;
+
+		T value = converter.apply(string);
+		if (value != null) return value;
+
+		onError.accept("Invalid value '" + string + "' for '" + name + "' in 'glow-spell-scoreboard-teams' in 'general.yml'.");
+		return def;
+	}
+
+	protected Collection<String> filterTeamEntries(@NotNull Player player, @NotNull Collection<String> entries) {
+		UUID playerUUID = player.getUniqueId();
+
+		List<String> filtered = new ArrayList<>(entries.size());
+		boolean modified = false;
+
+		for (String entry : entries) {
+			UUID entityUUID = scoreboardNames.get(entry);
+			if (entityUUID == null) {
+				filtered.add(entry);
+				continue;
+			}
+
+			GlowData data = getGlowData(playerUUID, entityUUID);
+			if (data == null || data.color() == null) {
+				filtered.add(entry);
+				continue;
+			}
+
+			modified = true;
+		}
+
+		return modified ? filtered : null;
+	}
+
+	@EventHandler
+	public void onPlayerJoin(PlayerJoinEvent event) {
+		Player player = event.getPlayer();
+		sendPackets(player, addTeamPackets);
+
+		GlowData data = getGlowData(player.getUniqueId(), player.getUniqueId());
+		if (data == null) return;
+
+		updateGlow(player, player, null, data);
+	}
+
+	@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+	public void onPlayerTrackEntity(PlayerTrackEntityEvent event) {
+		Player player = event.getPlayer();
+		Entity entity = event.getEntity();
+
+		scheduleDelayedTask(() -> {
+			GlowData data = getGlowData(player.getUniqueId(), entity.getUniqueId());
+			if (data == null) return;
+
+			updateGlow(player, entity, null, data);
+		}, 0);
+	}
+
+	@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+	public void onPlayerUntrackEntity(PlayerUntrackEntityEvent event) {
+		Player player = event.getPlayer();
+		Entity entity = event.getEntity();
+
+		GlowData data = getGlowData(player.getUniqueId(), entity.getUniqueId());
+		if (data == null) return;
+
+		resetGlow(player, entity, data);
+	}
+
+	protected abstract Collection<TTeamPacket> createAddTeamPackets();
+
+	protected abstract Collection<TTeamPacket> createRemoveTeamPackets();
+
+	protected abstract TEntityDataPacket createEntityDataPacket(@NotNull Entity entity, boolean forceGlow);
+
+	protected abstract Collection<TTeamPacket> createJoinTeamPacket(@NotNull GlowData data);
+
+	protected abstract Collection<TTeamPacket> createResetTeamPacket(@NotNull Scoreboard scoreboard, @NotNull GlowData data);
+
+	protected abstract void sendPacket(@NotNull Player player, @NotNull TPacket packet);
+
+	protected abstract void registerEvents(Listener listener);
+
+	protected abstract void cancelTask(int taskId);
+
+	public abstract int scheduleDelayedTask(Runnable runnable, long delay);
+
+	public record ScoreboardEntry(@NotNull String realEntry, @Nullable String disguisedEntry) {
+
+		public ScoreboardEntry {
+			disguisedEntry = realEntry.equals(disguisedEntry) ? null : disguisedEntry;
+		}
+
+		public ScoreboardEntry(@NotNull String realEntry) {
+			this(realEntry, null);
+		}
+
+		public <T> Collection<T> map(Function<String, T> function) {
+			List<T> list = new ArrayList<>(disguisedEntry == null ? 1 : 2);
+			if (disguisedEntry != null) list.add(function.apply(disguisedEntry));
+			list.add(function.apply(realEntry));
+
+			return list;
+		}
+
+		public void forEach(Consumer<String> consumer) {
+			if (disguisedEntry != null) consumer.accept(disguisedEntry);
+			consumer.accept(realEntry);
+		}
+
+	}
+
+	protected static final class GlowData implements Comparable<GlowData> {
+
+		private final NamedTextColor color;
+		private final int priority;
+		private final int sequence;
+
+		private ScoreboardEntry lastScoreboardEntry;
+		private int taskId;
+
+		public GlowData(int priority, int sequence, NamedTextColor color) {
+			this.priority = priority;
+			this.sequence = sequence;
+			this.color = color;
+
+			taskId = -1;
+		}
+
+		public NamedTextColor color() {
+			return color;
+		}
+
+		public int priority() {
+			return priority;
+		}
+
+		public int sequence() {
+			return sequence;
+		}
+
+		public int taskId() {
+			return taskId;
+		}
+
+		public void taskId(int taskId) {
+			this.taskId = taskId;
+		}
+
+		public ScoreboardEntry lastScoreboardEntry() {
+			return lastScoreboardEntry;
+		}
+
+		public void lastScoreboardEntry(ScoreboardEntry lastScoreboardEntry) {
+			this.lastScoreboardEntry = lastScoreboardEntry;
+		}
+
+		@Override
+		public int compareTo(@NotNull GlowData o) {
+			int compare = o.priority - priority;
+			return compare == 0 ? o.sequence - sequence : compare;
+		}
+
+	}
+
+	protected static final class GlowDataMap {
+
+		private final Map<NamespacedKey, GlowData> keyedGlowData = new Object2ObjectOpenHashMap<>();
+		private final PriorityQueue<GlowData> glowData = new PriorityQueue<>();
+
+		public GlowDataMap() {
+
+		}
+
+		public GlowData put(NamespacedKey key, GlowData data) {
+			GlowData old = keyedGlowData.put(key, data);
+			if (old != null) glowData.remove(old);
+
+			glowData.add(data);
+
+			return old;
+		}
+
+		public void remove(NamespacedKey key) {
+			GlowData old = keyedGlowData.remove(key);
+			if (old == null) return;
+
+			glowData.remove(old);
+		}
+
+		public GlowData get() {
+			return glowData.peek();
+		}
+
+		public boolean isEmpty() {
+			return keyedGlowData.isEmpty();
+		}
+
+	}
+
+}
diff --git a/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHandle.java b/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHandle.java
index 428671c7d..756e91a3c 100644
--- a/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHandle.java
+++ b/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHandle.java
@@ -1,5 +1,6 @@
 package com.nisovin.magicspells.volatilecode;
 
+import org.bukkit.World;
 import org.bukkit.entity.*;
 import org.bukkit.Location;
 import org.bukkit.util.Vector;
@@ -9,6 +10,8 @@
 
 import io.papermc.paper.advancement.AdvancementDisplay.Frame;
 
+import com.nisovin.magicspells.util.glow.GlowManager;
+
 public abstract class VolatileCodeHandle {
 
 	protected final VolatileCodeHelper helper;
@@ -35,4 +38,10 @@ public VolatileCodeHandle(VolatileCodeHelper helper) {
 
 	public abstract void clearGameTestMarkers(Player player);
 
+	public abstract byte getEntityMetadata(Entity entity);
+
+	public abstract Entity getEntityFromId(World world, int id);
+
+	public abstract GlowManager getGlowManager();
+
 }
diff --git a/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHelper.java b/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHelper.java
index b1d630dd4..debc99fdb 100644
--- a/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHelper.java
+++ b/nms/shared/src/main/java/com/nisovin/magicspells/volatilecode/VolatileCodeHelper.java
@@ -1,9 +1,18 @@
 package com.nisovin.magicspells.volatilecode;
 
+import org.bukkit.event.Listener;
+import org.bukkit.configuration.file.YamlConfiguration;
+
 public interface VolatileCodeHelper {
 
 	void error(String message);
 
 	int scheduleDelayedTask(Runnable task, long delay);
 
+	void cancelTask(int id);
+
+	void registerEvents(Listener listener);
+
+	YamlConfiguration getMainConfig();
+
 }


Reviews:
user: login: JasperLorelai
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T04:56:22Z
user: login: TonytheMacaroni
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T00:58:32Z
user: login: TonytheMacaroni
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T00:59:07Z
user: login: JasperLorelai
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-29T15:48:43Z
user: login: JasperLorelai
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-29T15:56:07Z

Review comments:
diff_hunk: @@ -211,4 +220,20 @@ public void clearGameTestMarkers(Player player) {
 		((CraftPlayer) player).getHandle().connection.send(new ClientboundCustomPayloadPacket(payload));
 	}
 
+	@Override
+	public byte getEntityMetadata(Entity entity) {
+		return ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+	}
+
+	@Override
+	public Entity getEntityFromId(World world, int id) {
+		var entity = ((CraftWorld) world).getHandle().moonrise$getEntityLookup().get(id);
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java
user: login: JasperLorelai
body: `((CraftWorld) world).getHandle().getEntity(id)`
created_at: 2025-03-10T22:42:11Z
author_association: COLLABORATOR
diff_hunk: @@ -710,6 +712,64 @@ public boolean isConstant() {
 		};
 	}
 
+	public static ConfigData<NamedTextColor> getNamedTextColor(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamedTextColor def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamedTextColor val = NamedTextColor.NAMES.value(value.toLowerCase());
+		if (val != null) return data -> val;
+
+		ConfigData<String> supplier = getString(value);
+		if (supplier.isConstant()) return data -> def;
+
+		return new ConfigData<>() {
+
+			@Override
+			public NamedTextColor get(@NotNull SpellData data) {
+				String val = supplier.get(data);
+				if (val == null) return def;
+
+				NamedTextColor color = NamedTextColor.NAMES.value(val.toLowerCase());
+				return color == null ? def : color;
+			}
+
+			@Override
+			public boolean isConstant() {
+				return false;
+			}
+
+		};
+	}
+
+	public static ConfigData<NamespacedKey> getNamespacedKey(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamespacedKey def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamespacedKey val = NamespacedKey.fromString(value.toLowerCase(), MagicSpells.getInstance());
path: core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java
user: login: JasperLorelai
body: Missing `Preconditions` handling
created_at: 2025-03-11T01:32:52Z
author_association: COLLABORATOR
diff_hunk: @@ -710,6 +712,64 @@ public boolean isConstant() {
 		};
 	}
 
+	public static ConfigData<NamedTextColor> getNamedTextColor(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamedTextColor def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamedTextColor val = NamedTextColor.NAMES.value(value.toLowerCase());
+		if (val != null) return data -> val;
+
+		ConfigData<String> supplier = getString(value);
+		if (supplier.isConstant()) return data -> def;
+
+		return new ConfigData<>() {
+
+			@Override
+			public NamedTextColor get(@NotNull SpellData data) {
+				String val = supplier.get(data);
+				if (val == null) return def;
+
+				NamedTextColor color = NamedTextColor.NAMES.value(val.toLowerCase());
+				return color == null ? def : color;
+			}
+
+			@Override
+			public boolean isConstant() {
+				return false;
+			}
+
+		};
+	}
+
+	public static ConfigData<NamespacedKey> getNamespacedKey(@NotNull ConfigurationSection config, @NotNull String path, @Nullable NamespacedKey def) {
+		String value = config.getString(path);
+		if (value == null) return data -> def;
+
+		NamespacedKey val = NamespacedKey.fromString(value.toLowerCase(), MagicSpells.getInstance());
+		if (val != null) return data -> val;
+
+		ConfigData<String> supplier = getString(value);
+		if (supplier.isConstant()) return data -> def;
+
+		return new ConfigData<>() {
+
+			@Override
+			public NamespacedKey get(@NotNull SpellData data) {
+				String val = supplier.get(data);
+				if (val == null) return def;
+
+				NamespacedKey key = NamespacedKey.fromString(val.toLowerCase(), MagicSpells.getInstance());
path: core/src/main/java/com/nisovin/magicspells/util/config/ConfigDataUtil.java
user: login: JasperLorelai
body: Missing `Preconditions` handling
created_at: 2025-03-11T01:32:56Z
author_association: COLLABORATOR
diff_hunk: @@ -15,8 +15,9 @@ dependencies {
     shadow(project(path: ":nms:shared", configuration: "apiElements"))
     shadow(project(path: ":nms:latest")) { transitive = false }
 
+    implementation(group: "com.github.retrooper", name: "packetevents-spigot", version: "2.7.0")
path: core/build.gradle
user: login: JasperLorelai
body: Add this at the bottom of `NOTICE.md`:
```md
- packetevents:
  - Repository: `retrooper/packetevents`
  - License: **General Public License v3.0** (See `3rd_party_licenses/LICENSE-GPLv3`)
```
created_at: 2025-03-11T01:46:56Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,228 @@
+package com.nisovin.magicspells.util.glow.impl;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import org.bukkit.Bukkit;
path: core/src/main/java/com/nisovin/magicspells/util/glow/impl/PacketEventsGlowManager.java
user: login: JasperLorelai
body: Unused import
created_at: 2025-03-11T03:35:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,306 @@
+package com.nisovin.magicspells.volatilecode.latest;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+
+import com.google.common.collect.MapMaker;
+
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.world.scores.PlayerTeam;
+import net.minecraft.world.scores.Scoreboard;
+import net.minecraft.world.scores.Team.Visibility;
+import net.minecraft.world.scores.Team.CollisionRule;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.craftbukkit.entity.CraftEntity;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class VolatileGlowManagerLatest extends PacketBasedGlowManager<Packet<?>, ClientboundSetEntityDataPacket, ClientboundSetPlayerTeamPacket> {
+
+	private static final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID = new EntityDataAccessor<>(0, EntityDataSerializers.BYTE);
+
+	private final Set<Packet<?>> handled = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap());
+	private final MethodHandle teamPacketHandle;
+	private final VolatileCodeHelper helper;
+
+	public VolatileGlowManagerLatest(VolatileCodeHelper helper) {
+		this.helper = helper;
+
+		try {
+			teamPacketHandle = MethodHandles
+				.privateLookupIn(ClientboundSetPlayerTeamPacket.class, MethodHandles.lookup())
+				.findConstructor(
+					ClientboundSetPlayerTeamPacket.class,
+					MethodType.methodType(void.class, String.class, int.class, Optional.class, Collection.class)
+				);
+		} catch (Exception e) {
+			throw new RuntimeException("Encountered an error while initializing VolatileGlowManagerLatest", e);
+		}
+
+		helper.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		Bukkit.getOnlinePlayers().forEach(this::addGlowChannelHandler);
+	}
+
+	@Override
+	public synchronized void unload() {
+		Bukkit.getOnlinePlayers().forEach(this::removeGlowChannelHandler);
+
+		super.unload();
+		handled.clear();
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createAddTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> teamPackets = new ArrayList<>();
+
+		ConfigurationSection config = helper.getMainConfig();
+		boolean seeFriendlyInvisibles = config.getBoolean("glow-spell-scoreboard-teams.see-friendly-invisibles", false);
+		CollisionRule collision = getStringOption("collision-rule", CollisionRule.ALWAYS, CollisionRule::byName, config, helper::error);
+		Visibility visibility = getStringOption("name-tag-visibility", Visibility.ALWAYS, Visibility::byName, config, helper::error);
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			team.setSeeFriendlyInvisibles(seeFriendlyInvisibles);
+			team.setNameTagVisibility(visibility);
+			team.setCollisionRule(collision);
+			team.setColor(formatting);
+
+			teamPackets.add(ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(team, true));
+		}
+
+		return teamPackets;
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createRemoveTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> packets = new ArrayList<>();
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			packets.add(ClientboundSetPlayerTeamPacket.createRemovePacket(team));
+		}
+
+		return packets;
+	}
+
+	@Override
+	protected ClientboundSetEntityDataPacket createEntityDataPacket(@NotNull Entity entity, boolean forceGlow) {
+		byte metadata = ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+		if (forceGlow) metadata |= 0x40;
+
+		return new ClientboundSetEntityDataPacket(
+			entity.getEntityId(),
+			list(new SynchedEntityData.DataValue<>(
+				0,
+				EntityDataSerializers.BYTE,
+				metadata
+			))
+		);
+	}
+
+	@Override
+	protected ClientboundSetPlayerTeamPacket createJoinTeamPacket(@NotNull GlowData data) {
+		try {
+			return  (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+				"magicspells:" + data.color(),
+				3,
+				Optional.empty(),
+				list(data.lastEntryName())
+			);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@Override
+	protected ClientboundSetPlayerTeamPacket createResetTeamPacket(org.bukkit.scoreboard.@NotNull Scoreboard scoreboard, @NotNull GlowData data) {
+		try {
+			Team team = scoreboard.getEntryTeam(data.lastEntryName());
+
+			if (team != null) {
+				return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+					team.getName(),
+					3,
+					Optional.empty(),
+					list(data.lastEntryName())
+				);
+			}
+
+			return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+				"magicspells:" + data.color(),
+				4,
+				Optional.empty(),
+				list(data.lastEntryName())
+			);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@Override
+	protected void sendPacket(@NotNull Player player, @NotNull Packet<?> packet) {
+		handled.add(packet);
+
+		ServerGamePacketListenerImpl connection = ((CraftPlayer) player).getHandle().connection;
+		connection.send(packet);
+	}
+
+	@Override
+	protected void cancelTask(int taskId) {
+		helper.cancelTask(taskId);
+	}
+
+	@Override
+	protected int scheduleDelayedTask(Runnable runnable, long delay) {
+		return helper.scheduleDelayedTask(runnable, delay);
+	}
+
+	private void addGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+		pipeline.addBefore("unbundler", "magicspells:glow_channel_handler", new GlowChannelHandler(player));
+	}
+
+	private void removeGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+
+		if (pipeline.get("magicspells:glow_channel_handler") != null)
+			pipeline.remove("magicspells:glow_channel_handler");
+	}
+
+	private <T> List<T> list(T element) {
+		List<T> list = new ArrayList<>(1);
+		list.add(element);
+
+		return list;
+	}
+
+	@Override
+	@EventHandler
+	public void onPlayerJoin(PlayerJoinEvent event) {
+		super.onPlayerJoin(event);
+		addGlowChannelHandler(event.getPlayer());
+	}
+
+	@EventHandler
+	public void onPlayerQuit(PlayerQuitEvent event) {
+		removeGlowChannelHandler(event.getPlayer());
+	}
+
+	private class GlowChannelHandler extends ChannelOutboundHandlerAdapter {
+
+		private final Player player;
+
+		private GlowChannelHandler(Player player) {
+			this.player = player;
+		}
+
+		@Override
+		public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+			if (msg instanceof Packet<?> p && handled.remove(p)) {
+				super.write(ctx, msg, promise);
+				return;
+			}
+
+			synchronized (VolatileGlowManagerLatest.this) {
+				if (glows.isEmpty() && perPlayerGlows.isEmpty()) {
+					super.write(ctx, msg, promise);
+					return;
+				}
+			}
+
+			switch (msg) {
+				case ClientboundSetEntityDataPacket packet -> handleEntityData(packet);
+				case ClientboundSetPlayerTeamPacket packet -> {
+					msg = handleTeamPacket(packet);
+					if (msg == null) return;
+				}
+				default -> {}
+			}
+
+			super.write(ctx, msg, promise);
+		}
+
+		private void handleEntityData(ClientboundSetEntityDataPacket packet) {
+			List<SynchedEntityData.DataValue<?>> packedItems = packet.packedItems();
+			if (packedItems.isEmpty()) return;
+
+			SynchedEntityData.DataValue<?> item = packedItems.getFirst();
+			if (item.id() != 0) return;
+
+			byte flags = (byte) item.value();
+			if ((flags & 0x40) > 0) return;
+
+			net.minecraft.world.entity.Entity entity = ((CraftPlayer) player).getHandle().level().moonrise$getEntityLookup().get(packet.id());
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
user: login: JasperLorelai
body: `((CraftPlayer) player).getHandle().level().getEntity(id)`
created_at: 2025-03-11T03:56:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,306 @@
+package com.nisovin.magicspells.volatilecode.latest;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+
+import com.google.common.collect.MapMaker;
+
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.world.scores.PlayerTeam;
+import net.minecraft.world.scores.Scoreboard;
+import net.minecraft.world.scores.Team.Visibility;
+import net.minecraft.world.scores.Team.CollisionRule;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.craftbukkit.entity.CraftEntity;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class VolatileGlowManagerLatest extends PacketBasedGlowManager<Packet<?>, ClientboundSetEntityDataPacket, ClientboundSetPlayerTeamPacket> {
+
+	private static final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID = new EntityDataAccessor<>(0, EntityDataSerializers.BYTE);
+
+	private final Set<Packet<?>> handled = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap());
+	private final MethodHandle teamPacketHandle;
+	private final VolatileCodeHelper helper;
+
+	public VolatileGlowManagerLatest(VolatileCodeHelper helper) {
+		this.helper = helper;
+
+		try {
+			teamPacketHandle = MethodHandles
+				.privateLookupIn(ClientboundSetPlayerTeamPacket.class, MethodHandles.lookup())
+				.findConstructor(
+					ClientboundSetPlayerTeamPacket.class,
+					MethodType.methodType(void.class, String.class, int.class, Optional.class, Collection.class)
+				);
+		} catch (Exception e) {
+			throw new RuntimeException("Encountered an error while initializing VolatileGlowManagerLatest", e);
+		}
+
+		helper.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		Bukkit.getOnlinePlayers().forEach(this::addGlowChannelHandler);
+	}
+
+	@Override
+	public synchronized void unload() {
+		Bukkit.getOnlinePlayers().forEach(this::removeGlowChannelHandler);
+
+		super.unload();
+		handled.clear();
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createAddTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> teamPackets = new ArrayList<>();
+
+		ConfigurationSection config = helper.getMainConfig();
+		boolean seeFriendlyInvisibles = config.getBoolean("glow-spell-scoreboard-teams.see-friendly-invisibles", false);
+		CollisionRule collision = getStringOption("collision-rule", CollisionRule.ALWAYS, CollisionRule::byName, config, helper::error);
+		Visibility visibility = getStringOption("name-tag-visibility", Visibility.ALWAYS, Visibility::byName, config, helper::error);
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			team.setSeeFriendlyInvisibles(seeFriendlyInvisibles);
+			team.setNameTagVisibility(visibility);
+			team.setCollisionRule(collision);
+			team.setColor(formatting);
+
+			teamPackets.add(ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(team, true));
+		}
+
+		return teamPackets;
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createRemoveTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> packets = new ArrayList<>();
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			packets.add(ClientboundSetPlayerTeamPacket.createRemovePacket(team));
+		}
+
+		return packets;
+	}
+
+	@Override
+	protected ClientboundSetEntityDataPacket createEntityDataPacket(@NotNull Entity entity, boolean forceGlow) {
+		byte metadata = ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+		if (forceGlow) metadata |= 0x40;
+
+		return new ClientboundSetEntityDataPacket(
+			entity.getEntityId(),
+			list(new SynchedEntityData.DataValue<>(
+				0,
+				EntityDataSerializers.BYTE,
+				metadata
+			))
+		);
+	}
+
+	@Override
+	protected ClientboundSetPlayerTeamPacket createJoinTeamPacket(@NotNull GlowData data) {
+		try {
+			return  (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
user: login: JasperLorelai
body: Double space
created_at: 2025-03-11T03:59:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,521 @@
+package com.nisovin.magicspells.util.glow;
+
+import org.jetbrains.annotations.Range;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.concurrent.ConcurrentHashMap;
+
+import it.unimi.dsi.fastutil.Pair;
+import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
+import it.unimi.dsi.fastutil.objects.ObjectObjectMutablePair;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+
+import com.google.common.collect.Iterables;
+
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.scoreboard.Scoreboard;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.configuration.ConfigurationSection;
+
+import io.papermc.paper.event.player.PlayerTrackEntityEvent;
+import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
+
+public abstract class PacketBasedGlowManager<TPacket, TEntityDataPacket extends TPacket, TTeamPacket extends TPacket> implements GlowManager, Listener {
+
+	protected final Object2ObjectMap<Pair<UUID, UUID>, GlowDataMap> perPlayerGlows = new Object2ObjectOpenHashMap<>();
+	protected final Object2ObjectMap<UUID, GlowDataMap> glows = new Object2ObjectOpenHashMap<>();
+	protected final ConcurrentHashMap<String, UUID>  scoreboardNames = new ConcurrentHashMap<>();
path: nms/shared/src/main/java/com/nisovin/magicspells/util/glow/PacketBasedGlowManager.java
user: login: JasperLorelai
body: Double space at `...UUID>  scor...`
created_at: 2025-03-11T04:00:15Z
author_association: COLLABORATOR
diff_hunk: @@ -211,4 +220,20 @@ public void clearGameTestMarkers(Player player) {
 		((CraftPlayer) player).getHandle().connection.send(new ClientboundCustomPayloadPacket(payload));
 	}
 
+	@Override
+	public byte getEntityMetadata(Entity entity) {
+		return ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+	}
+
+	@Override
+	public Entity getEntityFromId(World world, int id) {
+		var entity = ((CraftWorld) world).getHandle().moonrise$getEntityLookup().get(id);
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileCodeLatest.java
user: login: TonytheMacaroni
body: This is intentional - your suggested snippet would lead to an error due to a thread check.
created_at: 2025-03-12T00:58:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,306 @@
+package com.nisovin.magicspells.volatilecode.latest;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+
+import com.google.common.collect.MapMaker;
+
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.world.scores.PlayerTeam;
+import net.minecraft.world.scores.Scoreboard;
+import net.minecraft.world.scores.Team.Visibility;
+import net.minecraft.world.scores.Team.CollisionRule;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.craftbukkit.entity.CraftEntity;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class VolatileGlowManagerLatest extends PacketBasedGlowManager<Packet<?>, ClientboundSetEntityDataPacket, ClientboundSetPlayerTeamPacket> {
+
+	private static final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID = new EntityDataAccessor<>(0, EntityDataSerializers.BYTE);
+
+	private final Set<Packet<?>> handled = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap());
+	private final MethodHandle teamPacketHandle;
+	private final VolatileCodeHelper helper;
+
+	public VolatileGlowManagerLatest(VolatileCodeHelper helper) {
+		this.helper = helper;
+
+		try {
+			teamPacketHandle = MethodHandles
+				.privateLookupIn(ClientboundSetPlayerTeamPacket.class, MethodHandles.lookup())
+				.findConstructor(
+					ClientboundSetPlayerTeamPacket.class,
+					MethodType.methodType(void.class, String.class, int.class, Optional.class, Collection.class)
+				);
+		} catch (Exception e) {
+			throw new RuntimeException("Encountered an error while initializing VolatileGlowManagerLatest", e);
+		}
+
+		helper.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		Bukkit.getOnlinePlayers().forEach(this::addGlowChannelHandler);
+	}
+
+	@Override
+	public synchronized void unload() {
+		Bukkit.getOnlinePlayers().forEach(this::removeGlowChannelHandler);
+
+		super.unload();
+		handled.clear();
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createAddTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> teamPackets = new ArrayList<>();
+
+		ConfigurationSection config = helper.getMainConfig();
+		boolean seeFriendlyInvisibles = config.getBoolean("glow-spell-scoreboard-teams.see-friendly-invisibles", false);
+		CollisionRule collision = getStringOption("collision-rule", CollisionRule.ALWAYS, CollisionRule::byName, config, helper::error);
+		Visibility visibility = getStringOption("name-tag-visibility", Visibility.ALWAYS, Visibility::byName, config, helper::error);
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			team.setSeeFriendlyInvisibles(seeFriendlyInvisibles);
+			team.setNameTagVisibility(visibility);
+			team.setCollisionRule(collision);
+			team.setColor(formatting);
+
+			teamPackets.add(ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(team, true));
+		}
+
+		return teamPackets;
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createRemoveTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> packets = new ArrayList<>();
+
+		Scoreboard scoreboard = new Scoreboard();
+		for (ChatFormatting formatting : ChatFormatting.values()) {
+			if (!formatting.isColor()) continue;
+
+			PlayerTeam team = new PlayerTeam(scoreboard, "magicspells:" + formatting.getName());
+			packets.add(ClientboundSetPlayerTeamPacket.createRemovePacket(team));
+		}
+
+		return packets;
+	}
+
+	@Override
+	protected ClientboundSetEntityDataPacket createEntityDataPacket(@NotNull Entity entity, boolean forceGlow) {
+		byte metadata = ((CraftEntity) entity).getHandle().getEntityData().get(DATA_SHARED_FLAGS_ID);
+		if (forceGlow) metadata |= 0x40;
+
+		return new ClientboundSetEntityDataPacket(
+			entity.getEntityId(),
+			list(new SynchedEntityData.DataValue<>(
+				0,
+				EntityDataSerializers.BYTE,
+				metadata
+			))
+		);
+	}
+
+	@Override
+	protected ClientboundSetPlayerTeamPacket createJoinTeamPacket(@NotNull GlowData data) {
+		try {
+			return  (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+				"magicspells:" + data.color(),
+				3,
+				Optional.empty(),
+				list(data.lastEntryName())
+			);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@Override
+	protected ClientboundSetPlayerTeamPacket createResetTeamPacket(org.bukkit.scoreboard.@NotNull Scoreboard scoreboard, @NotNull GlowData data) {
+		try {
+			Team team = scoreboard.getEntryTeam(data.lastEntryName());
+
+			if (team != null) {
+				return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+					team.getName(),
+					3,
+					Optional.empty(),
+					list(data.lastEntryName())
+				);
+			}
+
+			return (ClientboundSetPlayerTeamPacket) teamPacketHandle.invoke(
+				"magicspells:" + data.color(),
+				4,
+				Optional.empty(),
+				list(data.lastEntryName())
+			);
+		} catch (Throwable e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@Override
+	protected void sendPacket(@NotNull Player player, @NotNull Packet<?> packet) {
+		handled.add(packet);
+
+		ServerGamePacketListenerImpl connection = ((CraftPlayer) player).getHandle().connection;
+		connection.send(packet);
+	}
+
+	@Override
+	protected void cancelTask(int taskId) {
+		helper.cancelTask(taskId);
+	}
+
+	@Override
+	protected int scheduleDelayedTask(Runnable runnable, long delay) {
+		return helper.scheduleDelayedTask(runnable, delay);
+	}
+
+	private void addGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+		pipeline.addBefore("unbundler", "magicspells:glow_channel_handler", new GlowChannelHandler(player));
+	}
+
+	private void removeGlowChannelHandler(Player player) {
+		ChannelPipeline pipeline = ((CraftPlayer) player).getHandle().connection.connection.channel.pipeline();
+
+		if (pipeline.get("magicspells:glow_channel_handler") != null)
+			pipeline.remove("magicspells:glow_channel_handler");
+	}
+
+	private <T> List<T> list(T element) {
+		List<T> list = new ArrayList<>(1);
+		list.add(element);
+
+		return list;
+	}
+
+	@Override
+	@EventHandler
+	public void onPlayerJoin(PlayerJoinEvent event) {
+		super.onPlayerJoin(event);
+		addGlowChannelHandler(event.getPlayer());
+	}
+
+	@EventHandler
+	public void onPlayerQuit(PlayerQuitEvent event) {
+		removeGlowChannelHandler(event.getPlayer());
+	}
+
+	private class GlowChannelHandler extends ChannelOutboundHandlerAdapter {
+
+		private final Player player;
+
+		private GlowChannelHandler(Player player) {
+			this.player = player;
+		}
+
+		@Override
+		public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+			if (msg instanceof Packet<?> p && handled.remove(p)) {
+				super.write(ctx, msg, promise);
+				return;
+			}
+
+			synchronized (VolatileGlowManagerLatest.this) {
+				if (glows.isEmpty() && perPlayerGlows.isEmpty()) {
+					super.write(ctx, msg, promise);
+					return;
+				}
+			}
+
+			switch (msg) {
+				case ClientboundSetEntityDataPacket packet -> handleEntityData(packet);
+				case ClientboundSetPlayerTeamPacket packet -> {
+					msg = handleTeamPacket(packet);
+					if (msg == null) return;
+				}
+				default -> {}
+			}
+
+			super.write(ctx, msg, promise);
+		}
+
+		private void handleEntityData(ClientboundSetEntityDataPacket packet) {
+			List<SynchedEntityData.DataValue<?>> packedItems = packet.packedItems();
+			if (packedItems.isEmpty()) return;
+
+			SynchedEntityData.DataValue<?> item = packedItems.getFirst();
+			if (item.id() != 0) return;
+
+			byte flags = (byte) item.value();
+			if ((flags & 0x40) > 0) return;
+
+			net.minecraft.world.entity.Entity entity = ((CraftPlayer) player).getHandle().level().moonrise$getEntityLookup().get(packet.id());
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
user: login: TonytheMacaroni
body: Same as before - this would fail due to a thread check.
created_at: 2025-03-12T00:59:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,320 @@
+package com.nisovin.magicspells.volatilecode.latest;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+
+import io.netty.channel.ChannelPromise;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.world.scores.PlayerTeam;
+import net.minecraft.world.scores.Scoreboard;
+import net.minecraft.world.scores.Team.Visibility;
+import net.minecraft.world.scores.Team.CollisionRule;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
+import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Listener;
+import org.bukkit.scoreboard.Team;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.craftbukkit.entity.CraftEntity;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.nisovin.magicspells.util.glow.LibsDisguiseHelper;
+import com.nisovin.magicspells.volatilecode.VolatileCodeHelper;
+import com.nisovin.magicspells.util.glow.PacketBasedGlowManager;
+
+public class VolatileGlowManagerLatest extends PacketBasedGlowManager<Packet<?>, ClientboundSetEntityDataPacket, ClientboundSetPlayerTeamPacket> {
+
+	private static final EntityDataAccessor<Byte> DATA_SHARED_FLAGS_ID = new EntityDataAccessor<>(0, EntityDataSerializers.BYTE);
+
+	private final Set<Packet<?>> handled = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
+	private final MethodHandle teamPacketHandle;
+	private final VolatileCodeHelper helper;
+
+	public VolatileGlowManagerLatest(VolatileCodeHelper helper) {
+		this.helper = helper;
+
+		try {
+			teamPacketHandle = MethodHandles
+				.privateLookupIn(ClientboundSetPlayerTeamPacket.class, MethodHandles.lookup())
+				.findConstructor(
+					ClientboundSetPlayerTeamPacket.class,
+					MethodType.methodType(void.class, String.class, int.class, Optional.class, Collection.class)
+				);
+		} catch (Exception e) {
+			throw new RuntimeException("Encountered an error while initializing VolatileGlowManagerLatest", e);
+		}
+
+		helper.registerEvents(this);
+	}
+
+	@Override
+	public void load() {
+		super.load();
+
+		Bukkit.getOnlinePlayers().forEach(this::addGlowChannelHandler);
+	}
+
+	@Override
+	public synchronized void unload() {
+		Bukkit.getOnlinePlayers().forEach(this::removeGlowChannelHandler);
+
+		super.unload();
+		handled.clear();
+	}
+
+	@Override
+	protected Collection<ClientboundSetPlayerTeamPacket> createAddTeamPackets() {
+		List<ClientboundSetPlayerTeamPacket> teamPackets = new ArrayList<>();
+
+		ConfigurationSection config = helper.getMainConfig();
+		boolean seeFriendlyInvisibles = config.getBoolean("glow-spell-scoreboard-teams.see-friendly-invisibles", false);
path: nms/latest/src/main/java/com/nisovin/magicspells/volatilecode/latest/VolatileGlowManagerLatest.java
user: login: JasperLorelai
body: `general.glow-spell-scoreboard-teams`
created_at: 2025-03-29T15:38:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,112 @@
+package com.nisovin.magicspells.spells.targeted;
+
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.LivingEntity;
+
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import com.nisovin.magicspells.MagicSpells;
+import com.nisovin.magicspells.util.SpellData;
+import com.nisovin.magicspells.util.CastResult;
+import com.nisovin.magicspells.util.TargetInfo;
+import com.nisovin.magicspells.util.MagicConfig;
+import com.nisovin.magicspells.spells.TargetedSpell;
+import com.nisovin.magicspells.util.config.ConfigData;
+import com.nisovin.magicspells.util.glow.GlowManager;
+import com.nisovin.magicspells.spells.TargetedEntitySpell;
+import com.nisovin.magicspells.util.glow.impl.PacketEventsGlowManager;
+
+public class GlowSpell extends TargetedSpell implements TargetedEntitySpell {
+
+	private static GlowManager glowManager;
+
+	private final ConfigData<Boolean> global;
+	private final ConfigData<Boolean> remove;
+	private final ConfigData<Integer> duration;
+	private final ConfigData<Integer> priority;
+	private final ConfigData<NamespacedKey> key;
+	private final ConfigData<NamedTextColor> color;
+
+	public GlowSpell(MagicConfig config, String spellName) {
+		super(config, spellName);
+
+		key = getConfigDataNamespacedKey("key", null);
+		color = getConfigDataNamedTextColor("color", null);
+		global = getConfigDataBoolean("global", true);
+		remove = getConfigDataBoolean("remove", false);
+		duration = getConfigDataInt("duration", 0);
+		priority = getConfigDataInt("priority", 0);
+
+		if (glowManager == null) {
+			if (Bukkit.getPluginManager().isPluginEnabled("packetevents")) glowManager = new PacketEventsGlowManager();
+			else glowManager = MagicSpells.getVolatileCodeHandler().getGlowManager();
+
+			glowManager.load();
+		}
+	}
+
+	@Override
+	public CastResult cast(SpellData data) {
+		TargetInfo<LivingEntity> info = getTargetedEntity(data);
+		if (info.noTarget()) return noTarget(info);
+
+		return castAtEntity(info.spellData());
+	}
+
+	@Override
+	public CastResult castAtEntity(SpellData data) {
+		if (global.get(data)) {
+			NamespacedKey key = this.key.get(data);
+
+			if (remove.get(data)) {
+				if (key == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+				glowManager.removeGlow(data.target(), key);
+			} else {
+				glowManager.applyGlow(
+					data.target(),
+					key != null ? key : new NamespacedKey(MagicSpells.getInstance(), UUID.randomUUID().toString()),
+					color.get(data),
+					priority.get(data),
+					duration.get(data)
+				);
+			}
+
+			return new CastResult(PostCastAction.HANDLE_NORMALLY, data);
+		}
+
+		if (!(data.caster() instanceof Player caster)) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+		NamespacedKey key = this.key.get(data);
+
+		if (remove.get(data)) {
+			if (key == null) return new CastResult(PostCastAction.ALREADY_HANDLED, data);
+
+			glowManager.removeGlow(caster, data.target(), key);
+		} else {
+			glowManager.applyGlow(
+				caster,
+				data.target(),
+				key != null ? key : new NamespacedKey(MagicSpells.getInstance(), UUID.randomUUID().toString()),
+				color.get(data),
+				priority.get(data),
+				duration.get(data)
+			);
+		}
+
+		return new CastResult(PostCastAction.HANDLE_NORMALLY, data);
path: core/src/main/java/com/nisovin/magicspells/spells/targeted/GlowSpell.java
user: login: JasperLorelai
body: `playSpellEffects(data)`
created_at: 2025-03-29T15:47:22Z
author_association: COLLABORATOR

PR comments:
user: login: JasperLorelai
created_at: 2025-03-11T18:43:55Z
body: We should also probably deprecate the old spell.
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-03-16T00:43:22Z
body: <!-- bot: Build PR Jar -->
Built artifact: [`MagicSpells-4.0-Beta-17-2a71c76`](https://nightly.link/TheComputerGeek2/MagicSpells/actions/artifacts/2844481296.zip)
author_association: NONE