Implement API for mods to control their server status response..

Signed-off-by: cpw <cpw+github@weeksfamily.ca>
This commit is contained in:
cpw 2019-03-31 16:36:49 -04:00
parent 4ecb04ed9f
commit 3f735b715b
No known key found for this signature in database
GPG Key ID: 8EB3DF749553B1B7
10 changed files with 183 additions and 76 deletions

View File

@ -4,7 +4,7 @@
private ServerStatusResponse.Players field_151324_b; private ServerStatusResponse.Players field_151324_b;
private ServerStatusResponse.Version field_151325_c; private ServerStatusResponse.Version field_151325_c;
private String field_151323_d; private String field_151323_d;
+ private net.minecraftforge.fml.network.FMLStatusPing forgeData; + private transient net.minecraftforge.fml.network.FMLStatusPing forgeData;
+ public net.minecraftforge.fml.network.FMLStatusPing getForgeData() { + public net.minecraftforge.fml.network.FMLStatusPing getForgeData() {
+ return this.forgeData; + return this.forgeData;

View File

@ -22,6 +22,7 @@ package net.minecraftforge.common;
import net.minecraftforge.eventbus.api.IEventBus; import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.fml.BrandingControl; import net.minecraftforge.fml.BrandingControl;
import net.minecraftforge.fml.DistExecutor; import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.fml.ExtensionPoint;
import net.minecraftforge.fml.FMLWorldPersistenceHook; import net.minecraftforge.fml.FMLWorldPersistenceHook;
import net.minecraftforge.fml.ModLoadingContext; import net.minecraftforge.fml.ModLoadingContext;
import net.minecraftforge.fml.VersionChecker; import net.minecraftforge.fml.VersionChecker;
@ -38,6 +39,7 @@ import net.minecraftforge.server.command.ForgeCommand;
import net.minecraftforge.versions.forge.ForgeVersion; import net.minecraftforge.versions.forge.ForgeVersion;
import net.minecraftforge.versions.mcp.MCPVersion; import net.minecraftforge.versions.mcp.MCPVersion;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@ -99,6 +101,8 @@ public class ForgeMod implements WorldPersistenceHooks.WorldPersistenceHook
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, ForgeConfig.clientSpec); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, ForgeConfig.clientSpec);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, ForgeConfig.serverSpec); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, ForgeConfig.serverSpec);
modEventBus.register(ForgeConfig.class); modEventBus.register(ForgeConfig.class);
// Forge does not display problems when the remote is not matching.
ModLoadingContext.get().registerExtensionPoint(ExtensionPoint.DISPLAYTEST, ()-> Pair.of(()->"ANY", (remote, isServer)-> true));
} }
/* /*

View File

@ -24,17 +24,28 @@ import net.minecraft.client.gui.GuiScreen;
import net.minecraft.resources.IResourcePack; import net.minecraft.resources.IResourcePack;
import net.minecraftforge.fml.network.FMLPlayMessages; import net.minecraftforge.fml.network.FMLPlayMessages;
import net.minecraftforge.fml.packs.ModFileResourcePack; import net.minecraftforge.fml.packs.ModFileResourcePack;
import org.apache.commons.lang3.tuple.Pair;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.BiPredicate;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier;
public class ExtensionPoint<T> public class ExtensionPoint<T>
{ {
public static final ExtensionPoint<BiFunction<Minecraft, GuiScreen, GuiScreen>> CONFIGGUIFACTORY = new ExtensionPoint<>(); public static final ExtensionPoint<BiFunction<Minecraft, GuiScreen, GuiScreen>> CONFIGGUIFACTORY = new ExtensionPoint<>();
public static final ExtensionPoint<BiFunction<Minecraft, ModFileResourcePack, IResourcePack>> RESOURCEPACK = new ExtensionPoint<>(); public static final ExtensionPoint<BiFunction<Minecraft, ModFileResourcePack, IResourcePack>> RESOURCEPACK = new ExtensionPoint<>();
/**
* Compatibility display test for the mod.
* Used for displaying compatibility with remote servers with the same mod, and on disk saves.
*
* The supplier provides my "local" version for sending across the network or writing to disk
* The predicate tests the version from a remote instance or save for acceptability (Boolean is true for network, false for local save)
*/
public static final ExtensionPoint<Pair<Supplier<String>, BiPredicate<String, Boolean>>> DISPLAYTEST = new ExtensionPoint<>();
/** /**
* Register with {@link ModLoadingContext#} * Register with {@link ModLoadingContext#registerExtensionPoint(ExtensionPoint, Supplier)}
*/ */
public static final ExtensionPoint<Function<FMLPlayMessages.OpenContainer, GuiScreen>> GUIFACTORY = new ExtensionPoint<>(); public static final ExtensionPoint<Function<FMLPlayMessages.OpenContainer, GuiScreen>> GUIFACTORY = new ExtensionPoint<>();

View File

@ -21,8 +21,16 @@ package net.minecraftforge.fml;
import net.minecraftforge.fml.config.ModConfig; import net.minecraftforge.fml.config.ModConfig;
import net.minecraftforge.forgespi.language.IModInfo; import net.minecraftforge.forgespi.language.IModInfo;
import org.apache.commons.lang3.tuple.Pair;
import java.util.*; import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -60,6 +68,9 @@ public abstract class ModContainer
this.modInfo = info; this.modInfo = info;
this.triggerMap = new HashMap<>(); this.triggerMap = new HashMap<>();
this.modLoadingStage = ModLoadingStage.CONSTRUCT; this.modLoadingStage = ModLoadingStage.CONSTRUCT;
// default displaytest extension checks for version string match
registerExtensionPoint(ExtensionPoint.DISPLAYTEST, () -> Pair.of(()->this.modInfo.getVersion().toString(),
(incoming, isNetwork)->Objects.equals(incoming, this.modInfo.getVersion().toString())));
} }
/** /**

View File

@ -183,4 +183,8 @@ public class ModList
{ {
modFiles.stream().map(ModFileInfo::getFile).forEach(fileConsumer); modFiles.stream().map(ModFileInfo::getFile).forEach(fileConsumer);
} }
public void forEachModContainer(BiConsumer<String, ModContainer> modContainerConsumer) {
indexedMods.forEach(modContainerConsumer);
}
} }

View File

@ -22,18 +22,28 @@ package net.minecraftforge.fml.client;
import java.io.File; import java.io.File;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import com.google.common.collect.*; import com.google.common.collect.HashBasedTable;
import net.minecraft.client.gui.*; import com.google.common.collect.HashMultimap;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import net.minecraft.client.gui.Gui;
import net.minecraft.client.gui.GuiMultiplayer;
import net.minecraft.client.gui.GuiWorldSelection;
import net.minecraftforge.fml.ExtensionPoint;
import net.minecraftforge.fml.ForgeI18n; import net.minecraftforge.fml.ForgeI18n;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.network.FMLNetworkConstants; import net.minecraftforge.fml.network.FMLNetworkConstants;
import net.minecraftforge.fml.network.NetworkRegistry; import net.minecraftforge.fml.network.NetworkRegistry;
import net.minecraftforge.registries.RegistryManager;
import net.minecraftforge.versions.forge.ForgeVersion; import net.minecraftforge.versions.forge.ForgeVersion;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Marker; import org.apache.logging.log4j.Marker;
@ -70,36 +80,59 @@ public class ClientHooks
private static final ResourceLocation iconSheet = new ResourceLocation(ForgeVersion.MOD_ID, "textures/gui/icons.png"); private static final ResourceLocation iconSheet = new ResourceLocation(ForgeVersion.MOD_ID, "textures/gui/icons.png");
@Nullable @Nullable
public static void processForgeListPingData(ServerStatusResponse packet, ServerData target) public static void processForgeListPingData(ServerStatusResponse packet, ServerData target)
{ {
if(packet.getForgeData() != null){ if (packet.getForgeData() != null) {
int numberOfMods = packet.getForgeData().getNumberOfMods(); final Map<String, String> mods = packet.getForgeData().getRemoteModData();
int fmlver = packet.getForgeData().getFMLNetworkVersion(); final Map<ResourceLocation, Pair<String, Boolean>> remoteChannels = packet.getForgeData().getRemoteChannels();
final int fmlver = packet.getForgeData().getFMLNetworkVersion();
boolean b = NetworkRegistry.checkListPingCompatibilityForClient(packet.getForgeData().getPresentMods()) boolean fmlNetMatches = fmlver == FMLNetworkConstants.FMLNETVERSION;
&& fmlver == FMLNetworkConstants.FMLNETVERSION; boolean channelsMatch = NetworkRegistry.checkListPingCompatibilityForClient(remoteChannels);
AtomicBoolean result = new AtomicBoolean(true);
ModList.get().forEachModContainer((modid, mc)-> mc.getCustomExtension(ExtensionPoint.DISPLAYTEST).ifPresent(ext->
result.compareAndSet(true, ext.getRight().test(mods.get(modid), true))));
boolean modsMatch = result.get();
LOGGER.debug(CLIENTHOOKS, "Received FML ping data from server at {}: FMLNETVER={}, {} mods, channels: [{}] - compatible: {}", target.serverIP, fmlver, numberOfMods, packet.getForgeData().getPresentMods().entrySet(), b); final Map<String, String> extraServerMods = mods.entrySet().stream().
filter(e -> !Objects.equals(FMLNetworkConstants.IGNORESERVERONLY, e.getValue())).
filter(e -> !ModList.get().isLoaded(e.getKey())).
collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
LOGGER.debug(CLIENTHOOKS, "Received FML ping data from server at {}: FMLNETVER={}, mod list is compatible : {}, channel list is compatible: {}, extra server mods: {}", target.serverIP, fmlver, modsMatch, channelsMatch, extraServerMods);
String extraReason = null; String extraReason = null;
if(fmlver<FMLNetworkConstants.FMLNETVERSION)
extraReason = "fml.menu.multiplayer.serveroutdated";
else if(fmlver > FMLNetworkConstants.FMLNETVERSION)
extraReason = "fml.menu.multiplayer.clientoutdated";
target.forgeData = new ExtendedServerListData("FML", b, packet.getForgeData().getPresentMods(), numberOfMods, extraReason); if (!extraServerMods.isEmpty()) {
}else{ extraReason = "fml.menu.multiplayer.extraservermods";
target.forgeData = new ExtendedServerListData("VANILLA", NetworkRegistry.canConnectToVanillaServer(), Maps.newHashMap(), 0, null); }
if (!modsMatch) {
extraReason = "fml.menu.multiplayer.modsincompatible";
}
if (!channelsMatch) {
extraReason = "fml.menu.multiplayer.networkincompatible";
}
if (fmlver < FMLNetworkConstants.FMLNETVERSION) {
extraReason = "fml.menu.multiplayer.serveroutdated";
}
if (fmlver > FMLNetworkConstants.FMLNETVERSION) {
extraReason = "fml.menu.multiplayer.clientoutdated";
}
target.forgeData = new ExtendedServerListData("FML", extraServerMods.isEmpty() && fmlNetMatches && channelsMatch && modsMatch, mods.size(), extraReason);
} else {
target.forgeData = new ExtendedServerListData("VANILLA", NetworkRegistry.canConnectToVanillaServer(),0, null);
} }
} }
public static void drawForgePingInfo(GuiMultiplayer gui, ServerData target, int x, int y, int width, int relativeMouseX, int relativeMouseY){ public static void drawForgePingInfo(GuiMultiplayer gui, ServerData target, int x, int y, int width, int relativeMouseX, int relativeMouseY) {
int idx; int idx;
String tooltip; String tooltip;
if(target.forgeData == null) if (target.forgeData == null)
return; return;
switch (target.forgeData.type){ switch (target.forgeData.type) {
case "FML": case "FML":
if (target.forgeData.isCompatible) { if (target.forgeData.isCompatible) {
idx = 0; idx = 0;
@ -115,7 +148,7 @@ public class ClientHooks
} }
break; break;
case "VANILLA": case "VANILLA":
if(target.forgeData.isCompatible) { if (target.forgeData.isCompatible) {
idx = 48; idx = 48;
tooltip = ForgeI18n.parseMessage("fml.menu.multiplayer.vanilla"); tooltip = ForgeI18n.parseMessage("fml.menu.multiplayer.vanilla");
} else { } else {

View File

@ -19,23 +19,16 @@
package net.minecraftforge.fml.client; package net.minecraftforge.fml.client;
import net.minecraft.util.ResourceLocation;
import org.apache.commons.lang3.tuple.Pair;
import java.util.Map;
public class ExtendedServerListData { public class ExtendedServerListData {
public final String type; public final String type;
public final boolean isCompatible; public final boolean isCompatible;
public final Map<ResourceLocation, Pair<String, Boolean>> channelData;
public int numberOfMods; public int numberOfMods;
public String extraReason; public String extraReason;
public ExtendedServerListData(String type, boolean isCompatible, Map<ResourceLocation, Pair<String, Boolean>> channelData, int num, String extraReason) public ExtendedServerListData(String type, boolean isCompatible, int num, String extraReason)
{ {
this.type = type; this.type = type;
this.isCompatible = isCompatible; this.isCompatible = isCompatible;
this.channelData = channelData;
this.numberOfMods = num; this.numberOfMods = num;
this.extraReason = extraReason; this.extraReason = extraReason;
} }

View File

@ -42,4 +42,8 @@ public class FMLNetworkConstants
static final ResourceLocation FML_PLAY_RESOURCE = new ResourceLocation("fml:play"); static final ResourceLocation FML_PLAY_RESOURCE = new ResourceLocation("fml:play");
static final SimpleChannel handshakeChannel = NetworkInitialization.getHandshakeChannel(); static final SimpleChannel handshakeChannel = NetworkInitialization.getHandshakeChannel();
static final SimpleChannel playChannel = NetworkInitialization.getPlayChannel(); static final SimpleChannel playChannel = NetworkInitialization.getPlayChannel();
/**
* Return this value in your {@link net.minecraftforge.fml.ExtensionPoint#DISPLAYTEST} function to be ignored.
*/
public static final String IGNORESERVERONLY = "OHNOES\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31\uD83D\uDE31";
} }

View File

@ -20,80 +20,124 @@
package net.minecraftforge.fml.network; package net.minecraftforge.fml.network;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps; import com.google.gson.JsonArray;
import com.google.gson.*; import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSyntaxException;
import net.minecraft.util.JsonUtils; import net.minecraft.util.JsonUtils;
import net.minecraft.util.ResourceLocation; import net.minecraft.util.ResourceLocation;
import net.minecraftforge.fml.ExtensionPoint;
import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.ModList;
import net.minecraftforge.registries.RegistryManager;
import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import static net.minecraftforge.fml.network.FMLNetworkConstants.NETWORK;
/**
* {
* "fmlNetworkVersion" : FMLNETVERSION,
* "channels": [
* {
* "res": "fml:handshake",
* "version": "1.2.3.4",
* "required": true
* }
* ],
* "mods": [
* {
* "modid": "modid",
* "modmarker": "<somestring>"
* }
* ]
* }
*
*/
public class FMLStatusPing { public class FMLStatusPing {
private static final Logger LOGGER = LogManager.getLogger();
private Map<ResourceLocation, Pair<String, Boolean>> channelVersions; private transient Map<ResourceLocation, Pair<String, Boolean>> channels;
private int numberOfMods; private transient Map<String, String> mods;
private int fmlNetworkVer; private transient int fmlNetworkVer;
public FMLStatusPing() {
public FMLStatusPing(){ this.channels = NetworkRegistry.buildChannelVersionsForListPing();
this.channelVersions = NetworkRegistry.buildChannelVersionsForListPing(); this.mods = new HashMap<>();
this.numberOfMods = ModList.get().size(); ModList.get().forEachModContainer((modid, mc) ->
mods.put(modid, mc.getCustomExtension(ExtensionPoint.DISPLAYTEST).
map(Pair::getLeft).map(Supplier::get).orElse(FMLNetworkConstants.IGNORESERVERONLY)));
this.fmlNetworkVer = FMLNetworkConstants.FMLNETVERSION; this.fmlNetworkVer = FMLNetworkConstants.FMLNETVERSION;
} }
private FMLStatusPing(Map<ResourceLocation, Pair<String, Boolean>> deserialized, int nom, int fmlNetVer){ private FMLStatusPing(Map<ResourceLocation, Pair<String, Boolean>> deserialized, Map<String,String> modMarkers, int fmlNetVer) {
this.channelVersions = ImmutableMap.copyOf(deserialized); this.channels = ImmutableMap.copyOf(deserialized);
this.numberOfMods = nom; this.mods = modMarkers;
this.fmlNetworkVer = fmlNetVer; this.fmlNetworkVer = fmlNetVer;
} }
public static class Serializer { public static class Serializer {
public static FMLStatusPing deserialize(JsonObject forgeData, JsonDeserializationContext ctx) { public static FMLStatusPing deserialize(JsonObject forgeData, JsonDeserializationContext ctx) {
try { try {
JsonArray mods = JsonUtils.getJsonArray(forgeData, "mods"); final Map<ResourceLocation, Pair<String, Boolean>> channels = StreamSupport.stream(JsonUtils.getJsonArray(forgeData, "channels").spliterator(), false).
Map<ResourceLocation, Pair<String, Boolean>> versions = Maps.newHashMap(); map(JsonElement::getAsJsonObject).
for(JsonElement el : mods){ collect(Collectors.toMap(jo -> new ResourceLocation(JsonUtils.getString(jo, "res")),
JsonObject jo = el.getAsJsonObject(); jo -> Pair.of(JsonUtils.getString(jo, "version"), JsonUtils.getBoolean(jo, "required")))
ResourceLocation name = new ResourceLocation(JsonUtils.getString(jo, "namespace"), JsonUtils.getString(jo, "path")); );
String version = JsonUtils.getString(jo, "version");
Boolean canBeAbsent = JsonUtils.getBoolean(jo, "mayBeAbsent"); final Map<String, String> mods = StreamSupport.stream(JsonUtils.getJsonArray(forgeData, "mods").spliterator(), false).
versions.put(name, Pair.of(version, canBeAbsent)); map(JsonElement::getAsJsonObject).
} collect(Collectors.toMap(jo -> JsonUtils.getString(jo, "modId"), jo->JsonUtils.getString(jo, "modmarker")));
return new FMLStatusPing(versions, JsonUtils.getInt(forgeData, "numberOfMods"), JsonUtils.getInt(forgeData, "fmlNetworkVersion"));
}catch (Exception c){ final int remoteFMLVersion = JsonUtils.getInt(forgeData, "fmlNetworkVersion");
return new FMLStatusPing(channels, mods, remoteFMLVersion);
} catch (JsonSyntaxException e) {
LOGGER.debug(NETWORK, "Encountered an error parsing status ping data", e);
return null; return null;
} }
} }
public static JsonObject serialize(FMLStatusPing forgeData, JsonSerializationContext ctx){ public static JsonObject serialize(FMLStatusPing forgeData, JsonSerializationContext ctx) {
JsonObject obj = new JsonObject(); JsonObject obj = new JsonObject();
JsonArray mods = new JsonArray(); JsonArray channels = new JsonArray();
forgeData.channelVersions.entrySet().stream().map(p -> { forgeData.channels.forEach((namespace, version) -> {
JsonObject mi = new JsonObject(); JsonObject mi = new JsonObject();
mi.addProperty("namespace", p.getKey().getNamespace()); mi.addProperty("res", namespace.toString());
mi.addProperty("path", p.getKey().getPath()); mi.addProperty("version", version.getLeft());
mi.addProperty("version", p.getValue().getKey()); mi.addProperty("required", version.getRight());
mi.addProperty("mayBeAbsent", p.getValue().getValue()); channels.add(mi);
return mi; });
}).forEach(mods::add);
obj.add("mods", mods); obj.add("channels", channels);
obj.addProperty("numberOfMods", forgeData.numberOfMods);
JsonArray modTestValues = new JsonArray();
forgeData.mods.forEach((modId, value) -> {
JsonObject mi = new JsonObject();
mi.addProperty("modId", modId);
mi.addProperty("modmarker", value);
modTestValues.add(mi);
});
obj.add("mods", modTestValues);
obj.addProperty("fmlNetworkVersion", forgeData.fmlNetworkVer); obj.addProperty("fmlNetworkVersion", forgeData.fmlNetworkVer);
return obj; return obj;
} }
} }
public Map<ResourceLocation, Pair<String, Boolean>> getPresentMods(){ public Map<ResourceLocation, Pair<String, Boolean>> getRemoteChannels() {
return this.channelVersions; return this.channels;
} }
public int getNumberOfMods(){ public Map<String,String> getRemoteModData() {
return numberOfMods; return mods;
} }
public int getFMLNetworkVersion(){ public int getFMLNetworkVersion() {
return fmlNetworkVer; return fmlNetworkVer;
} }

View File

@ -15,14 +15,17 @@
"fml.menu.mods.info.childmods":"Child mods: {0}", "fml.menu.mods.info.childmods":"Child mods: {0}",
"fml.menu.mods.info.updateavailable":"Update available: {0}", "fml.menu.mods.info.updateavailable":"Update available: {0}",
"fml.menu.mods.info.changelogheader":"Changelog:", "fml.menu.mods.info.changelogheader":"Changelog:",
"fml.menu.multiplayer.compatible":"Compatible FML modded server, {0,choice,1#1 mod|1<{0} mods} present", "fml.menu.multiplayer.compatible":"Compatible FML modded server\n{0,choice,1#1 mod|1<{0} mods} present",
"fml.menu.multiplayer.incompatible":"Incompatible FML modded server", "fml.menu.multiplayer.incompatible":"Incompatible FML modded server",
"fml.menu.multiplayer.incompatible.extra":"Incompatible FML modded server - {}", "fml.menu.multiplayer.incompatible.extra":"Incompatible FML modded server\n{0}",
"fml.menu.multiplayer.vanilla":"Vanilla server", "fml.menu.multiplayer.vanilla":"Vanilla server",
"fml.menu.multiplayer.vanilla.incompatible":"Incompatible Vanilla server", "fml.menu.multiplayer.vanilla.incompatible":"Incompatible Vanilla server",
"fml.menu.multiplayer.unknown":"Unknown server {0}", "fml.menu.multiplayer.unknown":"Unknown server {0}",
"fml.menu.multiplayer.serveroutdated":"Outdated server", "fml.menu.multiplayer.serveroutdated":"The Forge server network version is outdated",
"fml.menu.multiplayer.clientoutdated":"Outdated client", "fml.menu.multiplayer.clientoutdated":"The Forge client network version is outdated",
"fml.menu.multiplayer.extraservermods":"The Server has additional mods that may be needed on the client",
"fml.menu.multiplayer.modsincompatible":"The Server's mods are not compatible",
"fml.menu.multiplayer.networkincompatible":"The Server's network messages are not compatible",
"fml.menu.loadingmods": "{0,choice,0#No mods|1#1 mod|1<{0} mods} loaded", "fml.menu.loadingmods": "{0,choice,0#No mods|1#1 mod|1<{0} mods} loaded",
"fml.button.open.file": "Open {0}", "fml.button.open.file": "Open {0}",
"fml.button.open.mods.folder": "Open Mods Folder", "fml.button.open.mods.folder": "Open Mods Folder",