diff --git a/patches/minecraft/net/minecraft/client/multiplayer/ClientHandshakePacketListenerImpl.java.patch b/patches/minecraft/net/minecraft/client/multiplayer/ClientHandshakePacketListenerImpl.java.patch index 364a395efb..18e172351a 100644 --- a/patches/minecraft/net/minecraft/client/multiplayer/ClientHandshakePacketListenerImpl.java.patch +++ b/patches/minecraft/net/minecraft/client/multiplayer/ClientHandshakePacketListenerImpl.java.patch @@ -8,6 +8,15 @@ this.f_104522_.m_129505_(new ClientPacketListener(this.f_104519_, this.f_104520_, this.f_104522_, this.f_104523_, this.f_104519_.m_193590_())); } +@@ -122,7 +_,7 @@ + if (this.f_104520_ != null && this.f_104520_ instanceof RealmsScreen) { + this.f_104519_.m_91152_(new DisconnectedRealmsScreen(this.f_104520_, CommonComponents.f_130661_, p_104543_)); + } else { +- this.f_104519_.m_91152_(new DisconnectedScreen(this.f_104520_, CommonComponents.f_130661_, p_104543_)); ++ this.f_104519_.m_91152_(net.minecraftforge.network.NetworkHooks.getModMismatchData(f_104522_) != null ? new net.minecraftforge.client.gui.ModMismatchDisconnectedScreen(this.f_104520_, CommonComponents.f_130661_, p_104543_, net.minecraftforge.network.NetworkHooks.getModMismatchData(f_104522_)) : new DisconnectedScreen(this.f_104520_, CommonComponents.f_130661_, p_104543_)); + } + + } @@ -143,6 +_,7 @@ } diff --git a/src/main/java/net/minecraftforge/client/gui/ModMismatchDisconnectedScreen.java b/src/main/java/net/minecraftforge/client/gui/ModMismatchDisconnectedScreen.java new file mode 100644 index 0000000000..1bade1b79d --- /dev/null +++ b/src/main/java/net/minecraftforge/client/gui/ModMismatchDisconnectedScreen.java @@ -0,0 +1,301 @@ +/* + * Minecraft Forge + * Copyright (c) 2016-2022. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation version 2.1 + * of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package net.minecraftforge.client.gui; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.tuple.Pair; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.Tesselator; + +import net.minecraft.ChatFormatting; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +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.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.HoverEvent.Action; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import net.minecraftforge.common.ForgeI18n; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.FMLPaths; +import net.minecraftforge.network.ConnectionData.ModMismatchData; +import net.minecraftforge.network.NetworkRegistry; + +public class ModMismatchDisconnectedScreen extends Screen +{ + private final Component reason; + private MultiLineLabel message = MultiLineLabel.EMPTY; + private final Screen parent; + private int textHeight; + private final ModMismatchData modMismatchData; + private final Path modsDir; + private final Path logFile; + private final int listHeight; + private final Map> presentModData; + private final List missingModData; + private final Map mismatchedModData; + private final List allModIds; + private final Map presentModUrls; + private final boolean mismatchedDataFromServer; + + public ModMismatchDisconnectedScreen(Screen parentScreen, Component title, Component reason, ModMismatchData modMismatchData) + { + super(title); + this.parent = parentScreen; + this.reason = reason; + this.modMismatchData = modMismatchData; + this.modsDir = FMLPaths.MODSDIR.get(); + this.logFile = FMLPaths.GAMEDIR.get().resolve(Paths.get("logs","latest.log")); + this.listHeight = modMismatchData.containsMismatches() ? 140 : 0; + this.mismatchedDataFromServer = modMismatchData.mismatchedDataFromServer(); + this.presentModData = modMismatchData.presentModData(); + this.missingModData = modMismatchData.mismatchedModData().entrySet().stream().filter(e -> e.getValue().equals(NetworkRegistry.ABSENT)).map(Entry::getKey).collect(Collectors.toList()); + this.mismatchedModData = modMismatchData.mismatchedModData().entrySet().stream().filter(e -> !e.getValue().equals(NetworkRegistry.ABSENT)).collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + this.allModIds = presentModData.keySet().stream().map(ResourceLocation::getNamespace).distinct().collect(Collectors.toList()); + this.presentModUrls = ModList.get().getMods().stream().filter(info -> allModIds.contains(info.getModId())).map(info -> Pair.of(info.getModId(), (String)info.getConfig().getConfigElement("displayURL").orElse(""))).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + } + + @Override + protected void init() + { + this.message = MultiLineLabel.create(this.font, this.reason, this.width - 50); + this.textHeight = this.message.getLineCount() * 9; + + int listLeft = Math.max(8, this.width / 2 - 220); + int listWidth = Math.min(440, this.width - 16); + int upperButtonHeight = Math.min((this.height + this.listHeight + this.textHeight) / 2 + 10, this.height - 50); + int lowerButtonHeight = Math.min((this.height + this.listHeight + this.textHeight) / 2 + 35, this.height - 25); + if (modMismatchData.containsMismatches()) + this.addRenderableWidget(new MismatchInfoPanel(minecraft, listWidth, listHeight, (this.height - this.listHeight) / 2, listLeft)); + + int buttonWidth = Math.min(210, this.width / 2 - 20); + this.addRenderableWidget(new Button(Math.max(this.width / 4 - buttonWidth / 2, listLeft), upperButtonHeight, buttonWidth, 20, new TextComponent(ForgeI18n.parseMessage("fml.button.open.file", logFile.getFileName())), button -> Util.getPlatform().openFile(logFile.toFile()))); + this.addRenderableWidget(new Button(Math.min(this.width * 3 / 4 - buttonWidth / 2, listLeft + listWidth - buttonWidth), upperButtonHeight, buttonWidth, 20, new TextComponent(ForgeI18n.parseMessage("fml.button.open.mods.folder")), button -> Util.getPlatform().openFile(modsDir.toFile()))); + this.addRenderableWidget(new Button((this.width - buttonWidth) / 2, lowerButtonHeight, buttonWidth, 20, new TranslatableComponent("gui.toMenu"), button -> this.minecraft.setScreen(this.parent))); + } + + @Override + public void render(PoseStack stack, int mouseX, int mouseY, float partialTicks) + { + this.renderBackground(stack); + int textYOffset = modMismatchData.containsMismatches() ? 18 : 0; + drawCenteredString(stack, this.font, this.title, this.width / 2, (this.height - this.listHeight - this.textHeight) / 2 - textYOffset - 9 * 2, 0xAAAAAA); + this.message.renderCentered(stack, this.width / 2, (this.height - this.listHeight - this.textHeight) / 2 - textYOffset); + super.render(stack, mouseX, mouseY, partialTicks); + } + + class MismatchInfoPanel extends ScrollPanel + { + private final List>> lineTable; + private final int contentSize; + private final int nameIndent = 10; + private final int tableWidth = width - border * 2 - 6 - nameIndent; + private final int nameWidth = tableWidth * 3 / 5; + private final int versionWidth = (tableWidth - nameWidth) / 2; + + public MismatchInfoPanel(Minecraft client, int width, int height, int top, int left) + { + super(client, width, height, top, left); + + //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>> rawTable = new ArrayList<>(); + if (!missingModData.isEmpty()) + { + //The header of the section, colored in gray + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage(mismatchedDataFromServer ? "fml.modmismatchscreen.missingmods.server" : "fml.modmismatchscreen.missingmods.client")).withStyle(ChatFormatting.GRAY), null)); + //This table section contains the mod name and mod version of each mod that has a missing remote counterpart (if the mod is missing on the server, the client mod version is displayed, and vice versa) + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage("fml.modmismatchscreen.table.modname")).withStyle(ChatFormatting.UNDERLINE), Pair.of("", ForgeI18n.parseMessage(mismatchedDataFromServer ? "fml.modmismatchscreen.table.youhave" : "fml.modmismatchscreen.table.youneed")))); + int i = 0; + for (ResourceLocation mod : missingModData) { + rawTable.add(Pair.of(toModNameComponent(mod, presentModData.get(mod).getLeft(), i), Pair.of("", presentModData.getOrDefault(mod, Pair.of("", "")).getRight()))); + if (++i >= 10) { + //If too many missing mod entries are present, append a line referencing how to see the full list and stop rendering any more entries + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage("fml.modmismatchscreen.additional", missingModData.size() - i)).withStyle(ChatFormatting.ITALIC), Pair.of("", ""))); + break; + } + } + rawTable.add(Pair.of(new TextComponent(" "), null)); //padding + } + if (!mismatchedModData.isEmpty()) + { + //The header of the table section, colored in gray + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage("fml.modmismatchscreen.mismatchedmods")).withStyle(ChatFormatting.GRAY), null)); + //This table section contains the mod name and both mod versions of each mod that has a mismatching client and server version + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage("fml.modmismatchscreen.table.modname")).withStyle(ChatFormatting.UNDERLINE), Pair.of(ForgeI18n.parseMessage(mismatchedDataFromServer ? "fml.modmismatchscreen.table.youhave" : "fml.modmismatchscreen.table.serverhas"), ForgeI18n.parseMessage(mismatchedDataFromServer ? "fml.modmismatchscreen.table.serverhas" : "fml.modmismatchscreen.table.youhave")))); + int i = 0; + for (Map.Entry modData : mismatchedModData.entrySet()) { + rawTable.add(Pair.of(toModNameComponent(modData.getKey(), presentModData.get(modData.getKey()).getLeft(), i), Pair.of(presentModData.getOrDefault(modData.getKey(), Pair.of("", "")).getRight(), modData.getValue()))); + if (++i >= 10) { + //If too many mismatched mod entries are present, append a line referencing how to see the full list and stop rendering any more entries + rawTable.add(Pair.of(new TextComponent(ForgeI18n.parseMessage("fml.modmismatchscreen.additional", mismatchedModData.size() - i)).withStyle(ChatFormatting.ITALIC), Pair.of("", ""))); + break; + } + } + rawTable.add(Pair.of(new TextComponent(" "), null)); //padding + } + + this.lineTable = rawTable.stream().flatMap(p -> splitLineToWidth(p.getKey(), p.getValue()).stream()).collect(Collectors.toList()); + this.contentSize = lineTable.size(); + } + + /** + * Splits the raw name and version 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 the name of a mod entry + * @param versions The last two elements of the content row, usually representing the mod versions. If either one or both of them are not given, the first element may take up more space within the table. + * @return A list of table rows consisting of 3 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, Pair versions) + { + Style style = name.getStyle(); + int versionColumns = versions == null ? 0 : (versions.getLeft().isEmpty() ? (versions.getRight().isEmpty() ? 0 : 1) : 2); + int adaptedNameWidth = nameWidth + versionWidth * (2 - versionColumns) - 4; //the name width may be expanded when the version column string is missing + List nameLines = font.split(name, adaptedNameWidth); + List clientVersionLines = font.split(new TextComponent(versions != null ? versions.getLeft() : "").setStyle(style), versionWidth - 4); + List serverVersionLines = font.split(new TextComponent(versions != null ? versions.getRight() : "").setStyle(style), versionWidth - 4); + List>> splitLines = new ArrayList<>(); + int rowsOccupied = Math.max(nameLines.size(), Math.max(clientVersionLines.size(), serverVersionLines.size())); + for (int i = 0; i < rowsOccupied; i++) { + splitLines.add(Pair.of(i < nameLines.size() ? nameLines.get(i) : FormattedCharSequence.EMPTY, versions == null ? null : Pair.of(i < clientVersionLines.size() ? clientVersionLines.get(i) : FormattedCharSequence.EMPTY, i < serverVersionLines.size() ? serverVersionLines.get(i) : FormattedCharSequence.EMPTY))); + } + return splitLines; + } + + /** + * Adds a style information to the given mod name string. The style assigned to the returned component contains the color of the mod name, + * a hover event containing the given id, and an optional click event, which opens the homepage of mod, if present. + * @param id An id that gets displayed in the hover event. Depending on the origin it may only consist of a namespace (the mod id) or a namespace + path (a channel id associated with the mod). + * @param modName The name of the mod. It will be rendered as the main text component. + * @param color Defines the color of the returned style information. An odd number will result in a yellow, an even one in a gold color. This color variation makes it easier for users to distinguish different mod entries. + * @return A component with the mod name as the main text component, and an assigned style which will be used for the whole content row. + */ + private MutableComponent toModNameComponent(ResourceLocation id, String modName, int color) + { + String modId = id.getNamespace(); + String tooltipId = id.getPath().isEmpty() ? id.getNamespace() : id.toString(); + return new TextComponent(modName).withStyle(color % 2 == 0 ? ChatFormatting.GOLD : ChatFormatting.YELLOW) + .withStyle(s -> s.withHoverEvent(new HoverEvent(Action.SHOW_TEXT, new TextComponent(tooltipId + (!presentModUrls.getOrDefault(modId, "").isEmpty() ? "\n" + ForgeI18n.parseMessage("fml.modmismatchscreen.homepage") : ""))))) + .withStyle(s -> s.withClickEvent(!presentModUrls.getOrDefault(modId, "").isEmpty() ? new ClickEvent(ClickEvent.Action.OPEN_URL, presentModUrls.get(modId)) : null)); + } + + @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(PoseStack stack, int entryRight, int relativeY, Tesselator tess, int mouseX, int mouseY) + { + int i = 0; + + for (Pair> line : lineTable) { + FormattedCharSequence name = line.getLeft(); + Pair versions = 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 + (versions == null ? 0 : nameIndent); + font.draw(stack, name, nameLeft, relativeY + i * 12, color); + if (versions != null) + { + font.draw(stack, versions.getLeft(), left + border + nameIndent + nameWidth, relativeY + i * 12, color); + font.draw(stack, versions.getRight(), left + border + nameIndent + nameWidth + versionWidth, relativeY + i * 12, color); + } + + i++; + } + } + + @Override + public void render(PoseStack stack, int mouseX, int mouseY, float partialTicks) + { + super.render(stack, mouseX, mouseY, partialTicks); + Style style = getComponentStyleAt(mouseX, mouseY); + if (style != null && style.getHoverEvent() != null) + { + ModMismatchDisconnectedScreen.this.renderComponentHoverEffect(stack, style, mouseX, mouseY); + } + } + + public Style getComponentStyleAt(double x, double y) + { + if (this.isMouseOver(x, y)) + { + double relativeY = y - this.top + this.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/minecraftforge/network/ConnectionData.java b/src/main/java/net/minecraftforge/network/ConnectionData.java index 517b48288b..e23474c5bc 100644 --- a/src/main/java/net/minecraftforge/network/ConnectionData.java +++ b/src/main/java/net/minecraftforge/network/ConnectionData.java @@ -7,19 +7,27 @@ package net.minecraftforge.network; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Multimap; + import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.fml.ModList; import java.util.List; import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.tuple.Pair; public class ConnectionData { - private ImmutableList modList; + private ImmutableMap> modData; private ImmutableMap channels; - /* package private */ ConnectionData(List modList, Map channels) + /* package private */ ConnectionData(Map> modData, Map channels) { - this.modList = ImmutableList.copyOf(modList); + this.modData = ImmutableMap.copyOf(modData); this.channels = ImmutableMap.copyOf(channels); } @@ -35,7 +43,22 @@ public class ConnectionData */ public ImmutableList getModList() { - return modList; + return modData.keySet().asList(); + } + + /** + * Returns a map of mods and respective mod names and versions present in the remote. + * WARNING: This list is not authoritative. + * A mod missing from the list does not mean the mod isn't there, + * and similarly a mod present in the list does not mean it is there. + * People using hacked clients WILL hack the mod lists to make them look correct. + * Do not use this as an anti-cheat feature! + * + * @return An immutable map of MODIDs and their respective mod versions + */ + public ImmutableMap> getModData() + { + return modData; } /** @@ -52,4 +75,129 @@ public class ConnectionData { return channels; } + + /** + * A class for holding the mod mismatch data of a failed handshake. + * Contains a list of mismatched channels, the channels present on the side the handshake failed on, the mods with mismatching registries (if available) and the information of whether the mismatching data's origin is the server. + * @param mismatchedModData The data of the mismatched or missing mods/channels, consisting of the mod or channel id and the version of the respective mod. If the mod version is absent, the entry is assumed to be missing on either side. If it is present, the entry gets treated as a mismatch. + * @param presentModData The data of the channels/mods from the side the mismatch data doesn't originate from, consisting of the mod or channel id and the name and version of the respective mod. This data is useful for a proper comparison against the mismatched channel data. The data is stored like this: [id -> [modName, modVersion]] + * @param mismatchedDataFromServer Whether the mismatched data originates from the server. Note, that the mismatched data origin does not tell us if the actual mismatch check happened on the client or the server. + */ + public record ModMismatchData(Map mismatchedModData, Map> presentModData, boolean mismatchedDataFromServer) + { + /** + * Creates a ModMismatchData instance from given channel mismatch data, which is processed side-aware depending on the value of mismatchedDataFromServer + * @param mismatchedChannels The list of channels that were listed as mismatches, either because they are missing on one side or because their versions mismatched + * @param connectionData The connection data instance responsible for collecting the server mod data. + * @param mismatchedDataFromServer Whether the mismatched data originates from the server. + */ + public static ModMismatchData channel(Map mismatchedChannels, ConnectionData connectionData, boolean mismatchedDataFromServer) + { + Map mismatchedChannelData = enhanceWithModVersion(mismatchedChannels, connectionData, mismatchedDataFromServer); + Map> presentChannelData = getPresentChannelData(mismatchedChannels.keySet(), connectionData, mismatchedDataFromServer); + + return new ModMismatchData(mismatchedChannelData, presentChannelData, mismatchedDataFromServer); + } + + /** + * Creates a ModMismatchData instance from given mismatched registry entries. In this case, the mismatched data is always treated as originating from the client because registry entries missing on the server never cause the handshake to fail. + * @param mismatchedRegistryData The list of mismatched registries and the associated mismatched registry entries. The data is stored like this: "registryNamespace:registryPath" -> "entryNamespace:entryPath" + * @param connectionData The connection data instance responsible for collecting the server mod data. + */ + public static ModMismatchData registry(Multimap mismatchedRegistryData, ConnectionData connectionData) + { + List mismatchedRegistryMods = mismatchedRegistryData.values().stream().map(ResourceLocation::getNamespace).distinct().map(id -> new ResourceLocation(id, "")).toList(); + Map mismatchedRegistryModData = mismatchedRegistryMods.stream().map(id -> ModList.get().getModContainerById(id.getNamespace()).map(modContainer -> Pair.of(id, modContainer.getModInfo().getVersion().toString())).orElse(Pair.of(id, NetworkRegistry.ABSENT))).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + Map> presentModData = getServerSidePresentModData(mismatchedRegistryModData.keySet(), connectionData); + + return new ModMismatchData(mismatchedRegistryModData, presentModData, false); + } + + /** + * @return true if this ModMismatchData instance contains channel or registry mismatches + */ + public boolean containsMismatches() + { + return mismatchedModData != null && !mismatchedModData.isEmpty(); + } + + /** + * Enhances a map of mismatched channels with the corresponding mod version. + * @param mismatchedChannels The original mismatched channel list, containing the id and version of the mismatched channel. + * @param connectionData The connection data instance responsible for collecting the server mod data. + * @param mismatchedDataFromServer Whether the mismatched data originates from the server. The given mismatched channel list gets processed side-aware depending on the value of this parameter. + * @return A map containing the id of the channel and the version of the corresponding mod, or NetworkRegistry.ABSENT if the channel is missing. + */ + private static Map enhanceWithModVersion(Map mismatchedChannels, ConnectionData connectionData, boolean mismatchedDataFromServer) + { + Map mismatchedModVersions; + + if (mismatchedDataFromServer) //enhance with data from the server + { + mismatchedModVersions = connectionData != null ? connectionData.getModData().entrySet().stream().collect(Collectors.toMap(Entry::getKey, e -> e.getValue().getRight())) : Map.of(); + } + else //enhance with data from the client + { + mismatchedModVersions = ModList.get().getMods().stream().map(info -> Pair.of(info.getModId(), info.getVersion().toString())).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + } + return mismatchedChannels.keySet().stream().map(channel -> Pair.of(channel, mismatchedChannels.get(channel).equals(NetworkRegistry.ABSENT) ? NetworkRegistry.ABSENT : mismatchedModVersions.getOrDefault(channel.getNamespace(), NetworkRegistry.ABSENT))).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + } + + + /** + * Queries the channel data from the side the mismatched data isn't from, in order to provide a map of all the present channels for a proper comparison against the mismatched channel data. + * @param mismatchedChannelsFilter A filter that gets used in order to only query the data of mismatched mods, because that's the only one we need for a proper comparison between mismatched and present mods. + * @param connectionData The connection data instance responsible for collecting the server mod data. + * @param mismatchedDataFromServer Whether the mismatched data originates from the server. The data gets queried from the side that the mismatch data does not originate from. + * @return The data of all relevant present channels, containing the channel id and the name and version of the corresponding mod. + */ + private static Map> getPresentChannelData(Set mismatchedChannelsFilter, ConnectionData connectionData, boolean mismatchedDataFromServer) + { + Map channelData; + + if (mismatchedDataFromServer) //mismatch data comes from the server, use client channel data + { + channelData = NetworkRegistry.buildChannelVersions(); + } + else //mismatch data comes from the client, use server channel data + { + channelData = connectionData != null ? connectionData.getChannels() : Map.of(); + } + return channelData.keySet().stream().filter(mismatchedChannelsFilter::contains).map(id -> getPresentModDataFromChannel(id, connectionData, mismatchedDataFromServer)).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); + } + + /** + * Queries the mod data from a given channel id from the side the mismatched data isn't from. + * @param channel The id of the channel the mod data should be queried for. + * @param connectionData The connection data instance responsible for collecting the server mod data. + * @param mismatchedDataFromServer Whether the mismatched data originates from the server. The data gets queried from the side that the mismatch data does not originate from. + * @return The mod data corresponding to the channel id, containing the channel id and the name and version of the corresponding mod. + */ + private static Pair> getPresentModDataFromChannel(ResourceLocation channel, ConnectionData connectionData, boolean mismatchedDataFromServer) + { + if (mismatchedDataFromServer) + { + return ModList.get().getModContainerById(channel.getNamespace()).map(modContainer -> Pair.of(channel, Pair.of(modContainer.getModInfo().getDisplayName(), modContainer.getModInfo().getVersion().toString()))).orElse(Pair.of(channel, Pair.of(channel.getNamespace(), ""))); + } + else + { + Map> modData = connectionData != null ? connectionData.getModData() : Map.of(); + Pair modDataFromChannel = modData.getOrDefault(channel.getNamespace(), Pair.of(channel.getNamespace(), "")); + return Pair.of(channel, modDataFromChannel.getLeft().isEmpty() ? Pair.of(channel.getNamespace(), modDataFromChannel.getRight()) : modDataFromChannel); + } + } + + /** + * Queries the mod data from the server side. Useful in case of a registry mismatch, which always gets detected on the client. + * @param mismatchedModsFilter A filter that gets used in order to only query the data of mismatched mods, because that's the only one we need for a proper comparison between mismatched and present mods. + * @param connectionData The connection data instance responsible for collecting the server mod data. + * @return The mod data from the server, containing the channel id and the name and version of the corresponding mod. + */ + private static Map> getServerSidePresentModData(Set mismatchedModsFilter, ConnectionData connectionData) + { + Map> serverModData = connectionData != null ? connectionData.getModData() : Map.of(); + Set modIdFilter = mismatchedModsFilter.stream().map(ResourceLocation::getNamespace).collect(Collectors.toSet()); + return serverModData.entrySet().stream().filter(e -> modIdFilter.contains(e.getKey())).collect(Collectors.toMap(e -> new ResourceLocation(e.getKey(), ""), Entry::getValue)); + } + } } diff --git a/src/main/java/net/minecraftforge/network/HandshakeHandler.java b/src/main/java/net/minecraftforge/network/HandshakeHandler.java index 1afec1c9d1..877d57e3d3 100644 --- a/src/main/java/net/minecraftforge/network/HandshakeHandler.java +++ b/src/main/java/net/minecraftforge/network/HandshakeHandler.java @@ -21,10 +21,12 @@ import net.minecraft.server.network.ServerLoginPacketListenerImpl; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.common.util.LogMessageAdapter; import net.minecraftforge.event.entity.player.PlayerNegotiationEvent; +import net.minecraftforge.network.ConnectionData.ModMismatchData; import net.minecraftforge.network.simple.SimpleChannel; import net.minecraftforge.registries.DataPackRegistriesHooks; import net.minecraftforge.registries.ForgeRegistry; import net.minecraftforge.registries.GameData; +import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Marker; @@ -38,9 +40,12 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.function.IntSupplier; import java.util.function.Supplier; +import java.util.stream.Collectors; import static net.minecraftforge.registries.ForgeRegistry.REGISTRIES; @@ -178,10 +183,14 @@ public class HandshakeHandler void handleServerModListOnClient(HandshakeMessages.S2CModList serverModList, Supplier c) { LOGGER.debug(FMLHSMARKER, "Logging into server with mod list [{}]", String.join(", ", serverModList.getModList())); - boolean accepted = NetworkRegistry.validateClientChannels(serverModList.getChannels()); + Map mismatchedChannels = NetworkRegistry.validateClientChannels(serverModList.getChannels()); c.get().setPacketHandled(true); - if (!accepted) { + //The connection data needs to be modified before a new ModMismatchData instance could be constructed + NetworkHooks.appendConnectionData(c.get().getNetworkManager(), serverModList.getModList().stream().collect(Collectors.toMap(Function.identity(), s -> Pair.of("", ""))), serverModList.getChannels()); + if (!mismatchedChannels.isEmpty()) { LOGGER.error(FMLHSMARKER, "Terminating connection with server, mismatched mod list"); + //Populate the mod mismatch attribute with a new mismatch data instance to indicate that the disconnect happened due to a mod mismatch + c.get().getNetworkManager().channel().attr(NetworkConstants.FML_MOD_MISMATCH_DATA).set(ModMismatchData.channel(mismatchedChannels, NetworkHooks.getConnectionData(c.get().getNetworkManager()), true)); c.get().getNetworkManager().disconnect(new TextComponent("Connection closed - mismatched mod channel list")); return; } @@ -207,14 +216,18 @@ public class HandshakeHandler LOGGER.debug(FMLHSMARKER, "Accepted server connection"); // Set the modded marker on the channel so we know we got packets c.get().getNetworkManager().channel().attr(NetworkConstants.FML_NETVERSION).set(NetworkConstants.NETVERSION); - c.get().getNetworkManager().channel().attr(NetworkConstants.FML_CONNECTION_DATA) - .set(new ConnectionData(serverModList.getModList(), serverModList.getChannels())); this.registriesToReceive = new HashSet<>(serverModList.getRegistries()); this.registrySnapshots = Maps.newHashMap(); LOGGER.debug(REGISTRIES, "Expecting {} registries: {}", ()->this.registriesToReceive.size(), ()->this.registriesToReceive); } + void handleModData(HandshakeMessages.S2CModData serverModData, Supplier c) + { + c.get().getNetworkManager().channel().attr(NetworkConstants.FML_CONNECTION_DATA).set(new ConnectionData(serverModData.getMods(), new HashMap<>())); + c.get().setPacketHandled(true); + } + void handleIndexedMessage(MSG message, Supplier c) { LOGGER.debug(FMLHSMARKER, "Received client indexed reply {} of type {}", message.getAsInt(), message.getClass().getName()); @@ -227,18 +240,33 @@ public class HandshakeHandler void handleClientModListOnServer(HandshakeMessages.C2SModListReply clientModList, Supplier c) { LOGGER.debug(FMLHSMARKER, "Received client connection with modlist [{}]", String.join(", ", clientModList.getModList())); - boolean accepted = NetworkRegistry.validateServerChannels(clientModList.getChannels()); + Map mismatchedChannels = NetworkRegistry.validateServerChannels(clientModList.getChannels()); c.get().getNetworkManager().channel().attr(NetworkConstants.FML_CONNECTION_DATA) - .set(new ConnectionData(clientModList.getModList(), clientModList.getChannels())); + .set(new ConnectionData(clientModList.getModList().stream().collect(Collectors.toMap(Function.identity(), s -> Pair.of("", ""))), clientModList.getChannels())); c.get().setPacketHandled(true); - if (!accepted) { + if (!mismatchedChannels.isEmpty()) { LOGGER.error(FMLHSMARKER, "Terminating connection with client, mismatched mod list"); + NetworkConstants.handshakeChannel.reply(new HandshakeMessages.S2CChannelMismatchData(mismatchedChannels), c.get()); c.get().getNetworkManager().disconnect(new TextComponent("Connection closed - mismatched mod channel list")); return; } LOGGER.debug(FMLHSMARKER, "Accepted client connection mod list"); } + void handleModMismatchData(HandshakeMessages.S2CChannelMismatchData modMismatchData, Supplier c) + { + if (!modMismatchData.getMismatchedChannelData().isEmpty()) + { + LOGGER.error(FMLHSMARKER, "Channels [{}] rejected their client side version number", + modMismatchData.getMismatchedChannelData().keySet().stream().map(Object::toString).collect(Collectors.joining(","))); + LOGGER.error(FMLHSMARKER, "Terminating connection with server, mismatched mod list"); + c.get().setPacketHandled(true); + //Populate the mod mismatch attribute with a new mismatch data instance to indicate that the disconnect happened due to a mod mismatch + c.get().getNetworkManager().channel().attr(NetworkConstants.FML_MOD_MISMATCH_DATA).set(ModMismatchData.channel(modMismatchData.getMismatchedChannelData(), NetworkHooks.getConnectionData(c.get().getNetworkManager()), false)); + c.get().getNetworkManager().disconnect(new TextComponent("Connection closed - mismatched mod channel list")); + } + } + void handleRegistryMessage(final HandshakeMessages.S2CRegistry registryPacket, final Supplier contextSupplier){ LOGGER.debug(FMLHSMARKER,"Received registry packet for {}", registryPacket.getRegistryName()); this.registriesToReceive.remove(registryPacket.getRegistryName()); @@ -260,6 +288,7 @@ public class HandshakeHandler private boolean handleRegistryLoading(final Supplier contextSupplier) { // We use a countdown latch to suspend the impl thread pending the client thread processing the registry data AtomicBoolean successfulConnection = new AtomicBoolean(false); + AtomicReference> registryMismatches = new AtomicReference<>(); CountDownLatch block = new CountDownLatch(1); contextSupplier.get().enqueueWork(() -> { LOGGER.debug(FMLHSMARKER, "Injecting registry snapshot from server."); @@ -270,6 +299,7 @@ public class HandshakeHandler missingData.forEach((reg, entry)-> sb.append("\t").append(reg).append(": ").append(entry).append('\n')))); } successfulConnection.set(missingData.isEmpty()); + registryMismatches.set(missingData); block.countDown(); }); LOGGER.debug(FMLHSMARKER, "Waiting for registries to load."); @@ -282,6 +312,8 @@ public class HandshakeHandler LOGGER.debug(FMLHSMARKER, "Registry load complete, continuing handshake."); } else { LOGGER.error(FMLHSMARKER, "Failed to load registry, closing connection."); + //Populate the mod mismatch attribute with a new mismatch data instance to indicate that the disconnect happened due to a mod mismatch + this.manager.channel().attr(NetworkConstants.FML_MOD_MISMATCH_DATA).set(ModMismatchData.registry(registryMismatches.get(), NetworkHooks.getConnectionData(contextSupplier.get().getNetworkManager()))); this.manager.disconnect(new TextComponent("Failed to synchronize registry data from server, closing connection")); } return successfulConnection.get(); @@ -322,7 +354,8 @@ public class HandshakeHandler NetworkRegistry.LoginPayload message = messageList.get(packetPosition); LOGGER.debug(FMLHSMARKER, "Sending ticking packet info '{}' to '{}' sequence {}", message.getMessageContext(), message.getChannelName(), packetPosition); - sentMessages.add(packetPosition); + if (message.needsResponse()) + sentMessages.add(packetPosition); loginWrapper.sendServerToClientLoginPacket(message.getChannelName(), message.getData(), packetPosition, this.manager); packetPosition++; } @@ -352,4 +385,20 @@ public class HandshakeHandler } return false; } + + /** + * Helper method to determine if the S2C packet at the given packet position needs a response in form of a packet handled in {@link HandshakeHandler#handleIndexedMessage} for the handshake to progress. + * @param mgr The impl manager for this connection + * @param packetPosition The packet position of the packet that the status is queried of + * @return true if the packet at the given packet position needs a response and thus may stop the handshake from progressing + */ + public static boolean packetNeedsResponse(Connection mgr, int packetPosition) + { + HandshakeHandler handler = mgr.channel().attr(NetworkConstants.FML_HANDSHAKE_HANDLER).get(); + if (handler != null) + { + return handler.sentMessages.contains(packetPosition); + } + return false; + } } diff --git a/src/main/java/net/minecraftforge/network/HandshakeMessages.java b/src/main/java/net/minecraftforge/network/HandshakeMessages.java index 63afa9fbfd..b40b34ae80 100644 --- a/src/main/java/net/minecraftforge/network/HandshakeMessages.java +++ b/src/main/java/net/minecraftforge/network/HandshakeMessages.java @@ -26,6 +26,8 @@ import java.util.stream.Collectors; import javax.annotation.Nullable; +import org.apache.commons.lang3.tuple.Pair; + import com.google.common.collect.Maps; public class HandshakeMessages @@ -48,7 +50,7 @@ public class HandshakeMessages } } /** - * Server to client "list of mods". Always first handshake message. + * Server to client "list of mods". Always first handshake message after the data sent by S2CModData. */ public static class S2CModList extends LoginIndexedMessage { @@ -141,6 +143,44 @@ public class HandshakeMessages } } + /** + * Prefixes S2CModList by sending additional data about the mods installed on the server to the client + * The mod data is stored as follows: [modId -> [modName, modVersion]] + */ + public static class S2CModData extends LoginIndexedMessage + { + private final Map> mods; + + public S2CModData() + { + this.mods = ModList.get().getMods().stream().collect(Collectors.toMap(IModInfo::getModId, info -> Pair.of(info.getDisplayName(), info.getVersion().toString()))); + } + + private S2CModData(Map> mods) + { + this.mods = mods; + } + + public static S2CModData decode(FriendlyByteBuf input) + { + Map> mods = input.readMap(o -> o.readUtf(0x100), o -> Pair.of(o.readUtf(0x100), o.readUtf(0x100))); + return new S2CModData(mods); + } + + public void encode(FriendlyByteBuf output) + { + output.writeMap(mods, (o, s) -> o.writeUtf(s, 0x100), (o, p) -> { + o.writeUtf(p.getLeft(), 0x100); + o.writeUtf(p.getRight(), 0x100); + }); + } + + public Map> getMods() + { + return mods; + } + } + public static class C2SModListReply extends LoginIndexedMessage { private List mods; @@ -288,4 +328,35 @@ public class HandshakeMessages return fileData; } } + + /** + * Notifies the client of a channel mismatch on the server, so a {@link net.minecraftforge.client.gui.ModMismatchDisconnectedScreen} is used to notify the user of the disconnection. + * This packet also sends the data of a channel mismatch (currently, the ids and versions of the mismatched channels) to the client for it to display the correct information in said screen. + */ + public static class S2CChannelMismatchData extends LoginIndexedMessage + { + private final Map mismatchedChannelData; + + public S2CChannelMismatchData(Map mismatchedChannelData) + { + this.mismatchedChannelData = mismatchedChannelData; + } + + public static S2CChannelMismatchData decode(FriendlyByteBuf input) + { + Map mismatchedMods = input.readMap(i -> new ResourceLocation(i.readUtf(0x100)), i -> i.readUtf(0x100)); + + return new S2CChannelMismatchData(mismatchedMods); + } + + public void encode(FriendlyByteBuf output) + { + output.writeMap(mismatchedChannelData, (o, r) -> o.writeUtf(r.toString(), 0x100), (o, v) -> o.writeUtf(v, 0x100)); + } + + public Map getMismatchedChannelData() + { + return mismatchedChannelData; + } + } } diff --git a/src/main/java/net/minecraftforge/network/NetworkConstants.java b/src/main/java/net/minecraftforge/network/NetworkConstants.java index 26cf2c93a5..627c4da8ea 100644 --- a/src/main/java/net/minecraftforge/network/NetworkConstants.java +++ b/src/main/java/net/minecraftforge/network/NetworkConstants.java @@ -8,6 +8,7 @@ package net.minecraftforge.network; import io.netty.util.AttributeKey; import net.minecraft.resources.ResourceLocation; import net.minecraftforge.fml.IExtensionPoint.DisplayTest; +import net.minecraftforge.network.ConnectionData.ModMismatchData; import net.minecraftforge.network.HandshakeMessages.S2CModList; import net.minecraftforge.network.event.EventNetworkChannel; import net.minecraftforge.network.simple.SimpleChannel; @@ -33,6 +34,7 @@ public class NetworkConstants static final AttributeKey FML_HANDSHAKE_HANDLER = AttributeKey.valueOf("fml:handshake"); static final AttributeKey FML_MC_REGISTRY = AttributeKey.valueOf("minecraft:netregistry"); static final AttributeKey FML_CONNECTION_DATA = AttributeKey.valueOf("fml:conndata"); + static final AttributeKey FML_MOD_MISMATCH_DATA = AttributeKey.valueOf("fml:mismatchdata"); static final ResourceLocation FML_HANDSHAKE_RESOURCE = new ResourceLocation("fml:handshake"); static final ResourceLocation FML_PLAY_RESOURCE = new ResourceLocation("fml:play"); static final ResourceLocation MC_REGISTER_RESOURCE = new ResourceLocation("minecraft:register"); diff --git a/src/main/java/net/minecraftforge/network/NetworkEvent.java b/src/main/java/net/minecraftforge/network/NetworkEvent.java index a19f5fdd1a..da525ee864 100644 --- a/src/main/java/net/minecraftforge/network/NetworkEvent.java +++ b/src/main/java/net/minecraftforge/network/NetworkEvent.java @@ -107,6 +107,11 @@ public class NetworkEvent extends Event collected.add(new NetworkRegistry.LoginPayload(buffer, channelName, context)); } + public void add(FriendlyByteBuf buffer, ResourceLocation channelName, String context, boolean needsResponse) + { + collected.add(new NetworkRegistry.LoginPayload(buffer, channelName, context, needsResponse)); + } + public boolean isLocal() { return isLocal; } diff --git a/src/main/java/net/minecraftforge/network/NetworkHooks.java b/src/main/java/net/minecraftforge/network/NetworkHooks.java index d998645afe..d234d8fe5d 100644 --- a/src/main/java/net/minecraftforge/network/NetworkHooks.java +++ b/src/main/java/net/minecraftforge/network/NetworkHooks.java @@ -5,6 +5,7 @@ package net.minecraftforge.network; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -17,7 +18,9 @@ import io.netty.channel.ChannelHandlerContext; import net.minecraft.network.chat.TextComponent; import net.minecraftforge.fml.util.thread.EffectiveSide; import net.minecraftforge.client.ConfigGuiHandler.ConfigGuiFactory; +import net.minecraftforge.network.ConnectionData.ModMismatchData; import net.minecraftforge.network.filters.NetworkFilters; +import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -206,9 +209,27 @@ public class NetworkHooks MinecraftForge.EVENT_BUS.post(new PlayerContainerEvent.Open(player, c)); } + /** + * Updates the current ConnectionData instance with new mod or channel data if the old instance did not have either of these yet, + * or creates a new ConnectionData instance with the new data if the current ConnectionData instance doesn't exist yet. + */ + static void appendConnectionData(Connection mgr, Map> modData, Map channels) + { + ConnectionData oldData = mgr.channel().attr(NetworkConstants.FML_CONNECTION_DATA).get(); + + oldData = oldData != null ? new ConnectionData(oldData.getModData().isEmpty() ? modData : oldData.getModData(), oldData.getChannels().isEmpty() ? channels : oldData.getChannels()) : new ConnectionData(modData, channels); + mgr.channel().attr(NetworkConstants.FML_CONNECTION_DATA).set(oldData); + } + @Nullable public static ConnectionData getConnectionData(Connection mgr) { return mgr.channel().attr(NetworkConstants.FML_CONNECTION_DATA).get(); } + + @Nullable + public static ModMismatchData getModMismatchData(Connection mgr) + { + return mgr.channel().attr(NetworkConstants.FML_MOD_MISMATCH_DATA).get(); + } } diff --git a/src/main/java/net/minecraftforge/network/NetworkInitialization.java b/src/main/java/net/minecraftforge/network/NetworkInitialization.java index 8efcd19ed8..832af5c5a2 100644 --- a/src/main/java/net/minecraftforge/network/NetworkInitialization.java +++ b/src/main/java/net/minecraftforge/network/NetworkInitialization.java @@ -29,6 +29,15 @@ class NetworkInitialization { consumer(HandshakeHandler.indexFirst(HandshakeHandler::handleClientAck)). add(); + handshakeChannel.messageBuilder(HandshakeMessages.S2CModData.class, 5, NetworkDirection.LOGIN_TO_CLIENT). + loginIndex(HandshakeMessages.LoginIndexedMessage::getLoginIndex, HandshakeMessages.LoginIndexedMessage::setLoginIndex). + decoder(HandshakeMessages.S2CModData::decode). + encoder(HandshakeMessages.S2CModData::encode). + markAsLoginPacket(). + noResponse(). + consumer(HandshakeHandler.biConsumerFor(HandshakeHandler::handleModData)). + add(); + handshakeChannel.messageBuilder(HandshakeMessages.S2CModList.class, 1, NetworkDirection.LOGIN_TO_CLIENT). loginIndex(HandshakeMessages.LoginIndexedMessage::getLoginIndex, HandshakeMessages.LoginIndexedMessage::setLoginIndex). decoder(HandshakeMessages.S2CModList::decode). @@ -60,6 +69,13 @@ class NetworkInitialization { consumer(HandshakeHandler.biConsumerFor(HandshakeHandler::handleConfigSync)). add(); + handshakeChannel.messageBuilder(HandshakeMessages.S2CChannelMismatchData.class, 6, NetworkDirection.LOGIN_TO_CLIENT). + loginIndex(HandshakeMessages.LoginIndexedMessage::getLoginIndex, HandshakeMessages.LoginIndexedMessage::setLoginIndex). + decoder(HandshakeMessages.S2CChannelMismatchData::decode). + encoder(HandshakeMessages.S2CChannelMismatchData::encode). + consumer(HandshakeHandler.biConsumerFor(HandshakeHandler::handleModMismatchData)). + add(); + return handshakeChannel; } diff --git a/src/main/java/net/minecraftforge/network/NetworkRegistry.java b/src/main/java/net/minecraftforge/network/NetworkRegistry.java index 751c414b0b..cc507547c4 100644 --- a/src/main/java/net/minecraftforge/network/NetworkRegistry.java +++ b/src/main/java/net/minecraftforge/network/NetworkRegistry.java @@ -201,9 +201,9 @@ public class NetworkRegistry * supplied impl protocol version. * * @param channels An @{@link Map} of name->version pairs for testing - * @return true if all channels accept themselves + * @return a map of mismatched channel ids and versions, or an empty map if all channels accept themselves */ - static boolean validateClientChannels(final Map channels) { + static Map validateClientChannels(final Map channels) { return validateChannels(channels, "server", NetworkInstance::tryServerVersionOnClient); } @@ -211,9 +211,9 @@ public class NetworkRegistry * Validate the channels from the client on the server. Tests the server predicates against the client * supplied impl protocol version. * @param channels An @{@link Map} of name->version pairs for testing - * @return true if all channels accept themselves + * @return a map of mismatched channel ids and versions, or an empty map if all channels accept themselves */ - static boolean validateServerChannels(final Map channels) { + static Map validateServerChannels(final Map channels) { return validateChannels(channels, "client", NetworkInstance::tryClientVersionOnServer); } @@ -223,25 +223,25 @@ public class NetworkRegistry * @param incoming An @{@link Map} of name->version pairs for testing * @param originName A label for use in logging (where the version pairs came from) * @param testFunction The test function to use for testing - * @return true if all channels accept themselves + * @return a map of mismatched channel ids and versions, or an empty map if all channels accept themselves */ - private static boolean validateChannels(final Map incoming, final String originName, BiFunction testFunction) { - final List> results = instances.values().stream(). + private static Map validateChannels(final Map incoming, final String originName, BiFunction testFunction) { + final Map results = instances.values().stream(). map(ni -> { final String incomingVersion = incoming.getOrDefault(ni.getChannelName(), ABSENT); final boolean test = testFunction.apply(ni, incomingVersion); LOGGER.debug(NETREGISTRY, "Channel '{}' : Version test of '{}' from {} : {}", ni.getChannelName(), incomingVersion, originName, test ? "ACCEPTED" : "REJECTED"); - return Pair.of(ni.getChannelName(), test); - }).filter(p->!p.getRight()).collect(Collectors.toList()); + return Pair.of(Pair.of(ni.getChannelName(), incomingVersion), test); + }).filter(p->!p.getRight()).map(Pair::getLeft).collect(Collectors.toMap(Pair::getLeft, Pair::getRight)); if (!results.isEmpty()) { LOGGER.error(NETREGISTRY, "Channels [{}] rejected their {} side version number", - results.stream().map(Pair::getLeft).map(Object::toString).collect(Collectors.joining(",")), + results.keySet().stream().map(Object::toString).collect(Collectors.joining(",")), originName); - return false; + return results; } LOGGER.debug(NETREGISTRY, "Accepting channel list from {}", originName); - return true; + return results; } /** @@ -318,10 +318,21 @@ public class NetworkRegistry */ private final String messageContext; + /** + * If the connection should await a response to this packet to continue with the handshake + */ + private final boolean needsResponse; + public LoginPayload(final FriendlyByteBuf buffer, final ResourceLocation channelName, final String messageContext) { + this(buffer, channelName, messageContext, true); + } + + public LoginPayload(final FriendlyByteBuf buffer, final ResourceLocation channelName, final String messageContext, final boolean needsResponse) + { this.data = buffer; this.channelName = channelName; this.messageContext = messageContext; + this.needsResponse = needsResponse; } public FriendlyByteBuf getData() { @@ -335,6 +346,11 @@ public class NetworkRegistry public String getMessageContext() { return messageContext; } + + public boolean needsResponse() + { + return needsResponse; + } } /** diff --git a/src/main/java/net/minecraftforge/network/simple/IndexedMessageCodec.java b/src/main/java/net/minecraftforge/network/simple/IndexedMessageCodec.java index f1b58ceefa..fe0577e1d8 100644 --- a/src/main/java/net/minecraftforge/network/simple/IndexedMessageCodec.java +++ b/src/main/java/net/minecraftforge/network/simple/IndexedMessageCodec.java @@ -8,6 +8,7 @@ package net.minecraftforge.network.simple; import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap; import it.unimi.dsi.fastutil.shorts.Short2ObjectArrayMap; import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.network.HandshakeHandler; import net.minecraftforge.network.NetworkDirection; import net.minecraftforge.network.NetworkEvent; import net.minecraftforge.network.NetworkHooks; @@ -136,6 +137,10 @@ public class IndexedMessageCodec void consume(FriendlyByteBuf payload, int payloadIndex, Supplier context) { if (payload == null) { LOGGER.error(SIMPLENET, "Received empty payload on channel {}", Optional.ofNullable(networkInstance).map(NetworkInstance::getChannelName).map(Objects::toString).orElse("MISSING CHANNEL")); + if (!HandshakeHandler.packetNeedsResponse(context.get().getNetworkManager(), payloadIndex)) + { + context.get().setPacketHandled(true); //don't disconnect if the corresponding S2C packet that was not recognized on the client doesn't require a proper response + } return; } short discriminator = payload.readUnsignedByte(); diff --git a/src/main/java/net/minecraftforge/network/simple/SimpleChannel.java b/src/main/java/net/minecraftforge/network/simple/SimpleChannel.java index 82b81dafb7..71f41e77f0 100644 --- a/src/main/java/net/minecraftforge/network/simple/SimpleChannel.java +++ b/src/main/java/net/minecraftforge/network/simple/SimpleChannel.java @@ -18,7 +18,9 @@ import org.apache.commons.lang3.tuple.Pair; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -33,6 +35,7 @@ public class SimpleChannel private final IndexedMessageCodec indexedCodec; private final Optional> registryChangeConsumer; private List>>> loginPackets; + private Map, Boolean> packetsNeedResponse; public SimpleChannel(NetworkInstance instance) { this(instance, Optional.empty()); @@ -42,6 +45,7 @@ public class SimpleChannel this.instance = instance; this.indexedCodec = new IndexedMessageCodec(instance); this.loginPackets = new ArrayList<>(); + this.packetsNeedResponse = new HashMap<>(); instance.addListener(this::networkEventListener); instance.addGatherListener(this::networkLoginGather); this.registryChangeConsumer = registryChangeNotify; @@ -56,7 +60,7 @@ public class SimpleChannel packetGenerator.apply(gatherEvent.isLocal()).forEach(p->{ FriendlyByteBuf pb = new FriendlyByteBuf(Unpooled.buffer()); this.indexedCodec.build(p.getRight(), pb); - gatherEvent.add(pb, this.instance.getChannelName(), p.getLeft()); + gatherEvent.add(pb, this.instance.getChannelName(), p.getLeft(), packetsNeedResponse.getOrDefault(p.getRight().getClass(), true)); }); }); } @@ -166,6 +170,7 @@ public class SimpleChannel private BiConsumer loginIndexSetter; private Function>> loginPacketGenerators; private Optional networkDirection; + private boolean needsResponse = true; private static MessageBuilder forType(final SimpleChannel channel, final Class type, int id, NetworkDirection networkDirection) { MessageBuilder builder = new MessageBuilder<>(); @@ -209,6 +214,15 @@ public class SimpleChannel return this; } + /** + * Marks this packet as not needing a response when sent to the client + */ + public MessageBuilder noResponse() + { + this.needsResponse = false; + return this; + } + public MessageBuilder consumer(BiConsumer> consumer) { this.consumer = consumer; return this; @@ -245,6 +259,7 @@ public class SimpleChannel if (this.loginPacketGenerators != null) { this.channel.loginPackets.add(this.loginPacketGenerators); } + this.channel.packetsNeedResponse.put(this.type, this.needsResponse); } } } diff --git a/src/main/resources/assets/forge/lang/en_us.json b/src/main/resources/assets/forge/lang/en_us.json index 7bbdcc378b..7b8f78cfb3 100644 --- a/src/main/resources/assets/forge/lang/en_us.json +++ b/src/main/resources/assets/forge/lang/en_us.json @@ -47,6 +47,15 @@ "fml.button.continue.launch": "Proceed to main menu", "fml.loadingerrorscreen.errorheader": "Error loading mods\n{0,choice,1#1 error has|1<{0} errors have} occurred during loading", "fml.loadingerrorscreen.warningheader": "{0,choice,1#Warning|1 SOUND_EVENTS = DeferredRegister.create(ForgeRegistries.SOUND_EVENTS, MOD_ID); + private static final String CHANNEL_PROTOCOL_VERSION = FMLEnvironment.dist == Dist.CLIENT ? "V1" : "V2"; + + public ModMismatchTest() + { + if (ENABLED) + { + if ((FMLEnvironment.dist == Dist.DEDICATED_SERVER && REGISTER_FOR_SERVER) || (FMLEnvironment.dist == Dist.CLIENT && REGISTER_FOR_CLIENT)) + { + NetworkRegistry.newSimpleChannel(new ResourceLocation(MOD_ID, "channel"), () -> CHANNEL_PROTOCOL_VERSION, p -> p.equals(CHANNEL_PROTOCOL_VERSION), (p) -> p.equals(CHANNEL_PROTOCOL_VERSION)); + } + if (REGISTER_REGISTRY_ENTRY && FMLEnvironment.dist == Dist.DEDICATED_SERVER) + { + IEventBus eventBus = FMLJavaModLoadingContext.get().getModEventBus(); + SOUND_EVENTS.register("mismatching_sound_event", () -> new SoundEvent(new ResourceLocation(MOD_ID, "server.connect.fail"))); + SOUND_EVENTS.register(eventBus); + } + } + } +} diff --git a/src/test/resources/META-INF/mods.toml b/src/test/resources/META-INF/mods.toml index d64f58b165..04ec601c9b 100644 --- a/src/test/resources/META-INF/mods.toml +++ b/src/test/resources/META-INF/mods.toml @@ -228,5 +228,7 @@ license="LGPL v2.1" modId="data_pack_registries_test" [[mods]] modId="banner_pattern_test" +[[mods]] + modId="mod_mismatch_test" # ADD ABOVE THIS LINE