diff --git a/api/src/main/java/me/shedaniel/rei/api/client/config/ConfigObject.java b/api/src/main/java/me/shedaniel/rei/api/client/config/ConfigObject.java index 11fbe4a37..170f16dbe 100644 --- a/api/src/main/java/me/shedaniel/rei/api/client/config/ConfigObject.java +++ b/api/src/main/java/me/shedaniel/rei/api/client/config/ConfigObject.java @@ -288,6 +288,16 @@ default boolean isLeftHandSidePanel() { @ApiStatus.Experimental boolean doesCacheDisplayLookup(); + /** + * Returns whether REI should force loading recipes from local client data + * instead of waiting for server synchronization. + * Useful for multiplayer servers that do not send recipe data. + * + * @return whether force local recipes is enabled + */ + @ApiStatus.Experimental + boolean isForceLocalRecipes(); + boolean doDebugRenderTimeRequired(); boolean doMergeDisplayUnderOne(); diff --git a/runtime/src/main/java/me/shedaniel/rei/RoughlyEnoughItemsCoreClient.java b/runtime/src/main/java/me/shedaniel/rei/RoughlyEnoughItemsCoreClient.java index 86ccdcb37..154693844 100644 --- a/runtime/src/main/java/me/shedaniel/rei/RoughlyEnoughItemsCoreClient.java +++ b/runtime/src/main/java/me/shedaniel/rei/RoughlyEnoughItemsCoreClient.java @@ -92,6 +92,7 @@ import me.shedaniel.rei.impl.common.networking.DisplaySyncPacket; import me.shedaniel.rei.impl.common.plugins.PluginManagerImpl; import me.shedaniel.rei.impl.common.plugins.ReloadManagerImpl; +import me.shedaniel.rei.impl.common.registry.displays.ServerDisplayRegistryImpl; import me.shedaniel.rei.impl.common.util.InstanceHelper; import me.shedaniel.rei.impl.common.util.IssuesDetector; import me.shedaniel.rei.plugin.test.REITestCommonPlugin; @@ -117,7 +118,14 @@ import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.TooltipFlag; +import net.minecraft.core.HolderLookup; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.tags.TagLoader; import net.minecraft.world.item.crafting.RecipeAccess; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; import net.minecraft.world.item.crafting.display.RecipeDisplayEntry; import net.minecraft.world.item.crafting.display.RecipeDisplayId; import org.apache.commons.lang3.mutable.MutableLong; @@ -125,6 +133,7 @@ import org.jetbrains.annotations.Nullable; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.function.*; import java.util.stream.Stream; @@ -311,6 +320,11 @@ private void registerEvents() { Minecraft client = Minecraft.getInstance(); final ResourceLocation recipeButtonTex = ResourceLocation.withDefaultNamespace("textures/gui/recipe_button.png"); MutableLong endReload = new MutableLong(-1); + // Set up client-side recipe loading for forceLocalRecipes mode. + // When on a remote server with no integrated server, ServerDisplayRegistryImpl + // cannot access GameInstance.getServer().getRecipeManager(). This supplier + // provides recipes loaded from the client's vanilla data pack instead. + ServerDisplayRegistryImpl.setClientRecipeSupplier(RoughlyEnoughItemsCoreClient::loadRecipesFromClientDataPacks); PRE_UPDATE_RECIPES.register((recipeAccess, registryAccess) -> { reloadPlugins(null, ReloadStage.START, registryAccess); }); @@ -318,6 +332,10 @@ private void registerEvents() { reloadPlugins(endReload, ReloadStage.END); }); ClientRecipeUpdateEvent.ADD.register((recipeAccess, entries) -> { + if (ConfigObject.getInstance().isForceLocalRecipes() && !Minecraft.getInstance().isLocalServer()) { + InternalLogger.getInstance().debug("Ignoring server recipe ADD (%d entries) because forceLocalRecipes is enabled.", entries.size()); + return; + } if (ClientHelperImpl.getInstance().canUsePackets()) { return; } @@ -328,6 +346,10 @@ private void registerEvents() { registry.addJob(() -> registry.addRecipes(mapped)); }); ClientRecipeUpdateEvent.REMOVE.register((recipeAccess, entries) -> { + if (ConfigObject.getInstance().isForceLocalRecipes() && !Minecraft.getInstance().isLocalServer()) { + InternalLogger.getInstance().debug("Ignoring server recipe REMOVE (%d entries) because forceLocalRecipes is enabled.", entries.size()); + return; + } if (ClientHelperImpl.getInstance().canUsePackets()) { return; } @@ -500,4 +522,70 @@ public static void reloadPlugins(MutableLong lastReload, @Nullable ReloadStage s } ReloadManagerImpl.reloadPlugins(start, () -> InstanceHelper.connectionFromClient() == null); } + + private static List> loadRecipesFromClientDataPacks() { + try { + Minecraft mc = Minecraft.getInstance(); + if (mc.getConnection() == null) return Collections.emptyList(); + if (mc.isLocalServer()) return Collections.emptyList(); + if (!ConfigObject.getInstance().isForceLocalRecipes()) return Collections.emptyList(); + + InternalLogger.getInstance().info("[Force Local Recipes] Loading recipes from client data packs..."); + + RegistryAccess registryAccess = mc.getConnection().registryAccess(); + + // Create a data resource manager from the vanilla pack. + // The vanilla JAR contains all recipe JSONs under data//recipe/. + List packResourcesList = new ArrayList<>(); + packResourcesList.add(mc.getVanillaPackResources()); + + List> result; + try (MultiPackResourceManager dataManager = new MultiPackResourceManager(PackType.SERVER_DATA, packResourcesList)) { + // Load tags for ALL builtin registries (items, blocks, fluids, etc.) + // from the vanilla data pack. The connection's registryAccess only contains + // dynamic registries, but recipe ingredients reference tags from builtin + // registries like minecraft:item (e.g. #minecraft:bundles, #minecraft:planks). + int tagCount = 0; + for (net.minecraft.core.Registry registry : net.minecraft.core.registries.BuiltInRegistries.REGISTRY) { + if (registry instanceof net.minecraft.core.WritableRegistry) { + try { + @SuppressWarnings({"unchecked", "rawtypes"}) + net.minecraft.core.WritableRegistry writable = (net.minecraft.core.WritableRegistry) registry; + TagLoader.loadTagsForRegistry(dataManager, writable); + tagCount++; + } catch (Exception e) { + // Some registries might not have tag directories, skip silently + } + } + } + InternalLogger.getInstance().info("[Force Local Recipes] Loaded tags for %d builtin registries from client data packs", tagCount); + + // Also load tags for dynamic registries from the connection's registryAccess + List> pendingTags = + TagLoader.loadTagsForExistingRegistries(dataManager, registryAccess); + for (net.minecraft.core.Registry.PendingTags pt : pendingTags) { + pt.apply(); + } + if (!pendingTags.isEmpty()) { + InternalLogger.getInstance().info("[Force Local Recipes] Loaded and applied %d dynamic registry tag sets", pendingTags.size()); + } + + // Now load recipes — tag references will resolve correctly. + RecipeManager recipeManager = new RecipeManager(registryAccess); + recipeManager.reload( + CompletableFuture::completedFuture, + dataManager, + Runnable::run, + Runnable::run + ).join(); + result = new ArrayList<>(recipeManager.getRecipes()); + } + + InternalLogger.getInstance().info("[Force Local Recipes] Loaded %d recipes from client data packs", result.size()); + return result; + } catch (Exception e) { + InternalLogger.getInstance().error("[Force Local Recipes] Failed to load recipes from client data packs", e); + return Collections.emptyList(); + } + } } diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/client/config/ConfigObjectImpl.java b/runtime/src/main/java/me/shedaniel/rei/impl/client/config/ConfigObjectImpl.java index 41e91b7c9..df5e289b5 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/client/config/ConfigObjectImpl.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/client/config/ConfigObjectImpl.java @@ -313,6 +313,11 @@ public boolean doesCacheDisplayLookup() { return advanced.miscellaneous.cachingDisplayLookup; } + @Override + public boolean isForceLocalRecipes() { + return advanced.miscellaneous.forceLocalRecipes; + } + @Override public boolean doDebugRenderTimeRequired() { return advanced.layout.debugRenderTimeRequired; @@ -739,6 +744,8 @@ public static class Miscellaneous { public boolean newFastEntryRendering = true; public boolean cachingFastEntryRendering = false; public boolean cachingDisplayLookup = true; + @Comment("Forces REI to load recipes from local client data instead of waiting for server synchronization. Useful for servers that do not send recipe data.") + public boolean forceLocalRecipes = false; public CategorySettings categorySettings = new CategorySettings(); public static class CategorySettings { diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigGroups.java b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigGroups.java index 474132b8e..1ec97edcf 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigGroups.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigGroups.java @@ -80,7 +80,8 @@ static OptionGroup make(String id) { .add(CUSTOMIZED_FILTERING); OptionGroup FILTERING_ADVANCED = make("filtering.advanced") .add(FILTER_DISPLAYS) - .add(MERGE_DISPLAYS); + .add(MERGE_DISPLAYS) + .add(FORCE_LOCAL_RECIPES); OptionGroup LIST_ENTRIES = make("list.entries") .add(DISPLAY_MODE) .add(ORDERING) diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigOptions.java b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigOptions.java index 30a673e38..96e6af9f4 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigOptions.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/config/options/AllREIConfigOptions.java @@ -256,6 +256,8 @@ static ComparableValue[] doubleRange(double start, double end, double st .ofBoolean(translatable("config.rei.values.performance.reload_thread.main_thread"), translatable("config.rei.values.performance.reload_thread.rei_thread")); CompositeOption CACHED_DISPLAY_LOOKUP = make("performance.cached_display_lookup", i -> i.advanced.miscellaneous.cachingDisplayLookup, (i, v) -> i.advanced.miscellaneous.cachingDisplayLookup = v) .enabledDisabled(); + CompositeOption FORCE_LOCAL_RECIPES = make("filtering.force_local_recipes", i -> i.advanced.miscellaneous.forceLocalRecipes, (i, v) -> i.advanced.miscellaneous.forceLocalRecipes = v) + .enabledDisabled(); CompositeOption PLUGINS_PERFORMANCE = make("debug.plugins_performance", i -> null, (i, v) -> new Object()) .details((access, option, onClose) -> Minecraft.getInstance().setScreen(new PerformanceScreen(onClose))) .requiresLevel(); diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/hints/ImportantWarningsWidget.java b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/hints/ImportantWarningsWidget.java index 66daf5c3f..59dd0bedf 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/hints/ImportantWarningsWidget.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/client/gui/hints/ImportantWarningsWidget.java @@ -26,6 +26,7 @@ import me.shedaniel.math.Rectangle; import me.shedaniel.rei.RoughlyEnoughItemsCoreClient; import me.shedaniel.rei.api.client.ClientHelper; +import me.shedaniel.rei.api.client.config.ConfigObject; import me.shedaniel.rei.api.client.gui.config.DisplayPanelLocation; import me.shedaniel.rei.api.client.gui.widgets.WidgetWithBounds; import me.shedaniel.rei.api.client.gui.widgets.Widgets; @@ -63,7 +64,7 @@ public ImportantWarningsWidget() { prevId = newId; dirty = true; } - dirty = dirty && !ClientHelper.getInstance().canUseMovePackets(); + dirty = dirty && !ClientHelper.getInstance().canUseMovePackets() && !ConfigObject.getInstance().isForceLocalRecipes(); } this.visible = dirty; diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/client/registry/display/DisplayRegistryImpl.java b/runtime/src/main/java/me/shedaniel/rei/impl/client/registry/display/DisplayRegistryImpl.java index 6bcf1e4b9..402aaf21e 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/client/registry/display/DisplayRegistryImpl.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/client/registry/display/DisplayRegistryImpl.java @@ -156,6 +156,15 @@ public void startReload() { @Override public void endReload() { + // Inject force-local recipe displays from ServerDisplayRegistryImpl + List forceLocalDisplays = me.shedaniel.rei.impl.common.registry.displays.ServerDisplayRegistryImpl.consumePendingForceLocalDisplays(); + if (forceLocalDisplays != null && !forceLocalDisplays.isEmpty()) { + InternalLogger.getInstance().info("[Force Local Recipes] Injecting %d displays into client registry", forceLocalDisplays.size()); + for (Display display : forceLocalDisplays) { + super.add(display, SYNCED); + } + } + InternalLogger.getInstance().debug("Found %d displays", size()); for (CategoryIdentifier identifier : getAll().keySet()) { diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/common/networking/DisplaySyncPacket.java b/runtime/src/main/java/me/shedaniel/rei/impl/common/networking/DisplaySyncPacket.java index d808751a6..bcc80f3b6 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/common/networking/DisplaySyncPacket.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/common/networking/DisplaySyncPacket.java @@ -28,6 +28,7 @@ import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; import me.shedaniel.rei.RoughlyEnoughItemsNetwork; +import me.shedaniel.rei.api.client.config.ConfigObject; import me.shedaniel.rei.api.client.registry.display.DisplayRegistry; import me.shedaniel.rei.api.common.display.Display; import me.shedaniel.rei.impl.client.registry.display.DisplayRegistryImpl; @@ -102,6 +103,10 @@ public Type type() { @Environment(EnvType.CLIENT) public void handle(NetworkManager.PacketContext context) { + if (ConfigObject.getInstance().isForceLocalRecipes() && !net.minecraft.client.Minecraft.getInstance().isLocalServer()) { + InternalLogger.getInstance().info("[REI Server Display Sync] Ignoring server display sync because forceLocalRecipes is enabled."); + return; + } DisplayRegistryImpl registry = (DisplayRegistryImpl) DisplayRegistry.getInstance(); if (syncType() == SyncType.SET) { InternalLogger.getInstance().info("[REI Server Display Sync] Received server's request to set %d recipes.", displays().size()); diff --git a/runtime/src/main/java/me/shedaniel/rei/impl/common/registry/displays/ServerDisplayRegistryImpl.java b/runtime/src/main/java/me/shedaniel/rei/impl/common/registry/displays/ServerDisplayRegistryImpl.java index b2ee8456c..5d9d94af9 100644 --- a/runtime/src/main/java/me/shedaniel/rei/impl/common/registry/displays/ServerDisplayRegistryImpl.java +++ b/runtime/src/main/java/me/shedaniel/rei/impl/common/registry/displays/ServerDisplayRegistryImpl.java @@ -57,6 +57,21 @@ public class ServerDisplayRegistryImpl extends AbstractDisplayRegistry> RECIPE_COMPARATOR = Comparator.comparing((RecipeHolder o) -> o.id().location().getNamespace()).thenComparing(o -> o.id().location().getPath()); private final Object2LongMap playerVersionMap = new Object2LongOpenHashMap<>(); private int reloadVersionHash = UUID.randomUUID().hashCode(); + @Nullable + private static java.util.function.Supplier>> clientRecipeSupplier; + @Nullable + private static List pendingForceLocalDisplays; + + public static void setClientRecipeSupplier(@Nullable java.util.function.Supplier>> supplier) { + clientRecipeSupplier = supplier; + } + + @Nullable + public static List consumePendingForceLocalDisplays() { + List displays = pendingForceLocalDisplays; + pendingForceLocalDisplays = null; + return displays; + } public ServerDisplayRegistryImpl() { super(ServerDisplaysHolder::new); @@ -207,6 +222,24 @@ public boolean add(Display display, @Nullable Object origin) { public void endReload() { InternalLogger.getInstance().debug("Found preliminary %d displays", size()); fillRecipes(); + + // If force-local recipes were loaded, store displays for client registry injection + // On a remote server with forceLocalRecipes, the normal DisplaySyncPacket path won't work, + // so we need to pass displays directly to the client DisplayRegistryImpl. + try { + if (me.shedaniel.rei.api.client.config.ConfigObject.getInstance().isForceLocalRecipes() + && !net.minecraft.client.Minecraft.getInstance().isLocalServer() + && size() > 0) { + List allDisplays = new ArrayList<>(); + for (List displays : getAll().values()) { + allDisplays.addAll(displays); + } + pendingForceLocalDisplays = allDisplays; + InternalLogger.getInstance().info("[Force Local Recipes] Stored %d displays for client registry injection", allDisplays.size()); + } + } catch (Exception e) { + InternalLogger.getInstance().debug("[Force Local Recipes] Could not check force-local state: %s", e.getMessage()); + } } private void fillRecipes() { @@ -227,7 +260,27 @@ private void fillRecipes() { } private List> getAllSortedRecipes() { - return GameInstance.getServer().getRecipeManager().getRecipes().parallelStream().sorted(RECIPE_COMPARATOR).toList(); + try { + var server = GameInstance.getServer(); + if (server != null) { + return server.getRecipeManager().getRecipes() + .parallelStream().sorted(RECIPE_COMPARATOR).toList(); + } + } catch (Exception e) { + InternalLogger.getInstance().error("Failed to get recipes from server: %s", e.getMessage()); + } + if (clientRecipeSupplier != null) { + try { + List> recipes = clientRecipeSupplier.get(); + if (!recipes.isEmpty()) { + InternalLogger.getInstance().info("[Force Local Recipes] Loaded %d recipes from client-side provider", recipes.size()); + } + return recipes; + } catch (Exception e) { + InternalLogger.getInstance().error("[Force Local Recipes] Failed to get client-side recipes", e); + } + } + return Collections.emptyList(); } public static class ServerDisplaysHolder extends DisplaysHolderImpl { diff --git a/runtime/src/main/resources/assets/roughlyenoughitems/lang/en_us.json b/runtime/src/main/resources/assets/roughlyenoughitems/lang/en_us.json index d6c807650..3849b2200 100755 --- a/runtime/src/main/resources/assets/roughlyenoughitems/lang/en_us.json +++ b/runtime/src/main/resources/assets/roughlyenoughitems/lang/en_us.json @@ -436,6 +436,8 @@ "config.rei.options.filtering.filter_displays.desc": "Apply filtering rules to determine the visibility of displays. Displays with all entries filtered will be hidden.", "config.rei.options.filtering.merge_displays": "Merge Displays", "config.rei.options.filtering.merge_displays.desc": "Merge displays with the same recipe. This is useful for unifying recipes.", + "config.rei.options.filtering.force_local_recipes": "Force Local Recipes (Ignore Server Sync)", + "config.rei.options.filtering.force_local_recipes.desc": "Loads recipes from client resources instead of waiting for server synchronization. Useful for multiplayer servers (e.g. 2b2t) that do not send recipe data. §eWarning: Some recipes may not exist on the server. §eRequires reconnecting to the server after toggling.", "config.rei.options.groups.list.entries": "Entries", "config.rei.options.list.display_mode": "Display Mode", "config.rei.options.list.display_mode.desc": "The way entries are laid out. Paginated mode displays entries in pages, where there are buttons to traverse the different pages. Scrolled mode displays entries in a vertical list.",