diff --git a/src/main/java/io/github/pacifistmc/forgix/Forgix.java b/src/main/java/io/github/pacifistmc/forgix/Forgix.java index 1cbec4b..9ee3cc3 100644 --- a/src/main/java/io/github/pacifistmc/forgix/Forgix.java +++ b/src/main/java/io/github/pacifistmc/forgix/Forgix.java @@ -3,6 +3,8 @@ import io.github.pacifistmc.forgix.core.Multiversion; import io.github.pacifistmc.forgix.core.RelocationConfig; import io.github.pacifistmc.forgix.core.Relocator; +import io.github.pacifistmc.forgix.core.filehandlers.CustomFileHandler; +import io.github.pacifistmc.forgix.plugin.configurations.ForgixConfiguration; import io.github.pacifistmc.forgix.utils.JAR; import java.io.File; @@ -17,7 +19,9 @@ public class Forgix { private static final String MANIFEST_VERSION_KEY = "Forgix-Version"; private static final String MANIFEST_MAPPINGS_KEY = "Forgix-Mappings"; - public static void mergeLoaders(Map jarsAndLoadersMap, File outputFile, boolean silence = false) { + public static void mergeLoaders(Map jarsAndLoadersMap, File outputFile, + Map customFileHandlers = new HashMap(), + boolean silence = false) { if (!silence) { """ Thank you for using Forgix! @@ -27,7 +31,7 @@ public static void mergeLoaders(Map jarsAndLoadersMap, File output List configs = new ArrayList<>(); jarsAndLoadersMap.forEach((jar, loader) -> configs.add(new RelocationConfig(new JarFile(jar), loader))); - Relocator.relocate(configs); + Relocator.relocate(configs, customFileHandlers); Map tinyFiles = configs.stream() .map(RelocationConfig::getTinyFile) diff --git a/src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java b/src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java index d864631..49b94f7 100644 --- a/src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java +++ b/src/main/java/io/github/pacifistmc/forgix/core/Multiversion.java @@ -48,7 +48,7 @@ private static String generateMultiversionUUID() { var relocationConfig = new RelocationConfig(jarFile, uuid); relocationConfig.setMappings(renameMap); - Relocator.relocate(List.of(relocationConfig)); + Relocator.relocate(List.of(relocationConfig), Map.of()); } JAR.removeFiles(multiversionJar, List.of( diff --git a/src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java b/src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java index e67c4ae..db9378f 100644 --- a/src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java +++ b/src/main/java/io/github/pacifistmc/forgix/core/RelocationConfig.java @@ -1,5 +1,7 @@ package io.github.pacifistmc.forgix.core; +import io.github.pacifistmc.forgix.plugin.configurations.ForgixConfiguration; + import java.io.File; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; diff --git a/src/main/java/io/github/pacifistmc/forgix/core/Relocator.java b/src/main/java/io/github/pacifistmc/forgix/core/Relocator.java index 708c718..72eb45d 100644 --- a/src/main/java/io/github/pacifistmc/forgix/core/Relocator.java +++ b/src/main/java/io/github/pacifistmc/forgix/core/Relocator.java @@ -1,5 +1,7 @@ package io.github.pacifistmc.forgix.core; +import io.github.pacifistmc.forgix.core.filehandlers.CustomFileHandler; +import io.github.pacifistmc.forgix.plugin.configurations.ForgixConfiguration; import io.github.pacifistmc.forgix.utils.JAR; import io.github.pacifistmc.forgix.utils.TinyClassWriter; import net.fabricmc.tinyremapper.*; @@ -7,10 +9,7 @@ import org.apache.commons.io.FilenameUtils; import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; +import java.nio.file.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -22,220 +21,245 @@ * Relocates conflicting files in JARs. */ public class Relocator { - private static final File tempDir = Files.createTempDirectory("forgix-tiny").toFile(); - static { - tempDir.mustDeleteOnExit(); - } + private static final File tempDir = Files.createTempDirectory("forgix-tiny").toFile(); - /** + static { + tempDir.mustDeleteOnExit(); + } + + /** * Relocates conflicting files in JARs. - * @param relocationConfigs The relocationConfigs to process - */ - public static void relocate(List relocationConfigs) { - relocateClasses(relocationConfigs); - relocateResources(relocationConfigs); - } - - /** - * Relocates conflicting classes in JARs. - * @param relocationConfigs The relocationConfigs to process - */ - private static final Map> mappingsSnapshot = new ConcurrentHashMap<>(); // Store a snapshot of the mappings so we can restore it at the end of all passes - public static void relocateClasses(List relocationConfigs, boolean doAnotherPass = false) { - if (doAnotherPass || relocationConfigs.getFirst().tinyFile == null) generateMappings(relocationConfigs, !doAnotherPass); // Generate mappings if they don't exist - - // Return if there are no new conflicts (no new class conflicts) - // This will return if there are no conflicts at all (empty mappings) or there are only resource conflicts which we're ignoring - if (doAnotherPass && relocationConfigs.parallelStream() - .flatMap(config -> config.getMappings().keySet().parallelStream()) - .noneMatch(mapping -> mapping.endsWith(".class")) - ) { - relocationConfigs.parallelStream().forEach(config -> config.setMappings(mappingsSnapshot.get(config.getJarFile()))); - return; - } - - // Process each JAR file in parallel - relocationConfigs.parallelStream().forEach(relocationConfig -> { - // Update mapping snapshot, merge with existing ones - // TODO: Make mappingsSnapshot faster, relocateResources needs access to class mappings and - // doing multiple passes of class relocation resets the mappings so we need to store a snapshot of the mappings - // in order to restore them at the end of all passes - mappingsSnapshot.computeIfAbsent(relocationConfig.getJarFile(), _ -> new ConcurrentHashMap<>()); - mappingsSnapshot.computeIfPresent(relocationConfig.getJarFile(), (_, existingMap) -> { - existingMap.putAll(relocationConfig.mappings); - return existingMap; - }); - - // Create a tiny remapper with the mappings - IMappingProvider tinyMappings = TinyUtils.createTinyMappingProvider(relocationConfig.tinyFile.toPath(), "original", "relocated"); - var logger = new ConsoleLogger(); logger.setLevel(TrLogger.Level.ERROR); - TinyRemapper tinyRemapper = TinyRemapper.newRemapper(logger).withMappings(tinyMappings).ignoreConflicts(true).fixPackageAccess(true).renameInvalidLocals(true).rebuildSourceFilenames(true).resolveMissing(true).build(); - - // Get the paths - Path jarFilePath = Paths.get(relocationConfig.jarFile.getName()); - Path tempJarFilePath = Paths.get(relocationConfig.jarFile.getName().setExtension("tmp")); - - try (OutputConsumerPath outputConsumer = new OutputConsumerPath.Builder(tempJarFilePath).assumeArchive(true).build()) { - // Add the jar file as a target - outputConsumer.addNonClassFiles(jarFilePath); - tinyRemapper.readInputs(jarFilePath); - - // Apply the mappings - tinyRemapper.apply(outputConsumer); - } finally { - tinyRemapper.finish(); // Close the remapper - } - - // Close the original JAR file - relocationConfig.jarFile.close(); - - // Replace the original JAR file with the relocated one - Files.move(tempJarFilePath, jarFilePath, StandardCopyOption.REPLACE_EXISTING); // TODO: Probably don't overwrite, update the jar location in relocation config instead. FIXME: Trying to update the jar location gives so many lambda bootstrap errors due to how cursed manifold is 😭 - }); - - // Do multiple passes to handle possible conflicts created by the previous pass - // Example: - // ``` - // class A { - // public static Object get() { - // return Minecraft.getInstance(); - // } - // } - // class B { - // public static void use() { - // System.out.println(A.get()); - // } - // } - // ``` - // A will get remapped to `fabric.A` and `forge.A`, which will B call? - relocateClasses(relocationConfigs, true); - } - - /** - * Relocates conflicting resources in JARs. - * @param relocationConfigs The relocationConfigs to process + * + * @param relocationConfigs The relocationConfigs to process + * @param customFileHandlers */ - public static void relocateResources(List relocationConfigs, boolean anotherPass = false) { - // Generate mappings if they don't exist or this is another pass - if (anotherPass || relocationConfigs.getFirst().tinyFile == null) generateMappings(relocationConfigs, !anotherPass); + public static void relocate(List relocationConfigs, Map customFileHandlers) { + relocateClasses(relocationConfigs); + relocateResources(relocationConfigs, customFileHandlers); + } + + /** + * Relocates conflicting classes in JARs. + * + * @param relocationConfigs The relocationConfigs to process + */ + private static final Map> mappingsSnapshot = new ConcurrentHashMap<>(); // Store a snapshot of the mappings so we can restore it at the end of all passes + + public static void relocateClasses(List relocationConfigs, boolean doAnotherPass=false) { + if (doAnotherPass || relocationConfigs.getFirst().tinyFile == null) + generateMappings(relocationConfigs, !doAnotherPass); // Generate mappings if they don't exist + + // Return if there are no new conflicts (no new class conflicts) + // This will return if there are no conflicts at all (empty mappings) or there are only resource conflicts which we're ignoring + if (doAnotherPass && relocationConfigs.parallelStream() + .flatMap(config -> config.getMappings().keySet().parallelStream()) + .noneMatch(mapping -> mapping.endsWith(".class")) + ) { + relocationConfigs.parallelStream().forEach(config -> config.setMappings(mappingsSnapshot.get(config.getJarFile()))); + return; + } + + // Process each JAR file in parallel + relocationConfigs.parallelStream().forEach(relocationConfig -> { + // Update mapping snapshot, merge with existing ones + // TODO: Make mappingsSnapshot faster, relocateResources needs access to class mappings and + // doing multiple passes of class relocation resets the mappings so we need to store a snapshot of the mappings + // in order to restore them at the end of all passes + mappingsSnapshot.computeIfAbsent(relocationConfig.getJarFile(), _ -> new ConcurrentHashMap<>()); + mappingsSnapshot.computeIfPresent(relocationConfig.getJarFile(), (_, existingMap) -> { + existingMap.putAll(relocationConfig.mappings); + return existingMap; + }); + + // Create a tiny remapper with the mappings + IMappingProvider tinyMappings = TinyUtils.createTinyMappingProvider(relocationConfig.tinyFile.toPath(), "original", "relocated"); + var logger = new ConsoleLogger(); + logger.setLevel(TrLogger.Level.ERROR); + TinyRemapper tinyRemapper = TinyRemapper.newRemapper(logger).withMappings(tinyMappings).ignoreConflicts(true).fixPackageAccess(true).renameInvalidLocals(true).rebuildSourceFilenames(true).resolveMissing(true).build(); + + // Get the paths + Path jarFilePath = Paths.get(relocationConfig.jarFile.getName()); + Path tempJarFilePath = Paths.get(relocationConfig.jarFile.getName().setExtension("tmp")); - // Return if there are no new conflicts + try (OutputConsumerPath outputConsumer = new OutputConsumerPath.Builder(tempJarFilePath).assumeArchive(true).build()) { + // Add the jar file as a target + outputConsumer.addNonClassFiles(jarFilePath); + tinyRemapper.readInputs(jarFilePath); + + // Apply the mappings + tinyRemapper.apply(outputConsumer); + } finally { + tinyRemapper.finish(); // Close the remapper + } + + // Close the original JAR file + relocationConfig.jarFile.close(); + + // Replace the original JAR file with the relocated one + Files.move(tempJarFilePath, jarFilePath, StandardCopyOption.REPLACE_EXISTING); // TODO: Probably don't overwrite, update the jar location in relocation config instead. FIXME: Trying to update the jar location gives so many lambda bootstrap errors due to how cursed manifold is 😭 + }); + + // Do multiple passes to handle possible conflicts created by the previous pass + // Example: + // ``` + // class A { + // public static Object get() { + // return Minecraft.getInstance(); + // } + // } + // class B { + // public static void use() { + // System.out.println(A.get()); + // } + // } + // ``` + // A will get remapped to `fabric.A` and `forge.A`, which will B call? + relocateClasses(relocationConfigs, true); + } + + /** + * Relocates conflicting resources in JARs. + * + * @param relocationConfigs The relocationConfigs to process + */ + public static void relocateResources(List relocationConfigs, Map customFileHandlers, boolean anotherPass=false) { + // Generate mappings if they don't exist or this is another pass + if (anotherPass || relocationConfigs.getFirst().tinyFile == null) generateMappings(relocationConfigs, !anotherPass); + + // Return if there are no new conflicts // if (anotherPass && relocationConfigs.stream().allMatch(config -> config.mappings.isEmpty())) return; - // This will return if there are no conflicts at all (empty mappings) or there are only class and/or META-INF conflicts which we're ignoring - if (anotherPass && relocationConfigs.stream() - .flatMap(config -> config.mappings.keySet().stream()) - .noneMatch(mapping -> !mapping.endsWith(".class") && !mapping.startsWith("META-INF/")) // Checking to see if there's any resource that isn't a class or META-INF, if there isn't then return - ) return; - - AtomicBoolean doAnotherPass = new AtomicBoolean(false); - - // Process each JAR file in parallel - relocationConfigs.parallelStream().forEach(relocationConfig -> { - JarFile jarFile = JAR.isClosed(relocationConfig.jarFile) ? new JarFile(relocationConfig.jarFile.getName()) : relocationConfig.jarFile; // Reopen the JAR file if it's closed (it can be closed by the `relocateClasses` method) - - Set resources = JAR.getResources(jarFile); - Map contentMapping = new HashMap<>(); - - // Create a map of conflicts with path alterations. - Map, Map> conflicts = new HashMap<>(); - Map fileConflicts = new HashMap<>(); // Keep track of mixins to handle them specially - relocationConfig.mappings.forEach((originalPath, relocatedPath) -> { - if (originalPath.endsWith("META-INF/MANIFEST.MF")) return; // Skip manifest - if (!originalPath.endsWith(".class") && !originalPath.startsWith("META-INF/services/")) { // Is a regular file conflict - fileConflicts.put(originalPath, relocatedPath); - } - // replacing with `removeExtension()` would make the ones with extensions be replaced which is what we want - conflicts.put(content -> content.contains(originalPath.removeExtension()), Map.of(originalPath.removeExtension(), relocatedPath.removeExtension())); // Add the original path without the .class extension // the predicate should be _ -> true but for some reason that doesn't work - conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '.'), relocatedPath.removeExtension().replace('/', '.'))); // If it's in a directory, add the original path without the .class extension and with dots instead of slashes - conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '\\'), relocatedPath.removeExtension().replace('/', '\\'))); // If it's in a directory, add the original path without the .class extension and with backslashes instead of slashes - if (originalPath.endsWith(".class")) { // Is a class conflict - conflicts.put(content -> content.contains("\"${originalPath.getPath().replace('/', '.')}\""), // look for the path with dots and in quotes (for mixins), example: "com.example.mod.mixins" - Map.of("\"${originalPath.getBaseName().removeExtension()}\"", "\"${relocatedPath.getBaseName().removeExtension()}\"")); // Just the filename without the path & extension and in quotes (for mixins) - } - }); - - resources.parallelStream().forEach(entry -> { - String content = JAR.getResource(jarFile, entry); - for (var conflict : conflicts.entrySet()) { - // TODO: Smart replace - // If a file has "com.example.Meow" and "com.example.Meow2" and we're only replacing "com.example.Meow" then only replace all instances of "com.example.Meow" but not "com.example.Meow2" - if (conflict.getKey().test(content)) { - for (var replacement : conflict.getValue().entrySet()) { - content = content.replace(replacement.getKey(), replacement.getValue()); - } - } - } - contentMapping.put(entry, content); - }); - - doAnotherPass.set(JAR.writeResources(jarFile, contentMapping)); - jarFile.close(); - JAR.renameResources(jarFile.getName(), fileConflicts); - }); - - // Do a multiple passes to handle conflicts that were created by the previous pass - if (doAnotherPass.get()) relocateResources(relocationConfigs, true); - } - - /** - * Sets up the mappings for conflicting files in JARs. - * @param relocationConfigs The relocationConfigs to process - * @param append Isn't a good name, but we set it to false to check if we have any new conflicts, - * setting it to false will ignore previous mappings and overwrite them, - * but it will still append the mappings to the tiny file - */ - public static void generateMappings(List relocationConfigs, boolean append = true) { - mapConflicts(relocationConfigs, append); - TinyClassWriter.write(relocationConfigs, tempDir); - } - - /** - * Maps conflicting entries to their relocated paths. - * @param relocationConfigs The list of relocationConfigs to process - * @param append Whether to append to the existing mappings - */ - private static void mapConflicts(List relocationConfigs, boolean append = true) { - record FileInfo(String path, byte[] hash, RelocationConfig source) { } - - // Map to store all relocationConfigs and their hashes grouped by path - Map> filesByPath = new ConcurrentHashMap<>(); - - // Process each JAR file in parallel - relocationConfigs.parallelStream().forEach(file -> { - var jarFile = append ? file.jarFile : new JarFile(file.jarFile.getName()); // If we're not appending, we need to reopen the JAR file - var entries = jarFile.entries(); - while (entries.hasMoreElements()) { - var entry = entries.nextElement(); - if (!entry.isDirectory()) { - // Normalize the path to use forward slashes (JAR standard) - String path = FilenameUtils.normalize(entry.getName(), true); - byte[] hash = JAR.computeHash(jarFile, entry); - - filesByPath.compute(path, (_, existing) -> { - var list = existing == null ? new ArrayList() : existing; - // Only add if the hash is different - if (list.stream().noneMatch(info -> Arrays.equals(info.hash, hash))) - list.add(new FileInfo(path, hash, file)); - return list; - }); - } - } - if (!append) jarFile.close(); // Close the jar if we opened it - }); - - if (!append) { // remove all mappings from the relocation configs as we're not appending - relocationConfigs.forEach(config -> config.setMappings(new HashMap<>())); - } - - // Create mappings for conflicts - filesByPath.forEach((_, fileInfos) -> { - // Return if there are no conflicts - if (fileInfos.size() <= 1) return; - - // Create mappings for all relocationConfigs - fileInfos.forEach(fileInfo -> - fileInfo.source.mappings.putIfAbsent(fileInfo.path, fileInfo.path.addPrefixExtension(fileInfo.source.conflictPrefix)) - ); - }); - } + // This will return if there are no conflicts at all (empty mappings) or there are only class and/or META-INF conflicts which we're ignoring + if (anotherPass && relocationConfigs.stream() + .flatMap(config -> config.mappings.keySet().stream()) + .noneMatch(mapping -> !mapping.endsWith(".class") && !mapping.startsWith("META-INF/")) // Checking to see if there's any resource that isn't a class or META-INF, if there isn't then return + ) return; + + AtomicBoolean doAnotherPass = new AtomicBoolean(false); + + // Process each JAR file in parallel + relocationConfigs.parallelStream().forEach(relocationConfig -> { + JarFile jarFile = JAR.isClosed(relocationConfig.jarFile) ? new JarFile(relocationConfig.jarFile.getName()) : relocationConfig.jarFile; // Reopen the JAR file if it's closed (it can be closed by the `relocateClasses` method) + + Set resources = JAR.getResources(jarFile); + Map contentMapping = new HashMap<>(); + + // Create a map of conflicts with path alterations. + Map, Map> conflicts = new HashMap<>(); + Map fileConflicts = new HashMap<>(); // Keep track of mixins to handle them specially + relocationConfig.mappings.forEach((originalPath, relocatedPath) -> { + if (originalPath.endsWith("META-INF/MANIFEST.MF")) return; // Skip manifest + if (!originalPath.endsWith(".class") && !originalPath.startsWith("META-INF/services/")) { // Is a regular file conflict + fileConflicts.put(originalPath, relocatedPath); + } + // replacing with `removeExtension()` would make the ones with extensions be replaced which is what we want + conflicts.put(content -> content.contains(originalPath.removeExtension()), Map.of(originalPath.removeExtension(), relocatedPath.removeExtension())); // Add the original path without the .class extension // the predicate should be _ -> true but for some reason that doesn't work + conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '.'), relocatedPath.removeExtension().replace('/', '.'))); // If it's in a directory, add the original path without the .class extension and with dots instead of slashes + conflicts.put(_ -> originalPath.contains("/"), Map.of(originalPath.removeExtension().replace('/', '\\'), relocatedPath.removeExtension().replace('/', '\\'))); // If it's in a directory, add the original path without the .class extension and with backslashes instead of slashes + if (originalPath.endsWith(".class")) { // Is a class conflict + conflicts.put(content -> content.contains("\"${originalPath.getPath().replace('/', '.')}\""), // look for the path with dots and in quotes (for mixins), example: "com.example.mod.mixins" + Map.of("\"${originalPath.getBaseName().removeExtension()}\"", "\"${relocatedPath.getBaseName().removeExtension()}\"")); // Just the filename without the path & extension and in quotes (for mixins) + } + }); + + resources.parallelStream().forEach(entry -> { + String content = JAR.getResource(jarFile, entry); + // Get the custom handler for this file if the name fits a pattern + CustomFileHandler handler = customFileHandlers.entrySet() + .stream() + .filter(entry1 -> { + var matcher = FileSystems.getDefault().getPathMatcher("glob:${entry1.key}"); + return matcher.matches(Paths.get(entry.getName())); + }) + .findFirst() + .map(Map.Entry::getValue) + .orElse(null); + + for (var conflict : conflicts.entrySet()) { + if (handler != null) { // If there's a custom handler for this file, use it to handle the conflicts + content = handler.handle(entry.getName(), content, conflict.value); + } else if (conflict.getKey().test(content)) { + // TODO: Smart replace + // If a file has "com.example.Meow" and "com.example.Meow2" and we're only replacing "com.example.Meow" then only replace all instances of "com.example.Meow" but not "com.example.Meow2" + for (var replacement : conflict.getValue().entrySet()) { + content = content.replace(replacement.getKey(), replacement.getValue()); + } + } + + contentMapping.put(entry, content); + } + }); + + doAnotherPass.set(JAR.writeResources(jarFile, contentMapping)); + jarFile.close(); + JAR.renameResources(jarFile.getName(), fileConflicts); + }); + + // Do a multiple passes to handle conflicts that were created by the previous pass + if (doAnotherPass.get()) relocateResources(relocationConfigs, customFileHandlers, true); + } + + /** + * Sets up the mappings for conflicting files in JARs. + * + * @param relocationConfigs The relocationConfigs to process + * @param append Isn't a good name, but we set it to false to check if we have any new conflicts, + * setting it to false will ignore previous mappings and overwrite them, + * but it will still append the mappings to the tiny file + */ + public static void generateMappings(List relocationConfigs, boolean append=true) { + mapConflicts(relocationConfigs, append); + TinyClassWriter.write(relocationConfigs, tempDir); + } + + /** + * Maps conflicting entries to their relocated paths. + * + * @param relocationConfigs The list of relocationConfigs to process + * @param append Whether to append to the existing mappings + */ + private static void mapConflicts(List relocationConfigs, boolean append=true) { + record FileInfo(String path, byte[] hash, RelocationConfig source) { + } + + // Map to store all relocationConfigs and their hashes grouped by path + Map> filesByPath = new ConcurrentHashMap<>(); + + // Process each JAR file in parallel + relocationConfigs.parallelStream().forEach(file -> { + var jarFile = append ? file.jarFile : new JarFile(file.jarFile.getName()); // If we're not appending, we need to reopen the JAR file + var entries = jarFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (!entry.isDirectory()) { + // Normalize the path to use forward slashes (JAR standard) + String path = FilenameUtils.normalize(entry.getName(), true); + byte[] hash = JAR.computeHash(jarFile, entry); + + filesByPath.compute(path, (_, existing) -> { + var list = existing == null ? new ArrayList() : existing; + // Only add if the hash is different + if (list.stream().noneMatch(info -> Arrays.equals(info.hash, hash))) + list.add(new FileInfo(path, hash, file)); + return list; + }); + } + } + if (!append) jarFile.close(); // Close the jar if we opened it + }); + + if (!append) { // remove all mappings from the relocation configs as we're not appending + relocationConfigs.forEach(config -> config.setMappings(new HashMap<>())); + } + + // Create mappings for conflicts + filesByPath.forEach((_, fileInfos) -> { + // Return if there are no conflicts + if (fileInfos.size() <= 1) return; + + // Create mappings for all relocationConfigs + fileInfos.forEach(fileInfo -> + fileInfo.source.mappings.putIfAbsent(fileInfo.path, fileInfo.path.addPrefixExtension(fileInfo.source.conflictPrefix)) + ); + }); + } } diff --git a/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java b/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java new file mode 100644 index 0000000..32f2a22 --- /dev/null +++ b/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/CustomFileHandler.java @@ -0,0 +1,8 @@ +package io.github.pacifistmc.forgix.core.filehandlers; + +import java.util.Map; + +@FunctionalInterface +public interface CustomFileHandler { + String handle(String fileName, String fileContent, Map replacementPaths); +} \ No newline at end of file diff --git a/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java b/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java new file mode 100644 index 0000000..41e84d1 --- /dev/null +++ b/src/main/java/io/github/pacifistmc/forgix/core/filehandlers/MixinFileHandler.java @@ -0,0 +1,58 @@ +package io.github.pacifistmc.forgix.core.filehandlers; + +import com.google.gson.*; + +import java.util.Map; + +public class MixinFileHandler implements CustomFileHandler { + @Override + public String handle(String fileName, String fileContent, Map replacementPaths) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + JsonObject mixinJson = gson.fromJson(fileContent, JsonObject.class); + String packagePath = mixinJson.get("package").getAsString(); + + JsonArray commonMixinPaths = mixinJson.getAsJsonArray("mixins"); + if (commonMixinPaths != null) { + JsonArray newCommonMixinPaths = new JsonArray(); + for (JsonElement mixinPath : commonMixinPaths) { + String fullPath = packagePath + "." + mixinPath.getAsString(); + if (replacementPaths.containsKey(fullPath)) + newCommonMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1)); + else + newCommonMixinPaths.add(mixinPath); + } + mixinJson.add("mixins", newCommonMixinPaths); + } + + JsonArray clientMixinPaths = mixinJson.getAsJsonArray("client"); + if (clientMixinPaths != null) { + JsonArray newClientMixinPaths = new JsonArray(); + for (JsonElement mixinPath : clientMixinPaths) { + String fullPath = packagePath + "." + mixinPath.getAsString(); + if (replacementPaths.containsKey(fullPath)) + newClientMixinPaths.add(replacementPaths.get(fullPath).substring(packagePath.length() + 1)); + else + newClientMixinPaths.add(mixinPath); + } + mixinJson.add("client", newClientMixinPaths); + } + + JsonPrimitive mixinPlugin = mixinJson.getAsJsonPrimitive("plugin"); + if (mixinPlugin != null) { + if (replacementPaths.containsKey(mixinPlugin.getAsString())) + mixinJson.addProperty("plugin", replacementPaths.get(mixinPlugin.getAsString())); + else + mixinJson.addProperty("plugin", mixinPlugin.getAsString()); + } + + JsonPrimitive mixinRef = mixinJson.getAsJsonPrimitive("refmap"); + if (mixinRef != null) { + if (replacementPaths.containsKey(mixinRef.getAsString())) + mixinJson.addProperty("refmap", replacementPaths.get(mixinRef.getAsString())); + else + mixinJson.addProperty("refmap", mixinRef.getAsString()); + } + + return gson.toJson(mixinJson); + } +} diff --git a/src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java b/src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java index 50bac73..a39f647 100644 --- a/src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java +++ b/src/main/java/io/github/pacifistmc/forgix/plugin/configurations/ForgixConfiguration.java @@ -1,5 +1,8 @@ package io.github.pacifistmc.forgix.plugin.configurations; +import com.google.gson.*; +import io.github.pacifistmc.forgix.core.filehandlers.CustomFileHandler; +import io.github.pacifistmc.forgix.core.filehandlers.MixinFileHandler; import org.gradle.api.Action; import org.gradle.api.Project; import org.gradle.api.file.Directory; @@ -22,6 +25,7 @@ public class ForgixConfiguration { private final Property destinationDirectory; private final Map mergeConfigurations = new HashMap<>(); public MultiversionConfiguration multiversionConfiguration; + private final Map customFileHandlers = new HashMap<>(); private final Map> LOADER_DEFAULT_METHOD_MAP = new HashMap<>(); private final Project rootProject; @@ -327,6 +331,20 @@ public Property getInputJars() { } } + // Custom file handling stuff + + public void mixin(String mixinPathPattern) { + fileHandler(mixinPathPattern, new MixinFileHandler()); + } + + public void fileHandler(String fileNamePattern, CustomFileHandler handler) { + customFileHandlers.put(fileNamePattern, handler); + } + + public Map getCustomFileHandlers() { + return customFileHandlers; + } + // Internal Gradle stuff @Inject diff --git a/src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java b/src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java index eb9471a..753f72b 100644 --- a/src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java +++ b/src/main/java/io/github/pacifistmc/forgix/plugin/tasks/MergeJarsTask.java @@ -74,7 +74,7 @@ void mergeJars() { } // Perform the merge operation - Forgix.mergeLoaders(jarMap, outputFile, silence.get()); + Forgix.mergeLoaders(jarMap, outputFile, settings.customFileHandlers, silence.get()); } @Override diff --git a/src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java b/src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java index df93b49..20412bd 100644 --- a/src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java +++ b/src/test/java/io/github/pacifistmc/forgix/tests/CoreTest.java @@ -3,8 +3,11 @@ import io.github.pacifistmc.forgix.Forgix; import io.github.pacifistmc.forgix.core.Multiversion; import io.github.pacifistmc.forgix.core.Relocator; +import io.github.pacifistmc.forgix.core.filehandlers.MixinFileHandler; import io.github.pacifistmc.forgix.utils.JAR; import io.github.pacifistmc.forgix.utils.TinyClassWriter; +import org.apache.commons.io.IOUtils; +import org.gradle.internal.impldep.org.junit.Assert; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,13 +16,16 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.zip.ZipEntry; import org.apache.commons.io.FileUtils; @@ -184,7 +190,7 @@ void testRelocation() throws IOException { new RelocationConfig(differentJarA, "diffA"), new RelocationConfig(differentJarB, "diffB") )); - Relocator.relocate(files); + Relocator.relocate(files, Map.of()); var differentJarA1 = new JarFile(differentJarACopy); var differentJarB1 = new JarFile(differentJarBCopy); @@ -242,7 +248,7 @@ void testMerge() throws IOException { new RelocationConfig(mergeJarA, "diffA"), new RelocationConfig(mergeJarB, "diffB") )); - Relocator.relocate(files); + Relocator.relocate(files, Map.of()); try (var baos = JAR.combineJars(List.of(mergeJarACopy, mergeJarBCopy))) { try (var fos = new FileOutputStream(mergedJar)) { baos.writeTo(fos); @@ -307,6 +313,106 @@ void testMergeCLI() throws IOException { } } + @Test + void testCustomFileHandling() throws IOException { + File differentJarACopy = tempDir.resolve("conflict-a.jar").toFile(); + File differentJarBCopy = tempDir.resolve("conflict-b.jar").toFile(); + FileUtils.copyFile(differentJarA, differentJarACopy); + FileUtils.copyFile(differentJarB, differentJarBCopy); + + // Relocate & merge + try(JarFile differentJarA = new JarFile(differentJarACopy); + JarFile differentJarB = new JarFile(differentJarBCopy)) { + + List files = new ArrayList<>(List.of( + new RelocationConfig(differentJarA, "diffA"), + new RelocationConfig(differentJarB, "diffB") + )); + Relocator.relocate(files, Map.of("**.mixins.json", new MixinFileHandler())); + } + + // Verify the merged JAR mixins + try (JarFile differentJarA = new JarFile(differentJarACopy); + JarFile differentJarB = new JarFile(differentJarBCopy)) { + ZipEntry mixinEntryA = differentJarA.getEntry("DistantHorizons.fabric.mixins_diffA.json"); + try (InputStream is = differentJarA.getInputStream(mixinEntryA)) { + String content = IOUtils.toString(is, StandardCharsets.UTF_8); + if (debug) { + content.println(); + } + Assert.assertEquals(content, """ + { + "required": true, + "minVersion": "0.8", + "package": "com.seibel.distanthorizons.fabric.mixins", + "mixins": [ + "server.MixinChunkGenerator_diffA", + "server.MixinChunkMap_diffA", + "server.MixinEntity_diffA", + "server.MixinServerPlayer_diffA", + "server.MixinTracingExecutor_diffA", + "server.MixinUtilBackgroundThread_diffA" + ], + "client": [ + "client.MixinClientLevel_diffA", + "client.MixinClientPacketListener_diffA", + "client.MixinDebugScreenOverlay_diffA", + "client.MixinFogRenderer_diffA", + "client.MixinLevelRenderer_diffA", + "client.MixinLightTexture_diffA", + "client.MixinMinecraft_diffA", + "client.MixinOptionsScreen_diffA", + "client.MixinTextureUtil_diffA" + ], + "server": [], + "injectors": { + "defaultRequire": 1 + }, + "plugin": "com.seibel.distanthorizons.fabric.mixins.FabricMixinPlugin", + "refmap": "DistantHorizons-fabric-refmap.json" + }""".stripIndent()); + } + + ZipEntry mixinEntryB = differentJarB.getEntry("DistantHorizons.fabric.mixins_diffB.json"); + try (InputStream is = differentJarB.getInputStream(mixinEntryB)) { + String content = IOUtils.toString(is, StandardCharsets.UTF_8); + if (debug) { + content.println(); + } + Assert.assertEquals(content, """ + { + "required": true, + "minVersion": "0.8", + "package": "com.seibel.distanthorizons.fabric.mixins", + "mixins": [ + "server.MixinChunkGenerator_diffB", + "server.MixinChunkMap_diffB", + "server.MixinEntity_diffB", + "server.MixinServerPlayer_diffB", + "server.MixinTracingExecutor_diffB", + "server.MixinUtilBackgroundThread_diffB" + ], + "client": [ + "client.MixinClientLevel_diffB", + "client.MixinClientPacketListener_diffB", + "client.MixinDebugScreenOverlay_diffB", + "client.MixinFogRenderer_diffB", + "client.MixinLevelRenderer_diffB", + "client.MixinLightTexture_diffB", + "client.MixinMinecraft_diffB", + "client.MixinOptionsScreen_diffB", + "client.MixinTextureUtil_diffB" + ], + "injectors": { + "defaultRequire": 1 + }, + "plugin": "com.seibel.distanthorizons.fabric.mixins.FabricMixinPlugin", + "refmap": "DistantHorizons-fabric-refmap.json" + }""".stripIndent()); + } + } + } + @Test void testMultiversion() throws IOException { // Copy version jars into the temp directory