Implement API for mods to control their server status response..
Signed-off-by: cpw <cpw+github@weeksfamily.ca>
This commit is contained in:
parent
4ecb04ed9f
commit
3f735b715b
|
@ -4,7 +4,7 @@
|
|||
private ServerStatusResponse.Players field_151324_b;
|
||||
private ServerStatusResponse.Version field_151325_c;
|
||||
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() {
|
||||
+ return this.forgeData;
|
||||
|
|
|
@ -22,6 +22,7 @@ package net.minecraftforge.common;
|
|||
import net.minecraftforge.eventbus.api.IEventBus;
|
||||
import net.minecraftforge.fml.BrandingControl;
|
||||
import net.minecraftforge.fml.DistExecutor;
|
||||
import net.minecraftforge.fml.ExtensionPoint;
|
||||
import net.minecraftforge.fml.FMLWorldPersistenceHook;
|
||||
import net.minecraftforge.fml.ModLoadingContext;
|
||||
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.mcp.MCPVersion;
|
||||
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
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.SERVER, ForgeConfig.serverSpec);
|
||||
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));
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -24,17 +24,28 @@ import net.minecraft.client.gui.GuiScreen;
|
|||
import net.minecraft.resources.IResourcePack;
|
||||
import net.minecraftforge.fml.network.FMLPlayMessages;
|
||||
import net.minecraftforge.fml.packs.ModFileResourcePack;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class ExtensionPoint<T>
|
||||
{
|
||||
public static final ExtensionPoint<BiFunction<Minecraft, GuiScreen, GuiScreen>> CONFIGGUIFACTORY = 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<>();
|
||||
|
||||
|
|
|
@ -21,8 +21,16 @@ package net.minecraftforge.fml;
|
|||
|
||||
import net.minecraftforge.fml.config.ModConfig;
|
||||
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.Supplier;
|
||||
|
||||
|
@ -60,6 +68,9 @@ public abstract class ModContainer
|
|||
this.modInfo = info;
|
||||
this.triggerMap = new HashMap<>();
|
||||
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())));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -183,4 +183,8 @@ public class ModList
|
|||
{
|
||||
modFiles.stream().map(ModFileInfo::getFile).forEach(fileConsumer);
|
||||
}
|
||||
|
||||
public void forEachModContainer(BiConsumer<String, ModContainer> modContainerConsumer) {
|
||||
indexedMods.forEach(modContainerConsumer);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,18 +22,28 @@ package net.minecraftforge.fml.client;
|
|||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import com.google.common.collect.*;
|
||||
import net.minecraft.client.gui.*;
|
||||
import com.google.common.collect.HashBasedTable;
|
||||
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.ModList;
|
||||
import net.minecraftforge.fml.network.FMLNetworkConstants;
|
||||
import net.minecraftforge.fml.network.NetworkRegistry;
|
||||
import net.minecraftforge.registries.RegistryManager;
|
||||
import net.minecraftforge.versions.forge.ForgeVersion;
|
||||
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;
|
||||
|
@ -70,26 +80,49 @@ public class ClientHooks
|
|||
|
||||
private static final ResourceLocation iconSheet = new ResourceLocation(ForgeVersion.MOD_ID, "textures/gui/icons.png");
|
||||
@Nullable
|
||||
|
||||
public static void processForgeListPingData(ServerStatusResponse packet, ServerData target)
|
||||
{
|
||||
if (packet.getForgeData() != null) {
|
||||
int numberOfMods = packet.getForgeData().getNumberOfMods();
|
||||
int fmlver = packet.getForgeData().getFMLNetworkVersion();
|
||||
final Map<String, String> mods = packet.getForgeData().getRemoteModData();
|
||||
final Map<ResourceLocation, Pair<String, Boolean>> remoteChannels = packet.getForgeData().getRemoteChannels();
|
||||
final int fmlver = packet.getForgeData().getFMLNetworkVersion();
|
||||
|
||||
boolean b = NetworkRegistry.checkListPingCompatibilityForClient(packet.getForgeData().getPresentMods())
|
||||
&& fmlver == FMLNetworkConstants.FMLNETVERSION;
|
||||
boolean fmlNetMatches = 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;
|
||||
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()) {
|
||||
extraReason = "fml.menu.multiplayer.extraservermods";
|
||||
}
|
||||
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(), Maps.newHashMap(), 0, null);
|
||||
target.forgeData = new ExtendedServerListData("VANILLA", NetworkRegistry.canConnectToVanillaServer(),0, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -19,23 +19,16 @@
|
|||
|
||||
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 final String type;
|
||||
public final boolean isCompatible;
|
||||
public final Map<ResourceLocation, Pair<String, Boolean>> channelData;
|
||||
public int numberOfMods;
|
||||
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.isCompatible = isCompatible;
|
||||
this.channelData = channelData;
|
||||
this.numberOfMods = num;
|
||||
this.extraReason = extraReason;
|
||||
}
|
||||
|
|
|
@ -42,4 +42,8 @@ public class FMLNetworkConstants
|
|||
static final ResourceLocation FML_PLAY_RESOURCE = new ResourceLocation("fml:play");
|
||||
static final SimpleChannel handshakeChannel = NetworkInitialization.getHandshakeChannel();
|
||||
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";
|
||||
}
|
||||
|
|
|
@ -20,77 +20,121 @@
|
|||
package net.minecraftforge.fml.network;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.JsonArray;
|
||||
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.ResourceLocation;
|
||||
import net.minecraftforge.fml.ExtensionPoint;
|
||||
import net.minecraftforge.fml.ModList;
|
||||
import net.minecraftforge.registries.RegistryManager;
|
||||
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.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 {
|
||||
private static final Logger LOGGER = LogManager.getLogger();
|
||||
|
||||
private Map<ResourceLocation, Pair<String, Boolean>> channelVersions;
|
||||
private int numberOfMods;
|
||||
private int fmlNetworkVer;
|
||||
|
||||
private transient Map<ResourceLocation, Pair<String, Boolean>> channels;
|
||||
private transient Map<String, String> mods;
|
||||
private transient int fmlNetworkVer;
|
||||
public FMLStatusPing() {
|
||||
this.channelVersions = NetworkRegistry.buildChannelVersionsForListPing();
|
||||
this.numberOfMods = ModList.get().size();
|
||||
this.channels = NetworkRegistry.buildChannelVersionsForListPing();
|
||||
this.mods = new HashMap<>();
|
||||
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;
|
||||
}
|
||||
|
||||
private FMLStatusPing(Map<ResourceLocation, Pair<String, Boolean>> deserialized, int nom, int fmlNetVer){
|
||||
this.channelVersions = ImmutableMap.copyOf(deserialized);
|
||||
this.numberOfMods = nom;
|
||||
private FMLStatusPing(Map<ResourceLocation, Pair<String, Boolean>> deserialized, Map<String,String> modMarkers, int fmlNetVer) {
|
||||
this.channels = ImmutableMap.copyOf(deserialized);
|
||||
this.mods = modMarkers;
|
||||
this.fmlNetworkVer = fmlNetVer;
|
||||
}
|
||||
|
||||
public static class Serializer {
|
||||
|
||||
public static FMLStatusPing deserialize(JsonObject forgeData, JsonDeserializationContext ctx) {
|
||||
try {
|
||||
JsonArray mods = JsonUtils.getJsonArray(forgeData, "mods");
|
||||
Map<ResourceLocation, Pair<String, Boolean>> versions = Maps.newHashMap();
|
||||
for(JsonElement el : mods){
|
||||
JsonObject jo = el.getAsJsonObject();
|
||||
ResourceLocation name = new ResourceLocation(JsonUtils.getString(jo, "namespace"), JsonUtils.getString(jo, "path"));
|
||||
String version = JsonUtils.getString(jo, "version");
|
||||
Boolean canBeAbsent = JsonUtils.getBoolean(jo, "mayBeAbsent");
|
||||
versions.put(name, Pair.of(version, canBeAbsent));
|
||||
}
|
||||
return new FMLStatusPing(versions, JsonUtils.getInt(forgeData, "numberOfMods"), JsonUtils.getInt(forgeData, "fmlNetworkVersion"));
|
||||
}catch (Exception c){
|
||||
final Map<ResourceLocation, Pair<String, Boolean>> channels = StreamSupport.stream(JsonUtils.getJsonArray(forgeData, "channels").spliterator(), false).
|
||||
map(JsonElement::getAsJsonObject).
|
||||
collect(Collectors.toMap(jo -> new ResourceLocation(JsonUtils.getString(jo, "res")),
|
||||
jo -> Pair.of(JsonUtils.getString(jo, "version"), JsonUtils.getBoolean(jo, "required")))
|
||||
);
|
||||
|
||||
final Map<String, String> mods = StreamSupport.stream(JsonUtils.getJsonArray(forgeData, "mods").spliterator(), false).
|
||||
map(JsonElement::getAsJsonObject).
|
||||
collect(Collectors.toMap(jo -> JsonUtils.getString(jo, "modId"), jo->JsonUtils.getString(jo, "modmarker")));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonObject serialize(FMLStatusPing forgeData, JsonSerializationContext ctx) {
|
||||
JsonObject obj = new JsonObject();
|
||||
JsonArray mods = new JsonArray();
|
||||
forgeData.channelVersions.entrySet().stream().map(p -> {
|
||||
JsonArray channels = new JsonArray();
|
||||
forgeData.channels.forEach((namespace, version) -> {
|
||||
JsonObject mi = new JsonObject();
|
||||
mi.addProperty("namespace", p.getKey().getNamespace());
|
||||
mi.addProperty("path", p.getKey().getPath());
|
||||
mi.addProperty("version", p.getValue().getKey());
|
||||
mi.addProperty("mayBeAbsent", p.getValue().getValue());
|
||||
return mi;
|
||||
}).forEach(mods::add);
|
||||
obj.add("mods", mods);
|
||||
obj.addProperty("numberOfMods", forgeData.numberOfMods);
|
||||
mi.addProperty("res", namespace.toString());
|
||||
mi.addProperty("version", version.getLeft());
|
||||
mi.addProperty("required", version.getRight());
|
||||
channels.add(mi);
|
||||
});
|
||||
|
||||
obj.add("channels", channels);
|
||||
|
||||
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);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
public Map<ResourceLocation, Pair<String, Boolean>> getPresentMods(){
|
||||
return this.channelVersions;
|
||||
public Map<ResourceLocation, Pair<String, Boolean>> getRemoteChannels() {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public int getNumberOfMods(){
|
||||
return numberOfMods;
|
||||
public Map<String,String> getRemoteModData() {
|
||||
return mods;
|
||||
}
|
||||
|
||||
public int getFMLNetworkVersion() {
|
||||
|
|
|
@ -15,14 +15,17 @@
|
|||
"fml.menu.mods.info.childmods":"Child mods: {0}",
|
||||
"fml.menu.mods.info.updateavailable":"Update available: {0}",
|
||||
"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.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.incompatible":"Incompatible Vanilla server",
|
||||
"fml.menu.multiplayer.unknown":"Unknown server {0}",
|
||||
"fml.menu.multiplayer.serveroutdated":"Outdated server",
|
||||
"fml.menu.multiplayer.clientoutdated":"Outdated client",
|
||||
"fml.menu.multiplayer.serveroutdated":"The Forge server network version is outdated",
|
||||
"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.button.open.file": "Open {0}",
|
||||
"fml.button.open.mods.folder": "Open Mods Folder",
|
||||
|
|
Loading…
Reference in New Issue