Add recipe origins to handler debug widget (#1072)

This commit is contained in:
slprime authored and GitHub committed 2026-10-12 13:43:22 +03:00
1 parent 402c4325a6
commit 43ae1b613a
5 files changed
+454 -2

No files matched your search

@@ -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", "<init>", "()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)
&& "<init>".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(
@@ -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;
@@ -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<String, HandlerInfoRecord> 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 GuiRecipe<?>recipe) {
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 GuiRecipe<?>recipe) {
recipe.forceRefreshPage();
}
}
@Override
public List<String> handleTooltip(int mx, int my, List<String> tooltip) {
return this.record != null ? this.container.handleTooltip(mx, my, tooltip) : tooltip;
@@ -532,9 +576,51 @@ public class DebugHandlerWidget extends Widget {
}
}
@Override
public List<String> handleTooltip(GuiContainer gui, int mousex, int mousey, List<String> 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<String> origin : RecipeOrigins.findOrigins(ref.handler, ref.recipeIndex)) {
addOriginLines(tooltip, origin);
}
return tooltip;
}
private static void addOriginLines(List<String> tooltip, List<String> 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
@@ -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 <init>} 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<IRecipe, List<String>> origins = Collections.synchronizedMap(new IdentityHashMap<>());
private static class OutputIndex {
final int size;
final Map<Item, List<IRecipe>> byItem;
OutputIndex(int size, Map<Item, List<IRecipe>> byItem) {
this.size = size;
this.byItem = byItem;
}
}
private static final Map<CachedRecipe, List<List<String>>> cache = new WeakHashMap<>();
private static volatile OutputIndex outputIndex = new OutputIndex(-1, Collections.emptyMap());
private RecipeOrigins() {}
public static List<List<String>> findOrigins(IRecipeHandler handler, int recipeIndex) {
if (!(handler instanceof ShapedRecipeHandler shaped)) return Collections.emptyList();
final CachedRecipe recipe = shaped.arecipes.get(recipeIndex);
List<List<String>> 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<List<String>> findOrigins(ShapedRecipeHandler handler, PositionedStack result,
List<PositionedStack> ingredients) {
if (origins.isEmpty() || result == null || result.item == null) return Collections.emptyList();
final List<List<String>> found = new ArrayList<>();
for (IRecipe recipe : getRecipesByOutput(result.item.getItem())) {
final List<String> origin = origins.get(recipe);
if (origin != null && NEIServerUtils.areStacksSameTypeCrafting(recipe.getRecipeOutput(), result.item)
&& matchesIngredients(handler, recipe, ingredients)) {
final List<String> entry = new ArrayList<>(origin);
entry.add(recipe.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(recipe)));
found.add(entry);
}
}
return found;
}
private static List<IRecipe> getRecipesByOutput(Item item) {
final List<IRecipe> recipes = CraftingManager.getInstance().getRecipeList();
OutputIndex index = outputIndex;
if (recipes.size() != index.size) {
synchronized (RecipeOrigins.class) {
index = outputIndex;
if (recipes.size() != index.size) {
final Map<Item, List<IRecipe>> 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<PositionedStack> 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<PositionedStack> 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<PositionedStack> ingredients) {
final List<PositionedStack> 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 List<?>list ? 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<IRecipe> {
@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<? extends IRecipe> recipes) {
final List<String> origin = captureOrigin();
for (IRecipe recipe : recipes) origins.put(recipe, origin);
return super.addAll(recipes);
}
@Override
public boolean addAll(int index, Collection<? extends IRecipe> recipes) {
final List<String> 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<String> captureOrigin() {
final List<StackTraceElement> frames = findCallerFrames();
if (frames.isEmpty()) return Collections.singletonList("Unknown");
final ModContainer container = Loader.instance().activeModContainer();
final List<String> 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<StackTraceElement> findCallerFrames() {
final List<StackTraceElement> 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;
}
}
@@ -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