diff --git a/settings.gradle b/settings.gradle index 31144b8..83a52d0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -53,7 +53,7 @@ dependencyResolutionManagement { mavenCentral() maven { name = "NeoForge" - url = "https://maven.neoforged.net" + url = "https://maven.neoforged.net/releases/" } } } diff --git a/src/main/java/net/neoforged/installertools/EnumExtension.java b/src/main/java/net/neoforged/installertools/EnumExtension.java new file mode 100644 index 0000000..1554741 --- /dev/null +++ b/src/main/java/net/neoforged/installertools/EnumExtension.java @@ -0,0 +1,104 @@ +/* + * InstallerTools + * Copyright (c) 2019-2025. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation version 2.1 + * of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package net.neoforged.installertools; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * This class applies enum extension data files, + * and marks enums extended in such a way with an annotation. + *

+ * Note that only a subset of FML's RuntimeEnumExtender's functionality is re-implemented here. + * Thus, the resulting class files will not work at runtime, unless FML is present to replace the injected enum entries + * in a way that gives the proper ordering guarantees. + */ +public class EnumExtension { + private final String annotationMarker; + + // Enum class -> entries to add + private final Map> extensions; + + public EnumExtension(List extensionDataFiles, String annotationMarker) { + this.annotationMarker = annotationMarker; + + extensions = new HashMap<>(); + Gson gson = new Gson(); + for (File file : extensionDataFiles) { + try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + JsonObject json = gson.fromJson(new BufferedReader(reader), JsonObject.class); + if (json.has("entries")) { + JsonArray entries = json.getAsJsonArray("entries"); + for (JsonElement entry : entries) { + JsonObject entryObj = entry.getAsJsonObject(); + String enumName = entryObj.getAsJsonPrimitive("enum").getAsString(); + String entryName = entryObj.getAsJsonPrimitive("name").getAsString(); + extensions.computeIfAbsent(enumName, unused -> new LinkedHashSet<>()).add(entryName); + } + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read interface injection data file " + file, exception); + } + } + } + + public boolean containsClassTarget(Type classType) { + return extensions.containsKey(classType.getInternalName()); + } + + public void transform(ClassNode cn, Type type) { + Set entries = extensions.get(type.getInternalName()); + if (entries == null || entries.isEmpty()) { + return; + } + List sortedEntries = entries.stream().sorted().collect(Collectors.toList()); + for (String entry : sortedEntries) { + FieldNode field = new FieldNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL | Opcodes.ACC_ENUM, entry, type.getDescriptor(), null, null); + List invisibleAnnotations = new ArrayList<>(); + invisibleAnnotations.add(new AnnotationNode( + Type.getObjectType(annotationMarker).getDescriptor() + )); + field.invisibleAnnotations = invisibleAnnotations; + cn.fields.add(field); + } + } +} diff --git a/src/main/java/net/neoforged/installertools/ProcessMinecraftJar.java b/src/main/java/net/neoforged/installertools/ProcessMinecraftJar.java index ebb645b..227d04d 100644 --- a/src/main/java/net/neoforged/installertools/ProcessMinecraftJar.java +++ b/src/main/java/net/neoforged/installertools/ProcessMinecraftJar.java @@ -118,6 +118,8 @@ public void process(String[] args) throws IOException { OptionSpec accessTransformerArg = parser.accepts("access-transformer", "Apply an access transformer.").withOptionalArg().ofType(File.class); OptionSpec iiAnnotationMarkerArg = parser.accepts("interface-injection-marker", "The name (binary representation) of an annotation to use as a marker for injected interfaces.").withOptionalArg().ofType(String.class); OptionSpec iiDataFilesArg = parser.accepts("interface-injection-data", "The paths to read interface injection JSON files from.").withOptionalArg().ofType(File.class); + OptionSpec eeAnnotationMarkerArg = parser.accepts("enum-extensions-marker", "The name (binary representation) of an annotation to use as a marker for extended enum entries.").withOptionalArg().ofType(String.class); + OptionSpec eeDataFilesArg = parser.accepts("enum-extensions-data", "The paths to read enum extension JSON files from.").withOptionalArg().ofType(File.class); OptionSet options; try { @@ -145,6 +147,9 @@ public void process(String[] args) throws IOException { List accessTransformerFiles = options.valuesOf(accessTransformerArg); String iiAnnotationMarker = options.valueOf(iiAnnotationMarkerArg); List iiDataFiles = options.valuesOf(iiDataFilesArg); + + String eeAnnotationMarker = options.valueOf(eeAnnotationMarkerArg); + List eeDataFiles = options.valuesOf(eeDataFilesArg); AccessTransformerEngine accessTransformers = null; if (!accessTransformerFiles.isEmpty()) { @@ -156,6 +161,12 @@ public void process(String[] args) throws IOException { interfaceInjection = new InterfaceInjection(iiDataFiles, iiAnnotationMarker); logElapsed("load interface injection data", iiStart); } + EnumExtension enumExtension = null; + if (!eeDataFiles.isEmpty()) { + long eeStart = System.nanoTime(); + enumExtension = new EnumExtension(eeDataFiles, eeAnnotationMarker); + logElapsed("load enum extension data", eeStart); + } boolean addModManifest = !options.has(noModManifest); boolean addDistAnnotations = !options.has(noDistAnnotations); @@ -178,7 +189,7 @@ public void process(String[] args) throws IOException { } try { - processZip(inputFile, inputMappingsFile, mergeInputFile, outputFile, librariesFolder, neoformDataFile, patchBundleFile, addModManifest, accessTransformers, interfaceInjection, addDistAnnotations); + processZip(inputFile, inputMappingsFile, mergeInputFile, outputFile, librariesFolder, neoformDataFile, patchBundleFile, addModManifest, accessTransformers, interfaceInjection, enumExtension, addDistAnnotations); } finally { if (ownedExecutor != null) { ownedExecutor.shutdownNow(); @@ -202,6 +213,8 @@ private void processZip(File inputFile, AccessTransformerEngine accessTransformers, @Nullable InterfaceInjection interfaceInjection, + @Nullable + EnumExtension enumExtension, boolean addDistAnnotations) { CompletableFuture> outputEntries; @@ -232,8 +245,8 @@ private void processZip(File inputFile, outputEntries = outputEntries.thenCombineAsync(patches, (entries, bundle) -> applyPatches(entries, bundle, joined), executor); } - if (accessTransformers != null || interfaceInjection != null) { - outputEntries = outputEntries.thenCompose(entries -> applyDevTransforms(entries, accessTransformers, interfaceInjection)); + if (accessTransformers != null || interfaceInjection != null || enumExtension != null) { + outputEntries = outputEntries.thenCompose(entries -> applyDevTransforms(entries, accessTransformers, interfaceInjection, enumExtension)); } CompletableFuture outputFileFuture = outputEntries.thenAccept(outputFileEntries -> { @@ -272,7 +285,8 @@ private static InputFileEntry applyClassTransform(InputFileEntry entry, Consumer private CompletableFuture> applyDevTransforms(Map entries, @Nullable AccessTransformerEngine accessTransformers, - @Nullable InterfaceInjection interfaceInjection) { + @Nullable InterfaceInjection interfaceInjection, + @Nullable EnumExtension enumExtension) { long start = System.nanoTime(); // Find all classes that are targeted by access transformers @@ -281,9 +295,10 @@ private CompletableFuture> applyDevTransforms(Map entry.setValue(applyAccessTransformers(entry.getValue(), classType, accessTransformers, interfaceInjection)), + () -> entry.setValue(applyAccessTransformers(entry.getValue(), classType, accessTransformers, interfaceInjection, enumExtension)), executor )); } @@ -299,7 +314,8 @@ private CompletableFuture> applyDevTransforms(Map { if (accessTransformers != null) { accessTransformers.transform(classNode, type); @@ -307,6 +323,9 @@ private InputFileEntry applyAccessTransformers(InputFileEntry entry, if (interfaceInjection != null) { interfaceInjection.transform(classNode, type); } + if (enumExtension != null) { + enumExtension.transform(classNode, type); + } }); } diff --git a/src/test/java/net/neoforged/installertools/EnumExtensionsTest.java b/src/test/java/net/neoforged/installertools/EnumExtensionsTest.java new file mode 100644 index 0000000..b4abbf2 --- /dev/null +++ b/src/test/java/net/neoforged/installertools/EnumExtensionsTest.java @@ -0,0 +1,133 @@ +/* + * InstallerTools + * Copyright (c) 2019-2025. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation version 2.1 + * of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package net.neoforged.installertools; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; + +import java.io.File; +import java.io.FileWriter; +import java.io.InputStream; +import java.io.Serializable; +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EnumExtensionsTest { + @Test + void testInjection(@TempDir Path tempDir) throws Exception { + // Create interface injection data file + File extensionsDataFile = tempDir.resolve("enum_extensions.json").toFile(); + Map enumExtensionsData = new HashMap<>(); + List entries = new ArrayList<>(); + Map entryData = new HashMap<>(); + entryData.put("enum", "net/neoforged/installertools/TestEnum"); + entryData.put("name", "D"); + entries.add(entryData); + enumExtensionsData.put("entries", entries); + + try (FileWriter writer = new FileWriter(extensionsDataFile)) { + new Gson().toJson(enumExtensionsData, writer); + } + + // Read TestClass bytecode using ASM + String testEnum = TestEnum.class.getName().replace('.', '/'); + ClassNode classNode = new ClassNode(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream(testEnum + ".class")) { + assertNotNull(is, "Could not find TestClass bytecode"); + ClassReader reader = new ClassReader(is); + reader.accept(classNode, 0); + } + + // Apply interface injection with annotation marker + EnumExtension interfaceInjection = new EnumExtension( + Collections.singletonList(extensionsDataFile), + Type.getInternalName(ExtensionEnumEntry.class) + ); + + Type classType = Type.getObjectType(testEnum); + interfaceInjection.transform(classNode, classType); + + // Write transformed TestEnum to temp directory + ClassWriter writer = new ClassWriter(0); + classNode.accept(writer); + byte[] testClassBytecode = writer.toByteArray(); + + Path testClassFile = tempDir.resolve("net/neoforged/installertools/TestEnum.class"); + Files.createDirectories(testClassFile.getParent()); + Files.write(testClassFile, testClassBytecode); + + // Re-parse the bytecode and check for the runtime-invisible annotation + ClassNode transformedClassNode = new ClassNode(); + try (InputStream is = Files.newInputStream(testClassFile)) { + ClassReader reader = new ClassReader(is); + reader.accept(transformedClassNode, 0); + } + assertEquals(5, transformedClassNode.fields.size(), "Should have 4 enum entries (values field + original 3 + 1 extension)"); + assertEquals("D", transformedClassNode.fields.get(4).name, "The new enum entry should be named D"); + assertNotNull(transformedClassNode.fields.get(4).invisibleAnnotations); + assertEquals(1, transformedClassNode.fields.get(4).invisibleAnnotations.size(), "Should have one runtime-invisible annotation on the enum"); + assertEquals(Type.getDescriptor(ExtensionEnumEntry.class), transformedClassNode.fields.get(4).invisibleAnnotations.get(0).desc, "The annotation should be ExtensionEnumEntry"); + + // Load the class and verify via reflection + try (URLClassLoader classLoader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null)) { + Class loadedClass = classLoader.loadClass("net.neoforged.installertools.TestEnum"); + + Field[] fields = loadedClass.getFields(); + assertEquals(4, fields.length, "Should have 4 enum entries (original 3 + 1 extension)"); + assertEquals("D", fields[3].getName(), "The new enum entry should be named D"); + assertEquals(Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL | 0x4000 /* ACC_ENUM */, fields[3].getModifiers(), "The new enum entry should be public static final enum"); + assertTrue(fields[3].isEnumConstant(), "The new field should be an enum constant"); + } + } +} + +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE_USE) +@interface ExtensionEnumEntry { +} + +enum TestEnum { + A, B, C; +}