From 43ae1b613a320d479ea20dacae8fa107ddfce8b2 Mon Sep 17 00:00:00 2001 From: slprime <31038811+slprime@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:43:22 +0300 Subject: [PATCH] Add recipe origins to handler debug widget (#1072) --- .../codechicken/nei/asm/NEITransformer.java | 46 +++ .../nei/recipe/debug/CheckboxWidget.java | 1 + .../nei/recipe/debug/DebugHandlerWidget.java | 90 ++++- .../nei/recipe/debug/RecipeOrigins.java | 317 ++++++++++++++++++ src/main/resources/assets/nei/lang/en_US.lang | 2 + 5 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 src/main/java/codechicken/nei/recipe/debug/RecipeOrigins.java diff --git a/src/main/java/codechicken/nei/asm/NEITransformer.java b/src/main/java/codechicken/nei/asm/NEITransformer.java index 485f3178..60f6db8a 100644 --- a/src/main/java/codechicken/nei/asm/NEITransformer.java +++ b/src/main/java/codechicken/nei/asm/NEITransformer.java @@ -14,6 +14,7 @@ import static org.objectweb.asm.Opcodes.ILOAD; import static org.objectweb.asm.Opcodes.INVOKESPECIAL; import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL; import static org.objectweb.asm.Opcodes.IRETURN; +import static org.objectweb.asm.Opcodes.NEW; import java.util.Map; @@ -22,11 +23,14 @@ import net.minecraft.launchwrapper.IClassTransformer; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; import org.objectweb.asm.tree.VarInsnNode; import codechicken.lib.asm.ASMBlock; @@ -98,6 +102,48 @@ public class NEITransformer implements IClassTransformer { } }); + if (Boolean.getBoolean("nei.recipeOrigins")) { + // Replace the plain ArrayList CraftingManager's constructor builds its recipe list with, so every recipe + // added anywhere (including vanilla's own, added in that same constructor) is observed for + // codechicken.nei.recipe.debug.RecipeOrigins. Must be done at classload time, since by the time any mod + // code runs vanilla has already populated the list. Capturing a stack trace per recipe is slow, so this is + // opt-in via -Dnei.recipeOrigins=true. + transformer.add( + new MethodTransformer( + new ObfMapping("net/minecraft/item/crafting/CraftingManager", "", "()V")) { + + @Override + public void transform(MethodNode mv) { + ASMHelper.logger.debug("NEI: Hooking CraftingManager recipe list for origin tracking"); + + TypeInsnNode newInsn = null; + MethodInsnNode initInsn = null; + + // only the first ArrayList construction, the recipe list field + for (AbstractInsnNode insn : mv.instructions.toArray()) { + if (newInsn == null) { + if (insn.getOpcode() == NEW + && "java/util/ArrayList".equals(((TypeInsnNode) insn).desc)) { + newInsn = (TypeInsnNode) insn; + } + } else if (insn.getOpcode() == INVOKESPECIAL + && "java/util/ArrayList".equals(((MethodInsnNode) insn).owner) + && "".equals(((MethodInsnNode) insn).name)) { + initInsn = (MethodInsnNode) insn; + break; + } + } + + if (newInsn == null || initInsn == null) { + ASMHelper.logger.warn("NEI: CraftingManager recipe list not found, origins disabled"); + return; + } + + newInsn.desc = initInsn.owner = "codechicken/nei/recipe/debug/RecipeOrigins$TrackingList"; + } + }); + } + // put glint alpha into the buffer correctly for exporting transformer.add( new MethodReplacer( diff --git a/src/main/java/codechicken/nei/recipe/debug/CheckboxWidget.java b/src/main/java/codechicken/nei/recipe/debug/CheckboxWidget.java index 8d9148d5..0b516c37 100644 --- a/src/main/java/codechicken/nei/recipe/debug/CheckboxWidget.java +++ b/src/main/java/codechicken/nei/recipe/debug/CheckboxWidget.java @@ -25,6 +25,7 @@ public class CheckboxWidget extends Button implements IUpdatableWidget { @Override public boolean onButtonPress(boolean rightclick) { + if (this.state == 2) return false; this.value = !this.value; onChange.accept(this); return true; diff --git a/src/main/java/codechicken/nei/recipe/debug/DebugHandlerWidget.java b/src/main/java/codechicken/nei/recipe/debug/DebugHandlerWidget.java index 70f64a57..6e6f0757 100644 --- a/src/main/java/codechicken/nei/recipe/debug/DebugHandlerWidget.java +++ b/src/main/java/codechicken/nei/recipe/debug/DebugHandlerWidget.java @@ -30,19 +30,24 @@ import codechicken.nei.ClientHandler; import codechicken.nei.NEIClientConfig; import codechicken.nei.NEIClientUtils; import codechicken.nei.Widget; +import codechicken.nei.api.API; import codechicken.nei.drawable.DrawableBuilder; import codechicken.nei.drawable.DrawableResource; +import codechicken.nei.guihook.GuiContainerManager; +import codechicken.nei.guihook.IContainerTooltipHandler; import codechicken.nei.recipe.GuiRecipe; import codechicken.nei.recipe.GuiRecipeButton; import codechicken.nei.recipe.GuiRecipeTab; import codechicken.nei.recipe.HandlerInfo; import codechicken.nei.recipe.IRecipeHandler; +import codechicken.nei.recipe.RecipeHandlerRef; +import codechicken.nei.recipe.ShapedRecipeHandler; import codechicken.nei.recipe.TemplateRecipeHandler; import codechicken.nei.recipe.TemplateRecipeHandler.RecipeTransferRect; import codechicken.nei.recipe.widget.RecipeWidget; import codechicken.nei.scroll.ScrollContainer; -public class DebugHandlerWidget extends Widget { +public class DebugHandlerWidget extends Widget implements IContainerTooltipHandler { private static class HandlerInfoRecord { @@ -152,6 +157,9 @@ public class DebugHandlerWidget extends Widget { private Map patches = new HashMap<>(); private HandlerInfoRecord record; + private RecipeWidget hoveredRecipe; + private boolean duplicatesOnly = false; + private String duplicatesHandlerId; private Point dragPoint = null; private boolean showWidget = false; @@ -166,7 +174,9 @@ public class DebugHandlerWidget extends Widget { private static final int BORDER_PADDING = 6; private static final int TRANSPARENCY_BORDER = 4; - public DebugHandlerWidget() { + protected DebugHandlerWidget() { + GuiContainerManager.addTooltipHandler(this); + this.w = WINDOW_WIDTH; this.x = 10; this.y = 50; @@ -242,6 +252,20 @@ public class DebugHandlerWidget extends Widget { saveHandlerInfoPatch(); }, y); + if (Boolean.getBoolean("nei.recipeOrigins")) { + y = addCheckboxWidget("DuplicatesOnly", button -> { + this.duplicatesOnly = button.value; + + if (NEIClientUtils.getGuiContainer() instanceof GuiReciperecipe) { + this.duplicatesHandlerId = getHandlerID(recipe.getHandler()); + recipe.forceRefreshPage(); + } + }, y + LINE_HEIGHT / 2); + + // only the handler the checkbox was toggled on is filtered, so switching handlers does not pay for a rescan + API.addRecipeFilter(() -> this.duplicatesOnly ? this::isDuplicate : null); + } + this.h = 10 + LINE_HEIGHT + y; this.container.h = this.h - INLINE_PADDING * 2 - LINE_HEIGHT; } @@ -417,6 +441,11 @@ public class DebugHandlerWidget extends Widget { updateOverride(); + if (this.values.get("duplicatesonly") instanceof CheckboxWidget duplicates) { + duplicates.state = handler instanceof ShapedRecipeHandler ? 0 : 2; + resetDuplicatesOnly(); + } + this.container.removeIf( widget -> this.values.get("height") == widget || this.values.get("height_readonly") == widget); if (handler.getRecipeHeight(0) > 0) { @@ -430,11 +459,26 @@ public class DebugHandlerWidget extends Widget { } else { this.record = null; this.showWidget = false; + resetDuplicatesOnly(); } this.container.update(); } + private void resetDuplicatesOnly() { + if (!this.duplicatesOnly) return; + + this.duplicatesOnly = false; + + if (this.values.get("duplicatesonly") instanceof CheckboxWidget duplicates) { + duplicates.updateValue("false"); + } + + if (NEIClientUtils.getGuiContainer() instanceof GuiReciperecipe) { + recipe.forceRefreshPage(); + } + } + @Override public List handleTooltip(int mx, int my, List tooltip) { return this.record != null ? this.container.handleTooltip(mx, my, tooltip) : tooltip; @@ -532,9 +576,51 @@ public class DebugHandlerWidget extends Widget { } } + @Override + public List handleTooltip(GuiContainer gui, int mousex, int mousey, List tooltip) { + final RecipeWidget widget = this.hoveredRecipe; + this.hoveredRecipe = null; + + if (widget == null || !this.showWidget || !tooltip.isEmpty()) { + return tooltip; + } + + final RecipeHandlerRef ref = widget.getRecipeHandlerRef(); + + for (List origin : RecipeOrigins.findOrigins(ref.handler, ref.recipeIndex)) { + addOriginLines(tooltip, origin); + } + + return tooltip; + } + + private static void addOriginLines(List tooltip, List lines) { + tooltip.add( + EnumChatFormatting.WHITE + NEIClientUtils.translate("debug.RecipeHandler.RegisteredBy", lines.get(0)) + + GuiDraw.TOOLTIP_LINESPACE); + + for (int i = 1; i < lines.size(); i++) { + tooltip.add(EnumChatFormatting.GRAY + lines.get(i)); + } + + tooltip.add(""); + } + + // only the handler the checkbox was toggled on is filtered + private boolean isDuplicate(IRecipeHandler handler, int recipeIndex) { + return !getHandlerID(handler).equals(this.duplicatesHandlerId) + || RecipeOrigins.hasDuplicates(handler, recipeIndex); + } + public void drawGuiPlaceholder(RecipeWidget widget) { if (!this.showWidget) return; + final Point mouse = GuiDraw.getMousePosition(); + if (widget.getRecipeHandlerRef().handler instanceof ShapedRecipeHandler && widget.contains(mouse.x, mouse.y) + && widget.getStackMouseOver(mouse.x, mouse.y) == null) { + this.hoveredRecipe = widget; + } + NEIClientUtils.gl2DRenderContext(() -> { // background diff --git a/src/main/java/codechicken/nei/recipe/debug/RecipeOrigins.java b/src/main/java/codechicken/nei/recipe/debug/RecipeOrigins.java new file mode 100644 index 00000000..bfdb0bc5 --- /dev/null +++ b/src/main/java/codechicken/nei/recipe/debug/RecipeOrigins.java @@ -0,0 +1,317 @@ +package codechicken.nei.recipe.debug; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.crafting.CraftingManager; +import net.minecraft.item.crafting.IRecipe; +import net.minecraft.item.crafting.ShapedRecipes; +import net.minecraft.item.crafting.ShapelessRecipes; +import net.minecraftforge.oredict.ShapedOreRecipe; +import net.minecraftforge.oredict.ShapelessOreRecipe; + +import codechicken.nei.NEIServerUtils; +import codechicken.nei.PositionedStack; +import codechicken.nei.recipe.IRecipeHandler; +import codechicken.nei.recipe.ShapedRecipeHandler; +import codechicken.nei.recipe.ShapedRecipeHandler.CachedShapedRecipe; +import codechicken.nei.recipe.TemplateRecipeHandler.CachedRecipe; +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.ModContainer; + +/** + * Tracks which mod registered each crafting recipe, so the recipe handler debug widget can show it in the recipe + * tooltip. {@link TrackingList} is spliced in as the type of {@link CraftingManager}'s internal recipe list by + * {@link codechicken.nei.asm.NEITransformer} (see the {@code } transform there) in place of the plain + * {@code ArrayList} vanilla constructs it with. Patching the constructor itself, rather than swapping the field later + * on, means every single recipe is observed, including vanilla's own, which are added by that same constructor before + * any mod code ever runs. Opt-in via {@code -Dnei.recipeOrigins=true}, since capturing a stack trace per recipe slows + * down loading. + */ +public class RecipeOrigins { + + private static final int MAX_FRAMES = 10; + + private static final Map> origins = Collections.synchronizedMap(new IdentityHashMap<>()); + + private static class OutputIndex { + + final int size; + final Map> byItem; + + OutputIndex(int size, Map> byItem) { + this.size = size; + this.byItem = byItem; + } + } + + private static final Map>> cache = new WeakHashMap<>(); + + private static volatile OutputIndex outputIndex = new OutputIndex(-1, Collections.emptyMap()); + + private RecipeOrigins() {} + + public static List> findOrigins(IRecipeHandler handler, int recipeIndex) { + if (!(handler instanceof ShapedRecipeHandler shaped)) return Collections.emptyList(); + + final CachedRecipe recipe = shaped.arecipes.get(recipeIndex); + List> found; + + synchronized (cache) { + found = cache.get(recipe); + } + + if (found == null) { + found = findOrigins(shaped, recipe.getResult(), recipe.getIngredients()); + + synchronized (cache) { + cache.put(recipe, found); + } + } + + return found; + } + + public static boolean hasDuplicates(IRecipeHandler handler, int recipeIndex) { + return handler instanceof ShapedRecipeHandler shaped + && hasDuplicateCandidates(shaped.arecipes.get(recipeIndex).getResult()) + && findOrigins(handler, recipeIndex).size() > 1; + } + + private static boolean hasDuplicateCandidates(PositionedStack result) { + if (origins.isEmpty() || result == null || result.item == null) return false; + + int count = 0; + + for (IRecipe recipe : getRecipesByOutput(result.item.getItem())) { + if (origins.containsKey(recipe) + && NEIServerUtils.areStacksSameTypeCrafting(recipe.getRecipeOutput(), result.item) + && ++count > 1) { + return true; + } + } + + return false; + } + + private static List> findOrigins(ShapedRecipeHandler handler, PositionedStack result, + List ingredients) { + if (origins.isEmpty() || result == null || result.item == null) return Collections.emptyList(); + final List> found = new ArrayList<>(); + + for (IRecipe recipe : getRecipesByOutput(result.item.getItem())) { + final List origin = origins.get(recipe); + + if (origin != null && NEIServerUtils.areStacksSameTypeCrafting(recipe.getRecipeOutput(), result.item) + && matchesIngredients(handler, recipe, ingredients)) { + final List entry = new ArrayList<>(origin); + entry.add(recipe.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(recipe))); + found.add(entry); + } + } + + return found; + } + + private static List getRecipesByOutput(Item item) { + final List recipes = CraftingManager.getInstance().getRecipeList(); + OutputIndex index = outputIndex; + + if (recipes.size() != index.size) { + synchronized (RecipeOrigins.class) { + index = outputIndex; + + if (recipes.size() != index.size) { + final Map> byItem = new HashMap<>(); + + for (IRecipe recipe : recipes) { + final ItemStack output = recipe.getRecipeOutput(); + if (output != null) + byItem.computeIfAbsent(output.getItem(), k -> new ArrayList<>()).add(recipe); + } + + outputIndex = index = new OutputIndex(recipes.size(), byItem); + } + } + } + + return index.byItem.getOrDefault(item, Collections.emptyList()); + } + + private static boolean matchesIngredients(ShapedRecipeHandler handler, IRecipe recipe, + List ingredients) { + if (recipe instanceof ShapedRecipes shaped) { + return matchesGrid(handler.new CachedShapedRecipe(shaped), ingredients); + } + if (recipe instanceof ShapedOreRecipe shaped) + return matchesGrid(handler.forgeShapedRecipe(shaped), ingredients); + if (recipe instanceof ShapelessRecipes shapeless) return matchesUnordered(shapeless.recipeItems, ingredients); + if (recipe instanceof ShapelessOreRecipe shapeless) return matchesUnordered(shapeless.getInput(), ingredients); + return false; + } + + private static boolean matchesGrid(CachedShapedRecipe reference, List ingredients) { + if (reference == null || reference.ingredients.size() != ingredients.size()) return false; + + for (PositionedStack expected : reference.ingredients) { + final PositionedStack actual = ingredients.stream() + .filter(stack -> stack.relx == expected.relx && stack.rely == expected.rely).findFirst() + .orElse(null); + + if (actual == null || !matchesItems(expected.items, actual.items)) return false; + } + + return true; + } + + private static boolean matchesItems(ItemStack[] expected, ItemStack[] actual) { + for (ItemStack candidate : expected) { + for (ItemStack item : actual) { + if (NEIServerUtils.areStacksSameTypeCrafting(candidate, item)) return true; + } + } + + return false; + } + + private static boolean matchesUnordered(List inputs, List ingredients) { + final List remaining = new ArrayList<>(ingredients); + + for (Object input : inputs) { + if (input == null) continue; + + final PositionedStack match = remaining.stream().filter(stack -> matchesInput(input, stack)).findFirst() + .orElse(null); + if (match == null) return false; + remaining.remove(match); + } + + return remaining.isEmpty(); + } + + private static boolean matchesInput(Object input, PositionedStack stack) { + final List candidates = input instanceof Listlist ? list : Collections.singletonList(input); + + for (Object candidate : candidates) { + if (!(candidate instanceof ItemStack candidateStack)) continue; + for (ItemStack item : stack.items) { + if (NEIServerUtils.areStacksSameTypeCrafting(candidateStack, item)) return true; + } + } + + return false; + } + + public static class TrackingList extends ArrayList { + + @Override + public boolean add(IRecipe recipe) { + origins.put(recipe, captureOrigin()); + return super.add(recipe); + } + + @Override + public void add(int index, IRecipe recipe) { + origins.put(recipe, captureOrigin()); + super.add(index, recipe); + } + + @Override + public IRecipe set(int index, IRecipe recipe) { + origins.put(recipe, captureOrigin()); + return super.set(index, recipe); + } + + @Override + public boolean addAll(Collection recipes) { + final List origin = captureOrigin(); + for (IRecipe recipe : recipes) origins.put(recipe, origin); + return super.addAll(recipes); + } + + @Override + public boolean addAll(int index, Collection recipes) { + final List origin = captureOrigin(); + for (IRecipe recipe : recipes) origins.put(recipe, origin); + return super.addAll(index, recipes); + } + + @Override + public IRecipe remove(int index) { + final IRecipe removed = super.remove(index); + origins.remove(removed); + return removed; + } + + @Override + public boolean remove(Object recipe) { + final boolean removed = super.remove(recipe); + if (removed) origins.remove(recipe); + return removed; + } + + @Override + public void clear() { + origins.clear(); + super.clear(); + } + } + + private static List captureOrigin() { + final List frames = findCallerFrames(); + + if (frames.isEmpty()) return Collections.singletonList("Unknown"); + + final ModContainer container = Loader.instance().activeModContainer(); + final List lines = new ArrayList<>(frames.size() + 1); + + lines.add(container != null ? container.getName() : "Unknown"); + + for (int i = 0; i < frames.size(); i++) { + lines.add(formatFrame(frames.get(i))); + } + + return lines; + } + + private static String formatFrame(StackTraceElement frame) { + return frame.getFileName() != null ? frame.getClassName() + "." + + frame.getMethodName() + + "(" + + frame.getFileName() + + ":" + + frame.getLineNumber() + + ")" : frame.getClassName() + "." + frame.getMethodName(); + } + + private static List findCallerFrames() { + final List frames = new ArrayList<>(MAX_FRAMES); + + for (StackTraceElement element : Thread.currentThread().getStackTrace()) { + final String className = element.getClassName(); + + if (className.startsWith("java.") || className.startsWith("net.minecraft.") + || className.startsWith("net.minecraftforge.") + || className.startsWith("cpw.mods.fml.") + || className.startsWith("sun.") + || className.startsWith("jdk.") + || className.startsWith("org.spongepowered.") + || className.startsWith("codechicken.nei.recipe.debug.RecipeOrigins")) { + continue; + } + + frames.add(element); + if (frames.size() >= MAX_FRAMES) break; + } + + return frames; + } +} diff --git a/src/main/resources/assets/nei/lang/en_US.lang b/src/main/resources/assets/nei/lang/en_US.lang index 6e3258c0..c67fc28c 100644 --- a/src/main/resources/assets/nei/lang/en_US.lang +++ b/src/main/resources/assets/nei/lang/en_US.lang @@ -628,6 +628,7 @@ nei.debug.RecipeHandler.Width=Width nei.debug.RecipeHandler.ShowFavorites=Show Favorites nei.debug.RecipeHandler.ShowOverlay=Show Overlays nei.debug.RecipeHandler.ShowBadge=Show Badge +nei.debug.RecipeHandler.DuplicatesOnly=Duplicates Only nei.debug.RecipeHandler.AllowOverflowY=Allow Overflow Y nei.debug.RecipeHandler.AllowOverflowX=Allow Overflow X nei.debug.RecipeHandler.Override=Override @@ -636,6 +637,7 @@ nei.debug.RecipeHandler.checkbox.off=Off nei.debug.RecipeHandler.yes=Yes nei.debug.RecipeHandler.no=No nei.debug.RecipeHandler.reset=Reset +nei.debug.RecipeHandler.RegisteredBy=Registered by: %s nei.recipe.page=Page %s/%s nei.recipe.tooltip=Recipes