Skip to content

Commit

Permalink
Add dependency overrides (#214) (#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
Matyrobbrt authored Nov 25, 2024
1 parent 9367f9c commit 45105b7
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 3 deletions.
79 changes: 79 additions & 0 deletions loader/src/main/java/net/neoforged/fml/loading/FMLConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package net.neoforged.fml.loading;

import com.electronwill.nightconfig.core.CommentedConfig;
import com.electronwill.nightconfig.core.Config;
import com.electronwill.nightconfig.core.ConfigSpec;
import com.electronwill.nightconfig.core.InMemoryFormat;
import com.electronwill.nightconfig.core.file.CommentedFileConfig;
Expand All @@ -19,9 +20,16 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Unmodifiable;
import org.jetbrains.annotations.UnmodifiableView;
import org.slf4j.Logger;

public class FMLConfig {
Expand Down Expand Up @@ -95,6 +103,7 @@ private static Object maxThreads(final Object value) {

private static final Logger LOGGER = LogUtils.getLogger();
private static final FMLConfig INSTANCE = new FMLConfig();
private static Map<String, List<DependencyOverride>> dependencyOverrides = Map.of();
private static final ConfigSpec configSpec = new ConfigSpec(
// Make sure the values are written in the same order as the enum.
InMemoryFormat.withUniversalSupport().createConfig(LinkedHashMap::new));
Expand All @@ -103,6 +112,17 @@ private static Object maxThreads(final Object value) {
for (ConfigValue cv : ConfigValue.values()) {
cv.buildConfigEntry(configSpec, configComments);
}

// Make sure that we don't end up "correcting" the config and removing dependency overrides
// We accept any objects (parsing and validation is done when the config is loaded)
configSpec.define("dependencyOverrides", () -> null, object -> true);
configComments.set("dependencyOverrides", configComments.createSubConfig());
configComments.setComment("dependencyOverrides", """
Define dependency overrides below
Dependency overrides can be used to forcibly remove a dependency constraint from a mod or to force a mod to load AFTER another mod
Using dependency overrides can cause issues. Use at your own risk.
Example dependency override for the mod with the id 'targetMod': dependency constraints (incompatibility clauses or restrictive version ranges) against mod 'dep1' are removed, and the mod will now load after the mod 'dep2'
dependencyOverrides.targetMod = ["-dep1", "+dep2"]""");
}

private CommentedConfig configData;
Expand All @@ -124,6 +144,10 @@ private void loadFrom(Path configFile) {
} else {
// This populates the config with the default values.
configSpec.correct(this.configData);

// Since dependency overrides have an empty validator, they need to be added manually.
// (Correct doesn't correct an absent value since it's valid).
this.configData.set("dependencyOverrides", this.configData.createSubConfig());
}

this.configData.putAllComments(configComments);
Expand All @@ -144,6 +168,43 @@ public static void load() {
}
}
FMLPaths.getOrCreateGameRelativePath(Paths.get(FMLConfig.getConfigValue(ConfigValue.DEFAULT_CONFIG_PATH)));

// load dependency overrides
Map<String, List<DependencyOverride>> dependencyOverrides = new HashMap<>();
var overridesObject = INSTANCE.configData.get("dependencyOverrides");
if (overridesObject != null) {
if (!(overridesObject instanceof Config cfg)) {
LOGGER.error("Invalid dependency overrides declaration in config. Expected object but found {}", overridesObject);
return;
}

cfg.valueMap().forEach((modId, object) -> {
// We accept both dependencyOverrides.target = "-dep" and dependencyOverrides.target = ["-dep"]
var asList = object instanceof List<?> ls ? ls : List.of(object);
var overrides = dependencyOverrides.computeIfAbsent(modId, k -> new ArrayList<>());
for (Object o : asList) {
var str = (String) o;
var start = str.charAt(0);
if (start != '+' && start != '-') {
LOGGER.error("Found invalid dependency override for mod '{}'. Expected +/- in override '{}'. Did you forget to specify the override type?", modId, str);
} else {
var removal = start == '-';
var depMod = str.substring(1);
overrides.add(new DependencyOverride(depMod, removal));
}
}
});
}

if (!dependencyOverrides.isEmpty()) {
LOGGER.warn("*".repeat(30) + " Found dependency overrides " + "*".repeat(30));
dependencyOverrides.forEach((modId, ov) -> LOGGER.warn("Dependency overrides for mod '{}': {}", modId, ov.stream().map(DependencyOverride::getMessage).collect(Collectors.joining(", "))));
LOGGER.warn("*".repeat(88));
}

// Make the overrides immutable
dependencyOverrides.replaceAll((id, list) -> List.copyOf(list));
FMLConfig.dependencyOverrides = Collections.unmodifiableMap(dependencyOverrides);
}

public static String getConfigValue(ConfigValue v) {
Expand Down Expand Up @@ -172,4 +233,22 @@ public static <T> void updateConfig(ConfigValue v, T value) {
public static String defaultConfigPath() {
return getConfigValue(ConfigValue.DEFAULT_CONFIG_PATH);
}

@Unmodifiable
public static List<DependencyOverride> getOverrides(String modId) {
var ov = dependencyOverrides.get(modId);
if (ov == null) return List.of();
return ov;
}

@UnmodifiableView
public static Map<String, List<DependencyOverride>> getDependencyOverrides() {
return Collections.unmodifiableMap(dependencyOverrides);
}

public record DependencyOverride(String modId, boolean remove) {
public String getMessage() {
return (remove ? "softening dependency constraints against" : "adding explicit AFTER ordering against") + " '" + modId + "'";
}
}
}
38 changes: 35 additions & 3 deletions loader/src/main/java/net/neoforged/fml/loading/ModSorter.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public static LoadingModList sort(List<ModFile> plugins, List<ModFile> mods, fin
// Otherwise, lets try and sort the modlist and proceed
ModLoadingException modLoadingException = null;
try {
ms.sort();
ms.sort(issues);
} catch (ModLoadingException e) {
modLoadingException = e;
}
Expand Down Expand Up @@ -105,7 +105,7 @@ private static <T> List<T> concat(List<T>... lists) {
}

@SuppressWarnings("UnstableApiUsage")
private void sort() {
private void sort(List<ModLoadingIssue> issues) {
// lambdas are identity based, so sorting them is impossible unless you hold reference to them
final MutableGraph<ModInfo> graph = GraphBuilder.directed().build();
AtomicInteger counter = new AtomicInteger();
Expand All @@ -120,6 +120,26 @@ private void sort() {
.map(IModInfo::getDependencies).<IModInfo.ModVersion>mapMulti(Iterable::forEach)
.forEach(dep -> addDependency(graph, dep));

// now consider dependency overrides
// we also check their validity here, and report unknown mods as warnings
FMLConfig.getDependencyOverrides().forEach((id, overrides) -> {
var target = (ModInfo) modIdNameLookup.get(id);
if (target == null) {
issues.add(ModLoadingIssue.warning("fml.modloadingissue.depoverride.unknown_target", id));
} else {
for (FMLConfig.DependencyOverride override : overrides) {
var dep = (ModInfo) modIdNameLookup.get(override.modId());
if (dep == null) {
issues.add(ModLoadingIssue.warning("fml.modloadingissue.depoverride.unknown_dependency", override.modId(), id));
} else if (!override.remove()) {
// Add ordering dependency overrides (random order -> target AFTER dependency)
// We do not need to check for overrides that attempt to change the declared order as the sorter will detect the cycle itself and error
graph.putEdge(dep, target);
}
}
}
});

final List<ModInfo> sorted;
try {
sorted = TopologicalSort.topologicalSort(graph, Comparator.comparing(infos::get));
Expand Down Expand Up @@ -237,7 +257,19 @@ private DependencyResolutionResult verifyDependencyVersions() {
final var modVersionDependencies = modFiles.stream()
.map(ModFile::getModInfos)
.<IModInfo>mapMulti(Iterable::forEach)
.collect(groupingBy(Function.identity(), flatMapping(e -> e.getDependencies().stream(), toList())));
.collect(groupingBy(Function.identity(), flatMapping(e -> {
var overrides = FMLConfig.getOverrides(e.getModId());
// consider overrides and invalidate dependencies that are removed
if (!overrides.isEmpty()) {
var ids = overrides.stream()
.filter(FMLConfig.DependencyOverride::remove)
.map(FMLConfig.DependencyOverride::modId)
.collect(toSet());
return e.getDependencies().stream()
.filter(v -> !ids.contains(v.getModId()));
}
return e.getDependencies().stream();
}, toList())));

final var modRequirements = modVersionDependencies.values().stream().<IModInfo.ModVersion>mapMulti(Iterable::forEach)
.filter(mv -> mv.getSide().isCorrectSide())
Expand Down
2 changes: 2 additions & 0 deletions loader/src/main/resources/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"fml.modloadingissue.discouragedmod": "Mod §e{1}§r §ddiscourages§r the use of §3{0}§r §o{2,vr}§r\n§7Currently, §3{0}§r§7 is §o{3}§r\n§7The reason is:§r §o{4,i18ntranslate}§r",
"fml.modloadingissue.discouragedmod.proceed": "Proceed at your own risk",
"fml.modloadingissue.duplicate_mod": "Mod §e{0}§r is present in multiple files: {1}",
"fml.modloadingissue.depoverride.unknown_target": "Unknown dependency override target with id §e{0}§r",
"fml.modloadingissue.depoverride.unknown_dependency": "Unknown mod §e{0}§r referenced in dependency overrides for mod §e{1}§r",
"fml.modloading.duplicate_library": "Library §e{3}§r is present in multiple files: {4}",
"fml.modloading.incompatiblemod.noreason": "§eNo reason provided§r",
"fml.modloading.discouragedmod.noreason": "§eNo reason provided§r",
Expand Down
43 changes: 43 additions & 0 deletions loader/src/test/java/net/neoforged/fml/loading/FMLLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.electronwill.nightconfig.core.Config;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import net.neoforged.fml.ModLoader;
Expand Down Expand Up @@ -392,6 +395,46 @@ void testUnsatisfiedNeoForgeRange() throws Exception {
assertThat(getTranslatedIssues(e.getIssues())).containsOnly("ERROR: Mod testproject requires neoforge 999.6 or above\nCurrently, neoforge is 1\n");
}

@Test
void testDependencyOverride() throws Exception {
installation.setupProductionClient();
installation.writeConfig("[dependencyOverrides]", "targetmod = [\"-depmod\", \"-incompatiblemod\"]");
installation.buildModJar("depmod.jar").withMod("depmod", "1.0").build();
installation.buildModJar("incompatiblemod.jar").withMod("incompatiblemod", "1.0").build();
installation.buildModJar("targetmod.jar")
.withModsToml(builder -> {
builder.unlicensedJavaMod();
builder.addMod("targetmod", "1.0", c -> {
var sub = Config.inMemory();
sub.set("modId", "depmod");
sub.set("versionRange", "[2,)");
sub.set("type", "required");

var sub2 = Config.inMemory();
sub2.set("modId", "incompatiblemod");
sub2.set("versionRange", "[1,");
sub2.set("type", "incompatible");
c.set("dependencies.targetmod", new ArrayList<>(Arrays.asList(sub, sub2)));
});
})
.build();
assertThat(launchAndLoad("forgeclient").issues()).isEmpty();
}

@Test
void testInvalidDependencyOverride() throws Exception {
installation.setupProductionClient();

// Test that invalid targets and dependencies warn
installation.writeConfig("[dependencyOverrides]", "unknownmod = [\"-testmod\"]", "testmod = [\"+depdoesntexist\"]");
installation.buildModJar("testmod.jar").withMod("testmod", "1.0").build();

var r = launchAndLoad("forgeclient");
assertThat(getTranslatedIssues(r.issues())).containsOnly(
"WARNING: Unknown dependency override target with id unknownmod",
"WARNING: Unknown mod depdoesntexist referenced in dependency overrides for mod testmod");
}

@Test
void testDuplicateMods() throws Exception {
installation.setupProductionClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ public ModFileBuilder withTestmodModsToml(Consumer<ModsTomlBuilder> customizer)
});
}

public ModFileBuilder withMod(String id, String version) {
return withModsToml(builder -> builder.unlicensedJavaMod().addMod(id, version));
}

public ModFileBuilder withModTypeManifest(IModFile.Type type) {
return withManifest(Map.of(
"FMLModType", type.name()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,13 @@ public ModFileBuilder buildModJar(String filename) throws IOException {
return new ModFileBuilder(path);
}

public void writeConfig(String... lines) throws IOException {
var file = getGameDir().resolve("config/fml.toml");

Files.createDirectories(file.getParent());
Files.writeString(file, String.join("\n", lines));
}

public static void writeJarFile(Path file, IdentifiableContent... content) throws IOException {
try (var fout = Files.newOutputStream(file)) {
writeJarFile(fout, content);
Expand Down

0 comments on commit 45105b7

Please sign in to comment.