diff --git a/src/main/java/net/neoforged/neoforge/client/gui/ConfigurationScreen.java b/src/main/java/net/neoforged/neoforge/client/gui/ConfigurationScreen.java index becd2d4ca8..b18c5da144 100644 --- a/src/main/java/net/neoforged/neoforge/client/gui/ConfigurationScreen.java +++ b/src/main/java/net/neoforged/neoforge/client/gui/ConfigurationScreen.java @@ -342,7 +342,7 @@ public void added() { public void onClose() { translationChecker.finish(); switch (needsRestart) { - case GAME -> { + case REGISTRY, GAME -> { minecraft.setScreen(new TooltipConfirmScreen(b -> { if (b) { minecraft.stop(); diff --git a/src/main/java/net/neoforged/neoforge/client/gui/RegistryConfigMismatchScreen.java b/src/main/java/net/neoforged/neoforge/client/gui/RegistryConfigMismatchScreen.java new file mode 100644 index 0000000000..cd4aa64099 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/client/gui/RegistryConfigMismatchScreen.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui; + +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.logging.LogUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.MultiLineLabel; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import net.neoforged.neoforge.client.gui.widget.ScrollPanel; +import org.apache.commons.lang3.tuple.Pair; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +/** This is a copy-paste of {@link ModMismatchDisconnectedScreen} */ +public class RegistryConfigMismatchScreen extends Screen { + private static final Logger LOGGER = LogUtils.getLogger(); + private final Component reason; + private final int listHeight = 140; + private final Map mismatchedChannelData; + private final Runnable replaceAction; + private final Screen parent; + + private MultiLineLabel message = MultiLineLabel.EMPTY; + private int textHeight; + + public RegistryConfigMismatchScreen(Screen parent, Component reason, @Nullable Runnable replaceAction, Map mismatchedChannelData) { + super(Component.translatable("disconnect.lost")); + this.reason = reason; + this.parent = parent; + this.mismatchedChannelData = mismatchedChannelData; + mismatchedChannelData.forEach((id, r) -> LOGGER.warn("Registry Config [{}] failed to connect: {}", id, r.getString())); + this.replaceAction = replaceAction; + } + + @Override + protected void init() { + int lstX = Math.max(8, width / 2 - 220); + int lstW = Math.min(440, width - 16); + + message = MultiLineLabel.create(font, reason, width - 50); + textHeight = message.getLineCount() * 9; + + addRenderableWidget(new MismatchInfoPanel(minecraft, lstW, listHeight, (height - listHeight) / 2, lstX)); + + int btnY = Math.min((height + listHeight) / 2 + 50, height - 25); + int btnW = Math.min(210, width / 2 - 20); + + Button rep = addRenderableWidget(Button.builder(Component.translatable("fml.registryconfigmismatchscreen.overwrite"), + button -> Optional.ofNullable(replaceAction).ifPresent(Runnable::run)) + .bounds(Math.min(width * 3 / 4 - btnW / 2, lstX), + btnY, btnW, 20) + .build()); + rep.active = replaceAction != null; + addRenderableWidget(Button.builder(Component.translatable("gui.toMenu"), + button -> minecraft.setScreen(parent)) + .bounds(Math.min(width * 3 / 4 - btnW / 2, lstX + lstW - btnW), + btnY, btnW, 20) + .build()); + } + + @Override + public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + super.render(guiGraphics, mouseX, mouseY, partialTicks); + guiGraphics.drawCenteredString(font, title, width / 2, (height - listHeight) / 2 - textHeight - 9 * 4, 0xAAAAAA); + message.renderCentered(guiGraphics, width / 2, (height - listHeight) / 2 - textHeight - 9 * 2); + } + + class MismatchInfoPanel extends ScrollPanel { + private final int nameIndent = 10; + private final int tableWidth = width - border * 2 - 6 - nameIndent; + private final int nameWidth = tableWidth / 2; + private final int versionWidth = tableWidth - nameWidth; + private List> lineTable; + private int contentSize; + + public MismatchInfoPanel(Minecraft client, int width, int height, int top, int left) { + super(client, width, height, top, left); + updateListContent(); + } + + private void updateListContent() { + record Row(MutableComponent name, MutableComponent reason) {} + //The raw list of the strings in a table row, the components may still be too long for the final table and will be split up later. The first row element may have a style assigned to it that will be used for the whole content row. + List rows = new ArrayList<>(); + if (!mismatchedChannelData.isEmpty()) { + //Each table row contains the channel id(s) and the reason for the corresponding channel mismatch. + rows.add(new Row( + Component.translatable("fml.registryconfigmismatchscreen.table.type"), + Component.translatable("fml.registryconfigmismatchscreen.table.reason"))); + int i = 0; + for (var channelData : mismatchedChannelData.entrySet()) { + rows.add(new Row( + Component.literal(channelData.getKey().toString()).withStyle(i % 2 == 0 ? ChatFormatting.GOLD : ChatFormatting.YELLOW), + channelData.getValue().copy())); + i++; + } + rows.add(new Row(Component.literal(""), Component.literal(""))); //Add one line of padding. + } + + lineTable = rows.stream().flatMap(p -> splitLineToWidth(p.name(), p.reason()).stream()).collect(Collectors.toList()); + contentSize = lineTable.size(); + scrollDistance = 0; + } + + // Start copying ModMismatchDisconnectedScreen$MismatchInfoPanel + + /** + * Splits the raw channel namespace and mismatch reason strings, making them use multiple lines if needed, to fit within the table dimensions. + * The style assigned to the name element is then applied to the entire content row. + * + * @param name The first element of the content row, usually representing a table section header or a channel name entry + * @param reason The second element of the content row, usually representing the reason why the channel is mismatched + * @return A list of table rows consisting of 2 elements each which consist of the same content as was given by the parameters, but split up to fit within the table dimensions. + */ + private List> splitLineToWidth(MutableComponent name, MutableComponent reason) { + Style style = name.getStyle(); + List nameLines = font.split(name, nameWidth - 4); + List reasonLines = font.split(reason.setStyle(style), versionWidth - 4); + List> splitLines = new ArrayList<>(); + + int rowsOccupied = Math.max(nameLines.size(), reasonLines.size()); + for (int i = 0; i < rowsOccupied; i++) { + splitLines.add(Pair.of(i < nameLines.size() ? nameLines.get(i) : FormattedCharSequence.EMPTY, i < reasonLines.size() ? reasonLines.get(i) : FormattedCharSequence.EMPTY)); + } + return splitLines; + } + + @Override + protected int getContentHeight() { + int height = contentSize * (font.lineHeight + 3); + + if (height < bottom - top - 4) + height = bottom - top - 4; + + return height; + } + + @Override + protected void drawPanel(GuiGraphics guiGraphics, int entryRight, int relativeY, Tesselator tess, int mouseX, int mouseY) { + int i = 0; + + for (Pair line : lineTable) { + FormattedCharSequence name = line.getLeft(); + FormattedCharSequence reasons = line.getRight(); + //Since font#draw does not respect the color of the given component, we have to read it out here and then use it as the last parameter + int color = Optional.ofNullable(font.getSplitter().componentStyleAtWidth(name, 0)).map(Style::getColor).map(TextColor::getValue).orElse(0xFFFFFF); + //Only indent the given name if a version string is present. This makes it easier to distinguish table section headers and mod entries + int nameLeft = left + border + (reasons == null ? 0 : nameIndent); + guiGraphics.drawString(font, name, nameLeft, relativeY + i * 12, color, false); + if (reasons != null) { + guiGraphics.drawString(font, reasons, left + border + nameIndent + nameWidth, relativeY + i * 12, color, false); + } + + i++; + } + } + + @Override + public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + super.render(guiGraphics, mouseX, mouseY, partialTicks); + Style style = getComponentStyleAt(mouseX, mouseY); + if (style != null && style.getHoverEvent() != null) { + guiGraphics.renderComponentHoverEffect(font, style, mouseX, mouseY); + } + } + + public Style getComponentStyleAt(double x, double y) { + if (isMouseOver(x, y)) { + double relativeY = y - top + scrollDistance - border; + int slotIndex = (int) (relativeY + (border / 2)) / 12; + if (slotIndex < contentSize) { + //The relative x needs to take the potentially missing indent of the row into account. It does that by checking if the line has a version associated to it + double relativeX = x - left - border - (lineTable.get(slotIndex).getRight() == null ? 0 : nameIndent); + if (relativeX >= 0) + return font.getSplitter().componentStyleAtWidth(lineTable.get(slotIndex).getLeft(), (int) relativeX); + } + } + + return null; + } + + @Override + public boolean mouseClicked(final double mouseX, final double mouseY, final int button) { + Style style = getComponentStyleAt(mouseX, mouseY); + if (style != null) { + handleComponentClicked(style); + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public NarrationPriority narrationPriority() { + return NarrationPriority.NONE; + } + + @Override + public void updateNarration(NarrationElementOutput output) {} + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/ModConfigSpec.java b/src/main/java/net/neoforged/neoforge/common/ModConfigSpec.java index 870ce31e09..03d31b4c24 100644 --- a/src/main/java/net/neoforged/neoforge/common/ModConfigSpec.java +++ b/src/main/java/net/neoforged/neoforge/common/ModConfigSpec.java @@ -385,11 +385,11 @@ public ConfigValue defineInList(List path, Supplier defaultSup /** * See {@link #defineList(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its key as a string and splits it on ".".
* This variant takes its default value directly and wraps it in a supplier.
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineList(String, List, Supplier, Predicate)} */ @Deprecated @@ -399,10 +399,10 @@ public ConfigValue> defineList(String path, List - * + * * This variant takes its key as a string and splits it on ".".
* This variant takes its default value directly and wraps it in a supplier. - * + * */ public ConfigValue> defineList(String path, List defaultValue, Supplier newElementSupplier, Predicate elementValidator) { return defineList(split(path), defaultValue, newElementSupplier, elementValidator); @@ -410,10 +410,10 @@ public ConfigValue> defineList(String path, List - * + * * This variant takes its key as a string and splits it on ".".
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineList(String, Supplier, Supplier, Predicate)} */ @Deprecated @@ -423,9 +423,9 @@ public ConfigValue> defineList(String path, Supplier - * + * * This variant takes its key as a string and splits it on ".". - * + * */ public ConfigValue> defineList(String path, Supplier> defaultSupplier, Supplier newElementSupplier, Predicate elementValidator) { return defineList(split(path), defaultSupplier, newElementSupplier, elementValidator); @@ -433,10 +433,10 @@ public ConfigValue> defineList(String path, Supplier - * + * * This variant takes its default value directly and wraps it in a supplier.
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineList(List, List, Supplier, Predicate)} */ @Deprecated @@ -446,9 +446,9 @@ public ConfigValue> defineList(List path, List - * + * * This variant takes its default value directly and wraps it in a supplier. - * + * */ public ConfigValue> defineList(List path, List defaultValue, Supplier newElementSupplier, Predicate elementValidator) { return defineList(path, () -> defaultValue, newElementSupplier, elementValidator); @@ -456,9 +456,9 @@ public ConfigValue> defineList(List path, List - * + * * This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineList(List, Supplier, Supplier, Predicate)} */ @Deprecated @@ -468,9 +468,9 @@ public ConfigValue> defineList(List path, Supplier /** * Build a new config value that holds a {@link List}.

- * + * * This list cannot be empty. See also {@link #defineList(List, Supplier, Supplier, Predicate, Range)} for more control over the list size. - * + * * @param The class of element of the list. Directly supported are {@link String}, {@link Boolean}, {@link Integer}, {@link Long} and {@link Double}. * Other classes will be saved using their string representation and will be read back from the config file as strings. * @param path The key for the config value in list form, i.e. pre-split into section and key. @@ -489,11 +489,11 @@ public ConfigValue> defineList(List path, Supplier /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its key as a string and splits it on ".".
* This variant takes its default value directly and wraps it in a supplier.
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineListAllowEmpty(String, List, Supplier, Predicate)} */ @Deprecated @@ -503,10 +503,10 @@ public ConfigValue> defineListAllowEmpty(String path, List /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its key as a string and splits it on ".".
* This variant takes its default value directly and wraps it in a supplier. - * + * */ public ConfigValue> defineListAllowEmpty(String path, List defaultValue, Supplier newElementSupplier, Predicate elementValidator) { return defineListAllowEmpty(split(path), defaultValue, newElementSupplier, elementValidator); @@ -514,10 +514,10 @@ public ConfigValue> defineListAllowEmpty(String path, List /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its key as a string and splits it on ".".
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineListAllowEmpty(String, Supplier, Supplier, Predicate)} */ @Deprecated @@ -527,9 +527,9 @@ public ConfigValue> defineListAllowEmpty(String path, Supp /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its key as a string and splits it on ".". - * + * */ public ConfigValue> defineListAllowEmpty(String path, Supplier> defaultSupplier, Supplier newElementSupplier, Predicate elementValidator) { return defineListAllowEmpty(split(path), defaultSupplier, newElementSupplier, elementValidator); @@ -537,10 +537,10 @@ public ConfigValue> defineListAllowEmpty(String path, Supp /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its default value directly and wraps it in a supplier.
* This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineListAllowEmpty(List, List, Supplier, Predicate)} */ @Deprecated @@ -550,9 +550,9 @@ public ConfigValue> defineListAllowEmpty(List path /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant takes its default value directly and wraps it in a supplier. - * + * */ public ConfigValue> defineListAllowEmpty(List path, List defaultValue, Supplier newElementSupplier, Predicate elementValidator) { return defineListAllowEmpty(path, () -> defaultValue, newElementSupplier, elementValidator); @@ -560,9 +560,9 @@ public ConfigValue> defineListAllowEmpty(List path /** * See {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} for details.

- * + * * This variant has no supplier for new elements, so no new elements can be added in the config UI. - * + * * @deprecated Use {@link #defineListAllowEmpty(List, Supplier, Supplier, Predicate)} */ @Deprecated @@ -572,9 +572,9 @@ public ConfigValue> defineListAllowEmpty(List path /** * Build a new config value that holds a {@link List}.

- * + * * This list can be empty. See also {@link #defineList(List, Supplier, Supplier, Predicate, Range)} for more control over the list size. - * + * * @param The class of element of the list. Directly supported are {@link String}, {@link Boolean}, {@link Integer}, {@link Long} and {@link Double}. * Other classes will be saved using their string representation and will be read back from the config file as strings. * @param path The key for the config value in list form, i.e. pre-split into section and key. @@ -593,7 +593,7 @@ public ConfigValue> defineListAllowEmpty(List path /** * Build a new config value that holds a {@link List}.

- * + * * @param The class of element of the list. Directly supported are {@link String}, {@link Boolean}, {@link Integer}, {@link Long} and {@link Double}. * Other classes will be saved using their string representation and will be read back from the config file as strings. * @param path The key for the config value in list form, i.e. pre-split into section and key. @@ -838,6 +838,16 @@ public Builder gameRestart() { return this; } + /** + * Config values marked as needing a registry-affecting will need game restart as well, + * and will be added to {@link net.neoforged.neoforge.network.registryconfigsync.StartUpRegistryConfigHandler} + * constructed using this config spec to synchronize those entries to client with prompt + */ + public Builder registryAffecting() { + context.registryAffecting(); + return this; + } + public Builder push(String path) { return push(split(path)); } @@ -955,6 +965,10 @@ public void gameRestart() { this.restartType = RestartType.GAME; } + public void registryAffecting() { + this.restartType = RestartType.REGISTRY; + } + public RestartType restartType() { return restartType; } @@ -1149,9 +1163,9 @@ private ListValueSpec(Supplier supplier, @Nullable Supplier newElementSupp /** * Creates a new empty element that can be added to the end of the list or null if the list doesn't support adding elements.

- * + * * The element does not need to validate with either {@link #test(Object)} or {@link #testElement(Object)}, but it should give the user a good starting point for their edit.

- * + * * Only used by the UI! */ @Nullable @@ -1161,9 +1175,9 @@ public Supplier getNewElementSupplier() { /** * Determines if a given object can be part of the list.

- * + * * Note that the list-level validator overrules this.

- * + * * Only used by the UI! */ public boolean testElement(Object value) { @@ -1239,6 +1253,23 @@ public T getRaw(Config config, List path, Supplier defaultSupplier) { return config.getOrElse(path, defaultSupplier); } + @ApiStatus.Internal + @Nullable + public Object getRawData() { + Preconditions.checkNotNull(spec, "Cannot get config value before spec is built"); + var loadedConfig = spec.loadedConfig; + Preconditions.checkState(loadedConfig != null, "Cannot get config value before config is loaded."); + return loadedConfig.config().get(path); + } + + @ApiStatus.Internal + public void setRawData(@Nullable Object data) { + Preconditions.checkNotNull(spec, "Cannot set config value before spec is built"); + var loadedConfig = spec.loadedConfig; + Preconditions.checkState(loadedConfig != null, "Cannot set config value before config is loaded."); + loadedConfig.config().set(path, data); + } + /** * {@return the default value for the configuration setting} */ @@ -1389,7 +1420,13 @@ public enum RestartType { *

* Cannot be used for {@linkplain ModConfig.Type#SERVER server configs}. */ - GAME(ModConfig.Type.SERVER); + GAME(ModConfig.Type.SERVER), + /** + * Require a game restart, and will affect registry + *

+ * Can only be used for {@linkplain ModConfig.Type#STARTUP startup configs}. + */ + REGISTRY(ModConfig.Type.SERVER, ModConfig.Type.CLIENT, ModConfig.Type.COMMON); private final Set invalidTypes; @@ -1403,7 +1440,10 @@ private boolean isValid(ModConfig.Type type) { } public RestartType with(RestartType other) { - return other == NONE ? this : (other == GAME || this == GAME) ? GAME : WORLD; + if (other == NONE) return this; + if (other == REGISTRY || this == REGISTRY) return REGISTRY; + if (other == GAME || this == GAME) return GAME; + return WORLD; } } } diff --git a/src/main/java/net/neoforged/neoforge/internal/RegistrationEvents.java b/src/main/java/net/neoforged/neoforge/internal/RegistrationEvents.java index eecc40b9b2..c659816b35 100644 --- a/src/main/java/net/neoforged/neoforge/internal/RegistrationEvents.java +++ b/src/main/java/net/neoforged/neoforge/internal/RegistrationEvents.java @@ -10,6 +10,7 @@ import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager; import net.neoforged.neoforge.event.ModifyDefaultComponentsEvent; import net.neoforged.neoforge.fluids.CauldronFluidContent; +import net.neoforged.neoforge.network.registryconfigsync.RegistryConfigHandlers; import net.neoforged.neoforge.registries.RegistryManager; public class RegistrationEvents { @@ -19,6 +20,7 @@ static void init() { ForcedChunkManager.init(); RegistryManager.initDataMaps(); modifyComponents(); + RegistryConfigHandlers.init(); } private static boolean canModifyComponents; diff --git a/src/main/java/net/neoforged/neoforge/network/ConfigurationInitialization.java b/src/main/java/net/neoforged/neoforge/network/ConfigurationInitialization.java index 2c64664388..269f1ac854 100644 --- a/src/main/java/net/neoforged/neoforge/network/ConfigurationInitialization.java +++ b/src/main/java/net/neoforged/neoforge/network/ConfigurationInitialization.java @@ -17,6 +17,7 @@ import net.neoforged.neoforge.network.configuration.RegistryDataMapNegotiation; import net.neoforged.neoforge.network.configuration.SyncConfig; import net.neoforged.neoforge.network.configuration.SyncRegistries; +import net.neoforged.neoforge.network.configuration.SyncRegistryConfigTask; import net.neoforged.neoforge.network.event.RegisterConfigurationTasksEvent; import net.neoforged.neoforge.network.payload.CommonRegisterPayload; import net.neoforged.neoforge.network.payload.CommonVersionPayload; @@ -24,6 +25,8 @@ import net.neoforged.neoforge.network.payload.FrozenRegistryPayload; import net.neoforged.neoforge.network.payload.FrozenRegistrySyncCompletedPayload; import net.neoforged.neoforge.network.payload.FrozenRegistrySyncStartPayload; +import net.neoforged.neoforge.network.payload.RegistryConfigAckPayload; +import net.neoforged.neoforge.network.payload.RegistryConfigDataPayload; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal @@ -34,6 +37,10 @@ public class ConfigurationInitialization { * and most importantly before vanilla's own {@link SynchronizeRegistriesTask}. */ public static void configureEarlyTasks(ServerConfigurationPacketListener listener, Consumer tasks) { + if (listener.hasChannel(RegistryConfigDataPayload.TYPE) && + listener.hasChannel(RegistryConfigAckPayload.TYPE)) { + tasks.accept(new SyncRegistryConfigTask(listener)); + } if (listener.hasChannel(FrozenRegistrySyncStartPayload.TYPE) && listener.hasChannel(FrozenRegistryPayload.TYPE) && listener.hasChannel(FrozenRegistrySyncCompletedPayload.TYPE)) { diff --git a/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java b/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java index a3e9c32c35..db8bac7a8e 100644 --- a/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java +++ b/src/main/java/net/neoforged/neoforge/network/NetworkInitialization.java @@ -9,6 +9,7 @@ import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.neoforge.internal.versions.neoforge.NeoForgeVersion; import net.neoforged.neoforge.network.configuration.CheckExtensibleEnums; +import net.neoforged.neoforge.network.configuration.SyncRegistryConfigTask; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.handlers.ClientPayloadHandler; import net.neoforged.neoforge.network.handlers.ServerPayloadHandler; @@ -26,6 +27,8 @@ import net.neoforged.neoforge.network.payload.FrozenRegistrySyncStartPayload; import net.neoforged.neoforge.network.payload.KnownRegistryDataMapsPayload; import net.neoforged.neoforge.network.payload.KnownRegistryDataMapsReplyPayload; +import net.neoforged.neoforge.network.payload.RegistryConfigAckPayload; +import net.neoforged.neoforge.network.payload.RegistryConfigDataPayload; import net.neoforged.neoforge.network.payload.RegistryDataMapSyncPayload; import net.neoforged.neoforge.network.registration.PayloadRegistrar; import net.neoforged.neoforge.registries.ClientRegistryManager; @@ -94,6 +97,14 @@ private static void register(final RegisterPayloadHandlersEvent event) { .playToClient( ClientboundCustomSetTimePayload.TYPE, ClientboundCustomSetTimePayload.STREAM_CODEC, - ClientPayloadHandler::handle); + ClientPayloadHandler::handle) + .configurationToClient( + RegistryConfigDataPayload.TYPE, + RegistryConfigDataPayload.STREAM_CODEC, + SyncRegistryConfigTask::handleData) + .configurationToServer( + RegistryConfigAckPayload.TYPE, + RegistryConfigAckPayload.STREAM_CODEC, + SyncRegistryConfigTask::handleAck); } } diff --git a/src/main/java/net/neoforged/neoforge/network/configuration/SyncRegistryConfigTask.java b/src/main/java/net/neoforged/neoforge/network/configuration/SyncRegistryConfigTask.java new file mode 100644 index 0000000000..17ece48bce --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/configuration/SyncRegistryConfigTask.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.configuration; + +import com.google.gson.JsonElement; +import com.mojang.logging.LogUtils; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.configuration.ServerConfigurationPacketListener; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.network.ConfigurationTask; +import net.neoforged.neoforge.client.gui.RegistryConfigMismatchScreen; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import net.neoforged.neoforge.network.payload.RegistryConfigAckPayload; +import net.neoforged.neoforge.network.payload.RegistryConfigDataPayload; +import net.neoforged.neoforge.network.registryconfigsync.RegistryConfigHandler; +import net.neoforged.neoforge.network.registryconfigsync.RegistryConfigHandlers; +import org.slf4j.Logger; + +public record SyncRegistryConfigTask(ServerConfigurationPacketListener listener) implements ConfigurationTask { + public static final Type TYPE = new Type("synchronize_registry_config"); + public static final Logger LOGGER = LogUtils.getLogger(); + + public static synchronized void handleData(RegistryConfigDataPayload payload, IPayloadContext ctx) { + ClientHandler.handleData(payload, ctx); + } + + public static void handleAck(RegistryConfigAckPayload payload, IPayloadContext ctx) { + ctx.finishCurrentTask(TYPE); + } + + @Override + public synchronized void start(Consumer> packetSender) { + if (listener.getConnection().isMemoryConnection()) { + listener.finishCurrentTask(TYPE); + return; + } + Map configs = new TreeMap<>(); + for (var ent : RegistryConfigHandlers.getAllHandlers().entrySet()) { + try { + JsonElement data = ent.getValue().serializeConfig(); + configs.put(ent.getKey(), data); + } catch (Exception e) { + LOGGER.error("Failed to serialize " + ent.getKey(), e); + listener.disconnect(Component.translatable("neoforge.network.registry_config.encode_fail", ent.getKey())); + return; + } + } + + if (listener.getConnectionType().isOther()) { + if (!configs.isEmpty()) { + // Use plain components as vanilla connections will be missing our translation keys + listener.disconnect(Component.literal("This server does not support vanilla clients as it has registry configs used in clientbound networking")); + } else { + listener.finishCurrentTask(TYPE); + } + return; + } + + packetSender.accept(new RegistryConfigDataPayload(configs).toVanillaClientbound()); + } + + @Override + public Type type() { + return TYPE; + } + + private static class ClientHandler { + static synchronized void handleData(RegistryConfigDataPayload payload, IPayloadContext context) { + Set clientMissing = new TreeSet<>(); + Set serverMissing = new TreeSet<>(); + for (var key : RegistryConfigHandlers.getAllHandlers().keySet()) { + if (!payload.map().containsKey(key)) { + serverMissing.add(key); + } + } + for (var key : payload.map().keySet()) { + if (RegistryConfigHandlers.getHandler(key) == null) { + clientMissing.add(key); + } + } + if (!clientMissing.isEmpty() || !serverMissing.isEmpty()) { + context.disconnect(Component.translatable("neoforge.network.registry_config.mismatch_definition")); + if (!clientMissing.isEmpty()) { + LOGGER.error("Missing registry config handlers on client: {}", clientMissing); + } + if (!serverMissing.isEmpty()) { + LOGGER.error("Missing registry config handlers on server: {}", serverMissing); + } + return; + } + Map mismatched = new LinkedHashMap<>(); + Map reasons = new TreeMap<>(); + for (var ent : payload.map().entrySet()) { + try { + var handler = RegistryConfigHandlers.getHandler(ent.getKey()); + if (handler == null) continue; + var reason = handler.verifyConfig(ent.getValue()); + if (reason != null) { + mismatched.put(handler, ent.getValue()); + reasons.put(ent.getKey(), reason); + } + } catch (Exception e) { + context.disconnect(Component.translatable("neoforge.network.registry_config.decode_fail", ent.getKey())); + return; + } + } + if (!mismatched.isEmpty()) { + context.disconnect(Component.translatable("neoforge.network.registry_config.mismatch_config")); + Minecraft.getInstance().setScreen(new RegistryConfigMismatchScreen( + new JoinMultiplayerScreen(new TitleScreen()), + Component.translatable("fml.registryconfigmismatchscreen.title"), + () -> applyAndRestart(mismatched), reasons)); + return; + } + context.reply(RegistryConfigAckPayload.INSTANCE); + } + + static synchronized void applyAndRestart(Map mismatched) { + for (var ent : mismatched.entrySet()) { + ent.getKey().applyConfig(ent.getValue()); + } + Minecraft.getInstance().stop(); + } + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/event/RegisterRegistryConfigHandlersEvent.java b/src/main/java/net/neoforged/neoforge/network/event/RegisterRegistryConfigHandlersEvent.java new file mode 100644 index 0000000000..915c5e0410 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/event/RegisterRegistryConfigHandlersEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.event; + +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.bus.api.Event; +import net.neoforged.fml.event.IModBusEvent; +import net.neoforged.neoforge.network.registryconfigsync.RegistryConfigHandler; +import org.jetbrains.annotations.ApiStatus; + +/** + * Fired to register registry config handlers. At this point all registry-affecting config should be loaded already. + */ +public class RegisterRegistryConfigHandlersEvent extends Event implements IModBusEvent { + private final Map handlers = new ConcurrentHashMap<>(); + + @ApiStatus.Internal + public RegisterRegistryConfigHandlersEvent() {} + + /** + * Register a registry config handler. + * + * @param id The identifier of the handler + * @param handler The registry config handler + */ + public void register(ResourceLocation id, RegistryConfigHandler handler) { + handlers.put(id, handler); + } + + /** + * Get the registry config handlers that have been registered. + * + * @return The registry config handlers. + */ + @ApiStatus.Internal + public Map getHandlers() { + return new TreeMap<>(handlers); + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigAckPayload.java b/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigAckPayload.java new file mode 100644 index 0000000000..266cefd10d --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigAckPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.payload; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public final class RegistryConfigAckPayload implements CustomPacketPayload { + public static final Type TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath("neoforge", "registry_config_ack")); + + public static final RegistryConfigAckPayload INSTANCE = new RegistryConfigAckPayload(); + + public static final StreamCodec STREAM_CODEC = StreamCodec.unit(INSTANCE); + + private RegistryConfigAckPayload() {} + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigDataPayload.java b/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigDataPayload.java new file mode 100644 index 0000000000..75e3804b2d --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/payload/RegistryConfigDataPayload.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.payload; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import io.netty.buffer.ByteBuf; +import java.util.Map; +import java.util.TreeMap; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record RegistryConfigDataPayload(Map map) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath("neoforge", "registry_config_data")); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.map(i -> new TreeMap<>(), ResourceLocation.STREAM_CODEC, + ByteBufCodecs.stringUtf8(0xffffff).map(JsonParser::parseString, JsonElement::toString)), + RegistryConfigDataPayload::map, RegistryConfigDataPayload::new); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandler.java b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandler.java new file mode 100644 index 0000000000..5315b20fb2 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandler.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.registryconfigsync; + +import com.google.gson.JsonElement; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; + +/** + * A handler to: + *

  • Collect registry-affecting configs on server and parse them to json.
  • + *
  • Verify if received configs are compatible with the client config. If not, explain the difference.
  • + *
  • Overwrite local configs with received server configs.
  • + * This allows mods to have configs that affect the way items and blocks are registered, + * and inform client when those configs are different, preventing misleading registry conflict information. + */ +public interface RegistryConfigHandler { + /** + * Called when players are connecting to server. + * Collect all config values (in any form) that could affect static registry contents and encode them to json. + */ + JsonElement serializeConfig(); + + /** + * Called only when client attempts to join a server. Receive server config produced by {@link RegistryConfigHandler#serializeConfig}. + * + * @param value the server side config received + * @return null when the server side config is compatible with currently loaded client side config.
    + * When local config is not compatible with server config, + * return a brief explanation about what is different and what would happen if local config is replaced by the server side config. + * It will be displayed on {@link net.neoforged.neoforge.client.gui.RegistryConfigMismatchScreen} + */ + @Nullable + Component verifyConfig(JsonElement value); + + /** + * Overwrite local config with received server side config + * + * @param value the server side config received + */ + void applyConfig(JsonElement value); +} diff --git a/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandlers.java b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandlers.java new file mode 100644 index 0000000000..4591d4c94c --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/RegistryConfigHandlers.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.registryconfigsync; + +import java.util.Map; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.fml.ModLoader; +import net.neoforged.neoforge.network.event.RegisterRegistryConfigHandlersEvent; +import org.jetbrains.annotations.Nullable; + +public class RegistryConfigHandlers { + private static Map MAP; + + public static void init() { + MAP = ModLoader.postEventWithReturn(new RegisterRegistryConfigHandlersEvent()).getHandlers(); + } + + public static Map getAllHandlers() { + return MAP; + } + + @Nullable + public static RegistryConfigHandler getHandler(ResourceLocation key) { + return MAP.get(key); + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/registryconfigsync/StartUpRegistryConfigHandler.java b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/StartUpRegistryConfigHandler.java new file mode 100644 index 0000000000..338a29879e --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/StartUpRegistryConfigHandler.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.network.registryconfigsync; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.List; +import net.minecraft.network.chat.Component; +import net.neoforged.neoforge.common.ModConfigSpec; +import org.jetbrains.annotations.Nullable; + +/** + * Template registry config handler that sync entries in start up config marked with {@link net.neoforged.neoforge.common.ModConfigSpec.RestartType#REGISTRY} + *
    + * Modders need to create this handler and register through {@link net.neoforged.neoforge.network.event.RegisterRegistryConfigHandlersEvent} + *
    + * Note that it might not be able to serialize all kinds of config entries. In the case where default implementation fails, override corresponding methods. + */ +public class StartUpRegistryConfigHandler implements RegistryConfigHandler { + private final ModConfigSpec config; + + public StartUpRegistryConfigHandler(ModConfigSpec config) { + this.config = config; + } + + @Override + public JsonElement serializeConfig() { + JsonObject ans = new JsonObject(); + for (var e : config.getSpec().entrySet()) { + ModConfigSpec.ConfigValue value = e.getValue(); + if (value.getSpec().restartType() == ModConfigSpec.RestartType.REGISTRY) { + var val = value.getRawData(); + ans.add(e.getKey(), encode(val)); + } + } + return ans; + } + + @Override + public @Nullable Component verifyConfig(JsonElement json) { + var obj = json.getAsJsonObject(); + int counter = 0; + for (var e : config.getSpec().entrySet()) { + ModConfigSpec.ConfigValue value = e.getValue(); + if (value.getSpec().restartType() == ModConfigSpec.RestartType.REGISTRY) { + // use getRawData instead of getRaw to get raw config object instead of the parsed value + var val = encode(value.getRawData()); + var serverVal = obj.get(e.getKey()); + if (!val.equals(serverVal)) { + counter++; + } + } + } + if (counter > 0) { + return Component.translatable("fml.registryconfigmismatchscreen.mismatch", counter); + } + return null; + } + + @Override + public void applyConfig(JsonElement json) { + var obj = json.getAsJsonObject(); + for (var e : config.getSpec().entrySet()) { + ModConfigSpec.ConfigValue value = e.getValue(); + if (value.getSpec().restartType() == ModConfigSpec.RestartType.REGISTRY) { + // use setRawData to set raw config object instead of the parsed value + value.setRawData(decode(obj.get(e.getKey()))); + } + } + config.save(); + } + + /** + * Encode raw config object into json. + */ + protected JsonElement encode(@Nullable Object obj) { + if (obj == null) return JsonNull.INSTANCE; + return switch (obj) { + case String str -> new JsonPrimitive(str); + case Number num -> new JsonPrimitive(num); + case Boolean bool -> new JsonPrimitive(bool); + case List list -> list.stream().map(this::encode).collect(JsonArray::new, JsonArray::add, JsonArray::addAll); + default -> throw new IllegalStateException("Unexpected value of class: " + obj.getClass()); + }; + } + + /** + * Decode json into raw config object + */ + @Nullable + protected Object decode(JsonElement obj) { + return switch (obj) { + case JsonNull ignored -> null; + case JsonPrimitive prim -> prim.isBoolean() ? prim.getAsBoolean() : prim.isString() ? prim.getAsString() : prim.isNumber() ? prim.getAsNumber() : null; + case JsonArray arr -> arr.asList().stream().map(this::decode).toList(); + default -> throw new IllegalStateException("Unexpected value: " + obj); + }; + } +} diff --git a/src/main/java/net/neoforged/neoforge/network/registryconfigsync/package-info.java b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/package-info.java new file mode 100644 index 0000000000..46589e98d4 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/network/registryconfigsync/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault + +package net.neoforged.neoforge.network.registryconfigsync; + +import javax.annotation.ParametersAreNonnullByDefault; +import net.minecraft.MethodsReturnNonnullByDefault; diff --git a/src/main/resources/assets/neoforge/lang/en_us.json b/src/main/resources/assets/neoforge/lang/en_us.json index 484a2b29f3..7b75701f0f 100644 --- a/src/main/resources/assets/neoforge/lang/en_us.json +++ b/src/main/resources/assets/neoforge/lang/en_us.json @@ -60,6 +60,11 @@ "fml.modmismatchscreen.table.reason": "Reason", "fml.modmismatchscreen.table.visit.mod_page": "Open the mod page of the mod that registers the channel: %s", "fml.modmismatchscreen.simplifiedview": "Simplified view", + "fml.registryconfigmismatchscreen.table.type": "Registry Config Type", + "fml.registryconfigmismatchscreen.table.reason": "Mismatch Detail", + "fml.registryconfigmismatchscreen.title": "Mismatch in Registry Configs. Note that overwriting local config might corrupt your single player saves. Backup first if necessary.", + "fml.registryconfigmismatchscreen.overwrite": "Overwrite local configs", + "fml.registryconfigmismatchscreen.mismatch": "%s entries in conflict", "fml.resources.modresources": "Resources for %1$s mod files", "fml.resources.moddata": "Data for %1$s mod files", @@ -265,6 +270,11 @@ "neoforge.network.extensible_enums.enum_set_mismatch": "The set of extensible enums on the client and server do not match. Make sure you are using the same NeoForge version as the server", "neoforge.network.extensible_enums.enum_entry_mismatch": "The set of values added to extensible enums on the client and server do not match. Make sure you are using the same mod and NeoForge versions as the server. See the log for more details", + "neoforge.network.registry_config.encode_fail": "Failed to encode registry config of type %s", + "neoforge.network.registry_config.decode_fail": "Failed to decode registry config of type %s", + "neoforge.network.registry_config.mismatch_definition": "Mismatch in registry config definition", + "neoforge.network.registry_config.mismatch_config": "Mismatch in registry configs", + "neoforge.attribute.debug.base": "[Entity: %s | Item: %s]", "neoforge.value.flat": "%s",