diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java index 0e41da5f3..85fa84155 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -40,29 +40,31 @@ import java.util.function.BooleanSupplier; * references and is safe on a dedicated server. */ public final class ModdedMixinAudit { + private static final String ENTITY_CONFIG = "volmlib.entity.mixins.json"; + private static final String CLIENT_CONFIG = "volmlib.client.mixins.json"; private static final AtomicBoolean AUDITED = new AtomicBoolean(false); - private static final List EXPECTED = List.of( - new ExpectedMixin("EntityPersistenceMixin", "entity", + static final List EXPECTED = List.of( + new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", false, NativeMixinFlags::entityPersistenceRan), - new ExpectedMixin("LivingEntityLootMixin", "entity", + new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin", NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot", false, NativeMixinFlags::livingEntityLootRan), - new ExpectedMixin("MobAwarenessMixin", "entity", + new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin", NativeMixinTarget.MOB, "iris$tickUnawareMob", false, NativeMixinFlags::mobAwarenessRan), - new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common", + new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin", NativeMixinTarget.STRUCTURE_PALETTE, "iris$installConcurrentBlockCache", false, NativeMixinFlags::structureTemplatePaletteRan), - new ExpectedMixin("NativeWorldOpenFlowsMixin", "client", + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin", NativeMixinTarget.WORLD_OPEN_FLOWS, - "iris$openWorldCheckWorldStemCompatibility", + "volmlib$openWorldCheckWorldStemCompatibility", true, NativeMixinFlags::worldOpenFlowsRan), - new ExpectedMixin("NativeWorldTypeEntryMixin", "client", + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin", NativeMixinTarget.WORLD_TYPE_ENTRY, - "iris$describePreset", + "volmlib$describePreset", true, NativeMixinFlags::worldTypeEntryRan)); private ModdedMixinAudit() { @@ -90,7 +92,7 @@ public final class ModdedMixinAudit { if (isApplied(expected)) { applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)")); } else { - missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass()); + missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass().className()); } } if (missing.isEmpty()) { @@ -119,7 +121,7 @@ public final class ModdedMixinAudit { } } - private record ExpectedMixin(String mixinName, String config, NativeMixinTarget targetClass, String handlerMethod, - boolean clientOnly, BooleanSupplier ran) { + record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod, + boolean clientOnly, BooleanSupplier ran) { } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java new file mode 100644 index 000000000..97ef1c08c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java @@ -0,0 +1,194 @@ +package art.arcane.iris.modded; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Assume; +import org.junit.Test; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedMixinAuditTest { + private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;"; + private static final Set INJECTOR_ANNOTATIONS = Set.of( + "Lorg/spongepowered/asm/mixin/injection/Inject;", + "Lorg/spongepowered/asm/mixin/injection/Redirect;", + "Lorg/spongepowered/asm/mixin/injection/ModifyArg;", + "Lorg/spongepowered/asm/mixin/injection/ModifyArgs;", + "Lorg/spongepowered/asm/mixin/injection/ModifyVariable;", + "Lorg/spongepowered/asm/mixin/injection/ModifyConstant;"); + private static final Pattern NEOFORGE_MIXIN_CONFIG = Pattern.compile( + "\\[\\[mixins]]\\s*config\\s*=\\s*\"([^\"]+)\""); + // One applied mixin per config proves the config was registered, which is all the audit detects. + private static final Set UNAUDITED = Set.of( + "volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin", + "volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess", + "volmlib.entity.mixins.json/ChunkTerrainReceiptMixin", + "volmlib.entity.mixins.json/LevelChunkTerrainReceiptMixin", + "volmlib.entity.mixins.json/SerializableTerrainReceiptMixin", + "volmlib.fabric.mixins.json/BlockItemMixin", + "volmlib.fabric.mixins.json/BlockMixin", + "volmlib.fabric.mixins.json/PackRepositoryMixin"); + + @Test + public void everyExpectedHandlerIsAnInjectorDeclaredByItsMixin() throws IOException { + for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) { + MixinConfig config = MixinConfig.read(expected.config()); + String section = config.sections().get(expected.mixinName()); + assertNotNull(expected.config() + " does not list " + expected.mixinName(), section); + assertEquals(expected.mixinName() + " client-only flag disagrees with its config section", + "client".equals(section), expected.clientOnly()); + + MixinClass mixin = MixinClass.read(config.packageName() + '.' + expected.mixinName()); + assertTrue(expected.mixinName() + " does not target " + expected.targetClass().className() + + " (targets " + mixin.targets() + ")", + mixin.targets().contains(expected.targetClass().className())); + assertTrue(expected.mixinName() + " declares no injector handler named " + expected.handlerMethod() + + " (injectors " + mixin.injectors() + ")", + mixin.injectors().contains(expected.handlerMethod())); + } + } + + @Test + public void everyRegisteredMixinIsAuditedOrDeliberatelyUnaudited() throws IOException { + Set registered = registeredConfigs(); + Assume.assumeFalse("loader metadata on this classpath declares no mixin configs", registered.isEmpty()); + Set audited = new LinkedHashSet<>(); + for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) { + audited.add(expected.config() + '/' + expected.mixinName()); + } + + Set listed = new TreeSet<>(); + for (String configName : registered) { + for (String mixinName : MixinConfig.read(configName).sections().keySet()) { + listed.add(configName + '/' + mixinName); + } + } + + Set uncovered = new TreeSet<>(listed); + uncovered.removeAll(audited); + uncovered.removeAll(UNAUDITED); + assertEquals("mixins neither audited nor deliberately unaudited", Set.of(), uncovered); + + Set unregisteredAudits = new TreeSet<>(audited); + unregisteredAudits.removeAll(listed); + assertEquals("audited mixins missing from the registered configs", Set.of(), unregisteredAudits); + } + + private static Set registeredConfigs() throws IOException { + Set configs = new LinkedHashSet<>(); + String fabric = readResource("fabric.mod.json"); + if (fabric != null) { + for (JsonElement entry : JsonParser.parseString(fabric).getAsJsonObject().getAsJsonArray("mixins")) { + configs.add(entry.isJsonObject() + ? entry.getAsJsonObject().get("config").getAsString() + : entry.getAsString()); + } + } + String neoforge = readResource("META-INF/neoforge.mods.toml"); + if (neoforge != null) { + Matcher matcher = NEOFORGE_MIXIN_CONFIG.matcher(neoforge); + while (matcher.find()) { + configs.add(matcher.group(1)); + } + } + return configs; + } + + private static String readResource(String name) throws IOException { + try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(name)) { + return input == null ? null : new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private record MixinConfig(String packageName, Map sections) { + static MixinConfig read(String name) throws IOException { + String json = readResource(name); + assertNotNull("Missing mixin config resource " + name, json); + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + Map sections = new LinkedHashMap<>(); + for (String section : new String[]{"mixins", "client", "server"}) { + JsonArray entries = root.getAsJsonArray(section); + if (entries == null) { + continue; + } + for (JsonElement entry : entries) { + sections.put(entry.getAsString(), section); + } + } + return new MixinConfig(root.get("package").getAsString(), sections); + } + } + + private record MixinClass(Set targets, Set injectors) { + static MixinClass read(String className) throws IOException { + String resource = className.replace('.', '/') + ".class"; + byte[] bytes; + try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull("Missing mixin class " + className, input); + bytes = input.readAllBytes(); + } + Set targets = new TreeSet<>(); + Set injectors = new TreeSet<>(); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + if (!MIXIN_ANNOTATION.equals(descriptor)) { + return null; + } + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitArray(String name) { + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visit(String ignored, Object value) { + if (value instanceof Type type) { + targets.add(type.getClassName()); + } else if (value instanceof String target) { + targets.add(target.replace('/', '.')); + } + } + }; + } + }; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String annotation, boolean visible) { + if (INJECTOR_ANNOTATIONS.contains(annotation)) { + injectors.add(name); + } + return null; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return new MixinClass(targets, injectors); + } + } +}