From 402bcb04fe95caa242361b34db5ecee94dbde3af Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 10 Jul 2026 04:05:24 -0400 Subject: [PATCH] dd --- .github/workflows/ci.yml | 65 +- .gitignore | 14 + README.md | 15 +- .../core/nms/v26_2_R1/CustomBiomeSource.java | 43 +- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 49 +- .../iris/core/nms/v26_2_R1/NMSBinding.java | 11 + .../src/main/java/art/arcane/iris/Iris.java | 8 +- .../iris/core/commands/CommandDeveloper.java | 2 +- .../iris/core/commands/CommandIris.java | 5 +- .../iris/core/commands/CommandPack.java | 144 +++- .../iris/core/commands/CommandPregen.java | 4 +- adapters/fabric/build.gradle | 10 + adapters/forge/build.gradle | 1 + .../arcane/iris/modded/InitialSpawnQueue.java | 159 +++++ .../iris/modded/IrisModdedChunkGenerator.java | 90 +++ .../arcane/iris/modded/ModdedEntityType.java | 6 + .../art/arcane/iris/modded/ModdedStartup.java | 4 +- .../arcane/iris/modded/ModdedWorldCheck.java | 89 ++- .../iris/modded/ModdedWorldManager.java | 167 ++++- .../iris/modded/NativeSpawnTableMerger.java | 34 + .../modded/command/ModdedCommandHelp.java | 3 +- .../iris/modded/command/ModdedGoldenHash.java | 4 +- .../modded/command/ModdedPackCommands.java | 155 ++++- .../modded/command/ModdedPregenMethod.java | 7 +- .../service/ModdedChunkUpdateService.java | 14 +- .../iris/modded/InitialSpawnQueueTest.java | 143 ++++ .../IrisModdedChunkGeneratorSpawnTest.java | 47 ++ .../iris/modded/ModdedWorldCheckTest.java | 87 +++ .../service/ModdedChunkUpdateServiceTest.java | 23 + adapters/neoforge/build.gradle | 1 + build-all.sh | 2 +- build.gradle | 187 ++---- .../arcane/iris/core/ServerConfigurator.java | 1 + .../art/arcane/iris/core/nms/INMSBinding.java | 4 +- .../nms/datapack/v1217/DataFixerV1217.java | 4 + .../iris/core/nms/v1X/NMSBinding1X.java | 5 + .../iris/core/pack/PackDirectoryResolver.java | 26 + .../arcane/iris/core/pack/PackDownloader.java | 63 +- .../iris/core/pack/PackResourceCleanup.java | 620 ++++++++++++++++++ .../iris/core/pack/PackValidationResult.java | 7 - .../arcane/iris/core/pack/PackValidator.java | 521 ++++++++++----- .../core/pregenerator/IrisPregenerator.java | 27 +- .../arcane/iris/core/project/IrisProject.java | 11 +- .../arcane/iris/core/service/StudioSVC.java | 13 +- .../arcane/iris/core/tools/IrisCreator.java | 8 +- .../arcane/iris/core/tools/IrisToolbelt.java | 44 +- .../iris/core/tools/IrisWorldCreator.java | 12 +- .../art/arcane/iris/engine/IrisComplex.java | 11 +- .../arcane/iris/engine/IrisWorldManager.java | 335 +++++++--- .../engine/actuator/IrisBiomeActuator.java | 4 +- .../engine/actuator/IrisDecorantActuator.java | 4 +- .../decorator/IrisCeilingDecorator.java | 8 +- .../decorator/IrisSurfaceDecorator.java | 20 +- .../arcane/iris/engine/framework/Engine.java | 19 +- .../framework/StructurePlacementMarker.java | 95 +++ .../mantle/components/IrisCaveCarver3D.java | 52 +- .../components/IrisStructureComponent.java | 36 +- .../components/MantleCarvingComponent.java | 2 +- .../engine/modifier/IrisCarveModifier.java | 7 +- .../IrisFloatingChildBiomeModifier.java | 4 +- .../arcane/iris/engine/object/IrisBiome.java | 94 ++- .../iris/engine/object/IrisBiomeCustom.java | 4 + .../engine/object/IrisBiomeCustomSpawn.java | 10 +- .../object/IrisBiomeCustomSpawnType.java | 3 + .../iris/engine/object/IrisDimension.java | 103 +++ .../iris/engine/object/IrisStructure.java | 15 + .../engine/object/IrisStructurePlacement.java | 9 - .../platform/bukkit/BukkitEntityType.java | 8 + .../arcane/iris/util/common/scheduling/J.java | 35 + .../v1217/DataFixerV1217ClockTest.java | 42 ++ .../core/pack/PackDirectoryResolverTest.java | 58 ++ .../iris/core/pack/PackDownloaderTest.java | 67 ++ .../core/pack/PackResourceCleanupTest.java | 250 +++++++ .../PackValidatorCustomBiomeSpawnTest.java | 179 +++++ .../core/pack/PackValidatorReadOnlyTest.java | 59 ++ .../pack/PackValidatorSpawnerEntityTest.java | 122 ++++ .../PackValidatorStructureTransformTest.java | 64 ++ .../IrisPregeneratorInitTest.java | 80 +++ .../IrisProjectEntityDependencyTest.java | 33 + .../core/project/SchemaBuilderParityTest.java | 28 + .../tools/IrisToolbeltPackReferenceTest.java | 37 ++ .../IrisComplexGridBoundsCacheTest.java | 126 ++++ .../IrisBiomeActuatorCoordinateTest.java | 101 +++ .../IrisDecoratorCaveContextTest.java | 70 ++ .../StructurePlacementMarkerTest.java | 47 ++ .../IrisCaveCarver3DNearParityTest.java | 43 ++ .../IrisStructureComponentMarkerTest.java | 35 + ...tleCarvingComponentBoundaryRadiusTest.java | 135 ++++ ...isCarveModifierInferenceIsolationTest.java | 124 ++++ .../object/IrisBiomeCustomSpawnTest.java | 29 + .../IrisBiomeGeneratorSeedIsolationTest.java | 139 ++++ .../object/IrisDimensionBiomeTagTest.java | 49 ++ .../IrisStructureLootPlacementTest.java | 38 ++ out/production/resources/plugin.yml | 21 - probe/build.gradle | 5 + .../art/arcane/iris/probe/ClassloadProbe.java | 204 ++++-- .../main/resources/classload-allowlist.tsv | 83 +++ .../arcane/iris/probe/ClassloadProbeTest.java | 204 ++++++ .../arcane/iris/spi/PlatformEntityType.java | 2 + tools/simd-bench/README.md | 3 + tools/simd-bench/build.bat | 14 + tools/simd-bench/build.sh | 10 + tools/simd-bench/out/simdbench/Bench.class | Bin 12112 -> 0 bytes .../out/simdbench/NoiseKernels2D.class | Bin 208 -> 0 bytes .../out/simdbench/ScalarNoiseKernels2D.class | Bin 2168 -> 0 bytes .../out/simdbench/ScalarSimdKernels.class | Bin 770 -> 0 bytes .../out/simdbench/SimdKernels.class | Bin 224 -> 0 bytes .../out/simdbench/VectorNoiseKernels2D.class | Bin 7762 -> 0 bytes .../out/simdbench/VectorSimdKernels.class | Bin 3964 -> 0 bytes tools/simd-bench/run.bat | 2 + tools/simd-bench/run.sh | 3 + tools/simd-bench/simd-bench.jar | Bin 15234 -> 0 bytes 112 files changed, 5610 insertions(+), 733 deletions(-) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/NativeSpawnTableMerger.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackResourceCleanup.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementMarker.java create mode 100644 core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217ClockTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackResourceCleanupTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorCustomBiomeSpawnTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorReadOnlyTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorSpawnerEntityTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTransformTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/IrisProjectEntityDependencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementMarkerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeGeneratorSeedIsolationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisStructureLootPlacementTest.java delete mode 100644 out/production/resources/plugin.yml create mode 100644 probe/src/main/resources/classload-allowlist.tsv create mode 100644 probe/src/test/java/art/arcane/iris/probe/ClassloadProbeTest.java create mode 100644 tools/simd-bench/build.bat create mode 100755 tools/simd-bench/build.sh delete mode 100644 tools/simd-bench/out/simdbench/Bench.class delete mode 100644 tools/simd-bench/out/simdbench/NoiseKernels2D.class delete mode 100644 tools/simd-bench/out/simdbench/ScalarNoiseKernels2D.class delete mode 100644 tools/simd-bench/out/simdbench/ScalarSimdKernels.class delete mode 100644 tools/simd-bench/out/simdbench/SimdKernels.class delete mode 100644 tools/simd-bench/out/simdbench/VectorNoiseKernels2D.class delete mode 100644 tools/simd-bench/out/simdbench/VectorSimdKernels.class delete mode 100644 tools/simd-bench/simd-bench.jar diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b82b8079..2af7ab541 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,65 +26,6 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - name: Run verification gates - run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace - - build-artifacts: - name: Build ${{ matrix.platform }} - needs: verify - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - platform: bukkit - task: buildBukkit - - platform: fabric - task: buildFabric - - platform: forge - task: buildForge - - platform: neoforge - task: buildNeoforge - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up JDK 25 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '25' - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Build ${{ matrix.platform }} artifact - run: ./gradlew ${{ matrix.task }} -PuseLocalVolmLib=false --console=plain --stacktrace - - name: Upload ${{ matrix.platform }} jar - uses: actions/upload-artifact@v4 - with: - name: iris-${{ matrix.platform }} - path: dist/*.jar - retention-days: 14 - if-no-files-found: error - - release-bundle: - name: Release bundle (buildAll) - if: startsWith(github.ref, 'refs/tags/v') - needs: [verify, build-artifacts] - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up JDK 25 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '25' - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Build all platforms - run: ./gradlew buildAll --no-parallel -PuseLocalVolmLib=false --console=plain --stacktrace - - name: Upload release bundle - uses: actions/upload-artifact@v4 - with: - name: iris-release-bundle - path: dist/*.jar - retention-days: 90 - if-no-files-found: error + run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:test :probe:run :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace + - name: Run modded shared tests + run: ./gradlew -p adapters/fabric test -PuseLocalVolmLib=false --console=plain --stacktrace diff --git a/.gitignore b/.gitignore index 6358c0349..d18318624 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ collection/ out/ tools/simd-bench/out/ +tools/simd-bench/simd-bench.jar DataPackExamples/ @@ -25,4 +26,17 @@ adapters/*/run/ plans/ +.env +.env.* +!.env.example +local.properties +*.jks +*.p12 +*.pfx +*.pem +*.key +*.hprof +credentials.json +service-account*.json + docs/ diff --git a/README.md b/README.md index 80cc6fab9..31f0c3300 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,16 @@ Mod (positional arguments): /iris create myworld overworld 1337 ``` -`/iris pregen start` pregenerates around spawn; players with the client mod see the native HUD, -everyone else gets a boss bar (modded) or console/status output. `/iris pregen status` reports -progress on the plugin. +Pregeneration requires a radius in blocks. On the plugin, optional arguments are keyed; on modded +servers they are positional and composable: + +``` +/iris pregen start 352 world=myworld center=0,0 gui=false +/iris pregen start 352 irisworldgen:myworld at 0 0 sync +``` + +Players with the client mod see the native HUD; everyone else gets a boss bar (modded) or +console/status output. `/iris pregen status` reports progress on the plugin. ## Studio and VSCode workspace @@ -104,7 +111,7 @@ files hotloads the changes and regenerates the schemas. Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else. ``` -./gradlew buildAll +./gradlew buildAllToOut ``` builds every platform artifact into `dist/`: diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 23f395d23..161217e07 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -25,6 +25,9 @@ import org.bukkit.craftbukkit.CraftWorld; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -40,6 +43,7 @@ public class CustomBiomeSource extends BiomeSource { private final Registry biomeRegistry; private final AtomicCache registryAccess = new AtomicCache<>(); private final KMap> customBiomes; + private final Map> vanillaSpawnBiomes; private final Holder fallbackBiome; private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); @@ -51,6 +55,7 @@ public class CustomBiomeSource extends BiomeSource { this.biomeRegistry = ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())).lookup(Registries.BIOME).orElse(null); this.fallbackBiome = resolveFallbackBiome(this.biomeRegistry, this.biomeCustomRegistry); this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome); + this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine); } private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine, Holder fallback) { @@ -163,6 +168,38 @@ public class CustomBiomeSource extends BiomeSource { return m; } + private Map> fillVanillaSpawnBiomes(Registry customRegistry, Registry registry, Engine engine) { + IdentityHashMap> spawnBiomes = new IdentityHashMap<>(); + if (customRegistry == null || registry == null) { + return Collections.unmodifiableMap(spawnBiomes); + } + + for (IrisBiome irisBiome : engine.getAllBiomes()) { + if (!irisBiome.isCustom()) { + continue; + } + Holder vanillaHolder = NMSBinding.biomeToBiomeBase(registry, irisBiome.getVanillaDerivative()); + if (vanillaHolder == null) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + Holder customHolder = resolveCustomBiomeHolder(customRegistry, engine, customBiome.getId()); + if (customHolder != null) { + spawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + } + } + } + + return Collections.unmodifiableMap(spawnBiomes); + } + + Holder getVanillaSpawnBiome(Holder biome) { + if (biome == null) { + return null; + } + return vanillaSpawnBiomes.get(biome.value()); + } + private RegistryAccess registry() { return registryAccess.aquire(() -> (RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())); } @@ -242,8 +279,8 @@ public class CustomBiomeSource extends BiomeSource { } org.bukkit.block.Biome vanillaBiome = resolution.underground - ? resolution.irisBiome.getGroundBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ) - : resolution.irisBiome.getSkyBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ); + ? resolution.irisBiome.getGroundBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ) + : resolution.irisBiome.getSkyBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ); Holder holder = NMSBinding.biomeToBiomeBase(biomeRegistry, vanillaBiome); if (holder != null) { return holder; @@ -253,7 +290,7 @@ public class CustomBiomeSource extends BiomeSource { } private Holder resolveCustomHolder(BiomeResolution resolution) { - IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ); + IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ); if (customBiome != null) { Holder holder = customBiomes.get(customBiome.getId()); if (holder != null) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 7214c4385..3fe3adcdd 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -25,6 +25,7 @@ import net.minecraft.world.level.levelgen.WorldgenRandom; import net.minecraft.world.level.levelgen.XoroshiroRandomSource; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -40,7 +41,9 @@ import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.util.random.Weighted; import net.minecraft.util.random.WeightedList; +import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; @@ -68,6 +71,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; public class IrisChunkGenerator extends CustomChunkGenerator { private static final WrappedField BIOME_SOURCE; @@ -75,6 +79,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private final ChunkGenerator delegate; private final Engine engine; private final CustomBiomeSource customBiomeSource; + private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); private volatile Set reachableStructureKeysCache; public IrisChunkGenerator(ChunkGenerator delegate, long seed, Engine engine, World world) { @@ -225,7 +230,28 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public WeightedList getMobsAt(Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { - return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition); + Holder vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder); + if (vanillaSpawnBiome == null) { + return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition); + } + + WeightedList vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(enumcreaturetype); + WeightedList resolvedSpawns = delegate.getMobsAt( + vanillaSpawnBiome, structuremanager, enumcreaturetype, blockposition); + if (resolvedSpawns != vanillaSpawns) { + return resolvedSpawns; + } + + WeightedList explicitSpawns = holder.value().getMobSettings().getMobs(enumcreaturetype); + if (explicitSpawns.isEmpty()) { + return vanillaSpawns; + } + if (vanillaSpawns.isEmpty()) { + return explicitSpawns; + } + + SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype); + return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns)); } @Override @@ -352,6 +378,24 @@ public class IrisChunkGenerator extends CustomChunkGenerator { delegate.spawnOriginalMobs(regionlimitedworldaccess); } + private static WeightedList mergeSpawnTables( + WeightedList vanillaSpawns, + WeightedList explicitSpawns) { + List> entries = new ArrayList<>( + vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size()); + Set> explicitTypes = new HashSet<>(); + for (Weighted entry : explicitSpawns.unwrap()) { + explicitTypes.add(entry.value().type()); + } + for (Weighted entry : vanillaSpawns.unwrap()) { + if (!explicitTypes.contains(entry.value().type())) { + entries.add(entry); + } + } + entries.addAll(explicitSpawns.unwrap()); + return WeightedList.of(entries); + } + @Override public int getSpawnHeight(LevelHeightAccessor levelheightaccessor) { return delegate.getSpawnHeight(levelheightaccessor); @@ -428,4 +472,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { throw new RuntimeException(e); } } + + private record SpawnTableKey(Biome biome, MobCategory category) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index 65b49c71a..f4195c3fb 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -52,6 +52,7 @@ import net.minecraft.core.IdMapper; import net.minecraft.core.RegistryAccess; import net.minecraft.core.Registry; import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.nbt.ByteTag; import net.minecraft.nbt.CollectionTag; @@ -212,6 +213,16 @@ public class NMSBinding implements INMSBinding { return !CraftBlockState.class.equals(CraftBlockStates.getBlockStateType(material)); } + @Override + public String getEntitySpawnCategory(String key) { + Identifier identifier = Identifier.parse(key); + if (!BuiltInRegistries.ENTITY_TYPE.containsKey(identifier)) { + throw new IllegalArgumentException("Unknown native entity type: " + key); + } + EntityType type = BuiltInRegistries.ENTITY_TYPE.getValue(identifier); + return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT); + } + @Override public boolean hasTile(Location l) { return ((CraftWorld) l.getWorld()).getHandle().getBlockEntity(new BlockPos(l.getBlockX(), l.getBlockY(), l.getBlockZ())) != null; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 959a9b114..362d219f8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -1221,7 +1221,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { if (!ff.exists()) { ff.mkdirs(); - service(StudioSVC.class).installIntoWorld(getSender(), dim.getLoadKey(), w.worldFolder()); + dim = service(StudioSVC.class).installIntoWorld(getSender(), dim, w.worldFolder()); + if (dim == null) { + throw new IllegalStateException("Failed to install dimension pack for " + id); + } } return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey()); @@ -1243,9 +1246,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { for (String reason : result.getBlockingErrors()) { Iris.error(" - " + reason); } - } else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) { + } else if (!result.getWarnings().isEmpty()) { Iris.info("Pack '" + result.getPackName() + "' validated (" - + result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, " + result.getWarnings().size() + " warning(s))."); for (String warning : result.getWarnings()) { Iris.warn(" [" + result.getPackName() + "] " + warning); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index b055728ea..79509a722 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -218,7 +218,7 @@ public class CommandDeveloper implements DirectorExecutor { Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true); } - Iris.service(StudioSVC.class).installIntoWorld(sender(), pack.getLoadKey(), folder); + Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder); } @Director(description = "Test") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 5991ea535..be55651ac 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -135,7 +135,7 @@ public class CommandIris implements DirectorExecutor { try { worldCreation = true; IrisToolbelt.createWorld() - .dimension(dimension.getLoadKey()) + .dimension(resolvedType) .name(name) .seed(seed) .sender(sender()) @@ -199,7 +199,7 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.YELLOW + "Preparing world files and bukkit.yml for next startup..."); File worldFolder = new File(Bukkit.getWorldContainer(), name); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension.getLoadKey(), worldFolder); + IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder); if (installed == null) { sender().sendMessage(C.RED + "Failed to stage world files for dimension \"" + dimension.getLoadKey() + "\"."); return false; @@ -587,6 +587,7 @@ public class CommandIris implements DirectorExecutor { IrisData data = IrisData.get(pack); for (String key : data.getDimensionLoader().getPossibleKeys()) { options.add(key); + options.add(pack.getName() + ":" + key); } } catch (Throwable ex) { Iris.warn("Failed to read dimension keys from pack %s: %s%s", diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java index fedf7b75a..8f10b576d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java @@ -19,6 +19,8 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; +import art.arcane.iris.core.pack.PackDirectoryResolver; +import art.arcane.iris.core.pack.PackResourceCleanup; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; @@ -29,6 +31,7 @@ import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import java.io.File; +import java.util.List; @Director(name = "pack", aliases = {"pk"}, description = "Pack validation and maintenance") public class CommandPack implements DirectorExecutor { @@ -61,36 +64,115 @@ public class CommandPack implements DirectorExecutor { return; } - File target = new File(packsRoot, pack); - if (!target.isDirectory()) { + File target = PackDirectoryResolver.resolveExisting(packsRoot, pack); + if (target == null) { s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/."); return; } runValidate(s, target); } - @Director(description = "Restore most recent trashed files for a pack", aliases = {"r"}) - public void restore( - @Param(description = "The pack folder name to restore") - String pack + @Director(description = "Preview or apply unused-resource cleanup", aliases = {"c"}) + public void cleanup( + @Param(description = "The pack folder name to clean") + String pack, + @Param(description = "preview or apply", defaultValue = "preview") + String mode ) { VolmitSender s = sender(); - if (pack == null || pack.isBlank()) { - s.sendMessage(C.RED + "You must specify a pack name."); + File packFolder = findPack(s, pack); + if (packFolder == null) { return; } - File packFolder = new File(Iris.instance.getDataFolder("packs"), pack); - if (!packFolder.isDirectory()) { - s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/."); + if ("apply".equalsIgnoreCase(mode)) { + PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder); + if (!result.success()) { + s.sendMessage(C.RED + result.error()); + reportPaths(s, result.quarantinedPaths(), "still quarantined"); + return; + } + if (!result.changed()) { + s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'."); + return; + } + s.sendMessage(C.GREEN + "Quarantined " + result.quarantinedPaths().size() + + " cleanup candidate(s) under " + result.quarantinePath() + "."); + reportPaths(s, result.quarantinedPaths(), "quarantined"); return; } - int restored = PackValidator.restoreTrash(packFolder); - if (restored == 0) { + if (!"preview".equalsIgnoreCase(mode)) { + s.sendMessage(C.RED + "Cleanup mode must be preview or apply."); + return; + } + PackResourceCleanup.Preview preview = PackResourceCleanup.preview(packFolder); + if (!preview.success()) { + s.sendMessage(C.RED + preview.error()); + return; + } + if (!preview.hasCandidates()) { + s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'."); + return; + } + s.sendMessage(C.YELLOW + "Cleanup preview for pack '" + pack + "': " + + preview.candidatePaths().size() + " candidate(s). No files were changed."); + reportPaths(s, preview.candidatePaths(), "candidate"); + s.sendMessage(C.GRAY + "Run /iris pack cleanup " + pack + " mode=apply to quarantine these candidates after a fresh scan."); + } + + @Director(description = "Preview or apply restoration of the latest quarantine", aliases = {"r"}) + public void restore( + @Param(description = "The pack folder name to restore") + String pack, + @Param(description = "preview or apply", defaultValue = "preview") + String mode + ) { + VolmitSender s = sender(); + File packFolder = findPack(s, pack); + if (packFolder == null) { + return; + } + if ("apply".equalsIgnoreCase(mode)) { + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder); + if (!result.conflicts().isEmpty()) { + s.sendMessage(C.RED + "Restore refused because " + result.conflicts().size() + " destination(s) already exist."); + reportPaths(s, result.conflicts(), "conflict"); + return; + } + if (!result.success()) { + s.sendMessage(C.RED + result.error()); + return; + } + if (!result.changed()) { + s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'."); + return; + } + s.sendMessage(C.GREEN + "Restored " + result.restoredPaths().size() + + " file(s) from " + result.dumpPath() + "."); + reportPaths(s, result.restoredPaths(), "restored"); + return; + } + if (!"preview".equalsIgnoreCase(mode)) { + s.sendMessage(C.RED + "Restore mode must be preview or apply."); + return; + } + PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(packFolder); + if (!preview.success()) { + s.sendMessage(C.RED + preview.error()); + return; + } + if (!preview.hasFiles()) { s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'."); return; } - s.sendMessage(C.GREEN + "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'."); - s.sendMessage(C.GRAY + "Re-run /iris pack validate " + pack + " to re-check."); + s.sendMessage(C.YELLOW + "Restore preview for " + preview.dumpPath() + ": " + + preview.filePaths().size() + " file(s). No files were changed."); + reportPaths(s, preview.filePaths(), "file"); + if (!preview.conflicts().isEmpty()) { + s.sendMessage(C.RED + "Restore is blocked by " + preview.conflicts().size() + " existing destination(s)."); + reportPaths(s, preview.conflicts(), "conflict"); + return; + } + s.sendMessage(C.GRAY + "Run /iris pack restore " + pack + " mode=apply to restore after a fresh conflict check."); } @Director(description = "Show cached validation status for a pack", aliases = {"s"}) @@ -108,8 +190,7 @@ public class CommandPack implements DirectorExecutor { String tag = result.isLoadable() ? (C.GREEN + "OK") : (C.RED + "BROKEN"); s.sendMessage(tag + C.RESET + " " + name + C.GRAY + " (blocking=" + result.getBlockingErrors().size() - + ", warnings=" + result.getWarnings().size() - + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + + ", warnings=" + result.getWarnings().size() + ")"); }); return; } @@ -137,8 +218,7 @@ public class CommandPack implements DirectorExecutor { private void reportResult(VolmitSender s, PackValidationResult result) { if (result.isLoadable()) { s.sendMessage(C.GREEN + "Pack '" + result.getPackName() + "' is loadable." - + C.GRAY + " (warnings=" + result.getWarnings().size() - + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + + C.GRAY + " (warnings=" + result.getWarnings().size() + ")"); } else { s.sendMessage(C.RED + "Pack '" + result.getPackName() + "' is BROKEN:"); for (String reason : result.getBlockingErrors()) { @@ -152,12 +232,28 @@ public class CommandPack implements DirectorExecutor { if (result.getWarnings().size() > wMax) { s.sendMessage(C.GRAY + " ... and " + (result.getWarnings().size() - wMax) + " more warning(s)."); } - int tMax = Math.min(10, result.getRemovedUnusedFiles().size()); - for (int i = 0; i < tMax; i++) { - s.sendMessage(C.GRAY + " ~ trashed " + result.getRemovedUnusedFiles().get(i)); + } + + private File findPack(VolmitSender sender, String pack) { + if (pack == null || pack.isBlank()) { + sender.sendMessage(C.RED + "You must specify a pack name."); + return null; } - if (result.getRemovedUnusedFiles().size() > tMax) { - s.sendMessage(C.GRAY + " ... and " + (result.getRemovedUnusedFiles().size() - tMax) + " more trashed file(s)."); + File packFolder = PackDirectoryResolver.resolveExisting(Iris.instance.getDataFolder("packs"), pack); + if (packFolder == null) { + sender.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/."); + return null; + } + return packFolder; + } + + private void reportPaths(VolmitSender sender, List paths, String label) { + int max = Math.min(10, paths.size()); + for (int i = 0; i < max; i++) { + sender.sendMessage(C.GRAY + " - " + label + ": " + paths.get(i)); + } + if (paths.size() > max) { + sender.sendMessage(C.GRAY + " ... and " + (paths.size() - max) + " more."); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java index 071c0ed41..9e3b9f6ce 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java @@ -85,7 +85,9 @@ public class CommandPregen implements DirectorExecutor { @Director(description = "Stop the active pregeneration task", aliases = "x") public void stop() { if (PregeneratorJob.shutdownInstance()) { - Iris.info( C.BLUE + "Finishing up mca region..."); + String message = C.BLUE + "Pregen stop requested; finishing active work before cancellation."; + sender().sendMessage(message); + Iris.info(message); } else { sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop"); } diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 66efdacd2..9d414f466 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -55,6 +55,11 @@ sourceSets { srcDir '../modded-common/src/main/resources' } } + test { + java { + srcDir '../modded-common/src/test/java' + } + } } repositories { @@ -102,6 +107,7 @@ configurations.create('jij') { dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") + testImplementation('junit:junit:4.13.2') List fabricApi = [ libs.fabricApi.base, libs.fabricApi.registrySync, @@ -199,6 +205,10 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.RSA') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + relocate('com.sun.jna', 'art.arcane.iris.shadow.jna') + relocate('oshi', 'art.arcane.iris.shadow.oshi') + relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz') exclude('oshi.properties') from(project.configurations.named('jij')) { into('META-INF/jars') diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index f28d3a62f..e088d28d9 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -227,6 +227,7 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('org/apache/commons/lang/enum/**') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('io.sentry', 'art.arcane.iris.shadow.sentry') relocate('com.sun.jna', 'art.arcane.iris.shadow.jna') relocate('oshi', 'art.arcane.iris.shadow.oshi') relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz') diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java new file mode 100644 index 000000000..16ebff113 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java @@ -0,0 +1,159 @@ +/* + * 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 java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +final class InitialSpawnQueue { + private static final long DEFAULT_MAX_AGE_NANOS = TimeUnit.MINUTES.toNanos(5); + + private final int capacity; + private final long maxAgeNanos; + private final LongSupplier nanoTime; + private final ArrayDeque queue; + private final Map pending; + private final Set queued; + private boolean closed; + + InitialSpawnQueue(int capacity) { + this(capacity, DEFAULT_MAX_AGE_NANOS, System::nanoTime); + } + + InitialSpawnQueue(int capacity, long maxAgeNanos, LongSupplier nanoTime) { + if (capacity < 1) { + throw new IllegalArgumentException("capacity must be positive"); + } + if (maxAgeNanos < 1L) { + throw new IllegalArgumentException("maxAgeNanos must be positive"); + } + this.capacity = capacity; + this.maxAgeNanos = maxAgeNanos; + this.nanoTime = nanoTime; + this.queue = new ArrayDeque<>(Math.min(capacity, 256)); + this.pending = new HashMap<>(); + this.queued = new HashSet<>(); + } + + synchronized boolean offer(long key) { + if (closed || pending.containsKey(key)) { + return false; + } + if (pending.size() >= capacity) { + expire(nanoTime.getAsLong()); + } + if (pending.size() >= capacity) { + return false; + } + pending.put(key, nanoTime.getAsLong()); + queued.add(key); + queue.addLast(key); + return true; + } + + synchronized int batchSize(int limit) { + if (closed || limit <= 0) { + return 0; + } + return Math.min(limit, queue.size()); + } + + synchronized Long poll() { + long now = nanoTime.getAsLong(); + Long key; + while ((key = queue.pollFirst()) != null) { + queued.remove(key); + Long offeredAt = pending.get(key); + if (offeredAt == null) { + continue; + } + if (expired(offeredAt, now)) { + pending.remove(key); + continue; + } + return key; + } + return null; + } + + synchronized void retry(long key) { + Long offeredAt = pending.get(key); + if (closed || offeredAt == null || expired(offeredAt, nanoTime.getAsLong())) { + pending.remove(key); + queued.remove(key); + return; + } + if (!queued.add(key)) { + return; + } + queue.addLast(key); + } + + synchronized void complete(long key) { + pending.remove(key); + if (queued.remove(key)) { + queue.removeFirstOccurrence(key); + } + } + + synchronized boolean isEmpty() { + return queue.isEmpty(); + } + + synchronized int size() { + return pending.size(); + } + + synchronized void clear() { + queue.clear(); + pending.clear(); + queued.clear(); + } + + synchronized void close() { + closed = true; + clear(); + } + + private void expire(long now) { + Set expired = new HashSet<>(); + for (Map.Entry entry : pending.entrySet()) { + if (expired(entry.getValue(), now)) { + expired.add(entry.getKey()); + } + } + if (expired.isEmpty()) { + return; + } + for (Long key : expired) { + pending.remove(key); + queued.remove(key); + } + queue.removeIf(expired::contains); + } + + private boolean expired(long offeredAt, long now) { + return now - offeredAt >= maxAgeNanos; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 18665c20f..a4d35f9df 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -20,6 +20,8 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -38,6 +40,8 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.util.random.WeightedList; +import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.LevelHeightAccessor; @@ -49,6 +53,7 @@ import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.BiomeResolver; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.Climate; +import net.minecraft.world.level.biome.MobSpawnSettings; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; @@ -65,6 +70,7 @@ import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.EnumSet; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -136,8 +142,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final String defaultPack; private final String defaultDimensionKey; private final ConcurrentHashMap> biomeHolders = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); private final Set missingBiomeWarnings = ConcurrentHashMap.newKeySet(); private final AtomicBoolean announced = new AtomicBoolean(false); + private volatile boolean vanillaSpawnBiomesInitialized; private volatile Engine engine; private volatile String activePack; private volatile String activeDimensionKey; @@ -166,6 +175,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.announced.set(false); this.biomeHolders.clear(); this.missingBiomeWarnings.clear(); + resetVanillaSpawnBiomes(); } public synchronized void unbindEngine() { @@ -177,6 +187,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.announced.set(false); this.biomeHolders.clear(); this.missingBiomeWarnings.clear(); + resetVanillaSpawnBiomes(); } public synchronized void resetToDefault() { @@ -259,6 +270,82 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public void onHotload() { biomeHolders.clear(); missingBiomeWarnings.clear(); + resetVanillaSpawnBiomes(); + } + + @Override + public WeightedList getMobsAt( + Holder biome, StructureManager structureManager, MobCategory category, BlockPos pos) { + WeightedList explicitSpawns = biome.value().getMobSettings().getMobs(category); + WeightedList resolvedSpawns = super.getMobsAt( + biome, structureManager, category, pos); + if (resolvedSpawns != explicitSpawns) { + return resolvedSpawns; + } + + Registry registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); + initializeVanillaSpawnBiomes(registry); + Holder vanillaSpawnBiome = vanillaSpawnBiomes.get(biome.value()); + if (vanillaSpawnBiome == null) { + return explicitSpawns; + } + + WeightedList vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(category); + if (explicitSpawns.isEmpty()) { + return vanillaSpawns; + } + if (vanillaSpawns.isEmpty()) { + return explicitSpawns; + } + + SpawnTableKey key = new SpawnTableKey(biome.value(), category); + return mergedSpawnTables.computeIfAbsent(key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns)); + } + + private synchronized void initializeVanillaSpawnBiomes(Registry registry) { + if (vanillaSpawnBiomesInitialized) { + return; + } + Engine current = engineOrNull(); + if (current == null) { + return; + } + + String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : current.getDimension().getAllBiomes(current)) { + if (irisBiome == null || !irisBiome.isCustom()) { + continue; + } + Holder vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey()); + if (vanillaHolder == null) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + Holder customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId()); + if (customHolder != null) { + vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + } + } + } + vanillaSpawnBiomesInitialized = true; + } + + private Holder resolveBiomeHolder(Registry registry, String key) { + if (key == null || key.isBlank()) { + return null; + } + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + return null; + } + Optional> reference = registry.get(identifier); + return reference.>map((Holder.Reference value) -> value).orElse(null); + } + + private synchronized void resetVanillaSpawnBiomes() { + vanillaSpawnBiomes.clear(); + mergedSpawnTables.clear(); + vanillaSpawnBiomesInitialized = false; } @Override @@ -491,4 +578,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public void addDebugScreenInfo(List info, RandomState randomState, BlockPos pos) { info.add("Iris dimension: " + dimensionKey); } + + private record SpawnTableKey(Biome biome, MobCategory category) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityType.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityType.java index f8629cbd7..7d4c28b35 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityType.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityType.java @@ -21,6 +21,7 @@ package art.arcane.iris.modded; import art.arcane.iris.spi.PlatformEntityType; import net.minecraft.world.entity.EntityType; +import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; public final class ModdedEntityType implements PlatformEntityType { @@ -51,6 +52,11 @@ public final class ModdedEntityType implements PlatformEntityType { return namespace; } + @Override + public String spawnCategory() { + return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT); + } + @Override public Object nativeHandle() { return type; 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 67bacc8c8..7d7b2d433 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 @@ -88,8 +88,8 @@ public final class ModdedStartup { for (String reason : result.getBlockingErrors()) { LOGGER.error(" - {}", reason); } - } else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) { - LOGGER.info("Iris pack '{}' validated ({} unused file(s) quarantined to .iris-trash/, {} warning(s)).", result.getPackName(), result.getRemovedUnusedFiles().size(), result.getWarnings().size()); + } else if (!result.getWarnings().isEmpty()) { + LOGGER.info("Iris pack '{}' validated ({} warning(s)).", result.getPackName(), result.getWarnings().size()); for (String warning : result.getWarnings()) { LOGGER.warn(" [{}] {}", result.getPackName(), warning); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java index 92e63653b..7487f2bba 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java @@ -40,49 +40,85 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedWorldCheck { + private static final int EXIT_PASS = 0; + private static final int EXIT_FAILURE = 1; + private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L; + private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L; private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit; private ModdedWorldCheck() { } public static void schedule() { - Thread thread = new Thread(ModdedWorldCheck::waitAndRun, "Iris World Check"); - thread.setDaemon(true); - thread.start(); + coordinatorThread(() -> waitAndRun(PROCESS_EXIT)).start(); } - private static void waitAndRun() { + static Thread coordinatorThread(Runnable coordinator) { + Thread thread = new Thread(coordinator, "Iris World Check"); + thread.setDaemon(false); + return thread; + } + + private static void waitAndRun(ProcessExit processExit) { long start = System.currentTimeMillis(); MinecraftServer server = null; - while (System.currentTimeMillis() - start < 600000L) { - MinecraftServer candidate = ModdedEngineBootstrap.currentServer(); - if (candidate != null && candidate.isReady()) { - server = candidate; - break; + boolean ready = false; + int exitCode = EXIT_FAILURE; + try { + while (System.currentTimeMillis() - start < SERVER_WAIT_TIMEOUT_MILLIS) { + MinecraftServer candidate = ModdedEngineBootstrap.currentServer(); + if (candidate != null) { + server = candidate; + if (candidate.isReady()) { + ready = true; + break; + } + } + Thread.sleep(SERVER_WAIT_INTERVAL_MILLIS); } - try { - Thread.sleep(250L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + + if (!ready) { + LOGGER.error("[worldcheck] server did not become ready within 10 minutes"); return; } - } - if (server == null) { - LOGGER.error("[worldcheck] server did not become ready within 10 minutes"); - return; - } - - MinecraftServer serverRef = server; - AtomicBoolean pass = new AtomicBoolean(false); - try { + MinecraftServer serverRef = server; + AtomicBoolean pass = new AtomicBoolean(false); serverRef.submit(() -> pass.set(run(serverRef))).join(); + exitCode = pass.get() ? EXIT_PASS : EXIT_FAILURE; + } catch (InterruptedException e) { + LOGGER.error("[worldcheck] coordinator interrupted", e); + Thread.currentThread().interrupt(); } catch (Throwable e) { LOGGER.error("[worldcheck] check failed", e); + } finally { + int resultCode = exitCode; + LOGGER.info("[worldcheck] shutting down dev server (result={})", resultCode == EXIT_PASS ? "PASS" : "FAIL"); + MinecraftServer serverRef = server; + stopAndExit(serverRef == null ? null : () -> serverRef.halt(true), resultCode, processExit); } + } - LOGGER.info("[worldcheck] shutting down dev server (result={})", pass.get() ? "PASS" : "FAIL"); - serverRef.halt(false); + static void stopAndExit(Runnable serverStop, int requestedExitCode, ProcessExit processExit) { + int exitCode = requestedExitCode; + boolean interrupted = Thread.interrupted(); + if (interrupted) { + exitCode = EXIT_FAILURE; + } + try { + if (serverStop != null) { + serverStop.run(); + } + } catch (Throwable e) { + exitCode = EXIT_FAILURE; + LOGGER.error("[worldcheck] server shutdown failed", e); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + processExit.exit(exitCode); } private static boolean run(MinecraftServer server) { @@ -186,4 +222,9 @@ public final class ModdedWorldCheck { throw new IllegalStateException(e); } } + + @FunctionalInterface + interface ProcessExit { + void exit(int status); + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 88e5620c2..edebcfeb1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -57,88 +57,167 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerTeleportEvent; import java.util.HashSet; -import java.util.Queue; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; public final class ModdedWorldManager implements EngineWorldManager { - private static final ConcurrentHashMap> INITIAL_QUEUES = new ConcurrentHashMap<>(); private static final int MAX_INITIAL_QUEUE = 8192; private static final int MAX_INITIAL_DRAIN_PER_TICK = 8; + private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128; + private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256; + private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L; private static final long COUNT_INTERVAL_MS = 3_000L; private static final int ENTITY_SCAN_RADIUS = 64; private static final int PLAYER_CHUNK_RADIUS = 4; private static final int MIN_TICK_INTERVAL_MS = 1_000; private final Engine engine; + private final InitialSpawnQueue initialSpawnQueue; + private final Set mantleWarmups; + private final ThreadPoolExecutor mantleWarmupExecutor; private long lastAmbientAt; private long lastCountAt; + private long lastInitialRecoveryAt; + private volatile boolean closed; private volatile int cachedEntityCount; private volatile int cachedConsideredChunks; private volatile double cachedSaturation; public ModdedWorldManager(Engine engine) { this.engine = engine; - INITIAL_QUEUES.putIfAbsent(engine, new ConcurrentLinkedQueue<>()); + this.initialSpawnQueue = new InitialSpawnQueue(MAX_INITIAL_QUEUE); + this.mantleWarmups = ConcurrentHashMap.newKeySet(); + BlockingQueue warmupQueue = new ArrayBlockingQueue<>(MANTLE_WARMUP_QUEUE_CAPACITY); + this.mantleWarmupExecutor = new ThreadPoolExecutor( + 1, + 1, + 30L, + TimeUnit.SECONDS, + warmupQueue, + runnable -> { + Thread thread = new Thread(runnable, "Iris Initial Spawn Mantle Warmup"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }, + new ThreadPoolExecutor.AbortPolicy()); + this.mantleWarmupExecutor.allowCoreThreadTimeOut(true); } public static void enqueueGenerated(Engine engine, int chunkX, int chunkZ) { - if (engine == null) { + if (engine == null || engine.isClosed()) { return; } - Queue queue = INITIAL_QUEUES.get(engine); - if (queue == null || queue.size() >= MAX_INITIAL_QUEUE) { - return; + EngineWorldManager worldManager = engine.getWorldManager(); + if (worldManager instanceof ModdedWorldManager moddedWorldManager) { + moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ)); } - queue.add(pack(chunkX, chunkZ)); } public void serverTick(ServerLevel level) { - if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + if (closed || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { return; } if (isPregenActive()) { return; } + recoverLoadedInitialSpawns(level); drainInitialSpawns(level); ambientTick(level); } - private void drainInitialSpawns(ServerLevel level) { - Queue queue = INITIAL_QUEUES.get(engine); - if (queue == null || queue.isEmpty()) { - return; - } + private void recoverLoadedInitialSpawns(ServerLevel level) { if (!markerSystemEnabled() && !ambientSystemEnabled()) { - queue.clear(); return; } + long now = System.currentTimeMillis(); + if (now - lastInitialRecoveryAt < INITIAL_RECOVERY_INTERVAL_MS) { + return; + } + lastInitialRecoveryAt = now; - int budget = MAX_INITIAL_DRAIN_PER_TICK; - while (budget-- > 0) { - Long key = queue.poll(); - if (key == null) { - return; + Set candidates = new HashSet<>(); + for (ServerPlayer player : level.players()) { + int centerX = player.blockPosition().getX() >> 4; + int centerZ = player.blockPosition().getZ() >> 4; + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + candidates.add(pack(centerX + dx, centerZ + dz)); + } } + } + candidates.addAll(level.getForceLoadedChunks()); + + Mantle mantle = engine.getMantle().getMantle(); + int recovered = 0; + for (long key : candidates) { int chunkX = unpackX(key); int chunkZ = unpackZ(key); if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) { continue; } - try { - initialSpawnChunk(level, chunkX, chunkZ); - } catch (Throwable e) { - IrisLogging.reportError(e); + if (mantle.isChunkLoaded(chunkX, chunkZ) && mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) { + continue; + } + if (initialSpawnQueue.offer(key) && ++recovered >= MAX_INITIAL_RECOVERY_PER_PASS) { + return; } } } - private void initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { - Mantle mantle = engine.getMantle().getMantle(); - if (!mantle.isChunkLoaded(chunkX, chunkZ) || mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) { + private void drainInitialSpawns(ServerLevel level) { + if (initialSpawnQueue.isEmpty()) { return; } + if (!markerSystemEnabled() && !ambientSystemEnabled()) { + initialSpawnQueue.clear(); + return; + } + + int budget = initialSpawnQueue.batchSize(MAX_INITIAL_DRAIN_PER_TICK); + while (budget-- > 0) { + Long key = initialSpawnQueue.poll(); + if (key == null) { + return; + } + int chunkX = unpackX(key); + int chunkZ = unpackZ(key); + boolean retry = false; + try { + if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) { + retry = true; + continue; + } + if (!initialSpawnChunk(level, chunkX, chunkZ)) { + retry = true; + warmupMantleChunkAsync(key, chunkX, chunkZ); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + retry = true; + } finally { + if (retry) { + initialSpawnQueue.retry(key); + } else { + initialSpawnQueue.complete(key); + } + } + } + } + + private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { + Mantle mantle = engine.getMantle().getMantle(); + if (!mantle.isChunkLoaded(chunkX, chunkZ)) { + return false; + } + if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) { + return true; + } MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); try { @@ -153,6 +232,33 @@ public final class ModdedWorldManager implements EngineWorldManager { } finally { chunk.release(); } + return true; + } + + private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { + if (closed || !mantleWarmups.add(key)) { + return; + } + try { + mantleWarmupExecutor.execute(() -> warmupMantleChunk(key, chunkX, chunkZ)); + } catch (RejectedExecutionException e) { + mantleWarmups.remove(key); + } + } + + private void warmupMantleChunk(long key, int chunkX, int chunkZ) { + try { + Mantle mantle = engine.getMantle().getMantle(); + if (!closed && !engine.isClosed() && !mantle.isClosed() && !mantle.isChunkLoaded(chunkX, chunkZ)) { + mantle.getChunk(chunkX, chunkZ); + } + } catch (Throwable e) { + if (!closed && !engine.isClosed()) { + IrisLogging.reportError(e); + } + } finally { + mantleWarmups.remove(key); + } } private void ambientTick(ServerLevel level) { @@ -575,7 +681,10 @@ public final class ModdedWorldManager implements EngineWorldManager { @Override public void close() { - INITIAL_QUEUES.remove(engine); + closed = true; + initialSpawnQueue.close(); + mantleWarmupExecutor.shutdownNow(); + mantleWarmups.clear(); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/NativeSpawnTableMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/NativeSpawnTableMerger.java new file mode 100644 index 000000000..2faa17e2b --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/NativeSpawnTableMerger.java @@ -0,0 +1,34 @@ +package art.arcane.iris.modded; + +import net.minecraft.util.random.Weighted; +import net.minecraft.util.random.WeightedList; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.biome.MobSpawnSettings; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class NativeSpawnTableMerger { + private NativeSpawnTableMerger() { + } + + static WeightedList merge( + WeightedList vanillaSpawns, + WeightedList explicitSpawns) { + List> entries = new ArrayList<>( + vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size()); + Set> explicitTypes = new HashSet<>(); + for (Weighted entry : explicitSpawns.unwrap()) { + explicitTypes.add(entry.value().type()); + } + for (Weighted entry : vanillaSpawns.unwrap()) { + if (!explicitTypes.contains(entry.value().type())) { + entries.add(entry); + } + } + entries.addAll(explicitSpawns.unwrap()); + return WeightedList.of(entries); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index e005b99ee..a47eeb418 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -131,7 +131,8 @@ final class ModdedCommandHelp { SECTIONS.put("s", SECTIONS.get("studio")); SECTIONS.put("pack", List.of( Entry.command("validate", "[pack]", "Validate a pack or every pack", "v"), - Entry.command("restore", "", "Restore the latest trashed files for a pack", "r"), + Entry.command("cleanup", " [apply]", "Preview or quarantine unused-resource candidates", "c"), + Entry.command("restore", " [apply]", "Preview or restore the latest quarantine", "r"), Entry.command("status", "[pack]", "Show cached validation status", "s") )); SECTIONS.put("pk", SECTIONS.get("pack")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java index a75d6866f..be30f386e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java @@ -63,7 +63,7 @@ public final class ModdedGoldenHash { File goldenDir = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("golden").toFile(); GoldenHashEngine.Request request = new GoldenHashEngine.Request( engine.getWorld().name(), - level.getSeed(), + engine.getSeedManager().getSeed(), ModdedEngineBootstrap.loader().minecraftVersion(), engine.getMinHeight(), engine.getMaxHeight(), @@ -88,7 +88,7 @@ public final class ModdedGoldenHash { int chunks = (boundedRadius * 2 + 1) * (boundedRadius * 2 + 1); scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + Math.max(1, threads) + " mode=" + mode); LOGGER.info("goldenhash start: dim={} seed={} radius={} threads={} mode={} file={}", - engine.getDimension().getLoadKey(), level.getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName()); + engine.getDimension().getLoadKey(), engine.getSeedManager().getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName()); Thread thread = new Thread(() -> { try { scan.hashEngine.run(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java index 7cc9a3e7f..420c47866 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -18,6 +18,8 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.pack.PackDirectoryResolver; +import art.arcane.iris.core.pack.PackResourceCleanup; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; @@ -58,12 +60,27 @@ public final class ModdedPackCommands { .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) .executes((CommandContext context) -> validate(context.getSource(), StringArgumentType.getString(context, "pack"))))); + root.then(Commands.literal("cleanup") + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false)) + .then(Commands.literal("apply") + .executes((CommandContext context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true))))); + root.then(Commands.literal("c") + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false)) + .then(Commands.literal("apply") + .executes((CommandContext context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true))))); + root.then(Commands.literal("restore") .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) - .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"))))); + .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false)) + .then(Commands.literal("apply") + .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true))))); root.then(Commands.literal("r") .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) - .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"))))); + .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false)) + .then(Commands.literal("apply") + .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true))))); root.then(Commands.literal("status") .executes((CommandContext context) -> status(context.getSource(), null)) @@ -99,8 +116,8 @@ public final class ModdedPackCommands { targets.add(dir); } } else { - File target = new File(packsRoot, pack); - if (!target.isDirectory()) { + File target = PackDirectoryResolver.resolveExisting(packsRoot, pack); + if (target == null) { IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot.getAbsolutePath()); return 0; } @@ -133,19 +150,45 @@ public final class ModdedPackCommands { return 1; } - private static int restore(CommandSourceStack source, String pack) { - File packFolder = new File(packsRoot(), pack); - if (!packFolder.isDirectory()) { + private static int cleanup(CommandSourceStack source, String pack, boolean apply) { + File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack); + if (packFolder == null) { IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath()); return 0; } - int restored = PackValidator.restoreTrash(packFolder); - if (restored == 0) { - IrisModdedCommands.fail(source, "Nothing to restore for pack '" + pack + "'."); + MinecraftServer server = source.getServer(); + Thread thread = new Thread(() -> { + if (apply) { + PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder); + server.execute(() -> reportCleanupApply(source, pack, result)); + } else { + PackResourceCleanup.Preview result = PackResourceCleanup.preview(packFolder); + server.execute(() -> reportCleanupPreview(source, pack, result)); + } + }, "Iris Pack Cleanup"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static int restore(CommandSourceStack source, String pack, boolean apply) { + File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack); + if (packFolder == null) { + IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath()); return 0; } - IrisModdedCommands.ok(source, "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'."); - IrisModdedCommands.ok(source, "Re-run /iris pack validate " + pack + " to re-check."); + MinecraftServer server = source.getServer(); + Thread thread = new Thread(() -> { + if (apply) { + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder); + server.execute(() -> reportRestoreApply(source, pack, result)); + } else { + PackResourceCleanup.RestorePreview result = PackResourceCleanup.previewRestore(packFolder); + server.execute(() -> reportRestorePreview(source, pack, result)); + } + }, "Iris Pack Restore"); + thread.setDaemon(true); + thread.start(); return 1; } @@ -161,8 +204,7 @@ public final class ModdedPackCommands { String tag = result.isLoadable() ? "OK" : "BROKEN"; IrisModdedCommands.ok(source, tag + " " + entry.getKey() + " (blocking=" + result.getBlockingErrors().size() - + ", warnings=" + result.getWarnings().size() - + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + + ", warnings=" + result.getWarnings().size() + ")"); } return 1; } @@ -178,8 +220,7 @@ public final class ModdedPackCommands { private static void report(CommandSourceStack source, PackValidationResult result) { if (result.isLoadable()) { IrisModdedCommands.ok(source, "Pack '" + result.getPackName() + "' is loadable." - + " (warnings=" + result.getWarnings().size() - + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + + " (warnings=" + result.getWarnings().size() + ")"); } else { IrisModdedCommands.fail(source, "Pack '" + result.getPackName() + "' is BROKEN:"); for (String reason : result.getBlockingErrors()) { @@ -193,12 +234,84 @@ public final class ModdedPackCommands { if (result.getWarnings().size() > warningMax) { IrisModdedCommands.ok(source, " ... and " + (result.getWarnings().size() - warningMax) + " more warning(s)."); } - int trashMax = Math.min(10, result.getRemovedUnusedFiles().size()); - for (int i = 0; i < trashMax; i++) { - IrisModdedCommands.ok(source, " ~ trashed " + result.getRemovedUnusedFiles().get(i)); + } + + private static void reportCleanupPreview(CommandSourceStack source, String pack, PackResourceCleanup.Preview result) { + if (!result.success()) { + IrisModdedCommands.fail(source, result.error()); + return; } - if (result.getRemovedUnusedFiles().size() > trashMax) { - IrisModdedCommands.ok(source, " ... and " + (result.getRemovedUnusedFiles().size() - trashMax) + " more trashed file(s)."); + if (!result.hasCandidates()) { + IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'."); + return; + } + IrisModdedCommands.ok(source, "Cleanup preview for pack '" + pack + "': " + + result.candidatePaths().size() + " candidate(s). No files were changed."); + reportPaths(source, result.candidatePaths(), "candidate"); + IrisModdedCommands.ok(source, "Run /iris pack cleanup " + pack + " apply to quarantine after a fresh scan."); + } + + private static void reportCleanupApply(CommandSourceStack source, String pack, PackResourceCleanup.ApplyResult result) { + if (!result.success()) { + IrisModdedCommands.fail(source, result.error()); + reportPaths(source, result.quarantinedPaths(), "still quarantined"); + return; + } + if (!result.changed()) { + IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'."); + return; + } + IrisModdedCommands.ok(source, "Quarantined " + result.quarantinedPaths().size() + + " cleanup candidate(s) under " + result.quarantinePath() + "."); + reportPaths(source, result.quarantinedPaths(), "quarantined"); + } + + private static void reportRestorePreview(CommandSourceStack source, String pack, PackResourceCleanup.RestorePreview result) { + if (!result.success()) { + IrisModdedCommands.fail(source, result.error()); + return; + } + if (!result.hasFiles()) { + IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'."); + return; + } + IrisModdedCommands.ok(source, "Restore preview for " + result.dumpPath() + ": " + + result.filePaths().size() + " file(s). No files were changed."); + reportPaths(source, result.filePaths(), "file"); + if (!result.conflicts().isEmpty()) { + IrisModdedCommands.fail(source, "Restore is blocked by " + result.conflicts().size() + " existing destination(s)."); + reportPaths(source, result.conflicts(), "conflict"); + return; + } + IrisModdedCommands.ok(source, "Run /iris pack restore " + pack + " apply to restore after a fresh conflict check."); + } + + private static void reportRestoreApply(CommandSourceStack source, String pack, PackResourceCleanup.RestoreResult result) { + if (!result.conflicts().isEmpty()) { + IrisModdedCommands.fail(source, "Restore refused because " + result.conflicts().size() + " destination(s) already exist."); + reportPaths(source, result.conflicts(), "conflict"); + return; + } + if (!result.success()) { + IrisModdedCommands.fail(source, result.error()); + return; + } + if (!result.changed()) { + IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'."); + return; + } + IrisModdedCommands.ok(source, "Restored " + result.restoredPaths().size() + + " file(s) from " + result.dumpPath() + "."); + reportPaths(source, result.restoredPaths(), "restored"); + } + + private static void reportPaths(CommandSourceStack source, List paths, String label) { + int max = Math.min(10, paths.size()); + for (int i = 0; i < max; i++) { + IrisModdedCommands.ok(source, " - " + label + ": " + paths.get(i)); + } + if (paths.size() > max) { + IrisModdedCommands.ok(source, " ... and " + (paths.size() - max) + " more."); } } } 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 2a1517223..0a9a41d5b 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 @@ -77,7 +77,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight()); this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4)); this.semaphore = new Semaphore(maxInFlight, true); - this.adaptiveLimit = new AtomicInteger(maxInFlight); + this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight); this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds()); this.backpressure = new PregenMantleBackpressure( this::getMantle, @@ -179,6 +179,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { CompletableFuture loadFuture = CompletableFuture .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(PREGEN_TICKET, pos, 0), level.getServer()) .thenCompose((CompletableFuture inner) -> inner); + markSubmitted(); try { Object result = loadFuture.get(timeoutSeconds, TimeUnit.SECONDS); if (result instanceof ChunkResult chunkResult && !chunkResult.isSuccess()) { @@ -196,6 +197,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString()); listener.onChunkFailed(x, z); } finally { + markFinished(); level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0)); } } @@ -255,6 +257,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private void markFinished() { inFlight.decrementAndGet(); + if (sync) { + return; + } synchronized (permitMonitor) { permitMonitor.notifyAll(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index 72adf543b..d21292cab 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -44,6 +44,7 @@ import net.minecraft.nbt.NbtUtils; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.entity.BlockEntity; @@ -108,17 +109,22 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { continue; } - if (level.players().isEmpty() || isPregenActive(engine)) { + if (!hasUpdateTargets(!level.players().isEmpty(), !level.getForceLoadedChunks().isEmpty()) || isPregenActive(engine)) { continue; } try { updateNearPlayers(engine, level); + updateForcedChunks(engine, level); } catch (Throwable e) { IrisLogging.reportError(e); } } } + static boolean hasUpdateTargets(boolean hasPlayers, boolean hasForcedChunks) { + return hasPlayers || hasForcedChunks; + } + private boolean isPregenActive(Engine engine) { PregeneratorJob job = PregeneratorJob.getInstance(); return job != null && job.targetsWorldName(engine.getWorld().name()); @@ -136,6 +142,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } + private void updateForcedChunks(Engine engine, ServerLevel level) { + for (long chunkKey : level.getForceLoadedChunks()) { + updateChunk(engine, level, ChunkPos.getX(chunkKey), ChunkPos.getZ(chunkKey)); + } + } + private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) { for (int x = -1; x <= 1; x++) { for (int z = -1; z <= 1; z++) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java new file mode 100644 index 000000000..649ccb8b2 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class InitialSpawnQueueTest { + @Test + public void rejectsDuplicatesAndEnforcesCapacityWhileInFlight() { + InitialSpawnQueue queue = new InitialSpawnQueue(2); + + assertTrue(queue.offer(1L)); + assertFalse(queue.offer(1L)); + assertTrue(queue.offer(2L)); + assertEquals(Long.valueOf(1L), queue.poll()); + assertFalse(queue.offer(3L)); + queue.complete(1L); + assertTrue(queue.offer(3L)); + assertEquals(2, queue.size()); + } + + @Test + public void retryRotatesWithoutBlockingReadyEntries() { + InitialSpawnQueue queue = new InitialSpawnQueue(4); + queue.offer(1L); + queue.offer(2L); + queue.offer(3L); + + int budget = queue.batchSize(8); + Long unavailable = queue.poll(); + queue.retry(unavailable); + Long firstReady = queue.poll(); + queue.complete(firstReady); + Long secondReady = queue.poll(); + queue.complete(secondReady); + + assertEquals(3, budget); + assertEquals(Long.valueOf(1L), unavailable); + assertEquals(Long.valueOf(2L), firstReady); + assertEquals(Long.valueOf(3L), secondReady); + assertEquals(1, queue.batchSize(8)); + assertEquals(Long.valueOf(1L), queue.poll()); + } + + @Test + public void retryIsDeduplicated() { + InitialSpawnQueue queue = new InitialSpawnQueue(2); + queue.offer(7L); + Long key = queue.poll(); + + queue.retry(key); + queue.retry(key); + + assertEquals(1, queue.batchSize(8)); + assertEquals(Long.valueOf(7L), queue.poll()); + assertNull(queue.poll()); + } + + @Test + public void closeClearsAndRejectsFurtherWork() { + InitialSpawnQueue queue = new InitialSpawnQueue(2); + queue.offer(1L); + + queue.close(); + + assertTrue(queue.isEmpty()); + assertEquals(0, queue.size()); + assertFalse(queue.offer(2L)); + assertEquals(0, queue.batchSize(8)); + } + + @Test + public void clearAllowsLaterWork() { + InitialSpawnQueue queue = new InitialSpawnQueue(2); + queue.offer(1L); + + queue.clear(); + + assertTrue(queue.offer(2L)); + assertEquals(Long.valueOf(2L), queue.poll()); + } + + @Test + public void expiredEntriesReleaseCapacity() { + AtomicLong now = new AtomicLong(); + InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get); + queue.offer(1L); + queue.offer(2L); + + now.set(100L); + + assertTrue(queue.offer(3L)); + assertEquals(1, queue.size()); + assertEquals(Long.valueOf(3L), queue.poll()); + } + + @Test + public void expiredInFlightEntryCannotRetry() { + AtomicLong now = new AtomicLong(); + InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get); + queue.offer(1L); + Long inFlight = queue.poll(); + + now.set(100L); + queue.retry(inFlight); + + assertEquals(0, queue.size()); + assertNull(queue.poll()); + } + + @Test + public void concurrentOffersRemainDeduplicatedAndBounded() throws Exception { + InitialSpawnQueue queue = new InitialSpawnQueue(256); + ExecutorService executor = Executors.newFixedThreadPool(8); + List> futures = new ArrayList<>(); + for (int thread = 0; thread < 8; thread++) { + futures.add(executor.submit(() -> { + for (long key = 0L; key < 512L; key++) { + queue.offer(key); + } + })); + } + for (Future future : futures) { + future.get(); + } + executor.shutdown(); + + assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS)); + assertEquals(256, queue.size()); + assertEquals(256, queue.batchSize(512)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java new file mode 100644 index 000000000..9b2c34c5f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.modded; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.random.Weighted; +import net.minecraft.util.random.WeightedList; +import net.minecraft.world.entity.EntityTypes; +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(EntityTypes.ZOMBIE, 1, 4); + MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 1, 1); + MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 2, 5); + MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, 2, 4); + WeightedList vanilla = WeightedList.of(List.of( + new Weighted<>(zombie, 100), + new Weighted<>(vanillaSlime, 1))); + WeightedList explicit = WeightedList.of(List.of( + new Weighted<>(explicitSlime, 7), + new Weighted<>(explicitCow, 3))); + + WeightedList merged = NativeSpawnTableMerger.merge(vanilla, explicit); + List> entries = merged.unwrap(); + + assertEquals(3, entries.size()); + assertEquals(EntityTypes.ZOMBIE, entries.get(0).value().type()); + assertEquals(100, entries.get(0).weight()); + assertEquals(explicitSlime, entries.get(1).value()); + assertEquals(7, entries.get(1).weight()); + assertEquals(explicitCow, entries.get(2).value()); + assertEquals(3, entries.get(2).weight()); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java new file mode 100644 index 000000000..3c94f2272 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedWorldCheckTest { + @Test + public void coordinatorThreadIsNonDaemon() { + Thread thread = ModdedWorldCheck.coordinatorThread(() -> { + }); + + assertFalse(thread.isDaemon()); + } + + @Test + public void passStopsServerBeforeZeroExit() { + List events = new ArrayList<>(); + + ModdedWorldCheck.stopAndExit( + () -> events.add("stop"), + 0, + status -> events.add("exit:" + status) + ); + + assertEquals(List.of("stop", "exit:0"), events); + } + + @Test + public void failureStopsServerBeforeNonzeroExit() { + List events = new ArrayList<>(); + + ModdedWorldCheck.stopAndExit( + () -> events.add("stop"), + 1, + status -> events.add("exit:" + status) + ); + + assertEquals(List.of("stop", "exit:1"), events); + } + + @Test + public void shutdownFailureForcesNonzeroExit() { + AtomicInteger status = new AtomicInteger(-1); + + ModdedWorldCheck.stopAndExit( + () -> { + throw new IllegalStateException("shutdown failed"); + }, + 0, + status::set + ); + + assertEquals(1, status.get()); + } + + @Test + public void interruptionIsClearedForShutdownAndRestoredBeforeExit() { + AtomicBoolean interruptedDuringStop = new AtomicBoolean(true); + AtomicBoolean interruptedDuringExit = new AtomicBoolean(false); + AtomicInteger status = new AtomicInteger(-1); + Thread.currentThread().interrupt(); + try { + ModdedWorldCheck.stopAndExit( + () -> interruptedDuringStop.set(Thread.currentThread().isInterrupted()), + 0, + exitStatus -> { + status.set(exitStatus); + interruptedDuringExit.set(Thread.currentThread().isInterrupted()); + } + ); + + assertFalse(interruptedDuringStop.get()); + assertTrue(interruptedDuringExit.get()); + assertEquals(1, status.get()); + } finally { + Thread.interrupted(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java new file mode 100644 index 000000000..dcd63ed74 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java @@ -0,0 +1,23 @@ +package art.arcane.iris.modded.service; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedChunkUpdateServiceTest { + @Test + public void scansWhenPlayersArePresent() { + assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false)); + } + + @Test + public void scansHeadlessForceLoadedChunks() { + assertTrue(ModdedChunkUpdateService.hasUpdateTargets(false, true)); + } + + @Test + public void skipsLevelsWithoutPlayersOrForcedChunks() { + assertFalse(ModdedChunkUpdateService.hasUpdateTargets(false, false)); + } +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 6738859fe..59f550a8e 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -189,6 +189,7 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.RSA') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('io.sentry', 'art.arcane.iris.shadow.sentry') relocate('com.sun.jna', 'art.arcane.iris.shadow.jna') relocate('oshi', 'art.arcane.iris.shadow.oshi') relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz') diff --git a/build-all.sh b/build-all.sh index 02484afb1..c827831de 100755 --- a/build-all.sh +++ b/build-all.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" -./gradlew buildAll --no-parallel -PuseLocalVolmLib=false "$@" +./gradlew buildAllToOut --no-parallel -PuseLocalVolmLib=false "$@" diff --git a/build.gradle b/build.gradle index 018971e35..3836d242d 100644 --- a/build.gradle +++ b/build.gradle @@ -71,36 +71,14 @@ String bukkitArtifactName = irisArtifactName('CraftBukkit', minecraftVersion) String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}") String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}") String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}") -extensions.extraProperties.set('bukkitArtifactName', bukkitArtifactName) -extensions.extraProperties.set('fabricArtifactName', fabricArtifactName) -extensions.extraProperties.set('forgeArtifactName', forgeArtifactName) -extensions.extraProperties.set('neoForgeArtifactName', neoForgeArtifactName) - apply plugin: ApiGenerator -// ADD YOURSELF AS A NEW LINE IF YOU WANT YOUR OWN BUILD TASK GENERATED -// ======================== WINDOWS ============================= -registerCustomOutputTask('Cyberpwn', 'C://Users/cyberpwn/Documents/development/server/plugins') -registerCustomOutputTask('Psycho', 'C://Dan/MinecraftDevelopment/Server/plugins') -registerCustomOutputTask('ArcaneArts', 'C://Users/arcane/Documents/development/server/plugins') -registerCustomOutputTask('Coco', 'D://mcsm/plugins') -registerCustomOutputTask('Strange', 'D://Servers/1.17 Test Server/plugins') -registerCustomOutputTask('Vatuu', 'D://Minecraft/Servers/1.19.4/plugins') -registerCustomOutputTask('CrazyDev22', 'C://Users/Julian/Desktop/server/plugins') -registerCustomOutputTask('PixelFury', 'C://Users/repix/workplace/Iris/1.21.3 - Development-Public-v3/plugins') -registerCustomOutputTask('PixelFuryDev', 'C://Users/repix/workplace/Iris/1.21 - Development-v3/plugins') -// ========================== UNIX ============================== -registerCustomOutputTaskUnix('CyberpwnLT', '/Users/danielmills/development/server/plugins') -registerCustomOutputTaskUnix( - 'PsychoLT', - '/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/dropins/plugins', - '/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/fabric-mod-consumers/dropins/mods', - '/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/forge-mod-consumers/dropins/mods', - '/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/neoforge-mod-consumers/dropins/mods' -) -registerCustomOutputTaskUnix('PixelMac', '/Users/test/Desktop/mcserver/plugins') -registerCustomOutputTaskUnix('CrazyDev22LT', '/home/julian/Desktop/server/plugins') -// ============================================================== +String consumerLocation = providers.gradleProperty('location') + .getOrElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers') +String bukkitConsumerPath = "${consumerLocation}/plugin-consumers/dropins/plugins" +String fabricConsumerPath = "${consumerLocation}/fabric-mod-consumers/dropins/mods" +String forgeConsumerPath = "${consumerLocation}/forge-mod-consumers/dropins/mods" +String neoForgeConsumerPath = "${consumerLocation}/neoforge-mod-consumers/dropins/mods" def nmsBindings = [ v26_2_R1: '26.2.build.25-alpha', @@ -272,14 +250,14 @@ tasks.named('neoforgeJar').configure { mustRunAfter(tasks.named('forgeJar')) } -tasks.register('buildAll') { +tasks.register('buildAllToOut') { group = 'iris' dependsOn('buildBukkit', 'buildFabric', 'buildForge', 'buildNeoforge', ':spi:jar') doLast { File dist = layout.projectDirectory.dir('dist').asFile File[] jars = dist.listFiles((java.io.FileFilter) { File f -> f.name.endsWith('.jar') }) println('') - println('=== Iris buildAll -> dist/ ===') + println('=== Iris buildAllToOut -> dist/ ===') if (jars != null) { jars.sort { it.name }.each { File f -> println(String.format(' %-60s %8d KB', f.name, (long) (f.length() / 1024))) @@ -288,6 +266,60 @@ tasks.register('buildAll') { } } +tasks.register('buildAll') { + group = 'iris' + outputs.upToDateWhen { false } + dependsOn('iris', 'fabricJar', 'forgeJar', 'neoforgeJar') + doLast { + delete( + file("${bukkitConsumerPath}/Iris-Fabric.jar"), + file("${bukkitConsumerPath}/Iris-Forge.jar"), + file("${bukkitConsumerPath}/Iris-Neoforge.jar") + ) + copy { + from(layout.buildDirectory.file(bukkitArtifactName)) + into(bukkitConsumerPath) + rename { String ignored -> 'Iris.jar' } + } + delete( + file("${fabricConsumerPath}/Iris.jar"), + file("${fabricConsumerPath}/Iris-Forge.jar"), + file("${fabricConsumerPath}/Iris-Neoforge.jar") + ) + copy { + from(layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}")) + into(fabricConsumerPath) + rename { String ignored -> 'Iris-Fabric.jar' } + } + delete( + file("${forgeConsumerPath}/Iris.jar"), + file("${forgeConsumerPath}/Iris-Fabric.jar"), + file("${forgeConsumerPath}/Iris-Neoforge.jar") + ) + copy { + from(layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}")) + into(forgeConsumerPath) + rename { String ignored -> 'Iris-Forge.jar' } + } + delete( + file("${neoForgeConsumerPath}/Iris.jar"), + file("${neoForgeConsumerPath}/Iris-Fabric.jar"), + file("${neoForgeConsumerPath}/Iris-Forge.jar") + ) + copy { + from(layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}")) + into(neoForgeConsumerPath) + rename { String ignored -> 'Iris-Neoforge.jar' } + } + println('') + println("=== Iris buildAll -> ${consumerLocation} ===") + println(" ${bukkitConsumerPath}/Iris.jar") + println(" ${fabricConsumerPath}/Iris-Fabric.jar") + println(" ${forgeConsumerPath}/Iris-Forge.jar") + println(" ${neoForgeConsumerPath}/Iris-Neoforge.jar") + } +} + tasks.register('irisDev', Copy) { group = 'iris' from(project(':core').layout.buildDirectory.files('libs/core-javadoc.jar', 'libs/core-sources.jar')) @@ -409,98 +441,3 @@ if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_25)) { System.err.println() System.exit(69) } - -void registerCustomOutputTask(String name, String path) { - if (!System.getProperty('os.name').toLowerCase().contains('windows')) { - return - } - - createCustomOutputTasks(name, path) -} - -void registerCustomOutputTask(String name, String path, String fabricPath, String forgePath, String neoForgePath) { - if (!System.getProperty('os.name').toLowerCase().contains('windows')) { - return - } - - createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath) -} - -void registerCustomOutputTaskUnix(String name, String path) { - if (System.getProperty('os.name').toLowerCase().contains('windows')) { - return - } - - createCustomOutputTasks(name, path) -} - -void registerCustomOutputTaskUnix(String name, String path, String fabricPath, String forgePath, String neoForgePath) { - if (System.getProperty('os.name').toLowerCase().contains('windows')) { - return - } - - createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath) -} - -void createCustomOutputTasks(String name, String path) { - createBukkitOutputTask(name, path) - - tasks.register("buildAll${name}") { - group = 'development' - dependsOn("build${name}") - } -} - -void createCustomOutputTasks(String name, String path, String fabricPath, String forgePath, String neoForgePath) { - createBukkitOutputTask(name, path) - createPlatformOutputTask(name, fabricPath, 'Fabric', 'buildFabric', "adapters/fabric/build/libs/${String.valueOf(project.ext.fabricArtifactName)}") - createPlatformOutputTask(name, forgePath, 'Forge', 'buildForge', "adapters/forge/build/libs/${String.valueOf(project.ext.forgeArtifactName)}") - createPlatformOutputTask(name, neoForgePath, 'Neoforge', 'buildNeoforge', "adapters/neoforge/build/libs/${String.valueOf(project.ext.neoForgeArtifactName)}") - - tasks.register("buildAll${name}") { - group = 'development' - dependsOn( - "build${name}", - "build${name}Fabric", - "build${name}Forge", - "build${name}Neoforge" - ) - } -} - -void createBukkitOutputTask(String name, String path) { - tasks.register("build${name}", Copy) { - group = 'development' - outputs.upToDateWhen { false } - dependsOn('iris') - from(layout.buildDirectory.file((String) project.ext.bukkitArtifactName)) - into(file(path)) - rename { String ignored -> 'Iris.jar' } - doFirst { - delete( - file("${path}/Iris-Fabric.jar"), - file("${path}/Iris-Forge.jar"), - file("${path}/Iris-Neoforge.jar") - ) - } - } -} - -void createPlatformOutputTask(String name, String path, String platform, String buildTask, String sourcePath) { - tasks.register("build${name}${platform}", Copy) { - group = 'development' - outputs.upToDateWhen { false } - dependsOn(buildTask) - from(layout.projectDirectory.file(sourcePath)) - into(file(path)) - rename { String ignored -> "Iris-${platform}.jar" } - doFirst { - delete( - file("${path}/Iris.jar"), - file("${path}/Iris-Fabric.jar"), - file("${path}/Iris-Forge.jar"), - file("${path}/Iris-Neoforge.jar") - ) - } - } -} diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index c8d801932..711194f9f 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -160,6 +160,7 @@ public class ServerConfigurator { } DimensionHeight height = new DimensionHeight(fixer); KList folders = getDatapacksFolder(); + IrisDimension.clearGeneratedBiomeTags(folders); DatapackIngestService.reapplyFromStaging(folders); java.util.concurrent.ConcurrentMap> biomes = new java.util.concurrent.ConcurrentHashMap<>(); diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index f47dd5294..0bea1580a 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -205,7 +205,9 @@ public interface INMSBinding { void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException; Vector3d getBoundingbox(org.bukkit.entity.EntityType entity); - + + String getEntitySpawnCategory(String key); + Entity spawnEntity(Location location, EntityType type, CreatureSpawnEvent.SpawnReason reason); Color getBiomeColor(Location location, BiomeColor type); diff --git a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java index 4890bea9b..8e413209a 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java +++ b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java @@ -12,6 +12,7 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.OVERWORLD, """ { "ambient_light": 0.0, + "default_clock": "minecraft:overworld", "has_ender_dragon_fight": false, "attributes": { "minecraft:audio/ambient_sounds": { @@ -43,6 +44,7 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.NETHER, """ { "ambient_light": 0.1, + "has_fixed_time": true, "has_ender_dragon_fight": false, "attributes": { "minecraft:gameplay/sky_light_level": 4.0, @@ -59,6 +61,8 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.END, """ { "ambient_light": 0.25, + "default_clock": "minecraft:the_end", + "has_fixed_time": true, "has_ender_dragon_fight": true, "attributes": { "minecraft:audio/ambient_sounds": { diff --git a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java index 785c4e4a7..aa7844394 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java +++ b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java @@ -248,6 +248,11 @@ public class NMSBinding1X implements INMSBinding { return null; } + @Override + public String getEntitySpawnCategory(String key) { + return null; + } + @Override public MCAPaletteAccess createPalette() { IrisLogging.error("Cannot use the global data palette! Iris is incapable of using MCA generation on this version of minecraft!"); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java new file mode 100644 index 000000000..40c429637 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.pack; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + +public final class PackDirectoryResolver { + private PackDirectoryResolver() { + } + + public static File resolveExisting(File packsRoot, String packName) { + if (packsRoot == null || packName == null || packName.isBlank()) { + return null; + } + Path root = packsRoot.toPath().toAbsolutePath().normalize(); + Path candidate = root.resolve(packName).normalize(); + if (!root.equals(candidate.getParent())) { + return null; + } + if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + return candidate.toFile(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index 99d840fd0..f6d249659 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -30,13 +30,18 @@ import java.io.File; import java.io.IOException; import java.util.UUID; import java.util.function.Consumer; +import java.util.regex.Pattern; public final class PackDownloader { + private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"); + private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*"); + private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}"); + private PackDownloader() { } - public static String download(File packsFolder, String repo, String branch, boolean forceOverwrite, boolean directUrl, Consumer feedback) throws IOException { - String url = directUrl ? branch : "https://codeload.github.com/" + repo + "/zip/refs/heads/" + branch; + public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer feedback) throws IOException { + String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref); feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL File zip = WebCache.getNonCachedFile("pack-" + repo, url); File temp = WebCache.getTemp(); @@ -136,6 +141,57 @@ public final class PackDownloader { return key; } + static String resolveGithubArchiveUrl(String repo, String ref) { + if (repo == null || !GITHUB_REPOSITORY.matcher(repo).matches()) { + throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'"); + } + String[] repositoryParts = repo.split("/", -1); + if (repositoryParts[0].equals(".") || repositoryParts[0].equals("..") + || repositoryParts[1].equals(".") || repositoryParts[1].equals("..")) { + throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'"); + } + if (ref == null || ref.isBlank()) { + throw new IllegalArgumentException("GitHub reference cannot be empty"); + } + if (COMMIT_SHA.matcher(ref).matches()) { + return "https://github.com/" + repo + "/archive/" + ref + ".zip"; + } + if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) { + throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'"); + } + + String qualifiedRef; + if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/")) { + qualifiedRef = ref; + } else { + qualifiedRef = "refs/heads/" + ref; + } + validateGithubRef(qualifiedRef); + return "https://codeload.github.com/" + repo + "/zip/" + qualifiedRef; + } + + private static void validateGithubRef(String qualifiedRef) { + String refPath = qualifiedRef.startsWith("refs/heads/") + ? qualifiedRef.substring("refs/heads/".length()) + : qualifiedRef.substring("refs/tags/".length()); + if (!GITHUB_REF.matcher(refPath).matches() + || refPath.contains("..") + || refPath.contains("//") + || refPath.contains("@{") + || refPath.endsWith("/") + || refPath.endsWith(".") + || refPath.endsWith(".lock")) { + throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'"); + } + + String[] segments = refPath.split("/"); + for (String segment : segments) { + if (segment.startsWith(".") || segment.endsWith(".lock")) { + throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'"); + } + } + } + private static void validateDownloaded(File packEntry, Consumer feedback) { try { PackValidationResult result = PackValidator.validate(packEntry); @@ -146,9 +202,8 @@ public final class PackDownloader { for (String reason : result.getBlockingErrors()) { feedback.accept(" - " + reason); } - } else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) { + } else if (!result.getWarnings().isEmpty()) { feedback.accept("Pack '" + result.getPackName() + "' validated (" - + result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, " + result.getWarnings().size() + " warning(s))."); } else { feedback.accept("Pack '" + result.getPackName() + "' validated."); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackResourceCleanup.java b/core/src/main/java/art/arcane/iris/core/pack/PackResourceCleanup.java new file mode 100644 index 000000000..b352c1275 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackResourceCleanup.java @@ -0,0 +1,620 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.spi.IrisLogging; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +public final class PackResourceCleanup { + private static final String TRASH_ROOT = ".iris-trash"; + private static final List MANAGED_RESOURCE_FOLDERS = List.of( + "biomes", + "regions", + "entities", + "spawners", + "loot", + "generators", + "expressions", + "markers", + "blocks", + "mods" + ); + private static final List CORPUS_EXCLUSIONS = List.of( + TRASH_ROOT, + "datapack-imports", + "externaldatapacks", + "internaldatapacks", + "datapacks", + "cache", + "objects", + ".iris" + ); + private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS"); + private static final Map PACK_LOCKS = new ConcurrentHashMap<>(); + + private PackResourceCleanup() { + } + + public static Preview preview(File packFolder) { + Path lockPath = lockPath(packFolder); + if (lockPath == null) { + return new Preview(List.of(), "Pack folder is required."); + } + synchronized (packLock(lockPath)) { + try { + Path packRoot = requirePackRoot(packFolder); + CleanupScan scan = scanCleanup(packRoot); + return new Preview(scan.paths(), null); + } catch (IOException | RuntimeException e) { + return new Preview(List.of(), errorMessage("Unable to scan pack resources", e)); + } + } + } + + public static ApplyResult apply(File packFolder) { + Path lockPath = lockPath(packFolder); + if (lockPath == null) { + return new ApplyResult(null, List.of(), "Pack folder is required."); + } + synchronized (packLock(lockPath)) { + try { + Path packRoot = requirePackRoot(packFolder); + CleanupScan scan = scanCleanup(packRoot); + if (scan.paths().isEmpty()) { + return new ApplyResult(null, List.of(), null); + } + return quarantine(packRoot, scan.paths()); + } catch (IOException | RuntimeException e) { + return new ApplyResult(null, List.of(), errorMessage("Unable to quarantine pack resources", e)); + } + } + } + + public static RestorePreview previewRestore(File packFolder) { + Path lockPath = lockPath(packFolder); + if (lockPath == null) { + return new RestorePreview(null, List.of(), List.of(), "Pack folder is required."); + } + synchronized (packLock(lockPath)) { + try { + Path packRoot = requirePackRoot(packFolder); + RestoreScan scan = scanRestore(packRoot); + return new RestorePreview(scan.dumpPath(), scan.paths(), scan.conflicts(), null); + } catch (IOException | RuntimeException e) { + return new RestorePreview(null, List.of(), List.of(), errorMessage("Unable to scan quarantined resources", e)); + } + } + } + + public static RestoreResult restoreLatest(File packFolder) { + Path lockPath = lockPath(packFolder); + if (lockPath == null) { + return new RestoreResult(null, List.of(), List.of(), "Pack folder is required."); + } + synchronized (packLock(lockPath)) { + try { + Path packRoot = requirePackRoot(packFolder); + RestoreScan scan = scanRestore(packRoot); + if (scan.dump() == null) { + return new RestoreResult(null, List.of(), List.of(), null); + } + if (!scan.conflicts().isEmpty()) { + return new RestoreResult(scan.dumpPath(), List.of(), scan.conflicts(), null); + } + return restore(packRoot, scan); + } catch (IOException | RuntimeException e) { + return new RestoreResult(null, List.of(), List.of(), errorMessage("Unable to restore quarantined resources", e)); + } + } + } + + private static ApplyResult quarantine(Path packRoot, List paths) throws IOException { + Path dump = createUniqueDump(packRoot); + List moved = new ArrayList<>(paths.size()); + try { + for (String path : paths) { + Path source = resolveContained(packRoot, path); + requireRegularFile(source, "Cleanup candidate"); + Path destination = resolveContained(dump, path); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + throw new FileAlreadyExistsException(destination.toString()); + } + Files.createDirectories(destination.getParent()); + Files.move(source, destination); + moved.add(new Transfer(path, source, destination)); + } + return new ApplyResult(relativePath(packRoot, dump), paths, null); + } catch (IOException | RuntimeException e) { + List remaining = rollbackMoves(moved); + deleteEmptyDirectories(dump); + deleteIfEmpty(dump.getParent()); + String rollbackState = remaining.isEmpty() + ? "Quarantine failed and was rolled back" + : "Quarantine failed and rollback left " + remaining.size() + " file(s) quarantined"; + IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e); + return new ApplyResult(relativePath(packRoot, dump), remaining, errorMessage(rollbackState, e)); + } + } + + private static RestoreResult restore(Path packRoot, RestoreScan scan) throws IOException { + List copiedDestinations = new ArrayList<>(scan.transfers().size()); + List createdDirectories = new ArrayList<>(); + try { + for (Transfer transfer : scan.transfers()) { + createDirectories(packRoot, transfer.destination().getParent(), createdDirectories); + Files.copy(transfer.source(), transfer.destination(), StandardCopyOption.COPY_ATTRIBUTES); + copiedDestinations.add(transfer.destination()); + } + } catch (IOException | RuntimeException e) { + List rollbackRemainders = rollbackCopies(packRoot, copiedDestinations, createdDirectories); + String rollbackState = rollbackRemainders.isEmpty() + ? "Restore copy failed and was rolled back" + : "Restore copy failed and rollback left " + rollbackRemainders.size() + " destination(s)"; + IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e); + return new RestoreResult(scan.dumpPath(), rollbackRemainders, List.of(), errorMessage(rollbackState, e)); + } + + List removedSources = new ArrayList<>(scan.transfers().size()); + for (Transfer transfer : scan.transfers()) { + try { + Files.delete(transfer.source()); + removedSources.add(transfer); + } catch (IOException | RuntimeException e) { + return rollbackSourceRemoval(packRoot, scan, copiedDestinations, createdDirectories, removedSources, e); + } + } + deleteEmptyDirectories(scan.dump()); + deleteIfEmpty(scan.dump().getParent()); + return new RestoreResult(scan.dumpPath(), scan.paths(), List.of(), null); + } + + private static CleanupScan scanCleanup(Path packRoot) throws IOException { + List corpusFiles = listTree(packRoot); + StringBuilder corpus = new StringBuilder(1 << 16); + for (Path path : corpusFiles) { + if (!isScannableJson(packRoot, path)) { + continue; + } + requireRegularFile(path, "Corpus file"); + corpus.append(Files.readString(path, StandardCharsets.UTF_8)).append('\n'); + } + if (corpus.isEmpty()) { + return new CleanupScan(List.of()); + } + String corpusText = corpus.toString(); + + List candidates = new ArrayList<>(); + for (String folderName : MANAGED_RESOURCE_FOLDERS) { + Path resourceFolder = resolveContained(packRoot, folderName); + if (!Files.exists(resourceFolder, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + requireDirectory(resourceFolder, "Managed resource folder"); + for (Path resource : listTree(resourceFolder)) { + if (!isJsonFile(resource)) { + continue; + } + requireRegularFile(resource, "Managed resource"); + String key = stripJsonExtension(relativePath(resourceFolder, resource)); + if (!key.isBlank() && !isReferenced(corpusText, key)) { + candidates.add(relativePath(packRoot, resource)); + } + } + } + candidates.sort(String::compareTo); + return new CleanupScan(List.copyOf(candidates)); + } + + private static RestoreScan scanRestore(Path packRoot) throws IOException { + Path trashRoot = resolveContained(packRoot, TRASH_ROOT); + if (!Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) { + return RestoreScan.empty(); + } + requireDirectory(trashRoot, "Quarantine root"); + List dumps; + try (Stream stream = Files.list(trashRoot)) { + dumps = stream.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList(); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + List directories = new ArrayList<>(); + for (Path dump : dumps) { + if (Files.isSymbolicLink(dump)) { + throw new IOException("Quarantine entry is a symbolic link: " + dump); + } + if (Files.isDirectory(dump, LinkOption.NOFOLLOW_LINKS)) { + directories.add(dump); + } + } + if (directories.isEmpty()) { + return RestoreScan.empty(); + } + + Path latest = directories.get(directories.size() - 1); + String dumpPath = relativePath(packRoot, latest); + List transfers = new ArrayList<>(); + List conflicts = new ArrayList<>(); + for (Path source : listTree(latest)) { + if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + requireRegularFile(source, "Quarantined resource"); + String relative = relativePath(latest, source); + Path destination = resolveContained(packRoot, relative); + if (destination.startsWith(trashRoot)) { + throw new IOException("Quarantined resource resolves inside the quarantine root: " + relative); + } + if (hasDestinationConflict(packRoot, destination)) { + conflicts.add(relative); + } + transfers.add(new Transfer(relative, source, destination)); + } + transfers.sort(Comparator.comparing(Transfer::path)); + conflicts.sort(String::compareTo); + List paths = transfers.stream().map(Transfer::path).toList(); + return new RestoreScan(latest, dumpPath, List.copyOf(transfers), List.copyOf(paths), List.copyOf(conflicts)); + } + + private static List listTree(Path root) throws IOException { + requireDirectory(root, "Scan root"); + try (Stream stream = Files.walk(root)) { + List paths = stream.sorted(Comparator.comparing(path -> relativePath(root, path))).toList(); + for (Path path : paths) { + if (!path.equals(root) && Files.isSymbolicLink(path)) { + throw new IOException("Symbolic links are not supported during pack cleanup: " + path); + } + } + return paths; + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + private static Path createUniqueDump(Path packRoot) throws IOException { + Path trashRoot = resolveContained(packRoot, TRASH_ROOT); + if (Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) { + requireDirectory(trashRoot, "Quarantine root"); + } else { + Files.createDirectory(trashRoot); + } + String baseName = LocalDateTime.now().format(TRASH_STAMP); + for (int attempt = 0; attempt < 10_000; attempt++) { + String name = attempt == 0 ? baseName : baseName + '-' + String.format(Locale.ROOT, "%04d", attempt); + Path dump = resolveContained(trashRoot, name); + try { + return Files.createDirectory(dump); + } catch (FileAlreadyExistsException ignored) { + } + } + throw new IOException("Unable to allocate a unique quarantine directory."); + } + + private static List rollbackMoves(List moved) { + for (int i = moved.size() - 1; i >= 0; i--) { + Transfer transfer = moved.get(i); + try { + if (!Files.exists(transfer.source(), LinkOption.NOFOLLOW_LINKS) + && Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) { + Files.move(transfer.destination(), transfer.source()); + } + } catch (IOException | RuntimeException e) { + IrisLogging.reportError("Failed to roll back quarantined resource '" + transfer.path() + "'", e); + } + } + List remaining = new ArrayList<>(); + for (Transfer transfer : moved) { + if (Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) { + remaining.add(transfer.path()); + } + } + remaining.sort(String::compareTo); + return List.copyOf(remaining); + } + + private static RestoreResult rollbackSourceRemoval(Path packRoot, + RestoreScan scan, + List copiedDestinations, + List createdDirectories, + List removedSources, + Throwable failure) { + List sourceRollbackFailures = new ArrayList<>(); + for (int i = removedSources.size() - 1; i >= 0; i--) { + Transfer transfer = removedSources.get(i); + try { + Files.copy(transfer.destination(), transfer.source(), StandardCopyOption.COPY_ATTRIBUTES); + } catch (IOException | RuntimeException e) { + sourceRollbackFailures.add(transfer.path()); + IrisLogging.reportError("Failed to restore quarantined source '" + transfer.path() + "' during rollback", e); + } + } + List removableDestinations = new ArrayList<>(); + for (Path destination : copiedDestinations) { + String path = relativePath(packRoot, destination); + Path source = scan.dump().resolve(path).normalize(); + if (Files.exists(source, LinkOption.NOFOLLOW_LINKS)) { + removableDestinations.add(destination); + } + } + List destinationRemainders = rollbackCopies(packRoot, removableDestinations, createdDirectories); + List remainders = new ArrayList<>(sourceRollbackFailures.size() + destinationRemainders.size()); + remainders.addAll(sourceRollbackFailures); + for (String destinationRemainder : destinationRemainders) { + if (!remainders.contains(destinationRemainder)) { + remainders.add(destinationRemainder); + } + } + remainders.sort(String::compareTo); + String rollbackState = remainders.isEmpty() + ? "Restore source removal failed and was rolled back" + : "Restore source removal failed and rollback left " + remainders.size() + " destination(s)"; + IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), failure); + return new RestoreResult(scan.dumpPath(), remainders, List.of(), errorMessage(rollbackState, failure)); + } + + private static List rollbackCopies(Path packRoot, + List destinations, + List createdDirectories) { + for (int i = destinations.size() - 1; i >= 0; i--) { + try { + Files.deleteIfExists(destinations.get(i)); + } catch (IOException | RuntimeException e) { + IrisLogging.reportError("Failed to roll back restored resource '" + relativePath(packRoot, destinations.get(i)) + "'", e); + } + } + for (int i = createdDirectories.size() - 1; i >= 0; i--) { + deleteIfEmpty(createdDirectories.get(i)); + } + List remaining = new ArrayList<>(); + for (Path destination : destinations) { + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + remaining.add(relativePath(packRoot, destination)); + } + } + remaining.sort(String::compareTo); + return List.copyOf(remaining); + } + + private static void createDirectories(Path packRoot, Path target, List createdDirectories) throws IOException { + Path contained = requireContained(packRoot, target); + List missing = new ArrayList<>(); + Path current = contained; + while (!current.equals(packRoot) && !Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + missing.add(current); + current = current.getParent(); + } + if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(current)) { + throw new IOException("Restore parent is not a safe directory: " + current); + } + for (int i = missing.size() - 1; i >= 0; i--) { + Path directory = missing.get(i); + Files.createDirectory(directory); + createdDirectories.add(directory); + } + } + + private static boolean hasDestinationConflict(Path packRoot, Path destination) throws IOException { + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + return true; + } + Path parent = destination.getParent(); + while (parent != null && !parent.equals(packRoot)) { + if (Files.exists(parent, LinkOption.NOFOLLOW_LINKS) + && (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(parent))) { + return true; + } + parent = parent.getParent(); + } + return parent == null; + } + + private static void deleteEmptyDirectories(Path root) { + if (root == null || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try (Stream stream = Files.walk(root)) { + List directories = stream.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) + .sorted(Comparator.reverseOrder()) + .toList(); + for (Path directory : directories) { + deleteIfEmpty(directory); + } + } catch (IOException | RuntimeException ignored) { + } + } + + private static void deleteIfEmpty(Path directory) { + if (directory == null || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try (Stream stream = Files.list(directory)) { + if (stream.findAny().isEmpty()) { + Files.deleteIfExists(directory); + } + } catch (IOException | RuntimeException ignored) { + } + } + + private static boolean isScannableJson(Path packRoot, Path path) { + if (!isJsonFile(path)) { + return false; + } + Path relative = packRoot.relativize(path); + for (Path segment : relative) { + if (CORPUS_EXCLUSIONS.contains(segment.toString())) { + return false; + } + } + return true; + } + + private static boolean isJsonFile(Path path) { + return path.getFileName() != null && path.getFileName().toString().endsWith(".json") + && Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS); + } + + private static boolean isReferenced(String corpus, String key) { + if (corpus.contains("\"" + key + "\"")) { + return true; + } + int slash = key.indexOf('/'); + if (slash <= 0) { + return false; + } + String tail = key.substring(slash + 1); + return !tail.isBlank() && corpus.contains("\"" + tail + "\""); + } + + private static String stripJsonExtension(String path) { + return path.substring(0, path.length() - ".json".length()); + } + + private static Path requirePackRoot(File packFolder) throws IOException { + if (packFolder == null) { + throw new IOException("Pack folder is required."); + } + Path root = packFolder.toPath().toAbsolutePath().normalize(); + requireDirectory(root, "Pack folder"); + return root; + } + + private static void requireDirectory(Path path, String label) throws IOException { + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(label + " is not a safe directory: " + path); + } + } + + private static void requireRegularFile(Path path, String label) throws IOException { + if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(label + " is not a safe regular file: " + path); + } + } + + private static Path resolveContained(Path root, String relative) throws IOException { + return requireContained(root, root.resolve(relative).normalize()); + } + + private static Path requireContained(Path root, Path path) throws IOException { + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path normalizedPath = path.toAbsolutePath().normalize(); + if (!normalizedPath.startsWith(normalizedRoot)) { + throw new IOException("Path escapes pack boundary: " + path); + } + return normalizedPath; + } + + private static String relativePath(Path root, Path path) { + return root.relativize(path).toString().replace(File.separatorChar, '/'); + } + + private static Path lockPath(File packFolder) { + return packFolder == null ? null : packFolder.toPath().toAbsolutePath().normalize(); + } + + private static Object packLock(Path path) { + return PACK_LOCKS.computeIfAbsent(path, ignored -> new Object()); + } + + private static String errorMessage(String prefix, Throwable error) { + String message = error.getMessage(); + return prefix + (message == null || message.isBlank() ? "." : ": " + message); + } + + public record Preview(List candidatePaths, String error) { + public Preview { + candidatePaths = candidatePaths == null ? List.of() : List.copyOf(candidatePaths); + } + + public boolean success() { + return error == null; + } + + public boolean hasCandidates() { + return !candidatePaths.isEmpty(); + } + } + + public record ApplyResult(String quarantinePath, List quarantinedPaths, String error) { + public ApplyResult { + quarantinedPaths = quarantinedPaths == null ? List.of() : List.copyOf(quarantinedPaths); + } + + public boolean success() { + return error == null; + } + + public boolean changed() { + return !quarantinedPaths.isEmpty(); + } + } + + public record RestorePreview(String dumpPath, List filePaths, List conflicts, String error) { + public RestorePreview { + filePaths = filePaths == null ? List.of() : List.copyOf(filePaths); + conflicts = conflicts == null ? List.of() : List.copyOf(conflicts); + } + + public boolean success() { + return error == null; + } + + public boolean hasFiles() { + return !filePaths.isEmpty(); + } + + public boolean canRestore() { + return success() && hasFiles() && conflicts.isEmpty(); + } + + public boolean hasConflicts() { + return !conflicts.isEmpty(); + } + } + + public record RestoreResult(String dumpPath, List restoredPaths, List conflicts, String error) { + public RestoreResult { + restoredPaths = restoredPaths == null ? List.of() : List.copyOf(restoredPaths); + conflicts = conflicts == null ? List.of() : List.copyOf(conflicts); + } + + public boolean success() { + return error == null && conflicts.isEmpty(); + } + + public boolean changed() { + return !restoredPaths.isEmpty(); + } + + public boolean hasConflicts() { + return !conflicts.isEmpty(); + } + } + + private record CleanupScan(List paths) { + } + + private record RestoreScan(Path dump, String dumpPath, List transfers, List paths, + List conflicts) { + private static RestoreScan empty() { + return new RestoreScan(null, null, List.of(), List.of(), List.of()); + } + } + + private record Transfer(String path, Path source, Path destination) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java index b979bdfaf..b00e2e938 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java @@ -26,18 +26,15 @@ public final class PackValidationResult { private final String packName; private final List blockingErrors; private final List warnings; - private final List removedUnusedFiles; private final long validatedAtMillis; public PackValidationResult(String packName, List blockingErrors, List warnings, - List removedUnusedFiles, long validatedAtMillis) { this.packName = packName; this.blockingErrors = blockingErrors == null ? new ArrayList<>() : new ArrayList<>(blockingErrors); this.warnings = warnings == null ? new ArrayList<>() : new ArrayList<>(warnings); - this.removedUnusedFiles = removedUnusedFiles == null ? new ArrayList<>() : new ArrayList<>(removedUnusedFiles); this.validatedAtMillis = validatedAtMillis; } @@ -57,10 +54,6 @@ public final class PackValidationResult { return Collections.unmodifiableList(warnings); } - public List getRemovedUnusedFiles() { - return Collections.unmodifiableList(removedUnusedFiles); - } - public long getValidatedAtMillis() { return validatedAtMillis; } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index d8ee38796..9df668218 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -18,8 +18,10 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformEntityType; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; @@ -28,17 +30,16 @@ import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Stream; public final class PackValidator { @@ -50,19 +51,9 @@ public final class PackValidator { private static final String CACHE_FOLDER = "cache"; private static final String OBJECTS_FOLDER = "objects"; private static final String DIMENSIONS_FOLDER = "dimensions"; - private static final List MANAGED_RESOURCE_FOLDERS = List.of( - "biomes", - "regions", - "entities", - "spawners", - "loot", - "generators", - "expressions", - "markers", - "blocks", - "mods" - ); - private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final List STRUCTURE_HOST_FOLDERS = List.of(DIMENSIONS_FOLDER, "regions", "biomes"); + private static final List UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale"); + private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); private PackValidator() { } @@ -71,39 +62,376 @@ public final class PackValidator { String packName = packFolder == null ? "" : packFolder.getName(); List blockingErrors = new ArrayList<>(); List warnings = new ArrayList<>(); - List removedUnusedFiles = new ArrayList<>(); long validatedAt = System.currentTimeMillis(); if (packFolder == null || !packFolder.isDirectory()) { blockingErrors.add("Pack folder does not exist or is not a directory."); - return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt); + return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } File dimensionsFolder = new File(packFolder, DIMENSIONS_FOLDER); if (!dimensionsFolder.isDirectory()) { blockingErrors.add("Missing dimensions/ folder."); - return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt); + return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } File[] dimensionFiles = dimensionsFolder.listFiles(f -> f.isFile() && f.getName().endsWith(".json")); if (dimensionFiles == null || dimensionFiles.length == 0) { blockingErrors.add("No dimension JSON files under dimensions/."); - return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt); + return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); - - try { - String packTextCorpus = buildPackTextCorpus(packFolder); - runUnusedResourceGc(packFolder, packTextCorpus, removedUnusedFiles, warnings); - } catch (Throwable e) { - IrisLogging.reportError("PackValidator GC pass failed for pack '" + packName + "'", e); - warnings.add("Unused-resource GC pass failed: " + e.getMessage()); - } + blockingErrors.addAll(validateUnsupportedStructureTransforms(packFolder)); + blockingErrors.addAll(validateSpawnerEntityReferences( + new File(packFolder, "spawners"), new File(packFolder, "entities"))); + blockingErrors.addAll(validateCustomBiomeSpawns( + new File(packFolder, "biomes"), PackValidator::resolveEntitySpawnCategory)); runContentKeyValidation(packFolder, warnings); - return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt); + return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); + } + + static List validateUnsupportedStructureTransforms(File packFolder) { + List blockingErrors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return blockingErrors; + } + + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + String resourceType = structureHostType(folderName); + for (File resourceFile : resourceFiles) { + JSONObject resource; + try { + resource = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable ignored) { + continue; + } + + JSONArray placements = resource.optJSONArray("structures"); + if (placements == null) { + continue; + } + String resourceKey = deriveKey(resourceFolder, resourceFile); + for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { + JSONObject placement = placements.optJSONObject(placementIndex); + if (placement == null) { + continue; + } + for (String field : UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS) { + if (placement.has(field)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + placementIndex + + "] declares unsupported field '" + field + + "'. Structure placement transforms are not supported; remove the field."); + } + } + } + } + } + return blockingErrors; + } + + private static String structureHostType(String folderName) { + return switch (folderName) { + case "dimensions" -> "Dimension"; + case "regions" -> "Region"; + case "biomes" -> "Biome"; + default -> "Resource"; + }; + } + + static List validateSpawnerEntityReferences(File spawnersFolder, File entitiesFolder) { + List blockingErrors = new ArrayList<>(); + if (spawnersFolder == null || !spawnersFolder.isDirectory()) { + return blockingErrors; + } + + Path entityRoot = entitiesFolder.toPath().toAbsolutePath().normalize(); + Set validEntityFiles = new HashSet<>(); + Map invalidEntityFiles = new HashMap<>(); + List spawnerFiles = listJsonRecursive(spawnersFolder); + spawnerFiles.sort(Comparator.comparing(File::getPath)); + for (File spawnerFile : spawnerFiles) { + String spawnerKey = deriveKey(spawnersFolder, spawnerFile); + JSONObject spawner; + try { + spawner = new JSONObject(Files.readString(spawnerFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + blockingErrors.add("Spawner '" + spawnerKey + "' has invalid JSON: " + e.getMessage()); + continue; + } + + validateSpawnerSpawnEntries(spawnerKey, spawner, "spawns", entityRoot, + validEntityFiles, invalidEntityFiles, blockingErrors); + validateSpawnerSpawnEntries(spawnerKey, spawner, "initialSpawns", entityRoot, + validEntityFiles, invalidEntityFiles, blockingErrors); + } + return blockingErrors; + } + + private static void validateSpawnerSpawnEntries(String spawnerKey, + JSONObject spawner, + String field, + Path entityRoot, + Set validEntityFiles, + Map invalidEntityFiles, + List blockingErrors) { + if (!spawner.has(field)) { + return; + } + JSONArray entries = spawner.optJSONArray(field); + if (entries == null) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " must be an array."); + return; + } + + for (int index = 0; index < entries.length(); index++) { + JSONObject entry = entries.optJSONObject(index); + if (entry == null) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + + " has a non-object entry at index " + index + "."); + continue; + } + if (!entry.has("entity") || entry.isNull("entity")) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + + " has an entry without an entity reference at index " + index + "."); + continue; + } + + Object rawEntity = entry.get("entity"); + if (!(rawEntity instanceof String entityKey)) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + + " entity reference at index " + index + " must be a string."); + continue; + } + if (entityKey.isBlank()) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + + " has a blank entity reference at index " + index + "."); + continue; + } + + Path entityFile; + try { + if (entityKey.indexOf('\\') >= 0) { + throw new IllegalArgumentException("backslash path separators are not portable"); + } + entityFile = entityRoot.resolve(entityKey + ".json").normalize(); + } catch (RuntimeException e) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index + + " has invalid entity reference '" + entityKey + "': " + e.getMessage()); + continue; + } + if (!entityFile.startsWith(entityRoot)) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index + + " has unsafe entity reference '" + entityKey + "'."); + continue; + } + if (!Files.isRegularFile(entityFile)) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index + + " references missing entity '" + entityKey + "'."); + continue; + } + + String invalidJson = invalidEntityFiles.get(entityFile); + if (invalidJson != null) { + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index + + " references malformed entity '" + entityKey + "': " + invalidJson); + continue; + } + if (validEntityFiles.contains(entityFile)) { + continue; + } + + try { + new JSONObject(Files.readString(entityFile, StandardCharsets.UTF_8)); + validEntityFiles.add(entityFile); + } catch (Throwable e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + invalidEntityFiles.put(entityFile, message); + blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index + + " references malformed entity '" + entityKey + "': " + message); + } + } + } + + static List validateCustomBiomeSpawns(File biomesFolder, Function categoryResolver) { + List blockingErrors = new ArrayList<>(); + if (biomesFolder == null || !biomesFolder.isDirectory()) { + return blockingErrors; + } + + List biomeFiles = listJsonRecursive(biomesFolder); + biomeFiles.sort(Comparator.comparing(File::getPath)); + for (File biomeFile : biomeFiles) { + String biomeKey = deriveKey(biomesFolder, biomeFile); + JSONObject biome; + try { + biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + blockingErrors.add("Biome '" + biomeKey + "' has invalid JSON: " + e.getMessage()); + continue; + } + + JSONArray derivatives = biome.optJSONArray("customDerivitives"); + if (derivatives == null) { + if (biome.has("customDerivitives") && !biome.isNull("customDerivitives")) { + blockingErrors.add("Biome '" + biomeKey + "' customDerivitives must be an array."); + } + continue; + } + for (int derivativeIndex = 0; derivativeIndex < derivatives.length(); derivativeIndex++) { + JSONObject derivative = derivatives.optJSONObject(derivativeIndex); + if (derivative == null) { + blockingErrors.add("Biome '" + biomeKey + "' has a non-object custom derivative at index " + derivativeIndex + "."); + continue; + } + validateCustomBiomeDerivativeTags(biomeKey, derivative, derivativeIndex, blockingErrors); + validateCustomBiomeDerivativeSpawns( + biomeKey, derivative, derivativeIndex, categoryResolver, blockingErrors); + } + } + return blockingErrors; + } + + private static void validateCustomBiomeDerivativeTags(String biomeKey, + JSONObject derivative, + int derivativeIndex, + List blockingErrors) { + if (!derivative.has("tags") || derivative.isNull("tags")) { + return; + } + String derivativeId = derivative.optString("id", "#" + derivativeIndex); + JSONArray tags = derivative.optJSONArray("tags"); + if (tags == null) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' tags must be an array."); + return; + } + for (int tagIndex = 0; tagIndex < tags.length(); tagIndex++) { + Object rawTag = tags.opt(tagIndex); + if (!(rawTag instanceof String tag)) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' has a non-string tag at index " + tagIndex + "."); + continue; + } + String normalized = tag.trim().toLowerCase(Locale.ROOT); + if (normalized.indexOf(':') < 0) { + normalized = "minecraft:" + normalized; + } + if (!isSafeResourceKey(normalized)) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' has invalid tag '" + tag + "'."); + } + } + } + + private static boolean isSafeResourceKey(String key) { + if (!RESOURCE_KEY_PATTERN.matcher(key).matches()) { + return false; + } + int separator = key.indexOf(':'); + String[] segments = key.substring(separator + 1).split("/"); + for (String segment : segments) { + if (segment.equals("..")) { + return false; + } + } + return true; + } + + private static void validateCustomBiomeDerivativeSpawns(String biomeKey, + JSONObject derivative, + int derivativeIndex, + Function categoryResolver, + List blockingErrors) { + JSONArray spawns = derivative.optJSONArray("spawns"); + if (spawns == null) { + if (derivative.has("spawns") && !derivative.isNull("spawns")) { + String derivativeId = derivative.optString("id", "#" + derivativeIndex); + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawns must be an array."); + } + return; + } + String derivativeId = derivative.optString("id", "#" + derivativeIndex); + for (int spawnIndex = 0; spawnIndex < spawns.length(); spawnIndex++) { + JSONObject spawn = spawns.optJSONObject(spawnIndex); + if (spawn == null) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' has a non-object spawn at index " + spawnIndex + "."); + continue; + } + + String type = spawn.optString("type", "").trim().toLowerCase(Locale.ROOT); + if (type.isEmpty()) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' has a spawn without an entity type at index " + spawnIndex + "."); + continue; + } + String typeKey = type.indexOf(':') >= 0 ? type : "minecraft:" + type; + SpawnCategoryResolution resolution; + try { + resolution = categoryResolver == null ? null : categoryResolver.apply(typeKey); + } catch (Throwable e) { + IrisLogging.reportError("PackValidator failed to resolve spawn category for '" + typeKey + "'", e); + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawn category lookup failed for '" + typeKey + "': " + e.getMessage()); + continue; + } + if (resolution != null && !resolution.entityKnown()) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawn references unknown entity type '" + typeKey + "'."); + continue; + } + String expectedGroup = resolution == null ? null : resolution.category(); + String group = spawn.optString("group", "").trim(); + if (group.isEmpty()) { + if (expectedGroup != null && !expectedGroup.isBlank() + && !IrisBiomeCustomSpawnType.MISC.name().equalsIgnoreCase(expectedGroup)) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawn '" + typeKey + "' must declare group '" + + expectedGroup.toUpperCase(Locale.ROOT) + "'."); + } + continue; + } + + IrisBiomeCustomSpawnType configuredGroup; + try { + configuredGroup = IrisBiomeCustomSpawnType.valueOf(group); + } catch (IllegalArgumentException e) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawn '" + typeKey + "' declares unknown group '" + group + "'."); + continue; + } + + if (expectedGroup != null && !expectedGroup.isBlank() + && !configuredGroup.name().equalsIgnoreCase(expectedGroup)) { + blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId + + "' spawn '" + typeKey + "' declares group '" + configuredGroup.name() + + "' but the live entity registry requires '" + expectedGroup.toUpperCase(Locale.ROOT) + "'."); + } + } + } + + private static SpawnCategoryResolution resolveEntitySpawnCategory(String typeKey) { + if (!IrisPlatforms.isBound()) { + return null; + } + PlatformRegistries registries = IrisPlatforms.get().registries(); + if (registries == null) { + return null; + } + PlatformEntityType entityType = registries.entity(typeKey); + return entityType == null + ? SpawnCategoryResolution.unknown() + : SpawnCategoryResolution.known(entityType.spawnCategory()); } private static void runContentKeyValidation(File packFolder, List warnings) { @@ -221,6 +549,16 @@ public final class PackValidator { private record ReferencedContentKeys(Set blocks, Set items, Set entities) { } + record SpawnCategoryResolution(boolean entityKnown, String category) { + static SpawnCategoryResolution unknown() { + return new SpawnCategoryResolution(false, null); + } + + static SpawnCategoryResolution known(String category) { + return new SpawnCategoryResolution(true, category); + } + } + private static void validateDimensions(File packFolder, File[] dimensionFiles, List blockingErrors, List warnings) { File regionsFolder = new File(packFolder, "regions"); File biomesFolder = new File(packFolder, "biomes"); @@ -299,24 +637,6 @@ public final class PackValidator { return resolved; } - private static String buildPackTextCorpus(File packFolder) { - StringBuilder sb = new StringBuilder(1 << 16); - try (Stream stream = Files.walk(packFolder.toPath())) { - stream.filter(Files::isRegularFile) - .filter(PackValidator::isScannableJsonPath) - .forEach(p -> { - try { - sb.append(Files.readString(p, StandardCharsets.UTF_8)); - sb.append('\n'); - } catch (Throwable ignored) { - } - }); - } catch (Throwable e) { - IrisLogging.reportError("PackValidator failed to walk pack folder for corpus scan", e); - } - return sb.toString(); - } - private static boolean isScannableJsonPath(Path path) { String name = path.getFileName().toString(); if (!name.endsWith(".json")) { @@ -350,66 +670,6 @@ public final class PackValidator { return true; } - private static void runUnusedResourceGc(File packFolder, String corpus, List removedUnusedFiles, List warnings) { - if (corpus == null || corpus.isEmpty()) { - return; - } - File trashRoot = new File(packFolder, TRASH_ROOT + File.separator + LocalDateTime.now().format(TRASH_STAMP)); - Set scheduledForTrash = new LinkedHashSet<>(); - - for (String folderName : MANAGED_RESOURCE_FOLDERS) { - File resourceFolder = new File(packFolder, folderName); - if (!resourceFolder.isDirectory()) { - continue; - } - - List files = listJsonRecursive(resourceFolder); - for (File resourceFile : files) { - String key = deriveKey(resourceFolder, resourceFile); - if (key == null || key.isBlank()) { - continue; - } - if (isReferenced(corpus, key)) { - continue; - } - scheduledForTrash.add(resourceFile); - } - } - - if (scheduledForTrash.isEmpty()) { - return; - } - - for (File file : scheduledForTrash) { - try { - Path src = file.toPath(); - Path relative = packFolder.toPath().relativize(src); - Path dest = trashRoot.toPath().resolve(relative); - Files.createDirectories(dest.getParent()); - Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING); - removedUnusedFiles.add(relative.toString().replace(File.separatorChar, '/')); - } catch (Throwable e) { - IrisLogging.reportError("PackValidator failed to move unused file " + file.getPath() + " to trash", e); - warnings.add("Failed to quarantine unused file " + file.getName() + ": " + e.getMessage()); - } - } - } - - private static boolean isReferenced(String corpus, String key) { - String needleQuoted = "\"" + key + "\""; - if (corpus.contains(needleQuoted)) { - return true; - } - int slash = key.indexOf('/'); - if (slash > 0) { - String tail = key.substring(slash + 1); - if (!tail.isBlank() && corpus.contains("\"" + tail + "\"")) { - return true; - } - } - return false; - } - private static List listJsonRecursive(File root) { List out = new ArrayList<>(); try (Stream stream = Files.walk(root.toPath())) { @@ -435,49 +695,6 @@ public final class PackValidator { return dot <= 0 ? name : name.substring(0, dot); } - public static int restoreTrash(File packFolder) { - if (packFolder == null || !packFolder.isDirectory()) { - return 0; - } - File trashRoot = new File(packFolder, TRASH_ROOT); - if (!trashRoot.isDirectory()) { - return 0; - } - File[] dumps = trashRoot.listFiles(File::isDirectory); - if (dumps == null || dumps.length == 0) { - return 0; - } - Arrays.sort(dumps, Comparator.comparing(File::getName)); - File latestDump = dumps[dumps.length - 1]; - int restored = 0; - try (Stream stream = Files.walk(latestDump.toPath())) { - List files = stream.filter(Files::isRegularFile).toList(); - for (Path src : files) { - Path relative = latestDump.toPath().relativize(src); - Path dest = packFolder.toPath().resolve(relative); - Files.createDirectories(dest.getParent()); - Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING); - restored++; - } - } catch (Throwable e) { - IrisLogging.reportError("PackValidator failed to restore trash for pack " + packFolder.getName(), e); - } - deleteFolderQuiet(latestDump); - return restored; - } - - private static void deleteFolderQuiet(File folder) { - if (folder == null || !folder.exists()) { - return; - } - try (Stream stream = Files.walk(folder.toPath())) { - stream.sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); - } catch (Throwable ignored) { - } - } - public static Set listReferencedKeysFromCorpus(String corpus) { Set keys = new HashSet<>(); if (corpus == null) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 3bc6a2eda..a4908dcf3 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -26,7 +26,6 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.tools.IrisPackBenchmarking; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; -import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.M; @@ -217,16 +216,21 @@ public class IrisPregenerator { } finally { shutdown(); } - if (completed) { + if (completed && allVisitsComplete()) { logSuccessfulCompletion(p); - } - if (benchmarking == null) { - IrisLogging.info(C.IRIS + "Pregen stopped."); } else { + logIncompleteCompletion(p); + } + if (benchmarking != null) { benchmarking.finishedBenchmark(chunksPerSecondHistory); } } + private boolean allVisitsComplete() { + long total = totalChunks.get(); + return total > 0 && generated.get() + failed.get() >= total; + } + private void logSuccessfulCompletion(PrecisionStopwatch stopwatch) { long failedCount = failed.get(); if (failedCount > 0) { @@ -238,6 +242,19 @@ public class IrisPregenerator { + " duration=" + Form.duration((long) stopwatch.getMilliseconds())); } + private void logIncompleteCompletion(PrecisionStopwatch stopwatch) { + long generatedCount = generated.get(); + long failedCount = failed.get(); + long total = totalChunks.get(); + long remaining = Math.max(0L, total - generatedCount - failedCount); + String status = shutdown.get() ? "Pregen cancelled" : "Pregen stopped before completion"; + IrisLogging.info(status + ": generated=" + Form.f(generatedCount) + + " total=" + Form.f(total) + + " failed=" + Form.f(failedCount) + + " remaining=" + Form.f(remaining) + + " duration=" + Form.duration((long) stopwatch.getMilliseconds())); + } + private void checkRegions() { task.iterateRegions(this::checkRegion); } diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java index c613ddf70..6453f2db0 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java @@ -646,7 +646,7 @@ public class IrisProject { biomes.forEach((i) -> i.getGenerators().forEach((j) -> generators.add(j.getCachedGenerator(() -> dm)))); biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i)))); biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp)))); - spawners.forEach((i) -> i.getSpawns().forEach((j) -> entities.add(dm.getEntityLoader().load(j.getEntity())))); + collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i))); KMap renameObjects = new KMap<>(); String a; StringBuilder b = new StringBuilder(); @@ -769,6 +769,15 @@ public class IrisProject { return null; } + static KSet collectSpawnerEntityKeys(KSet spawners) { + KSet entityKeys = new KSet<>(); + for (IrisSpawner spawner : spawners) { + spawner.getSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity())); + spawner.getInitialSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity())); + } + return entityKeys; + } + public void compile(VolmitSender sender) { IrisData data = IrisData.get(getPath()); KList jobs = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 85c0842c6..409d538d5 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -130,8 +130,17 @@ public class StudioSVC implements IrisService { queueStudioWorldDeletionOnStartup(worldNamesToDelete); } - public IrisDimension installIntoWorld(VolmitSender sender, String type, File folder) { - return installInto(sender, type, new File(folder, "iris/pack")); + public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) { + File target = new File(folder, "iris/pack"); + File source = dimension.getLoader().getDataFolder(); + sender.sendMessage("Installing Package: " + source.getName() + ":" + dimension.getLoadKey()); + try { + FileUtils.copyDirectory(source, target); + } catch (IOException e) { + IrisLogging.reportError(e); + return null; + } + return IrisData.get(target).getDimensionLoader().load(dimension.getLoadKey()); } public IrisDimension installInto(VolmitSender sender, String type, File folder) { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 397d19e53..4c05d4c9d 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -166,7 +166,11 @@ public class IrisCreator { reportStudioProgress(0.16D, "prepare_world_pack"); if (!studio() || benchmark) { - IrisServices.get(StudioSVC.class).installIntoWorld(sender, d.getLoadKey(), new File(Bukkit.getWorldContainer(), name())); + d = IrisServices.get(StudioSVC.class).installIntoWorld(sender, d, new File(Bukkit.getWorldContainer(), name())); + if (d == null) { + throw new IrisException("Failed to install dimension pack for " + dimension()); + } + dimension = d.getLoadKey(); } if (studio()) { IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); @@ -178,7 +182,7 @@ public class IrisCreator { AtomicDouble pp = new AtomicDouble(0); AtomicBoolean done = new AtomicBoolean(false); WorldCreator wc = new IrisWorldCreator() - .dimension(dimension) + .dimension(d) .name(name) .seed(seed) .studio(studio) diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index c52f8299f..f9cd16dad 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -81,23 +81,23 @@ public class IrisToolbelt { return null; } - String requested = dimension.trim(); - if (requested.isEmpty()) { + PackReference reference = parsePackReference(dimension); + if (reference == null) { return null; } File packsFolder = IrisPlatforms.get().dataFolder("packs"); - File pack = new File(packsFolder, requested); + File pack = new File(packsFolder, reference.pack()); if (!pack.exists()) { - File found = findCaseInsensitivePack(packsFolder, requested); + File found = findCaseInsensitivePack(packsFolder, reference.pack()); if (found != null) { pack = found; } } if (!pack.exists()) { - IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), requested, false); - File found = findCaseInsensitivePack(packsFolder, requested); + IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), reference.pack(), false); + File found = findCaseInsensitivePack(packsFolder, reference.pack()); if (found != null) { pack = found; } @@ -108,13 +108,16 @@ public class IrisToolbelt { } IrisData data = IrisData.get(pack); - IrisDimension resolved = data.getDimensionLoader().load(requested, false); + IrisDimension resolved = data.getDimensionLoader().load(reference.dimension(), false); if (resolved != null) { return resolved; } + if (reference.explicitDimension()) { + return null; + } String packName = pack.getName(); - if (!packName.equals(requested)) { + if (!packName.equals(reference.pack())) { resolved = data.getDimensionLoader().load(packName, false); if (resolved != null) { return resolved; @@ -122,7 +125,7 @@ public class IrisToolbelt { } for (String key : data.getDimensionLoader().getPossibleKeys()) { - if (!key.equalsIgnoreCase(requested) && !key.equalsIgnoreCase(packName)) { + if (!key.equalsIgnoreCase(reference.pack()) && !key.equalsIgnoreCase(packName)) { continue; } @@ -135,6 +138,26 @@ public class IrisToolbelt { return null; } + static PackReference parsePackReference(String value) { + if (value == null) { + return null; + } + String requested = value.trim(); + if (requested.isEmpty()) { + return null; + } + int separator = requested.indexOf(':'); + if (separator < 0) { + return new PackReference(requested, requested, false); + } + String pack = requested.substring(0, separator).trim(); + String dimension = requested.substring(separator + 1).trim(); + if (pack.isEmpty() || dimension.isEmpty()) { + return null; + } + return new PackReference(pack, dimension, true); + } + private static File findCaseInsensitivePack(File packsFolder, String requested) { File[] children = packsFolder.listFiles(); if (children == null) { @@ -503,4 +526,7 @@ public class IrisToolbelt { public static boolean removeWorld(World world) throws IOException { return IrisCreator.removeFromBukkitYml(world.getName()); } + + record PackReference(String pack, String dimension, boolean explicitDimension) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java index 42831ff8a..c4397efb3 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java @@ -33,6 +33,7 @@ public class IrisWorldCreator { private String name; private boolean studio = false; private String dimensionName = null; + private IrisDimension dimension; private long seed = 1337; public IrisWorldCreator() { @@ -41,6 +42,13 @@ public class IrisWorldCreator { public IrisWorldCreator dimension(String loadKey) { this.dimensionName = loadKey; + this.dimension = null; + return this; + } + + public IrisWorldCreator dimension(IrisDimension dimension) { + this.dimension = dimension; + this.dimensionName = dimension.getLoadKey(); return this; } @@ -65,7 +73,7 @@ public class IrisWorldCreator { } public WorldCreator create() { - IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null); + IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; IrisWorld w = IrisWorld.builder() .name(name) @@ -87,7 +95,7 @@ public class IrisWorldCreator { } private World.Environment findEnvironment() { - IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null); + IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; if (dim == null || dim.getEnvironment() == null) { return World.Environment.NORMAL; } else { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 18631d68e..d57cb0f21 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -42,8 +42,10 @@ import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import lombok.AccessLevel; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.ToString; import java.io.File; @@ -56,13 +58,14 @@ import java.util.Set; import java.util.UUID; @Data -@EqualsAndHashCode(exclude = "data") -@ToString(exclude = "data") +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache"}) +@ToString(exclude = {"data", "gridBoundsCache"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int HEIGHT_BOUNDS_GRID = 4; - private static final ThreadLocal GRID_BOUNDS_CACHE = ThreadLocal.withInitial(GridBoundsCache::new); + @Getter(AccessLevel.NONE) + private final transient ThreadLocal gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); private RNG rng; private double fluidHeight; private IrisData data; @@ -362,7 +365,7 @@ public class IrisComplex implements DataProvider { double fx = (x - gx) / grid; double fz = (z - gz) / grid; - GridBoundsCache cache = GRID_BOUNDS_CACHE.get(); + GridBoundsCache cache = gridBoundsCache.get(); long b00 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz); long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz); long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index b44f0bee4..7beef7b7b 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -67,6 +67,8 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.inventory.ItemStack; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; @@ -78,6 +80,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -86,6 +90,8 @@ import java.util.stream.Stream; @EqualsAndHashCode(callSuper = true) @Data public class IrisWorldManager extends EngineAssignedWorldManager { + private static final int MAX_FORCED_CHUNK_UPDATES = 128; + private final Looper looper; private final int id; private final KList updateQueue = new KList<>(); @@ -100,11 +106,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager { private final Set markerFlagQueue = ConcurrentHashMap.newKeySet(); private final Set discoveredFlagQueue = ConcurrentHashMap.newKeySet(); private final Set markerScanQueue = ConcurrentHashMap.newKeySet(); + private final Set chunkUpdateQueue = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean(); + private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean(); private int entityCount = 0; private int actuallySpawned = 0; private int cooldown = 0; - private List precount = new KList<>(); + private int forcedChunkUpdateCursor = 0; + private volatile boolean playersPresent = false; private KSet injectBiomes = new KSet<>(); + private volatile int loadedChunkCount = 0; public IrisWorldManager() { super(null); @@ -126,7 +137,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { cl = new ChronoLatch(3000); clw = new ChronoLatch(1000, true); cleanupService = Executors.newSingleThreadScheduledExecutor(runnable -> { - var thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name()); + Thread thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name()); thread.setPriority(Thread.MIN_PRIORITY); return thread; }); @@ -139,18 +150,18 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } if (!getEngine().getWorld().hasRealWorld() && clw.flip()) { - getEngine().getWorld().tryGetRealWorld(); + J.runGlobal(() -> getEngine().getWorld().tryGetRealWorld()); } if (getEngine().getWorld().hasRealWorld()) { - if (getEngine().getWorld().getPlayers().isEmpty()) { - return 5000; - } - if (chunkUpdater.flip()) { updateChunks(); } + if (!playersPresent) { + return 5000; + } + if (chunkDiscovery.flip()) { discoverChunks(); } @@ -163,19 +174,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return 3000; } - if (precount != null) { - entityCount = 0; - for (Entity i : precount) { - if (i instanceof LivingEntity) { - if (!i.isDead()) { - entityCount++; - } - } - } - - precount = null; - } - onAsyncTick(); } @@ -202,23 +200,46 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - for (Player player : getEngine().getWorld().getPlayers()) { - if (player == null || !player.isOnline()) { - continue; - } + if (!chunkDiscoveryScanScheduled.compareAndSet(false, true)) { + return; + } - J.runEntity(player, () -> { - int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX()); - int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ()); - int radius = 1; - for (int x = -radius; x <= radius; x++) { - for (int z = -radius; z <= radius; z++) { - int chunkX = centerX + x; - int chunkZ = centerZ + z; - raiseDiscoveredChunkFlag(world, chunkX, chunkZ); - } + boolean scheduled = J.runGlobal(() -> { + try { + if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) { + return; } - }); + + for (Player player : world.getPlayers()) { + if (player == null) { + continue; + } + + J.runEntity(player, () -> { + if (!player.isOnline() || !world.equals(player.getWorld())) { + return; + } + + int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX()); + int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ()); + int radius = 1; + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + int chunkX = centerX + x; + int chunkZ = centerZ + z; + raiseDiscoveredChunkFlag(world, chunkX, chunkZ); + } + } + }); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } finally { + chunkDiscoveryScanScheduled.set(false); + } + }); + if (!scheduled) { + chunkDiscoveryScanScheduled.set(false); } } @@ -261,24 +282,98 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } - for (Player player : getEngine().getWorld().getPlayers()) { - if (player == null || !player.isOnline()) { - continue; + if (!chunkUpdateScanScheduled.compareAndSet(false, true)) { + return; + } + + boolean scheduled = J.runGlobal(() -> updateChunksOnGlobal(world)); + if (!scheduled) { + chunkUpdateScanScheduled.set(false); + } + } + + private void updateChunksOnGlobal(World world) { + try { + if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) { + return; } - J.runEntity(player, () -> { - int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX()); - int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ()); - int radius = 1; + List players = new ArrayList<>(world.getPlayers()); + playersPresent = !players.isEmpty(); + loadedChunkCount = world.getLoadedChunks().length; + for (Player player : players) { + if (player == null) { + continue; + } - for (int x = -radius; x <= radius; x++) { - for (int z = -radius; z <= radius; z++) { - int targetX = centerX + x; - int targetZ = centerZ + z; - J.runRegion(world, targetX, targetZ, () -> updateChunkRegion(world, targetX, targetZ)); - } + J.runEntity(player, () -> schedulePlayerChunkUpdates(world, player)); + } + + scheduleForcedChunkUpdates(world); + } catch (Throwable e) { + IrisLogging.reportError(e); + } finally { + chunkUpdateScanScheduled.set(false); + } + } + + private void schedulePlayerChunkUpdates(World world, Player player) { + if (!player.isOnline() || !world.equals(player.getWorld())) { + return; + } + + int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX()); + int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ()); + int radius = 1; + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + scheduleChunkUpdate(world, centerX + x, centerZ + z); + } + } + } + + private void scheduleForcedChunkUpdates(World world) { + List forcedChunks = new ArrayList<>(); + for (Chunk chunk : world.getForceLoadedChunks()) { + forcedChunks.add(new Position2(chunk.getX(), chunk.getZ())); + } + forcedChunks.sort(Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ)); + + int forcedChunkCount = forcedChunks.size(); + if (forcedChunkCount == 0) { + forcedChunkUpdateCursor = 0; + return; + } + + int updateCount = Math.min(forcedChunkCount, MAX_FORCED_CHUNK_UPDATES); + int start = Math.floorMod(forcedChunkUpdateCursor, forcedChunkCount); + for (int i = 0; i < updateCount; i++) { + Position2 chunk = forcedChunks.get((start + i) % forcedChunkCount); + scheduleChunkUpdate(world, chunk.getX(), chunk.getZ()); + } + forcedChunkUpdateCursor = (start + updateCount) % forcedChunkCount; + } + + private void scheduleChunkUpdate(World world, int chunkX, int chunkZ) { + long key = Cache.key(chunkX, chunkZ); + if (!chunkUpdateQueue.add(key)) { + return; + } + + try { + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + updateChunkRegion(world, chunkX, chunkZ); + } finally { + chunkUpdateQueue.remove(key); } }); + if (!scheduled) { + chunkUpdateQueue.remove(key); + } + } catch (Throwable e) { + chunkUpdateQueue.remove(key); + IrisLogging.reportError(e); } } @@ -407,6 +502,36 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return false; } + if (cl.flip()) { + try { + World realWorld = getEngine().getWorld().realWorld(); + if (realWorld == null) { + entityCount = 0; + } else if (J.isFolia()) { + entityCount = getFoliaLivingEntityCount(realWorld); + } else { + CompletableFuture future = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { + try { + int count = 0; + for (Entity entity : realWorld.getEntities()) { + if (entity instanceof LivingEntity && !entity.isDead()) { + count++; + } + } + future.complete(count); + } catch (Throwable ex) { + future.completeExceptionally(ex); + } + }); + entityCount = scheduled ? future.get(2, TimeUnit.SECONDS) : 0; + } + } catch (Throwable e) { + IrisLogging.reportError(e); + close(); + } + } + double epx = getEntitySaturation(); if (epx > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) { IrisLogging.debug("Can't spawn. The entity per chunk ratio is at " + Form.pc(epx, 2) + " > 100% (total entities " + entityCount + ")"); @@ -414,43 +539,20 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return false; } - if (cl.flip()) { - try { - World realWorld = getEngine().getWorld().realWorld(); - if (realWorld == null) { - precount = new KList<>(); - } else if (J.isFolia()) { - precount = getFoliaEntitySnapshot(realWorld); - } else { - CompletableFuture> future = new CompletableFuture<>(); - J.s(() -> { - try { - future.complete(realWorld.getEntities()); - } catch (Throwable ex) { - future.completeExceptionally(ex); - } - }); - precount = future.get(2, TimeUnit.SECONDS); - } - } catch (Throwable e) { - close(); - } - } - int spawnBuffer = RNG.r.i(2, 12); World world = getEngine().getWorld().realWorld(); if (world == null) { return false; } - Chunk[] cc = getLoadedChunksSnapshot(world); + Position2[] cc = getLoadedChunkPositionsSnapshot(world); while (spawnBuffer-- > 0) { if (cc.length == 0) { IrisLogging.debug("Can't spawn. No chunks!"); return false; } - Chunk c = cc[RNG.r.nextInt(cc.length)]; + Position2 c = cc[RNG.r.nextInt(cc.length)]; spawnChunkSafely(world, c.getX(), c.getZ(), false); } @@ -475,48 +577,76 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return job.targetsWorldName(world.getName()); } - private Chunk[] getLoadedChunksSnapshot(World world) { + private Position2[] getLoadedChunkPositionsSnapshot(World world) { if (world == null) { - return new Chunk[0]; + return new Position2[0]; } - CompletableFuture future = new CompletableFuture<>(); - J.s(() -> { + CompletableFuture future = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { try { - future.complete(world.getLoadedChunks()); + Chunk[] chunks = world.getLoadedChunks(); + Position2[] positions = new Position2[chunks.length]; + for (int i = 0; i < chunks.length; i++) { + positions[i] = new Position2(chunks[i].getX(), chunks[i].getZ()); + } + loadedChunkCount = positions.length; + future.complete(positions); } catch (Throwable e) { future.completeExceptionally(e); } }); + if (!scheduled) { + return new Position2[0]; + } try { return future.get(2, TimeUnit.SECONDS); } catch (Throwable e) { IrisLogging.reportError(e); - return new Chunk[0]; + return new Position2[0]; } } - private List getFoliaEntitySnapshot(World world) { - Map snapshot = new ConcurrentHashMap<>(); - List players = getEngine().getWorld().getPlayers(); - if (players == null || players.isEmpty()) { - return new KList<>(); + private int getFoliaLivingEntityCount(World world) { + CompletableFuture> playerFuture = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { + try { + playerFuture.complete(new ArrayList<>(world.getPlayers())); + } catch (Throwable e) { + playerFuture.completeExceptionally(e); + } + }); + if (!scheduled) { + return 0; } + List players; + try { + players = playerFuture.get(2, TimeUnit.SECONDS); + } catch (Throwable e) { + IrisLogging.reportError(e); + return 0; + } + + Map candidates = new ConcurrentHashMap<>(); + CountDownLatch latch = new CountDownLatch(players.size()); for (Player player : players) { - if (player == null || !player.isOnline() || !world.equals(player.getWorld())) { + if (player == null) { latch.countDown(); continue; } if (!J.runEntity(player, () -> { try { - snapshot.put(player.getUniqueId().toString(), player); + if (!player.isOnline() || !world.equals(player.getWorld())) { + return; + } + candidates.put(player.getUniqueId().toString(), player); for (Entity nearby : player.getNearbyEntities(64, 64, 64)) { - if (nearby != null && world.equals(nearby.getWorld())) { - snapshot.put(nearby.getUniqueId().toString(), nearby); + if (nearby != null) { + candidates.put(nearby.getUniqueId().toString(), nearby); } } } finally { @@ -533,9 +663,28 @@ public class IrisWorldManager extends EngineAssignedWorldManager { Thread.currentThread().interrupt(); } - KList entities = new KList<>(); - entities.addAll(snapshot.values()); - return entities; + AtomicInteger count = new AtomicInteger(); + CountDownLatch entityLatch = new CountDownLatch(candidates.size()); + for (Entity entity : candidates.values()) { + if (!J.runEntity(entity, () -> { + try { + if (entity instanceof LivingEntity && world.equals(entity.getWorld()) && !entity.isDead()) { + count.incrementAndGet(); + } + } finally { + entityLatch.countDown(); + } + })) { + entityLatch.countDown(); + } + } + + try { + entityLatch.await(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return count.get(); } private void spawnChunkSafely(World world, int chunkX, int chunkZ, boolean initial) { @@ -1039,7 +1188,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { @Override public int getChunkCount() { - return getEngine().getWorld().realWorld().getLoadedChunks().length; + return loadedChunkCount; } @Override @@ -1048,7 +1197,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return 1; } - return (double) entityCount / (getEngine().getWorld().realWorld().getLoadedChunks().length + 1) * 1.28; + return (double) entityCount / (loadedChunkCount + 1) * 1.28; } @Data diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java index b77728ebd..19e2bb57b 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java @@ -55,12 +55,12 @@ public class IrisBiomeActuator extends EngineAssignedActuator { PlatformBiome biome; if (ib.isCustom()) { - IrisBiomeCustom custom = ib.getCustomBiome(rng, x, 0, z); + IrisBiomeCustom custom = ib.getCustomBiome(rng, getEngine(), x + xf, 0, z + zf); String key = getDimension().getLoadKey() + ":" + custom.getId(); biome = IrisPlatforms.get().registries().biome(key); matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(key)); } else { - String skyKey = ib.getSkyBiomeKey(rng, x, 0, z); + String skyKey = ib.getSkyBiomeKey(rng, getEngine(), x + xf, 0, z + zf); biome = IrisPlatforms.get().registries().biome(skyKey); matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(skyKey)); } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index d220058af..15b3ed91b 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -42,9 +42,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator PREDICATE_SOLID = (s) -> s != null && !B.isAirOrFluid(s); private final RNG rng; @Getter - private final EngineDecorator surfaceDecorator; + private final IrisSurfaceDecorator surfaceDecorator; @Getter - private final EngineDecorator ceilingDecorator; + private final IrisCeilingDecorator ceilingDecorator; @Getter private final EngineDecorator seaSurfaceDecorator; @Getter diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java index cbb9f2002..520b44525 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java @@ -41,7 +41,13 @@ public class IrisCeilingDecorator extends IrisEngineDecorator { @Override public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, int height, int max) { - boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE; + decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max); + } + + @BlockCoordinates + public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, + Hunk data, IrisBiome biome, InferredType inferredType, int height, int max) { + boolean caveSkipFluid = IrisSurfaceDecorator.skipsFluid(inferredType); RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java index 534f1317e..225567af6 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java @@ -52,13 +52,19 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { @Override public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, int height, int max) { + decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max); + } + + @BlockCoordinates + public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, + Hunk data, IrisBiome biome, InferredType inferredType, int height, int max) { int fluidHeight = getDimension().getFluidHeight(); - if (biome.getInferredType().equals(InferredType.SHORE) && height < fluidHeight) { + if (inferredType == InferredType.SHORE && height < fluidHeight) { return; } - boolean underwater = height < fluidHeight && biome.getInferredType() != InferredType.CAVE; - boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE; + boolean underwater = isUnderwater(inferredType, height, fluidHeight); + boolean caveSkipFluid = skipsFluid(inferredType); RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); @@ -79,4 +85,12 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ, data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle()); } + + static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) { + return height < fluidHeight && inferredType != InferredType.CAVE; + } + + static boolean skipsFluid(InferredType inferredType) { + return inferredType == InferredType.CAVE; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 97a7d6c85..ca4337a3d 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -41,9 +41,11 @@ import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.engine.object.IrisLootMode; import art.arcane.iris.engine.object.IrisLootReference; import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -577,15 +579,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat return null; } - String[] v = objectAt.split("\\Q@\\E"); - String object = v[0]; - if (object.isEmpty() || object.equals("null")) { + StructurePlacementMarker.Decoded marker = StructurePlacementMarker.decode(objectAt); + if (marker == null) { return null; } + String object = marker.objectKey(); if (object.startsWith("procedural/")) { return null; } - int id = Integer.parseInt(v[1]); + int id = marker.placementId(); + + if (marker.structureAware()) { + IrisObject placedObject = getData().getObjectLoader().load(object); + IrisStructure structure = IrisData.loadAnyStructure(marker.structureKey(), getData()); + IrisObjectPlacement placement = placedObject == null || structure == null + ? null + : structure.createLootPlacement(object); + return new PlacedObject(placement, placedObject, id, x, z); + } IrisRegion region = getRegion(x, z); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementMarker.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementMarker.java new file mode 100644 index 000000000..13bd4bed2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementMarker.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.framework; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; + +public final class StructurePlacementMarker { + private static final String FAMILY_PREFIX = "@iris-structure:"; + private static final String VERSION_PREFIX = FAMILY_PREFIX + "v1:"; + + private StructurePlacementMarker() { + } + + public static String encodeStructure(String objectKey, int placementId, String structureKey) { + String normalizedObjectKey = requireKey(objectKey, "objectKey"); + String normalizedStructureKey = requireKey(structureKey, "structureKey"); + return VERSION_PREFIX + + encodeKey(normalizedObjectKey) + + ":" + placementId + + ":" + encodeKey(normalizedStructureKey); + } + + public static Decoded decode(String marker) { + if (marker == null || marker.isBlank()) { + return null; + } + if (marker.startsWith(FAMILY_PREFIX)) { + return decodeStructure(marker); + } + return decodeLegacy(marker); + } + + private static Decoded decodeStructure(String marker) { + if (!marker.startsWith(VERSION_PREFIX)) { + return null; + } + String[] fields = marker.split(":", -1); + if (fields.length != 5 || !fields[0].equals("@iris-structure") || !fields[1].equals("v1")) { + return null; + } + try { + String objectKey = decodeCanonicalKey(fields[2]); + int placementId = Integer.parseInt(fields[3]); + String structureKey = decodeCanonicalKey(fields[4]); + if (objectKey.isBlank() || structureKey.isBlank()) { + return null; + } + return new Decoded(objectKey, placementId, structureKey); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private static Decoded decodeLegacy(String marker) { + int separator = marker.indexOf('@'); + if (separator <= 0 || separator != marker.lastIndexOf('@') || separator == marker.length() - 1) { + return null; + } + String objectKey = marker.substring(0, separator); + if (objectKey.isEmpty() || objectKey.equals("null")) { + return null; + } + try { + return new Decoded(objectKey, Integer.parseInt(marker.substring(separator + 1)), null); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String requireKey(String key, String name) { + String required = Objects.requireNonNull(key, name); + if (required.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return required; + } + + private static String encodeKey(String key) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(key.getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeCanonicalKey(String key) { + String decoded = new String(Base64.getUrlDecoder().decode(key), StandardCharsets.UTF_8); + if (!encodeKey(decoded).equals(key)) { + throw new IllegalArgumentException("Non-canonical marker key"); + } + return decoded; + } + + public record Decoded(String objectKey, int placementId, String structureKey) { + public boolean structureAware() { + return structureKey != null; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index f0f1d2429..8e2a63289 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -46,7 +46,6 @@ public class IrisCaveCarver3D { private static final int ADAPTIVE_DEEP_SURFACE_MARGIN = 12; private static final double ADAPTIVE_LOCAL_RANGE_SCALE = 0.125D; private static final double ADAPTIVE_DEEP_MARGIN_BOOST = 0.015D; - private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(Scratch::new); private final Engine engine; private final IrisData data; @@ -70,6 +69,7 @@ public class IrisCaveCarver3D { private final boolean hasWarp; private final boolean hasModules; private final int warpResolution; + private final ThreadLocal scratchCache = ThreadLocal.withInitial(Scratch::new); public IrisCaveCarver3D(Engine engine, IrisCaveProfile profile) { this.engine = engine; @@ -112,7 +112,7 @@ public class IrisCaveCarver3D { } public int carve(MantleWriter writer, int chunkX, int chunkZ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); if (!scratch.fullWeightsInitialized) { Arrays.fill(scratch.fullWeights, 1D); scratch.fullWeightsInitialized = true; @@ -169,7 +169,7 @@ public class IrisCaveCarver3D { ) { PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start(); try { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); if (columnWeights == null || columnWeights.length < 256) { if (!scratch.fullWeightsInitialized) { Arrays.fill(scratch.fullWeights, 1D); @@ -364,7 +364,7 @@ public class IrisCaveCarver3D { boolean skipExistingCarved ) { int carved = 0; - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] passThreshold = scratch.passThreshold; int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnTopY = scratch.activeColumnTopY; @@ -483,7 +483,7 @@ public class IrisCaveCarver3D { boolean skipExistingCarved ) { int carved = 0; - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] passThreshold = scratch.passThreshold; int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnTopY = scratch.activeColumnTopY; @@ -639,7 +639,7 @@ public class IrisCaveCarver3D { boolean skipExistingCarved ) { int carved = 0; - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] passThreshold = scratch.passThreshold; int[] tileIndices = scratch.tileIndices; int[] tileLocalX = scratch.tileLocalX; @@ -778,7 +778,7 @@ public class IrisCaveCarver3D { boolean skipExistingCarved ) { int carved = 0; - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); for (int lx = 0; lx < 16; lx++) { int x = x0 + lx; @@ -926,7 +926,7 @@ public class IrisCaveCarver3D { } private void classifyDensityPlaneNoWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { classifyDensityPlaneNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve); @@ -964,7 +964,7 @@ public class IrisCaveCarver3D { } private void classifyDensityPlaneWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { classifyDensityPlaneWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve); @@ -1003,7 +1003,7 @@ public class IrisCaveCarver3D { int adaptiveSampleStep, double adaptiveThresholdMargin ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity; int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep; int axisSamples = axisCells + 1; @@ -1046,7 +1046,7 @@ public class IrisCaveCarver3D { int adaptiveSampleStep, double adaptiveThresholdMargin ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { classifyDensityPlaneAdaptiveNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin); @@ -1102,7 +1102,7 @@ public class IrisCaveCarver3D { int adaptiveSampleStep, double adaptiveThresholdMargin ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity; int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep; int axisSamples = axisCells + 1; @@ -1145,7 +1145,7 @@ public class IrisCaveCarver3D { int adaptiveSampleStep, double adaptiveThresholdMargin ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { classifyDensityPlaneAdaptiveWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin); @@ -1211,7 +1211,7 @@ public class IrisCaveCarver3D { int axisCells, int axisSamples ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -1271,7 +1271,7 @@ public class IrisCaveCarver3D { double[] remainingMin, double[] remainingMax ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -1348,7 +1348,7 @@ public class IrisCaveCarver3D { int axisCells, int axisSamples ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -1408,7 +1408,7 @@ public class IrisCaveCarver3D { double[] remainingMin, double[] remainingMax ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -1485,7 +1485,7 @@ public class IrisCaveCarver3D { double[] remainingMin, double[] remainingMax ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -1604,7 +1604,7 @@ public class IrisCaveCarver3D { } private boolean classifyDensityPointWarpOnly(int x, int y, int z, double thresholdLimit) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int sx = snapWarp(x); int sy = snapWarp(y); int sz = snapWarp(z); @@ -1640,7 +1640,7 @@ public class IrisCaveCarver3D { return classifyDensityPointWarpOnly(x, y, z, thresholdLimit); } - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int sx = snapWarp(x); int sy = snapWarp(y); int sz = snapWarp(z); @@ -1740,7 +1740,7 @@ public class IrisCaveCarver3D { return true; } - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int sx = snapWarp(x); int sy = snapWarp(y); int sz = snapWarp(z); @@ -1778,7 +1778,7 @@ public class IrisCaveCarver3D { int[] adaptivePlaneSampleBounds, int axisCells ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisCells + 1); int[] adaptiveCellX = scratch.adaptiveCellX; int[] adaptiveCellZ = scratch.adaptiveCellZ; @@ -1822,7 +1822,7 @@ public class IrisCaveCarver3D { double[] adaptivePlanePrediction, double[] adaptivePlaneAmbiguity ) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisSamples); int[] adaptiveCellZ = scratch.adaptiveCellZ; int[] adaptiveRow0 = scratch.adaptiveRow0; @@ -1882,7 +1882,7 @@ public class IrisCaveCarver3D { } private double sampleDensityNoWarpModules(int x, int y, int z) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { return sampleDensityNoWarpNoModules(x, y, z); @@ -1905,7 +1905,7 @@ public class IrisCaveCarver3D { } private double sampleDensityWarpOnly(int x, int y, int z) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int sx = snapWarp(x); int sy = snapWarp(y); int sz = snapWarp(z); @@ -1921,7 +1921,7 @@ public class IrisCaveCarver3D { } private double sampleDensityWarpModules(int x, int y, int z) { - Scratch scratch = SCRATCH.get(); + Scratch scratch = scratchCache.get(); int activeModuleCount = prepareActiveModules(scratch, y); if (activeModuleCount == 0) { return sampleDensityWarpOnly(x, y, z); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index db7aa37a5..667e99e54 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.framework.StructurePlacementMarker; import art.arcane.iris.engine.framework.StructurePlacementGrid; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; @@ -37,6 +38,7 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.context.ChunkContext; @@ -331,10 +333,10 @@ public class IrisStructureComponent extends IrisMantleComponent { private void placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng) { IrisObject object = p.getObject(); - IrisObjectPlacement config = new IrisObjectPlacement(); + String objectKey = object.getLoadKey(); + IrisObjectPlacement config = structure.createLootPlacement(objectKey); config.setMode(mode); config.setRotation(p.getRotation()); - config.getPlace().add(object.getLoadKey()); if (!structure.getEdit().isEmpty()) { config.setEdit(structure.getEdit()); } @@ -342,7 +344,35 @@ public class IrisStructureComponent extends IrisMantleComponent { config.setForcePlace(true); } int placeY = (y == -1) ? -1 : y - getEngineMantle().getEngine().getMinHeight(); - object.place(p.getX(), placeY, p.getZ(), writer, config, rng, null, null, getData()); + String marker = structurePlacementMarker(structure, p, objectKey); + object.place(p.getX(), placeY, p.getZ(), writer, config, rng, (position, state) -> { + if (marker != null && shouldWriteStructureMarker(state)) { + writer.setData(position.getX(), position.getY(), position.getZ(), marker); + } + }, null, getData()); + } + + static boolean shouldWriteStructureMarker(PlatformBlockState state) { + return state != null && state.isStorageChest(); + } + + static int structurePlacementId(String structureKey, String objectKey, int x, int y, int z) { + int hash = 17; + hash = 31 * hash + structureKey.hashCode(); + hash = 31 * hash + objectKey.hashCode(); + hash = 31 * hash + x; + hash = 31 * hash + y; + hash = 31 * hash + z; + return hash & Integer.MAX_VALUE; + } + + private static String structurePlacementMarker(IrisStructure structure, PlacedStructurePiece piece, String objectKey) { + String structureKey = structure.getLoadKey(); + if (structureKey == null || structureKey.isBlank() || objectKey == null || objectKey.isBlank()) { + return null; + } + int placementId = structurePlacementId(structureKey, objectKey, piece.getX(), piece.getY(), piece.getZ()); + return StructurePlacementMarker.encodeStructure(objectKey, placementId, structureKey); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index d42d1f364..80d0019d5 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -464,7 +464,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { } protected int computeRadius() { - return 0; + return 1; } private int[] prepareChunkSurfaceHeights(int chunkX, int chunkZ, ChunkContext context, int[] scratch) { diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index d4f37d517..da087f4c9 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -168,7 +168,6 @@ public class IrisCarveModifier extends EngineAssignedModifier floorLayers = biome.generateLayers(getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex()); for (int i = 0; i < zoneFloor - 1; i++) { @@ -550,7 +548,6 @@ public class IrisCarveModifier extends EngineAssignedModifier blocks = biome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex()); @@ -600,9 +597,9 @@ public class IrisCarveModifier extends EngineAssignedModifier 0 && B.isSolid(output.getRaw(rx, zone.getFloor() - 1, rz))) { - decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getFloor() - 1, zone.airThickness()); + decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness()); } else if (decorator.getPartOf().equals(IrisDecorationPart.CEILING) && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { - decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getCeiling(), zone.airThickness()); + decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java index 79aecfc4c..e2deabe97 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java @@ -394,10 +394,10 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier cacheColorLayerLoad = new AtomicCache<>(); private final transient AtomicCache cacheColorDepositLoad = new AtomicCache<>(); private final transient AtomicCache childrenCell = new AtomicCache<>(); - private final transient AtomicCache biomeGenerator = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + private final transient ConcurrentLinkedHashMap biomeGenerators = new ConcurrentLinkedHashMap.Builder() + .maximumWeightedCapacity(BIOME_GENERATOR_CACHE_SIZE) + .build(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile SeededBiomeGenerator recentBiomeGenerator; private final transient AtomicCache maxHeight = new AtomicCache<>(); private final transient AtomicCache maxWithObjectHeight = new AtomicCache<>(); private final transient AtomicCache realCarveBiome = new AtomicCache<>(); @@ -469,8 +482,39 @@ public class IrisBiome extends IrisRegistrant implements IRare { } public CNG getBiomeGenerator(RNG random) { - return biomeGenerator.aquire(() -> - biomeStyle.create(random.nextParallelRNG(213949 + 228888 + getRarity() + getName().length()), getLoader())); + IrisData loader = getLoader(); + Engine engine = resolveBiomeGeneratorEngine(loader); + return getBiomeGenerator(random, engine); + } + + public CNG getBiomeGenerator(RNG random, Engine engine) { + IrisData loader = getLoader(); + long seed = engine == null ? random.getSeed() : engine.getSeedManager().getBiome(); + SeededBiomeGenerator recent = recentBiomeGenerator; + if (recent != null && recent.seed == seed) { + return recent.generator; + } + + CNG generator = biomeGenerators.computeIfAbsent(seed, ignored -> createBiomeGenerator(seed, loader)); + recentBiomeGenerator = new SeededBiomeGenerator(seed, generator); + return generator; + } + + private Engine resolveBiomeGeneratorEngine(IrisData loader) { + IrisContext context = IrisContext.get(); + if (context != null) { + Engine contextEngine = context.getEngine(); + if (!contextEngine.isClosed() && (loader == null || contextEngine.getData() == loader)) { + return contextEngine; + } + } + + return loader == null ? null : loader.getEngine(); + } + + private CNG createBiomeGenerator(long seed, IrisData loader) { + int signature = 213949 + 228888 + getRarity() + getName().length(); + return biomeStyle.createNoCache(new RNG(seed).nextParallelRNG(signature), loader); } public CNG getChildrenGenerator(RNG random, int sig, double scale) { @@ -752,23 +796,31 @@ public class IrisBiome extends IrisRegistrant implements IRare { } public Biome getSkyBiome(RNG rng, double x, double y, double z) { + return getSkyBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z); + } + + public Biome getSkyBiome(RNG rng, Engine engine, double x, double y, double z) { if (biomeSkyScatter.size() == 1) { return getBiomeSkyScatterResolved().get(0); } if (biomeSkyScatter.isEmpty()) { - return getGroundBiome(rng, x, y, z); + return getGroundBiome(rng, engine, x, y, z); } - return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z)); + return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z)); } public IrisBiomeCustom getCustomBiome(RNG rng, double x, double y, double z) { + return getCustomBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z); + } + + public IrisBiomeCustom getCustomBiome(RNG rng, Engine engine, double x, double y, double z) { if (customDerivitives.size() == 1) { return customDerivitives.get(0); } - return customDerivitives.get(getBiomeGenerator(rng).fit(0, customDerivitives.size() - 1, x, y, z)); + return customDerivitives.get(getBiomeGenerator(rng, engine).fit(0, customDerivitives.size() - 1, x, y, z)); } public KList getRealChildren(DataProvider g) { @@ -801,6 +853,10 @@ public class IrisBiome extends IrisRegistrant implements IRare { //TODO: Test public Biome getGroundBiome(RNG rng, double x, double y, double z) { + return getGroundBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z); + } + + public Biome getGroundBiome(RNG rng, Engine engine, double x, double y, double z) { if (biomeScatter.isEmpty()) { return getDerivative(); } @@ -809,22 +865,30 @@ public class IrisBiome extends IrisRegistrant implements IRare { return getBiomeScatterResolved().get(0); } - return getBiomeGenerator(rng).fit(getBiomeScatterResolved(), x, y, z); + return getBiomeGenerator(rng, engine).fit(getBiomeScatterResolved(), x, y, z); } public String getSkyBiomeKey(RNG rng, double x, double y, double z) { + return getSkyBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z); + } + + public String getSkyBiomeKey(RNG rng, Engine engine, double x, double y, double z) { if (biomeSkyScatter.size() == 1) { return namespacedBiomeKey(biomeSkyScatter.get(0)); } if (biomeSkyScatter.isEmpty()) { - return getGroundBiomeKey(rng, x, y, z); + return getGroundBiomeKey(rng, engine, x, y, z); } - return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z))); + return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z))); } public String getGroundBiomeKey(RNG rng, double x, double y, double z) { + return getGroundBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z); + } + + public String getGroundBiomeKey(RNG rng, Engine engine, double x, double y, double z) { if (biomeScatter.isEmpty()) { return namespacedBiomeKey(derivative); } @@ -833,7 +897,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { return namespacedBiomeKey(biomeScatter.get(0)); } - return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng).fit(0, biomeScatter.size() - 1, x, y, z))); + return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z))); } public PlatformBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) { @@ -931,4 +995,14 @@ public class IrisBiome extends IrisRegistrant implements IRare { public void scanForErrors(JSONObject p, VolmitSender sender) { } + + private static final class SeededBiomeGenerator { + private final long seed; + private final CNG generator; + + private SeededBiomeGenerator(long seed, CNG generator) { + this.seed = seed; + this.generator = generator; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java index 8e9f4d4c9..75c87d735 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java @@ -65,6 +65,10 @@ public class IrisBiomeCustom { @Desc("The biome's mob spawns") private KList spawns = new KList<>(); + @ArrayType(min = 1, type = String.class) + @Desc("Biome tags to opt this custom biome into, such as minecraft:allows_surface_slime_spawns") + private KList tags = new KList<>(); + @Desc("The biome's downfall type") private IrisBiomeCustomPrecipType downfallType = IrisBiomeCustomPrecipType.rain; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java index b62c9b2dc..022822728 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java @@ -32,6 +32,8 @@ import lombok.experimental.Accessors; import org.bukkit.NamespacedKey; import org.bukkit.entity.EntityType; +import java.util.Locale; + @Snippet("custom-biome-spawn") @Accessors(chain = true) @NoArgsConstructor @@ -45,11 +47,12 @@ public class IrisBiomeCustomSpawn { private String type = "minecraft:cow"; public EntityType getType() { - if (type == null) { + String typeKey = getTypeKey(); + if (typeKey == null) { return null; } return typeResolved.aquire(() -> { - NamespacedKey namespacedKey = NamespacedKey.fromString(type); + NamespacedKey namespacedKey = NamespacedKey.fromString(typeKey); return namespacedKey == null ? null : RegistryUtil.lookup(EntityType.class).get(namespacedKey); }); } @@ -58,7 +61,8 @@ public class IrisBiomeCustomSpawn { if (type == null || type.isBlank()) { return null; } - return type.contains(":") ? type : "minecraft:" + type; + String normalized = type.trim().toLowerCase(Locale.ROOT); + return normalized.contains(":") ? normalized : "minecraft:" + normalized; } @MinNumber(1) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnType.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnType.java index cc639c742..ef614b7e6 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnType.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnType.java @@ -31,6 +31,9 @@ public enum IrisBiomeCustomSpawnType { @Desc("Eg bats") AMBIENT, + @Desc("Axolotls") + AXOLOTLS, + @Desc("Odd spawn group but ok") UNDERGROUND_WATER_CREATURE, diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 4c18cf7f9..01f06479d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -40,6 +40,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.math.Position2; @@ -56,9 +57,17 @@ import org.bukkit.block.Biome; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; @Accessors(chain = true) @NoArgsConstructor @@ -67,6 +76,8 @@ import java.util.Map; @EqualsAndHashCode(callSuper = false, doNotUseGetters = true) @ToString(doNotUseGetters = true) public class IrisDimension extends IrisRegistrant { + private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); + private final transient AtomicCache parallaxSize = new AtomicCache<>(); private final transient AtomicCache rockLayerGenerator = new AtomicCache<>(); private final transient AtomicCache fluidLayerGenerator = new AtomicCache<>(); @@ -523,6 +534,7 @@ public class IrisDimension extends IrisRegistrant { output.getParentFile().mkdirs(); try { IO.writeAll(output, json); + installBiomeTags(datapacks, namespace + ":" + customBiomeId, customBiome.getTags()); } catch (IOException e) { IrisLogging.reportError(e); e.printStackTrace(); @@ -532,6 +544,97 @@ public class IrisDimension extends IrisRegistrant { } } + public static void clearGeneratedBiomeTags(KList folders) { + for (File datapacks : folders) { + File dataFolder = new File(datapacks, "iris/data"); + File[] namespaces = dataFolder.listFiles(File::isDirectory); + if (namespaces == null) { + continue; + } + for (File namespace : namespaces) { + IO.delete(new File(namespace, "tags/worldgen/biome")); + } + } + } + + static void installBiomeTags(File datapacks, String biomeKey, KList tags) throws IOException { + if (tags == null || tags.isEmpty()) { + return; + } + for (String rawTag : tags) { + String tag = normalizeResourceKey(rawTag); + if (tag == null) { + IrisLogging.error("Invalid custom biome tag '" + rawTag + "' for " + biomeKey); + continue; + } + int separator = tag.indexOf(':'); + String namespace = tag.substring(0, separator); + String path = tag.substring(separator + 1); + Path tagRoot = new File(datapacks, "iris/data/" + namespace + "/tags/worldgen/biome").toPath() + .toAbsolutePath().normalize(); + Path output = tagRoot.resolve(path + ".json").normalize(); + if (!output.startsWith(tagRoot)) { + IrisLogging.error("Unsafe custom biome tag '" + rawTag + "' for " + biomeKey); + continue; + } + writeBiomeTag(output, biomeKey); + } + } + + private static String normalizeResourceKey(String key) { + if (key == null || key.isBlank()) { + return null; + } + String normalized = key.trim().toLowerCase(Locale.ROOT); + if (normalized.indexOf(':') < 0) { + normalized = "minecraft:" + normalized; + } + return RESOURCE_KEY_PATTERN.matcher(normalized).matches() ? normalized : null; + } + + static void writeBiomeTag(Path output, String biomeKey) throws IOException { + synchronized (IrisDimension.class) { + Set values = new TreeSet<>(); + if (Files.isRegularFile(output)) { + JSONObject existing = new JSONObject(Files.readString(output, StandardCharsets.UTF_8)); + JSONArray existingValues = existing.optJSONArray("values"); + if (existingValues != null) { + for (int index = 0; index < existingValues.length(); index++) { + String value = existingValues.optString(index, null); + if (value != null && !value.isBlank()) { + values.add(value); + } + } + } + } + values.add(biomeKey); + + JSONArray outputValues = new JSONArray(); + for (String value : values) { + outputValues.put(value); + } + JSONObject tag = new JSONObject(); + tag.put("replace", false); + tag.put("values", outputValues); + writeAtomic(output, tag.toString(4)); + } + } + + private static void writeAtomic(Path output, String content) throws IOException { + Files.createDirectories(output.getParent()); + Path temporary = Files.createTempFile(output.getParent(), output.getFileName().toString(), ".tmp"); + try { + Files.writeString(temporary, content, StandardCharsets.UTF_8); + try { + Files.move(temporary, output, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + public Dimension getBaseDimension() { return switch (environment) { case NETHER -> Dimension.NETHER; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java index 5a27f67bb..028f6babd 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java @@ -71,6 +71,21 @@ public class IrisStructure extends IrisRegistrant { @Desc("If this structure was generated by importing a vanilla or datapack structure, this is that structure's key (provenance). Empty for hand-authored structures.") private String vanillaSource = ""; + public IrisObjectPlacement createLootPlacement(String objectKey) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.getPlace().add(objectKey); + placement.setOverrideGlobalLoot(false); + if (loot == null) { + return placement; + } + for (String lootTable : loot) { + if (lootTable != null && !lootTable.isBlank()) { + placement.getLoot().add(new IrisObjectLoot().setName(lootTable).setWeight(1)); + } + } + return placement; + } + @Override public String getFolderName() { return "structures"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index f577f189e..ccf3a596d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -72,15 +72,6 @@ public class IrisStructurePlacement { @Desc("CONCENTRIC_RINGS only: how many placements share each ring before moving outward.") private int ringSpread = 3; - @Desc("rotation applied to the placed structure.") - private IrisObjectRotation rotation = new IrisObjectRotation(); - - @Desc("translation applied to the placed structure.") - private IrisObjectTranslate translate = new IrisObjectTranslate(); - - @Desc("scale applied to the placed structure.") - private IrisObjectScale scale = new IrisObjectScale(); - @Desc("When underground=false this is the minimum surface Y the placement is allowed at (a gate); when underground=true this is the lower bound of the Y band the structure is placed within.") private int minHeight = -2032; diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEntityType.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEntityType.java index 4d394c3d6..b08080711 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEntityType.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEntityType.java @@ -18,6 +18,7 @@ package art.arcane.iris.platform.bukkit; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.spi.PlatformEntityType; import org.bukkit.entity.EntityType; @@ -32,12 +33,14 @@ public final class BukkitEntityType implements PlatformEntityType { private final EntityType type; private final String key; private final String namespace; + private final String spawnCategory; private BukkitEntityType(EntityType type, String key) { this.type = type; this.key = key; int colon = key.indexOf(':'); this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; + this.spawnCategory = INMS.get().getEntitySpawnCategory(key); } public static BukkitEntityType of(EntityType type) { @@ -55,6 +58,11 @@ public final class BukkitEntityType implements PlatformEntityType { return namespace; } + @Override + public String spawnCategory() { + return spawnCategory; + } + @Override public Object nativeHandle() { return type; diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java index ab5844e65..3363635d7 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java @@ -281,6 +281,41 @@ public class J { return true; } + public static boolean runGlobal(Runnable runnable) { + if (runnable == null) { + return false; + } + + if (!BUKKIT_PRESENT) { + if (!IrisPlatforms.isBound()) { + return false; + } + IrisPlatforms.get().scheduler().global(runnable); + return true; + } + + if (!isPluginEnabled()) { + return false; + } + + if (isFolia()) { + return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable); + } + + if (Bukkit.isPrimaryThread()) { + runnable.run(); + return true; + } + + try { + Bukkit.getScheduler().runTask(BukkitPlatform.plugin(), runnable); + return true; + } catch (UnsupportedOperationException e) { + FoliaScheduler.forceFoliaThreading(Bukkit.getServer()); + return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable); + } + } + public static boolean runRegion(World world, int chunkX, int chunkZ, Runnable runnable, int delayTicks) { if (world == null || runnable == null) { return false; diff --git a/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217ClockTest.java b/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217ClockTest.java new file mode 100644 index 000000000..14b05a67d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217ClockTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.nms.datapack.v1217; + +import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DataFixerV1217ClockTest { + @Test + public void overworldProvidesTheVanillaDefaultClock() { + JSONObject json = fixed(Dimension.OVERWORLD); + + assertEquals("minecraft:overworld", json.getString("default_clock")); + assertFalse(json.optBoolean("has_fixed_time", false)); + } + + @Test + public void endProvidesItsFixedVanillaClock() { + JSONObject json = fixed(Dimension.END); + + assertEquals("minecraft:the_end", json.getString("default_clock")); + assertTrue(json.getBoolean("has_fixed_time")); + } + + @Test + public void netherRemainsFixedWithoutADefaultClock() { + JSONObject json = fixed(Dimension.NETHER); + + assertFalse(json.has("default_clock")); + assertTrue(json.getBoolean("has_fixed_time")); + } + + private JSONObject fixed(Dimension dimension) { + DataFixerV1217 fixer = new DataFixerV1217(); + JSONObject json = fixer.resolve(dimension, null); + fixer.fixDimension(dimension, json); + return json; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java new file mode 100644 index 000000000..62aef17ee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.core.pack; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class PackDirectoryResolverTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void resolvesOnlyExistingDirectChildren() throws Exception { + File packs = temporaryFolder.newFolder("packs"); + File overworld = new File(packs, "overworld"); + Files.createDirectory(overworld.toPath()); + + assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "overworld")); + assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "./overworld")); + assertNull(PackDirectoryResolver.resolveExisting(packs, "missing")); + assertNull(PackDirectoryResolver.resolveExisting(packs, "")); + } + + @Test + public void rejectsTraversalAbsoluteAndNestedPaths() throws Exception { + File packs = temporaryFolder.newFolder("pack-root"); + File outside = temporaryFolder.newFolder("outside"); + File nested = new File(packs, "nested/pack"); + Files.createDirectories(nested.toPath()); + + assertNull(PackDirectoryResolver.resolveExisting(packs, "../outside")); + assertNull(PackDirectoryResolver.resolveExisting(packs, outside.getAbsolutePath())); + assertNull(PackDirectoryResolver.resolveExisting(packs, "nested/pack")); + assertNull(PackDirectoryResolver.resolveExisting(packs, ".")); + } + + @Test + public void rejectsSymbolicLinkChildren() throws Exception { + File packs = temporaryFolder.newFolder("symlink-root"); + File outside = temporaryFolder.newFolder("symlink-target"); + Path link = new File(packs, "linked").toPath(); + try { + Files.createSymbolicLink(link, outside.toPath()); + } catch (IOException | UnsupportedOperationException | SecurityException e) { + Assume.assumeNoException(e); + } + + assertNull(PackDirectoryResolver.resolveExisting(packs, "linked")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java new file mode 100644 index 000000000..c4bd8a9f4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -0,0 +1,67 @@ +/* + * 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.core.pack; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class PackDownloaderTest { + @Test + public void resolvesBranchReference() { + assertEquals( + "https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/feature/release", + PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "feature/release") + ); + } + + @Test + public void resolvesQualifiedHeadReference() { + assertEquals( + "https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/master", + PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/master") + ); + } + + @Test + public void resolvesTagReference() { + assertEquals( + "https://codeload.github.com/IrisDimensions/overworld/zip/refs/tags/v4.0.0", + PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/tags/v4.0.0") + ); + } + + @Test + public void resolvesCommitReference() { + assertEquals( + "https://github.com/IrisDimensions/overworld/archive/8e32852ee6ecd039fae27a36f701f57cdc02e83f.zip", + PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "8e32852ee6ecd039fae27a36f701f57cdc02e83f") + ); + } + + @Test + public void rejectsUnsafeRepositoryAndReference() { + assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld?raw=1", "master")); + assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("../overworld", "master")); + assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/../master")); + assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/pull/123/head")); + assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackResourceCleanupTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackResourceCleanupTest.java new file mode 100644 index 000000000..c7c5edaaf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackResourceCleanupTest.java @@ -0,0 +1,250 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.Assume; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PackResourceCleanupTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void previewIsReadOnlyAndReturnsSortedCandidates() throws Exception { + File pack = createPack("preview"); + write(pack, "dimensions/main.json", "{\"biome\":\"nested/used\"}"); + write(pack, "biomes/z-last.json", "{}"); + write(pack, "biomes/a-first.json", "{}"); + write(pack, "biomes/nested/used.json", "{}"); + + PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack); + + assertTrue(preview.success()); + assertTrue(preview.hasCandidates()); + assertEquals(List.of("biomes/a-first.json", "biomes/z-last.json"), preview.candidatePaths()); + assertFalse(new File(pack, ".iris-trash").exists()); + assertTrue(new File(pack, "biomes/a-first.json").isFile()); + } + + @Test + public void applyRunsFreshScanBeforeMutating() throws Exception { + File pack = createPack("fresh-scan"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/kept.json", "{}"); + + PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack); + assertEquals(List.of("biomes/kept.json"), preview.candidatePaths()); + + write(pack, "dimensions/main.json", "{\"biome\":\"kept\"}"); + PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack); + + assertTrue(result.success()); + assertFalse(result.changed()); + assertNull(result.quarantinePath()); + assertTrue(new File(pack, "biomes/kept.json").isFile()); + assertFalse(new File(pack, ".iris-trash").exists()); + } + + @Test + public void applyCreatesUniqueDumpsAndPreservesRelativePaths() throws Exception { + File pack = createPack("unique-dumps"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/nested/first.json", "{\"value\":1}"); + + PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack); + assertTrue(first.success()); + assertEquals(List.of("biomes/nested/first.json"), first.quarantinedPaths()); + assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile()); + + write(pack, "biomes/nested/second.json", "{\"value\":2}"); + PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack); + + assertTrue(second.success()); + assertNotEquals(first.quarantinePath(), second.quarantinePath()); + assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile()); + assertTrue(new File(pack, second.quarantinePath() + "/biomes/nested/second.json").isFile()); + } + + @Test + public void restorePreviewReportsLatestDumpAndAllConflictsWithoutMutation() throws Exception { + File pack = createPack("restore-conflict"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/first.json", "{\"value\":1}"); + PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack); + write(pack, "biomes/second.json", "{\"value\":2}"); + PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack); + write(pack, "biomes/second.json", "{\"replacement\":true}"); + + PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(pack); + + assertTrue(preview.success()); + assertEquals(second.quarantinePath(), preview.dumpPath()); + assertEquals(List.of("biomes/second.json"), preview.filePaths()); + assertEquals(List.of("biomes/second.json"), preview.conflicts()); + assertTrue(preview.hasConflicts()); + assertFalse(preview.canRestore()); + assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile()); + assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile()); + + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack); + + assertFalse(result.success()); + assertEquals(List.of("biomes/second.json"), result.conflicts()); + assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile()); + assertEquals("{\"replacement\":true}", read(pack, "biomes/second.json")); + } + + @Test + public void restoreLatestRestoresOnlyNewestDump() throws Exception { + File pack = createPack("restore-latest"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/first.json", "{\"value\":1}"); + PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack); + write(pack, "biomes/second.json", "{\"value\":2}"); + PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack); + + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack); + + assertTrue(result.success()); + assertTrue(result.changed()); + assertEquals(second.quarantinePath(), result.dumpPath()); + assertEquals(List.of("biomes/second.json"), result.restoredPaths()); + assertEquals("{\"value\":2}", read(pack, "biomes/second.json")); + assertFalse(new File(pack, "biomes/first.json").exists()); + assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile()); + assertFalse(new File(pack, second.quarantinePath()).exists()); + } + + @Test + public void restoreCopyFailureRollsBackCreatedDestinations() throws Exception { + File pack = createPack("restore-rollback"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/a-readable.json", "{\"value\":1}"); + write(pack, "biomes/z-unreadable.json", "{\"value\":2}"); + PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack); + Path unreadable = new File(pack, applied.quarantinePath() + "/biomes/z-unreadable.json").toPath(); + Assume.assumeTrue(Files.getFileStore(unreadable).supportsFileAttributeView("posix")); + Set originalPermissions = Files.getPosixFilePermissions(unreadable); + Files.setPosixFilePermissions(unreadable, Set.of(PosixFilePermission.OWNER_WRITE)); + try { + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack); + + assertFalse(result.success()); + assertTrue(result.error().contains("rolled back")); + assertFalse(new File(pack, "biomes/a-readable.json").exists()); + assertFalse(new File(pack, "biomes/z-unreadable.json").exists()); + assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a-readable.json").isFile()); + assertTrue(Files.exists(unreadable)); + } finally { + Files.setPosixFilePermissions(unreadable, originalPermissions); + } + } + + @Test + public void restoreSourceRemovalFailureRollsBackDestinationsAndPreservesDump() throws Exception { + File pack = createPack("restore-delete-rollback"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/a.json", "{\"value\":1}"); + write(pack, "biomes/b.json", "{\"value\":2}"); + PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack); + Path quarantineBiomes = new File(pack, applied.quarantinePath() + "/biomes").toPath(); + Assume.assumeTrue(Files.getFileStore(quarantineBiomes).supportsFileAttributeView("posix")); + Set originalPermissions = Files.getPosixFilePermissions(quarantineBiomes); + Files.setPosixFilePermissions(quarantineBiomes, Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_EXECUTE)); + try { + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack); + + assertFalse(result.success()); + assertTrue(result.error().contains("source removal failed")); + assertFalse(new File(pack, "biomes/a.json").exists()); + assertFalse(new File(pack, "biomes/b.json").exists()); + assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a.json").isFile()); + assertTrue(new File(pack, applied.quarantinePath() + "/biomes/b.json").isFile()); + } finally { + Files.setPosixFilePermissions(quarantineBiomes, originalPermissions); + } + } + + @Test + public void cleanupFailsClosedWhenManagedContentContainsSymbolicLink() throws Exception { + File pack = createPack("symlink"); + write(pack, "dimensions/main.json", "{}"); + File outside = temporaryFolder.newFile("outside.json"); + Path link = new File(pack, "biomes/linked.json").toPath(); + Files.createDirectories(link.getParent()); + try { + Files.createSymbolicLink(link, outside.toPath()); + } catch (IOException | UnsupportedOperationException | SecurityException e) { + Assume.assumeNoException(e); + } + + PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack); + + assertFalse(result.success()); + assertFalse(result.changed()); + assertTrue(Files.isSymbolicLink(link)); + assertFalse(new File(pack, ".iris-trash").exists()); + } + + @Test + public void concurrentAppliesSerializePerPack() throws Exception { + File pack = createPack("concurrent"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/unused.json", "{}"); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> { + start.await(); + return PackResourceCleanup.apply(pack); + }); + Future second = executor.submit(() -> { + start.await(); + return PackResourceCleanup.apply(pack); + }); + start.countDown(); + + List results = List.of(first.get(), second.get()); + assertTrue(results.stream().allMatch(PackResourceCleanup.ApplyResult::success)); + assertEquals(1L, results.stream().filter(PackResourceCleanup.ApplyResult::changed).count()); + assertFalse(new File(pack, "biomes/unused.json").exists()); + } finally { + executor.shutdownNow(); + } + } + + private File createPack(String name) throws Exception { + return temporaryFolder.newFolder(name); + } + + private void write(File pack, String relativePath, String content) throws Exception { + Path path = new File(pack, relativePath).toPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private String read(File pack, String relativePath) throws Exception { + return Files.readString(new File(pack, relativePath).toPath(), StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCustomBiomeSpawnTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCustomBiomeSpawnTest.java new file mode 100644 index 000000000..ace98ab99 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCustomBiomeSpawnTest.java @@ -0,0 +1,179 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorCustomBiomeSpawnTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsExplicitMatchingSpawnGroup() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("monster")); + + assertTrue(errors.isEmpty()); + } + + @Test + public void acceptsBiomeWithoutCustomSpawns() throws Exception { + File biomes = createBiomes("{\"name\":\"Plains\"}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("monster")); + + assertTrue(errors.isEmpty()); + } + + @Test + public void rejectsMissingSpawnGroup() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("monster")); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("must declare group 'MONSTER'")); + } + + @Test + public void acceptsImplicitMiscSpawnGroup() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"effects\",\"spawns\":[{\"type\":\"minecraft:armor_stand\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("misc")); + + assertTrue(errors.isEmpty()); + } + + @Test + public void rejectsSpawnGroupThatDisagreesWithRegistry() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MISC\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("monster")); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("live entity registry requires 'MONSTER'")); + } + + @Test + public void acceptsAxolotlSpawnCategory() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"cave\",\"spawns\":[{\"type\":\"minecraft:axolotl\",\"group\":\"AXOLOTLS\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("axolotls")); + + assertTrue(errors.isEmpty()); + } + + @Test + public void rejectsUnknownSpawnEntity() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"missing:entity\",\"group\":\"MISC\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.unknown()); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("unknown entity type 'missing:entity'")); + } + + @Test + public void rejectsCaseNormalizedGroupThatRuntimeWouldNotParse() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"monster\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns( + biomes, key -> PackValidator.SpawnCategoryResolution.known("monster")); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("unknown group 'monster'")); + } + + @Test + public void rejectsWrongCustomDerivativeContainerType() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":{}}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("customDerivitives must be an array")); + } + + @Test + public void rejectsWrongSpawnContainerType() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":{}}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("spawns must be an array")); + } + + @Test + public void acceptsNullCustomDerivativeContainerAsAbsent() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":null}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertTrue(errors.isEmpty()); + } + + @Test + public void acceptsNullSpawnContainerAsAbsent() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":null}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertTrue(errors.isEmpty()); + } + + @Test + public void acceptsNamespacedCustomBiomeTags() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:allows_surface_slime_spawns\"]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertTrue(errors.isEmpty()); + } + + @Test + public void rejectsUnsafeCustomBiomeTags() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:../outside\"]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, null); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("invalid tag")); + } + + @Test + public void convertsSpawnCategoryResolverFailureIntoBlockingError() throws Exception { + File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}"); + + List errors = PackValidator.validateCustomBiomeSpawns(biomes, key -> { + throw new IllegalStateException("registry unavailable"); + }); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("spawn category lookup failed")); + assertTrue(errors.get(0).contains("registry unavailable")); + } + + private File createBiomes(String json) throws Exception { + File biomes = temporaryFolder.newFolder("biomes"); + File biome = new File(biomes, "test.json"); + Files.writeString(biome.toPath(), json, StandardCharsets.UTF_8); + return biomes; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorReadOnlyTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorReadOnlyTest.java new file mode 100644 index 000000000..bf0965f72 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorReadOnlyTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorReadOnlyTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void validationDoesNotMoveOrRewritePackFiles() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + write(pack, "generators/unused.json", "{\"name\":\"Unused\"}"); + Map before = snapshot(pack.toPath()); + + PackValidationResult result = PackValidator.validate(pack); + + assertTrue(result.isLoadable()); + assertEquals(before.keySet(), snapshot(pack.toPath()).keySet()); + Map after = snapshot(pack.toPath()); + for (Map.Entry entry : before.entrySet()) { + assertArrayEquals(entry.getValue(), after.get(entry.getKey())); + } + assertFalse(new File(pack, ".iris-trash").exists()); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private Map snapshot(Path root) throws Exception { + Map files = new LinkedHashMap<>(); + try (Stream stream = Files.walk(root)) { + for (Path path : stream.filter(Files::isRegularFile).sorted().toList()) { + files.put(root.relativize(path).toString(), Files.readAllBytes(path)); + } + } + return files; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorSpawnerEntityTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorSpawnerEntityTest.java new file mode 100644 index 000000000..1084a67aa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorSpawnerEntityTest.java @@ -0,0 +1,122 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorSpawnerEntityTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsNestedEntitiesReferencedByBothSpawnLists() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/frozen/passive.json", """ + { + "spawns": [{"entity": "standard/passive/cod"}], + "initialSpawns": [{"entity": "standard/passive/camel"}] + } + """); + write(pack, "entities/standard/passive/cod.json", "{\"type\":\"COD\",\"surface\":\"WATER\"}"); + write(pack, "entities/standard/passive/camel.json", "{\"type\":\"CAMEL\"}"); + + List errors = validate(pack); + + assertTrue(errors.isEmpty()); + } + + @Test + public void rejectsMalformedContainersAndEntries() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/broken.json", """ + { + "spawns": null, + "initialSpawns": [7, {}, {"entity": 9}, {"entity": " "}] + } + """); + + List errors = validate(pack); + + assertEquals(5, errors.size()); + assertTrue(errors.stream().anyMatch(error -> error.contains("spawns must be an array"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("non-object entry"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("without an entity reference"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("must be a string"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("blank entity reference"))); + } + + @Test + public void rejectsMissingAndMalformedReferencedEntities() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/broken.json", """ + { + "spawns": [ + {"entity": "standard/hostile/missing"}, + {"entity": "standard/hostile/broken"} + ] + } + """); + write(pack, "entities/standard/hostile/broken.json", "not-json"); + + List errors = validate(pack); + + assertEquals(2, errors.size()); + assertTrue(errors.stream().anyMatch(error -> error.contains("references missing entity 'standard/hostile/missing'"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("references malformed entity 'standard/hostile/broken'"))); + } + + @Test + public void rejectsMalformedSpawnerJson() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/broken.json", "not-json"); + + List errors = validate(pack); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("Spawner 'broken' has invalid JSON")); + } + + @Test + public void rejectsEntityReferenceThatEscapesPack() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/broken.json", "{\"spawns\":[{\"entity\":\"../outside\"}]}"); + write(pack, "outside.json", "{\"type\":\"COW\"}"); + + List errors = validate(pack); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).contains("unsafe entity reference '../outside'")); + } + + @Test + public void ignoresMalformedEntityThatNoSpawnerReferences() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "spawners/passive.json", "{\"spawns\":[{\"entity\":\"standard/passive/cow\"}]}"); + write(pack, "entities/standard/passive/cow.json", "{\"type\":\"COW\"}"); + write(pack, "entities/unique/unused.json", "not-json"); + + List errors = validate(pack); + + assertTrue(errors.isEmpty()); + } + + private List validate(File pack) { + return PackValidator.validateSpawnerEntityReferences( + new File(pack, "spawners"), new File(pack, "entities")); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTransformTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTransformTest.java new file mode 100644 index 000000000..e4423b50a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTransformTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorStructureTransformTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void reportsUnsupportedTransformsAcrossEveryStructureHost() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"test\"],\"rotation\":{}}]}"); + write(pack, "regions/nested/region.json", "{\"structures\":[{\"structures\":[\"test\"],\"translate\":{}}]}"); + write(pack, "biomes/nested/biome.json", "{\"structures\":[{\"structures\":[\"test\"],\"scale\":{}}]}"); + + List errors = PackValidator.validateUnsupportedStructureTransforms(pack); + + assertEquals(List.of( + "Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field.", + "Region 'nested/region' structures[0] declares unsupported field 'translate'. Structure placement transforms are not supported; remove the field.", + "Biome 'nested/biome' structures[0] declares unsupported field 'scale'. Structure placement transforms are not supported; remove the field." + ), errors); + } + + @Test + public void ignoresTransformsOutsideStructurePlacementArrays() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/biome.json", "{\"objects\":[{\"rotation\":{},\"translate\":{},\"scale\":{}}],\"structures\":[{\"structures\":[\"test\"]}]}"); + + assertTrue(PackValidator.validateUnsupportedStructureTransforms(pack).isEmpty()); + } + + @Test + public void unsupportedTransformBlocksFullPackValidation() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"],\"structures\":[{\"structures\":[\"test\"],\"rotation\":null}]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field.")); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java index df3af21df..2adfd8f2e 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java @@ -10,8 +10,10 @@ import java.io.PrintStream; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class IrisPregeneratorInitTest { @@ -53,6 +55,35 @@ public class IrisPregeneratorInitTest { assertTrue(output.contains("Pregen finished: generated=8 total=9 failed=1")); } + @Test + public void cancellationSummaryDoesNotReportPartialRunAsFinished() { + IrisSettings previousSettings = IrisSettings.settings; + PrintStream previousOut = System.out; + IrisSettings.settings = new IrisSettings(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8)); + AtomicReference pregeneratorReference = new AtomicReference<>(); + CancelAfterFirstChunkMethod method = new CancelAfterFirstChunkMethod(pregeneratorReference); + PregenTask task = PregenTask.builder() + .center(new Position2(0, 0)) + .radiusX(1) + .radiusZ(1) + .build(); + try { + IrisPregenerator pregenerator = new IrisPregenerator(task, method, new NoOpPregenListener()); + pregeneratorReference.set(pregenerator); + + pregenerator.start(); + + String summary = output.toString(StandardCharsets.UTF_8); + assertTrue(summary.contains("Pregen cancelled: generated=1 total=9 failed=0 remaining=8")); + assertFalse(summary.contains("Pregen finished:")); + } finally { + System.setOut(previousOut); + IrisSettings.settings = previousSettings; + } + } + private String runCompletionOnClose(boolean failLast) { IrisSettings previousSettings = IrisSettings.settings; PrintStream previousOut = System.out; @@ -173,6 +204,55 @@ public class IrisPregeneratorInitTest { } } + private static final class CancelAfterFirstChunkMethod implements PregeneratorMethod { + private final AtomicReference pregeneratorReference; + private boolean cancelled; + + private CancelAfterFirstChunkMethod(AtomicReference pregeneratorReference) { + this.pregeneratorReference = pregeneratorReference; + } + + @Override + public void init() { + } + + @Override + public void close() { + } + + @Override + public void save() { + } + + @Override + public boolean supportsRegions(int x, int z, PregenListener listener) { + return false; + } + + @Override + public String getMethod(int x, int z) { + return "test"; + } + + @Override + public void generateRegion(int x, int z, PregenListener listener) { + } + + @Override + public void generateChunk(int x, int z, PregenListener listener) { + listener.onChunkGenerated(x, z); + if (!cancelled) { + cancelled = true; + pregeneratorReference.get().close(); + } + } + + @Override + public Mantle getMantle() { + return null; + } + } + private static final class NoOpPregenListener implements PregenListener { @Override public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisProjectEntityDependencyTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisProjectEntityDependencyTest.java new file mode 100644 index 000000000..09a3af8f3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisProjectEntityDependencyTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.engine.object.IrisEntitySpawn; +import art.arcane.iris.engine.object.IrisSpawner; +import art.arcane.volmlib.util.collection.KSet; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisProjectEntityDependencyTest { + @Test + public void collectsInitialOnlyEntityAlongsideNormalSpawnEntityForExport() { + IrisSpawner spawner = new IrisSpawner(); + spawner.getSpawns().add(new IrisEntitySpawn().setEntity("standard/passive/cow")); + spawner.getInitialSpawns().add(new IrisEntitySpawn().setEntity("beta/sentinel")); + KSet spawners = new KSet<>(); + spawners.add(spawner); + + KSet entityKeys = IrisProject.collectSpawnerEntityKeys(spawners); + + assertEquals(2, entityKeys.size()); + assertTrue(entityKeys.contains("standard/passive/cow")); + assertTrue(entityKeys.contains("beta/sentinel")); + } + + @Test + public void returnsNoEntityDependenciesForEmptySpawnerSet() { + KSet entityKeys = IrisProject.collectSpawnerEntityKeys(new KSet<>()); + + assertTrue(entityKeys.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index 47e5045d4..5a135d285 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -19,6 +19,10 @@ package art.arcane.iris.core.project; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; @@ -50,6 +54,10 @@ import java.util.List; import java.util.Map; 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 SchemaBuilderParityTest { private static final List POTION_KEYS = List.of("minecraft:speed", "minecraft:slow_falling", "sniffer_mod:mega_boost"); @@ -108,6 +116,26 @@ public class SchemaBuilderParityTest { assertEquals(List.of("ALPHA", "BETA"), enumValues(schema.getJSONObject("definitions"), flavorDefinitionKey())); } + @Test + public void structurePlacementSchemaOmitsUnsupportedTransforms() { + IrisData data = mock(IrisData.class); + ResourceLoader structureLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + when(data.getStructureLoader()).thenReturn(structureLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(structureLoader.getPossibleKeys()).thenReturn(new String[0]); + when(pieceLoader.getPossibleKeys()).thenReturn(new String[0]); + + JSONObject properties = new SchemaBuilder(IrisStructurePlacement.class, data) + .construct().getJSONObject("properties"); + + assertTrue(properties.has("structures")); + assertTrue(properties.has("distribution")); + assertFalse(properties.has("rotation")); + assertFalse(properties.has("translate")); + assertFalse(properties.has("scale")); + } + private static String flavorDefinitionKey() { return "enum-" + Flavor.class.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); } diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java new file mode 100644 index 000000000..5fd1cecb2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java @@ -0,0 +1,37 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class IrisToolbeltPackReferenceTest { + @Test + public void plainPackUsesMatchingDimensionKey() { + IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference("overworld"); + + assertEquals("overworld", reference.pack()); + assertEquals("overworld", reference.dimension()); + assertFalse(reference.explicitDimension()); + } + + @Test + public void explicitDimensionKeepsPackAndDimensionSeparate() { + IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference(" custom_pack : dimensions/sky "); + + assertEquals("custom_pack", reference.pack()); + assertEquals("dimensions/sky", reference.dimension()); + assertTrue(reference.explicitDimension()); + } + + @Test + public void malformedReferencesAreRejected() { + assertNull(IrisToolbelt.parsePackReference(null)); + assertNull(IrisToolbelt.parsePackReference("")); + assertNull(IrisToolbelt.parsePackReference(":")); + assertNull(IrisToolbelt.parsePackReference("pack:")); + assertNull(IrisToolbelt.parsePackReference(":dimension")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java new file mode 100644 index 000000000..2e9c7ac18 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisInterpolator; +import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds; +import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBoundsProvider; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class IrisComplexGridBoundsCacheTest { + @Test + public void gridBoundsCacheIsIsolatedPerComplex() throws Exception { + IrisComplex first = createComplex(); + IrisComplex second = createComplex(); + Method cornerBounds = cornerBoundsMethod(); + + long firstPacked = invokeCornerBounds(first, cornerBounds, new CountingInterpolator(1.25D, 2.5D), 64, -32); + long secondPacked = invokeCornerBounds(second, cornerBounds, new CountingInterpolator(10.25D, 20.5D), 64, -32); + + assertNotEquals(firstPacked, secondPacked); + assertEquals(1.25F, unpackLow(firstPacked), 0D); + assertEquals(2.5F, unpackHigh(firstPacked), 0D); + assertEquals(10.25F, unpackLow(secondPacked), 0D); + assertEquals(20.5F, unpackHigh(secondPacked), 0D); + } + + @Test + public void gridBoundsCacheReusesCornersWithinSameComplex() throws Exception { + IrisComplex complex = createComplex(); + Method cornerBounds = cornerBoundsMethod(); + CountingInterpolator interpolator = new CountingInterpolator(3.5D, 7.25D); + + long firstPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96); + long secondPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96); + + assertEquals(firstPacked, secondPacked); + assertEquals(1, interpolator.getInvocations()); + } + + private IrisComplex createComplex() throws Exception { + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + + Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds"); + generatorBounds.setAccessible(true); + generatorBounds.set(complex, new HashMap<>()); + + Class cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache"); + Constructor cacheConstructor = cacheClass.getDeclaredConstructor(); + cacheConstructor.setAccessible(true); + ThreadLocal cache = ThreadLocal.withInitial(() -> newCache(cacheConstructor)); + Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache"); + gridBoundsCache.setAccessible(true); + gridBoundsCache.set(complex, cache); + return complex; + } + + private Object newCache(Constructor constructor) { + try { + return constructor.newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private Method cornerBoundsMethod() throws Exception { + Class cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache"); + Method method = IrisComplex.class.getDeclaredMethod( + "cornerBounds", + cacheClass, + Engine.class, + IrisInterpolator.class, + int.class, + Set.class, + int.class, + int.class + ); + method.setAccessible(true); + return method; + } + + private long invokeCornerBounds(IrisComplex complex, Method method, IrisInterpolator interpolator, int x, int z) throws Exception { + Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache"); + gridBoundsCache.setAccessible(true); + ThreadLocal cache = (ThreadLocal) gridBoundsCache.get(complex); + return (long) method.invoke(complex, cache.get(), null, interpolator, 0, Set.of(), x, z); + } + + private float unpackLow(long packed) { + return Float.intBitsToFloat((int) (packed >>> 32)); + } + + private float unpackHigh(long packed) { + return Float.intBitsToFloat((int) packed); + } + + private static final class CountingInterpolator extends IrisInterpolator { + private final NoiseBounds bounds; + private final AtomicInteger invocations = new AtomicInteger(); + + private CountingInterpolator(double low, double high) { + bounds = new NoiseBounds(low, high); + } + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + invocations.incrementAndGet(); + return bounds; + } + + private int getInvocations() { + return invocations.get(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java new file mode 100644 index 000000000..d1a286194 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.ChunkedDataCache; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.After; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeActuatorCoordinateTest { + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + @SuppressWarnings("unchecked") + public void derivativeScatterUsesEachWorldColumnCoordinate() { + bindPlatform(); + Engine engine = engine(); + IrisBiomeActuator actuator = new IrisBiomeActuator(engine); + Hunk output = mock(Hunk.class); + when(output.getWidth()).thenReturn(2); + when(output.getDepth()).thenReturn(2); + when(output.getHeight()).thenReturn(1); + + List samples = new ArrayList<>(); + IrisBiome biome = mock(IrisBiome.class); + when(biome.getSkyBiomeKey(any(RNG.class), same(engine), anyDouble(), anyDouble(), anyDouble())) + .thenAnswer(invocation -> { + double worldX = invocation.getArgument(2); + double worldZ = invocation.getArgument(4); + samples.add((int) worldX + "," + (int) worldZ); + return "minecraft:plains"; + }); + + ChunkedDataCache biomeCache = mock(ChunkedDataCache.class); + when(biomeCache.get(anyInt(), anyInt())).thenReturn(biome); + ChunkContext context = mock(ChunkContext.class); + when(context.getBiome()).thenReturn(biomeCache); + + actuator.onActuate(100, -200, output, false, context); + + assertEquals(List.of("100,-200", "100,-199", "101,-200", "101,-199"), samples); + } + + @SuppressWarnings("unchecked") + private Engine engine() { + Mantle mantle = mock(Mantle.class); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getMantle()).thenReturn(mantle); + SeedManager seedManager = mock(SeedManager.class); + when(seedManager.getBiome()).thenReturn(1337L); + Engine engine = mock(Engine.class); + when(engine.getCacheID()).thenReturn(1); + when(engine.getSeedManager()).thenReturn(seedManager); + when(engine.getMantle()).thenReturn(engineMantle); + when(engine.getMetrics()).thenReturn(new EngineMetrics(16)); + return engine; + } + + private void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBiome biome = mock(PlatformBiome.class); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.biome(anyString())).thenReturn(biome); + when(registries.block(anyString())).thenReturn(block); + PlatformBiomeWriter biomeWriter = mock(PlatformBiomeWriter.class); + when(biomeWriter.biomeIdFor(anyString())).thenReturn(1); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.biomeWriter()).thenReturn(biomeWriter); + IrisPlatforms.bind(platform); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java new file mode 100644 index 000000000..bd2740099 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java @@ -0,0 +1,70 @@ +package art.arcane.iris.engine.decorator; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDecorationPart; +import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class IrisDecoratorCaveContextTest { + @Test + @SuppressWarnings("unchecked") + public void explicitCaveContextSkipsFluidWithoutMutatingBiome() { + Engine engine = mock(Engine.class); + SeedManager seedManager = mock(SeedManager.class); + doReturn(seedManager).when(engine).getSeedManager(); + doReturn(mock(IrisData.class)).when(engine).getData(); + IrisDimension dimension = mock(IrisDimension.class); + doReturn(63).when(dimension).getFluidHeight(); + doReturn(dimension).when(engine).getDimension(); + + IrisDecorator decorator = mock(IrisDecorator.class); + doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any()); + doReturn(true).when(decorator).isStacking(); + doReturn(true).when(decorator).isForcePlace(); + doReturn(1).when(decorator).getHeight(any(), anyDouble(), anyDouble(), any()); + + IrisBiome biome = mock(IrisBiome.class); + doReturn(InferredType.LAND).when(biome).getInferredType(); + doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE); + doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.CEILING); + + PlatformBlockState fluid = mock(PlatformBlockState.class); + doReturn(true).when(fluid).isFluid(); + Hunk output = mock(Hunk.class); + doReturn(128).when(output).getHeight(); + doReturn(fluid).when(output).get(0, 10, 0); + + IrisSurfaceDecorator surfaceDecorator = new IrisSurfaceDecorator(engine); + surfaceDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10); + DecoratorCore.PlaceOpts surfaceOptions = DecoratorCore.SCRATCH_OPTS.get(); + assertTrue(surfaceOptions.caveSkipFluid); + assertFalse(surfaceOptions.underwater); + + IrisCeilingDecorator ceilingDecorator = new IrisCeilingDecorator(engine); + ceilingDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10); + DecoratorCore.PlaceOpts ceilingOptions = DecoratorCore.SCRATCH_OPTS.get(); + assertTrue(ceilingOptions.caveSkipFluid); + assertEquals(InferredType.LAND, biome.getInferredType()); + } + + @Test + public void nullInferenceRemainsSafeForNormalSurfaceContext() { + assertTrue(IrisSurfaceDecorator.isUnderwater(null, 10, 63)); + assertFalse(IrisSurfaceDecorator.skipsFluid(null)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementMarkerTest.java new file mode 100644 index 000000000..f25571f1c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementMarkerTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class StructurePlacementMarkerTest { + @Test + public void structureMarkerRoundTripsDelimiterSafeKeys() { + String objectKey = "pieces/room@east:alpha/樹"; + String structureKey = "structures/village@night:beta/城"; + + String encoded = StructurePlacementMarker.encodeStructure(objectKey, 918273, structureKey); + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(encoded); + + assertTrue(encoded.startsWith("@iris-structure:v1:")); + assertEquals(objectKey, decoded.objectKey()); + assertEquals(918273, decoded.placementId()); + assertEquals(structureKey, decoded.structureKey()); + assertTrue(decoded.structureAware()); + } + + @Test + public void legacyMarkerDecodesWithoutChangingFields() { + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode("objects/oak_tree@42"); + + assertEquals("objects/oak_tree", decoded.objectKey()); + assertEquals(42, decoded.placementId()); + assertNull(decoded.structureKey()); + assertFalse(decoded.structureAware()); + } + + @Test + public void malformedAndUnknownMarkersFailSafely() { + assertNull(StructurePlacementMarker.decode(null)); + assertNull(StructurePlacementMarker.decode("")); + assertNull(StructurePlacementMarker.decode("objects/oak_tree")); + assertNull(StructurePlacementMarker.decode("objects/oak_tree@not-an-id")); + assertNull(StructurePlacementMarker.decode("objects/oak_tree@42@extra")); + assertNull(StructurePlacementMarker.decode("@iris-structure:v2:b2JqZWN0:42:c3RydWN0dXJl")); + assertNull(StructurePlacementMarker.decode("@iris-structure:v1:not%base64:42:c3RydWN0dXJl")); + assertNull(StructurePlacementMarker.decode("@iris-structure:v1:b2JqZWN0:42:")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 3a92a88a7..c34767a06 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -37,9 +37,11 @@ import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -117,6 +119,43 @@ public class IrisCaveCarver3DNearParityTest { assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids); } + @Test + public void warpCacheDoesNotCrossContaminateCarverInstancesOnSameThread() throws Exception { + Engine engine = createEngine(128, 92); + IrisCaveProfile firstProfile = createProfile(true, false); + firstProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(0.12D)); + IrisCaveProfile secondProfile = createProfile(true, false); + secondProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D)); + IrisCaveProfile controlProfile = createProfile(true, false); + controlProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D)); + IrisCaveCarver3D firstCarver = new IrisCaveCarver3D(engine, firstProfile); + IrisCaveCarver3D secondCarver = new IrisCaveCarver3D(engine, secondProfile); + IrisCaveCarver3D controlCarver = new IrisCaveCarver3D(engine, controlProfile); + int x = 48; + int y = 64; + int z = -352; + + double firstDensity = sampleDensity(firstCarver, x, y, z); + double secondDensity = sampleDensity(secondCarver, x, y, z); + AtomicReference controlDensity = new AtomicReference<>(); + AtomicReference controlFailure = new AtomicReference<>(); + Thread controlThread = new Thread(() -> { + try { + controlDensity.set(sampleDensity(controlCarver, x, y, z)); + } catch (Throwable throwable) { + controlFailure.set(throwable); + } + }, "Iris cave warp cache control"); + controlThread.start(); + controlThread.join(); + + if (controlFailure.get() != null) { + throw new AssertionError(controlFailure.get()); + } + assertNotEquals(firstDensity, controlDensity.get(), 0D); + assertEquals(controlDensity.get(), secondDensity, 0D); + } + @Test public void exactPathCarvesChunkEdgesAndRespectsWorldHeightClipping() { Engine engine = createEngine(48, 46); @@ -277,6 +316,10 @@ public class IrisCaveCarver3DNearParityTest { return elapsed; } + private double sampleDensity(IrisCaveCarver3D carver, int x, int y, int z) throws Exception { + return (double) sampleDensityMethod.invoke(carver, x, y, z); + } + private long runNaiveOnce(IrisCaveCarver3D carver, int chunkX, int chunkZ, double[] columnWeights, IrisRange worldYRange, int[] precomputedSurfaceHeights, int worldHeight) throws Exception { WriterCapture capture = createWriterCapture(worldHeight); long start = System.nanoTime(); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java new file mode 100644 index 000000000..21cc1acf2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java @@ -0,0 +1,35 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisStructureComponentMarkerTest { + @Test + public void markerFilterAcceptsOnlyStorageContainers() { + PlatformBlockState storage = mock(PlatformBlockState.class); + PlatformBlockState solid = mock(PlatformBlockState.class); + when(storage.isStorageChest()).thenReturn(true); + when(solid.isStorageChest()).thenReturn(false); + + assertTrue(IrisStructureComponent.shouldWriteStructureMarker(storage)); + assertFalse(IrisStructureComponent.shouldWriteStructureMarker(solid)); + assertFalse(IrisStructureComponent.shouldWriteStructureMarker(null)); + } + + @Test + public void placementIdIsStableAndCoordinateSensitive() { + int first = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35); + int repeated = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35); + int moved = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 21, -14, 35); + + assertEquals(first, repeated); + assertNotEquals(first, moved); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java new file mode 100644 index 000000000..1a84f561d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java @@ -0,0 +1,135 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.nms.container.Pair; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.MantleComponent; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MatterGenerator; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class MantleCarvingComponentBoundaryRadiusTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void boundaryWallResolutionSchedulesAdjacentCarvingChunks() { + EngineMantle engineMantle = mock(EngineMantle.class); + MantleCarvingComponent component = new MantleCarvingComponent(engineMantle); + + assertEquals(1, component.getRadius()); + assertEquals(1, Math.ceilDiv(component.getRadius(), 16)); + } + + @Test + @SuppressWarnings("unchecked") + public void matterGenerationRunsCarvingForAllAdjacentChunks() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + EngineMantle engineMantle = mock(EngineMantle.class); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getEngine()).thenReturn(engine); + + List generatedChunks = new ArrayList<>(); + MantleCarvingComponent component = spy(new MantleCarvingComponent(engineMantle)); + doAnswer(invocation -> { + int chunkX = invocation.getArgument(1); + int chunkZ = invocation.getArgument(2); + generatedChunks.add(chunkX + "," + chunkZ); + return null; + }).when(component).generateLayer(any(), anyInt(), anyInt(), any()); + + TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, component); + generator.generateMatter(4, -7, false, mock(ChunkContext.class)); + + assertEquals(List.of( + "3,-8", "3,-7", "3,-6", + "4,-8", "4,-7", "4,-6", + "5,-8", "5,-7", "5,-6" + ), generatedChunks); + } + + private static final class TestMatterGenerator implements MatterGenerator { + private final Engine engine; + private final Mantle mantle; + private final List, Integer>> components; + + private TestMatterGenerator(Engine engine, Mantle mantle, MantleComponent component) { + this.engine = engine; + this.mantle = mantle; + this.components = List.of(new Pair<>(List.of(component), 1)); + } + + @Override + public Engine getEngine() { + return engine; + } + + @Override + public Mantle getMantle() { + return mantle; + } + + @Override + public int getRadius() { + return 1; + } + + @Override + public int getRealRadius() { + return 0; + } + + @Override + public List, Integer>> getComponents() { + return components; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java new file mode 100644 index 000000000..868786fe6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java @@ -0,0 +1,124 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.matter.MatterCavern; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class IrisCarveModifierInferenceIsolationTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + doReturn(block).when(registries).block(anyString()); + IrisPlatform platform = mock(IrisPlatform.class); + doReturn(registries).when(platform).registries(); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + @SuppressWarnings("unchecked") + public void cavePaintingDoesNotMutateSharedBiomeInference() throws Exception { + IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); + biome.getLayers().clear(); + biome.getCaveCeilingLayers().clear(); + + Engine engine = mock(Engine.class); + doReturn(mock(IrisData.class)).when(engine).getData(); + doReturn(mock(IrisDimension.class)).when(engine).getDimension(); + doReturn(mock(IrisComplex.class)).when(engine).getComplex(); + doReturn(IrisWorld.builder().minHeight(-256).maxHeight(512).build()).when(engine).getWorld(); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + + Map customBiomes = new HashMap<>(); + customBiomes.put("shared", biome); + Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod( + "paintBoundaryZone", + Hunk.class, + MatterCavern.class, + int.class, + int.class, + int.class, + int.class, + int.class, + int.class, + IrisDimensionCarvingResolver.State.class, + Long2ObjectOpenHashMap.class, + Map.class + ); + paintBoundaryZone.setAccessible(true); + paintBoundaryZone.invoke( + modifier, + mock(Hunk.class), + new MatterCavern(true, "shared", (byte) 0), + 0, + 0, + 0, + 0, + 1, + 1, + new IrisDimensionCarvingResolver.State(), + new Long2ObjectOpenHashMap(), + customBiomes + ); + + IrisBiome fallback = new IrisBiome(); + ProceduralStream landStream = mock(ProceduralStream.class); + doReturn(fallback).when(landStream).get(anyDouble(), anyDouble()); + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + Field landBiomeStream = IrisComplex.class.getDeclaredField("landBiomeStream"); + landBiomeStream.setAccessible(true); + landBiomeStream.set(complex, landStream); + IrisRegion region = mock(IrisRegion.class); + doReturn(0D).when(region).getShoreHeight(anyDouble(), anyDouble()); + Method fixBiomeType = IrisComplex.class.getDeclaredMethod( + "fixBiomeType", + Double.class, + IrisBiome.class, + IrisRegion.class, + Double.class, + Double.class, + double.class + ); + fixBiomeType.setAccessible(true); + IrisBiome resolved = (IrisBiome) fixBiomeType.invoke(complex, 10D, biome, region, 0D, 0D, 0D); + assertSame(biome, resolved); + assertEquals(InferredType.LAND, biome.getInferredType()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnTest.java new file mode 100644 index 000000000..47548d9a8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnTest.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class IrisBiomeCustomSpawnTest { + @Test + public void normalizesNamespacedEntityKey() { + IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" Minecraft:Slime "); + + assertEquals("minecraft:slime", spawn.getTypeKey()); + } + + @Test + public void prefixesBareEntityKey() { + IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType("Slime"); + + assertEquals("minecraft:slime", spawn.getTypeKey()); + } + + @Test + public void returnsNullForBlankEntityKey() { + IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" "); + + assertNull(spawn.getTypeKey()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeGeneratorSeedIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeGeneratorSeedIsolationTest.java new file mode 100644 index 000000000..bee31765e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeGeneratorSeedIsolationTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeGeneratorSeedIsolationTest { + private static final double SAMPLE_X = 128D; + private static final double SAMPLE_Z = -64D; + + @Test + public void engineSeedMakesPaperStyleFirstCallOrderIrrelevant() { + IrisData data = dataWithActiveEngine(new AtomicReference<>(engine(1337L))); + IrisBiome paperFirst = biome(data); + IrisBiome moddedFirst = biome(data); + + CNG paperGenerator = paperFirst.getBiomeGenerator(new RNG(8457289L)); + CNG moddedGenerator = moddedFirst.getBiomeGenerator(new RNG(-991245L)); + + assertEquals(paperGenerator.noise(SAMPLE_X, SAMPLE_Z), moddedGenerator.noise(SAMPLE_X, SAMPLE_Z), 0D); + } + + @Test + public void distinctEnginesKeepDistinctBiomeGenerators() { + Engine firstEngine = engine(1337L); + Engine secondEngine = engine(7331L); + IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine))); + + CNG first = biome.getBiomeGenerator(new RNG(1L), firstEngine); + CNG second = biome.getBiomeGenerator(new RNG(1L), secondEngine); + + assertNotSame(first, second); + assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D); + } + + @Test + public void sameSeedEnginesSharingBiomeReuseBiomeGenerator() { + Engine firstEngine = engine(1337L); + Engine secondEngine = engine(1337L); + IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine))); + + CNG first = biome.getBiomeGenerator(new RNG(11L), firstEngine); + CNG second = biome.getBiomeGenerator(new RNG(99L), secondEngine); + + assertSame(first, second); + } + + @Test + public void engineLessToolingUsesSuppliedRngSeed() { + IrisBiome biome = biome(null); + + CNG first = biome.getBiomeGenerator(new RNG(11L)); + CNG sameSeed = biome.getBiomeGenerator(new RNG(11L)); + CNG second = biome.getBiomeGenerator(new RNG(99L)); + + assertSame(first, sameSeed); + assertNotSame(first, second); + assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D); + } + + @Test + public void concurrentExactEnginesIgnoreLoaderEngine() throws Exception { + Engine firstEngine = engine(1337L); + Engine secondEngine = engine(7331L); + IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine))); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstFuture = executor.submit(() -> repeatedlyResolve(biome, firstEngine, start)); + Future secondFuture = executor.submit(() -> repeatedlyResolve(biome, secondEngine, start)); + start.countDown(); + + CNG first = firstFuture.get(); + CNG second = secondFuture.get(); + assertNotSame(first, second); + assertSame(first, biome.getBiomeGenerator(new RNG(99L), firstEngine)); + assertSame(second, biome.getBiomeGenerator(new RNG(99L), secondEngine)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void biomeGeneratorCacheEvictsOldSeeds() { + IrisBiome biome = biome(null); + CNG first = biome.getBiomeGenerator(new RNG(0L)); + + for (long seed = 1L; seed <= 8L; seed++) { + biome.getBiomeGenerator(new RNG(seed)); + } + + assertNotSame(first, biome.getBiomeGenerator(new RNG(0L))); + } + + private IrisBiome biome(IrisData data) { + IrisBiome biome = new IrisBiome().setName("Scatter Test"); + biome.setLoader(data); + return biome; + } + + private IrisData dataWithActiveEngine(AtomicReference activeEngine) { + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> activeEngine.get()); + return data; + } + + private Engine engine(long biomeSeed) { + SeedManager seedManager = mock(SeedManager.class); + when(seedManager.getBiome()).thenReturn(biomeSeed); + Engine engine = mock(Engine.class); + when(engine.getSeedManager()).thenReturn(seedManager); + return engine; + } + + private CNG repeatedlyResolve(IrisBiome biome, Engine engine, CountDownLatch start) throws InterruptedException { + start.await(); + CNG generator = biome.getBiomeGenerator(new RNG(1L), engine); + for (int index = 0; index < 100; index++) { + assertSame(generator, biome.getBiomeGenerator(new RNG(index + 2L), engine)); + } + return generator; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java new file mode 100644 index 000000000..31d93c6db --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.engine.object; + +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class IrisDimensionBiomeTagTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void biomeTagWritesAreSortedAndDeduplicated() throws Exception { + Path output = temporaryFolder.getRoot().toPath().resolve("allows_surface_slime_spawns.json"); + + IrisDimension.writeBiomeTag(output, "overworld:swamp_b"); + IrisDimension.writeBiomeTag(output, "overworld:swamp_a"); + IrisDimension.writeBiomeTag(output, "overworld:swamp_b"); + + JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8)); + JSONArray values = tag.getJSONArray("values"); + assertFalse(tag.getBoolean("replace")); + assertEquals(2, values.length()); + assertEquals("overworld:swamp_a", values.getString(0)); + assertEquals("overworld:swamp_b", values.getString(1)); + } + + @Test + public void customBiomeTagUsesTheMinecraftBiomeTagPath() throws Exception { + KList tags = new KList<>(); + tags.add("minecraft:allows_surface_slime_spawns"); + IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags); + + Path output = temporaryFolder.getRoot().toPath() + .resolve("iris/data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json"); + JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8)); + + assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStructureLootPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureLootPlacementTest.java new file mode 100644 index 000000000..578b153eb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureLootPlacementTest.java @@ -0,0 +1,38 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisStructureLootPlacementTest { + @Test + public void createsPiecePlacementWithAuthoredLootOrderAndDefaultWeights() { + IrisStructure structure = new IrisStructure(); + structure.getLoot().add("village_common"); + structure.getLoot().add("village_rare"); + + IrisObjectPlacement placement = structure.createLootPlacement("pieces/village_house"); + + assertEquals(1, placement.getPlace().size()); + assertEquals("pieces/village_house", placement.getPlace().get(0)); + assertEquals(2, placement.getLoot().size()); + assertEquals("village_common", placement.getLoot().get(0).getName()); + assertEquals(1, placement.getLoot().get(0).getWeight()); + assertTrue(placement.getLoot().get(0).getFilter().isEmpty()); + assertEquals("village_rare", placement.getLoot().get(1).getName()); + assertEquals(1, placement.getLoot().get(1).getWeight()); + assertTrue(placement.getLoot().get(1).getFilter().isEmpty()); + assertFalse(placement.isOverrideGlobalLoot()); + } + + @Test + public void createsEmptyLootPlacementWhenStructureHasNoLoot() { + IrisObjectPlacement placement = new IrisStructure().createLootPlacement("pieces/empty_house"); + + assertEquals("pieces/empty_house", placement.getPlace().get(0)); + assertTrue(placement.getLoot().isEmpty()); + assertFalse(placement.isOverrideGlobalLoot()); + } +} diff --git a/out/production/resources/plugin.yml b/out/production/resources/plugin.yml deleted file mode 100644 index 59767aa97..000000000 --- a/out/production/resources/plugin.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: ${name} -version: ${version} -main: ${main} -load: STARTUP -authors: [ cyberpwn, NextdoorPsycho ] -website: volmit.com -description: More than a Dimension! -libraries: - - org.zeroturnaround:zt-zip:1.14 - - com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2 - - org.ow2.asm:asm:9.2 - - com.google.code.gson:gson:2.8.7 - - it.unimi.dsi:fastutil:8.5.4 - - com.google.guava:guava:30.1.1-jre - - bsf:bsf:2.4.0 - - rhino:js:1.7R2 -commands: - iris: - aliases: [ ir, irs ] -api-version: ${apiversion} -hotload-dependencies: false \ No newline at end of file diff --git a/probe/build.gradle b/probe/build.gradle index 8261356ed..290b30721 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -16,6 +16,7 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') dependencies { implementation(project(':core')) compileOnly(libs.spigot) + testImplementation('junit:junit:4.13.2') compileOnly(volmLibCoordinate) { transitive = false } @@ -44,6 +45,10 @@ tasks.named('run', JavaExec).configure { dependsOn(':core:compileJava') } +tasks.named('test').configure { + dependsOn(':core:compileJava') +} + String probePack = providers.gradleProperty('probePack') .orElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/instances/purpur-26.2/plugins/Iris/packs/overworld') .get() diff --git a/probe/src/main/java/art/arcane/iris/probe/ClassloadProbe.java b/probe/src/main/java/art/arcane/iris/probe/ClassloadProbe.java index 9ff754466..716c91929 100644 --- a/probe/src/main/java/art/arcane/iris/probe/ClassloadProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/ClassloadProbe.java @@ -18,15 +18,23 @@ package art.arcane.iris.probe; +import java.io.BufferedReader; +import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.TreeMap; import java.util.stream.Stream; public final class ClassloadProbe { + private static final String ALLOWLIST_RESOURCE = "/classload-allowlist.tsv"; + private static final String[] CRITICAL_PREFIXES = { "art.arcane.iris.engine.", "art.arcane.iris.util.", @@ -34,26 +42,6 @@ public final class ClassloadProbe { "art.arcane.iris.spi.", }; - private static final String[] EXEMPT_PREFIXES = { - "art.arcane.iris.platform.bukkit.", - "art.arcane.iris.engine.platform.", - "art.arcane.iris.util.common.plugin.", - "art.arcane.iris.util.common.inventorygui.", - "art.arcane.iris.util.common.director.", - "art.arcane.iris.util.common.data.registry.", - }; - - private static final String[] EXEMPT_CLASSES = { - "art.arcane.iris.util.common.misc.Bindings", - "art.arcane.iris.util.common.misc.SlimJar", - "art.arcane.iris.util.common.misc.ServerProperties", - "art.arcane.iris.util.common.data.IrisCustomData", - "art.arcane.iris.engine.IrisWorldManager", - "art.arcane.iris.engine.framework.EngineAssignedWorldManager", - "art.arcane.iris.engine.object.StudioMode", - "art.arcane.iris.engine.framework.placer.WorldObjectPlacer", - }; - public static void main(String[] args) throws IOException { art.arcane.iris.spi.IrisPlatforms.bind(new StubPlatform()); boolean bukkitPresent = true; @@ -63,39 +51,42 @@ public final class ClassloadProbe { bukkitPresent = false; } System.out.println("[probe] org.bukkit on classpath: " + bukkitPresent); - - Path classesRoot = Path.of(args[0]); - List names = new ArrayList<>(); - try (Stream walk = Files.walk(classesRoot)) { - walk.filter(p -> p.toString().endsWith(".class")) - .forEach(p -> { - String rel = classesRoot.relativize(p).toString(); - String name = rel.substring(0, rel.length() - 6).replace('/', '.'); - if (name.contains("$")) { - return; - } - names.add(name); - }); + if (bukkitPresent) { + System.out.println("[probe] RESULT: FAIL - org.bukkit must be absent from the classload probe runtime"); + System.exit(1); + return; } - names.sort(String::compareTo); - TreeMap criticalFailures = new TreeMap<>(); - TreeMap otherFailures = new TreeMap<>(); + TreeMap allowlist = loadAllowlist(); + Path classesRoot = Path.of(args[0]); + List names = scanClassNames(classesRoot); + + TreeMap criticalFailures = new TreeMap<>(); + TreeMap otherFailures = new TreeMap<>(); int loaded = 0; + int nestedTotal = 0; + int nestedLoaded = 0; int criticalTotal = 0; for (String name : names) { - boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !matchesAny(name, EXEMPT_PREFIXES) && !exactAny(name); + boolean nested = name.contains("$"); + if (nested) { + nestedTotal++; + } + boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !allowlist.containsKey(name); if (critical) { criticalTotal++; } try { Class.forName(name, true, ClassloadProbe.class.getClassLoader()); loaded++; + if (nested) { + nestedLoaded++; + } } catch (Throwable failure) { if (System.getenv("PROBE_TRACE") != null && name.contains(System.getenv("PROBE_TRACE"))) { failure.printStackTrace(System.out); } - String cause = rootCause(failure); + Failure cause = Failure.from(failure); if (critical) { criticalFailures.put(name, cause); } else { @@ -105,25 +96,56 @@ public final class ClassloadProbe { } System.out.println("[probe] classes scanned: " + names.size() + ", initialized OK: " + loaded); + System.out.println("[probe] nested classes scanned: " + nestedTotal + ", initialized OK: " + nestedLoaded + + ", initialization failures: " + (nestedTotal - nestedLoaded)); System.out.println("[probe] critical set: " + criticalTotal + ", critical failures: " + criticalFailures.size()); - criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause)); + criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause.display())); System.out.println("[probe] non-critical failures: " + otherFailures.size()); - otherFailures.forEach((name, cause) -> System.out.println(" other " + name + " -> " + cause)); + Review review = review(otherFailures, allowlist); + System.out.println("[probe] reviewed allowances: " + allowlist.size() + ", matched failures: " + review.matched()); + otherFailures.forEach((name, cause) -> { + Allowance allowance = allowlist.get(name); + String status = allowance != null && allowance.matches(cause) ? "allowed " + allowance.category() : "UNEXPECTED"; + System.out.println(" " + status + " " + name + " -> " + cause.display()); + }); + System.out.println("[probe] unexpected non-critical failures: " + review.unexpected().size()); + System.out.println("[probe] stale allowances: " + review.staleAllowances().size()); + review.staleAllowances().forEach((name, allowance) -> System.out.println(" STALE " + name + " -> " + allowance)); - if (!criticalFailures.isEmpty()) { + if (!criticalFailures.isEmpty() || !review.passes()) { System.out.println("[probe] RESULT: FAIL"); System.exit(1); } System.out.println("[probe] RESULT: PASS"); } - private static boolean exactAny(String name) { - for (String exempt : EXEMPT_CLASSES) { - if (name.equals(exempt)) { - return true; + static Review review(Map failures, Map allowlist) { + TreeMap unexpected = new TreeMap<>(); + TreeMap staleAllowances = new TreeMap<>(allowlist); + int matched = 0; + for (Map.Entry entry : failures.entrySet()) { + Allowance allowance = allowlist.get(entry.getKey()); + staleAllowances.remove(entry.getKey()); + if (allowance == null || !allowance.matches(entry.getValue())) { + unexpected.put(entry.getKey(), entry.getValue()); + } else { + matched++; } } - return false; + return new Review(matched, unexpected, staleAllowances); + } + + static List scanClassNames(Path classesRoot) throws IOException { + List names = new ArrayList<>(); + try (Stream walk = Files.walk(classesRoot)) { + walk.filter(path -> path.toString().endsWith(".class")) + .forEach(path -> { + String relative = classesRoot.relativize(path).toString(); + names.add(relative.substring(0, relative.length() - 6).replace(File.separatorChar, '.')); + }); + } + names.sort(String::compareTo); + return names; } private static boolean matchesAny(String name, String[] prefixes) { @@ -135,11 +157,91 @@ public final class ClassloadProbe { return false; } - private static String rootCause(Throwable failure) { - Throwable cause = failure; - while (cause.getCause() != null && cause.getCause() != cause) { - cause = cause.getCause(); + private static TreeMap loadAllowlist() throws IOException { + InputStream resource = ClassloadProbe.class.getResourceAsStream(ALLOWLIST_RESOURCE); + if (resource == null) { + throw new IOException("Classload allowlist resource not found: " + ALLOWLIST_RESOURCE); + } + + TreeMap allowlist = new TreeMap<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isBlank()) { + continue; + } + String[] fields = line.split("\\t", -1); + if (fields.length != 3 || fields[0].isBlank() || fields[2].isBlank()) { + throw new IOException("Invalid classload allowlist entry at line " + lineNumber); + } + Allowance allowance; + try { + allowance = new Allowance(AllowanceCategory.valueOf(fields[1]), fields[2]); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid classload allowlist category at line " + lineNumber + ": " + fields[1], exception); + } + if (allowlist.put(fields[0], allowance) != null) { + throw new IOException("Duplicate classload allowlist class at line " + lineNumber + ": " + fields[0]); + } + } + } + return allowlist; + } + + enum AllowanceCategory { + BUKKIT_API, + PAPERLIB_API, + ADVENTURE_API, + MYTHICMOBS_API, + SERVER_RUNTIME_FILE + } + + record Allowance(AllowanceCategory category, String detail) { + boolean matches(Failure failure) { + return switch (category) { + case BUKKIT_API -> detail.startsWith("org.bukkit.") && failure.isMissingClassIn("org.bukkit."); + case PAPERLIB_API -> detail.startsWith("io.papermc.lib.") && failure.isMissingClassIn("io.papermc.lib."); + case ADVENTURE_API -> detail.startsWith("net.kyori.adventure.") && failure.isMissingClassIn("net.kyori.adventure."); + case MYTHICMOBS_API -> detail.startsWith("io.lumine.mythic.") && failure.isMissingClassIn("io.lumine.mythic."); + case SERVER_RUNTIME_FILE -> failure.exceptionClass().equals("java.io.FileNotFoundException") + && failure.message().startsWith(detail); + }; + } + } + + record Failure(String exceptionClass, String message) { + static Failure from(Throwable failure) { + Throwable cause = failure; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return new Failure(cause.getClass().getName(), String.valueOf(cause.getMessage())); + } + + boolean isMissingClassIn(String packagePrefix) { + if (exceptionClass.equals("java.lang.ClassNotFoundException")) { + return message.startsWith(packagePrefix); + } + String internalPrefix = packagePrefix.replace('.', '/'); + if (exceptionClass.equals("java.lang.NoClassDefFoundError")) { + return message.startsWith(internalPrefix); + } + return exceptionClass.equals("java.lang.ExceptionInInitializerError") + && message.contains("NoClassDefFoundError: " + internalPrefix); + } + + String display() { + int separator = exceptionClass.lastIndexOf('.'); + String simpleName = separator < 0 ? exceptionClass : exceptionClass.substring(separator + 1); + return simpleName + ": " + message; + } + } + + record Review(int matched, TreeMap unexpected, TreeMap staleAllowances) { + boolean passes() { + return unexpected.isEmpty() && staleAllowances.isEmpty(); } - return cause.getClass().getSimpleName() + ": " + cause.getMessage(); } } diff --git a/probe/src/main/resources/classload-allowlist.tsv b/probe/src/main/resources/classload-allowlist.tsv new file mode 100644 index 000000000..31c64c3df --- /dev/null +++ b/probe/src/main/resources/classload-allowlist.tsv @@ -0,0 +1,83 @@ +art.arcane.iris.core.IrisWorlds BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.core.ServerConfigurator BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.core.edit.BlockSignal BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.edit.BlockSignal$1 BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.edit.DustRevealer ADVENTURE_API net.kyori.adventure.text.format.TextColor +art.arcane.iris.core.events.IrisEngineEvent BUKKIT_API org.bukkit.event.Event +art.arcane.iris.core.events.IrisEngineHotloadEvent BUKKIT_API org.bukkit.event.Event +art.arcane.iris.core.events.IrisLootEvent BUKKIT_API org.bukkit.event.Event +art.arcane.iris.core.events.IrisLootEvent$1 BUKKIT_API org.bukkit.loot.LootTable +art.arcane.iris.core.events.IrisLootEvent$2 BUKKIT_API org.bukkit.inventory.InventoryHolder +art.arcane.iris.core.lifecycle.PaperLibBootstrap PAPERLIB_API io.papermc.lib.environments.Environment +art.arcane.iris.core.lifecycle.PaperLibBootstrap$ModernPaperEnvironment PAPERLIB_API io.papermc.lib.environments.PaperEnvironment +art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.HMCLeavesDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.ItemAdderDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.MythicMobsDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisBiomeCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition +art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition +art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit +art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.service.BoardSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.BoardSVC$PlayerBoard BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.service.ExternalDataSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.GlobalCacheSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.LogFilterSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.ObjectSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.ObjectStudioSaveService BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.PreservationSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.StudioSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.service.TreeSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.tools.IrisToolbelt BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.ChunkGenerator +art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed +art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event +art.arcane.iris.engine.object.IrisDirection$11 BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.engine.object.IrisEffect$BukkitFx BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.engine.object.IrisEntity$BukkitOps BUKKIT_API org.bukkit.loot.LootTable +art.arcane.iris.engine.object.IrisEntity$BukkitOps$1 BUKKIT_API org.bukkit.loot.LootTable +art.arcane.iris.engine.object.IrisObjectRotation$1 BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.engine.object.IrisObjectRotation$Faces BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.engine.object.LegacyTileData$BannerHandler BUKKIT_API org.bukkit.Keyed +art.arcane.iris.engine.object.LegacyTileData$Handlers BUKKIT_API org.bukkit.block.BlockState +art.arcane.iris.engine.object.LegacyTileData$SignHandler BUKKIT_API org.bukkit.Keyed +art.arcane.iris.engine.object.LegacyTileData$SignTags BUKKIT_API org.bukkit.Tag +art.arcane.iris.engine.object.LegacyTileData$SignTags$1 BUKKIT_API org.bukkit.Tag +art.arcane.iris.engine.platform.BukkitChunkGenerator BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.engine.platform.BukkitChunkGenerator$2 BUKKIT_API org.bukkit.HeightMap +art.arcane.iris.engine.platform.DummyBiomeProvider BUKKIT_API org.bukkit.generator.BiomeProvider +art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.generator.ChunkGenerator +art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material +art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material +art.arcane.iris.platform.bukkit.BukkitPlatform BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData +art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute +art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material +art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle +art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector +art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor +art.arcane.iris.util.common.inventorygui.WindowResolution BUKKIT_API org.bukkit.event.inventory.InventoryType +art.arcane.iris.util.common.misc.Bindings BUKKIT_API org.bukkit.plugin.Plugin +art.arcane.iris.util.common.misc.ServerProperties SERVER_RUNTIME_FILE server.properties +art.arcane.iris.util.common.misc.SlimJar BUKKIT_API org.bukkit.plugin.Plugin +art.arcane.iris.util.common.plugin.CommandDummy BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.util.common.plugin.Controller BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.util.common.plugin.IController BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.util.common.plugin.IrisService BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.util.common.plugin.RouterCommand BUKKIT_API org.bukkit.command.Command +art.arcane.iris.util.common.plugin.VolmitPlugin BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.util.common.plugin.VolmitSender BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.util.common.plugin.chunk.ChunkTickets BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.util.nbt.common.mca.NBTWorld$Holder BUKKIT_API org.bukkit.Material +art.arcane.iris.util.project.sentry.ServerID$Holder BUKKIT_API org.bukkit.Bukkit diff --git a/probe/src/test/java/art/arcane/iris/probe/ClassloadProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/ClassloadProbeTest.java new file mode 100644 index 000000000..e6a50c501 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/ClassloadProbeTest.java @@ -0,0 +1,204 @@ +package art.arcane.iris.probe; + +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class ClassloadProbeTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void scansNamedAndGeneratedNestedClasses() throws Exception { + Path classesRoot = temporaryFolder.newFolder("classes").toPath(); + Path packageRoot = Files.createDirectories(classesRoot.resolve("art/arcane/iris")); + Files.createFile(packageRoot.resolve("Outer.class")); + Files.createFile(packageRoot.resolve("Outer$Nested.class")); + Files.createFile(packageRoot.resolve("Outer$1.class")); + Files.createFile(packageRoot.resolve("ignored.txt")); + + List names = ClassloadProbe.scanClassNames(classesRoot); + + assertEquals(List.of( + "art.arcane.iris.Outer", + "art.arcane.iris.Outer$1", + "art.arcane.iris.Outer$Nested" + ), names); + } + + @Test + public void scansEmptyClassDirectory() throws Exception { + Path classesRoot = temporaryFolder.newFolder("empty-classes").toPath(); + + assertTrue(ClassloadProbe.scanClassNames(classesRoot).isEmpty()); + } + + @Test + public void acceptsMatchingReviewedFailure() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ClassNotFoundException", + "org.bukkit.event.Listener" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.event.Listener" + ); + + ClassloadProbe.Review review = ClassloadProbe.review( + Map.of("art.arcane.iris.Allowed", failure), + Map.of("art.arcane.iris.Allowed", allowance) + ); + + assertTrue(review.passes()); + } + + @Test + public void rejectsUnlistedFailure() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ClassNotFoundException", + "org.bukkit.event.Listener" + ); + + ClassloadProbe.Review review = ClassloadProbe.review( + Map.of("art.arcane.iris.Unlisted", failure), + Map.of() + ); + + assertFalse(review.passes()); + assertTrue(review.unexpected().containsKey("art.arcane.iris.Unlisted")); + } + + @Test + public void acceptsDifferentMissingClassInReviewedNamespace() { + ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure( + "java.lang.ClassNotFoundException", + "org.bukkit.Material" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.event.Listener" + ); + + ClassloadProbe.Review review = ClassloadProbe.review( + Map.of("art.arcane.iris.Changed", changedFailure), + Map.of("art.arcane.iris.Changed", allowance) + ); + + assertTrue(review.passes()); + } + + @Test + public void rejectsChangedFailureNamespace() { + ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure( + "java.lang.ClassNotFoundException", + "io.papermc.lib.environments.Environment" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.event.Listener" + ); + + ClassloadProbe.Review review = ClassloadProbe.review( + Map.of("art.arcane.iris.Changed", changedFailure), + Map.of("art.arcane.iris.Changed", allowance) + ); + + assertFalse(review.passes()); + assertTrue(review.unexpected().containsKey("art.arcane.iris.Changed")); + } + + @Test + public void rejectsNonMissingClassFailure() { + ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure( + "java.lang.IllegalStateException", + "org.bukkit.event.Listener" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.event.Listener" + ); + + assertFalse(allowance.matches(changedFailure)); + } + + @Test + public void acceptsBukkitFailureCachedByClassInitialization() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ExceptionInInitializerError", + "Exception java.lang.NoClassDefFoundError: org/bukkit/Material [in thread \"main\"]" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.Material" + ); + + assertTrue(allowance.matches(failure)); + } + + @Test + public void acceptsDifferentBukkitFailureCachedByClassInitialization() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ExceptionInInitializerError", + "Exception java.lang.NoClassDefFoundError: org/bukkit/World [in thread \"main\"]" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.Material" + ); + + assertTrue(allowance.matches(failure)); + } + + @Test + public void rejectsDifferentNamespaceCachedByClassInitialization() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ExceptionInInitializerError", + "Exception java.lang.NoClassDefFoundError: io/papermc/lib/environments/Environment [in thread \"main\"]" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.Material" + ); + + assertFalse(allowance.matches(failure)); + } + + @Test + public void acceptsMythicMobsApiNamespace() { + ClassloadProbe.Failure failure = new ClassloadProbe.Failure( + "java.lang.ClassNotFoundException", + "io.lumine.mythic.api.skills.conditions.ILocationCondition" + ); + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.MYTHICMOBS_API, + "io.lumine.mythic.api.skills.conditions.ILocationCondition" + ); + + assertTrue(allowance.matches(failure)); + } + + @Test + public void rejectsStaleAllowance() { + ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance( + ClassloadProbe.AllowanceCategory.BUKKIT_API, + "org.bukkit.event.Listener" + ); + TreeMap allowlist = new TreeMap<>(); + allowlist.put("art.arcane.iris.Stale", allowance); + + ClassloadProbe.Review review = ClassloadProbe.review(Map.of(), allowlist); + + assertFalse(review.passes()); + assertTrue(review.staleAllowances().containsKey("art.arcane.iris.Stale")); + } +} diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformEntityType.java b/spi/src/main/java/art/arcane/iris/spi/PlatformEntityType.java index e2c7e91cb..8931d0fd4 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformEntityType.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformEntityType.java @@ -26,5 +26,7 @@ public interface PlatformEntityType { String namespace(); + String spawnCategory(); + Object nativeHandle(); } diff --git a/tools/simd-bench/README.md b/tools/simd-bench/README.md index 39d1304e7..ae6bd62f0 100644 --- a/tools/simd-bench/README.md +++ b/tools/simd-bench/README.md @@ -30,6 +30,9 @@ macOS / Linux: ./run.sh ``` +The run scripts compile the sources and create the ignored `simd-bench.jar` locally when it is +missing. Use `build.bat` or `./build.sh` to rebuild it explicitly. + Or invoke directly (the `--add-modules` flag is required because the Vector API is still an incubator module): diff --git a/tools/simd-bench/build.bat b/tools/simd-bench/build.bat new file mode 100644 index 000000000..e137d313e --- /dev/null +++ b/tools/simd-bench/build.bat @@ -0,0 +1,14 @@ +@echo off +setlocal +cd /d "%~dp0" +if exist out rmdir /s /q out +mkdir out +dir /s /b src\*.java > sources.txt +javac --release 25 --add-modules jdk.incubator.vector -d out @sources.txt +if errorlevel 1 ( + del sources.txt + exit /b 1 +) +del sources.txt +jar --create --file simd-bench.jar --main-class simdbench.Bench -C out . +exit /b %errorlevel% diff --git a/tools/simd-bench/build.sh b/tools/simd-bench/build.sh new file mode 100755 index 000000000..1f85d612e --- /dev/null +++ b/tools/simd-bench/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +sources_file="$(mktemp)" +trap 'rm -f "$sources_file"' EXIT +find src -name '*.java' -print | sort > "$sources_file" +rm -rf out +mkdir -p out +javac --release 25 --add-modules jdk.incubator.vector -d out "@$sources_file" +jar --create --file simd-bench.jar --main-class simdbench.Bench -C out . diff --git a/tools/simd-bench/out/simdbench/Bench.class b/tools/simd-bench/out/simdbench/Bench.class deleted file mode 100644 index 45f8e6dff681a140a576cbde752875dc5d24e24e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12112 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 UMP6U!*U^I;(tp8k@LN>)e|%c`D*ylh diff --git a/tools/simd-bench/out/simdbench/NoiseKernels2D.class b/tools/simd-bench/out/simdbench/NoiseKernels2D.class deleted file mode 100644 index e80d06ef3a69dec7bfd78c55ead616819b256ecc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 208 zcmY+8y9&ZU5Jm6!NPIMk7Jh;@25jvl2I8|2Y^*k$A+D^6Y@+yW7Jh&qC2nlQJKbeC z_q^VZCxAKHE-HjUuFi49sXTg{Oy#^}ld{eyzKa^6a}qbExne8$z?}(fRkJv!dJapLC1gJ%YMI)DHo2D%Vc;GdX1 zo?95sDezp#NrP>m8-n8aumUBgQXu~DV~<*#jNWM=gyjKNE?nwFlLn#$z{a=y{YP2W8RUcg=j!A7}OsuuI* zaPot!?&iA{0$V=J{r z)2TJ*oa)sgt@9gJb!=sciqPqDtvGXad9heO=PXvK(UyvhLcP35Hkuwdy}7%5v(8Z* zX3~7qDO9GNEA5EzGyM}o?anXcb73|f;!KZ^cG&B zrURbNF_j4j#dd=C;75?Av&oEZ>rr&}mEUi1L0fIHwqE8xicnb^tl)K9cU?ZP{5RBV zBZlzZM>u8-RfOg;>cyc-;Vb5B^_OG?a2#I5Sjpyax>;Wkzi}Kwg1aHuTf6a8VIa-G zQ)!_V?ju&o`qIKzSVKIc3AN4Xwr15UQ=WSW_6nymJ?Zp0NU% z$SMMXjHPEH!mzDdCB_x9QDNGVHg5|fREnZcCM;xyFt52eFn+@kJjBa*gk$&}qYCeZ62X`fM^4#}aV3Qb-DN6z+dQ4 z)pfjz*oIE(rA2;R)`^kme{HUj+IxdGfgr<%ba}H+|IAbH?(~Zej*MNEcmAA z9H?YNB1jreX1eSy5fb4w?9RqQ*=}_t9*g(f!vQKgq+3`nVbLx8m29_&*K zbQ4dpn{j1z{Z|nafh}h-OrQfZy!>a#okfTPNO0=C$nzx5aq{Om?Kw{OHCpnd^X~i- z@W_TuRgpc&kCWj`@t9|#aK=;Zb}k~!AF$UuV#Taj=pJ^)&6u^lSKcC_2|aGatf6?2 zWDWafZ(7{rL-{7Ppc3J;0xS?J7rC7U-q1z2JfFlT@G6gq5)G&RecA4kliv-ZHj3dj z90-*v4-g~Y5_!E2tvo=lWqt3T_eODKc3yR9M`(85=W>3RYLqRXBz{RWlO*6;EIv*Q mvpS{9kR&KQOi4oK>;r^1pHx%ghAo8n;`MQ-3Qkk@VEP}{eOC7X diff --git a/tools/simd-bench/out/simdbench/ScalarSimdKernels.class b/tools/simd-bench/out/simdbench/ScalarSimdKernels.class deleted file mode 100644 index 556d95e297b7927e06890ada8c6cb831b0174e94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 770 zcmZXSzi!h|6vn?B+i{wtAx+}6U^`apk~qk*5f9LT zH(+Dv!U#+t1|EPnK`fmsO8Ksdk^&yiIrrXkf8TeH{O#A5uK*sRkwyZF4mAr6NrC*a z_0npMEdQ|8w~t*X6iD9ld@sB&pft_>6jI!oI@ZVv(nuqtBb&uCass)<)m<}Aa1aKbe|T3Q9h=;nbbUTG)ja4N zbi3xh!15rpoaejNiy29oMq|F54PCRtS&Xe$ygZmhf#W{%q*nPq&v&FA+9-0yG|y*h z%$zghDpz%8A$K=FL40H(u*N8l0fq58DvYwV${g<&^bh|u%JwR1v&KGDDgZTQTQ$`B zDU92Ox@D;C&scihKT*ZI%O6rI5la==4P>x{Ed6E3$v6ZOzbXr^uUIhSqj^CmP9T=KDFi9NQ>Rs7P=L1?DyKB%VLr1vJSokbHB_e>USc zN?4gItV#j%&0C~o@ki+U6`@LOsm7~y{)p=+6IW%ufkrHrfpP&|5ykU({2Q^uTfYIi CwTDLl diff --git a/tools/simd-bench/out/simdbench/SimdKernels.class b/tools/simd-bench/out/simdbench/SimdKernels.class deleted file mode 100644 index 7b1832343af4d50e962a34213af65bf3d129028c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224 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 diff --git a/tools/simd-bench/out/simdbench/VectorSimdKernels.class b/tools/simd-bench/out/simdbench/VectorSimdKernels.class deleted file mode 100644 index 711cc9c80cb2a41e414dfe5a3a14252b0078f536..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3964 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 R6B-6+<~LZ7&G~2ae*kDY