diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 1fb0a8abf..8127265c4 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileTree import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.attributes.Bundling import org.gradle.jvm.toolchain.JavaLanguageVersion import javax.inject.Inject @@ -38,10 +39,14 @@ buildscript { plugins { id 'java' - id 'net.fabricmc.fabric-loom' version '1.17.14' + id 'net.fabricmc.fabric-loom' version '1.17.21' apply false + id 'net.fabricmc.fabric-loom-remap' version '1.17.21' apply false alias(libs.plugins.shadow) } +apply from: file('../../gradle/mod-target.gradle') + +apply plugin: irisModTarget.fabricRemap ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom' apply plugin: 'art.arcane.volmit-packaging' interface IrisFileOperations { @@ -58,7 +63,7 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) -String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) +String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString()) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } @@ -141,7 +146,11 @@ configurations.create('jij') { dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") - implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") + if (irisModTarget.fabricRemap) { + mappings(loom.officialMojangMappings()) + } + String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation' + add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}") testImplementation('junit:junit:4.13.2') testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(testFixtures('art.arcane:core')) @@ -168,11 +177,17 @@ dependencies { libs.fabricApi.keyMapping, libs.fabricApi.permission ] + if (irisModTarget.fabricModules != null) { + fabricApi = ((Map) irisModTarget.fabricModules).collect { String module, String moduleVersion -> + "net.fabricmc.fabric-api:${module}:${moduleVersion}".toString() + } + fabricApi.add("me.lucko:fabric-permissions-api:${irisModTarget.fabricPermissions}".toString()) + } fabricApi.each { Object notation -> add('jij', notation) - add('implementation', notation) + add(modConfiguration, notation) } - compileOnly('org.slf4j:slf4j-api:2.0.17') + compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}") compileOnly(libs.spigot) { transitive = false } @@ -201,21 +216,28 @@ dependencies { libs.jaxen ] shared.each { Object notation -> - add('bundle', notation) - add('devBundle', notation) + ['bundle', 'devBundle'].each { String configuration -> + add(configuration, notation) { + if (group == 'art.arcane' && name == 'core') { + attributes { + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL)) + } + } + } + } } } tasks.named('compileJava', JavaCompile).configure { options.encoding = 'UTF-8' - options.release.set(25) + options.release.set((int) irisModTarget.java) } tasks.named('test').configure { } loom { - accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "fabric", layout.buildDirectory.dir("native-access").get().asFile) + accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "fabric", layout.buildDirectory.dir("native-access").get().asFile) runs { client { runDir(providers.gradleProperty('irisClientRunDir').getOrElse('run')) @@ -270,6 +292,13 @@ Map nestedFabricApiJars = [ 'fabric-permission-api-v1' : 'fabric-permission-api-v1.jar' ] +if (irisModTarget.fabricModules != null) { + nestedFabricApiJars = ((Map) irisModTarget.fabricModules).collectEntries { String module, String ignored -> + [(module): "${module}.jar".toString()] + } + nestedFabricApiJars.put('fabric-permissions-api', 'fabric-permissions-api.jar') +} + FileTree staleLoaderJars = fileTree(layout.buildDirectory.dir('libs')) { include('Iris v* [Fabric] *.jar') include('iris-fabric-*.jar') @@ -288,6 +317,10 @@ tasks.named('shadowJar', ShadowJar).configure { archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}")) configurations = [project.configurations.named('bundle').get()] mergeServiceFiles() + if ((int) irisModTarget.java == 21) { + exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') + exclude('art/arcane/volmlib/util/noise/BillowBatch*.class') + } exclude('META-INF/maven/**') exclude('META-INF/proguard/**') exclude('META-INF/*.SF') @@ -346,28 +379,39 @@ tasks.named('shadowJar', ShadowJar).configure { } } -// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero -// dependencies. Installing one is an instant NoClassDefFoundError, and they sit in build/libs right -// next to the real artifact. shadowJar is the only shippable Fabric jar. +// The thin jar omits bundled dependencies. Obfuscated targets remap the complete shadow jar. tasks.named('jar').configure { enabled = false } tasks.matching { it.name == 'remapJar' }.configureEach { - enabled = false + enabled = (boolean) irisModTarget.fabricRemap + if (irisModTarget.fabricRemap) { + dependsOn(tasks.named('shadowJar')) + inputFile.set(tasks.named('shadowJar', ShadowJar).flatMap { it.archiveFile }) + archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}")) + addNestedDependencies.set(false) + } +} +if (irisModTarget.fabricRemap) { + tasks.named('shadowJar', ShadowJar).configure { + destinationDirectory.set(layout.buildDirectory.dir('devlibs')) + } } tasks.named('assemble').configure { - dependsOn(tasks.named('shadowJar')) + dependsOn(tasks.named(irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar')) } pluginPackaging { artifacts { create('distribution') { - taskName = 'shadowJar' + taskName = irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar' policyName = 'Iris Fabric' prunePrefixes = ['art/arcane/volmlib/'] keepPrefixes = ['art/arcane/volmlib/util/matter/slices/'] } } } + +apply from: file('../../gradle/modded-version-sources.gradle') diff --git a/adapters/fabric/src/versions/1.21.1/resources/fabric.mod.json b/adapters/fabric/src/versions/1.21.1/resources/fabric.mod.json new file mode 100644 index 000000000..cde19b348 --- /dev/null +++ b/adapters/fabric/src/versions/1.21.1/resources/fabric.mod.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "id": "irisworldgen", + "version": "${version}", + "name": "Iris", + "description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)", + "authors": [ + "Arcane Arts (Volmit Software)" + ], + "contributors": [ + "cyberpwn", + "NextdoorPsycho", + "Vatuu" + ], + "contact": { + "homepage": "https://docs.volmit.com/iris/", + "issues": "https://github.com/VolmitSoftware/Iris/issues", + "sources": "https://github.com/VolmitSoftware/Iris" + }, + "license": "GPL-3.0", + "icon": "assets/irisworldgen/icon.png", + "environment": "*", + "accessWidener": "volmlib.accesswidener", + "mixins": [ + "volmlib.fabric.mixins.json", + "volmlib.entity.mixins.json", + { + "config": "volmlib.client.mixins.json", + "environment": "client" + } + ], + "entrypoints": { + "main": [ + "art.arcane.iris.fabric.IrisFabricBootstrap" + ], + "client": [ + "art.arcane.iris.fabric.IrisFabricClient" + ] + }, + "jars": [ + { + "file": "META-INF/jars/fabric-api-base.jar" + }, + { + "file": "META-INF/jars/fabric-registry-sync-v0.jar" + }, + { + "file": "META-INF/jars/fabric-resource-loader-v0.jar" + }, + { + "file": "META-INF/jars/fabric-lifecycle-events-v1.jar" + }, + { + "file": "META-INF/jars/fabric-command-api-v2.jar" + }, + { + "file": "META-INF/jars/fabric-events-interaction-v0.jar" + }, + { + "file": "META-INF/jars/fabric-networking-api-v1.jar" + }, + { + "file": "META-INF/jars/fabric-rendering-v1.jar" + }, + { + "file": "META-INF/jars/fabric-key-binding-api-v1.jar" + }, + { + "file": "META-INF/jars/fabric-permissions-api.jar" + } + ], + "depends": { + "fabricloader": ">=0.19.5", + "minecraft": "${minecraftVersion}", + "java": ">=21", + "fabric-permissions-api-v0": "*" + } +} diff --git a/adapters/fabric/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java b/adapters/fabric/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java new file mode 100644 index 000000000..4ea8299b0 --- /dev/null +++ b/adapters/fabric/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java @@ -0,0 +1,102 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.fabric; + +import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.Lifecycle; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class NativeFabricBiomeDefinitionTest { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String BIOME_KEY = "iris:biomes/fabric_modified"; + private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4," + + "\"effects\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327}," + + "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception { + MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable()); + Holder.Reference ore = features.register( + ResourceKey.create(Registries.PLACED_FEATURE, ResourceLocation.parse("iris:modded_ore")), + new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()), + RegistrationInfo.BUILT_IN); + features.freeze(); + MappedRegistry> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable()); + carvers.freeze(); + MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers)); + Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access), + JsonParser.parseString(SOURCE_JSON)).getOrThrow(); + NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller", + BooleanSupplier::getAsBoolean)); + loader.recordOriginalBiome(source); + Holder.Reference biome = biomes.register( + ResourceKey.create(Registries.BIOME, ResourceLocation.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN); + biomes.freeze(); + addFeatureInPlace(biome.value().getGenerationSettings(), ore); + warmInPlace(biome.value()); + NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome); + + assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F); + assertTrue(biome.value().getGenerationSettings().features() + .get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore)); + assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()), + JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value())); + } + + private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder feature) + throws ReflectiveOperationException { + List> steps = new ArrayList<>(settings.features()); + while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) { + steps.add(HolderSet.direct()); + } + steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature)); + Field features = BiomeGenerationSettings.class.getDeclaredField("features"); + features.setAccessible(true); + features.set(settings, List.copyOf(steps)); + } + + private static void warmInPlace(Biome biome) throws ReflectiveOperationException { + Constructor climate = Biome.ClimateSettings.class.getDeclaredConstructor( + boolean.class, float.class, Biome.TemperatureModifier.class, float.class); + climate.setAccessible(true); + Field climateSettings = Biome.class.getDeclaredField("climateSettings"); + climateSettings.setAccessible(true); + climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F)); + } +} diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index c45bdc61d..cae2321dd 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileTree import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.attributes.Bundling import org.gradle.jvm.toolchain.JavaLanguageVersion import javax.inject.Inject @@ -42,6 +43,8 @@ plugins { alias(libs.plugins.shadow) } +apply from: file('../../gradle/mod-target.gradle') + apply plugin: 'art.arcane.volmit-packaging' interface IrisFileOperations { @@ -58,7 +61,7 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString()) Closure loaderDisplayVersion = { String loaderVersion -> String coordinatePrefix = "${minecraftVersion}-" if (loaderVersion.startsWith(coordinatePrefix)) { @@ -165,7 +168,7 @@ dependencies { testImplementation(libs.spigot) { transitive = false } - compileOnly('org.slf4j:slf4j-api:2.0.17') + compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}") compileOnly(libs.spigot) { transitive = false } @@ -200,14 +203,23 @@ dependencies { libs.jaxen ] shared.each { Object notation -> - add('bundle', notation) - add('devBundle', notation) - add('runBundle', notation) + ['bundle', 'devBundle', 'runBundle'].each { String configuration -> + add(configuration, notation) { + if (group == 'art.arcane' && name == 'core') { + attributes { + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL)) + } + } + } + } } } minecraft { - accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "forge", layout.buildDirectory.dir("native-access").get().asFile)) + if (irisModTarget.fabricRemap) { + mappings channel: 'official', version: minecraftVersion + } + accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "forge", layout.buildDirectory.dir("native-access").get().asFile)) runs { register('client') { workingDir = layout.projectDirectory.dir('run') @@ -241,7 +253,8 @@ minecraft { tasks.named('compileJava', JavaCompile).configure { options.encoding = 'UTF-8' - options.release.set(25) + options.compilerArgs.addAll(['-Xmaxerrs', '2000']) + options.release.set((int) irisModTarget.java) } tasks.named('test').configure { @@ -280,6 +293,10 @@ tasks.named('shadowJar', ShadowJar).configure { configurations = [project.configurations.named('bundle').get()] from(sourceSets.main.output) mergeServiceFiles() + if ((int) irisModTarget.java == 21) { + exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') + exclude('art/arcane/volmlib/util/noise/BillowBatch*.class') + } exclude('META-INF/maven/**') exclude('META-INF/proguard/**') exclude('META-INF/*.SF') @@ -345,3 +362,5 @@ pluginPackaging { } } } + +apply from: file('../../gradle/modded-version-sources.gradle') diff --git a/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeBootstrap.java b/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeBootstrap.java new file mode 100644 index 000000000..54e110c96 --- /dev/null +++ b/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeBootstrap.java @@ -0,0 +1,29 @@ +package art.arcane.iris.forge; + +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedLifecycleCallbacks; +import art.arcane.iris.modded.service.ModdedTreeFellerService; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkGeneratorDefinition; +import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeLoader; +import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeBootstrap; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.fml.loading.FMLLoader; + +@Mod("irisworldgen") +public final class IrisForgeBootstrap { + public IrisForgeBootstrap(FMLJavaModLoadingContext context) { + NativeForgeLoader loader = new NativeForgeLoader(new NativeLoaderOptions( + "irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe)); + NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION; + ModdedEngineBootstrap.bootCommon(loader, "Forge " + FMLLoader.versionInfo().forgeVersion(), + () -> NativeForgeBootstrap.registerGenerators(context, generator, "block_drops")); + ForgeProtocolNetworking.register(); + if (loader.clientEnvironment()) { + IrisForgeClient.init(context); + } + NativeForgeBootstrap.install(context, loader, ModdedLifecycleCallbacks.create()); + } +} diff --git a/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeClient.java b/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeClient.java new file mode 100644 index 000000000..e9b5b9837 --- /dev/null +++ b/adapters/forge/src/versions/1.21.1/java/art/arcane/iris/forge/IrisForgeClient.java @@ -0,0 +1,25 @@ +package art.arcane.iris.forge; + +import art.arcane.iris.client.IrisClient; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeClientHooks; +import art.arcane.iris.client.IrisClientHud; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolChannel; + +public final class IrisForgeClient { + private IrisForgeClient() { + } + + public static void init(FMLJavaModLoadingContext context) { + IrisClient.bindSender(IrisForgeClient::sendToServer); + NativeForgeClientHooks.install(context, IrisClientHud.binding(), IrisClient::onWorldJoin, IrisClient::onDisconnect); + } + + private static void sendToServer(byte[] frame) { + NativeProtocolChannel channel = ForgeProtocolNetworking.channel(); + if (channel == null) { + return; + } + channel.sendToServer(frame); + } +} diff --git a/adapters/forge/src/versions/1.21.1/resources/META-INF/mods.toml b/adapters/forge/src/versions/1.21.1/resources/META-INF/mods.toml new file mode 100644 index 000000000..1e1f96721 --- /dev/null +++ b/adapters/forge/src/versions/1.21.1/resources/META-INF/mods.toml @@ -0,0 +1,28 @@ +modLoader = "javafml" +loaderVersion = "[52,53)" +license = "GPL-3.0" +issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues" + +[[mods]] +modId = "irisworldgen" +version = "${version}" +displayName = "Iris" +description = "Iris World Generation Engine (Forge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)" +authors = "Arcane Arts (Volmit Software)" +credits = "cyberpwn, NextdoorPsycho, Vatuu" +displayURL = "https://docs.volmit.com/iris/" +logoFile = "assets/irisworldgen/icon.png" + +[[dependencies.irisworldgen]] +modId = "forge" +mandatory = true +versionRange = "[52,53)" +ordering = "NONE" +side = "BOTH" + +[[dependencies.irisworldgen]] +modId = "minecraft" +mandatory = true +versionRange = "[${minecraftVersion}]" +ordering = "NONE" +side = "BOTH" diff --git a/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java b/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java new file mode 100644 index 000000000..e1bc22a10 --- /dev/null +++ b/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java @@ -0,0 +1,100 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.forge; + +import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.Lifecycle; +import com.mojang.serialization.MapCodec; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraftforge.common.world.BiomeModifier; +import net.minecraftforge.common.world.ModifiableBiomeInfo; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class NativeForgeBiomeDefinitionTest { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String BIOME_KEY = "iris:biomes/forge_modified"; + private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4," + + "\"effects\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327}," + + "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() { + MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable()); + Holder.Reference ore = features.register( + ResourceKey.create(Registries.PLACED_FEATURE, ResourceLocation.parse("iris:modded_ore")), + new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()), + RegistrationInfo.BUILT_IN); + features.freeze(); + MappedRegistry> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable()); + carvers.freeze(); + MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers)); + Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access), + JsonParser.parseString(SOURCE_JSON)).getOrThrow(); + Holder.Reference biome = biomes.register( + ResourceKey.create(Registries.BIOME, ResourceLocation.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN); + biomes.freeze(); + biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore))); + NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, + new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller", + BooleanSupplier::getAsBoolean))::unmodifiedBiome); + + assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F); + assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF); + assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value())); + assertTrue(biome.value().getMobSettings().getMobs(MobCategory.MONSTER).unwrap().stream() + .anyMatch(spawn -> spawn.type == EntityType.ZOMBIE)); + assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()), + JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value())); + } + + private record ModdedContent(Holder ore) implements BiomeModifier { + @Override + public void modify(Holder biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) { + if (phase != Phase.ADD) { + return; + } + builder.getClimateSettings().setTemperature(0.1F); + builder.getSpecialEffects().waterColor(0x102030); + builder.getMobSpawnSettings().addSpawn(MobCategory.MONSTER, new MobSpawnSettings.SpawnerData(EntityType.ZOMBIE, 5, 1, 2)); + builder.getGenerationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore); + } + + @Override + public MapCodec codec() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeSpawnPositionTest.java b/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeSpawnPositionTest.java new file mode 100644 index 000000000..04be61db3 --- /dev/null +++ b/adapters/forge/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeSpawnPositionTest.java @@ -0,0 +1,54 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.forge; + +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.living.MobSpawnEvent; +import net.minecraftforge.eventbus.api.Event; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NativeForgeSpawnPositionTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() { + NativeForgeLoader loader = new NativeForgeLoader( + new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean)); + ServerLevelAccessor level = mock(ServerLevelAccessor.class); + Mob mob = mock(Mob.class); + when(mob.checkSpawnRules(level, MobSpawnType.CHUNK_GENERATION)).thenReturn(true); + when(mob.checkSpawnObstruction(level)).thenReturn(true); + AtomicReference observed = new AtomicReference<>(); + Consumer listener = event -> { + observed.set(event.getSpawnType()); + event.setResult(Event.Result.DENY); + }; + MinecraftForge.EVENT_BUS.addListener(listener); + try { + assertFalse(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION)); + } finally { + MinecraftForge.EVENT_BUS.unregister(listener); + } + assertEquals(MobSpawnType.CHUNK_GENERATION, observed.get()); + assertTrue(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION)); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 5818308ff..d04dfc874 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -166,6 +166,12 @@ public final class ModdedEngineBootstrap { ModdedStartup.prepareForStartup(); } + public static void levelsReady(NativeModdedServer server) { + server.refreshWorlds(); + bindWorldGenerators(server); + ModdedStartup.reinjectPersistentDimensions(server); + } + public static void serverStarted(NativeModdedServer server) { // Prime the off-thread level snapshot before anything can read it; the per-tick refresh in // ModdedScheduler.tick has not run yet at this point. diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java index ba497052f..42d599f2c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java @@ -14,6 +14,7 @@ public final class ModdedLifecycleCallbacks { return new NativeModdedCallbacks( ModdedEngineBootstrap::serverAboutToStart, ModdedEngineBootstrap::start, + ModdedEngineBootstrap::levelsReady, ModdedEngineBootstrap::serverStarted, ModdedEngineBootstrap::stop, ModdedEngineBootstrap::stopped, 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 decc3121f..4b6b6a817 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 @@ -45,6 +45,9 @@ public final class ModdedMixinAudit { private static final AtomicBoolean AUDITED = new AtomicBoolean(false); static final List EXPECTED = List.of( + new ExpectedMixin(ENTITY_CONFIG, "ServerLevelsReadyMixin", + NativeMixinTarget.SERVER, "volmlib$levelsReady", + false, NativeMixinFlags::levelsReadyRan), new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin", NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames", false, NativeMixinFlags::registryClientNamesRan), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 0cd0ef925..ecc458335 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -91,7 +91,6 @@ public final class ModdedStartup { } ModdedForcedDatapack.verifyInjected(); ModdedMixinAudit.runOnce(); - reinjectPersistentDimensions(server); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index 513175cd9..df9b737dd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -114,15 +114,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public void close() { try { - if (!sync) { - try { - semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - ModdedIrisLog.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}", - runtime.worldIdentity(), completed.get(), inFlightPeak.get(), adaptiveLimit.get()); + awaitPendingChunks(); + ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}", + runtime.worldIdentity(), completed.get(), inFlight.get(), inFlightPeak.get(), adaptiveLimit.get()); try { engine.syncGenerationHistory(); } catch (RuntimeException e) { @@ -180,6 +174,39 @@ public final class ModdedPregenMethod implements PregeneratorMethod { || queuedFinalSave.get() != null; } + private void awaitPendingChunks() { + if (sync || runtime.isServerThread()) { + return; + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); + boolean interrupted = false; + try { + while (inFlight.get() > 0) { + if (finalSaveOnServerThread.get() || !runtime.isRunning()) { + return; + } + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("Iris pregen chunk callbacks did not finish for " + + runtime.worldIdentity() + ": " + inFlight.get() + " pending"); + } + try { + synchronized (permitMonitor) { + if (inFlight.get() > 0) { + permitMonitor.wait(FINAL_SAVE_POLL_MILLIS); + } + } + } catch (InterruptedException ignored) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + private void saveLevel(boolean wait) { if (wait) { saveFinalLevel(); @@ -237,30 +264,36 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private void awaitFinalSave(FinalSaveRequest request) { long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); - while (true) { - if (deferFinalSaveIfRequested()) { - return; + boolean interrupted = false; + try { + while (true) { + if (deferFinalSaveIfRequested()) { + return; + } + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0L) { + ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity()); + return; + } + long waitMillis = Math.max(1L, Math.min(FINAL_SAVE_POLL_MILLIS, + TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + try { + request.completion().get(waitMillis, TimeUnit.MILLISECONDS); + return; + } catch (InterruptedException ignored) { + interrupted = true; + } catch (TimeoutException e) { + continue; + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + ModdedIrisLog.error("Iris pregen level save failed for {}", runtime.worldIdentity(), cause); + throw new IllegalStateException("Iris pregen level save failed for " + + runtime.worldIdentity(), cause); + } } - long remainingNanos = deadline - System.nanoTime(); - if (remainingNanos <= 0L) { - ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity()); - return; - } - long waitMillis = Math.max(1L, Math.min(FINAL_SAVE_POLL_MILLIS, - TimeUnit.NANOSECONDS.toMillis(remainingNanos))); - try { - request.completion().get(waitMillis, TimeUnit.MILLISECONDS); - return; - } catch (InterruptedException e) { + } finally { + if (interrupted) { Thread.currentThread().interrupt(); - return; - } catch (TimeoutException e) { - continue; - } catch (ExecutionException e) { - Throwable cause = e.getCause() == null ? e : e.getCause(); - ModdedIrisLog.error("Iris pregen level save failed for {}", runtime.worldIdentity(), cause); - throw new IllegalStateException("Iris pregen level save failed for " - + runtime.worldIdentity(), cause); } } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupLifecycleTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupLifecycleTest.java new file mode 100644 index 000000000..9b74d9928 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupLifecycleTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class ModdedStartupLifecycleTest { + @After + public void resetStartup() { + ModdedStartup.reset(); + } + + @Test + public void restoresAfterWorldsLoadWithoutRepeatingDuringServerTicks() { + NativeModdedServer server = mock(NativeModdedServer.class); + AtomicBoolean worldsRefreshed = new AtomicBoolean(); + when(server.refreshWorlds()).thenAnswer(invocation -> { + worldsRefreshed.set(true); + return Thread.currentThread(); + }); + when(server.hasPlayerList()).thenReturn(true); + ModdedDimensionRegistryStore.PersistentDimension dimension = + new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L); + + try (MockedStatic startup = mockStatic(ModdedStartup.class, CALLS_REAL_METHODS); + MockedStatic store = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic datapack = mockStatic(ModdedForcedDatapack.class); + MockedStatic audit = mockStatic(ModdedMixinAudit.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + startup.when(ModdedStartup::prepareForStartup).thenAnswer(invocation -> null); + store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(dimension)); + manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L)) + .thenAnswer(invocation -> { + assertTrue(worldsRefreshed.get()); + return null; + }); + + ModdedLifecycleCallbacks.create().levelsReady().accept(server); + + manager.verify(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L)); + ModdedStartup.runOnce(server); + ModdedStartup.runOnce(server); + store.verify(() -> ModdedDimensionRegistryStore.loadForStartup(server), times(1)); + manager.verify(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L), times(1)); + datapack.verify(ModdedForcedDatapack::verifyInjected, times(1)); + audit.verify(ModdedMixinAudit::runOnce, times(1)); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodLifecycleTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodLifecycleTest.java index fe0c8748f..137c7718b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodLifecycleTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodLifecycleTest.java @@ -3,14 +3,25 @@ package art.arcane.iris.modded.command; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.modded.ModdedGenPool; +import art.arcane.iris.world.pregen.MantleHeapPressure; +import art.arcane.iris.world.pregen.PregenListener; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; +import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -20,6 +31,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -31,6 +43,7 @@ import static org.mockito.Mockito.when; public class ModdedPregenMethodLifecycleTest { private MockedStatic settings; private NativePregenRuntime runtime; + private Engine engine; private ModdedPregenMethod method; @Before @@ -40,7 +53,7 @@ public class ModdedPregenMethodLifecycleTest { settings.when(IrisSettings::get).thenReturn(configured); runtime = mock(NativePregenRuntime.class); when(runtime.worldIdentity()).thenReturn("example:terrain"); - Engine engine = mock(Engine.class); + engine = mock(Engine.class, RETURNS_DEEP_STUBS); when(engine.getHeight()).thenReturn(384); method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, true)); } @@ -82,6 +95,131 @@ public class ModdedPregenMethodLifecycleTest { verify(runtime, never()).save(); } + @Test + public void finalSaveWaitsForEveryCallbackBeyondFiveSeconds() throws Exception { + CompletableFuture load = new CompletableFuture<>(); + CompletableFuture failedLoad = new CompletableFuture<>(); + PregenListener listener = mock(PregenListener.class); + ExecutorService executor = Executors.newSingleThreadExecutor(); + when(runtime.isRunning()).thenReturn(true); + when(runtime.loadChunk(2, 3)).thenReturn(load); + when(runtime.loadChunk(4, 5)).thenReturn(failedLoad); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return null; + }).when(runtime).execute(any()); + try (MockedStatic pressure = mockStatic(MantleHeapPressure.class)) { + pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(512L << 20); + method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); + method.generateChunk(2, 3, listener); + method.generateChunk(4, 5, listener); + CountDownLatch started = new CountDownLatch(1); + Future closing = executor.submit(() -> { + started.countDown(); + method.close(); + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> closing.get(6, TimeUnit.SECONDS)); + verify(runtime, never()).save(); + verify(engine, never()).syncGenerationHistory(); + load.complete(new ChunkLoad(true, null)); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + verify(runtime, never()).save(); + failedLoad.completeExceptionally(new IllegalStateException("chunk failed")); + closing.get(5, TimeUnit.SECONDS); + verify(listener).onChunkGenerated(2, 3); + verify(listener).onChunkFailed(4, 5); + verify(engine).syncGenerationHistory(); + verify(runtime).save(); + } finally { + load.complete(new ChunkLoad(true, null)); + failedLoad.complete(new ChunkLoad(false, "chunk failed")); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void interruptedCloseDrainsPendingChunksAndWaitsForFinalSave() throws Exception { + CompletableFuture load = new CompletableFuture<>(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + AtomicReference closingThread = new AtomicReference<>(); + AtomicReference queuedSave = new AtomicReference<>(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch saveQueued = new CountDownLatch(1); + when(runtime.isRunning()).thenReturn(true); + when(runtime.loadChunk(2, 3)).thenReturn(load); + doAnswer(call -> { + queuedSave.set(call.getArgument(0, Runnable.class)); + saveQueued.countDown(); + return null; + }).when(runtime).execute(any()); + try (MockedStatic pressure = mockStatic(MantleHeapPressure.class)) { + pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(256L << 20); + method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); + method.generateChunk(2, 3, mock(PregenListener.class)); + Future closing = executor.submit(() -> { + closingThread.set(Thread.currentThread()); + started.countDown(); + method.close(); + return Thread.currentThread().isInterrupted(); + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + closingThread.get().interrupt(); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + verify(runtime, never()).save(); + verify(engine, never()).syncGenerationHistory(); + load.complete(new ChunkLoad(true, null)); + assertTrue(saveQueued.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + verify(runtime, never()).save(); + closingThread.get().interrupt(); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + queuedSave.get().run(); + assertTrue(closing.get(5, TimeUnit.SECONDS)); + verify(engine).syncGenerationHistory(); + verify(runtime).save(); + } finally { + load.complete(new ChunkLoad(true, null)); + saveQueued.await(5, TimeUnit.SECONDS); + Runnable pendingSave = queuedSave.get(); + if (pendingSave != null) { + pendingSave.run(); + } + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void serverShutdownClaimsFinalSaveWithoutWaitingForPendingChunks() throws Exception { + CompletableFuture load = new CompletableFuture<>(); + PregenListener listener = mock(PregenListener.class); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Thread serverThread = Thread.currentThread(); + when(runtime.isServerThread()).thenAnswer(call -> Thread.currentThread() == serverThread); + when(runtime.isRunning()).thenReturn(true); + when(runtime.loadChunk(2, 3)).thenReturn(load); + try (MockedStatic pressure = mockStatic(MantleHeapPressure.class)) { + pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(256L << 20); + method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); + method.generateChunk(2, 3, listener); + Future closing = executor.submit(method::close); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + assertTrue(method.deferFinalSaveToServerThread()); + closing.get(5, TimeUnit.SECONDS); + verify(runtime, never()).save(); + assertTrue(method.hasPendingFinalSave()); + method.completeDeferredFinalSave(); + verify(runtime).save(); + verify(runtime, never()).execute(any()); + } finally { + load.complete(new ChunkLoad(true, null)); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test public void failedFinalSaveStillRestoresTheEmptyServerPauseSetting() { AtomicInteger pause = new AtomicInteger(60); diff --git a/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeGeneratorOwnershipTest.java b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeGeneratorOwnershipTest.java index a6d8861b5..27977cbc4 100644 --- a/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeGeneratorOwnershipTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeGeneratorOwnershipTest.java @@ -8,6 +8,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld; import com.mojang.serialization.JsonOps; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.chunk.ChunkGenerator; import org.junit.Test; @@ -66,4 +67,26 @@ public class NativeGeneratorOwnershipTest { assertNull(NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class)); assertFalse(nativeGenerator.represents(world)); } + + @Test + public void protocolLookupTracksInstalledGeneratorAndPolicyOwner() { + IrisModdedChunkGenerator owner = new IrisModdedChunkGenerator( + new NativeGeneratorContext(mock(BiomeSource.class), "example:surface")); + NativeModdedChunkGenerator nativeGenerator = (NativeModdedChunkGenerator) owner.nativeGenerator(); + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(level.dimension()).thenReturn(Level.OVERWORLD); + when(level.getChunkSource()).thenReturn(chunks); + when(chunks.getGenerator()).thenReturn(nativeGenerator); + NativeProtocolWorld world = new NativeProtocolWorld(level); + + assertSame(owner, world.generator(IrisModdedChunkGenerator.class)); + assertSame(nativeGenerator, world.generator(ChunkGenerator.class)); + assertNull(world.generator(String.class)); + + ChunkGenerator replacement = mock(ChunkGenerator.class); + when(chunks.getGenerator()).thenReturn(replacement); + assertNull(world.generator(IrisModdedChunkGenerator.class)); + assertSame(replacement, world.generator(ChunkGenerator.class)); + } } diff --git a/adapters/modded-common/src/versions/1.21.1/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/versions/1.21.1/java/art/arcane/iris/modded/ModdedMixinAudit.java new file mode 100644 index 000000000..ba6493944 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -0,0 +1,141 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.modded.NativeMixinFlags; +import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeJigsawBounds; + + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeMixinTarget; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; + +/** + * Boot audit for the Iris mixin configs. Mixin registration is per-loader (fabric.mod.json entry, NeoForge + * mods.toml [[mixins]] block, Forge shadowJar MixinConfigs manifest attribute) and a config that never gets + * registered fails silently: no crash, the hooks simply never exist, and entity persistence, custom mob loot + * and the Iris world-type labels quietly stop working. + * + *

Application is checked structurally: Mixin transfers an {@code @Inject} handler into the target class, + * so the handler's presence on the target proves the mixin applied. Mixin 0.8.7 renames the transferred + * method to {@code handler$$}, so the declared name is matched as an exact name or as a + * {@code $}-prefixed suffix. Client targets are resolved by name so this class stays free of client + * 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); + + static final List EXPECTED = List.of( + new ExpectedMixin(ENTITY_CONFIG, "ServerLevelsReadyMixin", + NativeMixinTarget.SERVER, "volmlib$levelsReady", + false, NativeMixinFlags::levelsReadyRan), + new ExpectedMixin(ENTITY_CONFIG, "JigsawPlacementBoundsMixin", + NativeMixinTarget.JIGSAW_PLACEMENT, "volmlib$verticalBounds", + false, NativeJigsawBounds::placementBoundsApplied), + new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin", + NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames", + false, NativeMixinFlags::registryClientNamesRan), + new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", + NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", + false, NativeMixinFlags::entityPersistenceRan), + new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin", + NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot", + false, NativeMixinFlags::livingEntityLootRan), + new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin", + NativeMixinTarget.MOB, "iris$tickUnawareMob", + false, NativeMixinFlags::mobAwarenessRan), + new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin", + NativeMixinTarget.STRUCTURE_PALETTE, + "iris$installConcurrentBlockCache", + false, NativeMixinFlags::structureTemplatePaletteRan), + new ExpectedMixin(ENTITY_CONFIG, "PackRepositorySelectionMixin", + NativeMixinTarget.PACK_REPOSITORY, + "volmlib$keepNativeDatapacksLast", + false, NativeMixinFlags::packSelectionRan), + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin", + NativeMixinTarget.WORLD_OPEN_FLOWS, + "volmlib$openWorldCheckWorldStemCompatibility", + true, NativeMixinFlags::worldOpenFlowsRan), + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin", + NativeMixinTarget.WORLD_TYPE_ENTRY, + "volmlib$describePreset", + true, NativeMixinFlags::worldTypeEntryRan)); + + private ModdedMixinAudit() { + } + + public static void runOnce() { + if (!AUDITED.compareAndSet(false, true)) { + return; + } + audit(ModdedEngineBootstrap.loader().platformName(), + ModdedEngineBootstrap.loader().clientEnvironment()); + } + + static void reset() { + AUDITED.set(false); + } + + static void audit(String platform, boolean clientEnvironment) { + List missing = new ArrayList<>(); + List applied = new ArrayList<>(); + for (ExpectedMixin expected : EXPECTED) { + if (expected.clientOnly() && !clientEnvironment) { + continue; + } + if (isApplied(expected)) { + applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)")); + } else { + missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass()); + } + } + if (missing.isEmpty()) { + ModdedIrisLog.info("Iris mixin audit ok on {} ({} dist): {}", platform, + clientEnvironment ? "client" : "server", String.join(", ", applied)); + return; + } + ModdedIrisLog.error("==============================================================="); + ModdedIrisLog.error("Iris mixin audit FAILED on {} ({} dist): {} of {} expected mixin(s) were not applied.", + platform, clientEnvironment ? "client" : "server", missing.size(), + missing.size() + applied.size()); + for (String entry : missing) { + ModdedIrisLog.error(" missing: {}", entry); + } + ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); + ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, Iris datapack priority, readable biome identifiers, or Iris world-type labels are disabled until this is fixed."); + ModdedIrisLog.error("==============================================================="); + } + + private static boolean isApplied(ExpectedMixin expected) { + try { + return expected.targetClass().hasInjectedHandler(expected.handlerMethod()); + } catch (LinkageError unavailable) { + ModdedIrisLog.warn("Iris mixin audit could not inspect {}", expected.targetClass(), unavailable); + return false; + } + } + + record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod, + boolean clientOnly, BooleanSupplier ran) { + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/resources/pack.mcmeta b/adapters/modded-common/src/versions/1.21.1/resources/pack.mcmeta new file mode 100644 index 000000000..7e2b24955 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/resources/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "Iris client resources and mod data", + "pack_format": 34, + "supported_formats": [34, 48] + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java new file mode 100644 index 000000000..32269e0dc --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.modded; + +import net.minecraft.resources.ResourceKey; +import net.minecraft.core.registries.Registries; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.Registry; +import net.minecraft.core.MappedRegistry; +import com.mojang.serialization.Lifecycle; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeContainerLoot; + +import art.arcane.volmlib.util.math.RNG; +import com.mojang.serialization.Codec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.IdMapper; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.Container; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BannerBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SpawnerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class IrisModdedBlockEntityParityTest { + private static RegistryAccess registries; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + } + + @Test + public void generatedContainerGetsBlockEntityAndPersistsFilledLoot() { + ProtoChunk chunk = newChunk(); + BlockPos position = new BlockPos(3, 70, 5); + chunk.setBlockState(position, Blocks.CHEST.defaultBlockState(), false); + + NativeModdedChunkGenerator.createDefaultBlockEntity(chunk, position, Blocks.CHEST.defaultBlockState()); + + BlockEntity blockEntity = chunk.getBlockEntity(position); + assertNotNull(blockEntity); + assertTrue(blockEntity instanceof Container); + Container container = (Container) blockEntity; + NativeContainerLoot.fillContainer(container, List.of(new ItemStack(Items.DIAMOND)), new RNG(17L), message -> {}); + assertEquals(1, countItem(container, Items.DIAMOND)); + CompoundTag saved = blockEntity.saveWithoutMetadata(registries); + ChestBlockEntity restored = new ChestBlockEntity(position, Blocks.CHEST.defaultBlockState()); + restored.loadWithComponents(saved, registries); + assertEquals(1, countItem(restored, Items.DIAMOND)); + } + + @Test + public void generatedSignSpawnerAndBannerGetNativeBlockEntities() { + ProtoChunk chunk = newChunk(); + BlockState signState = Blocks.OAK_SIGN.defaultBlockState(); + BlockState spawnerState = Blocks.SPAWNER.defaultBlockState(); + BlockState bannerState = BuiltInRegistries.BLOCK.get( + ResourceLocation.parse("minecraft:white_banner")).defaultBlockState(); + BlockPos signPosition = new BlockPos(1, 70, 1); + BlockPos spawnerPosition = new BlockPos(2, 70, 1); + BlockPos bannerPosition = new BlockPos(3, 70, 1); + + chunk.setBlockState(signPosition, signState, false); + chunk.setBlockState(spawnerPosition, spawnerState, false); + chunk.setBlockState(bannerPosition, bannerState, false); + NativeModdedChunkGenerator.createDefaultBlockEntity(chunk, signPosition, signState); + NativeModdedChunkGenerator.createDefaultBlockEntity(chunk, spawnerPosition, spawnerState); + NativeModdedChunkGenerator.createDefaultBlockEntity(chunk, bannerPosition, bannerState); + + assertTrue(chunk.getBlockEntity(signPosition) instanceof SignBlockEntity); + assertTrue(chunk.getBlockEntity(spawnerPosition) instanceof SpawnerBlockEntity); + assertTrue(chunk.getBlockEntity(bannerPosition) instanceof BannerBlockEntity); + } + + private static ProtoChunk newChunk() { + return new ProtoChunk( + new ChunkPos(0, 0), + UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), + biomeRegistry(), + null); + } + + private static Registry biomeRegistry() { + Biome biome = new Biome.BiomeBuilder() + .hasPrecipitation(false) + .temperature(0.8F) + .downfall(0.4F) + .specialEffects(new BiomeSpecialEffects.Builder().fogColor(0).waterColor(0x3F76E4) + .waterFogColor(0).skyColor(0).build()) + .mobSpawnSettings(MobSpawnSettings.EMPTY) + .generationSettings(BiomeGenerationSettings.EMPTY) + .build(); + MappedRegistry registry = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + registry.register(ResourceKey.create(Registries.BIOME, ResourceLocation.withDefaultNamespace("plains")), + biome, RegistrationInfo.BUILT_IN); + return registry.freeze(); + } + + private static int countItem(Container container, Item item) { + int count = 0; + for (int slot = 0; slot < container.getContainerSize(); slot++) { + ItemStack stack = container.getItem(slot); + if (stack.getItem() == item) { + count += stack.getCount(); + } + } + return count; + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java new file mode 100644 index 000000000..3275ec843 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnTableMerger; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.random.WeightedRandomList; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.biome.MobSpawnSettings; +import org.junit.Test; +import org.junit.BeforeClass; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class IrisModdedChunkGeneratorSpawnTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void explicitSpawnsReplaceMatchingVanillaTypesAndPreserveOthers() { + MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityType.ZOMBIE, 100, 1, 4); + MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityType.SLIME, 1, 1, 1); + MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityType.SLIME, 7, 2, 5); + MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityType.COW, 3, 2, 4); + WeightedRandomList vanilla = WeightedRandomList.create(List.of( + zombie, + vanillaSlime)); + WeightedRandomList explicit = WeightedRandomList.create(List.of( + explicitSlime, + explicitCow)); + + WeightedRandomList merged = NativeSpawnTableMerger.merge(vanilla, explicit); + List entries = merged.unwrap(); + + assertEquals(3, entries.size()); + assertEquals(EntityType.ZOMBIE, entries.get(0).type); + assertEquals(100, entries.get(0).getWeight().asInt()); + assertEquals(explicitSlime, entries.get(1)); + assertEquals(7, entries.get(1).getWeight().asInt()); + assertEquals(explicitCow, entries.get(2)); + assertEquals(3, entries.get(2).getWeight().asInt()); + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java new file mode 100644 index 000000000..ac94ea764 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java @@ -0,0 +1,134 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntitySpawns; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.world.entity.IrisEntity; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Difficulty; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.MobSpawnType; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.chunk.LevelChunk; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedEntitySpawnDifficultyTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void sharedPlatformSpawnReturnsFalseForPeacefulHostiles() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + ModdedPlatform platform = new ModdedPlatform(mock(NativeModdedLoader.class)); + + assertFalse(platform.spawnEntity(new ModdedPlatformWorld(level), "minecraft:zombie", 0.5, 80, 0.5)); + + verify(level, never()).addFreshEntity(any(Entity.class)); + verify(level, never()).enabledFeatures(); + } + + @Test + public void ambientSpawnReturnsNullForPeacefulHostilesBeforeApplyingConfiguration() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + when(level.getChunkSource()).thenReturn(chunks); + when(chunks.getChunkNow(anyInt(), anyInt())).thenReturn(mock(LevelChunk.class)); + IrisEntity configured = new IrisEntity().setType("zombie").setSpawnEffectRiseOutOfGround(false); + + assertNull(ModdedEntitySpawner.spawn(mock(Engine.class), configured, new NativeEntityRuntime(level), 0, 80, 0, new RNG(1L))); + + verify(level, never()).addFreshEntity(any(Entity.class)); + verify(level, never()).enabledFeatures(); + } + + @Test + public void peacefulRejectsNativeHostilesBeforeCreation() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + + for (EntityType type : List.of(EntityType.ZOMBIE, EntityType.SKELETON, EntityType.CREEPER, + EntityType.SPIDER, EntityType.ENDERMAN, EntityType.BLAZE, EntityType.GHAST, + EntityType.PHANTOM, EntityType.SLIME, EntityType.MAGMA_CUBE, EntityType.WITHER, + EntityType.PIGLIN_BRUTE, EntityType.ZOGLIN, EntityType.WARDEN)) { + assertNull(NativeEntitySpawns.spawn(type, level, BlockPos.ZERO, MobSpawnType.NATURAL)); + } + + verify(level, never()).enabledFeatures(); + verify(level, never()).addFreshEntityWithPassengers(any(Entity.class)); + } + + @Test + public void peacefulPreservesPassiveAndNativeMonsterCategoryExceptions() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + IllegalStateException reachedNativeFactory = new IllegalStateException("Native entity factory reached"); + when(level.enabledFeatures()).thenThrow(reachedNativeFactory); + + for (EntityType type : List.of(EntityType.COW, EntityType.VILLAGER, EntityType.WOLF, + EntityType.PIGLIN, EntityType.HOGLIN, EntityType.SHULKER, EntityType.ENDER_DRAGON)) { + assertSame(reachedNativeFactory, assertThrows(IllegalStateException.class, + () -> NativeEntitySpawns.spawn(type, level, BlockPos.ZERO, MobSpawnType.NATURAL))); + } + } + + @Test + public void nonPeacefulDifficultiesPreserveHostileSpawns() { + ServerLevel level = mock(ServerLevel.class); + Entity created = mock(Entity.class); + + for (Difficulty difficulty : List.of(Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD)) { + EntityType type = spy(BuiltInRegistries.ENTITY_TYPE.get(ResourceLocation.parse("minecraft:zombie"))); + when(level.getDifficulty()).thenReturn(difficulty); + doReturn(created).when(type).spawn(level, BlockPos.ZERO, MobSpawnType.NATURAL); + + assertSame(created, NativeEntitySpawns.spawn(type, level, BlockPos.ZERO, MobSpawnType.NATURAL)); + verify(type).spawn(level, BlockPos.ZERO, MobSpawnType.NATURAL); + } + } + + @Test + public void unrelatedNativeSpawnFailuresStillPropagate() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + EntityType type = spy(BuiltInRegistries.ENTITY_TYPE.get(ResourceLocation.parse("minecraft:cow"))); + IllegalStateException failure = new IllegalStateException("Spawn rejected by platform"); + doThrow(failure).when(type).spawn(level, BlockPos.ZERO, MobSpawnType.NATURAL); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> NativeEntitySpawns.spawn(type, level, BlockPos.ZERO, MobSpawnType.NATURAL))); + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java new file mode 100644 index 000000000..03b4fe62c --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java @@ -0,0 +1,167 @@ +package art.arcane.iris.modded; + +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.core.registries.Registries; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.Registry; +import net.minecraft.core.MappedRegistry; +import com.mojang.serialization.Lifecycle; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; + +import com.mojang.serialization.Codec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.IdMapper; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.ai.village.poi.PoiSection; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.ai.village.poi.PoiTypes; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ModdedNativeStructurePoiRegistrationTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void visitsOnlyExistingPoiBlocksBeforeNativePlacement() { + ProtoChunk chunk = newChunk(); + BlockPos poiPosition = new BlockPos(3, 70, 5); + BlockPos ordinaryPosition = new BlockPos(4, 70, 5); + BlockState poiState = Blocks.BARREL.defaultBlockState(); + chunk.setBlockState(poiPosition, poiState, false); + chunk.setBlockState(ordinaryPosition, Blocks.STONE.defaultBlockState(), false); + Map visited = new LinkedHashMap<>(); + + NativeModdedStructureStage.visitExistingPois( + chunk, (position, state) -> visited.put(position.immutable(), state)); + + assertEquals(poiState, visited.get(poiPosition)); + assertFalse(visited.containsKey(ordinaryPosition)); + assertEquals(1, visited.size()); + } + + @Test + public void duplicatePrimingIsIdempotentForAnAlreadyRegisteredPoi() { + BlockPos position = new BlockPos(3, 70, 5); + Holder type = PoiTypes.forState( + Blocks.BARREL.defaultBlockState()).orElseThrow(); + PoiSection section = new PoiSection(() -> { }); + + section.add(position, type); + section.add(position, type); + assertEquals(1L, section.getRecords(candidate -> true, PoiManager.Occupancy.ANY).count()); + assertEquals(type, section.getType(position).orElseThrow()); + + section.remove(position); + + assertTrue(section.getType(position).isEmpty()); + } + + @Test + public void worldgenPrimingQueuesBeforeTheLaterVanillaRemoval() { + ProtoChunk chunk = newChunk(); + BlockPos position = new BlockPos(3, 70, 5); + BlockState poiState = Blocks.BARREL.defaultBlockState(); + chunk.setBlockState(position, poiState, false); + PoiSection section = new PoiSection(() -> { }); + Deque serverQueue = new ArrayDeque<>(); + AtomicInteger missingRemovals = new AtomicInteger(); + + NativeModdedStructureStage.visitExistingPois(chunk, + (poiPosition, state) -> queuePoiTransition( + serverQueue, section, poiPosition, + Blocks.AIR.defaultBlockState(), state, missingRemovals)); + assertTrue(section.getType(position).isEmpty()); + queuePoiTransition(serverQueue, section, position, + poiState, Blocks.AIR.defaultBlockState(), missingRemovals); + + while (!serverQueue.isEmpty()) { + serverQueue.removeFirst().run(); + } + + assertEquals(0, missingRemovals.get()); + assertTrue(section.getType(position).isEmpty()); + } + + private static void queuePoiTransition(Deque serverQueue, + PoiSection section, + BlockPos position, + BlockState oldState, + BlockState newState, + AtomicInteger missingRemovals) { + Optional> oldType = PoiTypes.forState(oldState); + Optional> newType = PoiTypes.forState(newState); + if (Objects.equals(oldType, newType)) { + return; + } + BlockPos immutablePosition = position.immutable(); + oldType.ifPresent(type -> serverQueue.addLast(() -> { + if (section.getType(immutablePosition).isEmpty()) { + missingRemovals.incrementAndGet(); + return; + } + section.remove(immutablePosition); + })); + newType.ifPresent(type -> serverQueue.addLast( + () -> section.add(immutablePosition, type))); + } + + private static ProtoChunk newChunk() { + return new ProtoChunk( + new ChunkPos(0, 0), + UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), + biomeRegistry(), + null); + } + + private static Registry biomeRegistry() { + Biome biome = new Biome.BiomeBuilder() + .hasPrecipitation(false) + .temperature(0.8F) + .downfall(0.4F) + .specialEffects(new BiomeSpecialEffects.Builder().fogColor(0).waterColor(0x3F76E4) + .waterFogColor(0).skyColor(0).build()) + .mobSpawnSettings(MobSpawnSettings.EMPTY) + .generationSettings(BiomeGenerationSettings.EMPTY) + .build(); + MappedRegistry registry = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + registry.register(ResourceKey.create(Registries.BIOME, ResourceLocation.withDefaultNamespace("plains")), + biome, RegistrationInfo.BUILT_IN); + return registry.freeze(); + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java new file mode 100644 index 000000000..570bd220b --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedPackMetadataTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.modded; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.metadata.pack.PackMetadataSection; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedPackMetadataTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void metadataLoadsClientResourcesAndServerDataForCurrentMinecraft() throws IOException { + try (InputStream resource = ModdedPackMetadataTest.class.getResourceAsStream("/pack.mcmeta")) { + assertNotNull(resource); + try (InputStreamReader reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) { + JsonObject metadata = JsonParser.parseReader(reader).getAsJsonObject(); + for (PackType type : PackType.values()) { + PackMetadataSection section = PackMetadataSection.CODEC + .parse(JsonOps.INSTANCE, metadata.get("pack")).getOrThrow(); + assertEquals("Iris client resources and mod data", section.description().getString()); + assertTrue(type.toString(), section.supportedFormats().orElseThrow().isValueInRange( + SharedConstants.getCurrentVersion().getPackVersion(type))); + } + } + } + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedStructureSetFrequencyOverridesTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedStructureSetFrequencyOverridesTest.java new file mode 100644 index 000000000..c8ab78d9e --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/iris/modded/ModdedStructureSetFrequencyOverridesTest.java @@ -0,0 +1,253 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStructureSetFrequencyOverrides; + +import art.arcane.iris.structure.nativegen.IrisImportedStructureControl; +import art.arcane.iris.structure.placement.IrisStructureSetFrequencyOverride; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderOwner; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Vec3i; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType; +import com.mojang.serialization.MapCodec; +import java.util.stream.Stream; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class ModdedStructureSetFrequencyOverridesTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void createStateReplacementScalesOnlyTheExactSetAndRetainsEntries() throws Exception { + Holder complexes = structureSet( + "minecraft:nether_complexes", 27, 4, 30084232); + Holder fossils = structureSet( + "minecraft:nether_fossils", 2, 1, 14357921); + ChunkGeneratorStructureState state = state(List.of(complexes, fossils)); + KList overrides = new KList<>(); + overrides.add(new IrisStructureSetFrequencyOverride() + .setStructureSet("minecraft:nether_complexes") + .setMultiplier(1.1D)); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setFrequencyOverrides(overrides); + + ChunkGeneratorStructureState returned = + NativeStructureSetFrequencyOverrides.apply(state, control); + + assertSame(state, returned); + List> scaledSets = state.possibleStructureSets(); + RandomSpreadStructurePlacement scaledComplexes = + (RandomSpreadStructurePlacement) scaledSets.get(0).value().placement(); + assertEquals(26, scaledComplexes.spacing()); + assertEquals(4, scaledComplexes.separation()); + assertSame(complexes.value().structures(), scaledSets.get(0).value().structures()); + assertSame(fossils, scaledSets.get(1)); + } + + @Test + public void unrelatedCustomPlacementTypeRemainsUntouched() { + Holder custom = new BoundStructureSetHolder( + "example:custom", + new StructureSet(List.of(), new UnsupportedPlacement())); + KList overrides = new KList<>(); + overrides.add(new IrisStructureSetFrequencyOverride() + .setStructureSet("minecraft:nether_complexes") + .setMultiplier(1.1D)); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setFrequencyOverrides(overrides); + + List> scaled = + NativeStructureSetFrequencyOverrides.scaleSets(List.of(custom), control); + + assertSame(custom, scaled.getFirst()); + } + + @Test + public void unrelatedExclusionCycleRemainsUntouched() { + BoundStructureSetHolder first = new BoundStructureSetHolder("example:first"); + BoundStructureSetHolder second = new BoundStructureSetHolder("example:second"); + first.bind(new StructureSet(List.of(), placement(32, 8, 1, second))); + second.bind(new StructureSet(List.of(), placement(40, 10, 2, first))); + KList overrides = new KList<>(); + overrides.add(new IrisStructureSetFrequencyOverride() + .setStructureSet("minecraft:nether_complexes") + .setMultiplier(1.1D)); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setFrequencyOverrides(overrides); + + List> scaled = NativeStructureSetFrequencyOverrides.scaleSets( + List.of(first, second), control); + + assertSame(first, scaled.get(0)); + assertSame(second, scaled.get(1)); + } + + @Test + public void affectedExclusionCycleUsesBoundScaledHolders() { + BoundStructureSetHolder first = new BoundStructureSetHolder("example:first"); + BoundStructureSetHolder second = new BoundStructureSetHolder("example:second"); + first.bind(new StructureSet(List.of(), placement(32, 8, 1, second))); + second.bind(new StructureSet(List.of(), placement(40, 10, 2, first))); + KList overrides = new KList<>(); + overrides.add(new IrisStructureSetFrequencyOverride() + .setStructureSet("example:first") + .setMultiplier(1.1D)); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setFrequencyOverrides(overrides); + + List> scaled = NativeStructureSetFrequencyOverrides.scaleSets( + List.of(first, second), control); + + assertEquals(31, ((RandomSpreadStructurePlacement) + scaled.get(0).value().placement()).spacing()); + assertEquals(40, ((RandomSpreadStructurePlacement) + scaled.get(1).value().placement()).spacing()); + } + + @Test + public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() { + Holder custom = new BoundStructureSetHolder( + "example:custom", + new StructureSet(List.of(), new CustomRandomSpreadPlacement())); + KList overrides = new KList<>(); + overrides.add(new IrisStructureSetFrequencyOverride() + .setStructureSet("example:custom") + .setMultiplier(1.1D)); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setFrequencyOverrides(overrides); + + assertThrows(IllegalStateException.class, () -> + NativeStructureSetFrequencyOverrides.scaleSets(List.of(custom), control)); + } + + private static Holder structureSet( + String key, + int spacing, + int separation, + int salt + ) { + StructureSet value = new StructureSet( + List.of(), + new RandomSpreadStructurePlacement( + spacing, separation, RandomSpreadType.LINEAR, salt)); + return new BoundStructureSetHolder(key, value); + } + + private static RandomSpreadStructurePlacement placement( + int spacing, + int separation, + int salt, + Holder exclusionTarget + ) { + return new RandomSpreadStructurePlacement( + Vec3i.ZERO, + StructurePlacement.FrequencyReductionMethod.DEFAULT, + 1F, + salt, + Optional.of(new StructurePlacement.ExclusionZone(exclusionTarget, 1)), + spacing, + separation, + RandomSpreadType.LINEAR); + } + + private static ChunkGeneratorStructureState state( + List> sets + ) throws Exception { + Constructor constructor = + ChunkGeneratorStructureState.class.getDeclaredConstructor( + RandomState.class, + BiomeSource.class, + long.class, + long.class, + List.class); + constructor.setAccessible(true); + return constructor.newInstance( + null, + new EmptyBiomeSource(), + 1L, + 1L, + sets); + } + + private static final class BoundStructureSetHolder extends Holder.Reference { + private BoundStructureSetHolder(String key) { + this(key, null); + } + + private BoundStructureSetHolder(String key, StructureSet value) { + super(Type.STAND_ALONE, new HolderOwner<>() { + }, ResourceKey.create(Registries.STRUCTURE_SET, ResourceLocation.parse(key)), value); + } + + private void bind(StructureSet value) { + bindValue(value); + } + } + + private static final class EmptyBiomeSource extends BiomeSource { + @Override + public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + return null; + } + + @Override + protected MapCodec codec() { + throw new UnsupportedOperationException(); + } + + @Override + protected Stream> collectPossibleBiomes() { + return HolderSet.empty().stream(); + } + } + + private static final class UnsupportedPlacement extends StructurePlacement { + private UnsupportedPlacement() { + super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 0, Optional.empty()); + } + + @Override + protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) { + return false; + } + + @Override + public StructurePlacementType type() { + return null; + } + } + + private static final class CustomRandomSpreadPlacement extends RandomSpreadStructurePlacement { + private CustomRandomSpreadPlacement() { + super(27, 4, RandomSpreadType.LINEAR, 30084232); + } + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativePregenRuntimeTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativePregenRuntimeTest.java new file mode 100644 index 000000000..cef4f3d34 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativePregenRuntimeTest.java @@ -0,0 +1,71 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.modded; + +import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ChunkResult; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.level.ChunkPos; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class NativePregenRuntimeTest { + @BeforeClass + public static void bootstrapRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void chunkTicketAdmissionAndReleaseRunThroughTheServerExecutor() { + MinecraftServer server = mock(MinecraftServer.class); + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(level.getServer()).thenReturn(server); + when(level.getChunkSource()).thenReturn(chunks); + CompletableFuture> loaded = new CompletableFuture<>(); + doReturn(loaded).when(chunks).getChunkFutureMainThread(-2, 7, ChunkStatus.FULL, true); + NativePregenRuntime runtime = NativeModdedPregenRuntime.from(new ModdedPlatformWorld(level)); + + CompletableFuture result = runtime.loadChunk(-2, 7); + verify(chunks, never()).addRegionTicket(any(), any(), eq(0), any()); + verify(chunks, never()).getChunkFutureMainThread(-2, 7, ChunkStatus.FULL, true); + ArgumentCaptor requests = ArgumentCaptor.forClass(Runnable.class); + verify(server).execute(requests.capture()); + requests.getValue().run(); + assertFalse(result.isDone()); + verify(chunks).getChunkFutureMainThread(-2, 7, ChunkStatus.FULL, true); + loaded.complete(ChunkResult.of(mock(ChunkAccess.class))); + assertTrue(result.join().successful()); + ArgumentCaptor ticket = ArgumentCaptor.forClass(TicketType.class); + verify(chunks).addRegionTicket(ticket.capture(), eq(new ChunkPos(-2, 7)), eq(0), eq(new ChunkPos(-2, 7))); + + runtime.releaseChunk(-2, 7); + verify(chunks, never()).removeRegionTicket(any(), any(), eq(0), any()); + verify(server, times(2)).execute(requests.capture()); + requests.getAllValues().getLast().run(); + ArgumentCaptor released = ArgumentCaptor.forClass(TicketType.class); + verify(chunks).removeRegionTicket(released.capture(), eq(new ChunkPos(-2, 7)), eq(0), eq(new ChunkPos(-2, 7))); + assertSame(ticket.getValue(), released.getValue()); + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeWorldgenScanTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeWorldgenScanTest.java new file mode 100644 index 000000000..800cfec6d --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeWorldgenScanTest.java @@ -0,0 +1,130 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.volmlib.nativelib.minecraft26_2.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenBiome; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenPlacedFeature; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.Lifecycle; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NativeWorldgenScanTest { + private static final String BIOME_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4," + + "\"effects\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327}," + + "\"carvers\":{},\"features\":[[],[],[],[],[],[],[\"examplemod:ore_glimmer\"]]," + + "\"spawners\":{},\"spawn_costs\":{}}"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void scanReadsLiveFeatureStepsAndEncodesDefinitions() { + NativeWorldgenSnapshot snapshot = NativeWorldgenScan.scan(server(), loader()).encode(); + + WorldgenBiome biome = snapshot.biomes().getFirst(); + assertEquals("examplemod:glade", biome.key()); + assertEquals(0.8F, biome.temperature(), 0F); + assertEquals(0.4F, biome.downfall(), 0.0001F); + assertTrue(biome.hasPrecipitation()); + assertEquals(GenerationStep.Decoration.values().length, biome.featureSteps().size()); + assertEquals(List.of("examplemod:ore_glimmer"), + biome.featureSteps().get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal())); + assertNotNull(biome.definition()); + assertEquals(0.4F, biome.definition().getAsJsonObject().get("downfall").getAsFloat(), 0.0001F); + } + + @Test + public void unencodableFeaturesStayInTheCatalogWithoutJson() { + NativeWorldgenSnapshot snapshot = NativeWorldgenScan.scan(server(), loader()).encode(); + + WorldgenPlacedFeature feature = snapshot.placedFeatures().getFirst(); + assertEquals("examplemod:ore_glimmer", feature.key()); + assertEquals("", feature.feature()); + assertNull(feature.config()); + assertTrue(feature.placement().isJsonArray()); + assertEquals(1, snapshot.failures().count()); + assertTrue(snapshot.failures().samples().getFirst().startsWith("feature of examplemod:ore_glimmer")); + assertTrue(snapshot.levelStems().isEmpty()); + } + + private static NativeModdedServer server() { + MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable()); + features.register(ResourceKey.create(Registries.PLACED_FEATURE, ResourceLocation.parse("examplemod:ore_glimmer")), + new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()), RegistrationInfo.BUILT_IN); + features.freeze(); + MappedRegistry> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable()); + carvers.freeze(); + MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + MappedRegistry structures = new MappedRegistry<>(Registries.STRUCTURE, Lifecycle.stable()); + structures.freeze(); + MappedRegistry structureSets = new MappedRegistry<>(Registries.STRUCTURE_SET, Lifecycle.stable()); + structureSets.freeze(); + MappedRegistry dimensionTypes = new MappedRegistry<>(Registries.DIMENSION_TYPE, Lifecycle.stable()); + dimensionTypes.freeze(); + RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess( + List.of(biomes, features, carvers, structures, structureSets, dimensionTypes)); + Biome biome = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access), + JsonParser.parseString(BIOME_JSON)).getOrThrow(); + biomes.register(ResourceKey.create(Registries.BIOME, ResourceLocation.parse("examplemod:glade")), biome, RegistrationInfo.BUILT_IN); + biomes.freeze(); + MinecraftServer server = mock(MinecraftServer.class); + when(server.registryAccess()).thenReturn(access.freeze()); + return NativeModdedServer.fromHandle(server); + } + + private static NativeModdedLoader loader() { + NativeModdedLoader loader = mock(NativeModdedLoader.class); + when(loader.unmodifiedBiome(any())).thenAnswer(invocation -> invocation.getArgument(0)); + return loader; + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeJigsawBoundsTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeJigsawBoundsTest.java new file mode 100644 index 000000000..55da6ab66 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeJigsawBoundsTest.java @@ -0,0 +1,68 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.terrain; + +import org.junit.Test; + +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class NativeJigsawBoundsTest { + @Test + public void verticalDistanceUsesInclusiveBlocksWithinExclusiveHeightLimits() { + try (NativeJigsawBounds bounds = NativeJigsawBounds.open(OptionalInt.of(12))) { + NativeJigsawBounds.heightLimits(-64, 320); + NativeJigsawBounds.center(100); + assertEquals(88, NativeJigsawBounds.minimum(40), 0); + assertEquals(113, NativeJigsawBounds.maximum(160), 0); + + NativeJigsawBounds.center(-60); + assertEquals(-64, NativeJigsawBounds.minimum(-120), 0); + assertEquals(-47, NativeJigsawBounds.maximum(0), 0); + + NativeJigsawBounds.center(315); + assertEquals(303, NativeJigsawBounds.minimum(255), 0); + assertEquals(320, NativeJigsawBounds.maximum(375), 0); + } + } + + @Test + public void nestedScopesRestoreTheOuterDistanceAndCenter() { + try (NativeJigsawBounds outer = NativeJigsawBounds.open(OptionalInt.of(10))) { + NativeJigsawBounds.heightLimits(-64, 320); + NativeJigsawBounds.center(80); + try (NativeJigsawBounds inner = NativeJigsawBounds.open(OptionalInt.of(2))) { + NativeJigsawBounds.heightLimits(0, 256); + NativeJigsawBounds.center(200); + assertEquals(198, NativeJigsawBounds.minimum(0), 0); + assertEquals(203, NativeJigsawBounds.maximum(256), 0); + } + assertEquals(70, NativeJigsawBounds.minimum(0), 0); + assertEquals(91, NativeJigsawBounds.maximum(256), 0); + try (NativeJigsawBounds unbounded = NativeJigsawBounds.open(OptionalInt.empty())) { + assertEquals(-20, NativeJigsawBounds.minimum(-20), 0); + assertEquals(400, NativeJigsawBounds.maximum(400), 0); + } + assertEquals(70, NativeJigsawBounds.minimum(0), 0); + } + assertEquals(-20, NativeJigsawBounds.minimum(-20), 0); + assertEquals(400, NativeJigsawBounds.maximum(400), 0); + } + + @Test + public void failedGenerationClosesTheScopeAndRejectsNegativeDistance() { + assertThrows(IllegalStateException.class, () -> { + try (NativeJigsawBounds bounds = NativeJigsawBounds.open(OptionalInt.of(0))) { + NativeJigsawBounds.heightLimits(-64, 320); + NativeJigsawBounds.center(40); + assertEquals(40, NativeJigsawBounds.minimum(-100), 0); + assertEquals(41, NativeJigsawBounds.maximum(100), 0); + throw new IllegalStateException("generation failed"); + } + }); + assertEquals(-100, NativeJigsawBounds.minimum(-100), 0); + assertEquals(100, NativeJigsawBounds.maximum(100), 0); + assertThrows(IllegalArgumentException.class, () -> NativeJigsawBounds.open(OptionalInt.of(-1))); + assertEquals(-100, NativeJigsawBounds.minimum(-100), 0); + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeStructureTemplateAccessTest.java b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeStructureTemplateAccessTest.java new file mode 100644 index 000000000..531387883 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/java/art/arcane/volmlib/nativelib/minecraft26_2/terrain/NativeStructureTemplateAccessTest.java @@ -0,0 +1,41 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.terrain; + +import com.mojang.datafixers.util.Either; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertSame; + +public class NativeStructureTemplateAccessTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void resolvesRuntimeTemplateThroughWidenedPoolField() { + StructureTemplate template = new StructureTemplate(); + SinglePoolElement element = new RuntimePoolElement(template); + assertSame(template, NativeStructureReflection.resolveTemplate(element, () -> { + throw new AssertionError("Runtime templates must not require a template manager"); + })); + } + + private static final class RuntimePoolElement extends SinglePoolElement { + private RuntimePoolElement(StructureTemplate template) { + super(Either.right(template), Holder.direct(new StructureProcessorList(List.of())), + StructureTemplatePool.Projection.RIGID, Optional.empty()); + } + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json b/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json new file mode 100644 index 000000000..6a48d6c28 --- /dev/null +++ b/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json @@ -0,0 +1,430 @@ +{ + "global": [ + { + "pattern": "\\bIdentifier\\b", + "replacement": "ResourceLocation" + }, + { + "pattern": "\\bEntitySpawnReason\\b", + "replacement": "MobSpawnType" + }, + { + "pattern": "net\\.minecraft\\.world\\.entity\\.animal\\.panda\\.Panda", + "replacement": "net.minecraft.world.entity.animal.Panda" + }, + { + "pattern": "net\\.minecraft\\.world\\.entity\\.npc\\.villager\\.Villager", + "replacement": "net.minecraft.world.entity.npc.Villager" + }, + { + "pattern": "net\\.minecraft\\.world\\.level\\.gamerules\\.GameRules", + "replacement": "net.minecraft.world.level.GameRules" + }, + { + "pattern": "\\bSpeleothemBlock\\b", + "replacement": "PointedDripstoneBlock" + }, + { + "pattern": "\\bSpeleothemThickness\\b", + "replacement": "DripstoneThickness" + }, + { + "pattern": "\\bEntityTypes\\b", + "replacement": "EntityType" + }, + { + "pattern": "\\bAbstractSpreadingStructurePlacement\\b", + "replacement": "StructurePlacement" + }, + { + "pattern": "\\bDirectPoolAlias\\b", + "replacement": "Direct" + }, + { + "pattern": "\\bRandomGroupPoolAlias\\b", + "replacement": "RandomGroup" + }, + { + "pattern": "\\bRandomPoolAlias\\b", + "replacement": "Random" + }, + { + "pattern": "\\.identifier\\(\\)", + "replacement": ".location()" + }, + { + "pattern": "ChunkPos\\.pack\\(", + "replacement": "ChunkPos.asLong(" + }, + { + "pattern": "\\.registryAccess\\(\\)\\.lookupOrThrow\\(", + "replacement": ".registryAccess().registryOrThrow(" + }, + { + "pattern": "\\.getPos\\(\\)\\.x\\(\\)", + "replacement": ".getPos().x" + }, + { + "pattern": "\\.getPos\\(\\)\\.z\\(\\)", + "replacement": ".getPos().z" + }, + { + "pattern": "\\.getChunkPos\\(\\)\\.x\\(\\)", + "replacement": ".getChunkPos().x" + }, + { + "pattern": "\\.getChunkPos\\(\\)\\.z\\(\\)", + "replacement": ".getChunkPos().z" + }, + { + "pattern": "IdentifierArgument", + "replacement": "ResourceLocationArgument" + }, + { + "pattern": "IdentifierArgument\\.identifier\\(\\)", + "replacement": "ResourceLocationArgument.id()" + }, + { + "pattern": "BuiltInRegistries\\.([A-Z_]+)\\.getValue\\(", + "replacement": "BuiltInRegistries.$1.get(" + }, + { + "pattern": "\\blevel\\.getMinY\\(", + "replacement": "level.getMinBuildHeight(" + }, + { + "pattern": "(?ms)^ if \\(!Items\\.[A-Z_]+\\.builtInRegistryHolder\\(\\)\\.areComponentsBound\\(\\)\\) \\{\\n.*?^ \\}\\n", + "replacement": "" + } + ], + "files": { + "art/arcane/iris/modded/command/NativeTaggedItemsTest.java": [ + { + "pattern": "\\.getBooleanOr\\(\"selection_tool\", false\\)", + "replacement": ".getBoolean(\"selection_tool\")" + }, + { + "pattern": "assertTrue\\(item\\.stack\\(\\)\\.get\\(DataComponents\\.TOOLTIP_DISPLAY\\)\\.hiddenComponents\\(\\)\\.contains\\(DataComponents\\.UNBREAKABLE\\)\\);", + "replacement": "assertEquals(false, item.stack().get(DataComponents.UNBREAKABLE).showInTooltip());" + } + ], + "art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java": [ + { + "pattern": "import net\\.minecraft\\.server\\.permissions\\.LevelBasedPermissionSet;\\n", + "replacement": "" + }, + { + "pattern": "when\\(handle\\.permissions\\(\\)\\)\\.thenReturn\\(LevelBasedPermissionSet\\.ALL\\);", + "replacement": "when(handle.hasPermission(2)).thenReturn(false);" + }, + { + "pattern": "handle\\.hasPermission\\(", + "replacement": "handle.hasPermissions(" + }, + { + "pattern": "handle\\.nameAndId\\(", + "replacement": "handle.getGameProfile(" + }, + { + "pattern": "handle\\.level\\(\\)", + "replacement": "handle.serverLevel()" + } + ], + "art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java": [ + { + "pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(319\\);", + "replacement": "when(level.getMaxBuildHeight()).thenReturn(320);" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java": [ + { + "pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(320\\);", + "replacement": "when(level.getMaxBuildHeight()).thenReturn(321);" + }, + { + "pattern": "rules\\.get\\(GameRules\\.SPAWN_MOBS\\)", + "replacement": "rules.getBoolean(GameRules.RULE_DOMOBSPAWNING)" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeTeleportSafetyTest.java": [ + { + "pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(16\\);", + "replacement": "when(level.getMaxBuildHeight()).thenReturn(17);" + }, + { + "pattern": "import net.minecraft.server.level.ServerLevel;", + "replacement": "import net.minecraft.server.level.ServerLevel;\nimport net.minecraft.server.level.ChunkResult;\nimport net.minecraft.world.level.chunk.ChunkAccess;" + }, + { + "pattern": "CompletableFuture loaded", + "replacement": "CompletableFuture> loaded" + }, + { + "pattern": "\\.addTicketAndLoadWithRadius\\(any\\(\\), any\\(\\), anyInt\\(\\)\\)", + "replacement": ".getChunkFutureMainThread(anyInt(), anyInt(), any(), anyBoolean())" + }, + { + "pattern": "loaded\\.complete\\(null\\)", + "replacement": "loaded.complete(ChunkResult.of(mock(ChunkAccess.class)))" + }, + { + "pattern": "\\.removeTicketWithRadius\\(any\\(\\), any\\(\\), anyInt\\(\\)\\)", + "replacement": ".removeRegionTicket(any(), any(), anyInt(), any())" + }, + { + "pattern": "import static org.mockito.ArgumentMatchers.anyInt;", + "replacement": "import static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.ArgumentMatchers.anyBoolean;" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeStructureOperationsTest.java": [ + { + "pattern": "import net\\.minecraft\\.world\\.level\\.levelgen\\.feature\\.FallenTreeFeature;\\n", + "replacement": "" + }, + { + "pattern": " assertEquals\\(\"fallen_trees\", NativeStructureOperations\\.classifyFeature\\(mock\\(FallenTreeFeature\\.class\\)\\)\\);\\n", + "replacement": "" + } + ], + "art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java": [ + { + "pattern": "\\borigin\\.x\\(\\)", + "replacement": "origin.x" + }, + { + "pattern": "\\borigin\\.z\\(\\)", + "replacement": "origin.z" + }, + { + "pattern": "\\bcandidate\\.x\\(\\)", + "replacement": "candidate.x" + }, + { + "pattern": "\\bcandidate\\.z\\(\\)", + "replacement": "candidate.z" + }, + { + "pattern": "\\borigin\\.pack\\(", + "replacement": "origin.toLong(" + }, + { + "pattern": "\"lookupOrThrow\"", + "replacement": "\"registryOrThrow\"" + }, + { + "pattern": "\"lookup\"", + "replacement": "\"registry\"" + } + ], + "art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java": [ + { + "pattern": "\\bgenerationCenter\\.x\\(\\)", + "replacement": "generationCenter.x" + }, + { + "pattern": "\\bgenerationCenter\\.z\\(\\)", + "replacement": "generationCenter.z" + }, + { + "pattern": "\\borigin\\.pack\\(", + "replacement": "origin.toLong(" + }, + { + "pattern": "\\.updateNeighborsAt\\(", + "replacement": ".blockUpdated(" + }, + { + "pattern": "(Direction\\.(?:NORTH|EAST)), position, position\\.(north|east)\\(\\),\\s*Blocks\\.DIRT\\.defaultBlockState\\(\\),", + "replacement": "$1, Blocks.DIRT.defaultBlockState(), position, position.$2()," + }, + { + "pattern": "\"getMinY\"", + "replacement": "\"getMinBuildHeight\"" + } + ], + "art/arcane/iris/modded/ModdedTileParityTest.java": [ + { + "pattern": "import net\\.minecraft\\.world\\.level\\.block\\.entity\\.SignTextSlot;\\n", + "replacement": "" + }, + { + "pattern": "import net\\.minecraft\\.world\\.level\\.storage\\.TagValueInput;\\n", + "replacement": "" + }, + { + "pattern": "\\.loadWithComponents\\(TagValueInput\\.create\\(\\s*ProblemReporter\\.DISCARDING, registries, (tile\\.nativeData\\(\\)\\.payload\\(\\)|\\(\\(ModdedTileData\\) tile\\)\\.nativeData\\(\\)\\.payload\\(\\))\\)\\)", + "replacement": ".loadWithComponents($1, registries)" + }, + { + "pattern": "\\.getText\\(SignTextSlot\\.FRONT\\)", + "replacement": ".getFrontText()" + }, + { + "pattern": "\\.getText\\(SignTextSlot\\.BACK\\)", + "replacement": ".getBackText()" + }, + { + "pattern": "\\.getMessages\\(false\\)\\.get\\((\\d+)\\)", + "replacement": ".getMessages(false)[$1]" + }, + { + "pattern": "out\\.writeShort\\(28\\);", + "replacement": "out.writeShort(37);" + } + ], + "art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java": [ + { + "pattern": "MobSpawnType\\.SPAWN_ITEM_USE", + "replacement": "MobSpawnType.SPAWN_EGG" + }, + { + "pattern": "MobSpawnType\\.LOAD", + "replacement": "MobSpawnType.EVENT" + } + ], + "art/arcane/iris/modded/service/ModdedWorldBoundaryTest.java": [ + { + "pattern": "\\.setSafeZone\\(", + "replacement": ".setDamageSafeZone(" + }, + { + "pattern": "\\.getSafeZone\\(", + "replacement": ".getDamageSafeZone(" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnBiomeTablesTest.java": [ + { + "pattern": "import net.minecraft.resources.ResourceLocation;", + "replacement": "import net.minecraft.resources.ResourceLocation;\nimport net.minecraft.resources.ResourceKey;\nimport net.minecraft.core.registries.Registries;" + }, + { + "pattern": "registry\\.get\\((ResourceLocation\\.parse\\(\"[^\"]+\"\\))\\)", + "replacement": "registry.getHolder(ResourceKey.create(Registries.BIOME, $1))" + } + ], + "art/arcane/iris/modded/NativeItemTranslatorTest.java": [ + { + "pattern": "assertEquals\\(73F, stack\\.get\\(DataComponents\\.CUSTOM_MODEL_DATA\\)\\.floats\\(\\)\\.getFirst\\(\\), 0F\\);", + "replacement": "assertEquals(73, stack.get(DataComponents.CUSTOM_MODEL_DATA).value());" + }, + { + "pattern": "\\.getString\\(\"marker\"\\)\\.orElseThrow\\(\\)", + "replacement": ".getString(\"marker\")" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeHarvestSessionTest.java": [ + { + "pattern": "\\.getSelectedItem\\(", + "replacement": ".getSelected(" + }, + { + "pattern": " doAnswer\\(invocation -> \\{ item\\.set\\(invocation\\.getArgument\\(0\\)\\); return null; \\}\\)\\n \\.when\\(inventory\\)\\.setSelectedItem\\(any\\(ItemStack\\.class\\)\\);\\n", + "replacement": "" + } + ], + "art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java": [ + { + "pattern": "\\.addTicketAndLoadWithRadius\\(any\\(\\), any\\(\\), anyInt\\(\\)\\)", + "replacement": ".getChunkFutureMainThread(anyInt(), anyInt(), any(), anyBoolean())" + }, + { + "pattern": "import static org.mockito.ArgumentMatchers.anyInt;", + "replacement": "import static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.ArgumentMatchers.anyBoolean;" + } + ], + "art/arcane/iris/modded/NativeRegistryAccessTest.java": [ + { + "pattern": "\\.registryAccess\\(\\)\\.registryOrThrow\\(", + "replacement": ".registryAccess().lookupOrThrow(" + }, + { + "pattern": "\\.reloadableRegistries\\(\\)\\.lookup\\(", + "replacement": ".reloadableRegistries().get(" + }, + { + "pattern": "import net.minecraft.core.HolderLookup;", + "replacement": "import net.minecraft.core.HolderLookup;\nimport net.minecraft.core.RegistryAccess;" + }, + { + "pattern": "HolderLookup\\.Provider reloadedRegistries = mock\\(HolderLookup\\.Provider.class", + "replacement": "RegistryAccess.Frozen reloadedRegistries = mock(RegistryAccess.Frozen.class" + } + ], + "art/arcane/iris/modded/ModdedEntityPersistenceTest.java": [ + { + "pattern": "\\.entityTags\\(", + "replacement": ".getTags(" + } + ], + "art/arcane/iris/modded/ModdedLootApplierTest.java": [ + { + "pattern": "import net.minecraft.core.BlockPos;", + "replacement": "import net.minecraft.core.BlockPos;\nimport net.minecraft.core.Direction;" + }, + { + "pattern": "ChestBlock\\.getConnectedBlockPos\\(leftPos, leftState\\)\\)\\.thenReturn\\(rightPos\\)", + "replacement": "ChestBlock.getConnectedDirection(leftState)).thenReturn(Direction.EAST)" + } + ], + "art/arcane/iris/modded/ModdedBlockResolutionContractTest.java": [ + { + "pattern": ", Blocks\\.PALE_MOSS_BLOCK", + "replacement": "" + }, + { + "pattern": "Blocks\\.SULFUR_SPIKE, ", + "replacement": "" + }, + { + "pattern": "\"minecraft:sulfur_spike\", ", + "replacement": "" + }, + { + "pattern": "copperChestVariantsReceiveIrisLootAndPostLoadUpdates", + "replacement": "chestsReceiveIrisLootAndPostLoadUpdates" + }, + { + "pattern": "\"copper_chest\", \"exposed_copper_chest\", \"weathered_copper_chest\",\\s*\"oxidized_copper_chest\", \"waxed_copper_chest\", \"waxed_exposed_copper_chest\",\\s*\"waxed_weathered_copper_chest\", \"waxed_oxidized_copper_chest\"", + "replacement": "\"chest\", \"trapped_chest\"" + }, + { + "pattern": "sulfurAndDripstoneTipsReceivePostLoadUpdates", + "replacement": "dripstoneTipsReceivePostLoadUpdates" + } + ], + "art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java": [ + { + "pattern": "(?ms)^ BuiltInRegistries\\.BLOCK\\.prepareTagReload\\(new TagLoader\\.LoadResult<>.*?\\)\\)\\)\\)\\.apply\\(\\);\\n", + "replacement": "" + } + ], + "art/arcane/iris/modded/command/ModdedLanguageCommandsTest.java": [ + { + "pattern": "new ClickEvent\\.RunCommand\\(", + "replacement": "new ClickEvent(ClickEvent.Action.RUN_COMMAND, " + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeCommandTextTest.java": [ + { + "pattern": "\\(\\(ClickEvent\\.RunCommand\\) component\\.getStyle\\(\\)\\.getClickEvent\\(\\)\\)\\.command\\(\\)", + "replacement": "component.getStyle().getClickEvent().getValue()" + }, + { + "pattern": "\\(\\(HoverEvent\\.ShowText\\) component\\.getStyle\\(\\)\\.getHoverEvent\\(\\)\\)\\.value\\(\\)", + "replacement": "component.getStyle().getHoverEvent().getValue(HoverEvent.Action.SHOW_TEXT)" + } + ], + "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeChunkRegenerationTest.java": [ + { + "pattern": "\\.markUnsaved\\(\\)", + "replacement": ".setUnsaved(true)" + } + ], + "art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java": [ + { + "pattern": "player\\.level\\(\\)", + "replacement": "player.serverLevel()" + } + ] + } +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 670b54d5d..6dbce8bf4 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileTree import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.attributes.Bundling import org.gradle.jvm.toolchain.JavaLanguageVersion import javax.inject.Inject @@ -42,6 +43,8 @@ plugins { alias(libs.plugins.shadow) } +apply from: file('../../gradle/mod-target.gradle') + apply plugin: 'art.arcane.volmit-packaging' interface IrisFileOperations { @@ -58,7 +61,7 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.3.0.33-beta')) +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(irisModTarget.neoForge.toString()) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } @@ -144,7 +147,7 @@ dependencies { } testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2') testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2') - compileOnly('org.slf4j:slf4j-api:2.0.17') + compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}") compileOnly(libs.spigot) { transitive = false } @@ -173,8 +176,15 @@ dependencies { libs.jaxen ] shared.each { Object notation -> - add('bundle', notation) - add('devBundle', notation) + ['bundle', 'devBundle'].each { String configuration -> + add(configuration, notation) { + if (group == 'art.arcane' && name == 'core') { + attributes { + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL)) + } + } + } + } } } @@ -197,7 +207,7 @@ sourceSets.modLibraries.output.classesDirs.setFrom(stageDevelopmentModLibraries) neoForge { version = neoForgeVersion - accessTransformers.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "neoforge", layout.buildDirectory.dir("native-access").get().asFile)) + accessTransformers.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "neoforge", layout.buildDirectory.dir("native-access").get().asFile)) runs { client { @@ -242,7 +252,7 @@ neoForge { tasks.named('compileJava', JavaCompile).configure { options.encoding = 'UTF-8' - options.release.set(25) + options.release.set((int) irisModTarget.java) } tasks.named('test').configure { @@ -277,6 +287,10 @@ tasks.named('shadowJar', ShadowJar).configure { configurations = [project.configurations.named('bundle').get()] from(sourceSets.main.output) mergeServiceFiles() + if ((int) irisModTarget.java == 21) { + exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') + exclude('art/arcane/volmlib/util/noise/BillowBatch*.class') + } exclude('META-INF/maven/**') exclude('META-INF/proguard/**') exclude('META-INF/*.SF') @@ -342,3 +356,5 @@ pluginPackaging { } } } + +apply from: file('../../gradle/modded-version-sources.gradle') diff --git a/adapters/neoforge/src/versions/1.21.1/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java b/adapters/neoforge/src/versions/1.21.1/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java new file mode 100644 index 000000000..0c44ee5d5 --- /dev/null +++ b/adapters/neoforge/src/versions/1.21.1/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java @@ -0,0 +1,29 @@ +package art.arcane.iris.neoforge; + +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedLifecycleCallbacks; +import art.arcane.iris.modded.service.ModdedTreeFellerService; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkGeneratorDefinition; +import art.arcane.volmlib.nativelib.minecraft26_2.neoforge.NativeNeoForgeLoader; +import art.arcane.volmlib.nativelib.minecraft26_2.neoforge.NativeNeoForgeBootstrap; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.loading.FMLLoader; + +@Mod("irisworldgen") +public final class IrisNeoForgeBootstrap { + public IrisNeoForgeBootstrap(IEventBus modBus) { + NativeNeoForgeLoader loader = new NativeNeoForgeLoader(new NativeLoaderOptions( + "irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe)); + NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION; + ModdedEngineBootstrap.bootCommon(loader, "NeoForge " + FMLLoader.versionInfo().neoForgeVersion(), + () -> NativeNeoForgeBootstrap.registerGenerator(modBus, generator)); + NeoForgeProtocolNetworking.register(modBus); + if (loader.clientEnvironment()) { + IrisNeoForgeClient.init(modBus); + } + NativeNeoForgeBootstrap.install(modBus, loader, ModdedLifecycleCallbacks.create()); + } +} diff --git a/adapters/neoforge/src/versions/1.21.1/resources/META-INF/neoforge.mods.toml b/adapters/neoforge/src/versions/1.21.1/resources/META-INF/neoforge.mods.toml new file mode 100644 index 000000000..33ebd2190 --- /dev/null +++ b/adapters/neoforge/src/versions/1.21.1/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,37 @@ +modLoader = "javafml" +loaderVersion = "[3,)" +license = "GPL-3.0" +issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues" + +[[mixins]] +config = "volmlib.entity.mixins.json" + +[[mixins]] +config = "volmlib.client.mixins.json" + +[[accessTransformers]] +file = "META-INF/accesstransformer.cfg" + +[[mods]] +modId = "irisworldgen" +version = "${version}" +displayName = "Iris" +description = "Iris World Generation Engine (NeoForge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)" +authors = "Arcane Arts (Volmit Software)" +credits = "cyberpwn, NextdoorPsycho, Vatuu" +displayURL = "https://docs.volmit.com/iris/" +logoFile = "assets/irisworldgen/icon.png" + +[[dependencies.irisworldgen]] +modId = "neoforge" +type = "required" +versionRange = "[21.1,21.2)" +ordering = "NONE" +side = "BOTH" + +[[dependencies.irisworldgen]] +modId = "minecraft" +type = "required" +versionRange = "[${minecraftVersion}]" +ordering = "NONE" +side = "BOTH" diff --git a/build.gradle b/build.gradle index 955735cff..02369675d 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,8 @@ import io.papermc.paperweight.userdev.PaperweightUserDependenciesExtension import io.papermc.paperweight.userdev.PaperweightUserExtension import org.gradle.api.artifacts.component.ComponentIdentifier import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Provider +import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.api.file.CopySpec import org.gradle.api.file.DeleteSpec @@ -71,12 +73,14 @@ project(':core') { group = 'art.arcane' version = rootProject.version } +apply from: file('gradle/mod-target.gradle') + String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3') -// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3; loader jars are 26.3-only. +// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3. String bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.3') -String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.5') -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.3-66.0.8') -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.3.0.33-beta') +String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString()) +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString()) +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(irisModTarget.neoForge.toString()) String volmLibShared = 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT' File localVolmLibCheckout = (File) gradle.extensions.extraProperties.get('volmLibDirectory') IncludedBuild localVolmLibBuild = localVolmLibCheckout == null ? null : gradle.includedBuilds.find { IncludedBuild includedBuild -> @@ -104,18 +108,18 @@ File pluginOutDirectory = file(providers.gradleProperty('pluginOutDirectory').ge File legacyBukkitJar = layout.buildDirectory.file("libs/Iris-${project.version}.jar").get().asFile File bukkitArtifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile File consumerBukkitArtifact = layout.buildDirectory.file("consumer/${bukkitArtifactName}").get().asFile -File fabricArtifact = layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}").asFile -File forgeArtifact = layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}").asFile -File neoForgeArtifact = layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}").asFile +File fabricArtifact = layout.projectDirectory.file("adapters/fabric/build/${minecraftVersion}/libs/${fabricArtifactName}").asFile +File forgeArtifact = layout.projectDirectory.file("adapters/forge/build/${minecraftVersion}/libs/${forgeArtifactName}").asFile +File neoForgeArtifact = layout.projectDirectory.file("adapters/neoforge/build/${minecraftVersion}/libs/${neoForgeArtifactName}").asFile File consumerDirectory = layout.buildDirectory.dir('consumer').get().asFile FileTree staleConsumerBukkitJars = fileTree(consumerDirectory) { include('Iris v* [CraftBukkit] *.jar') } Map staleOutJars = [ CraftBukkit: 'Iris v* [CraftBukkit] *.jar', - Fabric : 'Iris v* [Fabric] *.jar', - Forge : 'Iris v* [Forge] *.jar', - NeoForge : 'Iris v* [NeoForge] *.jar' + Fabric : "Iris v* [Fabric] ${minecraftVersion}+*.jar", + Forge : "Iris v* [Forge] ${minecraftVersion}+*.jar", + NeoForge : "Iris v* [NeoForge] ${minecraftVersion}+*.jar" ].collectEntries { String platform, String pattern -> [(platform): fileTree(pluginOutDirectory) { include(pattern) }] } @@ -323,7 +327,10 @@ List moddedSharedInputs = [] 'gradle/volmlib-stamp.gradle', 'gradle/generation-build-revision.gradle', 'gradle/modded-generation-build-revision.gradle', - 'gradle/native-minecraft-sources.gradle' + 'gradle/native-minecraft-sources.gradle', + 'gradle/mod-target.gradle', + 'gradle/mod-targets.json', + 'gradle/modded-version-sources.gradle' ].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) } if (localVolmLibCheckout != null) { moddedSharedInputs.add(fileTree(localVolmLibCheckout) { @@ -358,7 +365,7 @@ Closure declareModdedJarIo = { Exec task, String adapter, String artifactN ).withPropertyName('adapterSources') versions.each { String key, String value -> task.inputs.property(key, value) } declareVolmLibInputs(task) - task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/libs/${artifactName}")) + task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/${minecraftVersion}/libs/${artifactName}")) return null } @@ -368,7 +375,7 @@ tasks.register('fabricJar', Exec) { String wrapperScript = System.getProperty('os.name').toLowerCase().contains('windows') ? 'gradlew.bat' : 'gradlew' List command = [ layout.projectDirectory.file(wrapperScript).asFile.absolutePath, - 'shadowJar', + 'assemble', '--console=plain', "-PirisVersion=${project.version}", "-PminecraftVersion=${minecraftVersion}", @@ -389,7 +396,7 @@ tasks.register('fabricJar', Exec) { tasks.register('buildFabric', Copy) { group = 'iris' dependsOn('fabricJar') - from(layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}")) + from(layout.projectDirectory.file("adapters/fabric/build/${minecraftVersion}/libs/${fabricArtifactName}")) into(pluginOutDirectory) File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${minecraftVersion}-fabric.jar") doFirst { @@ -405,7 +412,7 @@ tasks.register('forgeJar', Exec) { String wrapperScript = System.getProperty('os.name').toLowerCase().contains('windows') ? 'gradlew.bat' : 'gradlew' List command = [ layout.projectDirectory.file(wrapperScript).asFile.absolutePath, - 'shadowJar', + 'assemble', '--console=plain', "-PirisVersion=${project.version}", "-PminecraftVersion=${minecraftVersion}", @@ -426,7 +433,7 @@ tasks.register('forgeJar', Exec) { tasks.register('buildForge', Copy) { group = 'iris' dependsOn('forgeJar') - from(layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}")) + from(layout.projectDirectory.file("adapters/forge/build/${minecraftVersion}/libs/${forgeArtifactName}")) into(pluginOutDirectory) File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${minecraftVersion}-forge.jar") doFirst { @@ -442,7 +449,7 @@ tasks.register('neoforgeJar', Exec) { String wrapperScript = System.getProperty('os.name').toLowerCase().contains('windows') ? 'gradlew.bat' : 'gradlew' List command = [ layout.projectDirectory.file(wrapperScript).asFile.absolutePath, - 'shadowJar', + 'assemble', '--console=plain', "-PirisVersion=${project.version}", "-PminecraftVersion=${minecraftVersion}", @@ -463,7 +470,7 @@ tasks.register('neoforgeJar', Exec) { tasks.register('buildNeoforge', Copy) { group = 'iris' dependsOn('neoforgeJar') - from(layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}")) + from(layout.projectDirectory.file("adapters/neoforge/build/${minecraftVersion}/libs/${neoForgeArtifactName}")) into(pluginOutDirectory) File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${minecraftVersion}-neoforge.jar") doFirst { @@ -521,7 +528,7 @@ tasks.register('moddedTest', Exec) { inputs.property('minecraftVersion', minecraftVersion) inputs.property('fabricLoaderVersion', fabricLoaderVersion) declareVolmLibInputs(it) - outputs.dir(layout.projectDirectory.dir('adapters/fabric/build/test-results/test')) + outputs.dir(layout.projectDirectory.dir("adapters/fabric/build/${minecraftVersion}/test-results/test")) mustRunAfter(tasks.named('jar')) mustRunAfter(project(':core').tasks.named('check')) mustRunAfter(project(':spi').tasks.named('jar')) @@ -549,6 +556,7 @@ tasks.named('check').configure { } List commonModdedArtifactEntries = [ + 'agent.jar', 'pack.mcmeta', 'com/fulcrumgenomics/jlibdeflate/LibdeflateDecompressor.class', 'com/fulcrumgenomics/jlibdeflate/LibdeflateException.class', @@ -601,6 +609,7 @@ tasks.register('verifyFabricArtifact') { 'art/arcane/iris/fabric/IrisFabricBootstrap.class' ] ModdedArtifactVerifier.verify(artifact, requiredEntries, bukkitCoupledCoreClasses) + ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) irisModTarget.java) logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } @@ -618,6 +627,7 @@ tasks.register('verifyForgeArtifact') { 'art/arcane/iris/forge/IrisForgeBootstrap.class' ] ModdedArtifactVerifier.verify(artifact, requiredEntries, bukkitCoupledCoreClasses) + ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) irisModTarget.java) logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } @@ -635,6 +645,7 @@ tasks.register('verifyNeoforgeArtifact') { 'art/arcane/iris/neoforge/IrisNeoForgeBootstrap.class' ] ModdedArtifactVerifier.verify(artifact, requiredEntries, bukkitCoupledCoreClasses) + ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) irisModTarget.java) logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } @@ -644,6 +655,59 @@ tasks.register('verifyModdedArtifacts') { dependsOn('verifyFabricArtifact', 'verifyForgeArtifact', 'verifyNeoforgeArtifact') } +TaskProvider verifyModdedMatrix = tasks.register('verifyModdedMatrix') { + group = 'verification' + description = 'Builds and verifies every supported Minecraft mod target and runs the shared mod tests.' +} +TaskProvider previousMatrixBuild = null +irisModTargets.each { String targetMinecraft, Map target -> + ['fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'].each { String loader, String label -> + String pinKey = loader == 'fabric' ? 'fabricLoader' : loader == 'neoforge' ? 'neoForge' : 'forge' + String loaderPin = target.get(pinKey).toString() + String displayedLoader = loaderPin.startsWith(targetMinecraft + '-') + ? loaderPin.substring(targetMinecraft.length() + 1) : loaderPin + String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}") + File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}") + TaskProvider buildTarget = tasks.register("verify${label}${targetMinecraft.replace('.', '_')}", Exec) { + group = 'verification' + workingDir = file("adapters/${loader}") + String wrapperScript = System.getProperty('os.name').toLowerCase().contains('windows') ? 'gradlew.bat' : 'gradlew' + List command = [file(wrapperScript).absolutePath, 'assemble', 'compileTestJava', + 'verifyLoggingPolicy', 'verifyNativeClassBoundary', '--console=plain', + "-PirisVersion=${project.version}", "-PminecraftVersion=${targetMinecraft}", + "-PuseLocalVolmLib=${localVolmLibCheckout != null}"] + if (loader == 'fabric') { + command.add('test') + } + commandLine(command) + if (localVolmLibCheckout != null) { + environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath) + } + inputs.files(moddedSharedInputs, file("adapters/${loader}/src"), file("adapters/${loader}/build.gradle"), + file("adapters/${loader}/settings.gradle")) + inputs.properties(target) + declareVolmLibInputs(it) + outputs.file(artifact) + if (loader == 'fabric') { + outputs.dir(file("adapters/${loader}/build/${targetMinecraft}/test-results/test")) + } + doLast { + String metadata = loader == 'fabric' ? 'fabric.mod.json' + : loader == 'forge' ? 'META-INF/mods.toml' : 'META-INF/neoforge.mods.toml' + ModdedArtifactVerifier.verify(artifact, commonModdedArtifactEntries + [metadata], bukkitCoupledCoreClasses) + ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) target.java) + logger.lifecycle("Verified ${artifact.name}") + } + } + if (previousMatrixBuild != null) { + TaskProvider preceding = previousMatrixBuild + buildTarget.configure { mustRunAfter(preceding) } + } + previousMatrixBuild = buildTarget + verifyModdedMatrix.configure { dependsOn(buildTarget) } + } +} + // Entries the Bukkit artifact only ever reaches by name - NMS binding lookup, the SIMD kernels, the // instrumentation agent, the JarScanner-driven command and service discovery, the Matter codec - plus // the two plugin descriptors and the slimjar manifests. Nothing in the reference graph points at @@ -805,9 +869,18 @@ tasks.register('irisDev', Copy) { int defaultTestForks = Math.min(8, Math.max(1, Runtime.runtime.availableProcessors().intdiv(2))) int testForks = Math.max(1, Integer.parseInt(providers.gradleProperty('testForks') .getOrElse(String.valueOf(defaultTestForks)))) +int irisRuntimeJava = providers.gradleProperty('irisRuntimeJava').getOrElse(irisModTarget.java.toString()).toInteger() +if (!(irisRuntimeJava in [21, 25])) { + throw new GradleException('irisRuntimeJava must be 21 or 25') +} +Set sharedRuntimeProjects = [':core', ':core:agent', ':spi'] as Set allprojects { apply plugin: 'java' + boolean java21Runtime = irisRuntimeJava == 21 && sharedRuntimeProjects.contains(project.path) + if (java21Runtime) { + layout.buildDirectory.set(layout.projectDirectory.dir('build/java21')) + } tasks.withType(Test).configureEach { int moduleForks = project.path.startsWith(':adapters:bukkit:') ? Math.min(2, testForks) @@ -820,6 +893,32 @@ allprojects { toolchain { languageVersion = JavaLanguageVersion.of(25) } + if (java21Runtime) { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + } + + if (java21Runtime) { + Provider runtimeLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } + tasks.withType(JavaCompile).configureEach { + sourceCompatibility = '21' + targetCompatibility = '21' + if (project.path == ':core') { + options.compilerArgumentProviders.add({ + ['--system', runtimeLauncher.get().metadata.installationPath.asFile.absolutePath] + } as org.gradle.process.CommandLineArgumentProvider) + } else { + options.release.set(21) + } + } + if (project.path != ':core') { + tasks.withType(Test).configureEach { + javaLauncher.set(runtimeLauncher) + } + } } repositories { @@ -881,7 +980,9 @@ allprojects { options.compilerArgs.add('-parameters') options.encoding = 'UTF-8' options.debugOptions.debugLevel = 'none' - options.release.set(25) + if (!java21Runtime) { + options.release.set(25) + } } tasks.named('javadoc').configure { diff --git a/buildSrc/src/main/java/ModdedArtifactVerifier.java b/buildSrc/src/main/java/ModdedArtifactVerifier.java index c497bd8e8..a2b7bff90 100644 --- a/buildSrc/src/main/java/ModdedArtifactVerifier.java +++ b/buildSrc/src/main/java/ModdedArtifactVerifier.java @@ -2,6 +2,8 @@ import org.gradle.api.GradleException; import org.objectweb.asm.ClassReader; import java.io.File; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -39,7 +41,8 @@ public final class ModdedArtifactVerifier { private static final List PRIVATE_REFERENCE_PREFIXES = List.of( "art/arcane/iris/shadow/jpountz", "art/arcane/iris/shadow/oshi", - "art/arcane/iris/shadow/jna" + "art/arcane/iris/shadow/jna", + "art/arcane/iris/util/gson" ); // ASM must always be relocated: the loaders put their own copy on the module layer and a second // org.objectweb.asm package on the same layer is a boot failure. @@ -66,6 +69,14 @@ public final class ModdedArtifactVerifier { private ModdedArtifactVerifier() { } + public static void verifyJavaRuntime(File artifact, int runtimeJava) { + try (JarInputStream archive = new JarInputStream(new FileInputStream(artifact))) { + verifyJavaRuntime(archive, runtimeJava, artifact.getName()); + } catch (IOException error) { + throw new GradleException("Could not verify Java " + runtimeJava + " compatibility of " + artifact, error); + } + } + public static void verify(File artifact, List requiredEntries) { verify(artifact, requiredEntries, Set.of()); } @@ -192,6 +203,38 @@ public final class ModdedArtifactVerifier { } } + private static void verifyJavaRuntime(JarInputStream archive, int runtimeJava, String location) throws IOException { + JarEntry entry; + while ((entry = archive.getNextJarEntry()) != null) { + String name = entry.getName(); + if (entry.isDirectory()) { + continue; + } + if (name.startsWith("META-INF/versions/")) { + String version = name.substring("META-INF/versions/".length()).split("/", 2)[0]; + if (Integer.parseInt(version) > runtimeJava) { + continue; + } + } + if (name.endsWith(".jar")) { + try (JarInputStream nested = new JarInputStream(new ByteArrayInputStream(archive.readAllBytes()))) { + verifyJavaRuntime(nested, runtimeJava, location + "!/" + name); + } + } else if (name.endsWith(".class")) { + byte[] header = archive.readNBytes(8); + if (header.length != 8 || header[0] != (byte) 0xca || header[1] != (byte) 0xfe + || header[2] != (byte) 0xba || header[3] != (byte) 0xbe) { + throw new GradleException("Invalid class file " + location + "!/" + name); + } + int major = (header[6] & 0xff) << 8 | header[7] & 0xff; + if (major > runtimeJava + 44) { + throw new GradleException(location + "!/" + name + " requires Java " + (major - 44) + + "; target runtime is Java " + runtimeJava); + } + } + } + } + private static void verifyNestedJarDeclarations(File artifact, JarFile jar, Set shippedNestedJars) throws IOException { String metadata = readEntryText(jar, FABRIC_METADATA); diff --git a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java index b1df903d5..c0f36e213 100644 --- a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java +++ b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java @@ -15,7 +15,10 @@ public final class GenerationRevisionScope { "adapters/client-common/src/main/java", "adapters/fabric/src/main/java", "adapters/forge/src/main/java", - "adapters/neoforge/src/main/java" + "adapters/neoforge/src/main/java", + "adapters/forge/src/versions/1.21.1/java", + "adapters/neoforge/src/versions/1.21.1/java", + "adapters/modded-common/src/versions/1.21.1/java" ); private static final List OPERATIONAL_EXCLUSIONS = List.of( "core/src/main/java/art/arcane/iris/generation/runtime/EngineDiagnostics.java", diff --git a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java index b159b300a..4647beec6 100644 --- a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java +++ b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java @@ -11,6 +11,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -38,6 +39,24 @@ public class ModdedArtifactVerifierTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void rejectsClassesNewerThanSelectedRuntimeIncludingNestedJars() throws Exception { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, "example/NewRuntime", null, "java/lang/Object", null); + writer.visitEnd(); + Map entries = new LinkedHashMap<>(); + entries.put("example/NewRuntime.class", writer.toByteArray()); + File artifact = createArtifact(entries); + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verifyJavaRuntime(artifact, 21)); + assertTrue(failure.getMessage().contains("requires Java 25")); + ModdedArtifactVerifier.verifyJavaRuntime(artifact, 25); + File nested = createArtifact(Map.of("META-INF/jars/library.jar", Files.readAllBytes(artifact.toPath()))); + GradleException nestedFailure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verifyJavaRuntime(nested, 21)); + assertTrue(nestedFailure.getMessage().contains("library.jar!/example/NewRuntime.class")); + } + @Test public void acceptsMinecraftRuntimeReferences() throws Exception { Map entries = validEntries(); @@ -166,6 +185,16 @@ public class ModdedArtifactVerifierTest { assertTrue(failure.getMessage().contains("Iris-private runtime references")); } + @Test + public void rejectsBukkitRelocatedGsonReferences() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/Test.class", "art/arcane/iris/util/gson/Gson" + .getBytes(StandardCharsets.ISO_8859_1)); + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("Iris-private runtime references")); + } + @Test public void rejectsBundledMinecraftRuntimeLibrary() throws Exception { Map entries = validEntries(); diff --git a/core/build.gradle b/core/build.gradle index 412e263e3..abaab4f4e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,4 +1,5 @@ import io.github.slimjar.resolver.data.Mirror +import org.gradle.api.attributes.java.TargetJvmVersion import org.gradle.api.file.FileTreeElement import org.gradle.api.tasks.Copy import org.gradle.api.tasks.TaskProvider @@ -197,6 +198,11 @@ slimJar { } def embeddedAgentJar = project(':core:agent').tasks.named('jar', Jar) +tasks.named('processResources').configure { + from(embeddedAgentJar.map { it.archiveFile }) { + rename { String ignored -> 'agent.jar' } + } +} def templateSource = file('src/main/templates') def templateDest = layout.buildDirectory.dir('generated/sources/templates') def generateTemplates = tasks.register('generateTemplates', Copy) { @@ -263,7 +269,35 @@ tasks.named('compileJava', JavaCompile).configure { tasks.named('test', Test).configure { jvmArgs('--add-modules', 'jdk.incubator.vector') dependsOn(languageSourceManifestTask) - classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + sourceSets.main.output + classpath + classpath = (files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + + sourceSets.main.output + classpath) - files(tasks.named('shadowJar')) +} + +if (providers.gradleProperty('irisRuntimeJava').getOrElse(rootProject.irisModTarget.java.toString()) == '21') { + configurations.configureEach { + if (name == 'compileClasspath' || name.endsWith('CompileClasspath') + || name in ['testRuntimeClasspath', 'externalProviderTestRuntimeClasspath']) { + attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 25) + } + } + tasks.register('java21RuntimeTest', Test) { + group = 'verification' + description = 'Verifies generation kernels and pack updates on the Minecraft 1.21.1 Java runtime.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = (files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + + sourceSets.main.output + sourceSets.test.runtimeClasspath) - files(tasks.named('shadowJar')) + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + }) + jvmArgs('--add-modules', 'jdk.incubator.vector') + filter { + includeTestsMatching('art.arcane.iris.generation.simd.*') + includeTestsMatching('art.arcane.iris.pack.BuiltInPackUpdatesTest') + } + } + tasks.named('check').configure { + dependsOn('java21RuntimeTest') + } } configurations.matching { it.name.startsWith('slim') }.all { } @@ -417,6 +451,10 @@ tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.Shadow inputs.property('languageTomlSupportClassPattern', languageTomlSupportClassPattern) dependsOn(embeddedAgentJar) mergeServiceFiles() + if (providers.gradleProperty('irisRuntimeJava').getOrElse(rootProject.irisModTarget.java.toString()) == '21') { + exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') + exclude('art/arcane/volmlib/util/noise/BillowBatch*.class') + } // minimize() stays off. Caffeine reaches its 520 generated cache and node classes through // MethodHandles.Lookup.findClass on a computed name, and the VolmLib command, GUI and hotload // surfaces are only referenced from :adapters:bukkit:plugin, which is merged into the artifact @@ -434,9 +472,6 @@ tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.Shadow exclude(unusedVolmLibClassEntries) exclude(annotationOnlyArtifacts) exclude(dependencyBuildMetadata) - from(embeddedAgentJar.map { it.archiveFile }) { - rename { String ignored -> 'agent.jar' } - } } rootProject.tasks.matching { diff --git a/core/src/main/java/art/arcane/iris/generation/block/TileData.java b/core/src/main/java/art/arcane/iris/generation/block/TileData.java index 9ec61df1a..cbc76ff78 100644 --- a/core/src/main/java/art/arcane/iris/generation/block/TileData.java +++ b/core/src/main/java/art/arcane/iris/generation/block/TileData.java @@ -22,7 +22,6 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.Strictness; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.reflect.KeyedType; import art.arcane.volmlib.util.collection.KMap; @@ -51,7 +50,7 @@ import java.util.concurrent.ConcurrentHashMap; @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TileData implements Cloneable { - private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.LONG_OR_DOUBLE).create(); + private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setLenient().setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.LONG_OR_DOUBLE).create(); /** * Memoized {@link #resolveMaterial()}. Pasting one tile resolves its material at least twice * ({@link #isApplicable(BlockData)} then {@link #toBukkit(Block)}), and matchMaterial normalizes the string and diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index c45f08688..aa767b0f3 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -1360,11 +1360,12 @@ public class IrisDimension extends IrisRegistrant { "pack": { "description": "Iris Data Pack. This pack contains all installed Iris Packs' resources.", "pack_format": %d, + "supported_formats": [%d, %d], "min_format": %d, "max_format": %d } } - """.formatted(maxFormat, minFormat, maxFormat); + """.formatted(maxFormat, minFormat, maxFormat, minFormat, maxFormat); for (File datapackRoot : datapackRoots) { File mcm = new File(datapackRoot, "pack.mcmeta"); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java index 56ef088e4..494ea4a1a 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java @@ -467,7 +467,7 @@ public class IrisRegion extends IrisRegistrant implements Rarity { } /** - * Cascade: a region that lost every land biome to the gate cannot generate, so it leaves the dimension's region + * Cascade: a region that lost every biome in a declared surface pool cannot generate, so it leaves the dimension's region * pool too. Loading biomes here is safe - biomes never load regions. */ @Override @@ -489,17 +489,9 @@ public class IrisRegion extends IrisRegistrant implements Rarity { "structures[" + index + "].caveBiomes"); } - if (getLandBiomes().isEmpty()) { - return base; - } - - List drops = new ArrayList<>(); - - if (!resolvePool(() -> data, getLandBiomes(), "landBiomes", drops).isEmpty()) { - return base; - } - - return CompatPools.cascade(data, base, drops, "region", getLoadKey(), "no land biomes remain"); + base = evaluateSurfacePoolCompat(data, base, getLandBiomes(), "land"); + base = evaluateSurfacePoolCompat(data, base, getSeaBiomes(), "sea"); + return evaluateSurfacePoolCompat(data, base, getShoreBiomes(), "shore"); } @Override @@ -511,4 +503,17 @@ public class IrisRegion extends IrisRegistrant implements Rarity { public String getTypeName() { return "Region"; } + + private CompatStatus evaluateSurfacePoolCompat(IrisData data, CompatStatus base, KList keys, String role) { + if (base.excluded() || keys.isEmpty()) { + return base; + } + + List drops = new ArrayList<>(); + if (!resolvePool(() -> data, keys, role + "Biomes", drops).isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "region", getLoadKey(), "no " + role + " biomes remain"); + } } diff --git a/core/src/main/java/art/arcane/iris/pack/BuiltInPackUpdates.java b/core/src/main/java/art/arcane/iris/pack/BuiltInPackUpdates.java index 756901e70..645b927e9 100644 --- a/core/src/main/java/art/arcane/iris/pack/BuiltInPackUpdates.java +++ b/core/src/main/java/art/arcane/iris/pack/BuiltInPackUpdates.java @@ -4,16 +4,20 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import java.io.IOException; import java.math.BigInteger; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.ByteBuffer; import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.regex.Pattern; @@ -48,7 +52,7 @@ public final class BuiltInPackUpdates { .GET() .build(); CompletableFuture> response = client.sendAsync(request, - HttpResponse.BodyHandlers.limiting(HttpResponse.BodyHandlers.ofString(), 1024L * 1024L)); + info -> new ReleaseBodySubscriber(HttpResponse.BodyHandlers.ofString().apply(info))); requests.put(pack, response.copy().orTimeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) .whenComplete((result, error) -> { if (error != null) { @@ -109,4 +113,58 @@ public final class BuiltInPackUpdates { : ""; } } + + private static final class ReleaseBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber body; + private Flow.Subscription subscription; + private int remaining = 1024 * 1024; + private boolean complete; + + private ReleaseBodySubscriber(HttpResponse.BodySubscriber body) { + this.body = body; + } + + @Override + public CompletionStage getBody() { + return body.getBody(); + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + body.onSubscribe(subscription); + } + + @Override + public void onNext(List buffers) { + if (complete) { + return; + } + for (ByteBuffer buffer : buffers) { + if (buffer.remaining() > remaining) { + subscription.cancel(); + onError(new IOException("Release metadata exceeds 1 MiB")); + return; + } + remaining -= buffer.remaining(); + } + body.onNext(buffers); + } + + @Override + public void onError(Throwable error) { + if (!complete) { + complete = true; + body.onError(error); + } + } + + @Override + public void onComplete() { + if (!complete) { + complete = true; + body.onComplete(); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java b/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java index db9a5b9e7..f71066934 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java @@ -2,6 +2,7 @@ package art.arcane.iris.pack.datapack; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.pack.datapack.v1217.DataFixerV1217; +import art.arcane.iris.pack.datapack.v1206.DataFixerV1206; import art.arcane.volmlib.util.collection.KMap; import lombok.AccessLevel; import lombok.Getter; @@ -13,6 +14,7 @@ import art.arcane.iris.pack.datapack.v263.DataFixerV263; @Getter public enum DataVersion { UNSUPPORTED("0.0.0", 0, () -> null), + V1_21_1("1.21.1", 48, DataFixerV1206::new), V26_1_2("26.1.2", 101, DataFixerV1217::new), V26_2("26.2", 107, DataFixerV1217::new), V26_3("26.3", 121, DataFixerV263::new); @@ -41,6 +43,7 @@ public enum DataVersion { return UNSUPPORTED; } return switch (version) { + case "1.21.1" -> V1_21_1; case "26.1.2" -> V26_1_2; case "26.2", "26.2.0" -> V26_2; case "26.3", "26.3.0" -> V26_3; diff --git a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java index 23fb8fff5..d7f2bd104 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java @@ -25,7 +25,6 @@ import com.google.gson.ExclusionStrategy; import com.google.gson.FieldAttributes; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.Strictness; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; @@ -676,7 +675,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { builder = new GsonBuilder() .addDeserializationExclusionStrategy(this) .addSerializationExclusionStrategy(this) - .setStrictness(Strictness.LENIENT) + .setLenient() .registerTypeAdapterFactory(this) .registerTypeAdapter(MantleFlag.class, new MantleFlagAdapter()) .setPrettyPrinting(); @@ -718,7 +717,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { builder = new GsonBuilder() .addDeserializationExclusionStrategy(this) .addSerializationExclusionStrategy(this) - .setStrictness(Strictness.LENIENT) + .setLenient() .registerTypeAdapterFactory(this) .registerTypeAdapter(MantleFlag.class, new MantleFlagAdapter()) .setPrettyPrinting(); diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java index cfa0f96a9..1137c40af 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java @@ -1,6 +1,7 @@ package art.arcane.iris.structure.export; import art.arcane.iris.structure.graph.CompiledStructureGraph; +import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver; import art.arcane.iris.structure.graph.StructureGraphCompilation; import art.arcane.iris.structure.graph.StructureGraphCompiler; @@ -214,6 +215,12 @@ final class VanillaJigsawExportCompiler { "Minecraft jigsaw size must be within 0..20; Iris export requires 1..20."); } long horizontalDistance = (long) structure.getMaxSizeChunks() * 16L; + if (DataVersion.getRuntime() == DataVersion.V1_21_1 && horizontalDistance != settings.maxDistanceVertical()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, + "max_distance_from_center.vertical", + "Minecraft 1.21.1 vanilla export requires equal horizontal and vertical maximum distances."); + } if (horizontalDistance < 1L || horizontalDistance > MAX_HORIZONTAL_DISTANCE) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, @@ -576,6 +583,12 @@ final class VanillaJigsawExportCompiler { private JsonObject packMetadata(VanillaJigsawExportRequest request) { JsonObject pack = new JsonObject(); pack.addProperty("description", request.description()); + if (DataVersion.getRuntime() == DataVersion.V1_21_1) { + pack.addProperty("pack_format", DataVersion.V1_21_1.getPackFormat()); + JsonObject root = new JsonObject(); + root.add("pack", pack); + return root; + } JsonArray minimum = new JsonArray(); boolean current = VanillaStructureTemplateEncoder.targets263(); int packFormat = current ? 121 : 107; diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java index 33a6bd675..ad3395168 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java @@ -61,7 +61,8 @@ final class VanillaStructureTemplateEncoder { root.put("palette", palette(paletteStates, format)); root.put("blocks", blocks(orderedBlocks, paletteIndexes)); root.put("entities", new ListTag<>(CompoundTag.class)); - root.putInt("DataVersion", lowercase ? 5023 : DATA_VERSION_26_2); + root.putInt("DataVersion", "1.21.1".equals(IrisPlatforms.get().minecraftVersion()) + ? 3955 : lowercase ? 5023 : DATA_VERSION_26_2); ByteArrayOutputStream output = new ByteArrayOutputStream(); NBTUtil.write(new NamedTag("", root), output, true); diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryStore.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryStore.java index 410fc9d5a..8dcbb8959 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryStore.java @@ -7,8 +7,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import com.google.gson.JsonParser; -import com.google.gson.Strictness; +import art.arcane.volmlib.util.json.StrictJsonReader; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; @@ -229,9 +228,9 @@ public final class GenerationHistoryStore { throw new IOException("Generation manifest size changed while reading: " + manifestPath); } try (Reader input = new StringReader(new String(encoded, StandardCharsets.UTF_8)); - JsonReader reader = new JsonReader(input)) { - reader.setStrictness(Strictness.STRICT); - JsonElement parsed = JsonParser.parseReader(reader); + JsonReader reader = new JsonReader(new StrictJsonReader(input))) { + reader.setLenient(false); + JsonElement parsed = GSON.getAdapter(JsonElement.class).read(reader); if (parsed == null || !parsed.isJsonObject()) { throw new IllegalArgumentException("Generation manifest must be a JSON object."); } @@ -314,9 +313,9 @@ public final class GenerationHistoryStore { if (Files.size(metadata) > MAX_MANIFEST_BYTES) { throw new IOException("Generation epoch metadata exceeds the supported size: " + metadata); } - try (JsonReader reader = new JsonReader(Files.newBufferedReader(metadata, StandardCharsets.UTF_8))) { - reader.setStrictness(Strictness.STRICT); - JsonElement parsed = JsonParser.parseReader(reader); + try (JsonReader reader = new JsonReader(new StrictJsonReader(Files.newBufferedReader(metadata, StandardCharsets.UTF_8)))) { + reader.setLenient(false); + JsonElement parsed = GSON.getAdapter(JsonElement.class).read(reader); if (!parsed.isJsonObject() || reader.peek() != JsonToken.END_DOCUMENT) { throw new IOException("Invalid generation epoch metadata: " + metadata); } diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/IrisRegionCompatPoolTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/IrisRegionCompatPoolTest.java index f79b1d6d7..a5f1d0421 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/IrisRegionCompatPoolTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/IrisRegionCompatPoolTest.java @@ -23,6 +23,7 @@ import static art.arcane.iris.testsupport.PackCompatFixtures.find; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -132,6 +133,133 @@ public class IrisRegionCompatPoolTest { assertFalse(region.evaluateCompat(gate(report)).excluded()); } + @Test + public void evaluateCompatExcludesRegionWithNoShoreBiomesLeft() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome beach = PackCompatFixtures.exclude(biome("swamp/shore/beach"), + CompatRegistry.BLOCK, "minecraft:firefly_bush"); + IrisData data = dataWith(report, plains, beach); + IrisRegion region = PackCompatFixtures.region("swamp"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setShoreBiomes(new KList<>("swamp/shore/beach")); + + CompatStatus status = region.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + assertSame(plains, region.getRealLandBiomes(() -> data).getFirst()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "region", "swamp"); + assertNotNull(cascade); + assertEquals("minecraft:firefly_bush", cascade.key()); + assertEquals("no shore biomes remain", cascade.detail()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "region", "swamp"); + assertNotNull(dropped); + assertEquals("shoreBiomes[0] swamp/shore/beach", dropped.detail()); + } + + @Test + public void evaluateCompatExcludesRegionWithNoSeaBiomesLeft() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sea = excludeBlock(biome("sea/sulfur")); + IrisData data = dataWith(report, plains, sea); + IrisRegion region = PackCompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setSeaBiomes(new KList<>("sea/sulfur")); + + assertTrue(region.evaluateCompat(gate(report)).excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "region", "overworld"); + assertNotNull(cascade); + assertEquals(MISSING_BLOCK, cascade.key()); + assertEquals("no sea biomes remain", cascade.detail()); + } + + @Test + public void evaluateCompatPreservesSurvivingSurfacePoolOrder() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sea = biome("sea/ocean"); + IrisBiome sand = biome("shore/sand"); + IrisBiome gravel = biome("shore/gravel"); + IrisBiome sulfur = excludeBlock(biome("shore/sulfur")); + IrisData data = dataWith(report, plains, sea, sand, gravel, sulfur); + IrisRegion region = PackCompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setSeaBiomes(new KList<>("shore/sulfur", "sea/ocean")); + region.setShoreBiomes(new KList<>("shore/sand", "shore/sulfur", "shore/gravel")); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + KList shores = region.getRealShoreBiomes(() -> data); + assertEquals(2, shores.size()); + assertSame(sand, shores.get(0)); + assertSame(gravel, shores.get(1)); + assertSame(sea, region.getRealSeaBiomes(() -> data).getFirst()); + } + + @Test + public void evaluateCompatAllowsOmittedSurfacePoolsAndDepletedCavePool() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = PackCompatFixtures.region("highlands"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setCaveBiomes(new KList<>("cave/sulfur")); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + assertTrue(region.getRealSeaBiomes(() -> data).isEmpty()); + assertTrue(region.getRealShoreBiomes(() -> data).isEmpty()); + assertTrue(region.getRealCaveBiomes(() -> data).isEmpty()); + } + + @Test + public void evaluateCompatDoesNotMisattributeUnresolvedShorePoolToLandDrop() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("land/sulfur")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = PackCompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains", "land/sulfur")); + region.setShoreBiomes(new KList<>("shore/missing")); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + } + + @Test + @SuppressWarnings("unchecked") + public void dimensionDropsRegionWhoseDeclaredShorePoolWasExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome beach = excludeBlock(biome("shore/sulfur")); + IrisData data = dataWith(report, plains, beach); + IrisRegion swamp = PackCompatFixtures.region("swamp"); + swamp.setLoader(data); + swamp.setLandBiomes(new KList<>("land/plains")); + swamp.setShoreBiomes(new KList<>("shore/sulfur")); + swamp.setCompat(swamp.evaluateCompat(gate(report))); + IrisRegion highlands = PackCompatFixtures.region("highlands"); + ResourceLoader regions = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regions); + when(regions.load("swamp")).thenReturn(swamp); + when(regions.load("highlands")).thenReturn(highlands); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("swamp", "highlands")); + dimension.setLoadKey("overworld"); + dimension.setLoader(data); + + assertFalse(dimension.evaluateCompat(gate(report)).excluded()); + KList surviving = dimension.getAllRegions(() -> data); + assertEquals(1, surviving.size()); + assertSame(highlands, surviving.getFirst()); + CompatFinding drop = find(report, CompatAction.DROPPED, "dimension", "overworld"); + assertNotNull(drop); + assertEquals("regions[0] swamp", drop.detail()); + } + @Test public void evaluateCompatDoesNotExcludeRegionThatNeverDeclaredLandBiomes() { PackCompatReport report = new PackCompatReport(); diff --git a/core/src/test/java/art/arcane/iris/pack/BuiltInPackUpdatesTest.java b/core/src/test/java/art/arcane/iris/pack/BuiltInPackUpdatesTest.java index ecbf628b2..e46a41222 100644 --- a/core/src/test/java/art/arcane/iris/pack/BuiltInPackUpdatesTest.java +++ b/core/src/test/java/art/arcane/iris/pack/BuiltInPackUpdatesTest.java @@ -71,7 +71,15 @@ public class BuiltInPackUpdatesTest { @Test public void boundsReleaseMetadataSize() throws Exception { - assertEquals(BuiltInPackUpdates.Update.unavailable(), checkResponse(" ".repeat(1024 * 1024 + 1))); + String release = release("4010", "overworld.zip", false); + assertEquals(BuiltInPackUpdates.Update.unavailable(), + checkResponse(release + " ".repeat(1024 * 1024 - release.length() + 1))); + } + + @Test + public void acceptsReleaseMetadataAtSizeLimit() throws Exception { + String release = release("4010", "overworld.zip", false); + assertEquals("4010", checkResponse(release + " ".repeat(1024 * 1024 - release.length())).version()); } @Test diff --git a/core/src/test/java/art/arcane/iris/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/pack/DefaultPackBootstrapProvisionerTest.java index c7abf39d4..17c569198 100644 --- a/core/src/test/java/art/arcane/iris/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/DefaultPackBootstrapProvisionerTest.java @@ -58,7 +58,7 @@ public class DefaultPackBootstrapProvisionerTest { Path dataDirectory = root.resolve("plugins/Iris"); writePack(dataDirectory.resolve("packs/overworld"), "overworld", "runtime"); BukkitStartupPaths startupPaths = BukkitStartupPaths.resolve(root, new String[0]); - for (DataVersion version : new DataVersion[]{DataVersion.V26_1_2, DataVersion.V26_2, DataVersion.V26_3}) { + for (DataVersion version : new DataVersion[]{DataVersion.V1_21_1, DataVersion.V26_1_2, DataVersion.V26_2, DataVersion.V26_3}) { DefaultPackBootstrapProvisioner.ProvisionResult result = DefaultPackBootstrapProvisioner.provision( new DefaultPackBootstrapProvisioner.BootstrapRequest(dataDirectory, ignored -> {}, startupPaths, version)); Path biomeRoot = result.datapackRoot().resolve("data/iris/worldgen/biome/biomes"); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java index c3fe97d64..bca100068 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java @@ -9,6 +9,7 @@ import static org.junit.Assert.assertSame; public class DataVersionTest { @Test public void runtimeSelectionPreservesEverySupportedVersion() { + assertSame(DataVersion.V1_21_1, DataVersion.forMinecraftVersion("1.21.1")); assertSame(DataVersion.V26_1_2, DataVersion.forMinecraftVersion("26.1.2")); assertSame(DataVersion.V26_2, DataVersion.forMinecraftVersion("26.2")); assertSame(DataVersion.V26_2, DataVersion.forMinecraftVersion("26.2.0")); @@ -24,6 +25,7 @@ public class DataVersionTest { @Test public void packFormatsMatchServerVersionJson() { + assertEquals(48, DataVersion.V1_21_1.getPackFormat()); assertEquals(101, DataVersion.V26_1_2.getPackFormat()); assertEquals(107, DataVersion.V26_2.getPackFormat()); assertEquals(121, DataVersion.V26_3.getPackFormat()); @@ -31,7 +33,7 @@ public class DataVersionTest { @Test public void minSupportedPackFormatIsTheOldestSupportedRuntime() { - assertEquals(101, DataVersion.minSupportedPackFormat()); + assertEquals(48, DataVersion.minSupportedPackFormat()); } @Test diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java index 6efb7f46a..f7e096695 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java +++ b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java @@ -142,6 +142,47 @@ public class VanillaJigsawDatapackExporterTest { assertEquals("north_up", paletteEntry.getCompoundTag("Properties").getString("orientation")); } + @Test + public void exports1211RuntimeBlockStatesAndPackFormat() throws Exception { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); + try { + TestGraph graph = connectorGraph(); + Path output = temporaryFolder.getRoot().toPath().resolve("village-1211"); + VanillaJigsawExportSettings settings = VanillaJigsawExportSettings.builder() + .maxDistanceVertical(graph.structure.getMaxSizeChunks() * 16).build(); + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).namespace("studio").resourcePath("village/test").settings(settings).build()); + assertTrue(result.diagnostics().toString(), result.isSuccess()); + JsonObject pack = json(output.resolve("pack.mcmeta")).getAsJsonObject("pack"); + assertEquals(48, pack.get("pack_format").getAsInt()); + JsonObject structure = json(output.resolve("data/studio/worldgen/structure/village/test.json")); + assertTrue(structure.get("max_distance_from_center").isJsonPrimitive()); + CompoundTag template = (CompoundTag) NBTUtil.read(output.resolve( + "data/studio/structure/village/test/piece/pieces/start.nbt").toFile()).getTag(); + assertEquals(3955, template.getInt("DataVersion")); + CompoundTag palette = (CompoundTag) template.getListTag("palette").get(0); + assertEquals("minecraft:jigsaw", palette.getString("Name")); + } finally { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("26.2"); + } + } + + @Test + public void rejects1211VanillaExportWithDifferentVerticalDistance() throws Exception { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); + try { + Path output = temporaryFolder.getRoot().toPath().resolve("unequal-1211"); + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(connectorGraph(), output).settings(VanillaJigsawExportSettings.builder() + .maxDistanceVertical(127).build()).build()); + assertFalse(result.isSuccess()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE)); + } finally { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("26.2"); + } + } + @Test public void exports26_3RuntimeBlockStatesAndPackFormat() throws Exception { when(IrisPlatforms.get().minecraftVersion()).thenReturn("26.3.0"); diff --git a/gradle.properties b/gradle.properties index 410ef6606..c92d84e13 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,6 +25,3 @@ irisVersion=4.3.3 minecraftVersion=26.3 # Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 through 26.3 apiVersion=26.1 -fabricLoaderVersion=0.19.5 -forgeVersion=26.3-66.0.8 -neoForgeVersion=26.3.0.33-beta diff --git a/gradle/mod-target.gradle b/gradle/mod-target.gradle new file mode 100644 index 000000000..bc882bbe7 --- /dev/null +++ b/gradle/mod-target.gradle @@ -0,0 +1,15 @@ +import groovy.json.JsonSlurper + +File irisDirectory = project.name.startsWith('iris-') ? file('../..') : rootDir +String targetMinecraft = providers.gradleProperty('minecraftVersion').getOrElse('26.3') +Map> targets = (Map>) new JsonSlurper() + .parse(new File(irisDirectory, 'gradle/mod-targets.json')) +Map target = targets.get(targetMinecraft) +if (target == null) { + throw new GradleException("Unsupported Iris mod target ${targetMinecraft}; choose ${targets.keySet().join(', ')}") +} +ext.irisModTarget = target +ext.irisModTargets = targets +if (project.name.startsWith('iris-')) { + layout.buildDirectory.set(layout.projectDirectory.dir("build/${targetMinecraft}")) +} diff --git a/gradle/mod-targets.json b/gradle/mod-targets.json new file mode 100644 index 000000000..7aff1b03e --- /dev/null +++ b/gradle/mod-targets.json @@ -0,0 +1,32 @@ +{ + "1.21.1": { + "java": 21, + "fabricRemap": true, + "fabricLoader": "0.19.5", + "forge": "1.21.1-52.1.20", + "neoForge": "21.1.262", + "nativeAccess": "minecraft1_21_1", + "fabricModules": { + "fabric-api-base": "0.4.42+6573ed8c19", + "fabric-registry-sync-v0": "5.3.2+e3eddc2119", + "fabric-resource-loader-v0": "1.3.1+5b5275af19", + "fabric-lifecycle-events-v1": "2.6.0+0865547519", + "fabric-command-api-v2": "2.2.28+6ced4dd919", + "fabric-events-interaction-v0": "0.7.14+ba9dae0619", + "fabric-networking-api-v1": "4.3.1+d30f6a7919", + "fabric-rendering-v1": "5.2.1+f44c2eea19", + "fabric-key-binding-api-v1": "1.0.47+0af3f5a719" + }, + "fabricPermissions": "0.3.1", + "slf4j": "2.0.9" + }, + "26.3": { + "java": 25, + "fabricRemap": false, + "fabricLoader": "0.19.5", + "forge": "26.3-66.0.8", + "neoForge": "26.3.0.33-beta", + "nativeAccess": "minecraft26_2", + "slf4j": "2.0.17" + } +} diff --git a/gradle/modded-generation-build-revision.gradle b/gradle/modded-generation-build-revision.gradle index 7b2264df9..b57ce7291 100644 --- a/gradle/modded-generation-build-revision.gradle +++ b/gradle/modded-generation-build-revision.gradle @@ -18,7 +18,8 @@ TaskProvider resolvedRevisionVerification = tasks.register('verifyReso classpath = files('../../buildSrc/build/libs/buildSrc.jar') mainClass.set('art.arcane.iris.buildtools.GenerationBuildRevision') doFirst { - List arguments = ['verify-dependencies', file('../../core/build/generated/generation-revision/META-INF/iris/generation-kernels/abi-1.revision').absolutePath] + String runtimeVariant = providers.gradleProperty('irisRuntimeJava').getOrElse(irisModTarget.java.toString()) == '21' ? 'java21/' : '' + List arguments = ['verify-dependencies', file("../../core/build/${runtimeVariant}generated/generation-revision/META-INF/iris/generation-kernels/abi-1.revision").absolutePath] for (ResolvedArtifact artifact : configurations.bundle.resolvedConfiguration.resolvedArtifacts) { String group = artifact.moduleVersion.id.group String name = artifact.moduleVersion.id.name diff --git a/gradle/modded-version-sources.gradle b/gradle/modded-version-sources.gradle new file mode 100644 index 000000000..c0ad7dba9 --- /dev/null +++ b/gradle/modded-version-sources.gradle @@ -0,0 +1,80 @@ +import groovy.json.JsonSlurper + +String sourceMinecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3') +List adapterSourceRoots = [projectDir, file('../modded-common'), file('../client-common')] + +tasks.named('compileTestJava', JavaCompile).configure { + options.release.set((int) irisModTarget.java) +} +tasks.withType(Test).configureEach { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of((int) irisModTarget.java) + }) +} + +['java', 'resources'].each { String kind -> + List overlays = adapterSourceRoots.collect { File adapter -> + new File(adapter, "src/versions/${sourceMinecraftVersion}/${kind}") + } + if (!overlays.any { File directory -> directory.isDirectory() }) { + return + } + TaskProvider prepareSources = tasks.register("prepareVersion${kind.capitalize()}", Sync) { + adapterSourceRoots.each { File adapter -> + File overlay = new File(adapter, "src/versions/${sourceMinecraftVersion}/${kind}") + from(new File(adapter, "src/main/${kind}")) { + exclude { FileTreeElement entry -> new File(overlay, entry.path).isFile() } + } + from(overlay) + } + includeEmptyDirs = false + into(layout.buildDirectory.dir("generated/sources/iris/${kind}")) + } + Set replacedRoots = adapterSourceRoots.collect { File adapter -> + new File(adapter, "src/main/${kind}").canonicalFile + }.toSet() + List retainedRoots = sourceSets.main."${kind}".srcDirs.findAll { File source -> + !replacedRoots.contains(source.canonicalFile) + }.toList() + sourceSets.main."${kind}".setSrcDirs(retainedRoots + [prepareSources]) + tasks.named('verifyLoggingPolicy').configure { dependsOn(prepareSources) } + tasks.named('verifyNativeBoundary').configure { dependsOn(prepareSources) } +} + +File testRulesFile = file("../modded-common/src/versions/${sourceMinecraftVersion}/test/source-replacements.json") +if (testRulesFile.isFile()) { + Map testRules = (Map) new JsonSlurper().parse(testRulesFile) + TaskProvider prepareTests = tasks.register('prepareVersionTests', Sync) { + inputs.file(testRulesFile) + adapterSourceRoots.each { File adapter -> + File overlay = new File(adapter, "src/versions/${sourceMinecraftVersion}/test/java") + from(new File(adapter, 'src/test/java')) { + exclude { FileTreeElement entry -> new File(overlay, entry.path).isFile() } + } + from(overlay) + } + includeEmptyDirs = false + into(layout.buildDirectory.dir('generated/sources/iris/test')) + doLast { + List> rules = (List>) (testRules.get('global') ?: []) + Map>> fileRules = (Map>>) (testRules.get('files') ?: [:]) + project.fileTree(destinationDir).matching { include('**/*.java') }.each { File source -> + String path = destinationDir.toPath().relativize(source.toPath()).toString().replace(File.separator, '/') + boolean overridden = adapterSourceRoots.any { File adapter -> + new File(adapter, "src/versions/${sourceMinecraftVersion}/test/java/${path}").isFile() + } + if (!overridden) { + String content = source.getText('UTF-8') + String rewritten = content + for (Map rule : rules + (fileRules.get(path) ?: [])) { + rewritten = rewritten.replaceAll(rule.pattern, rule.replacement) + } + if (content != rewritten) { + source.setText(rewritten, 'UTF-8') + } + } + } + } + } + sourceSets.test.java.setSrcDirs([prepareTests]) +} diff --git a/gradle/native-minecraft-sources.gradle b/gradle/native-minecraft-sources.gradle index 38ab738e0..ef0818a30 100644 --- a/gradle/native-minecraft-sources.gradle +++ b/gradle/native-minecraft-sources.gradle @@ -1,3 +1,6 @@ +import groovy.json.JsonSlurper +import java.util.zip.ZipFile + Configuration nativeMinecraftSources = configurations.create('nativeMinecraftSources') { canBeConsumed = false canBeResolved = true @@ -6,41 +9,86 @@ Configuration nativeMinecraftSources = configurations.create('nativeMinecraftSou String nativeSourceLoader = extensions.extraProperties.get('nativeSourceLoader') String nativeSourceImplementation = 'minecraft26_2' String nativeSourceMinecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3') +String versionPrefix = "versions/${nativeSourceMinecraftVersion}/" extensions.extraProperties.set('nativeSourceImplementation', nativeSourceImplementation) dependencies.add(nativeMinecraftSources.name, "com.github.VolmitSoftware.VolmLib:native-${nativeSourceImplementation}-sources:master-SNAPSHOT") +Set overrides = new HashSet<>() +Map replacements = [:] TaskProvider unpackNativeMinecraftSources = tasks.register('unpackNativeMinecraftSources', Sync) { dependsOn(nativeMinecraftSources) - from(provider { nativeMinecraftSources.collect { File artifact -> zipTree(artifact) } }) { - include('art/**', 'modded/**', "${nativeSourceLoader}/**", - 'resources/modded/**', "resources/${nativeSourceLoader}/**", "versions/${nativeSourceMinecraftVersion}/**") - eachFile { FileCopyDetails source -> - String versionPrefix = "versions/${nativeSourceMinecraftVersion}/" - if (source.path.startsWith(versionPrefix)) { - source.path = source.path.substring(versionPrefix.length()) + inputs.property('minecraftVersion', nativeSourceMinecraftVersion) + inputs.property('loader', nativeSourceLoader) + doFirst { + overrides.clear() + for (File artifact : nativeMinecraftSources) { + new ZipFile(artifact).withCloseable { ZipFile archive -> + archive.entries().each { entry -> + if (entry.name.startsWith(versionPrefix)) { + overrides.add(entry.name.substring(versionPrefix.length())) + } + } + java.util.zip.ZipEntry rules = archive.getEntry(versionPrefix + 'source-replacements.json') + if (rules != null) { + archive.getInputStream(rules).withCloseable { InputStream stream -> + replacements = (Map) new JsonSlurper().parse(stream) + } + } } } } + from(provider { nativeMinecraftSources.collect { File artifact -> zipTree(artifact) } }) { + include('art/**', 'modded/**', "${nativeSourceLoader}/**", + 'resources/modded/**', "resources/${nativeSourceLoader}/**") + eachFile { FileCopyDetails source -> + if (overrides.contains(source.path)) { + source.exclude() + } + } + } + from(provider { nativeMinecraftSources.collect { File artifact -> zipTree(artifact) } }) { + include("${versionPrefix}art/**", "${versionPrefix}modded/**", "${versionPrefix}${nativeSourceLoader}/**", + "${versionPrefix}resources/modded/**", "${versionPrefix}resources/${nativeSourceLoader}/**") + eachFile { FileCopyDetails source -> source.path = source.path.substring(versionPrefix.length()) } + } + includeEmptyDirs = false into(layout.buildDirectory.dir('generated/sources/native-minecraft')) + doLast { + List> rules = (List>) (replacements.get('global') ?: []) + Map>> fileRules = (Map>>) (replacements.get('files') ?: [:]) + project.fileTree(destinationDir).matching { include('**/*.java') }.each { File source -> + String path = destinationDir.toPath().relativize(source.toPath()).toString().replace(File.separator, '/') + if (!overrides.contains(path)) { + String content = source.getText('UTF-8') + String rewritten = content + for (Map rule : rules + (fileRules.get(path) ?: [])) { + rewritten = rewritten.replaceAll(rule.pattern, rule.replacement) + } + if (content != rewritten) { + source.setText(rewritten, 'UTF-8') + } + } + } + } } sourceSets.main.java.srcDir(unpackNativeMinecraftSources) - +tasks.named('compileJava').configure { dependsOn(unpackNativeMinecraftSources) } sourceSets.main.resources.srcDir(layout.buildDirectory.dir('generated/sources/native-minecraft/resources/modded')) sourceSets.main.resources.srcDir(layout.buildDirectory.dir("generated/sources/native-minecraft/resources/${nativeSourceLoader}")) tasks.named('processResources').configure { dependsOn(unpackNativeMinecraftSources) } - tasks.named('verifyLoggingPolicy').configure { dependsOn(unpackNativeMinecraftSources) } - +tasks.named('verifyNativeBoundary').configure { + dependsOn(unpackNativeMinecraftSources) +} tasks.named('verifyNativeClassBoundary').configure { dependsOn(tasks.named('classes')) } - tasks.matching { Task task -> task.name == 'createMinecraftArtifacts' }.configureEach { dependsOn(unpackNativeMinecraftSources) }