From dca1a1132d318176bc57e714c746eeb148fbecb4 Mon Sep 17 00:00:00 2001
From: LatvianModder <latvianmodder@gmail.com>
Date: Wed, 24 Jul 2024 02:45:31 +0300
Subject: [PATCH] Reworked item tooltip event, removed chanced item

---
 .../mods/kubejs/BuiltinKubeJSPlugin.java      |   7 +-
 .../dev/latvian/mods/kubejs/KubeJSCommon.java |   4 +
 .../kubejs/bindings/event/ItemEvents.java     |   6 +-
 .../mods/kubejs/client/KubeJSClient.java      |  19 ++-
 .../client/KubeJSClientEventHandler.java      | 117 +++++++++------
 .../mods/kubejs/client/KubeSessionData.java   |  37 +++++
 .../kubejs/core/ClientPacketListenerKJS.java  |   5 +-
 .../mods/kubejs/core/ItemStackKJS.java        |   6 -
 .../core/mixin/ClientPacketListenerMixin.java |  10 +-
 .../kubejs/core/mixin/GameRendererMixin.java  |   8 +-
 .../kubejs/core/mixin/LocalPlayerMixin.java   |  10 +-
 .../kubejs/ingredient/KubeJSIngredient.java   |   3 +-
 .../kubejs/ingredient/WildcardIngredient.java |  10 +-
 .../integration/emi/KubeJSEMIPlugin.java      |   6 +-
 .../latvian/mods/kubejs/item/ChancedItem.java |  57 -------
 .../item/DynamicItemTooltipsKubeEvent.java    |  35 +++++
 .../kubejs/item/ItemTooltipKubeEvent.java     | 141 ------------------
 .../item/ModifyItemTooltipsKubeEvent.java     |  47 ++++++
 .../kubejs/neoforge/KubeJSNeoForgeClient.java |   8 +
 .../latvian/mods/kubejs/net/KubeJSNet.java    |   4 +-
 .../mods/kubejs/net/KubeServerData.java       |  35 +++++
 .../net/SyncRecipeViewerDataPayload.java      |  28 ----
 .../kubejs/net/SyncServerDataPayload.java     |  20 +++
 .../player/KubeJSPlayerEventHandler.java      |   5 +-
 .../recipe/schema/JsonRecipeSchemaLoader.java |  21 ++-
 .../kubejs/recipe/schema/RecipeSchema.java    |   4 +
 .../recipe/schema/RecipeSchemaFunction.java   |  11 ++
 .../viewer/server/RecipeViewerData.java       |   2 -
 .../kubejs/server/ServerScriptManager.java    |  11 +-
 .../tooltip/ItemStartupTooltipsKubeEvent.java |   9 ++
 .../mods/kubejs/tooltip/ItemTooltipData.java  |  23 +++
 .../kubejs/tooltip/TooltipActionBuilder.java  |  43 ++++++
 .../kubejs/tooltip/TooltipRequirements.java   |  37 +++++
 .../tooltip/action/AddTooltipAction.java      |  21 +++
 .../tooltip/action/DynamicTooltipAction.java  |  20 +++
 .../tooltip/action/InsertTooltipAction.java   |  26 ++++
 .../action/RemoveExactTextTooltipAction.java  |  20 +++
 .../action/RemoveLineTooltipAction.java       |  20 +++
 .../action/RemoveTextTooltipAction.java       |  35 +++++
 .../kubejs/tooltip/action/TooltipAction.java  |  41 +++++
 .../tooltip/action/TooltipActionType.java     |   7 +
 .../mods/kubejs/util/RecordDefaults.java      |   1 +
 .../latvian/mods/kubejs/util/Tristate.java    |  57 +++++++
 43 files changed, 710 insertions(+), 327 deletions(-)
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/client/KubeSessionData.java
 delete mode 100644 src/main/java/dev/latvian/mods/kubejs/item/ChancedItem.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/item/DynamicItemTooltipsKubeEvent.java
 delete mode 100644 src/main/java/dev/latvian/mods/kubejs/item/ItemTooltipKubeEvent.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/item/ModifyItemTooltipsKubeEvent.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/net/KubeServerData.java
 delete mode 100644 src/main/java/dev/latvian/mods/kubejs/net/SyncRecipeViewerDataPayload.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/net/SyncServerDataPayload.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/ItemStartupTooltipsKubeEvent.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/ItemTooltipData.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipActionBuilder.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipRequirements.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/AddTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/DynamicTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/InsertTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveExactTextTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveLineTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveTextTooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipAction.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipActionType.java
 create mode 100644 src/main/java/dev/latvian/mods/kubejs/util/Tristate.java

diff --git a/src/main/java/dev/latvian/mods/kubejs/BuiltinKubeJSPlugin.java b/src/main/java/dev/latvian/mods/kubejs/BuiltinKubeJSPlugin.java
index 9274249db..dd1cef947 100644
--- a/src/main/java/dev/latvian/mods/kubejs/BuiltinKubeJSPlugin.java
+++ b/src/main/java/dev/latvian/mods/kubejs/BuiltinKubeJSPlugin.java
@@ -59,7 +59,6 @@
 import dev.latvian.mods.kubejs.fluid.ThickFluidBuilder;
 import dev.latvian.mods.kubejs.fluid.ThinFluidBuilder;
 import dev.latvian.mods.kubejs.item.ArmorMaterialBuilder;
-import dev.latvian.mods.kubejs.item.ChancedItem;
 import dev.latvian.mods.kubejs.item.ItemEnchantmentsWrapper;
 import dev.latvian.mods.kubejs.item.ItemPredicate;
 import dev.latvian.mods.kubejs.item.ItemStackJS;
@@ -148,6 +147,7 @@
 import dev.latvian.mods.kubejs.util.ScheduledEvents;
 import dev.latvian.mods.kubejs.util.SlotFilter;
 import dev.latvian.mods.kubejs.util.TimeJS;
+import dev.latvian.mods.kubejs.util.Tristate;
 import dev.latvian.mods.kubejs.util.UtilsJS;
 import dev.latvian.mods.kubejs.util.registrypredicate.RegistryPredicate;
 import dev.latvian.mods.rhino.type.RecordTypeInfo;
@@ -484,7 +484,6 @@ public void registerBindings(BindingRegistry bindings) {
 		bindings.add("FluidAmounts", FluidAmounts.class);
 		bindings.add("Notification", NotificationToastData.class);
 		bindings.add("SizedIngredient", SizedIngredientWrapper.class);
-		bindings.add("ChancedItem", ChancedItem.class);
 		bindings.add("ParticleOptions", ParticleOptionsWrapper.class);
 		bindings.add("Registry", RegistryWrapper.class);
 
@@ -592,7 +591,7 @@ public void registerTypeWrappers(TypeWrapperRegistry registry) {
 		registry.register(ParticleOptions.class, ParticleOptionsWrapper::wrap);
 		registry.register(ItemTintFunction.class, ItemTintFunction::of);
 		registry.register(BlockTintFunction.class, BlockTintFunction::of);
-		registry.register(ChancedItem.class, ChancedItem::wrap);
+		registry.register(Tristate.class, Tristate::wrap);
 
 		// components //
 		registry.register(Component.class, TextWrapper::of);
@@ -648,8 +647,6 @@ public void registerRecipeComponents(RecipeComponentFactoryRegistry registry) {
 		registry.register(SizedFluidIngredientComponent.FLAT);
 		registry.register(SizedFluidIngredientComponent.NESTED);
 
-		registry.register(ChancedItem.RECIPE_COMPONENT);
-
 		registry.register(BlockComponent.BLOCK);
 
 		registry.register(BlockStateComponent.BLOCK);
diff --git a/src/main/java/dev/latvian/mods/kubejs/KubeJSCommon.java b/src/main/java/dev/latvian/mods/kubejs/KubeJSCommon.java
index ebff78c4c..7ed7286dc 100644
--- a/src/main/java/dev/latvian/mods/kubejs/KubeJSCommon.java
+++ b/src/main/java/dev/latvian/mods/kubejs/KubeJSCommon.java
@@ -1,5 +1,6 @@
 package dev.latvian.mods.kubejs;
 
+import dev.latvian.mods.kubejs.net.KubeServerData;
 import dev.latvian.mods.kubejs.script.ConsoleLine;
 import dev.latvian.mods.kubejs.script.ScriptType;
 import dev.latvian.mods.kubejs.script.data.ExportablePackResources;
@@ -58,4 +59,7 @@ public void runInMainThread(Runnable runnable) {
 			runnable.run();
 		}
 	}
+
+	public void updateServerData(KubeServerData data) {
+	}
 }
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/bindings/event/ItemEvents.java b/src/main/java/dev/latvian/mods/kubejs/bindings/event/ItemEvents.java
index b4c603d79..4a2652f33 100644
--- a/src/main/java/dev/latvian/mods/kubejs/bindings/event/ItemEvents.java
+++ b/src/main/java/dev/latvian/mods/kubejs/bindings/event/ItemEvents.java
@@ -4,6 +4,7 @@
 import dev.latvian.mods.kubejs.event.EventHandler;
 import dev.latvian.mods.kubejs.event.EventTargetType;
 import dev.latvian.mods.kubejs.event.TargetedEventHandler;
+import dev.latvian.mods.kubejs.item.DynamicItemTooltipsKubeEvent;
 import dev.latvian.mods.kubejs.item.FoodEatenKubeEvent;
 import dev.latvian.mods.kubejs.item.ItemClickedKubeEvent;
 import dev.latvian.mods.kubejs.item.ItemCraftedKubeEvent;
@@ -15,7 +16,7 @@
 import dev.latvian.mods.kubejs.item.ItemPickedUpKubeEvent;
 import dev.latvian.mods.kubejs.item.ItemSmeltedKubeEvent;
 import dev.latvian.mods.kubejs.item.ItemStackJS;
-import dev.latvian.mods.kubejs.item.ItemTooltipKubeEvent;
+import dev.latvian.mods.kubejs.item.ModifyItemTooltipsKubeEvent;
 import dev.latvian.mods.kubejs.item.custom.ItemToolTierRegistryKubeEvent;
 import net.minecraft.core.registries.Registries;
 import net.minecraft.resources.ResourceKey;
@@ -35,7 +36,8 @@ public interface ItemEvents {
 	TargetedEventHandler<ResourceKey<Item>> CRAFTED = GROUP.common("crafted", () -> ItemCraftedKubeEvent.class).supportsTarget(TARGET);
 	TargetedEventHandler<ResourceKey<Item>> SMELTED = GROUP.common("smelted", () -> ItemSmeltedKubeEvent.class).supportsTarget(TARGET);
 	TargetedEventHandler<ResourceKey<Item>> FOOD_EATEN = GROUP.common("foodEaten", () -> FoodEatenKubeEvent.class).hasResult().supportsTarget(TARGET);
-	EventHandler TOOLTIP = GROUP.client("tooltip", () -> ItemTooltipKubeEvent.class);
+	EventHandler MODIFY_TOOLTIPS = GROUP.common("modifyTooltips", () -> ModifyItemTooltipsKubeEvent.class);
+	TargetedEventHandler<String> DYNAMIC_TOOLTIPS = GROUP.client("dynamicTooltips", () -> DynamicItemTooltipsKubeEvent.class).requiredTarget(EventTargetType.STRING);
 	EventHandler MODEL_PROPERTIES = GROUP.startup("modelProperties", () -> ItemModelPropertiesKubeEvent.class);
 	TargetedEventHandler<ResourceKey<Item>> FIRST_RIGHT_CLICKED = GROUP.common("firstRightClicked", () -> ItemClickedKubeEvent.class).supportsTarget(TARGET);
 	TargetedEventHandler<ResourceKey<Item>> FIRST_LEFT_CLICKED = GROUP.common("firstLeftClicked", () -> ItemClickedKubeEvent.class).supportsTarget(TARGET);
diff --git a/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClient.java b/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClient.java
index 8ebc93d96..fb8606a47 100644
--- a/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClient.java
+++ b/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClient.java
@@ -3,8 +3,11 @@
 import dev.latvian.mods.kubejs.KubeJS;
 import dev.latvian.mods.kubejs.KubeJSCommon;
 import dev.latvian.mods.kubejs.KubeJSPaths;
+import dev.latvian.mods.kubejs.bindings.event.ItemEvents;
 import dev.latvian.mods.kubejs.bindings.event.NetworkEvents;
+import dev.latvian.mods.kubejs.item.ModifyItemTooltipsKubeEvent;
 import dev.latvian.mods.kubejs.kubedex.KubedexHighlight;
+import dev.latvian.mods.kubejs.net.KubeServerData;
 import dev.latvian.mods.kubejs.net.NetworkKubeEvent;
 import dev.latvian.mods.kubejs.script.ConsoleLine;
 import dev.latvian.mods.kubejs.script.ScriptType;
@@ -12,6 +15,7 @@
 import dev.latvian.mods.kubejs.script.data.GeneratedData;
 import dev.latvian.mods.kubejs.script.data.GeneratedDataStage;
 import dev.latvian.mods.kubejs.script.data.VirtualAssetPack;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
 import net.minecraft.SharedConstants;
 import net.minecraft.Util;
 import net.minecraft.client.Minecraft;
@@ -36,6 +40,7 @@
 import java.io.PrintWriter;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.util.ArrayList;
 import java.util.EnumMap;
 import java.util.List;
 import java.util.Map;
@@ -46,6 +51,7 @@ public class KubeJSClient extends KubeJSCommon {
 	public static final ResourceLocation RECIPE_BUTTON_TEXTURE = ResourceLocation.parse("textures/gui/recipe_button.png");
 
 	public static final Map<GeneratedDataStage, VirtualAssetPack> CLIENT_PACKS = new EnumMap<>(GeneratedDataStage.class);
+	public static List<ItemTooltipData> clientItemTooltips = List.of();
 
 	static {
 		for (var stage : GeneratedDataStage.values()) {
@@ -59,8 +65,10 @@ public void reloadClientInternal() {
 	}
 
 	public static void reloadClientScripts() {
-		KubeJSClientEventHandler.staticItemTooltips = null;
 		KubeJS.getClientScriptManager().reload();
+		var list = new ArrayList<ItemTooltipData>();
+		ItemEvents.MODIFY_TOOLTIPS.post(ScriptType.CLIENT, new ModifyItemTooltipsKubeEvent(list::add));
+		clientItemTooltips = List.copyOf(list);
 	}
 
 	public static void copyDefaultOptionsFile(File optionsFile) {
@@ -177,6 +185,15 @@ public void runInMainThread(Runnable runnable) {
 		}
 	}
 
+	@Override
+	public void updateServerData(KubeServerData data) {
+		var sessionData = KubeSessionData.of(Minecraft.getInstance());
+
+		if (sessionData != null) {
+			sessionData.sync(data);
+		}
+	}
+
 	public static void loadPostChains(Minecraft mc) {
 		KubedexHighlight.INSTANCE.loadPostChains(mc);
 	}
diff --git a/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClientEventHandler.java b/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClientEventHandler.java
index 63a68a8eb..6f3fc0c94 100644
--- a/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClientEventHandler.java
+++ b/src/main/java/dev/latvian/mods/kubejs/client/KubeJSClientEventHandler.java
@@ -6,10 +6,14 @@
 import dev.latvian.mods.kubejs.bindings.TextIcons;
 import dev.latvian.mods.kubejs.bindings.event.ClientEvents;
 import dev.latvian.mods.kubejs.bindings.event.ItemEvents;
-import dev.latvian.mods.kubejs.item.ItemTooltipKubeEvent;
+import dev.latvian.mods.kubejs.item.DynamicItemTooltipsKubeEvent;
 import dev.latvian.mods.kubejs.kubedex.KubedexHighlight;
 import dev.latvian.mods.kubejs.script.ConsoleJS;
 import dev.latvian.mods.kubejs.script.ScriptType;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
+import dev.latvian.mods.kubejs.tooltip.TooltipRequirements;
+import dev.latvian.mods.kubejs.tooltip.action.DynamicTooltipAction;
+import dev.latvian.mods.kubejs.util.Tristate;
 import net.minecraft.client.Minecraft;
 import net.minecraft.client.gui.components.ImageButton;
 import net.minecraft.client.gui.screens.Screen;
@@ -26,8 +30,6 @@
 import net.minecraft.resources.ResourceLocation;
 import net.minecraft.world.item.BlockItem;
 import net.minecraft.world.item.BucketItem;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.Items;
 import net.minecraft.world.item.SpawnEggItem;
 import net.minecraft.world.level.material.Fluid;
 import net.minecraft.world.level.material.Fluids;
@@ -45,15 +47,10 @@
 import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent;
 import org.jetbrains.annotations.Nullable;
 
-import java.util.IdentityHashMap;
 import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
 
 @EventBusSubscriber(modid = KubeJS.MOD_ID, value = Dist.CLIENT)
 public class KubeJSClientEventHandler {
-	public static Map<Item, List<ItemTooltipKubeEvent.StaticTooltipHandler>> staticItemTooltips = null;
-
 	@SubscribeEvent
 	public static void debugInfo(CustomizeGuiOverlayEvent.DebugText event) {
 		var mc = Minecraft.getInstance();
@@ -84,18 +81,82 @@ private static <T> void appendComponentValue(DynamicOps<Tag> ops, MutableCompone
 		}
 	}
 
-	@SubscribeEvent
+	public static boolean testRequirements(Minecraft mc, DynamicItemTooltipsKubeEvent event, TooltipRequirements r) {
+		if (!r.advanced().test(event.advanced)) {
+			return false;
+		}
+
+		if (!r.creative().test(event.creative)) {
+			return false;
+		}
+
+		if (!r.shift().test(event.shift)) {
+			return false;
+		}
+
+		if (!r.ctrl().test(event.ctrl)) {
+			return false;
+		}
+
+		if (!r.alt().test(event.alt)) {
+			return false;
+		}
+
+		if (!r.stages().isEmpty()) {
+			var stages = mc.player.kjs$getStages();
+
+			for (var entry : r.stages().entrySet()) {
+				if (entry.getValue() != Tristate.DEFAULT && !entry.getValue().test(stages.has(entry.getKey()))) {
+					return false;
+				}
+			}
+		}
+
+		return true;
+	}
+
+	private static void handleItemTooltips(Minecraft mc, ItemTooltipData tooltip, DynamicItemTooltipsKubeEvent event) {
+		if ((tooltip.filter().isEmpty() || tooltip.filter().get().test(event.item)) && (tooltip.requirements().isEmpty() || testRequirements(mc, event, tooltip.requirements().get()))) {
+			for (var action : tooltip.actions()) {
+				if (action instanceof DynamicTooltipAction dynamic) {
+					try {
+						ItemEvents.DYNAMIC_TOOLTIPS.post(ScriptType.CLIENT, dynamic.id(), event);
+					} catch (Exception ex) {
+						ConsoleJS.CLIENT.error("Item " + event.item.kjs$getId() + " dynamic tooltip error", ex);
+					}
+				} else {
+					action.apply(event.lines);
+				}
+			}
+		}
+	}
+
+	@SubscribeEvent(priority = EventPriority.LOW)
 	public static void onItemTooltip(ItemTooltipEvent event) {
-		var mc = Minecraft.getInstance();
 		var stack = event.getItemStack();
-		var lines = event.getToolTip();
-		var flag = event.getFlags();
 
 		if (stack.isEmpty()) {
 			return;
 		}
 
-		var advanced = flag.isAdvanced();
+		var mc = Minecraft.getInstance();
+		var lines = event.getToolTip();
+		var flags = event.getFlags();
+		var sessionData = KubeSessionData.of(mc);
+
+		var dynamicEvent = new DynamicItemTooltipsKubeEvent(stack, flags, lines, sessionData == null);
+
+		for (var tooltip : KubeJSClient.clientItemTooltips) {
+			handleItemTooltips(mc, tooltip, dynamicEvent);
+		}
+
+		if (sessionData != null) {
+			for (var tooltip : sessionData.itemTooltips) {
+				handleItemTooltips(mc, tooltip, dynamicEvent);
+			}
+		}
+
+		var advanced = flags.isAdvanced();
 
 		if (mc.level != null && advanced && ClientProperties.get().showComponents && Screen.hasAltDown()) {
 			var components = BuiltInRegistries.DATA_COMPONENT_TYPE;
@@ -165,36 +226,6 @@ public static void onItemTooltip(ItemTooltipEvent event) {
 				lines.add(instance.toText());
 			}
 		}
-
-		if (staticItemTooltips == null) {
-			var staticItemTooltips0 = new IdentityHashMap<Item, List<ItemTooltipKubeEvent.StaticTooltipHandler>>();
-			ItemEvents.TOOLTIP.post(ScriptType.CLIENT, new ItemTooltipKubeEvent(staticItemTooltips0));
-			staticItemTooltips = staticItemTooltips0;
-		}
-
-		try {
-			var handlers = staticItemTooltips.get(Items.AIR);
-
-			if (handlers != null && !handlers.isEmpty()) {
-				for (var handler : handlers) {
-					handler.tooltip(stack, advanced, lines);
-				}
-			}
-		} catch (Exception ex) {
-			ConsoleJS.CLIENT.error("Error while gathering tooltip for " + stack, ex);
-		}
-
-		try {
-			var handlers = staticItemTooltips.get(stack.getItem());
-
-			if (handlers != null && !handlers.isEmpty()) {
-				for (var handler : handlers) {
-					handler.tooltip(stack, advanced, lines);
-				}
-			}
-		} catch (Exception ex) {
-			ConsoleJS.CLIENT.error("Error while gathering tooltip for " + stack, ex);
-		}
 	}
 
 	@SubscribeEvent
diff --git a/src/main/java/dev/latvian/mods/kubejs/client/KubeSessionData.java b/src/main/java/dev/latvian/mods/kubejs/client/KubeSessionData.java
new file mode 100644
index 000000000..e0bba5846
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/client/KubeSessionData.java
@@ -0,0 +1,37 @@
+package dev.latvian.mods.kubejs.client;
+
+import dev.latvian.mods.kubejs.core.ClientPacketListenerKJS;
+import dev.latvian.mods.kubejs.net.KubeServerData;
+import dev.latvian.mods.kubejs.recipe.viewer.server.RecipeViewerData;
+import dev.latvian.mods.kubejs.recipe.viewer.server.RemoteRecipeViewerDataUpdatedEvent;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.resources.ResourceLocation;
+import net.neoforged.neoforge.common.NeoForge;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class KubeSessionData {
+	@Nullable
+	public static KubeSessionData of(ClientPacketListener listener) {
+		return listener == null ? null : ((ClientPacketListenerKJS) listener).kjs$sessionData();
+	}
+
+	@Nullable
+	public static KubeSessionData of(Minecraft mc) {
+		return mc == null ? null : of(mc.getConnection());
+	}
+
+	public ResourceLocation activePostShader = null;
+	public RecipeViewerData recipeViewerData = null;
+	public List<ItemTooltipData> itemTooltips = List.of();
+
+	public void sync(KubeServerData data) {
+		recipeViewerData = data.recipeViewerData().orElse(null);
+		itemTooltips = List.copyOf(data.itemTooltipData());
+
+		NeoForge.EVENT_BUS.post(new RemoteRecipeViewerDataUpdatedEvent(recipeViewerData));
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/core/ClientPacketListenerKJS.java b/src/main/java/dev/latvian/mods/kubejs/core/ClientPacketListenerKJS.java
index 75a787ed9..124fea2be 100644
--- a/src/main/java/dev/latvian/mods/kubejs/core/ClientPacketListenerKJS.java
+++ b/src/main/java/dev/latvian/mods/kubejs/core/ClientPacketListenerKJS.java
@@ -1,10 +1,9 @@
 package dev.latvian.mods.kubejs.core;
 
-import net.minecraft.resources.ResourceLocation;
-import org.apache.commons.lang3.mutable.Mutable;
+import dev.latvian.mods.kubejs.client.KubeSessionData;
 
 public interface ClientPacketListenerKJS {
-	default Mutable<ResourceLocation> kjs$activePostShader() {
+	default KubeSessionData kjs$sessionData() {
 		throw new NoMixinException();
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/core/ItemStackKJS.java b/src/main/java/dev/latvian/mods/kubejs/core/ItemStackKJS.java
index c6a353590..a1bf66ca8 100644
--- a/src/main/java/dev/latvian/mods/kubejs/core/ItemStackKJS.java
+++ b/src/main/java/dev/latvian/mods/kubejs/core/ItemStackKJS.java
@@ -3,7 +3,6 @@
 import com.mojang.serialization.Codec;
 import com.mojang.serialization.DynamicOps;
 import dev.latvian.mods.kubejs.bindings.DataComponentWrapper;
-import dev.latvian.mods.kubejs.item.ChancedItem;
 import dev.latvian.mods.kubejs.item.ItemStackJS;
 import dev.latvian.mods.kubejs.level.BlockContainerJS;
 import dev.latvian.mods.kubejs.recipe.match.ItemMatch;
@@ -25,7 +24,6 @@
 import net.minecraft.network.chat.Component;
 import net.minecraft.resources.ResourceKey;
 import net.minecraft.resources.ResourceLocation;
-import net.minecraft.util.valueproviders.FloatProvider;
 import net.minecraft.world.item.BlockItem;
 import net.minecraft.world.item.Item;
 import net.minecraft.world.item.ItemStack;
@@ -248,10 +246,6 @@ default Codec<ItemStack> getCodec(Context cx) {
 		return ItemStack.CODEC;
 	}
 
-	default ChancedItem kjs$withChance(FloatProvider chance) {
-		return new ChancedItem(kjs$self(), chance);
-	}
-
 	@ReturnsSelf(copy = true)
 	default ItemStack kjs$withLore(Component[] lines) {
 		var is = kjs$self().copy();
diff --git a/src/main/java/dev/latvian/mods/kubejs/core/mixin/ClientPacketListenerMixin.java b/src/main/java/dev/latvian/mods/kubejs/core/mixin/ClientPacketListenerMixin.java
index 7ec43cf5b..25c5efc89 100644
--- a/src/main/java/dev/latvian/mods/kubejs/core/mixin/ClientPacketListenerMixin.java
+++ b/src/main/java/dev/latvian/mods/kubejs/core/mixin/ClientPacketListenerMixin.java
@@ -1,20 +1,18 @@
 package dev.latvian.mods.kubejs.core.mixin;
 
+import dev.latvian.mods.kubejs.client.KubeSessionData;
 import dev.latvian.mods.kubejs.core.ClientPacketListenerKJS;
 import net.minecraft.client.multiplayer.ClientPacketListener;
-import net.minecraft.resources.ResourceLocation;
-import org.apache.commons.lang3.mutable.Mutable;
-import org.apache.commons.lang3.mutable.MutableObject;
 import org.spongepowered.asm.mixin.Mixin;
 import org.spongepowered.asm.mixin.Unique;
 
 @Mixin(ClientPacketListener.class)
 public class ClientPacketListenerMixin implements ClientPacketListenerKJS {
 	@Unique
-	private final Mutable<ResourceLocation> kjs$activePostShader = new MutableObject<>(null);
+	private final KubeSessionData kjs$sessionData = new KubeSessionData();
 
 	@Override
-	public Mutable<ResourceLocation> kjs$activePostShader() {
-		return kjs$activePostShader;
+	public KubeSessionData kjs$sessionData() {
+		return kjs$sessionData;
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/core/mixin/GameRendererMixin.java b/src/main/java/dev/latvian/mods/kubejs/core/mixin/GameRendererMixin.java
index b8395e8af..0e4e7c8e1 100644
--- a/src/main/java/dev/latvian/mods/kubejs/core/mixin/GameRendererMixin.java
+++ b/src/main/java/dev/latvian/mods/kubejs/core/mixin/GameRendererMixin.java
@@ -1,6 +1,6 @@
 package dev.latvian.mods.kubejs.core.mixin;
 
-import dev.latvian.mods.kubejs.core.ClientPacketListenerKJS;
+import dev.latvian.mods.kubejs.client.KubeSessionData;
 import net.minecraft.client.Minecraft;
 import net.minecraft.client.renderer.GameRenderer;
 import net.minecraft.client.renderer.PostChain;
@@ -29,12 +29,14 @@ public abstract class GameRendererMixin {
 
 	@Inject(method = "checkEntityPostEffect", at = @At("HEAD"), cancellable = true)
 	private void kjs$checkEntityPostEffect(CallbackInfo ci) {
-		if (minecraft.getConnection() instanceof ClientPacketListenerKJS connection && connection.kjs$activePostShader().getValue() != null) {
+		var data = KubeSessionData.of(minecraft);
+
+		if (data != null && data.activePostShader != null) {
 			if (postEffect != null) {
 				postEffect.close();
 			}
 
-			loadEffect(connection.kjs$activePostShader().getValue());
+			loadEffect(data.activePostShader);
 			ci.cancel();
 		}
 	}
diff --git a/src/main/java/dev/latvian/mods/kubejs/core/mixin/LocalPlayerMixin.java b/src/main/java/dev/latvian/mods/kubejs/core/mixin/LocalPlayerMixin.java
index c10443757..d8b9d7e35 100644
--- a/src/main/java/dev/latvian/mods/kubejs/core/mixin/LocalPlayerMixin.java
+++ b/src/main/java/dev/latvian/mods/kubejs/core/mixin/LocalPlayerMixin.java
@@ -1,7 +1,7 @@
 package dev.latvian.mods.kubejs.core.mixin;
 
 import com.mojang.authlib.GameProfile;
-import dev.latvian.mods.kubejs.core.ClientPacketListenerKJS;
+import dev.latvian.mods.kubejs.client.KubeSessionData;
 import dev.latvian.mods.rhino.util.RemapForJS;
 import net.minecraft.client.Minecraft;
 import net.minecraft.client.multiplayer.ClientPacketListener;
@@ -45,7 +45,11 @@ public LocalPlayerMixin(Level level, BlockPos blockPos, float f, GameProfile gam
 
 	@Override
 	public void kjs$setActivePostShader(@Nullable ResourceLocation id) {
-		((ClientPacketListenerKJS) connection).kjs$activePostShader().setValue(id);
-		minecraft.gameRenderer.checkEntityPostEffect(minecraft.options.getCameraType().isFirstPerson() ? minecraft.getCameraEntity() : null);
+		var sessionData = KubeSessionData.of(connection);
+
+		if (sessionData != null) {
+			sessionData.activePostShader = id;
+			minecraft.gameRenderer.checkEntityPostEffect(minecraft.options.getCameraType().isFirstPerson() ? minecraft.getCameraEntity() : null);
+		}
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/ingredient/KubeJSIngredient.java b/src/main/java/dev/latvian/mods/kubejs/ingredient/KubeJSIngredient.java
index 8728a9cee..c4bb1fbca 100644
--- a/src/main/java/dev/latvian/mods/kubejs/ingredient/KubeJSIngredient.java
+++ b/src/main/java/dev/latvian/mods/kubejs/ingredient/KubeJSIngredient.java
@@ -1,5 +1,6 @@
 package dev.latvian.mods.kubejs.ingredient;
 
+import dev.latvian.mods.kubejs.CommonProperties;
 import dev.latvian.mods.kubejs.item.ItemStackJS;
 import net.minecraft.world.item.ItemStack;
 import net.neoforged.neoforge.common.crafting.ICustomIngredient;
@@ -18,6 +19,6 @@ default Stream<ItemStack> getItems() {
 
 	@Override
 	default boolean isSimple() {
-		return true;
+		return CommonProperties.get().serverOnly;
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/ingredient/WildcardIngredient.java b/src/main/java/dev/latvian/mods/kubejs/ingredient/WildcardIngredient.java
index 5093fb54c..271669a7b 100644
--- a/src/main/java/dev/latvian/mods/kubejs/ingredient/WildcardIngredient.java
+++ b/src/main/java/dev/latvian/mods/kubejs/ingredient/WildcardIngredient.java
@@ -1,15 +1,12 @@
 package dev.latvian.mods.kubejs.ingredient;
 
 import com.mojang.serialization.MapCodec;
-import dev.latvian.mods.kubejs.item.ItemStackJS;
 import io.netty.buffer.ByteBuf;
 import net.minecraft.network.codec.StreamCodec;
 import net.minecraft.world.item.ItemStack;
 import net.neoforged.neoforge.common.crafting.IngredientType;
 import org.jetbrains.annotations.Nullable;
 
-import java.util.stream.Stream;
-
 public class WildcardIngredient implements KubeJSIngredient {
 	public static WildcardIngredient INSTANCE = new WildcardIngredient();
 
@@ -26,11 +23,6 @@ public IngredientType<?> getType() {
 
 	@Override
 	public boolean test(@Nullable ItemStack stack) {
-		return stack != null;
-	}
-
-	@Override
-	public Stream<ItemStack> getItems() {
-		return ItemStackJS.getList().stream();
+		return stack != null && !stack.isEmpty();
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/integration/emi/KubeJSEMIPlugin.java b/src/main/java/dev/latvian/mods/kubejs/integration/emi/KubeJSEMIPlugin.java
index 1ed676eaf..4a79b16f2 100644
--- a/src/main/java/dev/latvian/mods/kubejs/integration/emi/KubeJSEMIPlugin.java
+++ b/src/main/java/dev/latvian/mods/kubejs/integration/emi/KubeJSEMIPlugin.java
@@ -6,10 +6,11 @@
 import dev.emi.emi.api.recipe.EmiInfoRecipe;
 import dev.emi.emi.api.stack.EmiIngredient;
 import dev.emi.emi.api.stack.EmiStack;
+import dev.latvian.mods.kubejs.client.KubeSessionData;
 import dev.latvian.mods.kubejs.recipe.viewer.RecipeViewerEntryType;
 import dev.latvian.mods.kubejs.recipe.viewer.RecipeViewerEvents;
-import dev.latvian.mods.kubejs.recipe.viewer.server.RecipeViewerData;
 import dev.latvian.mods.kubejs.script.ScriptType;
+import net.minecraft.client.Minecraft;
 import net.minecraft.resources.ResourceLocation;
 
 import java.util.HashMap;
@@ -20,7 +21,8 @@
 public class KubeJSEMIPlugin implements EmiPlugin {
 	@Override
 	public void register(EmiRegistry registry) {
-		var remote = RecipeViewerData.remote;
+		var sessionData = KubeSessionData.of(Minecraft.getInstance());
+		var remote = sessionData == null ? null : sessionData.recipeViewerData;
 
 		if (remote != null) {
 			var removedCategories = Set.copyOf(remote.removedCategories());
diff --git a/src/main/java/dev/latvian/mods/kubejs/item/ChancedItem.java b/src/main/java/dev/latvian/mods/kubejs/item/ChancedItem.java
deleted file mode 100644
index 9f3b4642a..000000000
--- a/src/main/java/dev/latvian/mods/kubejs/item/ChancedItem.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package dev.latvian.mods.kubejs.item;
-
-import com.mojang.serialization.MapCodec;
-import com.mojang.serialization.codecs.RecordCodecBuilder;
-import dev.latvian.mods.kubejs.recipe.component.RecipeComponent;
-import dev.latvian.mods.kubejs.recipe.component.SimpleRecipeComponent;
-import dev.latvian.mods.kubejs.util.RegistryAccessContainer;
-import dev.latvian.mods.rhino.Context;
-import dev.latvian.mods.rhino.type.RecordTypeInfo;
-import dev.latvian.mods.rhino.type.TypeInfo;
-import net.minecraft.network.RegistryFriendlyByteBuf;
-import net.minecraft.network.codec.ByteBufCodecs;
-import net.minecraft.network.codec.StreamCodec;
-import net.minecraft.util.RandomSource;
-import net.minecraft.util.valueproviders.ConstantFloat;
-import net.minecraft.util.valueproviders.FloatProvider;
-import net.minecraft.world.item.ItemStack;
-
-public record ChancedItem(ItemStack item, FloatProvider chance) {
-	public static final RecordTypeInfo TYPE_INFO = (RecordTypeInfo) TypeInfo.of(ChancedItem.class);
-	public static final FloatProvider DEFAULT_CHANCE = ConstantFloat.of(1F);
-
-	public static final MapCodec<ChancedItem> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
-		ItemStack.CODEC.fieldOf("item").forGetter(ChancedItem::item),
-		FloatProvider.CODEC.optionalFieldOf("chance", DEFAULT_CHANCE).forGetter(ChancedItem::chance)
-	).apply(instance, ChancedItem::new));
-
-	public static final StreamCodec<RegistryFriendlyByteBuf, ChancedItem> STREAM_CODEC = StreamCodec.composite(
-		ItemStack.STREAM_CODEC, ChancedItem::item,
-		ByteBufCodecs.fromCodec(FloatProvider.CODEC), ChancedItem::chance,
-		ChancedItem::new
-	);
-
-	public static final RecipeComponent<ChancedItem> RECIPE_COMPONENT = new SimpleRecipeComponent<>("chanced_item", CODEC.codec(), TypeInfo.of(ChancedItem.class));
-
-	public static ChancedItem wrap(Context cx, Object from) {
-		if (from instanceof ItemStack is) {
-			return new ChancedItem(is, DEFAULT_CHANCE);
-		} else if (from instanceof CharSequence) {
-			return new ChancedItem(ItemStackJS.wrap(RegistryAccessContainer.of(cx), from), DEFAULT_CHANCE);
-		} else {
-			return (ChancedItem) TYPE_INFO.wrap(cx, from, TYPE_INFO);
-		}
-	}
-
-	public boolean test(RandomSource random) {
-		return random.nextFloat() < chance.sample(random);
-	}
-
-	public ItemStack getItemOrEmpty(RandomSource random) {
-		return test(random) ? item : ItemStack.EMPTY;
-	}
-
-	public ChancedItem withChance(FloatProvider chance) {
-		return new ChancedItem(item, chance);
-	}
-}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/item/DynamicItemTooltipsKubeEvent.java b/src/main/java/dev/latvian/mods/kubejs/item/DynamicItemTooltipsKubeEvent.java
new file mode 100644
index 000000000..84454bb99
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/item/DynamicItemTooltipsKubeEvent.java
@@ -0,0 +1,35 @@
+package dev.latvian.mods.kubejs.item;
+
+import dev.latvian.mods.kubejs.event.KubeEvent;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.TooltipFlag;
+
+import java.util.List;
+
+public class DynamicItemTooltipsKubeEvent implements KubeEvent {
+	public final ItemStack item;
+	public final List<Component> lines;
+	public final boolean startup;
+	public final boolean advanced;
+	public final boolean creative;
+	public final boolean shift;
+	public final boolean ctrl;
+	public final boolean alt;
+
+	public DynamicItemTooltipsKubeEvent(ItemStack item, TooltipFlag flags, List<Component> lines, boolean startup) {
+		this.item = item;
+		this.lines = lines;
+		this.startup = startup;
+		this.advanced = flags.isAdvanced();
+		this.creative = flags.isCreative();
+		this.shift = !startup && Screen.hasShiftDown();
+		this.ctrl = !startup && Screen.hasControlDown();
+		this.alt = !startup && Screen.hasAltDown();
+	}
+
+	public void add(List<Component> text) {
+		lines.addAll(text);
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/item/ItemTooltipKubeEvent.java b/src/main/java/dev/latvian/mods/kubejs/item/ItemTooltipKubeEvent.java
deleted file mode 100644
index 15899f9d0..000000000
--- a/src/main/java/dev/latvian/mods/kubejs/item/ItemTooltipKubeEvent.java
+++ /dev/null
@@ -1,141 +0,0 @@
-package dev.latvian.mods.kubejs.item;
-
-import dev.latvian.mods.kubejs.event.KubeEvent;
-import dev.latvian.mods.kubejs.script.ConsoleJS;
-import dev.latvian.mods.kubejs.typings.Info;
-import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.network.chat.Component;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.ItemStack;
-import net.minecraft.world.item.Items;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-
-@Info("""
-	Invoked when registering handlers for item tooltips.
-			
-	`text` can be a component or a list of components.
-	""")
-public class ItemTooltipKubeEvent implements KubeEvent {
-	@FunctionalInterface
-	public interface StaticTooltipHandler {
-		void tooltip(ItemStack stack, boolean advanced, List<Component> components);
-	}
-
-	@FunctionalInterface
-	public interface StaticTooltipHandlerFromJS {
-		void accept(ItemStack stack, boolean advanced, List<Component> text);
-	}
-
-	public static class StaticTooltipHandlerFromLines implements StaticTooltipHandler {
-		public final List<Component> lines;
-
-		public StaticTooltipHandlerFromLines(List<Component> l) {
-			lines = l;
-		}
-
-		@Override
-		public void tooltip(ItemStack stack, boolean advanced, List<Component> components) {
-			if (!stack.isEmpty()) {
-				components.addAll(lines);
-			}
-		}
-	}
-
-	public static class StaticTooltipHandlerFromJSWrapper implements StaticTooltipHandler {
-		private final StaticTooltipHandlerFromJS handler;
-
-		public StaticTooltipHandlerFromJSWrapper(StaticTooltipHandlerFromJS h) {
-			handler = h;
-		}
-
-		@Override
-		public void tooltip(ItemStack stack, boolean advanced, List<Component> components) {
-			if (stack.isEmpty()) {
-				return;
-			}
-
-			List<Component> text = new ArrayList<>(components);
-
-			try {
-				handler.accept(stack, advanced, text);
-			} catch (Exception ex) {
-				ConsoleJS.CLIENT.error("Error while gathering tooltip for " + stack, ex);
-			}
-
-			components.clear();
-			components.addAll(text);
-		}
-	}
-
-	private final Map<Item, List<StaticTooltipHandler>> map;
-
-	public ItemTooltipKubeEvent(Map<Item, List<ItemTooltipKubeEvent.StaticTooltipHandler>> m) {
-		map = m;
-	}
-
-	@Info("Adds text to all items matching the ingredient.")
-	public void add(ItemPredicate item, List<Component> text) {
-		if (item.kjs$isWildcard()) {
-			addToAll(text);
-			return;
-		}
-
-		var l = new StaticTooltipHandlerFromLines(text);
-
-		if (!l.lines.isEmpty()) {
-			for (var i : item.kjs$getItemTypes()) {
-				if (i != Items.AIR) {
-					map.computeIfAbsent(i, k -> new ArrayList<>()).add(l);
-				}
-			}
-		}
-	}
-
-	@Info("Adds text to all items.")
-	public void addToAll(List<Component> text) {
-		var l = new StaticTooltipHandlerFromLines(text);
-
-		if (!l.lines.isEmpty()) {
-			map.computeIfAbsent(Items.AIR, k -> new ArrayList<>()).add(l);
-		}
-	}
-
-	@Info("Adds a dynamic tooltip handler to all items matching the ingredient.")
-	public void addAdvanced(ItemPredicate item, StaticTooltipHandlerFromJS handler) {
-		if (item.kjs$isWildcard()) {
-			addAdvancedToAll(handler);
-			return;
-		}
-
-		var l = new StaticTooltipHandlerFromJSWrapper(handler);
-
-		for (var i : item.kjs$getItemTypes()) {
-			if (i != Items.AIR) {
-				map.computeIfAbsent(i, k -> new ArrayList<>()).add(l);
-			}
-		}
-	}
-
-	@Info("Adds a dynamic tooltip handler to all items.")
-	public void addAdvancedToAll(StaticTooltipHandlerFromJS handler) {
-		map.computeIfAbsent(Items.AIR, k -> new ArrayList<>()).add(new StaticTooltipHandlerFromJSWrapper(handler));
-	}
-
-	@Info("Is shift key pressed.")
-	public boolean isShift() {
-		return Screen.hasShiftDown();
-	}
-
-	@Info("Is control key pressed.")
-	public boolean isCtrl() {
-		return Screen.hasControlDown();
-	}
-
-	@Info("Is alt key pressed.")
-	public boolean isAlt() {
-		return Screen.hasAltDown();
-	}
-}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/item/ModifyItemTooltipsKubeEvent.java b/src/main/java/dev/latvian/mods/kubejs/item/ModifyItemTooltipsKubeEvent.java
new file mode 100644
index 000000000..8b8ac2977
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/item/ModifyItemTooltipsKubeEvent.java
@@ -0,0 +1,47 @@
+package dev.latvian.mods.kubejs.item;
+
+import dev.latvian.mods.kubejs.event.KubeEvent;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
+import dev.latvian.mods.kubejs.tooltip.TooltipActionBuilder;
+import dev.latvian.mods.kubejs.tooltip.TooltipRequirements;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.item.crafting.Ingredient;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+public class ModifyItemTooltipsKubeEvent implements KubeEvent {
+	private final Consumer<ItemTooltipData> callback;
+
+	public ModifyItemTooltipsKubeEvent(Consumer<ItemTooltipData> callback) {
+		this.callback = callback;
+	}
+
+	private void modify(@Nullable Ingredient filter, Optional<TooltipRequirements> requirements, Consumer<TooltipActionBuilder> consumer) {
+		var builder = new TooltipActionBuilder();
+		consumer.accept(builder);
+		callback.accept(new ItemTooltipData(filter == null || filter.isEmpty() || filter.kjs$isWildcard() ? Optional.empty() : Optional.of(filter), requirements, List.copyOf(builder.actions)));
+	}
+
+	public void modify(Ingredient filter, TooltipRequirements requirements, Consumer<TooltipActionBuilder> consumer) {
+		modify(filter, Optional.ofNullable(requirements), consumer);
+	}
+
+	public void modify(Ingredient filter, Consumer<TooltipActionBuilder> consumer) {
+		modify(filter, Optional.empty(), consumer);
+	}
+
+	public void modifyAll(TooltipRequirements requirements, Consumer<TooltipActionBuilder> consumer) {
+		modify(null, Optional.ofNullable(requirements), consumer);
+	}
+
+	public void modifyAll(Consumer<TooltipActionBuilder> consumer) {
+		modify(null, Optional.empty(), consumer);
+	}
+
+	public void add(Ingredient filter, List<Component> text) {
+		modify(filter, builder -> builder.add(text));
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/neoforge/KubeJSNeoForgeClient.java b/src/main/java/dev/latvian/mods/kubejs/neoforge/KubeJSNeoForgeClient.java
index d24155663..43454e4b6 100644
--- a/src/main/java/dev/latvian/mods/kubejs/neoforge/KubeJSNeoForgeClient.java
+++ b/src/main/java/dev/latvian/mods/kubejs/neoforge/KubeJSNeoForgeClient.java
@@ -20,9 +20,11 @@
 import dev.latvian.mods.kubejs.gui.KubeJSScreen;
 import dev.latvian.mods.kubejs.item.ItemBuilder;
 import dev.latvian.mods.kubejs.item.ItemModelPropertiesKubeEvent;
+import dev.latvian.mods.kubejs.item.ModifyItemTooltipsKubeEvent;
 import dev.latvian.mods.kubejs.kubedex.KubedexHighlight;
 import dev.latvian.mods.kubejs.registry.RegistryObjectStorage;
 import dev.latvian.mods.kubejs.script.ScriptType;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
 import dev.latvian.mods.kubejs.util.ID;
 import net.minecraft.client.KeyMapping;
 import net.minecraft.client.renderer.ItemBlockRenderTypes;
@@ -45,6 +47,8 @@
 import org.lwjgl.glfw.GLFW;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 
 @EventBusSubscriber(modid = KubeJS.MOD_ID, value = Dist.CLIENT, bus = EventBusSubscriber.Bus.MOD)
 public class KubeJSNeoForgeClient {
@@ -93,6 +97,10 @@ private static void setupClient0() {
 				}
 			}
 		}
+
+		var list = new ArrayList<ItemTooltipData>();
+		ItemEvents.MODIFY_TOOLTIPS.post(ScriptType.CLIENT, new ModifyItemTooltipsKubeEvent(list::add));
+		KubeJSClient.clientItemTooltips = List.copyOf(list);
 	}
 
 	@SubscribeEvent
diff --git a/src/main/java/dev/latvian/mods/kubejs/net/KubeJSNet.java b/src/main/java/dev/latvian/mods/kubejs/net/KubeJSNet.java
index ec6f1f45e..ebc25f738 100644
--- a/src/main/java/dev/latvian/mods/kubejs/net/KubeJSNet.java
+++ b/src/main/java/dev/latvian/mods/kubejs/net/KubeJSNet.java
@@ -25,7 +25,7 @@ private static <T extends CustomPacketPayload> CustomPacketPayload.Type<T> type(
 	CustomPacketPayload.Type<RequestInventoryKubedexPayload> REQUEST_INVENTORY_KUBEDEX = type("request_inventory_kubedex");
 	CustomPacketPayload.Type<RequestBlockKubedexPayload> REQUEST_BLOCK_KUBEDEX = type("request_block_kubedex");
 	CustomPacketPayload.Type<RequestEntityKubedexPayload> REQUEST_ENTITY_KUBEDEX = type("request_entity_kubedex");
-	CustomPacketPayload.Type<SyncRecipeViewerDataPayload> SYNC_RECIPE_VIEWER = type("sync_recipe_viewer");
+	CustomPacketPayload.Type<SyncServerDataPayload> SYNC_SERVER_DATA = type("sync_server_data");
 	CustomPacketPayload.Type<SetActivePostShaderPayload> SET_ACTIVE_POST_SHADER = type("set_active_post_shader");
 
 	@SubscribeEvent
@@ -47,7 +47,7 @@ static void register(RegisterPayloadHandlersEvent event) {
 		reg.playToServer(REQUEST_INVENTORY_KUBEDEX, RequestInventoryKubedexPayload.STREAM_CODEC, RequestInventoryKubedexPayload::handle);
 		reg.playToServer(REQUEST_BLOCK_KUBEDEX, RequestBlockKubedexPayload.STREAM_CODEC, RequestBlockKubedexPayload::handle);
 		reg.playToServer(REQUEST_ENTITY_KUBEDEX, RequestEntityKubedexPayload.STREAM_CODEC, RequestEntityKubedexPayload::handle);
-		reg.playToClient(SYNC_RECIPE_VIEWER, SyncRecipeViewerDataPayload.STREAM_CODEC, SyncRecipeViewerDataPayload::handle);
+		reg.playToClient(SYNC_SERVER_DATA, SyncServerDataPayload.STREAM_CODEC, SyncServerDataPayload::handle);
 		reg.playToClient(SET_ACTIVE_POST_SHADER, SetActivePostShaderPayload.STREAM_CODEC, SetActivePostShaderPayload::handle);
 	}
 }
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/net/KubeServerData.java b/src/main/java/dev/latvian/mods/kubejs/net/KubeServerData.java
new file mode 100644
index 000000000..97a2359e5
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/net/KubeServerData.java
@@ -0,0 +1,35 @@
+package dev.latvian.mods.kubejs.net;
+
+import dev.latvian.mods.kubejs.bindings.event.ItemEvents;
+import dev.latvian.mods.kubejs.item.ModifyItemTooltipsKubeEvent;
+import dev.latvian.mods.kubejs.recipe.viewer.server.RecipeViewerData;
+import dev.latvian.mods.kubejs.script.ScriptType;
+import dev.latvian.mods.kubejs.tooltip.ItemTooltipData;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public record KubeServerData(
+	Optional<RecipeViewerData> recipeViewerData,
+	List<ItemTooltipData> itemTooltipData
+) {
+	public static final StreamCodec<RegistryFriendlyByteBuf, KubeServerData> STREAM_CODEC = StreamCodec.composite(
+		ByteBufCodecs.optional(RecipeViewerData.STREAM_CODEC), KubeServerData::recipeViewerData,
+		ItemTooltipData.STREAM_CODEC.apply(ByteBufCodecs.list()), KubeServerData::itemTooltipData,
+		KubeServerData::new
+	);
+
+	public static KubeServerData collect() {
+		var itemTooltipData = new ArrayList<ItemTooltipData>();
+		ItemEvents.MODIFY_TOOLTIPS.post(ScriptType.SERVER, new ModifyItemTooltipsKubeEvent(itemTooltipData::add));
+
+		return new KubeServerData(
+			Optional.ofNullable(RecipeViewerData.collect()),
+			List.copyOf(itemTooltipData)
+		);
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/net/SyncRecipeViewerDataPayload.java b/src/main/java/dev/latvian/mods/kubejs/net/SyncRecipeViewerDataPayload.java
deleted file mode 100644
index 810dbe020..000000000
--- a/src/main/java/dev/latvian/mods/kubejs/net/SyncRecipeViewerDataPayload.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package dev.latvian.mods.kubejs.net;
-
-import dev.latvian.mods.kubejs.recipe.viewer.server.RecipeViewerData;
-import dev.latvian.mods.kubejs.recipe.viewer.server.RemoteRecipeViewerDataUpdatedEvent;
-import net.minecraft.network.RegistryFriendlyByteBuf;
-import net.minecraft.network.codec.ByteBufCodecs;
-import net.minecraft.network.codec.StreamCodec;
-import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
-import net.neoforged.neoforge.common.NeoForge;
-import net.neoforged.neoforge.network.handling.IPayloadContext;
-
-import java.util.Optional;
-
-public record SyncRecipeViewerDataPayload(Optional<RecipeViewerData> data) implements CustomPacketPayload {
-	public static final StreamCodec<RegistryFriendlyByteBuf, SyncRecipeViewerDataPayload> STREAM_CODEC = ByteBufCodecs.optional(RecipeViewerData.STREAM_CODEC).map(SyncRecipeViewerDataPayload::new, SyncRecipeViewerDataPayload::data);
-
-	@Override
-	public Type<?> type() {
-		return KubeJSNet.SYNC_RECIPE_VIEWER;
-	}
-
-	public void handle(IPayloadContext ctx) {
-		ctx.enqueueWork(() -> {
-			RecipeViewerData.remote = data.orElse(null);
-			NeoForge.EVENT_BUS.post(new RemoteRecipeViewerDataUpdatedEvent(RecipeViewerData.remote));
-		});
-	}
-}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/net/SyncServerDataPayload.java b/src/main/java/dev/latvian/mods/kubejs/net/SyncServerDataPayload.java
new file mode 100644
index 000000000..45c72c26c
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/net/SyncServerDataPayload.java
@@ -0,0 +1,20 @@
+package dev.latvian.mods.kubejs.net;
+
+import dev.latvian.mods.kubejs.KubeJS;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+import net.neoforged.neoforge.network.handling.IPayloadContext;
+
+public record SyncServerDataPayload(KubeServerData data) implements CustomPacketPayload {
+	public static final StreamCodec<RegistryFriendlyByteBuf, SyncServerDataPayload> STREAM_CODEC = KubeServerData.STREAM_CODEC.map(SyncServerDataPayload::new, SyncServerDataPayload::data);
+
+	@Override
+	public Type<?> type() {
+		return KubeJSNet.SYNC_SERVER_DATA;
+	}
+
+	public void handle(IPayloadContext ctx) {
+		ctx.enqueueWork(() -> KubeJS.PROXY.updateServerData(data));
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/dev/latvian/mods/kubejs/player/KubeJSPlayerEventHandler.java b/src/main/java/dev/latvian/mods/kubejs/player/KubeJSPlayerEventHandler.java
index ed0d5d2d3..3f96b1d33 100644
--- a/src/main/java/dev/latvian/mods/kubejs/player/KubeJSPlayerEventHandler.java
+++ b/src/main/java/dev/latvian/mods/kubejs/player/KubeJSPlayerEventHandler.java
@@ -3,7 +3,6 @@
 import dev.latvian.mods.kubejs.CommonProperties;
 import dev.latvian.mods.kubejs.KubeJS;
 import dev.latvian.mods.kubejs.bindings.event.PlayerEvents;
-import dev.latvian.mods.kubejs.net.SyncRecipeViewerDataPayload;
 import dev.latvian.mods.kubejs.script.ConsoleJS;
 import dev.latvian.mods.kubejs.script.ScriptType;
 import net.minecraft.resources.ResourceKey;
@@ -22,13 +21,11 @@
 import net.neoforged.neoforge.event.tick.PlayerTickEvent;
 import net.neoforged.neoforge.network.PacketDistributor;
 
-import java.util.Optional;
-
 @EventBusSubscriber(modid = KubeJS.MOD_ID)
 public class KubeJSPlayerEventHandler {
 	@SubscribeEvent(priority = EventPriority.HIGH)
 	public static void datapackSync(OnDatapackSyncEvent event) {
-		var payload = new SyncRecipeViewerDataPayload(Optional.ofNullable(event.getPlayerList().getServer().getServerResources().managers().kjs$getServerScriptManager().recipeViewerData));
+		var payload = event.getPlayerList().getServer().getServerResources().managers().kjs$getServerScriptManager().serverData;
 		event.getRelevantPlayers().forEach(player -> PacketDistributor.sendToPlayer(player, payload));
 	}
 
diff --git a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/JsonRecipeSchemaLoader.java b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/JsonRecipeSchemaLoader.java
index a0ba3fc30..70690b87d 100644
--- a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/JsonRecipeSchemaLoader.java
+++ b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/JsonRecipeSchemaLoader.java
@@ -187,25 +187,38 @@ private RecipeSchema getSchema(DynamicOps<JsonElement> jsonOps) {
 					// schema.constructors(constructors.toArray(new RecipeConstructor[0]));
 
 					for (var entry : functionMap.entrySet()) {
+						var funcName = entry.getKey();
 						var funcJson = entry.getValue().json;
 
 						if (funcJson.has("set")) {
 							Map<RecipeKey<?>, Object> map = new HashMap<>(1);
 
 							for (var entry1 : funcJson.getAsJsonObject("set").entrySet()) {
-								var key = keyMap.get(entry1.getKey());
+								var keyName = entry1.getKey();
+								var key = keyMap.get(keyName);
 
 								if (key != null) {
 									map.put(key, key.codec.decode(jsonOps, entry1.getValue()).getOrThrow().getFirst());
 								} else {
-									throw new NullPointerException("Key '" + entry1.getKey() + "' not found in function '" + entry1.getKey() + "' of recipe schema '" + id + "'");
+									throw new NullPointerException("Key '" + keyName + "' not found in function '" + funcName + "' of recipe schema '" + id + "'");
 								}
 							}
 
 							if (map.size() == 1) {
-								schema.function(entry.getKey(), new RecipeSchemaFunction.SetFunction(map.keySet().iterator().next(), map.values().iterator().next()));
+								schema.function(funcName, new RecipeSchemaFunction.SetFunction(map.keySet().iterator().next(), map.values().iterator().next()));
 							} else if (!map.isEmpty()) {
-								schema.function(entry.getKey(), new RecipeSchemaFunction.SetManyFunction(map));
+								schema.function(funcName, new RecipeSchemaFunction.SetManyFunction(map));
+							}
+						}
+
+						if (funcJson.has("add_to_list")) {
+							var keyName = funcJson.get("add_to_list").getAsString();
+							var key = keyMap.get(keyName);
+
+							if (key != null) {
+								schema.function(funcName, new RecipeSchemaFunction.AddToListFunction<>((RecipeKey) key));
+							} else {
+								throw new NullPointerException("Key '" + keyName + "' not found in function '" + funcName + "' of recipe schema '" + id + "'");
 							}
 						}
 					}
diff --git a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchema.java b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchema.java
index eef86b867..d224e25e6 100644
--- a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchema.java
+++ b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchema.java
@@ -262,6 +262,10 @@ public <T> RecipeSchema setOpFunction(String name, RecipeKey<T> key, T value) {
 		return function(name, new RecipeSchemaFunction.SetFunction<>(key, value));
 	}
 
+	public <T> RecipeSchema addToListOpFunction(String name, RecipeKey<List<T>> key) {
+		return function(name, new RecipeSchemaFunction.AddToListFunction<>(key));
+	}
+
 	public <T> RecipeKey<T> getKey(String id) {
 		for (var key : keys) {
 			if (key.name.equals(id)) {
diff --git a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchemaFunction.java b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchemaFunction.java
index 1823e2a37..82a958c92 100644
--- a/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchemaFunction.java
+++ b/src/main/java/dev/latvian/mods/kubejs/recipe/schema/RecipeSchemaFunction.java
@@ -8,6 +8,7 @@
 import dev.latvian.mods.rhino.Scriptable;
 import dev.latvian.mods.rhino.type.TypeInfo;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
@@ -79,4 +80,14 @@ public void execute(Context cx, KubeRecipe recipe, Object[] args) {
 			}
 		}
 	}
+
+	record AddToListFunction<T>(RecipeKey<List<T>> key) implements RecipeSchemaFunction {
+		@Override
+		public void execute(Context cx, KubeRecipe recipe, Object[] args) {
+			var value = recipe.getValue(key);
+			var list = value == null ? new ArrayList<T>() : new ArrayList<>(value);
+			list.addAll(key.component.wrap(cx, recipe, args));
+			recipe.setValue(key, list);
+		}
+	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/recipe/viewer/server/RecipeViewerData.java b/src/main/java/dev/latvian/mods/kubejs/recipe/viewer/server/RecipeViewerData.java
index f9faaf6af..0fdea88f8 100644
--- a/src/main/java/dev/latvian/mods/kubejs/recipe/viewer/server/RecipeViewerData.java
+++ b/src/main/java/dev/latvian/mods/kubejs/recipe/viewer/server/RecipeViewerData.java
@@ -28,8 +28,6 @@ public record RecipeViewerData(
 		RecipeViewerData::new
 	);
 
-	public static RecipeViewerData remote = null;
-
 	@Nullable
 	public static RecipeViewerData collect() {
 		var removedCategories = new HashSet<ResourceLocation>();
diff --git a/src/main/java/dev/latvian/mods/kubejs/server/ServerScriptManager.java b/src/main/java/dev/latvian/mods/kubejs/server/ServerScriptManager.java
index 488dea07e..acb42cd48 100644
--- a/src/main/java/dev/latvian/mods/kubejs/server/ServerScriptManager.java
+++ b/src/main/java/dev/latvian/mods/kubejs/server/ServerScriptManager.java
@@ -7,13 +7,14 @@
 import dev.latvian.mods.kubejs.bindings.event.ServerEvents;
 import dev.latvian.mods.kubejs.core.RecipeManagerKJS;
 import dev.latvian.mods.kubejs.error.KubeRuntimeException;
+import dev.latvian.mods.kubejs.net.KubeServerData;
+import dev.latvian.mods.kubejs.net.SyncServerDataPayload;
 import dev.latvian.mods.kubejs.plugin.KubeJSPlugin;
 import dev.latvian.mods.kubejs.plugin.KubeJSPlugins;
 import dev.latvian.mods.kubejs.recipe.CompostableRecipesKubeEvent;
 import dev.latvian.mods.kubejs.recipe.RecipesKubeEvent;
 import dev.latvian.mods.kubejs.recipe.schema.RecipeSchemaStorage;
 import dev.latvian.mods.kubejs.recipe.special.SpecialRecipeSerializerManager;
-import dev.latvian.mods.kubejs.recipe.viewer.server.RecipeViewerData;
 import dev.latvian.mods.kubejs.registry.AdditionalObjectRegistry;
 import dev.latvian.mods.kubejs.registry.BuilderBase;
 import dev.latvian.mods.kubejs.registry.RegistryObjectStorage;
@@ -87,7 +88,7 @@ public static ServerScriptManager release() {
 
 	public final Map<ResourceKey<?>, PreTagKubeEvent> preTagEvents;
 	public final RecipeSchemaStorage recipeSchemaStorage;
-	public RecipeViewerData recipeViewerData;
+	public SyncServerDataPayload serverData;
 	public final VirtualDataPack internalDataPack;
 	public final VirtualDataPack registriesDataPack;
 	public final Map<GeneratedDataStage, VirtualDataPack> virtualPacks;
@@ -97,7 +98,7 @@ private ServerScriptManager() {
 		super(ScriptType.SERVER);
 		this.preTagEvents = new ConcurrentHashMap<>();
 		this.recipeSchemaStorage = new RecipeSchemaStorage();
-		this.recipeViewerData = null;
+		this.serverData = null;
 
 		this.internalDataPack = new VirtualDataPack(GeneratedDataStage.INTERNAL);
 		this.registriesDataPack = new VirtualDataPack(GeneratedDataStage.REGISTRIES);
@@ -202,7 +203,7 @@ public void reload() {
 			pack.reset();
 		}
 
-		recipeViewerData = null;
+		serverData = null;
 
 		super.reload();
 
@@ -232,7 +233,7 @@ public boolean recipes(RecipeManagerKJS recipeManager, ResourceManager resourceM
 			result = true;
 		}
 
-		recipeViewerData = RecipeViewerData.collect();
+		serverData = new SyncServerDataPayload(KubeServerData.collect());
 		RecipesKubeEvent.TEMP_ITEM_TAG_LOOKUP.setValue(null);
 		return result;
 	}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemStartupTooltipsKubeEvent.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemStartupTooltipsKubeEvent.java
new file mode 100644
index 000000000..ae7201f23
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemStartupTooltipsKubeEvent.java
@@ -0,0 +1,9 @@
+package dev.latvian.mods.kubejs.tooltip;
+
+import dev.latvian.mods.kubejs.event.KubeStartupEvent;
+import dev.latvian.mods.kubejs.item.ItemPredicate;
+
+public class ItemStartupTooltipsKubeEvent implements KubeStartupEvent {
+	public void modify(ItemPredicate filter) {
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemTooltipData.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemTooltipData.java
new file mode 100644
index 000000000..812b3ac06
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/ItemTooltipData.java
@@ -0,0 +1,23 @@
+package dev.latvian.mods.kubejs.tooltip;
+
+import dev.latvian.mods.kubejs.tooltip.action.TooltipAction;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.world.item.crafting.Ingredient;
+
+import java.util.List;
+import java.util.Optional;
+
+public record ItemTooltipData(
+	Optional<Ingredient> filter,
+	Optional<TooltipRequirements> requirements,
+	List<TooltipAction> actions
+) {
+	public static final StreamCodec<RegistryFriendlyByteBuf, ItemTooltipData> STREAM_CODEC = StreamCodec.composite(
+		ByteBufCodecs.optional(Ingredient.CONTENTS_STREAM_CODEC), ItemTooltipData::filter,
+		ByteBufCodecs.optional(TooltipRequirements.STREAM_CODEC), ItemTooltipData::requirements,
+		TooltipAction.STREAM_CODEC.apply(ByteBufCodecs.list()), ItemTooltipData::actions,
+		ItemTooltipData::new
+	);
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipActionBuilder.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipActionBuilder.java
new file mode 100644
index 000000000..5a17abecd
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipActionBuilder.java
@@ -0,0 +1,43 @@
+package dev.latvian.mods.kubejs.tooltip;
+
+import dev.latvian.mods.kubejs.tooltip.action.AddTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.DynamicTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.InsertTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.RemoveExactTextTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.RemoveLineTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.RemoveTextTooltipAction;
+import dev.latvian.mods.kubejs.tooltip.action.TooltipAction;
+import dev.latvian.mods.rhino.util.HideFromJS;
+import net.minecraft.network.chat.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TooltipActionBuilder {
+	@HideFromJS
+	public List<TooltipAction> actions = new ArrayList<>(1);
+
+	public void dynamic(String id) {
+		actions.add(new DynamicTooltipAction(id));
+	}
+
+	public void add(List<Component> text) {
+		actions.add(new AddTooltipAction(text));
+	}
+
+	public void insert(int line, List<Component> text) {
+		actions.add(new InsertTooltipAction(line, text));
+	}
+
+	public void removeLine(int line) {
+		actions.add(new RemoveLineTooltipAction(line));
+	}
+
+	public void removeText(Component match) {
+		actions.add(new RemoveTextTooltipAction(match));
+	}
+
+	public void removeExactText(Component match) {
+		actions.add(new RemoveExactTextTooltipAction(match));
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipRequirements.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipRequirements.java
new file mode 100644
index 000000000..cee2e4f6c
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/TooltipRequirements.java
@@ -0,0 +1,37 @@
+package dev.latvian.mods.kubejs.tooltip;
+
+import dev.latvian.mods.kubejs.util.Tristate;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public record TooltipRequirements(
+	Tristate shift,
+	Tristate ctrl,
+	Tristate alt,
+	Tristate advanced,
+	Tristate creative,
+	Map<String, Tristate> stages
+) {
+	public static final TooltipRequirements DEFAULT = new TooltipRequirements(
+		Tristate.DEFAULT,
+		Tristate.DEFAULT,
+		Tristate.DEFAULT,
+		Tristate.DEFAULT,
+		Tristate.DEFAULT,
+		Map.of()
+	);
+
+	public static final StreamCodec<RegistryFriendlyByteBuf, TooltipRequirements> STREAM_CODEC = StreamCodec.composite(
+		Tristate.STREAM_CODEC, TooltipRequirements::shift,
+		Tristate.STREAM_CODEC, TooltipRequirements::ctrl,
+		Tristate.STREAM_CODEC, TooltipRequirements::alt,
+		Tristate.STREAM_CODEC, TooltipRequirements::advanced,
+		Tristate.STREAM_CODEC, TooltipRequirements::creative,
+		ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, Tristate.STREAM_CODEC), TooltipRequirements::stages,
+		TooltipRequirements::new
+	);
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/AddTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/AddTooltipAction.java
new file mode 100644
index 000000000..d331ae306
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/AddTooltipAction.java
@@ -0,0 +1,21 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.ComponentSerialization;
+import net.minecraft.network.codec.ByteBufCodecs;
+
+import java.util.List;
+
+public record AddTooltipAction(List<Component> lines) implements TooltipAction {
+	public static final TooltipActionType<AddTooltipAction> TYPE = new TooltipActionType<>(1, ComponentSerialization.STREAM_CODEC.apply(ByteBufCodecs.list()).map(AddTooltipAction::new, AddTooltipAction::lines));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.addAll(lines);
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/DynamicTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/DynamicTooltipAction.java
new file mode 100644
index 000000000..5cdc5b56a
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/DynamicTooltipAction.java
@@ -0,0 +1,20 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.codec.ByteBufCodecs;
+
+import java.util.List;
+
+public record DynamicTooltipAction(String id) implements TooltipAction {
+	public static final TooltipActionType<DynamicTooltipAction> TYPE = new TooltipActionType<>(0, ByteBufCodecs.STRING_UTF8.map(DynamicTooltipAction::new, DynamicTooltipAction::id));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.add(Component.literal("Dynamic tooltip is not supported!").kjs$red());
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/InsertTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/InsertTooltipAction.java
new file mode 100644
index 000000000..358536fcf
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/InsertTooltipAction.java
@@ -0,0 +1,26 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.ComponentSerialization;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+
+import java.util.List;
+
+public record InsertTooltipAction(int line, List<Component> lines) implements TooltipAction {
+	public static final TooltipActionType<InsertTooltipAction> TYPE = new TooltipActionType<>(2, StreamCodec.composite(
+		ByteBufCodecs.VAR_INT, InsertTooltipAction::line,
+		ComponentSerialization.STREAM_CODEC.apply(ByteBufCodecs.list()), InsertTooltipAction::lines,
+		InsertTooltipAction::new
+	));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.addAll(line, lines);
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveExactTextTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveExactTextTooltipAction.java
new file mode 100644
index 000000000..8750564d3
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveExactTextTooltipAction.java
@@ -0,0 +1,20 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.ComponentSerialization;
+
+import java.util.List;
+
+public record RemoveExactTextTooltipAction(Component match) implements TooltipAction {
+	public static final TooltipActionType<RemoveExactTextTooltipAction> TYPE = new TooltipActionType<>(5, ComponentSerialization.STREAM_CODEC.map(RemoveExactTextTooltipAction::new, RemoveExactTextTooltipAction::match));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.removeIf(match::equals);
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveLineTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveLineTooltipAction.java
new file mode 100644
index 000000000..3849ead90
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveLineTooltipAction.java
@@ -0,0 +1,20 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.codec.ByteBufCodecs;
+
+import java.util.List;
+
+public record RemoveLineTooltipAction(int line) implements TooltipAction {
+	public static final TooltipActionType<RemoveLineTooltipAction> TYPE = new TooltipActionType<>(3, ByteBufCodecs.VAR_INT.map(RemoveLineTooltipAction::new, RemoveLineTooltipAction::line));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.remove(line);
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveTextTooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveTextTooltipAction.java
new file mode 100644
index 000000000..c0c049a81
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/RemoveTextTooltipAction.java
@@ -0,0 +1,35 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.ComponentContents;
+import net.minecraft.network.chat.ComponentSerialization;
+
+import java.util.List;
+
+public record RemoveTextTooltipAction(Component match) implements TooltipAction {
+	public static final TooltipActionType<RemoveTextTooltipAction> TYPE = new TooltipActionType<>(4, ComponentSerialization.STREAM_CODEC.map(RemoveTextTooltipAction::new, RemoveTextTooltipAction::match));
+
+	@Override
+	public TooltipActionType<?> type() {
+		return TYPE;
+	}
+
+	@Override
+	public void apply(List<Component> tooltip) {
+		tooltip.removeIf(component -> RemoveTextTooltipAction.equals(component, match.getContents()));
+	}
+
+	private static boolean equals(Component c, ComponentContents contents) {
+		if (c.getContents().equals(contents)) {
+			return true;
+		}
+
+		for (var s : c.getSiblings()) {
+			if (equals(s, contents)) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipAction.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipAction.java
new file mode 100644
index 000000000..fac791b49
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipAction.java
@@ -0,0 +1,41 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import dev.latvian.mods.kubejs.util.Cast;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.codec.StreamCodec;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public interface TooltipAction {
+	Map<Integer, TooltipActionType<?>> MAP = Stream.of(
+		DynamicTooltipAction.TYPE,
+		AddTooltipAction.TYPE,
+		InsertTooltipAction.TYPE,
+		RemoveLineTooltipAction.TYPE,
+		RemoveTextTooltipAction.TYPE,
+		RemoveExactTextTooltipAction.TYPE
+	).collect(Collectors.toMap(TooltipActionType::type, Function.identity()));
+
+	StreamCodec<RegistryFriendlyByteBuf, TooltipAction> STREAM_CODEC = new StreamCodec<>() {
+		@Override
+		public TooltipAction decode(RegistryFriendlyByteBuf buf) {
+			int id = buf.readByte();
+			return MAP.get(id).streamCodec().decode(buf);
+		}
+
+		@Override
+		public void encode(RegistryFriendlyByteBuf buf, TooltipAction value) {
+			buf.writeByte(value.type().type());
+			value.type().streamCodec().encode(buf, Cast.to(value));
+		}
+	};
+
+	TooltipActionType<?> type();
+
+	void apply(List<Component> tooltip);
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipActionType.java b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipActionType.java
new file mode 100644
index 000000000..00dbcb510
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/tooltip/action/TooltipActionType.java
@@ -0,0 +1,7 @@
+package dev.latvian.mods.kubejs.tooltip.action;
+
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+
+public record TooltipActionType<T extends TooltipAction>(int type, StreamCodec<? super RegistryFriendlyByteBuf, ? extends T> streamCodec) {
+}
diff --git a/src/main/java/dev/latvian/mods/kubejs/util/RecordDefaults.java b/src/main/java/dev/latvian/mods/kubejs/util/RecordDefaults.java
index bba4f59cc..2280c27f2 100644
--- a/src/main/java/dev/latvian/mods/kubejs/util/RecordDefaults.java
+++ b/src/main/java/dev/latvian/mods/kubejs/util/RecordDefaults.java
@@ -41,5 +41,6 @@ public static void init() {
 		add(DataComponentPredicate.class, DataComponentPredicate.EMPTY);
 		add(EntityPredicate.LocationWrapper.class, new EntityPredicate.LocationWrapper(Optional.empty(), Optional.empty(), Optional.empty()));
 		add(GameTypePredicate.class, GameTypePredicate.ANY);
+		add(Tristate.class, Tristate.DEFAULT);
 	}
 }
diff --git a/src/main/java/dev/latvian/mods/kubejs/util/Tristate.java b/src/main/java/dev/latvian/mods/kubejs/util/Tristate.java
new file mode 100644
index 000000000..10366cc67
--- /dev/null
+++ b/src/main/java/dev/latvian/mods/kubejs/util/Tristate.java
@@ -0,0 +1,57 @@
+package dev.latvian.mods.kubejs.util;
+
+import com.mojang.datafixers.util.Either;
+import com.mojang.serialization.Codec;
+import io.netty.buffer.ByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.util.StringRepresentable;
+
+import java.util.function.BooleanSupplier;
+
+public enum Tristate implements StringRepresentable {
+	FALSE("false"),
+	TRUE("true"),
+	DEFAULT("default");
+
+	public static final Tristate[] VALUES = values();
+
+	public static final Codec<Tristate> CODEC = Codec.either(Codec.BOOL, Codec.unit("default")).xmap(
+		either -> either.map(b -> b ? TRUE : FALSE, s -> s.equalsIgnoreCase("true") ? TRUE : s.equalsIgnoreCase("false") ? FALSE : DEFAULT),
+		t -> t == DEFAULT ? Either.right("default") : Either.left(t == TRUE)
+	);
+
+	public static final StreamCodec<ByteBuf, Tristate> STREAM_CODEC = ByteBufCodecs.idMapper(i -> VALUES[i], Enum::ordinal);
+
+	public static Tristate wrap(Object from) {
+		return switch (from) {
+			case null -> DEFAULT;
+			case Tristate t -> t;
+			case Boolean b -> b ? TRUE : FALSE;
+			default -> switch (from.toString().toLowerCase()) {
+				case "true" -> TRUE;
+				case "false" -> FALSE;
+				default -> DEFAULT;
+			};
+		};
+	}
+
+	public final String name;
+
+	Tristate(String name) {
+		this.name = name;
+	}
+
+	@Override
+	public String getSerializedName() {
+		return name;
+	}
+
+	public boolean test(boolean enabled) {
+		return this == DEFAULT || (this == TRUE) == enabled;
+	}
+
+	public boolean test(BooleanSupplier enabled) {
+		return this == DEFAULT || (this == TRUE) == enabled.getAsBoolean();
+	}
+}