Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dependencyResolutionManagement {
mavenCentral()
maven {
name = "NeoForge"
url = "https://maven.neoforged.net"
url = "https://maven.neoforged.net/releases/"
}
}
}
Expand Down
104 changes: 104 additions & 0 deletions src/main/java/net/neoforged/installertools/EnumExtension.java
Original file line number Diff line number Diff line change
@@ -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 <a href="https://docs.neoforged.net/docs/advanced/extensibleenums/">enum extension</a> data files,
* and marks enums extended in such a way with an annotation.
* <p>
* <strong>Note that only a subset of FML's <code>RuntimeEnumExtender</code>'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.</strong>
*/
public class EnumExtension {
private final String annotationMarker;

// Enum class -> entries to add
private final Map<String, Set<String>> extensions;

public EnumExtension(List<File> 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<String> entries = extensions.get(type.getInternalName());
if (entries == null || entries.isEmpty()) {
return;
}
List<String> 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<AnnotationNode> invisibleAnnotations = new ArrayList<>();
invisibleAnnotations.add(new AnnotationNode(
Type.getObjectType(annotationMarker).getDescriptor()
));
field.invisibleAnnotations = invisibleAnnotations;
cn.fields.add(field);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ public void process(String[] args) throws IOException {
OptionSpec<File> accessTransformerArg = parser.accepts("access-transformer", "Apply an access transformer.").withOptionalArg().ofType(File.class);
OptionSpec<String> 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<File> iiDataFilesArg = parser.accepts("interface-injection-data", "The paths to read interface injection JSON files from.").withOptionalArg().ofType(File.class);
OptionSpec<String> 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<File> eeDataFilesArg = parser.accepts("enum-extensions-data", "The paths to read enum extension JSON files from.").withOptionalArg().ofType(File.class);

OptionSet options;
try {
Expand Down Expand Up @@ -145,6 +147,9 @@ public void process(String[] args) throws IOException {
List<File> accessTransformerFiles = options.valuesOf(accessTransformerArg);
String iiAnnotationMarker = options.valueOf(iiAnnotationMarkerArg);
List<File> iiDataFiles = options.valuesOf(iiDataFilesArg);

String eeAnnotationMarker = options.valueOf(eeAnnotationMarkerArg);
List<File> eeDataFiles = options.valuesOf(eeDataFilesArg);

AccessTransformerEngine accessTransformers = null;
if (!accessTransformerFiles.isEmpty()) {
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -202,6 +213,8 @@ private void processZip(File inputFile,
AccessTransformerEngine accessTransformers,
@Nullable
InterfaceInjection interfaceInjection,
@Nullable
EnumExtension enumExtension,
boolean addDistAnnotations) {

CompletableFuture<Map<String, InputFileEntry>> outputEntries;
Expand Down Expand Up @@ -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<Void> outputFileFuture = outputEntries.thenAccept(outputFileEntries -> {
Expand Down Expand Up @@ -272,7 +285,8 @@ private static InputFileEntry applyClassTransform(InputFileEntry entry, Consumer

private CompletableFuture<Map<String, InputFileEntry>> applyDevTransforms(Map<String, InputFileEntry> 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
Expand All @@ -281,9 +295,10 @@ private CompletableFuture<Map<String, InputFileEntry>> applyDevTransforms(Map<St
if (entry.getKey().endsWith(".class")) {
Type classType = Type.getObjectType(entry.getKey().substring(0, entry.getKey().length() - 6));
if (accessTransformers != null && accessTransformers.containsClassTarget(classType)
|| interfaceInjection != null && interfaceInjection.containsClassTarget(classType)) {
|| interfaceInjection != null && interfaceInjection.containsClassTarget(classType)
|| enumExtension != null && enumExtension.containsClassTarget(classType)) {
futures.add(CompletableFuture.runAsync(
() -> entry.setValue(applyAccessTransformers(entry.getValue(), classType, accessTransformers, interfaceInjection)),
() -> entry.setValue(applyAccessTransformers(entry.getValue(), classType, accessTransformers, interfaceInjection, enumExtension)),
executor
));
}
Expand All @@ -299,14 +314,18 @@ private CompletableFuture<Map<String, InputFileEntry>> applyDevTransforms(Map<St
private InputFileEntry applyAccessTransformers(InputFileEntry entry,
Type type,
@Nullable AccessTransformerEngine accessTransformers,
@Nullable InterfaceInjection interfaceInjection) {
@Nullable InterfaceInjection interfaceInjection,
@Nullable EnumExtension enumExtension) {
return applyClassTransform(entry, classNode -> {
if (accessTransformers != null) {
accessTransformers.transform(classNode, type);
}
if (interfaceInjection != null) {
interfaceInjection.transform(classNode, type);
}
if (enumExtension != null) {
enumExtension.transform(classNode, type);
}
});
}

Expand Down
133 changes: 133 additions & 0 deletions src/test/java/net/neoforged/installertools/EnumExtensionsTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> enumExtensionsData = new HashMap<>();
List<Object> entries = new ArrayList<>();
Map<String, Object> 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;
}
Loading