From 14b628066862c6f21616cf1629b1c7fe194a6e8b Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 13 Aug 2026 01:01:01 -0400 Subject: [PATCH] Full audit fixpass: 93 defect fixes, 14 perf wins, vestigial cleanup --- .../iris/core/nms/v26_2_R1/NMSBinding.java | 77 ++++- adapters/bukkit/plugin/build.gradle | 3 + .../src/main/java/art/arcane/iris/Iris.java | 214 ++++++++----- .../iris/core/IrisWorldGeneratorResolver.java | 2 +- .../core/PendingWorldReplacementManager.java | 52 ++- .../iris/core/commands/CommandDeveloper.java | 4 +- .../iris/core/commands/CommandIris.java | 6 +- .../iris/core/commands/CommandObject.java | 5 +- .../iris/core/commands/CommandPack.java | 17 +- .../iris/core/commands/CommandPregen.java | 7 + .../iris/core/commands/CommandStudio.java | 91 +++--- .../iris/core/commands/CommandWhat.java | 72 ++++- .../service/DatapackStructureScopeSVC.java | 24 +- .../art/arcane/iris/core/service/EditSVC.java | 3 +- .../iris/core/IrisStartupOrderingTest.java | 2 +- .../CommandIrisDownloadDefaultTest.java | 20 ++ .../CommandPackOptionalPackContractTest.java | 63 ++++ .../iris/client/IrisClientPregenState.java | 5 +- .../NativeStructureVegetationClearer.java | 4 +- .../iris/modded/IrisModdedChunkGenerator.java | 4 +- .../arcane/iris/modded/ModdedBlockBuffer.java | 8 + .../iris/modded/ModdedDimensionManager.java | 2 + .../iris/modded/ModdedEngineBootstrap.java | 18 +- .../iris/modded/ModdedForcedDatapack.java | 3 +- .../arcane/iris/modded/ModdedIrisSplash.java | 3 +- .../arcane/iris/modded/ModdedPlatform.java | 55 ++++ .../iris/modded/ModdedSpawnTableMerger.java | 5 + .../art/arcane/iris/modded/ModdedStartup.java | 27 ++ .../iris/modded/ModdedWorldEngines.java | 28 +- .../iris/modded/ModdedWorldManager.java | 22 +- .../arcane/iris/modded/api/IrisModdedAPI.java | 12 +- .../command/ModdedCommandSuggestions.java | 3 +- .../modded/command/ModdedCommandTree.java | 7 +- .../modded/command/ModdedDustRevealer.java | 3 + .../iris/modded/command/ModdedGuiHost.java | 23 ++ .../modded/command/ModdedLocateCommands.java | 10 + .../modded/command/ModdedObjectCommands.java | 49 +-- .../iris/modded/command/ModdedObjectUndo.java | 34 +- .../modded/command/ModdedPackCommands.java | 3 +- .../modded/command/ModdedStudioCommands.java | 60 +++- .../modded/command/ModdedWhatCommands.java | 3 + .../modded/command/ModdedWorldCommands.java | 5 +- .../service/ModdedChunkUpdateService.java | 13 +- .../ModdedDownloadBranchParityTest.java | 31 ++ .../iris/modded/ModdedLootApplierTest.java | 4 +- .../iris/modded/ModdedPlatformPathsTest.java | 106 ++++++ .../modded/ModdedSpawnerRarityParityTest.java | 75 +++++ .../art/arcane/iris/core/IrisSettings.java | 2 +- .../java/art/arcane/iris/core/IrisWorlds.java | 2 +- .../arcane/iris/core/ServerConfigurator.java | 2 +- .../core/datapack/DatapackIngestService.java | 80 +++-- .../iris/core/datapack/ModrinthResolver.java | 19 +- .../arcane/iris/core/edit/BlockSignal.java | 21 +- .../arcane/iris/core/edit/DustRevealer.java | 150 ++++----- .../arcane/iris/core/gui/PregenRenderer.java | 34 +- .../arcane/iris/core/gui/PregeneratorJob.java | 48 +-- .../art/arcane/iris/core/gui/VisionGUI.java | 75 +++-- .../lifecycle/IrisWorldRemovalService.java | 2 +- .../arcane/iris/core/link/WorldEditLink.java | 39 ++- .../art/arcane/iris/core/loader/IrisData.java | 44 ++- .../iris/core/loader/ResourceLoader.java | 64 ++-- .../localization/DirectorCommandMessages.java | 4 +- .../java/art/arcane/iris/core/nms/INMS.java | 30 +- .../art/arcane/iris/core/nms/INMSBinding.java | 7 + .../art/arcane/iris/core/pack/IrisPack.java | 2 +- .../iris/core/pack/IrisPackRepository.java | 6 +- .../core/pack/PackBiomeLayerValidator.java | 87 +++++ .../core/pack/PackDimensionValidator.java | 82 +++++ .../arcane/iris/core/pack/PackDownloader.java | 29 +- .../iris/core/pack/PackExportClosure.java | 72 +++++ .../pack/PackGeneratorDuplicateValidator.java | 165 ++++++++++ .../iris/core/pack/PackLootValidator.java | 25 +- .../pack/PackStyledRangeDefaultValidator.java | 154 +++++++++ .../arcane/iris/core/pack/PackValidator.java | 9 +- .../core/pregenerator/IrisPregenerator.java | 22 +- .../methods/AsyncPregenMethod.java | 31 +- .../methods/MedievalPregenMethod.java | 57 ++-- .../iris/core/project/IrisCodeWorkspace.java | 18 +- .../core/project/IrisPackageCompiler.java | 118 +++++-- .../iris/core/runtime/GoldenHashEngine.java | 35 +- .../core/runtime/InPlaceChunkRegenerator.java | 57 ++-- .../core/runtime/StudioOpenCoordinator.java | 2 +- .../iris/core/safeguard/IrisSafeguard.java | 12 +- .../iris/core/safeguard/task/Tasks.java | 33 +- .../iris/core/service/ExternalDataSVC.java | 3 +- .../iris/core/service/LogFilterSVC.java | 29 +- .../arcane/iris/core/service/ObjectSVC.java | 30 +- .../arcane/iris/core/service/StudioSVC.java | 9 +- .../art/arcane/iris/core/service/TreeSVC.java | 25 +- .../iris/core/splash/IrisSplashComposer.java | 23 +- .../arcane/iris/core/tools/IrisConverter.java | 19 +- .../arcane/iris/core/tools/IrisCreator.java | 22 +- .../iris/core/tools/IrisReflectiveAPI.java | 9 +- .../arcane/iris/core/tools/IrisToolbelt.java | 27 +- .../iris/core/tools/WorldMaintenance.java | 31 +- .../iris/engine/EngineBackgroundTasks.java | 32 +- .../iris/engine/EngineShutdownSequence.java | 57 +++- .../art/arcane/iris/engine/IrisComplex.java | 70 ++-- .../arcane/iris/engine/IrisEngineMantle.java | 19 +- .../iris/engine/WorldEntitySpawner.java | 13 +- .../engine/actuator/IrisBiomeActuator.java | 60 ++-- .../actuator/IrisTerrainNormalActuator.java | 10 +- .../iris/engine/data/cache/AtomicCache.java | 28 ++ .../iris/engine/decorator/DecoratorCore.java | 35 -- .../arcane/iris/engine/framework/Engine.java | 136 -------- .../framework/EngineAssignedActuator.java | 18 +- .../iris/engine/framework/EngineMode.java | 33 +- .../iris/engine/framework/LootResolver.java | 6 +- .../iris/engine/mantle/EngineMantle.java | 22 +- .../engine/mantle/MantleSliceRetention.java | 71 ++++ .../iris/engine/mantle/MantleWriter.java | 29 +- .../mantle/components/CarveOrphanSweep.java | 28 ++ .../components/MantleCarvingComponent.java | 20 +- .../MantleFloatingObjectComponent.java | 25 +- .../iris/engine/mode/ModeOverworld.java | 12 +- .../engine/modifier/IrisCarveModifier.java | 19 +- .../engine/modifier/IrisCustomModifier.java | 52 +-- .../engine/modifier/IrisDepositModifier.java | 9 +- .../engine/modifier/IrisPostModifier.java | 4 +- .../object/FloatingObjectFootprint.java | 14 +- .../art/arcane/iris/engine/object/IRare.java | 18 ++ .../engine/object/IrisAxisRotationClamp.java | 7 +- .../arcane/iris/engine/object/IrisBiome.java | 12 +- .../object/IrisBiomeLayerGenerator.java | 4 +- .../iris/engine/object/IrisBiomeOres.java | 10 + .../iris/engine/object/IrisDimension.java | 8 - .../iris/engine/object/IrisDimensionType.java | 14 +- .../engine/object/IrisGeneratorStyle.java | 5 +- .../iris/engine/object/IrisLootMode.java | 6 +- .../iris/engine/object/IrisLootReference.java | 2 +- .../arcane/iris/engine/object/IrisObject.java | 21 +- .../iris/engine/object/IrisObjectIO.java | 57 +++- .../engine/object/IrisObjectPlacement.java | 3 - .../object/IrisObjectPlacementRunner.java | 34 +- .../iris/engine/object/IrisObjectScale.java | 10 + .../iris/engine/object/IrisObjectShaping.java | 48 ++- .../engine/object/IrisObjectTransforms.java | 4 + .../arcane/iris/engine/object/IrisRegion.java | 26 -- .../arcane/iris/engine/object/IrisTree.java | 4 - .../arcane/iris/engine/object/StudioMode.java | 2 +- .../engine/platform/BukkitChunkGenerator.java | 27 +- .../platform/bukkit/BukkitBlockState.java | 18 +- .../specialhandlers/IrisStructureHandler.java | 2 +- .../specialhandlers/ObjectHandler.java | 2 +- .../specialhandlers/ObjectTargetHandler.java | 2 +- .../specialhandlers/RegistrantHandler.java | 2 +- .../iris/util/common/misc/getHardware.java | 4 +- .../iris/util/common/plugin/VolmitSender.java | 20 +- .../common/plugin/chunk/ChunkTickets.java | 15 +- .../common/plugin/chunk/TicketHolder.java | 12 + .../arcane/iris/util/common/scheduling/J.java | 24 +- .../util/project/hunk/storage/AtomicHunk.java | 8 +- .../hunk/view/ChunkDataHunkHolder.java | 10 +- .../interpolation/IrisInterpolation.java | 28 +- .../NoiseBoundsSampleCache2D.java | 24 +- .../interpolation/NoiseSampleCache2D.java | 23 +- .../arcane/iris/util/project/noise/CNG.java | 30 +- .../util/project/stream/ProceduralStream.java | 19 -- .../stream/utility/ProfiledStream.java | 176 ---------- .../stream/utility/SemaphoreStream.java | 70 ---- .../project/stream/utility/WasteDetector.java | 73 ----- .../arcane/iris/util/project/uniques/U.java | 13 - .../util/project/uniques/UBufferedImage.java | 46 --- .../iris/util/project/uniques/UFeature.java | 64 ---- .../util/project/uniques/UFeatureMeta.java | 101 ------ .../iris/util/project/uniques/UImage.java | 19 -- .../iris/util/project/uniques/UMeta.java | 45 --- .../util/project/uniques/UniqueRenderer.java | 302 ------------------ .../uniques/features/UFInterpolator.java | 59 ---- .../util/project/uniques/features/UFNOOP.java | 13 - .../uniques/features/UFWarpedBackground.java | 26 -- .../uniques/features/UFWarpedCircle.java | 44 --- .../uniques/features/UFWarpedDisc.java | 35 -- .../uniques/features/UFWarpedDots.java | 43 --- .../uniques/features/UFWarpedLines.java | 42 --- .../iris/util/simd/VectorSimdKernels.java | 15 +- core/src/main/resources/languages/de_DE.json | 4 +- core/src/main/resources/languages/es_ES.json | 4 +- core/src/main/resources/languages/fi_FI.json | 4 +- core/src/main/resources/languages/fr_FR.json | 4 +- core/src/main/resources/languages/he_IL.json | 4 +- core/src/main/resources/languages/it_IT.json | 4 +- core/src/main/resources/languages/ja-JP.json | 4 +- core/src/main/resources/languages/ko_KR.json | 4 +- core/src/main/resources/languages/lt_LT.json | 4 +- core/src/main/resources/languages/nl_NL.json | 4 +- core/src/main/resources/languages/pl_PL.json | 4 +- core/src/main/resources/languages/pt_PT.json | 4 +- core/src/main/resources/languages/ru_RU.json | 4 +- core/src/main/resources/languages/tr_TR.json | 4 +- core/src/main/resources/languages/vi_VI.json | 4 +- core/src/main/resources/languages/zh_CN.json | 4 +- core/src/main/resources/languages/zh_TW.json | 4 +- .../iris/core/IrisSettingsDefaultsTest.java | 21 ++ .../core/link/WorldEditLinkDetectionTest.java | 57 ++++ .../localization/ResetMantleHelpTextTest.java | 41 +++ .../pack/PackBiomeLayerValidatorTest.java | 83 +++++ .../pack/PackDownloadBranchDefaultTest.java | 25 ++ .../iris/core/pack/PackDownloaderTest.java | 35 +- .../iris/core/pack/PackExportClosureTest.java | 73 +++++ .../PackGeneratorDuplicateValidatorTest.java | 110 +++++++ .../PackStyledRangeDefaultValidatorTest.java | 99 ++++++ .../PackValidatorDimensionHeightTest.java | 148 +++++++++ .../iris/core/pack/PackValidatorLootTest.java | 41 ++- .../engine/decorator/DecoratorCoreTest.java | 20 -- .../framework/LootResolverClearModeTest.java | 30 ++ .../mantle/EngineMantleCleanupTest.java | 52 +++ .../IrisAxisRotationClampPurityTest.java | 70 ++++ .../IrisBiomeCeilingLayerGuardTest.java | 50 +++ .../object/IrisObjectIoWriteLimitsTest.java | 116 +++++++ .../IrisObjectPlacementToPlacementTest.java | 2 - .../engine/object/RemovedDeadFieldsTest.java | 55 ++++ .../iris/spi/PacksFolderContractTest.java | 53 +++ .../iris/util/simd/SimdKernelsTest.java | 6 +- .../art/arcane/iris/spi/IrisPlatform.java | 30 ++ 215 files changed, 4604 insertions(+), 2407 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisDownloadDefaultTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDownloadBranchParityTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSpawnerRarityParityTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidator.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/mantle/MantleSliceRetention.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/stream/utility/ProfiledStream.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/stream/utility/SemaphoreStream.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/stream/utility/WasteDetector.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/U.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UBufferedImage.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UFeature.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UFeatureMeta.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UImage.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UMeta.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/UniqueRenderer.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFInterpolator.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFNOOP.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedBackground.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedCircle.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDisc.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDots.java delete mode 100644 core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedLines.java create mode 100644 core/src/test/java/art/arcane/iris/core/link/WorldEditLinkDetectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/localization/ResetMantleHelpTextTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackDownloadBranchDefaultTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionHeightTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/LootResolverClearModeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisAxisRotationClampPurityTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/RemovedDeadFieldsTest.java create mode 100644 core/src/test/java/art/arcane/iris/spi/PacksFolderContractTest.java 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 43944569c..f6656c240 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 @@ -55,6 +55,7 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.shorts.ShortList; import net.bytebuddy.ByteBuddy; import net.bytebuddy.agent.builder.AgentBuilder; +import net.bytebuddy.agent.builder.ResettableClassFileTransformer; import net.bytebuddy.asm.Advice; import net.bytebuddy.matcher.ElementMatchers; import net.minecraft.core.BlockPos; @@ -172,6 +173,7 @@ public class NMSBinding implements INMSBinding { private final BlockData AIR = Material.AIR.createBlockData(); private final AtomicCache> biomeMapCache = new AtomicCache<>(); private final AtomicBoolean injected = new AtomicBoolean(); + private volatile ResettableClassFileTransformer serverLevelTransformer; private final AtomicCache> registryCache = new AtomicCache<>(); private final AtomicCache> globalCache = new AtomicCache<>(); private final AtomicCache registryAccess = new AtomicCache<>(); @@ -1523,7 +1525,7 @@ public class NMSBinding implements INMSBinding { } try { IrisLogging.info("Injecting Bukkit"); - new AgentBuilder.Default() + serverLevelTransformer = new AgentBuilder.Default() .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .type(ElementMatchers.is(ServerLevel.class)) @@ -1546,6 +1548,17 @@ public class NMSBinding implements INMSBinding { } catch (Throwable e) { IrisLogging.error(C.RED + "Failed to inject Bukkit"); e.printStackTrace(); + // The ServerLevel transformer may already be installed when the ChunkAccess + // redefine throws; remove it or a retry would stack a second one and orphan + // this one permanently. + ResettableClassFileTransformer partial = serverLevelTransformer; + serverLevelTransformer = null; + if (partial != null) { + try { + partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); + } catch (Throwable ignored) { + } + } return false; } } @@ -1562,6 +1575,24 @@ public class NMSBinding implements INMSBinding { } } + @Override + public void uninjectBukkit() { + synchronized (injected) { + ResettableClassFileTransformer transformer = serverLevelTransformer; + serverLevelTransformer = null; + injected.set(false); + if (transformer == null) { + return; + } + try { + transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); + } catch (Throwable e) { + IrisLogging.error(C.RED + "Failed to remove ServerLevel injection"); + e.printStackTrace(); + } + } + } + @Override public KMap> getBlockProperties() { KMap> states = new KMap<>(); @@ -1624,24 +1655,56 @@ public class NMSBinding implements INMSBinding { if (dimensionKey == null) return; + // This advice is inlined into every ServerLevel construction on the server. Until a + // world is proven Iris-owned, every failure must fail OPEN (keep the vanilla stem): + // Iris being unloaded or half-loaded must never break other plugins' world creation. + String levelId; + ClassLoader pluginClassLoader; + Class generatorType; + Class stagingType; try { - String levelId = dimensionKey.identifier().getPath(); + levelId = dimensionKey.identifier().getPath(); if (levelId == null || levelId.isBlank()) { return; } - ClassLoader pluginClassLoader = Bukkit.getPluginManager().getPlugin("Iris").getClass().getClassLoader(); - Class generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader); + org.bukkit.plugin.Plugin irisPlugin = Bukkit.getPluginManager().getPlugin("Iris"); + if (irisPlugin == null) { + return; + } + pluginClassLoader = irisPlugin.getClass().getClassLoader(); + generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader); + stagingType = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader); + } catch (Throwable ignored) { + // Iris absent or half-loaded: fail OPEN for a world we cannot prove ours. + return; + } + + // From here Iris is present and its classes resolve; a failure resolving the + // staged generator must fail LOUD — silently handing a possibly-staged Iris world + // the vanilla stem corrupts generation. + ChunkGenerator gen = null; + try { Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null; if (generator == null) { - generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader) + generator = stagingType .getDeclaredMethod("consumeStemGenerator", String.class) .invoke(null, levelId); } - if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) { - return; + if (generator instanceof ChunkGenerator owned && owned.getClass().getPackageName().startsWith("art.arcane.iris")) { + gen = owned; } + } catch (Throwable e) { + throw new RuntimeException("Iris failed to resolve the staged world generator", + e instanceof InvocationTargetException ex ? ex.getCause() : e); + } + if (gen == null) { + return; + } + // Past the ownership gate the world is Iris-owned; silently handing back the + // vanilla stem would corrupt generation, so failures from here rethrow. + try { Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, pluginClassLoader) .getDeclaredMethod("get") .invoke(null); diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 5d4202f0b..8b71a70c4 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -14,6 +14,9 @@ dependencies { transitive = false } compileOnly(libs.paper.api) + // LogFilterSVC (constructed in Iris.enable's explicit service list) implements the + // log4j-core Filter interface, so its constructor reference needs the type resolvable. + compileOnly(libs.log4j.core) testImplementation('junit:junit:4.13.2') testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.paper.api) 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 2212f26e9..04775fe09 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 @@ -51,9 +51,27 @@ import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.gui.BukkitGuiHost; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.service.BoardSVC; +import art.arcane.iris.core.service.CommandSVC; +import art.arcane.iris.core.service.DatapackStructureScopeSVC; import art.arcane.iris.core.service.EditSVC; +import art.arcane.iris.core.service.EntityRiseSVC; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.core.service.GlobalCacheSVC; +import art.arcane.iris.core.service.IrisApiEventSVC; +import art.arcane.iris.core.service.IrisEngineSVC; +import art.arcane.iris.core.service.IrisIntegrationService; +import art.arcane.iris.core.service.IrisProtocolService; +import art.arcane.iris.core.service.IrisTerrainSVC; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.core.service.LogFilterSVC; +import art.arcane.iris.core.service.ObjectSVC; +import art.arcane.iris.core.service.ObjectStudioSaveService; import art.arcane.iris.core.service.PreservationSVC; import art.arcane.iris.core.service.StudioSVC; +import art.arcane.iris.core.service.TreeFellerSVC; +import art.arcane.iris.core.service.TreeSVC; +import art.arcane.iris.core.service.WandSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.EnginePanic; import art.arcane.iris.engine.framework.BlockEditAccess; @@ -80,7 +98,6 @@ import art.arcane.volmlib.util.hud.HudBossBarLane; import art.arcane.volmlib.util.hud.HudSlotService; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.io.InstanceState; -import art.arcane.volmlib.util.io.JarScanner; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.common.misc.Bindings; @@ -98,7 +115,6 @@ import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.World; -import org.bukkit.block.data.BlockData; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -115,9 +131,9 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.FileOutputStream; import java.io.PrintWriter; -import java.lang.annotation.Annotation; import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.Date; import java.util.List; import java.util.Map; @@ -165,6 +181,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private volatile IrisPapiListener papiListener; private volatile IrisPapiState papiState; private KMap, IrisService> services; + // Copy-on-write: mutated on the main thread during enable() and iterated by the JVM + // shutdown-hook thread during teardown; a plain list would CME and abort the teardown. + private final List enabledServices = new CopyOnWriteArrayList<>(); private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this); private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this); private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this); @@ -206,28 +225,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - private static KList initialize(String s, Class requiredType) { - JarScanner js = new JarScanner(instance.getJarFile(), s); - KList v = new KList<>(); - J.attempt(js::scan); - for (Class i : js.getClasses()) { - if (!isConcreteImplementation(i, requiredType)) { - continue; - } - try { - v.add(requiredType.cast(i.getDeclaredConstructor().newInstance())); - } catch (Throwable ex) { - Iris.warn("Skipped class initialization for %s: %s%s", - i.getName(), - ex.getClass().getSimpleName(), - ex.getMessage() == null ? "" : " - " + ex.getMessage()); - Iris.reportError(ex); - } - } - - return v; - } - static boolean isConcreteImplementation(Class candidate, Class requiredType) { int modifiers = candidate.getModifiers(); return requiredType.isAssignableFrom(candidate) @@ -235,27 +232,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { && !Modifier.isAbstract(modifiers); } - public static KList> getClasses(String s, Class slicedClass) { - JarScanner js = new JarScanner(instance.getJarFile(), s); - KList> v = new KList<>(); - J.attempt(js::scan); - for (Class i : js.getClasses()) { - if (slicedClass == null || i.isAnnotationPresent(slicedClass)) { - try { - v.add(i); - } catch (Throwable ex) { - Iris.warn("Skipped class discovery entry for %s: %s%s", - i.getName(), - ex.getClass().getSimpleName(), - ex.getMessage() == null ? "" : " - " + ex.getMessage()); - Iris.reportError(ex); - } - } - } - - return v; - } - public static void sq(Runnable r) { synchronized (syncJobs) { syncJobs.queue(r); @@ -538,7 +514,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - private void enable() { + /** + * @return false when the bootstrap was aborted (unsupported server version); the caller + * must bail out of onEnable without touching any further setup. + */ + private boolean enable() { + if (!INMS.isBound()) { + Throwable bindFailure = INMS.bindFailure(); + Iris.error("Iris cannot start: " + (bindFailure == null + ? "no NMS binding is available for this server version." + : bindFailure.getMessage())); + // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable + // synchronously and the loader then continues registering the half-enabled plugin. + J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); + return false; + } alreadyDrained.set(false); servicesDisabled.set(false); sharedRuntimeClosed.set(false); @@ -551,25 +541,50 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { setupAudience(); BukkitPlatform.hostHud(new HudSlotService(this), new HudBossBarLane()); Bindings.setupSentry(); - initialize("art.arcane.iris.core.service", IrisService.class).forEach((i) -> { + // Explicit, ordered service list: the previous reflective jar scan gave hash-ordered + // enable/disable and paid a full-jar class sweep at boot. Infrastructure first, + // engine/world services next, commands last. + List orderedServices = List.of( + new PreservationSVC(), + new GlobalCacheSVC(), + new LogFilterSVC(), + new ExternalDataSVC(), + new EditSVC(), + new ObjectSVC(), + new ObjectStudioSaveService(), + new JigsawStudioService(), + new StudioSVC(), + new DatapackStructureScopeSVC(), + new IrisEngineSVC(), + new IrisTerrainSVC(), + new TreeSVC(), + new TreeFellerSVC(), + new EntityRiseSVC(), + new WandSVC(), + new BoardSVC(), + new IrisIntegrationService(), + new IrisProtocolService(), + new IrisApiEventSVC(), + new CommandSVC() + ); + for (IrisService i : orderedServices) { Class serviceType = i.getClass().asSubclass(IrisService.class); services.put(serviceType, i); IrisServices.register(serviceType, i); - }); + } IrisServices.register(BlockEditAccess.class, services.get(EditSVC.class)); IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class)); - IO.delete(new File("iris")); compat = IrisCompat.configured(getDataFile("compat.json")); IrisServices.register(IrisCompat.class, compat); ServerConfigurator.configure(); - IrisToolbelt.applyPregenPerformanceProfile(); StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup(); if (datapackValidation == StartupValidationOutcome.READY) { generatorResolver.validateAllPacks(); } IrisSafeguard.execute(); getSender().setTag(getTag()); - splash(); + // A cosmetic banner must never abort the bootstrap. + J.attempt(this::splash); IrisSafeguard.printReports(); IrisSafeguard.printFooter(); tickets = new ChunkTickets(); @@ -594,20 +609,54 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { // packs through cache/temp on an async thread, and a concurrent delete of that folder // truncated pack imports mid-copy (partial packs/ without dimensions/). IO.delete(getTemp()); - services.values().forEach(IrisService::onEnable); - services.values().forEach(this::registerListener); + // One throwing service must not abort the bootstrap: the steps after this loop + // (listeners, shutdown hook, replacement journals) are the safety-critical ones. + // Only services that actually enabled get listeners and a later onDisable. + enabledServices.clear(); + IrisService firstFailedService = null; + for (IrisService service : orderedServices) { + try { + service.onEnable(); + enabledServices.add(service); + } catch (Throwable e) { + if (firstFailedService == null) { + firstFailedService = service; + } + Iris.reportError("Failed to enable " + service.getClass().getSimpleName() + "; continuing with a degraded runtime.", e); + // A failed service is excluded from the teardown loop, so clean up whatever + // its partial onEnable started right here, best-effort. + try { + service.onDisable(); + } catch (Throwable cleanup) { + Iris.reportError("Failed to clean up partially enabled " + service.getClass().getSimpleName() + ".", cleanup); + } + } + } + if (firstFailedService != null) { + IrisStartupValidation.markDatapacksInvalid("Iris service " + + firstFailedService.getClass().getSimpleName() + + " failed to enable; world creation and player admission are locked. Check the log above."); + } + for (IrisService service : enabledServices) { + try { + registerListener(service); + } catch (Throwable e) { + Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e); + } + } addShutdownHook(); pendingWorldReplacements.processPendingStartupReplacements(); pendingWorldDeletes.processPendingStartupWorldDeletes(); - WorldLifecycleService.get(); - WorldRuntimeControlService.get(); if (J.isFolia() && IrisStartupValidation.isReady()) { J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true), 1); } J.s(() -> { - pendingWorldReplacements.verifyLoadedPublishedWorlds(); + pendingWorldReplacements.captureVanillaLevelContext(); + // Off-main: the verify body takes the replacement-manager monitor and SHA-hashes + // whole pack trees; neither belongs on the tick thread. + J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds); J.a(this::bstats); J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60); J.sr(this::tickQueue, 0); @@ -620,20 +669,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { worldReconciler.checkForBukkitWorlds(s -> true); } IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName()); - IrisToolbelt.retainMantleDataForSlice(BlockData.class.getCanonicalName()); + // The mantle stores block values as PlatformBlockState, so a BlockData retention can never + // match a slice type; the block-state slice is deliberately never retainable (regenerable, huge). IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName()); }); + return true; } public void addShutdownHook() { - if (shutdownHook != null) { - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook); - } catch (IllegalStateException ex) { - Iris.debug("Skipping shutdown hook replacement because JVM shutdown is already in progress."); - return; - } - } + removeShutdownHook(); shutdownHook = new Thread(() -> teardownRuntime("shutdown-hook", 30L), "Iris-ShutdownHook"); try { Runtime.getRuntime().addShutdownHook(shutdownHook); @@ -642,6 +686,24 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } + /** + * The static-field guard in addShutdownHook is dead across a plugin reload (fresh + * classloader, fresh static), so onDisable must deregister the hook explicitly or each + * reload stacks another hook pinning the previous plugin classloader for the JVM's life. + */ + public void removeShutdownHook() { + Thread hook = shutdownHook; + shutdownHook = null; + if (hook == null) { + return; + } + try { + Runtime.getRuntime().removeShutdownHook(hook); + } catch (IllegalStateException ignored) { + Iris.debug("Skipping shutdown hook removal because JVM shutdown is already in progress."); + } + } + public BukkitWorldReconciler worldReconciler() { return worldReconciler; } @@ -692,16 +754,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); - enable(); + boolean enabled = enable(); + if (!enabled) { + return; + } BukkitGuiHost.install(); + // super.onEnable() already registers this instance as a listener. super.onEnable(); - Bukkit.getPluginManager().registerEvents(this, this); } public void onDisable() { teardownPapi(); - if (IrisSafeguard.isForceShutdown()) return; teardownRuntime("onDisable", 30L); + removeShutdownHook(); + J.attempt(() -> INMS.get().uninjectBukkit()); if (BukkitPlatform.hasHud()) { BukkitPlatform.hudSlots().shutdown(); BukkitPlatform.hudLanes().shutdown(); @@ -710,12 +776,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { configHotloadEngine.clear(); configHotloadEngine = null; } - J.cancelPluginTasks(); - HandlerList.unregisterAll((Plugin) this); runPostShutdown(); + // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); - - J.attempt(new JarScanner(instance.getJarFile(), "", false)::scanAll); IrisPlatforms.unbind(); } @@ -753,7 +816,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } if (services != null && servicesDisabled.compareAndSet(false, true)) { - for (IrisService service : services.values()) { + // Only services whose onEnable actually completed; disabling a service that + // never initialized runs teardown against uninitialized state. + for (IrisService service : enabledServices) { try { service.onDisable(); } catch (Throwable e) { @@ -768,6 +833,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { J.attempt(MultiBurst.burst::close); J.attempt(MultiBurst.ioBurst::close); + clearQueues(); IrisServices.clear(); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 91083be75..df7fb0296 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -169,7 +169,7 @@ public final class IrisWorldGeneratorResolver { IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null; if (dimension == null) dimension = IrisData.loadAnyDimension(id, null); if (dimension == null) { - File packsRoot = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME); + File packsRoot = IrisPlatforms.get().packsFolderNoCreate(); if (PackDownloader.isPackPresent(packsRoot, id)) { Iris.error("Pack '" + id + "' exists at " + new File(packsRoot, id).getPath() + " but its dimension failed to load; not redownloading. Fix or delete the pack folder."); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index dcfc8b5d1..cf81f83f4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -254,7 +254,10 @@ public final class PendingWorldReplacementManager implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onWorldLoad(WorldLoadEvent event) { World world = event.getWorld(); - J.s(() -> verifyPublishedWorldIfPending(world), 1); + // One tick to let the load settle, then verify off-main: the body takes the manager + // monitor (held across pack staging by async threads) and SHA-hashes the whole pack + // tree — blocking the main thread on either froze the server. + J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1); } private synchronized void verifyPublishedWorldIfPending(World world) { @@ -506,10 +509,46 @@ public final class PendingWorldReplacementManager implements Listener { } } + /** + * Captures the primary level context on the main thread at startup. resolveEffectiveSeed + * reads this snapshot so staging never blocks on a main-thread hop while holding the + * manager monitor — that inversion froze the server for the full 30s hop timeout whenever + * another world loaded during staging. The context is stable for the process lifetime: + * slot replacements only commit across a restart. + */ + public void captureVanillaLevelContext() { + try { + vanillaLevelContext = new VanillaLevelContext( + WorldIdentity.resolve(NamespacedKey.minecraft("overworld")) + .orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded.")) + .getSeed(), + Iris.instance.getServer().getAllowNether(), + Iris.instance.getServer().getAllowEnd() + ); + } catch (Throwable failure) { + Iris.debug("Could not capture the primary level context yet: " + detail(failure)); + } + } + private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException { if (slotKind == SlotKind.IRIS_MANAGED) { return requestedSeed; } + VanillaLevelContext context = vanillaLevelContext; + if (context == null) { + context = resolveVanillaLevelContext(); + vanillaLevelContext = context; + } + if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) { + throw new IOException("allow-nether must be true before the vanilla Nether can be replaced."); + } + if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) { + throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced."); + } + return context.seed(); + } + + private static VanillaLevelContext resolveVanillaLevelContext() throws IOException { CompletableFuture contextFuture = J.sfut(() -> new VanillaLevelContext( WorldIdentity.resolve(NamespacedKey.minecraft("overworld")) .orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded.")) @@ -521,14 +560,7 @@ public final class PendingWorldReplacementManager implements Listener { throw new IOException("Could not schedule primary level-seed resolution."); } try { - VanillaLevelContext context = contextFuture.get(30L, TimeUnit.SECONDS); - if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) { - throw new IOException("allow-nether must be true before the vanilla Nether can be replaced."); - } - if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) { - throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced."); - } - return context.seed(); + return contextFuture.get(30L, TimeUnit.SECONDS); } catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException("Primary level-seed resolution was interrupted.", failure); @@ -580,6 +612,8 @@ public final class PendingWorldReplacementManager implements Listener { } } + private static volatile VanillaLevelContext vanillaLevelContext; + private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) { } 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 6521aabab..0f79731b1 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 @@ -394,7 +394,7 @@ public class CommandDeveloper implements DirectorExecutor { } } - @Director(name = "goldenhash", aliases = {"gold"}, description = "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds.", descriptionKey = "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden", origin = DirectorOrigin.BOTH) + @Director(name = "goldenhash", aliases = {"gold"}, description = "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Deletes the world's entire mantle - use on disposable test worlds.", descriptionKey = "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden", origin = DirectorOrigin.BOTH) public void goldenhash( @Param(description = "The world to scan", descriptionKey = "iris.director.commanddeveloper.param.world_scan", contextual = true) World world, @@ -404,7 +404,7 @@ public class CommandDeveloper implements DirectorExecutor { int centerX, @Param(name = "center-z", description = "Center chunk Z", descriptionKey = "iris.director.commanddeveloper.param.center_chunk_z_2", defaultValue = "0") int centerZ, - @Param(name = "reset-mantle", description = "Delete mantle data in the scan area first for full regeneration from scratch", descriptionKey = "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch", defaultValue = "true") + @Param(name = "reset-mantle", description = "Delete the world's entire mantle folder first for full regeneration from scratch", descriptionKey = "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch", defaultValue = "true") boolean resetMantle, @Param(name = "threads", description = "Concurrent chunk generations; 1 = strictly serial for order-dependence testing", descriptionKey = "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing", defaultValue = "8") int threads, 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 dd21f68f7..ce50a765b 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 @@ -705,7 +705,7 @@ public class CommandIris implements DirectorExecutor { public void download( @Param(name = "pack", description = "The pack to download", descriptionKey = "iris.director.commandiris.param.pack_download", aliases = "project") String pack, - @Param(name = "branch", description = "The branch to download from", descriptionKey = "iris.director.commandiris.param.branch_download_from", defaultValue = "stable") + @Param(name = "branch", description = "The branch to download from", descriptionKey = "iris.director.commandiris.param.branch_download_from", defaultValue = PackDownloader.DEFAULT_BRANCH) String branch, @Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false") boolean overwrite @@ -803,7 +803,7 @@ public class CommandIris implements DirectorExecutor { }); guardUnloadCompletion(sequence, terminalTimeout, world.getName()) .whenComplete((unloaded, throwable) -> { - IrisToolbelt.endWorldMaintenance(world, "world-unload"); + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); lease.close(); Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable); if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { @@ -812,7 +812,7 @@ public class CommandIris implements DirectorExecutor { J.s(response); }); } catch (Exception e) { - IrisToolbelt.endWorldMaintenance(world, "world-unload"); + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); lease.close(); responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index cda4d4126..47eb8ab0b 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -37,7 +37,6 @@ import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisObjectPlacementScaleInterpolator; import art.arcane.iris.engine.object.IrisObjectRotation; -import art.arcane.iris.engine.object.StudioMode; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.PlatformBlockState; @@ -144,7 +143,8 @@ public class CommandObject implements DirectorExecutor { return; } - hostDimension.setStudioMode(StudioMode.OBJECT_BUFFET); + // ObjectStudioActivation carries the buffet state; mutating the shared cached + // IrisDimension here leaked OBJECT_BUFFET into later pack exports of this dimension. ObjectStudioActivation.activate(hostDimension.getLoadKey()); ObjectStudioActivation.setSources(hostDimension.getLoadKey(), sources); @@ -637,6 +637,7 @@ public class CommandObject implements DirectorExecutor { } catch (IOException e) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_OBJECT_FAILED_SAVE_OBJECT_BECAUSE_IOEXCEPTION, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); Iris.reportError(e); + return; } sender().playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f); 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 619494a7c..1f778fb55 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 @@ -42,9 +42,18 @@ import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.iris.core.localization.BukkitRuntimeMessages; @Director(name = "pack", aliases = {"pk"}, description = "Pack validation and maintenance", descriptionKey = "iris.director.commandpack.director.pack_validation_maintenance") public class CommandPack implements DirectorExecutor { + /** + * Director treats a blank defaultValue as "required", which made the all-packs branch + * unreachable from chat. The "*" sentinel keeps the parameter genuinely optional; a blank + * value (the documented "pack=" escape hatch) still means every pack. + */ + static boolean wantsAllPacks(String pack) { + return pack == null || pack.isBlank() || "*".equals(pack.trim()); + } + @Director(description = "Validate a pack (or all packs) and re-publish results", descriptionKey = "iris.director.commandpack.director.validate_pack_all_packs_re_publish_results", aliases = {"v"}) public void validate( - @Param(description = "The pack folder name to validate (leave empty for all)", descriptionKey = "iris.director.commandpack.param.pack_folder_name_validate_leave_empty_all", defaultValue = "") + @Param(description = "The pack folder name to validate, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name_validate_leave_empty_all", defaultValue = "*") String pack ) { VolmitSender s = sender(); @@ -54,7 +63,7 @@ public class CommandPack implements DirectorExecutor { return; } - if (pack == null || pack.isBlank()) { + if (wantsAllPacks(pack)) { List dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); if (dirs.isEmpty()) { s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE)); @@ -196,11 +205,11 @@ public class CommandPack implements DirectorExecutor { @Director(description = "Show cached validation status for a pack", descriptionKey = "iris.director.commandpack.director.show_cached_validation_status_pack", aliases = {"s"}) public void status( - @Param(description = "The pack folder name", descriptionKey = "iris.director.commandpack.param.pack_folder_name", defaultValue = "") + @Param(description = "The pack folder name, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name", defaultValue = "*") String pack ) { VolmitSender s = sender(); - if (pack == null || pack.isBlank()) { + if (wantsAllPacks(pack)) { if (PackValidationRegistry.snapshot().isEmpty()) { s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST)); return; 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 5b888e668..bab287a01 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 @@ -33,6 +33,7 @@ import org.bukkit.util.Vector; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; +import art.arcane.iris.core.localization.ModdedCommandMessages; import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.volmlib.util.localization.MessageArgument; @Director(name = "pregen", aliases = "pregenerate", description = "Pregenerate your Iris worlds!", descriptionKey = "iris.director.commandpregen.director.pregenerate_your_iris_worlds") @@ -59,6 +60,12 @@ public class CommandPregen implements DirectorExecutor { return; } + if (PregeneratorJob.getInstance() != null) { + // Reject like the modded adapter does; never silently kill a running job. + sender().sendMessage(IrisLanguage.text(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PREGENERATION_TASK_IS_ALREADY_RUNNING_STOP_IT_FIRST_WITH_IRIS)); + return; + } + try { if (sender().isPlayer() && access() == null) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_PREGEN_ENGINE_ACCESS_THIS_WORLD_IS_NULL)); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 188e1045b..8096c17d0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -328,35 +328,50 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); return; } + if (radius <= 0 || radius > 2048) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); + sender().sendMessage("Radius must be between 1 and 2048 chunks."); + return; + } var sender = sender(); var player = player(); Thread.ofVirtual() .start(() -> { int d = radius * 2; KMap data = new KMap<>(); + // Everything acquired below is released in the finally: a throw mid-scan + // previously leaked the whole sampler ForkJoinPool, the self-rescheduling + // progress task and both HUD claims for the rest of the server's uptime. + MultiBurst multiBurst = null; + HudSlotClaim titleClaim = null; + HudSlotClaim barClaim = null; + int c = -1; + try { engine.getDimension().getRegions().forEach(key -> data.put(key, new AtomicInteger(0))); - var multiBurst = new MultiBurst("Region Sampler"); - var executor = multiBurst.burst(radius * radius); + multiBurst = new MultiBurst("Region Sampler"); + var executor = multiBurst.burst(Math.min(radius * radius, 1 << 20)); sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_GENERATING_DATA)); var loc = player.getLocation(); int totalTasks = d * d; AtomicInteger completedTasks = new AtomicInteger(0); - HudSlotClaim titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE))); - HudSlotClaim barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR))); + titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE))); + barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR))); + HudSlotClaim finalTitleClaim = titleClaim; + HudSlotClaim finalBarClaim = barClaim; AtomicLong lastResolveMs = new AtomicLong(0L); - int c = J.ar(() -> { + c = J.ar(() -> { long now = System.currentTimeMillis(); if (now - lastResolveMs.get() >= 250L) { lastResolveMs.set(now); - titleClaim.resolve(); - barClaim.resolve(); + finalTitleClaim.resolve(); + finalBarClaim.resolve(); } double jobProgress = (double) completedTasks.get() / totalTasks; - HudSurface barSurface = barClaim.granted(); + HudSurface barSurface = finalBarClaim.granted(); sender.sendProgress( jobProgress, IrisLanguage.text(RuntimeUiMessages.FINDING_REGIONS), - titleClaim.granted(), + finalTitleClaim.granted(), barSurface ); if (barSurface == HudSurface.BOSS_BAR) { @@ -372,15 +387,28 @@ public class CommandStudio implements DirectorExecutor { completedTasks.incrementAndGet(); })).setOffset(loc.getBlockX(), loc.getBlockZ()).drain(); executor.complete(); - multiBurst.close(); - J.car(c); - titleClaim.release(); - barClaim.release(); - BukkitPlatform.hudLanes().hide(player, "iris:job"); sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_DONE)); var loader = engine.getData().getRegionLoader(); data.forEach((k, v) -> sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_MESSAGE, MessageArgument.untrusted("k", k), MessageArgument.untrusted("value", loader.load(k).getRarity()), MessageArgument.untrusted("value2", Form.f((double) v.get() / totalTasks * 100, 2))))); + } catch (Throwable e) { + Iris.reportError(e); + sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); + } finally { + if (c != -1) { + J.car(c); + } + if (titleClaim != null) { + titleClaim.release(); + } + if (barClaim != null) { + barClaim.release(); + BukkitPlatform.hudLanes().hide(player, "iris:job"); + } + if (multiBurst != null) { + multiBurst.close(); + } + } }); } @@ -437,7 +465,6 @@ public class CommandStudio implements DirectorExecutor { KMap interpolatorTimings = new KMap<>(); KMap generatorTimings = new KMap<>(); KMap biomeTimings = new KMap<>(); - KMap regionTimings = new KMap<>(); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CALCULATING_PERFORMANCE_METRICS_NOISE_GENERATORS)); @@ -561,23 +588,6 @@ public class CommandStudio implements DirectorExecutor { fileText.add(""); - for (String i : data.getRegionLoader().getPossibleKeys()) { - IrisRegion b = data.getRegionLoader().load(i); - double score = 0; - - score += styleTimings.get(b.getLakeStyle().getStyle()); - score += styleTimings.get(b.getRiverStyle().getStyle()); - regionTimings.put(i, score); - } - - fileText.add("Project Region Performance Impacts: "); - - for (String i : regionTimings.sortKNumber()) { - fileText.add(i + ": " + regionTimings.get(i)); - } - - fileText.add(""); - double m = 0; for (double i : biomeTimings.v()) { m += i; @@ -589,12 +599,6 @@ public class CommandStudio implements DirectorExecutor { } mm /= generatorTimings.size(); m += mm; - double mmm = 0; - for (double i : regionTimings.v()) { - mmm += i; - } - mmm /= regionTimings.size(); - m += mmm; fileText.add("Average Score: " + m); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_SCORE, MessageArgument.untrusted("value", Form.duration(m, 0)))); @@ -763,9 +767,8 @@ public class CommandStudio implements DirectorExecutor { } sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS, MessageArgument.untrusted("value", chunks.size()))); - try { - File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt"); - PrintWriter pw = new PrintWriter(ff); + File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt"); + try (PrintWriter pw = new PrintWriter(ff)) { pw.println("=== Iris Chunk Report ==="); pw.println("== General Info =="); pw.println("Iris Version: " + Iris.instance.getDescription().getVersion()); @@ -820,7 +823,8 @@ public class CommandStudio implements DirectorExecutor { } } - regions = Objects.requireNonNull(new File(world.getWorldFolder().getPath() + "/region").list()).length; + String[] regionFiles = new File(world.getWorldFolder(), "region").list(); + regions = regionFiles == null ? 0 : regionFiles.length; pw.println(); pw.println("== World Info =="); @@ -854,10 +858,9 @@ public class CommandStudio implements DirectorExecutor { } pw.println(); - pw.close(); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REPORTED, MessageArgument.untrusted("value", ff.getPath()))); - } catch (FileNotFoundException e) { + } catch (Throwable e) { e.printStackTrace(); Iris.reportError(e); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java index 81d8d1fb7..73f7f0acd 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java @@ -26,7 +26,10 @@ import art.arcane.iris.core.edit.BlockSignal; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.util.common.director.DirectorExecutor; @@ -36,8 +39,10 @@ import art.arcane.volmlib.util.director.DirectorOrigin; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.iris.util.common.format.C; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.matter.MatterMarker; import org.bukkit.Chunk; +import org.bukkit.Location; import org.bukkit.FluidCollisionMode; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -47,7 +52,6 @@ import org.bukkit.block.data.BlockData; import org.bukkit.entity.Player; import java.lang.reflect.Method; -import java.util.concurrent.atomic.AtomicInteger; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; @@ -172,35 +176,73 @@ public class CommandWhat implements DirectorExecutor { }); } + // Matches ModdedWhatCommands.MAX_MARKERS so both loaders truncate identically. + private static final int MAX_MARKER_HITS = 8_192; + private static final int MARKER_SIGNAL_BATCH = 128; + @Director(description = "Show markers in chunk", descriptionKey = "iris.director.commandwhat.director.show_markers_chunk", origin = DirectorOrigin.PLAYER) public void markers(@Param(description = "Marker name such as cave_floor or cave_ceiling", descriptionKey = "iris.director.commandwhat.param.marker_name_such_as_cave_floor_cave_ceiling") String marker) { VolmitSender commandSender = sender(); Player player = player(); + if (player == null) { + return; + } - // Chunk lookup plus the block signals both need the thread owning the player's chunk. + // Only the chunk coordinates need the owning thread; the 81-chunk mantle sweep loads + // tectonic plates from disk and must never run on the tick thread (the modded adapter + // already scans async, leased and capped — mirror it). onPlayerThread(player, () -> { + World world = player.getWorld(); Chunk c = player.getLocation().getChunk(); + int chunkX = c.getX(); + int chunkZ = c.getZ(); - if (IrisToolbelt.isIrisWorld(c.getWorld())) { - AtomicInteger v = new AtomicInteger(0); + if (!IrisToolbelt.isIrisWorld(world)) { + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2)); + return; + } + Engine engine = IrisToolbelt.access(world).getEngine(); - for (int xxx = c.getX() - 4; xxx <= c.getX() + 4; xxx++) { - for (int zzz = c.getZ() - 4; zzz <= c.getZ() + 4; zzz++) { - IrisToolbelt.access(c.getWorld()).getEngine().getMantle().findMarkers(xxx, zzz, new MatterMarker(marker)) - .convert((i) -> BukkitPlatform.toLocation(i, c.getWorld())).forEach((i) -> { - BlockSignal.of(i.getWorld(), i.getBlockX(), i.getBlockY(), i.getBlockZ(), 100); - v.incrementAndGet(); - }); + J.a(() -> { + KList hits = new KList<>(); + MatterMarker matterMarker = new MatterMarker(marker); + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_what_markers"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + scan: + for (int xxx = chunkX - 4; xxx <= chunkX + 4; xxx++) { + for (int zzz = chunkZ - 4; zzz <= chunkZ + 4; zzz++) { + for (Location i : engine.getMantle().findMarkers(xxx, zzz, matterMarker) + .convert((p) -> BukkitPlatform.toLocation(p, world))) { + hits.add(i); + if (hits.size() >= MAX_MARKER_HITS) { + break scan; + } + } + } } + } catch (GenerationSessionException e) { + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2)); + return; } - commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", v.get()), MessageArgument.untrusted("marker", marker))); - } else { - commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2)); - } + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", hits.size()), MessageArgument.untrusted("marker", marker))); + emitMarkerSignals(player, engine, hits, 0); + }); }); } + private void emitMarkerSignals(Player player, Engine engine, KList hits, int from) { + if (from >= hits.size() || !player.isOnline() || engine.isClosed() || engine.isClosing()) { + return; + } + int to = Math.min(from + MARKER_SIGNAL_BATCH, hits.size()); + for (int i = from; i < to; i++) { + Location at = hits.get(i); + BlockSignal.of(at.getWorld(), at.getBlockX(), at.getBlockY(), at.getBlockZ(), 100); + } + J.s(() -> emitMarkerSignals(player, engine, hits, to), 1); + } + /** * Runs the body on the thread owning the player, reporting when the hop cannot be scheduled. */ diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java index 72f3b7362..cf86b2587 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.service; +import art.arcane.iris.core.IrisStartupValidation; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; import art.arcane.iris.core.nms.DatapackStructureScopeResult; @@ -29,13 +30,26 @@ public final class DatapackStructureScopeSVC implements IrisService { scopeIndex = DatapackStructureScopeIndex.create( DatapackIngestService.installedStructureScopeResources()); } catch (IOException e) { - throw new IllegalStateException( - "Iris could not establish ownership for installed datapack structure sets", e); + // A missing manifest entry is the condition validateOnStartup already classified as + // non-fatal; degrade to an empty scope and lock world creation instead of aborting + // the whole plugin bootstrap (which would skip listeners and journal reconciliation). + scopeIndex = DatapackStructureScopeIndex.create(null); + IrisLogging.error("Could not establish ownership for installed datapack structure sets; " + + "datapack structure scoping is disabled until the manifest is repaired: " + e.getMessage()); + IrisStartupValidation.markDatapacksInvalid( + "Iris could not establish ownership for installed datapack structure sets: " + e.getMessage()); } for (World world : Bukkit.getWorlds()) { - IrisImportedStructureControl importedStructures = importedStructures(world); - if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) { - applyScope(world, importedStructures); + try { + IrisImportedStructureControl importedStructures = importedStructures(world); + if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) { + applyScope(world, importedStructures); + } + } catch (Throwable e) { + // Per-world containment: one world's scoping failure must not abort the + // service enable (and with it the whole bootstrap's containment contract). + IrisLogging.error("Could not scope datapack structures for world '" + world.getName() + "'."); + IrisLogging.reportError(e); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/EditSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/EditSVC.java index b34d7f77a..a62037704 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/EditSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/EditSVC.java @@ -35,7 +35,6 @@ import org.bukkit.event.world.WorldUnloadEvent; public class EditSVC implements IrisService, BlockEditAccess { private KMap editors; private int updateTaskId = -1; - public static boolean deletingWorld = false; @Override public void onEnable() { @@ -84,7 +83,7 @@ public class EditSVC implements IrisService, BlockEditAccess= 0); + String declaration = source.substring(branchParam, source.indexOf(')', branchParam)); + assertTrue("branch param default must be PackDownloader.DEFAULT_BRANCH, not a literal", + declaration.contains("defaultValue = PackDownloader.DEFAULT_BRANCH")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java new file mode 100644 index 000000000..70f091b9c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.core.commands; + +import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility; +import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor; +import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CommandPackOptionalPackContractTest { + private static DirectorParameterDescriptor packParam(String method) throws Exception { + DirectorNodeDescriptor node = DirectorAnnotationCompatibility + .fromMethod(CommandPack.class.getDeclaredMethod(method, String.class)) + .orElseThrow(); + DirectorParameterDescriptor pack = node.getParameters().get(0); + assertEquals("pack", pack.getName()); + return pack; + } + + @Test + public void bareValidateAndStatusAreInvocableWithoutAPackArgument() throws Exception { + for (String method : List.of("validate", "status")) { + DirectorParameterDescriptor pack = packParam(method); + assertFalse(method + " must not require a pack", pack.isRequired()); + assertFalse(method + " needs a non-blank default for Director to treat it as optional", + pack.getDefaultValue().isBlank()); + } + } + + @Test + public void cleanupAndRestoreStillRequireAPack() throws Exception { + for (String method : List.of("cleanup", "restore")) { + DirectorNodeDescriptor node = DirectorAnnotationCompatibility + .fromMethod(findMethod(method)) + .orElseThrow(); + DirectorParameterDescriptor pack = node.getParameters().get(0); + assertEquals("pack", pack.getName()); + assertTrue(method + " must keep requiring a pack", pack.isRequired()); + } + } + + @Test + public void allPacksPredicateAcceptsSentinelBlankAndNull() { + assertTrue(CommandPack.wantsAllPacks(null)); + assertTrue(CommandPack.wantsAllPacks("")); + assertTrue(CommandPack.wantsAllPacks(" ")); + assertTrue(CommandPack.wantsAllPacks("*")); + assertFalse(CommandPack.wantsAllPacks("overworld")); + } + + private static java.lang.reflect.Method findMethod(String name) { + for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) { + if (method.getName().equals(name)) { + return method; + } + } + throw new AssertionError("CommandPack must declare " + name); + } +} diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java index 038591ccb..6e5171f16 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java @@ -46,7 +46,10 @@ public final class IrisClientPregenState { jobs.remove(jobId); Long current = activeJobId; if (current != null && current == jobId) { - activeJobId = jobs.keySet().stream().findFirst().orElse(null); + // Promote nothing: the server runs a single live job, so any remaining entry is + // dead or orphaned — and the access-ordered map's head is the STALEST entry, + // which is what the HUD used to promote. + activeJobId = null; } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java index c08964c3d..88674aacf 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java @@ -161,7 +161,9 @@ public final class NativeStructureVegetationClearer { if (state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES)) { return true; } - String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath(); + // Field reads instead of a registry byValue map hit — this predicate runs per block + // probe on the hottest native-structure scans. + String path = state.getBlock().builtInRegistryHolder().key().identifier().getPath(); return path.endsWith("_log") || path.endsWith("_wood") || path.endsWith("_stem") || path.endsWith("_hyphae") || path.endsWith("_leaves"); 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 a42b2e48a..c93b07ceb 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 @@ -815,10 +815,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { int localY = blockY & 15; for (int z = 0; z < 16; z++) { for (int x = 0; x < 16; x++) { - if (blocks.isAir(x, bufferY, z)) { + PlatformBlockState state = blocks.rawOrNull(x, bufferY, z); + if (state == null) { continue; } - PlatformBlockState state = blocks.getRaw(x, bufferY, z); BlockState blockState = (BlockState) state.nativeHandle(); section.setBlockState(x, localY, z, blockState, false); if (blockState.hasBlockEntity()) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBuffer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBuffer.java index 7b1a5633c..52d68111c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBuffer.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBuffer.java @@ -40,6 +40,14 @@ public final class ModdedBlockBuffer implements Hunk { return data[index(x, y, z)] == null; } + /** + * Direct slot read, null when unset — lets writeBlocks pay one index + one array load per + * block instead of the isAir + getRaw pair. + */ + public PlatformBlockState rawOrNull(int x, int y, int z) { + return data[index(x, y, z)]; + } + @Override public int getWidth() { return 16; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index ad0a2f70c..322084ea6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -226,6 +226,8 @@ public final class ModdedDimensionManager { ModdedWorldEngines.evictOrThrow(level); level.save(null, true, false); serverAccess.removeLevel(server, key); + // Undo snapshots pin the ServerLevel and could replay into the dead level. + art.arcane.iris.modded.command.ModdedObjectUndo.forget(level); level.close(); HANDLES.remove(dimensionId); if (wipeStorage) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 7d3c92ef0..73135b003 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -47,6 +47,7 @@ import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.parallel.MultiBurst; import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -99,6 +100,11 @@ public final class ModdedEngineBootstrap { captureInitialSpawn(server); currentServer = server; bind(); + // Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once + // closed, MultiBurst falls back to a same-thread executor, so a second world load + // without reopen() would silently run every burst inline. + MultiBurst.burst.reopen(); + MultiBurst.ioBurst.reopen(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { scheduler.reset(); @@ -170,6 +176,7 @@ public final class ModdedEngineBootstrap { failure = runStopStage(failure, "wand service", ModdedWandService::clearAll); failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear); failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear); + failure = runStopStage(failure, "gui host", ModdedGuiHost::clear); failure = runStopStage(failure, "services", () -> services().disableAll()); failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown); failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear); @@ -183,6 +190,10 @@ public final class ModdedEngineBootstrap { failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget); } failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool); + failure = runStopStage(failure, "burst pools", () -> { + MultiBurst.burst.close(); + MultiBurst.ioBurst.close(); + }); failure = runStopStage(failure, "sentry", ModdedSentry::flush); failure = runStopStage(failure, "startup state", ModdedStartup::reset); failure = runStopStage(failure, "server state", () -> { @@ -386,7 +397,12 @@ public final class ModdedEngineBootstrap { ModdedCustomContentRegistry.Discovery customContentDiscovery = ModdedCustomContentRegistry.discover(); rollback.add(customContentDiscovery::rollback); - ModdedIrisSplash.print(boundLoader); + try { + ModdedIrisSplash.print(boundLoader); + } catch (Throwable splashFailure) { + // A cosmetic banner must never roll back the platform bind. + LOGGER.warn("Iris splash could not be printed", splashFailure); + } createdServices.enableAll(); runtime = new BoundRuntime(created, createdServices); rollback.clear(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index d6be9b58c..a3acdf47d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeUiMessages; @@ -734,7 +735,7 @@ public final class ModdedForcedDatapack { } private static Path packsRoot() { - return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs"); + return IrisPlatforms.get().packsFolderNoCreate().toPath(); } private record PublishedState(Path directory, String packsHash) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java index aaef0e8c7..bf3ee022c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.core.splash.IrisSplashRenderer; @@ -38,7 +39,7 @@ public final class ModdedIrisSplash { } private static void printPacks(ModdedLoader loader) { - File packFolder = loader.configDir().resolve("irisworldgen").resolve("packs").toFile(); + File packFolder = IrisPlatforms.get().packsFolderNoCreate(); for (String line : IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)) { IrisLogging.info(line); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 88a3db5dd..df5b1d0ff 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -116,13 +116,68 @@ public final class ModdedPlatform implements IrisPlatform { return folder; } + /** + * Modded packs live under config/irisworldgen/packs, not the config/iris data folder. The + * packsFolder overrides are the source of truth; the dataFolder/dataFile overrides below + * re-root any path whose FIRST segment is exactly "packs" so no call site can regress onto + * the empty config/iris/packs directory. Settings, worlds.json, parity/, cache/ and every + * other name stay under config/iris. + */ + @Override + public File packsFolder(String... sub) { + File folder = packsFolderNoCreate(sub); + folder.mkdirs(); + return folder; + } + + @Override + public File packsFolderNoCreate(String... sub) { + File root = loader.configDir().resolve("irisworldgen").resolve("packs").toFile(); + if (sub == null || sub.length == 0) { + return root; + } + return new File(root, String.join(File.separator, sub)); + } + + @Override + public File dataFolder(String... path) { + if (isPacksPath(path)) { + return packsFolder(stripPacksSegment(path)); + } + return IrisPlatform.super.dataFolder(path); + } + + @Override + public File dataFolderNoCreate(String... path) { + if (isPacksPath(path)) { + return packsFolderNoCreate(stripPacksSegment(path)); + } + return IrisPlatform.super.dataFolderNoCreate(path); + } + @Override public File dataFile(String... path) { + if (isPacksPath(path)) { + File file = packsFolderNoCreate(stripPacksSegment(path)); + file.getParentFile().mkdirs(); + return file; + } File file = new File(dataFolder(), String.join(File.separator, path)); file.getParentFile().mkdirs(); return file; } + private static boolean isPacksPath(String... path) { + // Exact-segment match only: "packbenchmarks" and "packsx" must stay under config/iris. + return path != null && path.length > 0 && "packs".equals(path[0]); + } + + private static String[] stripPacksSegment(String... path) { + String[] sub = new String[path.length - 1]; + System.arraycopy(path, 1, sub, 0, sub.length); + return sub; + } + @Override public File pluginJar() { File jar = loader.modJar(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java index 17fe820dc..f7fde8a5c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java @@ -50,6 +50,11 @@ final class ModdedSpawnTableMerger { } void initializeVanillaSpawnBiomes(Registry registry) { + // Volatile fast path BEFORE the monitor: this runs per mob category per spawn attempt + // on the server thread, and the generator monitor is contended by binds/hotloads. + if (vanillaSpawnBiomesInitialized) { + return; + } synchronized (generator) { if (vanillaSpawnBiomesInitialized) { return; 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 762d02082..4ce95fd03 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 @@ -59,9 +59,36 @@ public final class ModdedStartup { if (!PREPARED.compareAndSet(false, true)) { return; } + reportLegacyPacksDirectory(); validateAllPacks(); } + /** + * Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded + * packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when + * the legacy directory holds packs, and quietly remove it when it is empty. + */ + private static void reportLegacyPacksDirectory() { + try { + File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile(); + if (!legacy.isDirectory()) { + return; + } + if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) { + File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate(); + LOGGER.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.", + legacy.getAbsolutePath(), real.getAbsolutePath()); + return; + } + String[] entries = legacy.list(); + if (entries == null || entries.length == 0) { + legacy.delete(); + } + } catch (Throwable e) { + LOGGER.debug("Iris legacy packs directory check failed", e); + } + } + /** * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 19ed6d4e2..56e7935db 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.IrisEngine; @@ -26,6 +27,7 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.modded.command.ModdedGuiHost; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.storage.LevelResource; @@ -76,6 +78,8 @@ public final class ModdedWorldEngines { if (removed[0] == null) { return; } + // The GUI host holds strong Engine/ServerLevel references with no other remove path. + ModdedGuiHost.unbind(removed[0]); LOGGER.info("Iris engine evicted for {}", level.dimension().identifier()); } @@ -89,6 +93,7 @@ public final class ModdedWorldEngines { ENGINES.compute(activeLevel, (ServerLevel ignored, Engine current) -> { if (current != null && current != activeReplacement) { close(current); + ModdedGuiHost.unbind(current); } return activeReplacement; }); @@ -96,6 +101,7 @@ public final class ModdedWorldEngines { static void closeUnregistered(Engine engine) { close(engine); + ModdedGuiHost.unbind(engine); } private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) { @@ -167,11 +173,7 @@ public final class ModdedWorldEngines { } public static File packFolder(String pack) { - return ModdedEngineBootstrap.loader().configDir() - .resolve("irisworldgen") - .resolve("packs") - .resolve(pack) - .toFile(); + return IrisPlatforms.get().packsFolderNoCreate(pack); } static File resolvePack(String pack, String dimensionKey) { @@ -200,10 +202,18 @@ public final class ModdedWorldEngines { ServerLevel level = entry.getKey(); Engine engine = entry.getValue(); try { - close(engine); - if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) { - throw new IllegalStateException("Iris engine mapping changed during shutdown for " - + level.dimension().identifier()); + // Latch the generator's unloading flag BEFORE closing (unbindEngine sets it, + // then evicts): chunk-system drain work running after this stage would + // otherwise see a closed engine and silently rebuild a fresh engine + Mantle + // that no teardown stage ever closes, writing plates after the final save. + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { + generator.unbindEngine(level); + } else { + close(engine); + if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) { + throw new IllegalStateException("Iris engine mapping changed during shutdown for " + + level.dimension().identifier()); + } } LOGGER.info("Iris engine closed for {}", level.dimension().identifier()); } catch (Throwable e) { 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 b6fdc24ff..26a165498 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 @@ -565,9 +565,7 @@ public final class ModdedWorldManager implements EngineWorldManager { if (worldY <= level.getMinY() || worldY >= level.getMaxY()) { continue; } - if (!LootResolver.oneIn(entityRng, entry.getRarity())) { - continue; - } + // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } @@ -605,9 +603,7 @@ public final class ModdedWorldManager implements EngineWorldManager { int worldZ = position.getZ(); int spawned = 0; for (int i = 0; i < count; i++) { - if (!LootResolver.oneIn(entityRng, entry.getRarity())) { - continue; - } + // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } @@ -774,18 +770,8 @@ public final class ModdedWorldManager implements EngineWorldManager { } private IrisEntitySpawn rarityPick(KList entries) { - int totalRarity = 0; - for (IrisEntitySpawn entry : entries) { - totalRarity += IRare.get(entry); - } - if (totalRarity <= 0) { - return entries.getRandom(); - } - KList weighted = new KList<>(); - for (IrisEntitySpawn entry : entries) { - weighted.addMultiple(entry, totalRarity / IRare.get(entry)); - } - return weighted.getRandom(); + KList weighted = IRare.expandWeighted(entries); + return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom(); } private static long pack(int x, int z) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java index 88cd88f35..340cd5cfb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java @@ -178,10 +178,16 @@ public final class IrisModdedAPI { /** * Declares that mantle slices of {@code sliceType} must be kept rather than discarded. *

- * Iris drops slices it does not need once a region's generation data has served its purpose. Any type a mod - * writes with {@link #setMantleData(ServerLevel, int, int, int, Object)} and expects to read back later must be + * Iris drops slices it does not need once a region's generation data has served its purpose - both the + * normal per-chunk trim and pregeneration's forced cleanup honor this registry. Any type a mod writes + * with {@link #setMantleData(ServerLevel, int, int, int, Object)} and expects to read back later must be * declared here first. Registration is by canonical class name, process-wide across every Iris world, and - * cannot be undone - declare it once during mod setup. A null {@code sliceType} is ignored. + * cannot be undone - declare it once during mod setup. A null {@code sliceType} is ignored, and the + * block-state slice can never be retained. + *

+ * Retained data lives for the world's lifetime: it persists into the mantle region files and unloads + * with them, so region files grow with everything you retain. The mod owns the cleanup - call + * {@code deleteMantleData} when a value is no longer needed. */ public static void retainMantleDataForSlice(Class sliceType) { if (sliceType == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index c4ca14844..1f593aa89 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; @@ -230,7 +231,7 @@ final class ModdedCommandSuggestions { Set names = new TreeSet<>(); names.add("overworld"); try { - File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); + File packs = IrisPlatforms.get().packsFolderNoCreate(); for (File child : PackDirectoryResolver.listVisiblePackDirectories(packs)) { String packName = child.getName(); names.add(packName); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index e1af70f5c..bbf5a2a91 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.pack.PackDownloader; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.LongArgumentType; @@ -190,15 +191,15 @@ final class ModdedCommandTree { .then(Commands.argument("pack", StringArgumentType.word()).suggests(ModdedCommandSuggestions.PACK_NAMES) .executes((CommandContext context) -> IrisModdedCommands.download(context.getSource(), - StringArgumentType.getString(context, "pack"), "stable", false)) + StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH, false)) .then(Commands.literal("force") .executes((CommandContext context) -> IrisModdedCommands.download(context.getSource(), - StringArgumentType.getString(context, "pack"), "stable", true))) + StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH, true))) .then(Commands.argument("overwrite", BoolArgumentType.bool()) .executes((CommandContext context) -> IrisModdedCommands.download(context.getSource(), - StringArgumentType.getString(context, "pack"), "stable", + StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH, BoolArgumentType.getBool(context, "overwrite")))) .then(Commands.argument("branch", StringArgumentType.word()) .executes((CommandContext context) -> diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java index 1ac745d9b..e1857c67f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java @@ -188,6 +188,9 @@ public final class ModdedDustRevealer { private static void revealBatch(ModdedScheduler scheduler, RevealRun run, List hits, int from) { if (!active(run)) { + // Drop the registry entry on abort too, or the run record pins the player, level + // and engine until server stop. No-op if a newer run already replaced it. + ACTIVE_RUNS.remove(run.playerId(), run); return; } int to = Math.min(hits.size(), from + PARTICLE_BATCH_SIZE); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java index 1a5828cd4..3f2b22bd1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java @@ -55,6 +55,29 @@ public final class ModdedGuiHost implements GuiHost.Provider { } } + /** + * Drops the GUI binding for an evicted engine. Without this the host pinned every + * GUI-bound Engine, its ServerLevel and transitively the MinecraftServer for the process + * lifetime — there was no remove path at all. + */ + public static void unbind(Engine engine) { + if (engine == null) { + return; + } + INSTANCE.levels.remove(engine); + INSTANCE.openers.remove(engine); + if (INSTANCE.active == engine) { + INSTANCE.active = null; + } + } + + public static void clear() { + INSTANCE.levels.clear(); + INSTANCE.openers.clear(); + INSTANCE.active = null; + INSTANCE.server = null; + } + public static boolean isGuiLaunchable() { return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index 5e40a83cf..2eaada62f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -543,6 +543,16 @@ final class ModdedLocateCommands { private static void teleportToLocateResult(CommandSourceStack source, ServerLevel level, Engine engine, ServerPlayer player, String label, Position2 at) { + // Same liveness guards the structure completion path has: the search can take up to + // two minutes, and the captured ServerPlayer may be gone or elsewhere by then. + if (player.hasDisconnected() || player.isRemoved()) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PLAYER_DISCONNECTED_BEFORE_STRUCTURE_SEARCH_COMPLETED)); + return; + } + if (player.level() != level) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_YOU_CHANGED_DIMENSIONS_BEFORE_STRUCTURE_SEARCH_COMPLETED_RUN_COMMAND_AGAIN)); + return; + } int blockX = (at.getX() << 4) + 8; int blockZ = (at.getZ() << 4) + 8; try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java index fabc4e889..e58ed694a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java @@ -291,30 +291,35 @@ public final class ModdedObjectCommands { } int[] tilesSkipped = {0}; int[] tilesSaved = {0}; + // capture() must stay on the server thread (getBlockState/getBlockEntity are not + // async-safe), but the disk write of a local, unshared object is not tick work. IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved); - File parent = file.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } - try { - object.write(file); - } catch (IOException e) { - LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); - return 0; - } - StringBuilder tileNote = new StringBuilder(); - if (tilesSaved[0] > 0) { - tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured"); - if (tilesSkipped[0] > 0) { - tileNote.append(", ").append(tilesSkipped[0]).append(" failed"); + MinecraftServer server = source.getServer(); + J.a(() -> { + File parent = file.getParentFile(); + if (parent != null) { + parent.mkdirs(); } - tileNote.append(")"); - } else if (tilesSkipped[0] > 0) { - tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)"); - } - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote))); - LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath()); + try { + object.write(file); + } catch (IOException e) { + LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e); + server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))))); + return; + } + StringBuilder tileNote = new StringBuilder(); + if (tilesSaved[0] > 0) { + tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured"); + if (tilesSkipped[0] > 0) { + tileNote.append(", ").append(tilesSkipped[0]).append(" failed"); + } + tileNote.append(")"); + } else if (tilesSkipped[0] > 0) { + tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)"); + } + server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote)))); + LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath()); + }); return 1; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java index 123fb4f49..236f503ca 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded.command; import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; @@ -88,10 +89,22 @@ public final class ModdedObjectUndo { if (entry == null) { break; } + // Identity check, not just null: a studio closed and reopened under the same + // dimension id must never have blocks replayed into the dead ServerLevel. + MinecraftServer server = entry.level().getServer(); + if (server == null || server.getLevel(entry.level().dimension()) != entry.level()) { + LOGGER.warn("Iris object undo: skipped a stale entry for removed dimension {}", + entry.level().dimension().identifier()); + continue; + } int writes = 0; for (Map.Entry block : entry.blocks().entrySet()) { - entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); - writes++; + try { + entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + writes++; + } catch (Throwable e) { + LOGGER.error("Iris object undo: failed to revert a block at {}", block.getKey(), e); + } } LOGGER.info("Iris object undo: reverted {} block(s) in {}", writes, entry.level().dimension().identifier()); reverted++; @@ -99,6 +112,23 @@ public final class ModdedObjectUndo { return reverted; } + /** + * Drops every entry recorded against the given level. Called on dimension removal so a + * closed studio releases its block snapshots and the ServerLevel reference. + */ + public static void forget(ServerLevel level) { + if (level == null) { + return; + } + UNDOS.entrySet().removeIf((Map.Entry> ownerEntry) -> { + Deque queue = ownerEntry.getValue(); + synchronized (queue) { + queue.removeIf((Entry entry) -> entry.level() == level); + return queue.isEmpty(); + } + }); + } + public static void clearAll() { UNDOS.clear(); } 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 cc8f79d0d..614fa74de 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,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackResourceCleanup; import art.arcane.iris.core.pack.PackValidationRegistry; @@ -98,7 +99,7 @@ public final class ModdedPackCommands { } public static File packsRoot() { - return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); + return IrisPlatforms.get().packsFolderNoCreate(); } private static int validate(CommandSourceStack source, String pack) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 8f9bccf8f..a573c7c08 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.StructurePackageClosure; import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.engine.framework.Engine; @@ -31,7 +32,9 @@ import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntitySpawn; import art.arcane.iris.engine.object.IrisGenerator; -import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.core.pack.PackExportClosure; +import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.engine.object.IrisStructurePlacement; @@ -383,7 +386,7 @@ public final class ModdedStudioCommands { File packFolder = new File(ModdedPackCommands.packsRoot(), pack); if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) { server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); @@ -467,7 +470,9 @@ public final class ModdedStudioCommands { ServerPlayer player = source.getPlayer(); MinecraftServer server = source.getServer(); UUID owner = player == null ? CONSOLE_OWNER : player.getUUID(); - String dimensionId = STUDIOS.remove(owner); + // Commit the ownership drop only after removal succeeds: dropping it first orphaned a + // still-registered studio that no command could ever remove again. + String dimensionId = STUDIOS.get(owner); if (dimensionId == null) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN)); return 0; @@ -479,6 +484,7 @@ public final class ModdedStudioCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); return 0; } + STUDIOS.remove(owner, dimensionId); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, MessageArgument.untrusted("dimensionId", dimensionId))); return 1; } @@ -588,7 +594,7 @@ public final class ModdedStudioCommands { File templateFolder = new File(packsRoot, template); if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) { server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, PackDownloader.DEFAULT_BRANCH, false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); @@ -655,6 +661,7 @@ public final class ModdedStudioCommands { LinkedHashSet generatorKeys = new LinkedHashSet<>(); LinkedHashSet lootKeys = new LinkedHashSet<>(); LinkedHashSet objectKeys = new LinkedHashSet<>(); + LinkedHashSet markerKeys = new LinkedHashSet<>(); LinkedHashSet structureKeys = new LinkedHashSet<>(); regionKeys.addAll(dimension.getRegions()); @@ -675,6 +682,8 @@ public final class ModdedStudioCommands { lootKeys.addAll(region.getLoot().getTables()); spawnerKeys.addAll(region.getEntitySpawners()); collectStructureKeys(structureKeys, region.getStructures()); + objectKeys.addAll(PackExportClosure.collectObjectKeys(region.getObjects())); + markerKeys.addAll(PackExportClosure.collectMarkerKeys(region.getObjects())); } for (String biomeKey : biomeKeys) { IrisBiome biome = dm.getBiomeLoader().load(biomeKey); @@ -685,9 +694,15 @@ public final class ModdedStudioCommands { lootKeys.addAll(biome.getLoot().getTables()); spawnerKeys.addAll(biome.getEntitySpawners()); collectStructureKeys(structureKeys, biome.getStructures()); - for (IrisObjectPlacement placement : biome.getObjects()) { - objectKeys.addAll(placement.getPlace()); + objectKeys.addAll(PackExportClosure.collectObjectKeys(biome.getObjects())); + markerKeys.addAll(PackExportClosure.collectMarkerKeys(biome.getObjects())); + } + for (String markerKey : markerKeys) { + IrisMarker marker = dm.getMarkerLoader().load(markerKey); + if (marker == null) { + continue; } + spawnerKeys.addAll(marker.getSpawners()); } for (String spawnerKey : spawnerKeys) { IrisSpawner spawner = dm.getSpawnerLoader().load(spawnerKey); @@ -695,6 +710,14 @@ public final class ModdedStudioCommands { continue; } spawner.getSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity())); + spawner.getInitialSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity())); + } + for (String entityKey : entityKeys) { + IrisEntity entity = dm.getEntityLoader().load(entityKey); + if (entity == null) { + continue; + } + lootKeys.addAll(entity.getLoot().getTables()); } StringBuilder hashes = new StringBuilder(); @@ -732,6 +755,12 @@ public final class ModdedStudioCommands { for (String key : lootKeys) { hashes.append(copyJson(folder, "loot", key, dm.getLootLoader().findFile(key))); } + for (String key : spawnerKeys) { + hashes.append(copyJson(folder, "spawners", key, dm.getSpawnerLoader().findFile(key))); + } + for (String key : markerKeys) { + hashes.append(copyJson(folder, "markers", key, dm.getMarkerLoader().findFile(key))); + } JSONObject meta = new JSONObject(); meta.put("hash", IO.hash(hashes.toString())); @@ -794,14 +823,19 @@ public final class ModdedStudioCommands { int totalTasks = diameter * diameter; KMap counts = new KMap<>(); engine.getDimension().getRegions().forEach((String key) -> counts.put(key, new AtomicInteger(0))); + // finally-scoped: a throw mid-scan previously leaked the sampler's whole + // ForkJoinPool (close() is the only thing that shuts it down). MultiBurst burst = new MultiBurst("Region Sampler"); - BurstExecutor executor = burst.burst(totalTasks); - new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> { - IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8); - counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet(); - })).setOffset(blockX >> 4, blockZ >> 4).drain(); - executor.complete(); - burst.close(); + try { + BurstExecutor executor = burst.burst(totalTasks); + new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> { + IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8); + counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet(); + })).setOffset(blockX >> 4, blockZ >> 4).drain(); + executor.complete(); + } finally { + burst.close(); + } server.execute(() -> counts.forEach((String key, AtomicInteger count) -> { IrisRegion region = engine.getData().getRegionLoader().load(key); String rarity = region == null ? "?" : String.valueOf(region.getRarity()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java index baaa8332c..b480fb44a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java @@ -451,6 +451,9 @@ public final class ModdedWhatCommands { ModdedScheduler scheduler, MarkerRun run, List hits, int from) { if (!active(run)) { + // Drop the registry entry on abort too, or the run record pins the player, level + // and engine until server stop. No-op if a newer run already replaced it. + ACTIVE_MARKER_RUNS.remove(run.playerId(), run); return; } int to = Math.min(hits.size(), from + MARKER_BATCH_SIZE); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index 95ca1ee20..3d85adffd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.BrokenPackException; +import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -177,7 +178,7 @@ public final class ModdedWorldCommands { } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); Thread thread = new Thread(() -> { - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); server.execute(() -> { if (!installed || !packFolder.isDirectory()) { @@ -280,7 +281,7 @@ public final class ModdedWorldCommands { } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); Thread thread = new Thread(() -> { - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); server.execute(() -> { if (!installed || !packFolder.isDirectory()) { 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 4cb118ae2..cd5867744 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 @@ -55,6 +55,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; public final class ModdedChunkUpdateService implements ModdedTickableService { private static final long PASS_PERIOD_MILLIS = 3_000L; @@ -84,7 +85,17 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { ExecutorService active = warmupExecutor; warmupExecutor = null; if (active != null) { - active.shutdown(); + // Drop queued warm-ups (pure prefetch) and AWAIT: the very next shutdown stage + // closes every Mantle, and an in-flight mantle.getChunk would fault a plate back + // in after the close-time flush. + active.shutdownNow(); + try { + if (!active.awaitTermination(5L, TimeUnit.SECONDS)) { + IrisLogging.warn("Iris mantle warm-up did not stop before engine close"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } warmupQueue.clear(); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDownloadBranchParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDownloadBranchParityTest.java new file mode 100644 index 000000000..815cce66e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDownloadBranchParityTest.java @@ -0,0 +1,31 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedDownloadBranchParityTest { + private static final List DOWNLOAD_SOURCES = List.of( + "art/arcane/iris/modded/command/ModdedWorldCommands.java", + "art/arcane/iris/modded/command/ModdedStudioCommands.java", + "art/arcane/iris/modded/command/ModdedCommandTree.java"); + + @Test + public void implicitAndExplicitDownloadsShareTheCoreDefaultBranch() throws Exception { + for (String source : DOWNLOAD_SOURCES) { + Path path = Path.of(System.getProperty("iris.moddedCommonSources"), source); + String text = Files.readString(path); + assertFalse(source + " must not hardcode a \"master\" download branch", + text.contains("\"master\"")); + assertFalse(source + " must not hardcode a \"stable\" download branch", + text.contains("\"stable\"")); + assertTrue(source + " must use PackDownloader.DEFAULT_BRANCH", + text.contains("PackDownloader.DEFAULT_BRANCH")); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java index f2738b115..07214edbf 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java @@ -36,12 +36,12 @@ public class ModdedLootApplierTest { } @Test - public void clearRemovesNativeAndIrisSourcesBeforeAdding() { + public void clearRemovesEverythingAndContributesNothing() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); LootResolver.injectSources(sources, List.of("biome-iris"), IrisLootMode.CLEAR, false); - assertEquals(List.of("biome-iris"), sources); + assertEquals(List.of(), sources); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java new file mode 100644 index 000000000..fe42157df --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.modded; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; + +public class ModdedPlatformPathsTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private ModdedPlatform platform() { + Path configDir = temporaryFolder.getRoot().toPath(); + return new ModdedPlatform(new ModdedLoader() { + @Override + public String platformName() { + return "test"; + } + + @Override + public String minecraftVersion() { + return "26.2"; + } + + @Override + public String modVersion() { + return "0.0.0"; + } + + @Override + public MinecraftServer currentServer() { + return null; + } + + @Override + public void invalidateLevelCache(MinecraftServer server) { + } + + @Override + public boolean clientEnvironment() { + return false; + } + + @Override + public Path configDir() { + return configDir; + } + + @Override + public File modJar() { + return null; + } + + @Override + public boolean hasTreeFellerPermission(ServerPlayer player) { + return false; + } + + @Override + public boolean canTreeFellerBreak(ServerLevel level, ServerPlayer player, BlockPos position, BlockState state) { + return false; + } + }); + } + + @Test + public void packsResolveUnderIrisworldgen() { + ModdedPlatform platform = platform(); + File root = temporaryFolder.getRoot(); + File packsRoot = new File(new File(root, "irisworldgen"), "packs"); + + assertEquals(packsRoot, platform.packsFolder()); + assertEquals(packsRoot, platform.dataFolder("packs")); + assertEquals(new File(packsRoot, "overworld"), platform.dataFolderNoCreate("packs", "overworld")); + assertEquals(new File(packsRoot, "overworld" + File.separator + "dimensions" + File.separator + "overworld.json"), + platform.dataFile("packs", "overworld", "dimensions", "overworld.json")); + } + + @Test + public void everythingElseStaysUnderIris() { + ModdedPlatform platform = platform(); + File iris = new File(temporaryFolder.getRoot(), "iris"); + + assertEquals(iris, platform.dataFolder()); + assertEquals(new File(iris, "settings.json"), platform.dataFile("settings.json")); + assertEquals(new File(iris, "parity"), platform.dataFolder("parity")); + } + + @Test + public void packsMatchIsExactSegmentOnly() { + ModdedPlatform platform = platform(); + File iris = new File(temporaryFolder.getRoot(), "iris"); + + assertEquals(new File(iris, "packbenchmarks"), platform.dataFolder("packbenchmarks")); + assertEquals(new File(iris, "packsx"), platform.dataFolderNoCreate("packsx")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSpawnerRarityParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSpawnerRarityParityTest.java new file mode 100644 index 000000000..95562cb3c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSpawnerRarityParityTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IRare; +import art.arcane.iris.engine.object.IrisEntitySpawn; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +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 ModdedSpawnerRarityParityTest { + private static IrisEntitySpawn spawn(int rarity) { + IrisEntitySpawn spawn = new IrisEntitySpawn(); + spawn.setRarity(rarity); + return spawn; + } + + @Test + public void rarityIsAppliedOnceAsPoolWeightOnly() { + IrisEntitySpawn common = spawn(1); + IrisEntitySpawn rare = spawn(4); + + KList expanded = IRare.expandWeighted(List.of(common, rare)); + + // totalRarity 5 -> common appears 5/1 = 5 times, rare 5/4 = 1 time. + assertEquals(6, expanded.size()); + assertEquals(5, expanded.stream().filter(entry -> entry == common).count()); + assertEquals(1, expanded.stream().filter(entry -> entry == rare).count()); + } + + @Test + public void rarityZeroAndNegativeAreClampedToOne() { + assertEquals(1, IRare.get(spawn(0))); + assertEquals(1, IRare.get(spawn(-5))); + assertEquals(3, IRare.get(spawn(3))); + } + + @Test + public void perPositionSpawnLoopsDoNotRerollEntryRarity() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedWorldManager.java")); + + for (String method : List.of("private int spawnEntry(", "private int spawnEntryAt(")) { + String body = methodBody(source, method); + assertFalse(method + " must not re-roll rarity per position; rarityPick already weighted the pool", + body.contains("getRarity()")); + assertTrue(method + " must keep the min/max spawn-count roll", + body.contains("LootResolver.inclusive(")); + } + } + + private static String methodBody(String source, String declaration) { + int start = source.indexOf(declaration); + assertTrue("ModdedWorldManager must declare " + declaration, start >= 0); + int open = source.indexOf('{', start); + int depth = 0; + for (int index = open; index < source.length(); index++) { + char character = source.charAt(index); + if (character == '{') { + depth++; + } else if (character == '}') { + depth--; + if (depth == 0) { + return source.substring(open + 1, index); + } + } + } + throw new AssertionError(declaration + " is not brace balanced"); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index f1834fb46..216da6f49 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -41,7 +41,7 @@ public class IrisSettings { private IrisSettingsGUI gui = new IrisSettingsGUI(); private IrisSettingsAutoconfiguration autoConfiguration = new IrisSettingsAutoconfiguration(); private IrisSettingsGenerator generator = new IrisSettingsGenerator(); - private IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency(); + private transient IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency(); private IrisSettingsStudio studio = new IrisSettingsStudio(); private IrisSettingsPerformance performance = new IrisSettingsPerformance(); private IrisSettingsPregen pregen = new IrisSettingsPregen(); diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index 33438eee2..1984deb3e 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -222,7 +222,7 @@ public class IrisWorlds { dimension = IrisData.loadAnyDimension(id, null); } if (dimension == null) { - File packsRoot = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME); + File packsRoot = IrisPlatforms.get().packsFolderNoCreate(); if (PackDownloader.isPackPresent(packsRoot, id)) { IrisLogging.error("Pack '" + id + "' exists at " + new File(packsRoot, id).getPath() + " but its dimension failed to load; not redownloading. Fix or delete the pack folder."); 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 9cfb7a363..1faf9c2be 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -892,7 +892,7 @@ public class ServerConfigurator { public static Stream allPacks() { Stream locals = PackDirectoryResolver.listVisiblePackDirectories( - IrisPlatforms.get().dataFolder("packs") + IrisPlatforms.get().packsFolder() ).stream(); return Stream.concat(locals .filter(base -> { diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 54a43022d..5dce1d5f8 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -175,7 +175,7 @@ public final class DatapackIngestService { if (autoIngest && !configured.isEmpty()) { IrisLogging.info("Validating " + configured.size() + " configured external datapack import(s) before player admission..."); - Report report = ingest(null, configured, true); + Report report = ingest(null, configured, true, true); if (!report.getFailed().isEmpty()) { String failure = report.getFailed().getFirst(); IrisStartupValidation.markDatapacksInvalid(failure); @@ -513,15 +513,35 @@ public final class DatapackIngestService { } public static Report ingest(VolmitSender sender, KList urls, boolean restart) { - IrisStartupValidation.beginDatapackValidation(); + return ingest(sender, urls, restart, false); + } + + /** + * Only startup validation owns the global player-admission gate (gateAdmission=true). The + * runtime admin command must never flip it: a transient download failure there would lock + * every login until restart, and the PENDING window would close logins for the whole + * (potentially very slow) download. World-creation safety on the ungated path is carried + * by the loaded-runtime invalidation plus requireDatapackRestart when files change. + */ + private static Report ingest(VolmitSender sender, KList urls, boolean restart, boolean gateAdmission) { + if (gateAdmission) { + IrisStartupValidation.beginDatapackValidation(); + } ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation = ServerConfigurator.invalidateLoadedDatapackRuntime(); Report report; + boolean settled = false; TRANSACTION_LOCK.lock(); try { report = ingestLocked(sender, urls, restart); + settled = true; } finally { TRANSACTION_LOCK.unlock(); + if (!settled && gateAdmission) { + // Never strand the admission gate at PENDING on an unexpected throw. + IrisStartupValidation.markDatapacksInvalid( + "External datapack ingest failed unexpectedly; check the log above."); + } } if (!report.changed() && report.getFailed().isEmpty()) { ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation); @@ -532,10 +552,12 @@ public final class DatapackIngestService { ServerConfigurator.requireDatapackRestart(); } } - if (!report.getFailed().isEmpty()) { - IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst()); - } else if (!report.changed()) { - IrisStartupValidation.markDatapacksReady(); + if (gateAdmission) { + if (!report.getFailed().isEmpty()) { + IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst()); + } else if (!report.changed()) { + IrisStartupValidation.markDatapacksReady(); + } } return report; } @@ -944,45 +966,61 @@ public final class DatapackIngestService { } } + enum RemoveOutcome { + REMOVED, + REJECTED, + FAILED_AFTER_MUTATION + } + public static boolean remove(VolmitSender sender, String id) { - ServerConfigurator.invalidateLoadedDatapackRuntime(); - boolean removed; + ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation = + ServerConfigurator.invalidateLoadedDatapackRuntime(); + RemoveOutcome outcome; TRANSACTION_LOCK.lock(); try { - removed = removeLocked(sender, id); + outcome = removeOutcomeLocked(sender, id); } finally { TRANSACTION_LOCK.unlock(); } - if (removed) { + if (outcome == RemoveOutcome.REMOVED) { ServerConfigurator.requireDatapackRestart(); + } else if (outcome == RemoveOutcome.REJECTED) { + // Rejected before any mutation: nothing on disk changed, so the loaded runtime is + // still valid. Leaving it invalidated forced a full datapack reinstall on every + // later world creation for the rest of the session. + ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation); } - return removed; + return outcome == RemoveOutcome.REMOVED; } - private static boolean removeLocked(VolmitSender sender, String id) { + private static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) { File root = IrisPlatforms.get().dataFolder("datapacks"); - return removeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder()); + return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder()); } static boolean removeLocked(VolmitSender sender, String id, File root, List worldFolders) { + return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED; + } + + static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List worldFolders) { String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT); String cleaned = sanitizeId(id); if (requested.isBlank() || !requested.equals(cleaned) || RESERVED_IDS.contains(cleaned)) { message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id."); - return false; + return RemoveOutcome.REJECTED; } try { recoverTransactions(root, worldFolders); } catch (IOException e) { message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage()); IrisLogging.reportError(e); - return false; + return RemoveOutcome.REJECTED; } Manifest manifest = readManifest(root); Entry ownedEntry = manifest.findById(cleaned); if (ownedEntry == null) { message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris."); - return false; + return RemoveOutcome.REJECTED; } List targets; @@ -991,7 +1029,7 @@ public final class DatapackIngestService { } catch (IOException e) { message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage()); IrisLogging.reportError(e); - return false; + return RemoveOutcome.REJECTED; } EditableImportRemoval editableRemoval = null; @@ -1017,7 +1055,7 @@ public final class DatapackIngestService { removalFailure); message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN + "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone."); - return true; + return RemoveOutcome.REMOVED; } boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure); if (restored && coordinator != null) { @@ -1030,12 +1068,14 @@ public final class DatapackIngestService { message(sender, C.RED + "Failed to remove datapack '" + cleaned + "'; Iris attempted to restore every prior location: " + removalFailure.getMessage()); IrisLogging.reportError(removalFailure); - return false; + // A mutation was attempted; even a successful rollback is not provably identical + // (the fingerprint does not cover datapacks/), so stay conservatively invalidated. + return RemoveOutcome.FAILED_AFTER_MUTATION; } finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null); message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN + "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone."); - return true; + return RemoveOutcome.REMOVED; } private static void finishCommittedRemoval( diff --git a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java index d982fffbd..55c102f4e 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java @@ -358,15 +358,18 @@ public final class ModrinthResolver { connection.setReadTimeout(20000); connection.setInstanceFollowRedirects(true); - int code = connection.getResponseCode(); - if (code != 200) { - connection.disconnect(); - throw new IOException("HTTP " + code + " from " + url); - } + // Everything past openConnection under one finally: a timeout/DNS/TLS throw inside + // getResponseCode abandoned the connection undisconnected. + try { + int code = connection.getResponseCode(); + if (code != 200) { + throw new IOException("HTTP " + code + " from " + url); + } - try (InputStream input = connection.getInputStream(); - InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { - return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS); + try (InputStream input = connection.getInputStream(); + InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS); + } } finally { connection.disconnect(); } diff --git a/core/src/main/java/art/arcane/iris/core/edit/BlockSignal.java b/core/src/main/java/art/arcane/iris/core/edit/BlockSignal.java index d1ec0b86b..19c104f41 100644 --- a/core/src/main/java/art/arcane/iris/core/edit/BlockSignal.java +++ b/core/src/main/java/art/arcane/iris/core/edit/BlockSignal.java @@ -36,9 +36,17 @@ public class BlockSignal { public static final AtomicInteger active = new AtomicInteger(0); public BlockSignal(Block block, int ticks) { - active.incrementAndGet(); + // Count only after the entity actually exists, and release unconditionally: a refused + // Folia schedule or a spawn failure must not inflate the throttle counter forever. Location tg = block.getLocation().clone().add(0.5, 0, 0.5); - FallingBlock e = block.getWorld().spawnFallingBlock(tg, block.getBlockData()); + FallingBlock e; + try { + e = block.getWorld().spawnFallingBlock(tg, block.getBlockData()); + } catch (Throwable spawnFailure) { + sendBlockRefresh(block); + throw spawnFailure; + } + active.incrementAndGet(); e.setGravity(false); e.setInvulnerable(true); e.setGlowing(true); @@ -55,10 +63,11 @@ public class BlockSignal { active.decrementAndGet(); sendBlockRefresh(block); }; - if (!J.runAt(blockLocation, removeTask, ticks)) { - if (!J.isFolia()) { - J.s(removeTask, ticks); - } + boolean scheduled = J.runAt(blockLocation, removeTask, ticks); + if (!scheduled && !J.isFolia()) { + J.s(removeTask, ticks); + } else if (!scheduled) { + removeTask.run(); } } diff --git a/core/src/main/java/art/arcane/iris/core/edit/DustRevealer.java b/core/src/main/java/art/arcane/iris/core/edit/DustRevealer.java index 85ae9a041..f3b1998ef 100644 --- a/core/src/main/java/art/arcane/iris/core/edit/DustRevealer.java +++ b/core/src/main/java/art/arcane/iris/core/edit/DustRevealer.java @@ -36,7 +36,6 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; -import lombok.Data; import org.bukkit.Location; import org.bukkit.Sound; import org.bukkit.World; @@ -47,74 +46,83 @@ import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; +import java.util.ArrayDeque; +import java.util.HashSet; import java.util.Set; import java.util.function.Supplier; -@SuppressWarnings("ALL") -@Data public class DustRevealer { - private final Engine engine; - private final World world; - private final BlockPosition block; - private final String key; - private final KList hits; + // Matches the modded twin (ModdedDustRevealer) so both platforms truncate identically. + private static final int MAX_HITS = 2_048; + private static final int PARTICLE_BATCH_SIZE = 64; - public DustRevealer(Engine engine, World world, BlockPosition block, String key, KList hits) { - this.engine = engine; - this.world = world; - this.block = block; - this.key = key; - this.hits = hits; + /** + * Bounded, single-threaded flood fill over the object's placement key, followed by batched + * particle playback on the owning threads. The previous shape recursively spawned one + * scheduler task + one burst task per matched block, all mutating one unsynchronized + * ArrayList and sleep-spinning MultiBurst workers — unbounded fan-out on large objects. + */ + private static void reveal(Engine engine, World world, BlockPosition origin, String key) { + KList hits = new KList<>(); + Set visited = new HashSet<>(); + ArrayDeque frontier = new ArrayDeque<>(); + visited.add(origin); + frontier.add(origin); + hits.add(origin); + int minY = world.getMinHeight(); + int maxY = world.getMaxHeight(); - Location blockLocation = block.toBlock(world).getLocation(); - Runnable revealTask = () -> { - BlockSignal.of(world, block.getX(), block.getY(), block.getZ(), 10); + try { + search: + while (!frontier.isEmpty()) { + BlockPosition at = frontier.poll(); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dy == 0 && dz == 0) { + continue; + } + BlockPosition next = new BlockPosition(at.getX() + dx, at.getY() + dy, at.getZ() + dz); + if (next.getY() < minY || next.getY() >= maxY || !visited.add(next)) { + continue; + } + if (key.equals(engine.getObjectPlacementKey(next.getX(), next.getY() - minY, next.getZ()))) { + hits.add(next); + frontier.add(next); + if (hits.size() >= MAX_HITS) { + break search; + } + } + } + } + } + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + + revealBatch(world, hits, 0); + } + + private static void revealBatch(World world, KList hits, int from) { + if (from >= hits.size()) { + return; + } + int to = Math.min(from + PARTICLE_BATCH_SIZE, hits.size()); + Runnable batch = () -> { + for (int i = from; i < to; i++) { + BlockPosition p = hits.get(i); + BlockSignal.of(world, p.getX(), p.getY(), p.getZ(), 10); + } + BlockPosition first = hits.get(from); if (M.r(0.25)) { - world.playSound(block.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f)); + world.playSound(first.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f)); } - J.a(() -> { - while (BlockSignal.active.get() > 128) { - J.sleep(5); - } - - try { - is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ())); - is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ())); - is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ())); - is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ())); - is(new BlockPosition(block.getX(), block.getY(), block.getZ() + 1)); - is(new BlockPosition(block.getX(), block.getY(), block.getZ() - 1)); - is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() + 1)); - is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() - 1)); - is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() + 1)); - is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() - 1)); - is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ())); - is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ())); - is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ())); - is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ())); - is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() - 1)); - is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() + 1)); - is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() - 1)); - is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() + 1)); - is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() - 1)); - is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() + 1)); - is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() - 1)); - is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() + 1)); - is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() - 1)); - is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() + 1)); - is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() - 1)); - is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() + 1)); - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - }); + revealBatch(world, hits, to); }; - int delay = RNG.r.i(2, 8); - if (!J.runAt(blockLocation, revealTask, delay)) { - if (!J.isFolia()) { - J.s(revealTask, delay); - } + Location at = hits.get(from).toBlock(world).getLocation(); + if (!J.runAt(at, batch, 1) && !J.isFolia()) { + J.s(batch, 1); } } @@ -137,9 +145,7 @@ public class DustRevealer { RuntimeUiMessages.DUST_FOUND_OBJECT, MessageArgument.untrusted("object", a) )); - J.a(() -> { - new DustRevealer(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a, new KList<>()); - }); + J.a(() -> reveal(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a)); } } } @@ -354,24 +360,6 @@ public class DustRevealer { } } - private boolean is(BlockPosition a) { - if (a.getY() < world.getMinHeight() || a.getY() >= world.getMaxHeight()) { - return false; - } - int betterY = a.getY() - world.getMinHeight(); - if (isValidTry(a) && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()) != null && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()).equals(key)) { - hits.add(a); - new DustRevealer(engine, world, a, key, hits); - return true; - } - - return false; - } - - private boolean isValidTry(BlockPosition b) { - return !hits.contains(b); - } - private record DustLine(String text, boolean emphasis) { } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java index 01339cb21..6c3eaf5c6 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java @@ -42,7 +42,10 @@ import java.util.concurrent.locks.ReentrantLock; public final class PregenRenderer extends JPanel implements KeyListener { private static final long serialVersionUID = 2094606939770332040L; - private final KList order = new KList<>(); + // Backstop: if paint() ever stalls (iconified frame, EDT hiccup), the producer must not + // grow this without bound — one entry per drawn chunk adds up fast on a big pregen. + private static final int MAX_QUEUED_DRAWS = 250_000; + private KList order = new KList<>(); private final ReentrantLock lock = new ReentrantLock(); private final int res = 512; private final BufferedImage image = new BufferedImage(res, res, BufferedImage.TYPE_INT_RGB); @@ -71,7 +74,9 @@ public final class PregenRenderer extends JPanel implements KeyListener { return (Position2 c, Color color) -> { lock.lock(); try { - order.add(() -> draw(c, color, bg)); + if (order.size() < MAX_QUEUED_DRAWS) { + order.add(() -> draw(c, color, bg)); + } } finally { lock.unlock(); } @@ -83,7 +88,11 @@ public final class PregenRenderer extends JPanel implements KeyListener { } public boolean isVisibleFrame() { - return frame != null && frame.isVisible(); + // An iconified frame still reports isVisible() but AWT stops repainting it, which + // would stop the only queue drain while the producer keeps appending. + JFrame activeFrame = frame; + return activeFrame != null && activeFrame.isVisible() + && (activeFrame.getExtendedState() & java.awt.Frame.ICONIFIED) == 0; } public void close() { @@ -99,18 +108,23 @@ public final class PregenRenderer extends JPanel implements KeyListener { public void paint(Graphics gx) { Graphics2D g = (Graphics2D) gx; bg = (Graphics2D) image.getGraphics(); + // Swap the queue under the lock and drain outside it: generation threads must not + // block on the lock while the EDT walks a large batch, and pop()-per-entry was O(N^2). + KList batch; lock.lock(); try { - while (order.isNotEmpty()) { - try { - order.pop().run(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + batch = order; + order = new KList<>(); } finally { lock.unlock(); } + for (Runnable r : batch) { + try { + r.run(); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } g.drawImage(image, 0, 0, getParent().getWidth(), getParent().getHeight(), (img, infoflags, x, y, width, height) -> true); g.setColor(Color.WHITE); diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index a3ba9bb91..a7c402cbd 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -38,7 +38,6 @@ import art.arcane.volmlib.util.function.Consumer2; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.math.Position2; -import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import java.awt.Color; @@ -46,12 +45,14 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; public class PregeneratorJob implements PregenListener, PregenRenderSource { - private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 15_000L; + // Must exceed the worker's own worst-case teardown budget (AsyncPregenMethod close: + // 60s permit drain + 120s flush + plate reclaim), or a routine drain trips the deadline + // and aborts the engine shutdown sequence mid-teardown. + private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 200_000L; private static final Color COLOR_EXISTS = parseColor("#4d7d5b"); private static final Color COLOR_BLACK = parseColor("#4d7d5b"); private static final Color COLOR_MANTLE = parseColor("#3c2773"); @@ -69,14 +70,12 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private final IrisPregenerator pregenerator; private final Position2 min; private final Position2 max; - private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1)); private final Engine engine; private final ExecutorService service; private final Thread worker; private final PregenPhaseTracker apiPhases = new PregenPhaseTracker(); private PregenRenderer renderer; private Consumer2 drawFunction; - private int rgc = 0; private String[] info; private volatile double lastChunksPerSecond = 0D; private volatile long lastChunksRemaining = 0L; @@ -87,14 +86,6 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private volatile String lastMethod = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING); public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) { - instance.updateAndGet(old -> { - if (old != null) { - old.pregenerator.close(); - old.worker.interrupt(); - old.close(); - } - return this; - }); this.engine = engine; monitor = new MemoryMonitor(50); saving = false; @@ -120,6 +111,19 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { worker.setPriority(Thread.MIN_PRIORITY); worker.setDaemon(true); worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex)); + + // Publish into the static only after every field is assigned (the volatile swap is + // what makes them visible to metrics/shutdown readers), and start the worker after + // publication so it also sees a complete object. + // CAS-or-throw: updateAndGet must be side-effect free (it can re-apply on + // contention), and silently killing the previous job overlapped its 60s+120s + // teardown with the new job's generation. Replacement goes through + // shutdownAndWait first, matching the modded adapter's rejection contract. + if (!instance.compareAndSet(null, this)) { + monitor.close(); + service.shutdown(); + throw new IllegalStateException("An Iris pregeneration job is already running; stop it first."); + } worker.start(); } @@ -138,6 +142,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return false; } + if (!inst.worker.isAlive() && inst.worker.getState() != Thread.State.NEW) { + // The worker died without running onClose (early abort); clear the phantom job so + // it stops suppressing entity spawns and blocking future pregens. A NEW worker is + // a job mid-construction, not a dead one. + instance.compareAndSet(inst, null); + return false; + } + J.a(() -> { inst.pregenerator.close(); inst.worker.interrupt(); @@ -432,17 +444,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { @Override public void onRegionGenerated(int x, int z) { - shouldGc(); - rgc++; + // No forced System.gc() here: a wall-clock full STW collection mid-generation stalled + // everything; MantleHeapPressure's 96% panic reclaim already owns heap pressure. broadcastRegionDelta(x, z, IrisMessage.PregenRegionDelta.STATE_DONE); } - private void shouldGc() { - if (cl.flip() && rgc > 16) { - System.gc(); - } - } - private void broadcastRegionDelta(int regionX, int regionZ, int state) { IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class); if (protocolServer == null) { diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 7f43abae6..2900b731c 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -24,6 +24,7 @@ import art.arcane.iris.engine.framework.render.IrisRenderer; import art.arcane.iris.engine.framework.render.RenderType; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; @@ -281,7 +282,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener } public boolean updateEngine() { - if (engine.isClosed()) { + if (engine.isClosed() || engine.isClosing() || engine.getComplex() == null) { try { Engine reacquired = GuiHost.get().findActiveEngine(); if (reacquired != null && !reacquired.isClosed()) { @@ -451,12 +452,19 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener double mk = mscale; double mkd = scale; e.submit(() -> { - PrecisionStopwatch ps = PrecisionStopwatch.start(); - BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType); - rs.put(ps.getMilliseconds()); - working.remove(key); - if (mk == mscale && mkd == scale) { - positions.put(key, b); + // finally: a render throw is swallowed by the discarded Future, and a + // leaked key permanently blocks the admission gate (working < 9). + try { + PrecisionStopwatch ps = PrecisionStopwatch.start(); + BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType); + rs.put(ps.getMilliseconds()); + if (mk == mscale && mkd == scale) { + positions.put(key, b); + } + } catch (Throwable ex) { + IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } finally { + working.remove(key); } }); } @@ -472,12 +480,17 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener double mk = mscale; double mkd = scale; eh.submit(() -> { - PrecisionStopwatch ps = PrecisionStopwatch.start(); - BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType); - rs.put(ps.getMilliseconds()); - workingfast.remove(key); - if (mk == mscale && mkd == scale) { - fastpositions.put(key, b); + try { + PrecisionStopwatch ps = PrecisionStopwatch.start(); + BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType); + rs.put(ps.getMilliseconds()); + if (mk == mscale && mkd == scale) { + fastpositions.put(key, b); + } + } catch (Throwable ex) { + IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } finally { + workingfast.remove(key); } }); return null; @@ -507,12 +520,30 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener @Override public void paint(Graphics gx) { - if (engine.isClosed() && !updateEngine()) { + // Cover the closing window too: releaseRuntime nulls the complex BEFORE closed flips, + // and a paint NPE in that window killed the self-driven repaint loop forever. + if ((engine.isClosed() || engine.isClosing() || engine.getComplex() == null) && !updateEngine()) { EventQueue.invokeLater(() -> { try { setVisible(false); } catch (Throwable ignored) { } }); return; } + try { + paintBody(gx); + } catch (Throwable e) { + IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } finally { + // The repaint loop is entirely self-driven from here; it must survive any render + // exception or the window freezes on a stale frame permanently. + long sleepMs = eco ? 32 : 16; + J.a(() -> { + J.sleep(sleepMs); + repaint(); + }); + } + } + + private void paintBody(Graphics gx) { velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36; oxp = lerp(oxp, ox, 0.36); @@ -587,6 +618,16 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener } } + // Bounded, not pruned-to-frame: the low-quality cache is the pan placeholder, but + // unbounded growth while panning leaked one BufferedImage per visited tile forever. + if (fastpositions.size() > 4096) { + for (BlockPosition i : fastpositions.k()) { + if (!gg.contains(i)) { + fastpositions.remove(i); + } + } + } + handleFollow(); renderOverlays(g, p.getMilliseconds()); @@ -594,12 +635,6 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener return; } - long targetMs = eco ? 32 : 16; - long sleepMs = Math.max(1, targetMs - (long) p.getMilliseconds()); - J.a(() -> { - J.sleep(sleepMs); - repaint(); - }); } private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java index b7891ec8f..d43082720 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java @@ -768,7 +768,7 @@ public final class IrisWorldRemovalService { public CompletableFuture endMaintenance(ResolvedWorld resolvedWorld) { World world = resolvedWorld.loadedWorld(); if (world != null) { - IrisToolbelt.endWorldMaintenance(world, "world-remove"); + IrisToolbelt.endWorldMaintenance(world, "world-remove", true); } return CompletableFuture.completedFuture(null); } diff --git a/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java b/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java index a86966966..f9c3deee8 100644 --- a/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java @@ -3,14 +3,17 @@ package art.arcane.iris.core.link; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.volmlib.util.data.Cuboid; +import art.arcane.volmlib.util.scheduling.ChronoLatch; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; import java.lang.reflect.InvocationTargetException; +import java.util.function.BooleanSupplier; public class WorldEditLink { private static final AtomicCache active = new AtomicCache<>(); + private static final ChronoLatch errorThrottle = new ChronoLatch(60_000); public static Cuboid getSelection(Player p) { if (!hasWorldEdit()) @@ -42,16 +45,40 @@ public class WorldEditLink { (int) min.getClass().getDeclaredMethod("z").invoke(max) ); } catch (Throwable e) { - IrisLogging.error("Could not get selection"); - e.printStackTrace(); - IrisLogging.reportError(e); - active.reset(); - active.aquire(() -> false); + if (errorThrottle.flip()) { + IrisLogging.error("Could not get selection"); + e.printStackTrace(); + IrisLogging.reportError(e); + } + invalidate(); } return null; } public static boolean hasWorldEdit() { - return active.aquire(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit")); + return hasWorldEdit(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit")); + } + + static boolean hasWorldEdit(BooleanSupplier detector) { + if (Boolean.TRUE.equals(active.getIfPresent())) { + return true; + } + + boolean present; + try { + present = detector.getAsBoolean(); + } catch (Throwable ignored) { + return false; + } + + if (present) { + active.aquire(() -> Boolean.TRUE); + } + + return present; + } + + static void invalidate() { + active.reset(); } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index f7f0668d5..68a0283d0 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -43,6 +43,7 @@ import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntity; import art.arcane.iris.engine.object.IrisExpression; +import art.arcane.iris.engine.object.IrisObjectScale; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisImage; import art.arcane.iris.engine.object.IrisJigsawPiece; @@ -86,12 +87,20 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Data public class IrisData implements ExclusionStrategy, TypeAdapterFactory { private static final Map dataLoaders = new ConcurrentHashMap<>(); + // Loaders (cached or detached) that currently have registered engines; see hasActiveEngines. + // Identity-keyed on purpose: Lombok's @Data hashCode over this class's mutable loader state + // drifts while an engine runs, so a hashing set would silently fail removal and pin every + // engine-hosting IrisData (and its pack graph) for the JVM lifetime. + private static final Set ENGINE_HOLDERS = + Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); private final File dataFolder; private final int id; private final boolean datapackCompiler; @@ -262,7 +271,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } for (File i : PackDirectoryResolver.listVisiblePackDirectories( - IrisPlatforms.get().dataFolder("packs"))) { + IrisPlatforms.get().packsFolder())) { IrisData dm = get(i); if (dm == nearest) continue; T t = dm.load(type, key, false); @@ -338,6 +347,30 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } } + /** + * True when any loader — including detached {@link #openRuntime(File)} instances, which + * never enter the dataLoaders cache — has a live engine reading the given pack folder. + * The dataLoaders cache cannot answer this question: engines only ever attach to + * openRuntime instances. + */ + public static boolean hasActiveEngines(File dataFolder) { + Path target = dataFolder.toPath().toAbsolutePath().normalize(); + IrisData[] holders; + synchronized (ENGINE_HOLDERS) { + holders = ENGINE_HOLDERS.toArray(new IrisData[0]); + } + for (IrisData data : holders) { + if (data.getEngines().isEmpty()) { + ENGINE_HOLDERS.remove(data); + continue; + } + if (data.getDataFolder().toPath().toAbsolutePath().normalize().equals(target)) { + return true; + } + } + return false; + } + public void registerEngine(Engine engine) { Objects.requireNonNull(engine, "engine"); synchronized (engines) { @@ -348,6 +381,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } engines.add(engine); } + ENGINE_HOLDERS.add(this); } public void unregisterEngine(Engine engine) { @@ -359,9 +393,12 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { while (iterator.hasNext()) { if (iterator.next() == engine) { iterator.remove(); - return; + break; } } + if (engines.isEmpty()) { + ENGINE_HOLDERS.remove(this); + } } } @@ -383,6 +420,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { synchronized (engines) { engines.clear(); } + ENGINE_HOLDERS.remove(this); dataLoaders.remove(dataFolder, this); } @@ -425,6 +463,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public synchronized void hotloaded() { StructureGraphCatalog.invalidate(this); + IrisObjectScale.invalidate(); closed = false; possibleSnippets = new KMap<>(); builder = new GsonBuilder() @@ -490,6 +529,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public void dump() { StructureGraphCatalog.invalidate(this); + IrisObjectScale.invalidate(); for (ResourceLoader i : loaders.values()) { i.clearCache(); } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index 902c8859f..5a51acf80 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -63,8 +63,8 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Predicate; @@ -79,14 +79,8 @@ import java.util.zip.GZIPOutputStream; public class ResourceLoader implements MeteredCache { public static final AtomicDouble tlt = new AtomicDouble(0); private static final int CACHE_SIZE = 100000; - private static final ExecutorService schemaBuildExecutor = Executors.newSingleThreadExecutor(runnable -> { - Thread thread = new Thread(runnable, "Iris-Schema-Builder"); - thread.setDaemon(true); - thread.setPriority(Thread.MIN_PRIORITY); - return thread; - }); + private static volatile ExecutorService schemaBuildExecutor; private static final Set schemaBuildQueue = ConcurrentHashMap.newKeySet(); - private static final AtomicBoolean schemaBuildExecutorRegistered = new AtomicBoolean(); protected final AtomicCache> folderCache; protected volatile KSet firstAccess; protected File root; @@ -124,11 +118,32 @@ public class ResourceLoader implements MeteredCache { IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> created in " + C.RED + "IDM/" + manager.getId() + C.LIGHT_PURPLE + " on " + C.GRAY + manager.getDataFolder().getPath()); if (options.registerPreservation()) { IrisServices.get(PreservationRegistry.class).registerCache(this); + } + } + + /** + * Lazily (re)creates the schema-build pool. PreservationSVC shutdownNow()s every + * registered executor on plugin disable; a static final pool registered once was dead for + * the rest of the JVM after a hot reload, breaking every studio workspace refresh. Each + * created pool is registered with the CURRENT cycle's preservation registry. + */ + private static synchronized ExecutorService schemaBuildExecutor() { + ExecutorService current = schemaBuildExecutor; + if (current == null || current.isShutdown()) { + schemaBuildQueue.clear(); + current = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "Iris-Schema-Builder"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }); + schemaBuildExecutor = current; PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); - if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) { - preservation.register(schemaBuildExecutor); + if (preservation != null) { + preservation.register(current); } } + return current; } public JSONObject buildSchema() { @@ -145,15 +160,22 @@ public class ResourceLoader implements MeteredCache { File a = new File(getManager().getDataFolder(), ".iris/schema/" + getFolderName() + "-schema.json"); String schemaPath = a.getAbsolutePath(); if (schemaBuildQueue.add(schemaPath)) { - schemaBuildExecutor.execute(() -> { - try { - IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } finally { - schemaBuildQueue.remove(schemaPath); - } - }); + try { + schemaBuildExecutor().execute(() -> { + try { + IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4)); + } catch (Throwable e) { + IrisLogging.reportError(e); + } finally { + schemaBuildQueue.remove(schemaPath); + } + }); + } catch (RejectedExecutionException rejected) { + // Never let a dead pool leak the queue entry or escape into workspace + // generation (which would delete the user's .code-workspace on the way out). + schemaBuildQueue.remove(schemaPath); + IrisLogging.warn("Schema build skipped (executor unavailable): " + schemaPath); + } } return o; @@ -475,7 +497,9 @@ public class ResourceLoader implements MeteredCache { } KSet set = firstAccess; - if (set != null) set.add(name); + // contains-first: CHM.add takes the bin monitor even when the key is present, and + // every generation thread hammers the same few keys through this line. + if (set != null && !set.contains(name)) set.add(name); return loadCache.get(name); } diff --git a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java index 1811250d7..e8a86de88 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java @@ -148,7 +148,7 @@ public final class DirectorCommandMessages { ); public static final TextKey COMMAND_DEVELOPER_DIRECTOR_GENERATE_CHUNKS_INTO_BUFFERS_NO_WORLD_WRITES_HASH_BLOCKS_BIOMES_CAPTURES_GOLDEN = TextKey.of( "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden", - "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds." + "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Deletes the world's entire mantle - use on disposable test worlds." ); public static final TextKey COMMAND_DEVELOPER_PARAM_WORLD_SCAN = TextKey.of( "iris.director.commanddeveloper.param.world_scan", @@ -168,7 +168,7 @@ public final class DirectorCommandMessages { ); public static final TextKey COMMAND_DEVELOPER_PARAM_DELETE_MANTLE_DATA_SCAN_AREA_FIRST_FULL_REGENERATION_FROM_SCRATCH = TextKey.of( "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch", - "Delete mantle data in the scan area first for full regeneration from scratch" + "Delete the world's entire mantle folder first for full regeneration from scratch" ); public static final TextKey COMMAND_DEVELOPER_PARAM_CONCURRENT_CHUNK_GENERATIONS_1_STRICTLY_SERIAL_ORDER_DEPENDENCE_TESTING = TextKey.of( "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing", diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMS.java b/core/src/main/java/art/arcane/iris/core/nms/INMS.java index 9b5574081..15a627d02 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMS.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMS.java @@ -25,9 +25,37 @@ import org.bukkit.Bukkit; public class INMS { //@done - private static final INMSBinding binding = bind(); + private static final INMSBinding binding; + private static final Throwable bindFailure; + + // A throwing field initializer would leave this class permanently erroneous — every + // later touch raises NoClassDefFoundError and buries the real "unsupported Minecraft + // version" message. Capture the failure so callers can report the actual cause. + static { + INMSBinding bound = null; + Throwable failure = null; + try { + bound = bind(); + } catch (Throwable t) { + failure = t; + } + binding = bound; + bindFailure = failure; + } + + public static boolean isBound() { + return binding != null; + } + + public static Throwable bindFailure() { + return bindFailure; + } public static INMSBinding get() { + if (binding == null) { + throw new IllegalStateException("Iris NMS binding is unavailable: " + + (bindFailure == null ? "unknown failure" : bindFailure.getMessage()), bindFailure); + } return binding; } 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 8f6088e25..0f028833e 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 @@ -262,6 +262,13 @@ public interface INMSBinding { return true; } + /** + * Removes any instrumentation installed by {@link #injectBukkit()}. Must be idempotent; + * called on plugin disable and pre-unload so the transformer cannot outlive the plugin. + */ + default void uninjectBukkit() { + } + KMap> getBlockProperties(); private void validateDimensionTypes(WorldCreator c) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/IrisPack.java b/core/src/main/java/art/arcane/iris/core/pack/IrisPack.java index 1c1011d6f..875b33f69 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/IrisPack.java +++ b/core/src/main/java/art/arcane/iris/core/pack/IrisPack.java @@ -154,7 +154,7 @@ public class IrisPack { * @return the file path */ public static File packsPack(String name) { - return IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, name); + return IrisPlatforms.get().packsFolderNoCreate(name); } private static KList collectFiles(File f, String fileExtension) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/IrisPackRepository.java b/core/src/main/java/art/arcane/iris/core/pack/IrisPackRepository.java index bf305b66e..33298116c 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/IrisPackRepository.java +++ b/core/src/main/java/art/arcane/iris/core/pack/IrisPackRepository.java @@ -47,7 +47,7 @@ public class IrisPackRepository { private String repo = "overworld"; @Builder.Default - private String branch = "stable"; + private String branch = PackDownloader.DEFAULT_BRANCH; @Builder.Default private String tag = ""; @@ -96,7 +96,7 @@ public class IrisPackRepository { return IrisPackRepository.builder() .user("IrisDimensions") .repo(g) - .branch("stable") + .branch(PackDownloader.DEFAULT_BRANCH) .build(); } @@ -112,7 +112,7 @@ public class IrisPackRepository { } public void install(VolmitSender sender, Runnable whenComplete) throws MalformedURLException { - File pack = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, getRepo()); + File pack = IrisPlatforms.get().packsFolderNoCreate(getRepo()); if (!pack.exists()) { File dl = new File(IrisPlatforms.get().dataFolder("cache", "temp"), "dltk-" + UUID.randomUUID() + ".zip"); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java new file mode 100644 index 000000000..a13e249cc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java @@ -0,0 +1,87 @@ +/* + * 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 art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Validates biome layer stacks. caveCeilingLayers reuses the height generators built from layers, + * so a biome with more ceiling entries than surface entries has no generator for the extras; the + * engine skips them, and this validator surfaces the mistake to the author at validate time. + */ +final class PackBiomeLayerValidator { + /** Both layers and caveCeilingLayers default to a single entry when absent (IrisBiome field initializers). */ + private static final int DEFAULT_LAYER_COUNT = 1; + + private PackBiomeLayerValidator() { + } + + static List validateCeilingLayerCounts(File biomesFolder) { + List blockingErrors = new ArrayList<>(); + if (biomesFolder == null || !biomesFolder.isDirectory()) { + return blockingErrors; + } + + List biomeFiles = PackValidationIo.listJsonRecursive(biomesFolder); + biomeFiles.sort(Comparator.comparing(File::getPath)); + for (File biomeFile : biomeFiles) { + String biomeKey = PackValidationIo.deriveKey(biomesFolder, biomeFile); + JSONObject biome; + try { + biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + // Invalid JSON is reported by the graph validators; layer counts have nothing to add. + continue; + } + + Integer layers = arrayLength(biome, "layers", biomeKey, blockingErrors); + Integer ceiling = arrayLength(biome, "caveCeilingLayers", biomeKey, blockingErrors); + if (layers == null || ceiling == null) { + continue; + } + + if (ceiling > layers) { + blockingErrors.add("Biome '" + biomeKey + "' declares " + ceiling + " caveCeilingLayers but only " + + layers + " layers. caveCeilingLayers reuses the layers height generators and must not have more entries."); + } + } + return blockingErrors; + } + + private static Integer arrayLength(JSONObject biome, String field, String biomeKey, List blockingErrors) { + if (!biome.has(field) || biome.isNull(field)) { + return DEFAULT_LAYER_COUNT; + } + + JSONArray array = biome.optJSONArray(field); + if (array == null) { + blockingErrors.add("Biome '" + biomeKey + "' " + field + " must be an array."); + return null; + } + return array.length(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java index 5d4b4fa22..6066363b3 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.engine.object.IrisDimensionType; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; @@ -45,6 +46,7 @@ final class PackDimensionValidator { } validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); + validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -226,4 +228,84 @@ final class PackDimensionValidator { } return resolved; } + + /** + * Mirrors the IrisDimensionType constructor checks so a pack that would throw at world creation + * fails validation instead. Defaults must match the POJOs exactly: dimensionHeight absent means + * the IrisDimension field initializer (-64..320); present-but-partial means the IrisRange field + * initializers (min 16, max 32); logicalHeight absent means 256. + */ + static void validateDimensionHeights(File packFolder, String dimensionKey, JSONObject dimJson, List blockingErrors) { + JSONObject range = resolveDimensionHeight(packFolder, dimJson); + if (range == null && dimJson.has("dimensionHeight") && !dimJson.isNull("dimensionHeight")) { + if (!(dimJson.opt("dimensionHeight") instanceof String)) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a range snippet reference."); + } + // Unresolvable snippet references are reported by the content-key machinery. + return; + } + + int minY; + int maxY; + if (range == null) { + minY = -64; + maxY = 320; + } else { + minY = (int) range.optDouble("min", 16D); + maxY = (int) range.optDouble("max", 32D); + } + int height = maxY - minY; + int logicalHeight = dimJson.optInt("logicalHeight", 256); + + if (height < IrisDimensionType.MIN_HEIGHT || height > IrisDimensionType.MAX_HEIGHT) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height + + "; it must be between " + IrisDimensionType.MIN_HEIGHT + " and " + IrisDimensionType.MAX_HEIGHT + "."); + } else if ((height & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height + + "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + "."); + } + if (minY < IrisDimensionType.MIN_MIN_Y || minY > IrisDimensionType.MAX_MIN_Y) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY + + "; it must be between " + IrisDimensionType.MIN_MIN_Y + " and " + IrisDimensionType.MAX_MIN_Y + "."); + } else if ((minY & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY + + "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + "."); + } + if (logicalHeight < 0) { + blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight + "; it cannot be negative."); + } else if (logicalHeight > height) { + blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight + + "; it cannot be greater than the dimension height of " + height + "."); + } + } + + private static JSONObject resolveDimensionHeight(File packFolder, JSONObject dimJson) { + if (!dimJson.has("dimensionHeight") || dimJson.isNull("dimensionHeight")) { + return null; + } + + JSONObject inline = dimJson.optJSONObject("dimensionHeight"); + if (inline != null) { + return inline; + } + + String reference = dimJson.optString("dimensionHeight", null); + if (reference == null || !reference.startsWith("snippet/")) { + return null; + } + // Mirror IrisData's snippet adapter: canonical snippet/range/... is used verbatim; any other + // snippet/... reference is re-rooted under this field's snippet folder. + if (!reference.startsWith("snippet/range/")) { + reference = "snippet/range/" + reference.substring("snippet/".length()); + } + File snippet = new File(packFolder, reference + ".json"); + if (!snippet.isFile()) { + return null; + } + try { + return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + return null; + } + } } 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 8d532f4b0..73485d022 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 @@ -55,6 +55,12 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; public final class PackDownloader { + /** + * The branch every download path falls back to when the caller did not name one. Compile-time + * constant so Director {@code @Param(defaultValue = ...)} annotations can reference it. + */ + public static final String DEFAULT_BRANCH = "stable"; + private static final String HEAD_REF = "HEAD"; private static final String DEFAULT_OVERWORLD_PACK = "overworld"; private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld"; private static final String DEFAULT_OVERWORLD_REF = "master"; @@ -234,6 +240,13 @@ public final class PackDownloader { String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes()); + if ((zip == null || !zip.exists()) && !directUrl && DEFAULT_BRANCH.equals(ref)) { + // A repo without the shared default branch still resolves via its HEAD; one retry, never recursive. + String headUrl = resolveGithubArchiveUrl(repo, HEAD_REF); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", headUrl)) + " "); + zip = WebCache.getNonCachedFile("pack-" + repo, headUrl, ARCHIVE_LIMITS.maxArchiveBytes()); + url = headUrl; + } File temp = WebCache.getTemp(); File work = new File(temp, "dl-" + UUID.randomUUID()); @@ -436,17 +449,21 @@ public final class PackDownloader { + " (required primary dimension is missing)."); } - Optional loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key())); - if (loadedData.isEmpty()) { - loadedData = IrisData.getLoaded(target.toFile()); - } - if (loadedData.isPresent()) { + // A registered loader alone is not "active": startup validation registers a loader for + // every visible pack (permanently), which made force-updating any installed pack + // impossible. Live engines only ever attach to detached openRuntime loaders, so the + // gate must go through the engine index, not the cached loader's own engine list. + // Stale cached registrations are closed so the swap cannot race a loader holding the + // old tree. + if (IrisData.hasActiveEngines(target.toFile())) { sendFeedback( feedback, "Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying." ); return null; } + IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close); + IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close); try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { publication.commit(); try { @@ -471,7 +488,7 @@ public final class PackDownloader { Set roots = new LinkedHashSet<>(); roots.add(packsRoot); if (IrisPlatforms.isBound()) { - roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize()); + roots.add(IrisPlatforms.get().packsFolder().toPath().toAbsolutePath().normalize()); } for (Path root : roots) { if (!Files.isDirectory(root)) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java new file mode 100644 index 000000000..e4ea58f3e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java @@ -0,0 +1,72 @@ +/* + * 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 art.arcane.iris.engine.object.IrisObjectMarker; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.volmlib.util.collection.KSet; + +/** + * Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the + * modded verbatim-copy compiler must export the complete ambient-spawning graph: object placements + * carry markers, markers carry spawners, spawners carry entities, entities carry loot. These + * helpers keep the two packagers walking placements identically. + */ +public final class PackExportClosure { + private PackExportClosure() { + } + + public static KSet collectMarkerKeys(Iterable placements) { + KSet markerKeys = new KSet<>(); + if (placements == null) { + return markerKeys; + } + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getMarkers() == null) { + continue; + } + for (IrisObjectMarker marker : placement.getMarkers()) { + if (marker == null || marker.getMarker() == null || marker.getMarker().isBlank()) { + continue; + } + markerKeys.add(marker.getMarker()); + } + } + return markerKeys; + } + + public static KSet collectObjectKeys(Iterable placements) { + KSet objectKeys = new KSet<>(); + if (placements == null) { + return objectKeys; + } + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getPlace() == null) { + continue; + } + for (String key : placement.getPlace()) { + if (key == null || key.isBlank()) { + continue; + } + objectKeys.add(key); + } + } + return objectKeys; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidator.java new file mode 100644 index 000000000..0e18fe1d8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidator.java @@ -0,0 +1,165 @@ +/* + * 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 art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Warns when two generator files with identical content are both referenced by biomes. IrisGenerator's + * equals ignores the load key and IrisComplex buckets generators into a HashSet, so only one of a + * content-identical pair survives; biomes linked to the losing key resolve 0/0 height bounds and + * contribute a flat band at fluid height. + * + *

The fingerprint is a conservative approximation of IrisGenerator.equals built from raw JSON + * (sorted keys, canonical number formatting). It can miss a collision where one file spells out a + * value the other leaves defaulted, and it ignores fields the POJO does not declare - acceptable + * for a warning aimed at the copy-the-file-and-forget-the-seed mistake. + */ +final class PackGeneratorDuplicateValidator { + private static final String GENERATOR_SNIPPET_FOLDER = "snippet/generator-layer/"; + + private PackGeneratorDuplicateValidator() { + } + + static List validateDuplicateGenerators(File packFolder) { + List warnings = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return warnings; + } + File generatorsFolder = new File(packFolder, "generators"); + if (!generatorsFolder.isDirectory()) { + return warnings; + } + + Set referenced = collectReferencedGeneratorKeys(packFolder); + Map> byFingerprint = new TreeMap<>(); + List generatorFiles = PackValidationIo.listJsonRecursive(generatorsFolder); + generatorFiles.sort(Comparator.comparing(File::getPath)); + for (File generatorFile : generatorFiles) { + String key = PackValidationIo.deriveKey(generatorsFolder, generatorFile); + String fingerprint; + try { + fingerprint = fingerprint(new JSONObject(Files.readString(generatorFile.toPath(), StandardCharsets.UTF_8))); + } catch (Throwable e) { + continue; + } + byFingerprint.computeIfAbsent(fingerprint, ignored -> new ArrayList<>()).add(key); + } + + for (List group : byFingerprint.values()) { + List referencedKeys = group.stream().filter(referenced::contains).sorted().toList(); + if (referencedKeys.size() < 2) { + continue; + } + warnings.add("Generators " + String.join(", ", referencedKeys) + + " have identical content and are both referenced by biomes. Iris buckets generators by value" + + " (IrisGenerator equals ignores the load key), so only one survives and biomes referencing the" + + " others get a zero height band. Give each generator a distinct value (for example a different" + + " seed) or point every biome at a single key."); + } + return warnings; + } + + private static Set collectReferencedGeneratorKeys(File packFolder) { + Set referenced = new HashSet<>(); + File biomesFolder = new File(packFolder, "biomes"); + if (!biomesFolder.isDirectory()) { + return referenced; + } + for (File biomeFile : PackValidationIo.listJsonRecursive(biomesFolder)) { + JSONObject biome; + try { + biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + continue; + } + JSONArray links = biome.optJSONArray("generators"); + if (links == null) { + continue; + } + for (int i = 0; i < links.length(); i++) { + JSONObject link = links.optJSONObject(i); + if (link == null) { + String reference = links.optString(i, null); + link = resolveGeneratorLayerSnippet(packFolder, reference); + if (link == null) { + continue; + } + } + referenced.add(link.optString("generator", "default")); + } + } + return referenced; + } + + private static JSONObject resolveGeneratorLayerSnippet(File packFolder, String reference) { + if (reference == null || !reference.startsWith("snippet/")) { + return null; + } + String resolved = reference.startsWith(GENERATOR_SNIPPET_FOLDER) + ? reference + : GENERATOR_SNIPPET_FOLDER + reference.substring("snippet/".length()); + File snippet = new File(packFolder, resolved + ".json"); + if (!snippet.isFile()) { + return null; + } + try { + return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + return null; + } + } + + private static String fingerprint(Object value) { + if (value instanceof JSONObject object) { + Map sorted = new TreeMap<>(); + for (String key : object.keySet()) { + sorted.put(key, fingerprint(object.get(key))); + } + StringBuilder builder = new StringBuilder("{"); + sorted.forEach((key, child) -> builder.append(key).append('=').append(child).append(';')); + return builder.append('}').toString(); + } + if (value instanceof JSONArray array) { + StringBuilder builder = new StringBuilder("["); + for (int i = 0; i < array.length(); i++) { + builder.append(fingerprint(array.get(i))).append(';'); + } + return builder.append(']').toString(); + } + if (value instanceof Number number) { + // Gson deserializes 1 and 1.0 into the same field value; fingerprint them alike. + return Double.toString(number.doubleValue()); + } + return String.valueOf(value); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackLootValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackLootValidator.java index d324a1d75..102eca458 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackLootValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackLootValidator.java @@ -34,10 +34,18 @@ final class PackLootValidator { private PackLootValidator() { } - static List validateLootGraph(File packFolder) { + record LootGraphIssues(List errors, List warnings) { + LootGraphIssues { + errors = List.copyOf(errors); + warnings = List.copyOf(warnings); + } + } + + static LootGraphIssues validateLootGraph(File packFolder) { List blockingErrors = new ArrayList<>(); + List warnings = new ArrayList<>(); if (packFolder == null || !packFolder.isDirectory()) { - return blockingErrors; + return new LootGraphIssues(blockingErrors, warnings); } File lootFolder = new File(packFolder, PackValidator.LOOT_FOLDER); @@ -69,10 +77,10 @@ final class PackLootValidator { } String resourceType = PackStructurePlacementValidator.structureHostType(folderName); String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile); - validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors); + validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors, warnings); } } - return blockingErrors; + return new LootGraphIssues(blockingErrors, warnings); } private static void validateLootTable(String lootKey, JSONObject table, List blockingErrors) { @@ -158,17 +166,20 @@ final class PackLootValidator { } private static void validateLootReference(String resourceType, String resourceKey, Object rawLoot, - Set lootKeys, List blockingErrors) { + Set lootKeys, List blockingErrors, List warnings) { String path = resourceType + " '" + resourceKey + "'.loot"; if (!(rawLoot instanceof JSONObject reference)) { blockingErrors.add(path + " must be an object."); return; } + boolean clearMode = false; if (reference.has("mode")) { Object rawMode = reference.opt("mode"); if (!(rawMode instanceof String mode) || !Set.of("ADD", "CLEAR", "REPLACE", "FALLBACK").contains(mode)) { blockingErrors.add(path + ".mode must be ADD, CLEAR, REPLACE, or FALLBACK."); + } else { + clearMode = "CLEAR".equals(mode); } } if (reference.has("multiplier")) { @@ -189,6 +200,10 @@ final class PackLootValidator { blockingErrors.add(path + ".tables must be an array."); return; } + if (clearMode && tables.length() > 0) { + warnings.add(path + " uses mode CLEAR and lists " + tables.length() + + " table(s); CLEAR clears parent tables and contributes no tables of its own, so these entries are dead. Use REPLACE to substitute them."); + } for (int tableIndex = 0; tableIndex < tables.length(); tableIndex++) { Object rawTableKey = tables.opt(tableIndex); if (!(rawTableKey instanceof String tableKey) || tableKey.isBlank()) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidator.java new file mode 100644 index 000000000..bbfa1abfb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidator.java @@ -0,0 +1,154 @@ +/* + * 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 art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Catches the shared IrisStyledRange class default (min 16, max 32) leaking into pack content. + * The POJO cannot distinguish an empty object from an explicit 16/32 after Gson runs, so the raw + * JSON is the only place this mistake is still visible: an objects[].densityStyle of {} places + * 16-32 objects per chunk, and a caveProfile.densityThreshold of {} hollows the whole cave range. + */ +final class PackStyledRangeDefaultValidator { + private static final String STYLE_RANGE_SNIPPET_FOLDER = "snippet/style-range/"; + + record Validation(List errors, List warnings) { + Validation { + errors = List.copyOf(errors); + warnings = List.copyOf(warnings); + } + } + + private PackStyledRangeDefaultValidator() { + } + + static Validation validate(File packFolder) { + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return new Validation(errors, warnings); + } + + for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = PackValidationIo.listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + String type = hostType(folderName); + for (File resourceFile : resourceFiles) { + String key = PackValidationIo.deriveKey(resourceFolder, resourceFile); + JSONObject json; + try { + json = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable e) { + continue; + } + + validateObjectPlacements(packFolder, type, key, json, errors, warnings); + validateCaveProfileThreshold(packFolder, type, key, json.optJSONObject("caveProfile"), errors, warnings); + } + } + return new Validation(errors, warnings); + } + + private static void validateObjectPlacements(File packFolder, String type, String key, JSONObject json, + List errors, List warnings) { + JSONArray objects = json.optJSONArray("objects"); + if (objects == null) { + return; + } + for (int i = 0; i < objects.length(); i++) { + JSONObject placement = objects.optJSONObject(i); + if (placement == null || !placement.has("densityStyle") || placement.isNull("densityStyle")) { + continue; + } + checkRange(packFolder, placement.opt("densityStyle"), + type + " '" + key + "' objects[" + i + "].densityStyle", + "16-32 objects per chunk", "remove densityStyle to use the scalar density field", + errors, warnings); + } + } + + private static void validateCaveProfileThreshold(File packFolder, String type, String key, JSONObject caveProfile, + List errors, List warnings) { + if (caveProfile == null || !caveProfile.has("densityThreshold") || caveProfile.isNull("densityThreshold")) { + return; + } + checkRange(packFolder, caveProfile.opt("densityThreshold"), + type + " '" + key + "' caveProfile.densityThreshold", + "a carve threshold of 16-32, hollowing the entire vertical range", + "remove densityThreshold to keep the cave profile default", + errors, warnings); + } + + private static void checkRange(File packFolder, Object raw, String path, String consequence, String removal, + List errors, List warnings) { + JSONObject range = null; + String via = ""; + if (raw instanceof JSONObject inline) { + range = inline; + } else if (raw instanceof String reference && reference.startsWith("snippet/")) { + String resolved = reference.startsWith(STYLE_RANGE_SNIPPET_FOLDER) + ? reference + : STYLE_RANGE_SNIPPET_FOLDER + reference.substring("snippet/".length()); + File snippet = new File(packFolder, resolved + ".json"); + if (!snippet.isFile()) { + return; // Missing snippets are reported by the content-key machinery. + } + try { + range = new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8)); + via = " (via snippet '" + resolved + "')"; + } catch (Throwable e) { + return; + } + } + if (range == null) { + return; + } + + boolean hasMin = range.has("min") && !range.isNull("min"); + boolean hasMax = range.has("max") && !range.isNull("max"); + if (!hasMin && !hasMax) { + errors.add(path + via + " omits both min and max, which resolves to the shared IrisStyledRange default of " + + consequence + ". Set min and max explicitly, or " + removal + "."); + } else if (!hasMin || !hasMax) { + String missing = hasMin ? "max" : "min"; + String defaultValue = hasMin ? "32" : "16"; + warnings.add(path + via + " omits " + missing + ", which falls back to the shared IrisStyledRange default of " + + defaultValue + ". Set it explicitly if that is not intended."); + } + } + + private static String hostType(String folderName) { + String singular = folderName.endsWith("s") ? folderName.substring(0, folderName.length() - 1) : folderName; + return singular.substring(0, 1).toUpperCase(Locale.ROOT) + singular.substring(1); + } +} 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 7073dafd8..b89c0d6a2 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 @@ -77,7 +77,9 @@ public final class PackValidator { PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder)); - blockingErrors.addAll(PackLootValidator.validateLootGraph(packFolder)); + PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder); + addDistinct(blockingErrors, lootIssues.errors()); + addDistinct(warnings, lootIssues.warnings()); blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder)); @@ -96,6 +98,11 @@ public final class PackValidator { new File(packFolder, "spawners"), new File(packFolder, "entities"))); blockingErrors.addAll(PackSpawnValidator.validateCustomBiomeSpawns( new File(packFolder, "biomes"), PackSpawnValidator::resolveEntitySpawnCategory)); + blockingErrors.addAll(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(packFolder, "biomes"))); + PackStyledRangeDefaultValidator.Validation styledRanges = PackStyledRangeDefaultValidator.validate(packFolder); + addDistinct(blockingErrors, styledRanges.errors()); + addDistinct(warnings, styledRanges.warnings()); + addDistinct(warnings, PackGeneratorDuplicateValidator.validateDuplicateGenerators(packFolder)); // Strict content mode promotes unresolved keys and bad block properties from advisory to blocking. Palette // -sourced findings are exempt and stay warnings - see ContentKeyValidator.collectContentKeyIssues. 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 6f22d05e1..5fcbbdea5 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 @@ -199,14 +199,17 @@ public class IrisPregenerator { } public void start() { - init(); - task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet()); - startTime.set(M.ms()); - ticker.start(); - checkRegions(); PrecisionStopwatch p = PrecisionStopwatch.start(); boolean completed = false; + // Everything runs inside the try: an early throw from init/checkRegions must still + // reach shutdown(), or the ticker/monitor threads leak and listener.onClose never + // fires — leaving a phantom job that suppresses spawns and blocks future pregens. try { + init(); + task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet()); + startTime.set(M.ms()); + ticker.start(); + checkRegions(); int[] regionBounds = task.regionBounds(); generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]); task.iterateRegions((x, z) -> visitRegion(x, z, true)); @@ -371,10 +374,13 @@ public class IrisPregenerator { generator.generateChunk(xx, zz, listener); }); - generator.onRegionSubmitted(x, z); } if (hit) { + // Exactly once per visited region, on BOTH branches: a cache-completed region that + // never releases its sentinel wedges neighbor eviction for the whole resume + // frontier (allNeighborsDrained can never pass around it). + generator.onRegionSubmitted(x, z); listener.onRegionGenerated(x, z); if (saveLatch.flip()) { @@ -391,7 +397,9 @@ public class IrisPregenerator { } generatedRegions.add(pos); - checkRegions(); + // No checkRegions() here: re-spiraling every region after every completed region + // was O(regions^2) dead work on the submitter thread (result discarded, and the + // only side effect was CachedPregenMethod faulting plates against eviction). } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 2ad172667..7995826e8 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -57,7 +57,11 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; public class AsyncPregenMethod implements PregeneratorMethod { + // THREAD_COUNT records the pool's original size while boosted; BOOST_HOLDERS counts live + // jobs holding the boost. Pairing them per instance stops a dying job (whose close can + // land minutes after its replacement started) from shrinking the pool under the live job. private static final AtomicInteger THREAD_COUNT = new AtomicInteger(); + private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger(); private static final int ADAPTIVE_TIMEOUT_STEP = 3; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L; @@ -102,6 +106,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final AtomicLong failed = new AtomicLong(); private final AtomicLong lastProgressAt = new AtomicLong(M.ms()); private final AtomicBoolean closing = new AtomicBoolean(); + private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean(); private final Object permitMonitor = new Object(); private volatile Engine metricsEngine; private volatile Mantle cachedMantle; @@ -738,8 +743,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { + ", recommendedCap=" + recommendedRuntimeConcurrencyCap + ", urgent=" + urgent + ", timeout=" + timeoutSeconds + "s"); - if (workerPoolThreads > 0) { - increaseWorkerThreads(); + if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) { + acquireWorkerThreadBoost(); } } @@ -775,7 +780,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { flushAllRemainingChunks(); executor.shutdown(); - resetWorkerThreads(); + if (holdsWorkerBoost.compareAndSet(true, false)) { + releaseWorkerThreadBoost(); + } } finally { if (interrupted) { Thread.currentThread().interrupt(); @@ -945,7 +952,21 @@ public class AsyncPregenMethod implements PregeneratorMethod { return null; } - public static void increaseWorkerThreads() { + public static void acquireWorkerThreadBoost() { + if (BOOST_HOLDERS.getAndIncrement() > 0) { + return; + } + increaseWorkerThreads(); + } + + public static void releaseWorkerThreadBoost() { + if (BOOST_HOLDERS.decrementAndGet() > 0) { + return; + } + resetWorkerThreads(); + } + + private static void increaseWorkerThreads() { THREAD_COUNT.updateAndGet(i -> { if (i > 0) { return i; @@ -974,7 +995,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { }); } - public static void resetWorkerThreads() { + private static void resetWorkerThreads() { THREAD_COUNT.updateAndGet(i -> { if (i == 0) { return 0; diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java index 048930171..b2560d3d1 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java @@ -207,18 +207,28 @@ public class MedievalPregenMethod implements PregeneratorMethod { } listener.onChunkGenerating(x, z); - if (J.isFolia()) { - futures.add(PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> { - if (c != null) { - lastUse.put(c, M.ms()); - } - listener.onChunkGenerated(x, z); - listener.onChunkCleaned(x, z); - })); - return; - } - - futures.add(scheduleChunkLoad(x, z, listener)); + // Single choke point for failure accounting: without onChunkFailed a lossy run can + // never satisfy allVisitsComplete, so a finished pregen reports as aborted forever. + CompletableFuture chunkFuture = J.isFolia() + ? PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> { + if (c != null) { + lastUse.put(c, M.ms()); + } + listener.onChunkGenerated(x, z); + try { + listener.onChunkCleaned(x, z); + } catch (Throwable e) { + // Already counted as generated; a throw here must not also count the + // chunk failed through the whenComplete choke point below. + IrisLogging.reportError(e); + } + }) + : scheduleChunkLoad(x, z, listener); + futures.add(chunkFuture.whenComplete((r, err) -> { + if (err != null) { + listener.onChunkFailed(x, z); + } + })); } private CompletableFuture scheduleChunkLoad(int x, int z, PregenListener listener) { @@ -294,12 +304,15 @@ public class MedievalPregenMethod implements PregeneratorMethod { IrisLogging.reportError(error); } - try { - generateChunkSync(x, z, listener).get(CHUNK_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - future.complete(null); - } catch (Throwable fallbackError) { - future.completeExceptionally(fallbackError); - } + // Chain instead of blocking: parking this MultiBurst worker on a 60s get() + // pinned a shared pool thread per failing chunk. + generateChunkSync(x, z, listener).whenComplete((r, fallbackError) -> { + if (fallbackError != null) { + future.completeExceptionally(fallbackError); + } else { + future.complete(null); + } + }); } }); @@ -314,7 +327,13 @@ public class MedievalPregenMethod implements PregeneratorMethod { Chunk chunk = world.getChunkAt(x, z); lastUse.put(chunk, M.ms()); listener.onChunkGenerated(x, z); - listener.onChunkCleaned(x, z); + try { + listener.onChunkCleaned(x, z); + } catch (Throwable e) { + // The chunk already counted as generated; a throw here must not also count it + // as failed through the whenComplete choke point. + IrisLogging.reportError(e); + } } @Override diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java index 7ce539ad4..ae1d8a426 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java @@ -131,16 +131,28 @@ public class IrisCodeWorkspace { project.getPath().mkdirs(); File ws = getCodeWorkspaceFile(); + // Render before touching the file: a config-generation failure has nothing to do with + // the existing workspace file, and deleting it before a rethrowing rebuild silently + // destroyed the author's workspace on every boot. + String rendered; try { - writeIfChanged(ws, createCodeWorkspaceConfig().toString(4)); + rendered = createCodeWorkspaceConfig().toString(4); + } catch (Throwable e) { + IrisLogging.reportError(e); + IrisLogging.warn("Could not generate the code workspace config for " + ws.getAbsolutePath() + "; leaving the existing workspace file untouched."); + return false; + } + + try { + writeIfChanged(ws, rendered); return true; } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.warn("Project invalid: " + ws.getAbsolutePath() + " Re-creating. You may loose some vs-code workspace settings! But not your actual project!"); ws.delete(); try { - IO.writeAll(ws, createCodeWorkspaceConfig()); - } catch (IOException e1) { + IO.writeAll(ws, rendered); + } catch (Throwable e1) { IrisLogging.reportError(e1); e1.printStackTrace(); } diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java index 9f48f1963..2011305f6 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java @@ -29,6 +29,8 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntity; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.core.pack.PackExportClosure; +import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; @@ -49,6 +51,7 @@ import org.zeroturnaround.zip.ZipUtil; import java.io.File; import java.io.IOException; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; @@ -62,8 +65,25 @@ public class IrisPackageCompiler { } public File compilePackage(VolmitSender sender, boolean obfuscate, boolean minify) { + // Detached loader on purpose: obfuscation rewrites the placement 'place' lists on the + // loaded resources, which must never leak into the shared cached pack state — a second + // export against a mutated cache produced archives with an empty objects/ directory. + IrisData dm = IrisData.openRuntime(project.getPath()); + try { + return compilePackage(dm, sender, obfuscate, minify); + } finally { + dm.close(); + } + } + + private static void addIfPresent(KSet set, T value) { + if (value != null) { + set.add(value); + } + } + + private File compilePackage(IrisData dm, VolmitSender sender, boolean obfuscate, boolean minify) { String dimm = project.getName(); - IrisData dm = IrisData.get(project.getPath()); IrisDimension dimension = dm.getDimensionLoader().load(dimm); File folder = new File(IrisPlatforms.get().dataFolder(), "exports/" + dimension.getLoadKey()); IO.delete(folder); @@ -82,20 +102,35 @@ public class IrisPackageCompiler { KSet loot = new KSet<>(); KSet blocks = new KSet<>(); + // KSet is ConcurrentHashMap-backed: both add(null) and remove(null) throw NPE, so a + // failed loader lookup must be filtered at the add site, never stripped afterwards. for (String i : dm.getBlockLoader().getPossibleKeys()) { - blocks.add(dm.getBlockLoader().load(i)); + addIfPresent(blocks, dm.getBlockLoader().load(i)); } - dimension.getRegions().forEach((i) -> regions.add(dm.getRegionLoader().load(i))); - dimension.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))); + dimension.getRegions().forEach((i) -> addIfPresent(regions, dm.getRegionLoader().load(i))); + dimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))); regions.forEach((i) -> biomes.addAll(i.getAllBiomes(() -> dm))); - regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i)))); - regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp)))); - dimension.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))); - 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)))); - collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i))); + regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); + regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)))); + dimension.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))); + biomes.forEach((i) -> i.getGenerators().forEach((j) -> addIfPresent(generators, j.getCachedGenerator(() -> dm)))); + biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); + biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)))); + KList allPlacements = new KList<>(); + regions.forEach((r) -> allPlacements.addAll(r.getObjects())); + biomes.forEach((i) -> allPlacements.addAll(i.getObjects())); + KSet markers = new KSet<>(); + for (String markerKey : PackExportClosure.collectMarkerKeys(allPlacements)) { + IrisMarker marker = dm.getMarkerLoader().load(markerKey); + if (marker == null) { + continue; + } + markers.add(marker); + marker.getSpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))); + } + collectSpawnerEntityKeys(spawners).forEach((i) -> addIfPresent(entities, dm.getEntityLoader().load(i))); + entities.forEach((e) -> e.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); Set structureKeys = new LinkedHashSet<>(); collectStructureKeys(structureKeys, dimension.getStructures()); regions.forEach((region) -> collectStructureKeys(structureKeys, region.getStructures())); @@ -106,25 +141,23 @@ public class IrisPackageCompiler { StringBuilder c = new StringBuilder(); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_SERIALIZING_OBJECTS)); - for (IrisBiome i : biomes) { - for (IrisObjectPlacement j : i.getObjects()) { - b.append(j.hashCode()); - KList newNames = new KList<>(); + for (IrisObjectPlacement j : allPlacements) { + b.append(j.hashCode()); + KList newNames = new KList<>(); - for (String k : j.getPlace()) { - if (renameObjects.containsKey(k)) { - newNames.add(renameObjects.get(k)); - continue; - } - - String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", ""); - b.append(name); - newNames.add(name); - renameObjects.put(k, name); + for (String k : j.getPlace()) { + if (renameObjects.containsKey(k)) { + newNames.add(renameObjects.get(k)); + continue; } - j.setPlace(newNames); + String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", ""); + b.append(name); + newNames.add(name); + renameObjects.put(k, name); } + + j.setPlace(newNames); } KMap> lookupObjects = renameObjects.flip(); @@ -132,10 +165,18 @@ public class IrisPackageCompiler { ChronoLatch cl = new ChronoLatch(1000); O ggg = new O<>(); ggg.set(0); - biomes.forEach((i) -> i.getObjects().forEach((j) -> j.getPlace().forEach((k) -> + O missingObjects = new O<>(); + missingObjects.set(0); + allPlacements.forEach((j) -> j.getPlace().forEach((k) -> { try { - File f = dm.getObjectLoader().findFile(lookupObjects.get(k).get(0)); + KList sources = lookupObjects.get(k); + File f = sources == null || sources.isEmpty() ? null : dm.getObjectLoader().findFile(sources.get(0)); + if (f == null) { + missingObjects.set(missingObjects.get() + 1); + IrisLogging.error("Missing object for placement key " + k); + return; + } IO.copyFile(f, new File(folder, "objects/" + k + ".iob")); gb.append(IO.hash(f)); ggg.set(ggg.get() + 1); @@ -146,9 +187,15 @@ public class IrisPackageCompiler { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_WROTE_ANOTHER_OBJECTS, MessageArgument.untrusted("g", String.valueOf(g)))); } } catch (Throwable e) { + missingObjects.set(missingObjects.get() + 1); IrisLogging.reportError(e); } - }))); + })); + if (missingObjects.get() > 0) { + // A package missing objects must fail loudly, not ship as a clean "compiled". + throw new IllegalStateException(missingObjects.get() + + " object(s) could not be exported for pack '" + dimm + "'; package compile aborted."); + } b.append(IO.hash(gb.toString())); c.append(IO.hash(b.toString())); @@ -205,6 +252,19 @@ public class IrisPackageCompiler { b.append(IO.hash(a)); } + // Sorted so package.json.hash stays stable across runs. + for (IrisSpawner i : spawners.stream().sorted(Comparator.comparing(IrisSpawner::getLoadKey)).toList()) { + a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4); + IO.writeAll(new File(folder, "spawners/" + i.getLoadKey() + ".json"), a); + b.append(IO.hash(a)); + } + + for (IrisMarker i : markers.stream().sorted(Comparator.comparing(IrisMarker::getLoadKey)).toList()) { + a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4); + IO.writeAll(new File(folder, "markers/" + i.getLoadKey() + ".json"), a); + b.append(IO.hash(a)); + } + c.append(IO.hash(b.toString())); String finalHash = IO.hash(c.toString()); JSONObject meta = new JSONObject(); diff --git a/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java index 5a8588ffa..d540de662 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java @@ -149,7 +149,10 @@ public final class GoldenHashEngine { if (request.resetMantle()) { progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_RESETTING_MANTLE)); - resetMantleFull(); + if (!resetMantleFull()) { + // A partial reset silently invalidates the capture; never scan over it. + return false; + } } List targets = ChunkSpiral.centerOut(request.centerChunkX(), request.centerChunkZ(), radius); @@ -184,29 +187,24 @@ public final class GoldenHashEngine { } } - private void resetMantleFull() { + private boolean resetMantleFull() { + Mantle mantle = engine.getMantle().getMantle(); try { - Mantle mantle = engine.getMantle().getMantle(); - mantle.saveAll(); - File folder = mantle.getDataFolder(); - File[] files = folder.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isFile()) { - file.delete(); - } - } - } + // resetStorage invalidates the IOWorker channel cache before unlinking; deleting + // behind cached channels sent every post-reset plate write to an unlinked inode. + mantle.resetStorage(); feedback.ok(IrisLanguage.plain( RuntimeProgressMessages.GOLDEN_MANTLE_RESET, - MessageArgument.untrusted("path", folder.getAbsolutePath()) + MessageArgument.untrusted("path", mantle.getDataFolder().getAbsolutePath()) )); + return true; } catch (Throwable e) { IrisLogging.reportError(e); feedback.warn(IrisLanguage.plain( RuntimeProgressMessages.GOLDEN_MANTLE_RESET_FAILED, MessageArgument.untrusted("type", e.getClass().getSimpleName()) )); + return false; } } @@ -345,11 +343,18 @@ public final class GoldenHashEngine { List body = orderedBody(lines); List mismatches = new ArrayList<>(); + // Display strings and diagnosis input are different shapes: diagnose() parses a bare + // " " key, and feeding it a localized sentence aborted the diagnosis whenever + // the FIRST mismatch was a chunk missing from the golden capture. + String firstMismatchKey = null; for (String line : body) { int second = line.indexOf(' ', line.indexOf(' ') + 1); String key = line.substring(0, second); String golden = goldenChunks.get(key); if (!line.equals(golden)) { + if (firstMismatchKey == null) { + firstMismatchKey = key; + } mismatches.add(golden == null ? IrisLanguage.plain( RuntimeProgressMessages.GOLDEN_MISSING_IN_GOLDEN, @@ -400,7 +405,7 @@ public final class GoldenHashEngine { IrisLogging.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath()); progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_DIAGNOSING)); - diagnose(mismatches.getFirst()); + diagnose(firstMismatchKey); return false; } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java index f44e99532..bc0ebabb1 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java @@ -44,6 +44,8 @@ import org.bukkit.generator.ChunkGenerator.ChunkData; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; public final class InPlaceChunkRegenerator { private static final int BIOME_STEP = 4; @@ -111,36 +113,41 @@ public final class InPlaceChunkRegenerator { inFlight.acquire(); MultiBurst.burst.lazy(() -> { - TerrainChunk buffer = TerrainChunk.create(world); - try { - engine.generate(chunkX << 4, chunkZ << 4, buffer, false); - } catch (Throwable e) { - IrisLogging.reportError(e); - reporter.countApplied(false); - inFlight.release(); - allApplied.countDown(); - return; - } - - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - boolean ok = false; - try { - applyToLiveChunk(chunkX, chunkZ, buffer); - ok = true; - } catch (Throwable e) { - IrisLogging.reportError(e); - } finally { + // Settle exactly once no matter which path fails: an unguarded throw here + // (TerrainChunk.create, runRegion) parked the regen thread on the latch + // forever and stranded the player's boss bar. CAS is required — on Folia's + // owned-region path runRegion runs the apply inline before returning. + AtomicBoolean settled = new AtomicBoolean(); + Consumer settle = ok -> { + if (settled.compareAndSet(false, true)) { reporter.countApplied(ok); inFlight.release(); allApplied.countDown(); } - }); + }; + try { + TerrainChunk buffer = TerrainChunk.create(world); + engine.generate(chunkX << 4, chunkZ << 4, buffer, false); - if (!scheduled) { - IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); - reporter.countApplied(false); - inFlight.release(); - allApplied.countDown(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + boolean ok = false; + try { + applyToLiveChunk(chunkX, chunkZ, buffer); + ok = true; + } catch (Throwable e) { + IrisLogging.reportError(e); + } finally { + settle.accept(ok); + } + }); + + if (!scheduled) { + IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); + settle.accept(false); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + settle.accept(false); } }); } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index b6820b9e5..24c092136 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -609,7 +609,7 @@ public final class StudioOpenCoordinator { }); return operation.whenComplete((result, throwable) -> { if (world != null) { - IrisToolbelt.endWorldMaintenance(world, "studio-close"); + IrisToolbelt.endWorldMaintenance(world, "studio-close", true); } }); } diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java index 55f27129d..52600e294 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java @@ -6,7 +6,6 @@ import art.arcane.iris.core.safeguard.task.Tasks; import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.format.C; -import art.arcane.iris.util.common.scheduling.J; import java.util.ArrayList; import java.util.Collections; @@ -15,7 +14,6 @@ import java.util.List; import java.util.Map; public final class IrisSafeguard { - private static volatile boolean forceShutdown = false; private static Map> results = Collections.emptyMap(); private static Map context = Collections.emptyMap(); private static Map> attachment = Collections.emptyMap(); @@ -100,10 +98,6 @@ public final class IrisSafeguard { } } - public static boolean isForceShutdown() { - return forceShutdown; - } - private static void warning() { IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode"); IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions."); @@ -115,9 +109,9 @@ public final class IrisSafeguard { IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode"); IrisLogging.error(""); IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--"); - IrisLogging.error("Critical startup checks failed. Iris will continue startup in 10 seconds."); - IrisLogging.error("Review and resolve the errors above as soon as possible."); - J.sleep(10000L); + IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible."); + // No startup sleep: blocking the boot thread protected nothing — world creation and + // player admission are already gated by IrisStartupValidation. IrisLogging.info(""); } } diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java index 78d1f5217..1e45cb74a 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java @@ -7,6 +7,7 @@ import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.v1X.NMSBinding1X; import art.arcane.iris.core.safeguard.Mode; +import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.common.misc.getHardware; import art.arcane.iris.util.project.agent.Agent; @@ -99,13 +100,21 @@ public final class Tasks { supportedVersions = BuildConstants.MINECRAFT_VERSION; } + if (!INMS.isBound()) { + String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage(); + return withDiagnostics(Mode.UNSTABLE, + Diagnostic.Logger.ERROR.create("Server Version"), + Diagnostic.Logger.ERROR.create("- " + cause), + Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions)); + } + if (!(INMS.get() instanceof NMSBinding1X)) { return withDiagnostics(Mode.STABLE); } return withDiagnostics(Mode.UNSTABLE, - Diagnostic.Logger.ERROR.create("Server Version"), - Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions)); + Diagnostic.Logger.ERROR.create("NMS Disabled"), + Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable.")); }); private static final Task INJECTION = Task.of("injection", () -> { @@ -149,7 +158,13 @@ public final class Tasks { }); private static final Task JAVA = Task.of("java", () -> { - int version = javaVersion(); + int version = IrisSplashComposer.javaVersion(); + if (version < 0) { + return withDiagnostics(Mode.WARNING, + Diagnostic.Logger.WARN.create("Java Runtime"), + Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version=" + + System.getProperty("java.version") + ").")); + } if (version == 25) { return withDiagnostics(Mode.STABLE); } @@ -211,16 +226,4 @@ public final class Tasks { return new ValueWithDiagnostics<>(mode, diagnostics); } - private static int javaVersion() { - String version = System.getProperty("java.version"); - if (version.startsWith("1.")) { - version = version.substring(2, 3); - } else { - int dot = version.indexOf("."); - if (dot != -1) { - version = version.substring(0, dot); - } - } - return Integer.parseInt(version); - } } diff --git a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java index 352140b9c..45b2aaab0 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java @@ -67,7 +67,8 @@ public class ExternalDataSVC implements IrisService { @Override public void onEnable() { IrisLogging.info("Loading ExternalDataProvider..."); - Bukkit.getPluginManager().registerEvents(this, BukkitPlatform.plugin()); + // enable() registers every enabled service as a listener; self-registration here + // doubled every handler invocation. for (ProviderDefinition definition : BUILT_IN_PROVIDERS) { activateConfiguredProvider(definition); diff --git a/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java b/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java index a7bf663ce..352da58f2 100644 --- a/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java @@ -1,6 +1,5 @@ package art.arcane.iris.core.service; -import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.plugin.IrisService; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; @@ -10,17 +9,26 @@ import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; import org.apache.logging.log4j.message.Message; +import java.util.List; + public class LogFilterSVC implements IrisService, Filter { private static final String HEIGHTMAP_MISMATCH = "Ignoring heightmap data for chunk"; private static final String RAID_PERSISTENCE = "Could not save data net.minecraft.world.entity.raid.PersistentRaid"; private static final String DUPLICATE_ENTITY_UUID = "UUID of added entity already exists"; - private static final KList FILTERS = new KList<>(); + // Immutable: check() runs on arbitrary logger threads, and the root logger is JVM-global, + // so a mutable static here was both a CME hazard and grew by three entries per enable. + private static final List FILTERS = List.of(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID); + + private boolean installed = false; public void onEnable() { - FILTERS.add(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID); + if (installed) { + return; + } ((Logger) LogManager.getRootLogger()).addFilter(this); + installed = true; } public void initialize() { @@ -33,6 +41,11 @@ public class LogFilterSVC implements IrisService, Filter { } public void onDisable() { + if (!installed) { + return; + } + ((Logger) LogManager.getRootLogger()).get().removeFilter(this); + installed = false; } public boolean isStarted() { @@ -116,8 +129,14 @@ public class LogFilterSVC implements IrisService, Filter { } private Result check(String string) { - if (FILTERS.stream().anyMatch(string::contains)) - return Result.DENY; + if (string == null) { + return Result.NEUTRAL; + } + for (String filter : FILTERS) { + if (string.contains(filter)) { + return Result.DENY; + } + } return Result.NEUTRAL; } } diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java b/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java index 2bc3b7897..e52870b87 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java @@ -24,15 +24,17 @@ import lombok.Getter; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; -import java.util.ArrayDeque; import java.util.Deque; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.Iterator; import java.util.Map; public class ObjectSVC implements IrisService { @Getter - private final Deque> undos = new ArrayDeque<>(); + // Concurrent + global-thread pipeline: pastes publish via J.runGlobal while the undo + // command arrives on an async pool thread; a plain ArrayDeque was mutated from both. + private final Deque> undos = new ConcurrentLinkedDeque<>(); @Override @@ -50,7 +52,9 @@ public class ObjectSVC implements IrisService { } public void revertChanges(int amount) { - loopChange(amount); + if (!J.runGlobal(() -> loopChange(amount))) { + loopChange(amount); + } } private void loopChange(int amount) { @@ -68,22 +72,26 @@ public class ObjectSVC implements IrisService { * @param blocks The blocks to remove */ private void revert(Map blocks) { - Iterator> it = blocks.entrySet().iterator(); + if (blocks == null || blocks.isEmpty()) { + return; + } + J.s(() -> { + Iterator> it = blocks.entrySet().iterator(); int amount = 0; while (it.hasNext()) { Map.Entry entry = it.next(); - BlockData data = entry.getValue(); - entry.getKey().setBlockData(data, false); - + entry.getKey().setBlockData(entry.getValue(), false); it.remove(); - amount++; - - if (amount > 200) { - J.s(() -> revert(blocks), 1); + if (++amount >= 200) { + break; } } + + if (!blocks.isEmpty()) { + J.s(() -> revert(blocks), 1); + } }); } } 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 f130511e0..587f6a7c6 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 @@ -219,7 +219,10 @@ public class StudioSVC implements IrisService { validatePublishedPack(target); IrisData installedData; - boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty(); + // Live engines only ever attach to detached openRuntime loaders, never to the + // dataLoaders-cached previousData — so the old previousData.getEngines() test was + // always empty and a running world's pack could be swapped with no restart. + boolean activeRuntime = IrisData.hasActiveEngines(target.toFile()); if (previousData == null) { createdData = IrisData.get(target.toFile()); installedData = createdData; @@ -780,7 +783,7 @@ public class StudioSVC implements IrisService { return generator.closeAsync().thenApply(ignored -> true); }) .whenComplete((unloaded, throwable) -> { - IrisToolbelt.endWorldMaintenance(world, "studio-disable"); + IrisToolbelt.endWorldMaintenance(world, "studio-disable", true); if (throwable != null) { IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + "\" during disable cleanup; startup deletion remains queued.", throwable); @@ -790,7 +793,7 @@ public class StudioSVC implements IrisService { } }); } catch (Throwable e) { - IrisToolbelt.endWorldMaintenance(world, "studio-disable"); + IrisToolbelt.endWorldMaintenance(world, "studio-disable", true); IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e); } } diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index 41dbbf577..c4095c2dc 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -64,7 +64,10 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; public class TreeSVC implements IrisService { - private boolean block = false; + // Re-entrancy guard for the synthetic StructureGrowEvent this service fires from inside + // its own handler. callEvent dispatches inline on the calling thread, so a ThreadLocal + // is exact; a shared boolean let one region's growth suppress another's on Folia. + private static final ThreadLocal REENTRANT = ThreadLocal.withInitial(() -> false); @Override public void onEnable() { @@ -88,7 +91,7 @@ public class TreeSVC implements IrisService { */ @EventHandler(priority = EventPriority.HIGHEST) public void on(StructureGrowEvent event) { - if (block || event.isCancelled()) { + if (REENTRANT.get() || event.isCancelled()) { return; } IrisLogging.debug(this.getClass().getName() + " received a structure grow event"); @@ -161,11 +164,13 @@ public class TreeSVC implements IrisService { public void set(int x, int y, int z, PlatformBlockState s) { BlockData d = (BlockData) s.nativeHandle(); Block b = event.getWorld().getBlockAt(x, y, z); + // Listeners get the post-growth tree, per the StructureGrowEvent contract; + // a fresh snapshot here handed every plugin an all-air "tree". BlockState state = b.getState(); if (d instanceof IrisCustomData data) state.setBlockData(data.getBase()); else state.setBlockData(d); - blockStateList.add(b.getState()); + blockStateList.add(state); dataCache.put(new Location(event.getWorld(), x, y, z), d); } @@ -245,9 +250,12 @@ public class TreeSVC implements IrisService { Runnable growTask = () -> { StructureGrowEvent iGrow = new StructureGrowEvent(event.getLocation(), event.getSpecies(), event.isFromBonemeal(), event.getPlayer(), blockStateList); - block = true; - Bukkit.getServer().getPluginManager().callEvent(iGrow); - block = false; + REENTRANT.set(true); + try { + Bukkit.getServer().getPluginManager().callEvent(iGrow); + } finally { + REENTRANT.set(false); + } if (!iGrow.isCancelled()) { for (BlockState state : iGrow.getBlocks()) { @@ -338,6 +346,11 @@ public class TreeSVC implements IrisService { public Cuboid getSaplings(Location at, Predicate valid, World world) { KList blockPositions = new KList<>(); grow(at.getWorld(), new BlockPosition(at.getBlockX(), at.getBlockY(), at.getBlockZ()), valid, blockPositions); + if (blockPositions.isEmpty()) { + // No matching saplings (e.g. a giant-mushroom grow event): a 1x1 plane at the + // event location, never the MIN/MAX sentinel cuboid below. + return new Cuboid(at, at); + } BlockPosition a = new BlockPosition(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE); BlockPosition b = new BlockPosition(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); diff --git a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java index b90891afa..930c68c5b 100644 --- a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java +++ b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java @@ -24,7 +24,7 @@ public final class IrisSplashComposer { prefix + style.label(" By: ") + style.value("Volmit Software (Arcane Arts)"), prefix + style.label(" Web: ") + style.value("VolmitSoftware.com"), prefix + style.label(" Server: ") + style.value(serverLine), - prefix + style.label(" Java: ") + style.value(String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()), + prefix + style.label(" Java: ") + style.value(javaVersion() < 0 ? "unknown" : String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()), prefix + style.label(" Commit: ") + style.value(BuildConstants.COMMIT) + style.label("/") + style.value(BuildConstants.ENVIRONMENT), "", "", @@ -54,17 +54,22 @@ public final class IrisSplashComposer { return lines; } + /** + * Total parser: java.version values like "25-ea" or "26-internal" have no dot but a + * non-numeric suffix, and a cosmetic banner must never be able to abort startup. + * + * @return the feature version, or -1 when it cannot be determined + */ public static int javaVersion() { - String version = System.getProperty("java.version"); + String version = System.getProperty("java.version", ""); if (version.startsWith("1.")) { - version = version.substring(2, 3); - } else { - int dot = version.indexOf('.'); - if (dot != -1) { - version = version.substring(0, dot); - } + version = version.length() > 2 ? version.substring(2, 3) : ""; } - return Integer.parseInt(version); + int end = 0; + while (end < version.length() && Character.isDigit(version.charAt(end))) { + end++; + } + return end == 0 ? -1 : Integer.parseInt(version, 0, end, 10); } public static String releaseTrain(String version) { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java b/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java index 20cd56588..87b174079 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java @@ -90,6 +90,9 @@ public class IrisConverter { HudSlotClaim barClaim = reportProgress ? BukkitPlatform.hudSlots().open(sender.player(), new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR))) : null; + // try/finally over the whole decode: a throw must never leak the + // self-rescheduling progress task or the HUD claims. + try { if (mv > 2_000_000) { largeObject = true; IrisLogging.info(C.GRAY + "Converting.. " + schem.getName() + " -> " + schem.getName().replace(".schem", ".iob")); @@ -149,13 +152,15 @@ public class IrisConverter { } } - if (i != -1) J.car(i); - if (titleClaim != null) { - titleClaim.release(); - } - if (barClaim != null) { - barClaim.release(); - BukkitPlatform.hudLanes().hide(sender.player(), "iris:job"); + } finally { + if (i != -1) J.car(i); + if (titleClaim != null) { + titleClaim.release(); + } + if (barClaim != null) { + barClaim.release(); + BukkitPlatform.hudLanes().hide(sender.player(), "iris:job"); + } } try { object.shrinkwrap(); 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 ac153882d..988511c19 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 @@ -207,6 +207,7 @@ public class IrisCreator { World world = null; boolean bukkitRegistered = false; + PlatformChunkGenerator stagedGenerator = null; try { reportStudioProgress(0.08D, "resolve_dimension"); reportStudioProgress(0.16D, "prepare_world_pack"); @@ -252,6 +253,7 @@ public class IrisCreator { if (access == null) { throw new IrisException("Access is null. Something bad happened."); } + stagedGenerator = access; HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer() ? openLoaderClaim("iris:world-create") : null; @@ -332,7 +334,7 @@ public class IrisCreator { } return world; } catch (Throwable failure) { - rollbackWorldCreation(worldKey, world, dimensionRoot, bukkitRegistered, failure); + rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure); if (failure instanceof IrisException irisException) { throw irisException; } @@ -655,12 +657,23 @@ public class IrisCreator { private void rollbackWorldCreation( NamespacedKey worldKey, World createdWorld, + PlatformChunkGenerator stagedGenerator, File dimensionRoot, boolean bukkitRegistered, Throwable failure ) { World activeWorld = createdWorld == null ? WorldIdentity.resolve(worldKey).orElse(null) : createdWorld; boolean safeToDelete = activeWorld != null || !containsTimeout(failure); + if (activeWorld == null && stagedGenerator != null) { + // The world never materialized, so no unload path will ever close the staged + // generator; without this it stays registered on WorldInitEvent and attaches a + // second engine when a same-name world is created later. + try { + stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(unwrapFailure(rollbackFailure)); + } + } if (activeWorld != null) { IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); try { @@ -682,6 +695,11 @@ public class IrisCreator { if (safeToDelete && generator != null) { generator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } + // A staged generator the world never bound (e.g. the creation failed after a + // retry re-staged a fresh one) has no unload path either. + if (stagedGenerator != null && stagedGenerator != generator) { + stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } } catch (Throwable rollbackFailure) { Throwable cause = unwrapFailure(rollbackFailure); failure.addSuppressed(cause); @@ -690,7 +708,7 @@ public class IrisCreator { ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\"."); } } finally { - IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback"); + IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true); } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisReflectiveAPI.java b/core/src/main/java/art/arcane/iris/core/tools/IrisReflectiveAPI.java index 3072fc9f6..b01742a80 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisReflectiveAPI.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisReflectiveAPI.java @@ -27,15 +27,18 @@ public class IrisReflectiveAPI { WorldMaintenance.retainMantleDataForSlice(classname); } + // These delegate to IrisToolbelt so the caller's WORLD Y is rebased into mantle space + // (0..worldHeight). Raw pass-through silently no-opped below Y=0 and read the wrong + // cell everywhere else, diverging from IrisToolbelt and IrisModdedAPI semantics. public static void setMantleData(World world, int x, int y, int z, Object data) { - IrisToolbelt.access(world).getEngine().getMantle().getMantle().set(x, y, z, data); + IrisToolbelt.setMantleData(world, x, y, z, data); } public static void deleteMantleData(World world, int x, int y, int z, Class c) { - IrisToolbelt.access(world).getEngine().getMantle().getMantle().remove(x, y, z, c); + IrisToolbelt.deleteMantleData(world, x, y, z, c); } public static Object getMantleData(World world, int x, int y, int z, Class c) { - return IrisToolbelt.access(world).getEngine().getMantle().getMantle().get(x, y, z, c); + return IrisToolbelt.getMantleData(world, x, y, z, c); } } 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 767c04e1b..31be88ab1 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 @@ -90,7 +90,7 @@ public class IrisToolbelt { return null; } - File packsFolder = IrisPlatforms.get().dataFolder("packs"); + File packsFolder = IrisPlatforms.get().packsFolder(); File pack = PackDirectoryResolver.resolveExisting(packsFolder, reference.pack()); if (pack == null) { File found = findCaseInsensitivePack(packsFolder, reference.pack()); @@ -302,6 +302,13 @@ public class IrisToolbelt { * @return the pregenerator job (already started) */ public static PregeneratorJob pregenerate(PregenTask task, PregeneratorMethod method, Engine engine, boolean cached) { + // Match the modded adapter's contract: a running job is a rejection, never a silent + // kill whose teardown (60s drain + 120s flush) overlaps the new job's generation. + // Callers that genuinely want replacement must stop the old job first + // (PregeneratorJob.shutdownAndWait). + if (PregeneratorJob.getInstance() != null) { + throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop."); + } applyPregenPerformanceProfile(engine); boolean useCachedWrapper = false; if (cached && engine != null) { @@ -588,17 +595,25 @@ public class IrisToolbelt { } public static void endWorldMaintenance(World world, String reason) { + endWorldMaintenance(world, reason, false); + } + + public static void endWorldMaintenance(World world, String reason, boolean bypassMantleStages) { if (world == null) { return; } - endWorldMaintenance(WorldIdentity.serialize(world), reason); + endWorldMaintenance(WorldIdentity.serialize(world), reason, bypassMantleStages); } public static void endWorldMaintenance(String worldName, String reason) { WorldMaintenance.endWorldMaintenance(worldName, reason); } + public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) { + WorldMaintenance.endWorldMaintenance(worldName, reason, bypassMantleStages); + } + public static boolean isWorldMaintenanceActive(World world) { return world != null && isWorldMaintenanceActive(WorldIdentity.serialize(world)); } @@ -639,6 +654,14 @@ public class IrisToolbelt { e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of); } + public static void setMantleData(World world, int x, int y, int z, T data) { + PlatformChunkGenerator e = access(world); + if (e == null || data == null) { + return; + } + e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data); + } + public static boolean removeWorld(World world) throws IOException { return IrisCreator.removeFromBukkitYml(IrisWorldStorage.logicalName(world)); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java b/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java index 3e4960535..0c12908bb 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java +++ b/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java @@ -1,17 +1,16 @@ package art.arcane.iris.core.tools; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.mantle.MantleSliceRetention; import art.arcane.iris.spi.IrisLogging; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; public final class WorldMaintenance { private static final Map worldMaintenanceDepth = new ConcurrentHashMap<>(); private static final Map worldMaintenanceMantleBypassDepth = new ConcurrentHashMap<>(); - private static final Set retainedMantleSlices = ConcurrentHashMap.newKeySet(); private WorldMaintenance() { } @@ -37,6 +36,10 @@ public final class WorldMaintenance { } public static void endWorldMaintenance(String worldName, String reason) { + endWorldMaintenance(worldName, reason, false); + } + + public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) { if (worldName == null) { return; } @@ -52,13 +55,17 @@ public final class WorldMaintenance { depth = 0; } - AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName); + // Only a bypass-begin's paired end releases bypass credit: a plain end overlapping a + // bypassing operation stole its credit and re-enabled mantle stages under it. int bypassDepth = 0; - if (bypassCounter != null) { - bypassDepth = bypassCounter.decrementAndGet(); - if (bypassDepth <= 0) { - worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter); - bypassDepth = 0; + if (bypassMantleStages) { + AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName); + if (bypassCounter != null) { + bypassDepth = bypassCounter.decrementAndGet(); + if (bypassDepth <= 0) { + worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter); + bypassDepth = 0; + } } } @@ -88,14 +95,10 @@ public final class WorldMaintenance { } public static void retainMantleDataForSlice(String className) { - if (className == null) { - return; - } - - retainedMantleSlices.add(className); + MantleSliceRetention.retain(className); } public static boolean isRetainingMantleDataForSlice(String className) { - return className != null && retainedMantleSlices.contains(className); + return MantleSliceRetention.isRetained(className); } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java b/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java index c32496c9e..951d0354f 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java @@ -18,10 +18,14 @@ package art.arcane.iris.engine; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.scheduling.J; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -46,8 +50,10 @@ final class EngineBackgroundTasks { boolean scheduleTrackedTask(Runnable task) { synchronized (backgroundTaskLock) { - backgroundTasks.removeIf(tracked -> tracked.completion.isDone() - && !tracked.completion.isCompletedExceptionally()); + // A finished task is not outstanding work, however it finished. Retaining failed + // entries made the NEXT transition's drain re-report a long-settled failure and + // blocked close() from ever marking the engine closed. + backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); if (!backgroundTaskAdmission) { return false; } @@ -60,6 +66,10 @@ final class EngineBackgroundTasks { return null; } catch (Throwable exception) { tracked.completion.completeExceptionally(exception); + // J.a(Callable) routes through a future nobody reads; report here or the + // failure is invisible. + IrisLogging.reportError(exception); + IrisLogging.error("Iris background task failed."); throw propagate(exception); } }); @@ -79,6 +89,15 @@ final class EngineBackgroundTasks { } long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS); Throwable failure = null; + // Settled-set snapshot taken ONCE at drain entry: a per-iteration isDone() sample + // would also suppress tasks that failed while the drain was blocked on an earlier + // task, letting a real in-flight failure pass the transition unreported. + Set settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>()); + for (TrackedBackgroundTask task : tasks) { + if (task.completion.isDone()) { + settledAtEntry.add(task); + } + } for (TrackedBackgroundTask task : tasks) { long remaining = deadline - System.nanoTime(); if (remaining <= 0L) { @@ -86,6 +105,7 @@ final class EngineBackgroundTasks { failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + ".")); continue; } + boolean alreadyDone = settledAtEntry.contains(task); try { task.completion.get(remaining, TimeUnit.NANOSECONDS); } catch (InterruptedException e) { @@ -94,7 +114,13 @@ final class EngineBackgroundTasks { failure = appendFailure(failure, e); } catch (ExecutionException | TimeoutException e) { cancelBackgroundTask(task, reason); - failure = appendFailure(failure, e); + // A task that had settled before this drain began was already reported when it + // failed; only genuinely in-flight failures may poison this transition. + if (!alreadyDone) { + failure = appendFailure(failure, e); + } else { + IrisLogging.debug("Ignoring pre-settled background task failure during " + reason + "."); + } } } boolean complete; diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index a8af8456f..64393913f 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -33,6 +33,8 @@ import art.arcane.iris.spi.IrisServices; * having succeeded so a partially released engine never reports itself as closed. */ final class EngineShutdownSequence { + private static final long CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS = 5000L; + private final IrisEngine engine; private boolean runtimeReleased; private boolean targetReleased; @@ -55,11 +57,50 @@ final class EngineShutdownSequence { engine.getClosing().set(true); engine.backgroundTasks.closeBackgroundTaskAdmission(); EngineTickRegistry.unregisterTicking(engine); - engine.getPlatformHooks().shutdownPregenerator(engine); + // Best-effort like every other step: a pregen join timeout must not escape the + // synchronized block before anything is saved or released. On failure, save what + // can be saved and leave the close incomplete-but-retryable (a live pregen writer + // may still hold the mantle, so the releases are skipped). + Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine)); + if (pregenFailure != null) { + engine.lifecycleState = LifecycleState.FAILED; + failure = pregenFailure; + failure = runCleanup(failure, engine::savePrefetchOnce); + failure = runCleanup(failure, engine::saveEngineData); + failure = runCleanup(failure, () -> engine.getMantle().saveAllNow()); + reportIncompleteClose(failure); + return; + } + Throwable drainFailure = null; try { engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); } catch (GenerationSessionException e) { - throw new IllegalStateException("Failed to drain Iris generation for close.", e); + drainFailure = e; + } + if (drainFailure != null) { + // A drain timeout must not abandon teardown: the world manager is the lease + // producer, so stop it first, then re-drain briefly. + IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name() + + "; stopping the world manager and retrying."); + Throwable managerFailure = engine.runtime == null + ? null + : runCleanup(null, engine.runtime.worldManager()::close); + try { + engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true); + drainFailure = null; + } catch (GenerationSessionException e) { + drainFailure = appendFailure(drainFailure, e); + } + drainFailure = appendFailure(drainFailure, managerFailure); + } + if (drainFailure != null) { + // A live lease may be mid-write, so the mantle must not be closed at it — but + // dirty plates can still be flushed (saveAll is synchronized) so terrain since + // the last periodic save is not lost. The close stays incomplete and retryable. + engine.lifecycleState = LifecycleState.FAILED; + failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow()); + reportIncompleteClose(failure); + return; } BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close"); @@ -108,13 +149,17 @@ final class EngineShutdownSequence { } } if (failure != null) { - IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + "."); - IrisLogging.reportError(failure); - failure.printStackTrace(); - throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure); + reportIncompleteClose(failure); } } + private void reportIncompleteClose(Throwable failure) { + IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + "."); + IrisLogging.reportError(failure); + failure.printStackTrace(); + throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure); + } + void cleanupFailedConstruction(Throwable original) { engine.getClosing().set(true); engine.backgroundTasks.closeBackgroundTaskAdmission(); 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 bef584071..89dbd0156 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -118,7 +118,11 @@ public class IrisComplex implements DataProvider { private IrisRegion focusRegion; private Map> generatorBounds; private Set generatorBiomes; - private final Map childSelectionPlans = Collections.synchronizedMap(new IdentityHashMap<>()); + // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap + // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map + // per insert is cheap. Identity keying is load-bearing (IrisBiome is mutable/value-hashed). + private volatile IdentityHashMap childSelectionPlans = new IdentityHashMap<>(); + private final Object childSelectionPlanLock = new Object(); public IrisComplex(Engine engine) { this(engine, false); @@ -169,7 +173,7 @@ public class IrisComplex implements DataProvider { generatorBounds = buildGeneratorBounds(engine); KList overlayNoise = engine.getDimension().getOverlayNoise(); overlayStream = overlayNoise.isEmpty() - ? ProceduralStream.ofDouble((x, z) -> 0.0D).waste("Overlay Stream") + ? ProceduralStream.ofDouble((x, z) -> 0.0D) : ProceduralStream.ofDouble((x, z) -> { double value = 0D; @@ -178,22 +182,22 @@ public class IrisComplex implements DataProvider { } return value; - }).waste("Overlay Stream"); + }); rockStream = engine.getDimension().getRockPalette().getLayerGenerator(rng.nextParallelRNG(45), data).stream() - .select(engine.getDimension().getRockPalette().getBlockData(data)).waste("Rock Stream"); + .select(engine.getDimension().getRockPalette().getBlockData(data)); fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream() - .select(engine.getDimension().getFluidPalette().getBlockData(data)).waste("Fluid Stream"); + .select(engine.getDimension().getFluidPalette().getBlockData(data)); regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() - .zoom(engine.getDimension().getRegionZoom()).waste("Region Style"); - regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE).waste("Region Identity Stream"); + .zoom(engine.getDimension().getRegionZoom()); + regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); regionStream = focusRegion != null ? ProceduralStream.of((x, z) -> focusRegion, Interpolated.of(a -> 0D, a -> focusRegion)) : regionStyleStream .selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions())) - .cache2D("regionStream", engine, cacheSize).waste("Region Stream"); + .cache2D("regionStream", engine, cacheSize); regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), - String.valueOf(i * 38445).hashCode() * 3245556666L)).waste("Region ID Stream"); + String.valueOf(i * 38445).hashCode() * 3245556666L)); caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) .convert((r) -> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream() @@ -201,7 +205,7 @@ public class IrisComplex implements DataProvider { .zoom(r.getCaveBiomeZoom()) .selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE)) .onNull(emptyBiome) - ).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize).waste("Cave Biome Stream"); + ).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.CAVE, caveBiomeStream); landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) .convert((r) @@ -211,7 +215,7 @@ public class IrisComplex implements DataProvider { .zoom(r.getLandBiomeZoom()) .selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND)) ).convertAware2D(ProceduralStream::get) - .cache2D("landBiomeStream", engine, cacheSize).waste("Land Biome Stream"); + .cache2D("landBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.LAND, landBiomeStream); seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) .convert((r) @@ -221,7 +225,7 @@ public class IrisComplex implements DataProvider { .zoom(r.getSeaBiomeZoom()) .selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA)) ).convertAware2D(ProceduralStream::get) - .cache2D("seaBiomeStream", engine, cacheSize).waste("Sea Biome Stream"); + .cache2D("seaBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.SEA, seaBiomeStream); shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) .convert((r) @@ -229,60 +233,60 @@ public class IrisComplex implements DataProvider { .zoom(engine.getDimension().getBiomeZoom()) .zoom(r.getShoreBiomeZoom()) .selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE)) - ).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize).waste("Shore Biome Stream"); + ).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.SHORE, shoreBiomeStream); bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) : engine.getDimension().getContinentalStyle().create(rng.nextParallelRNG(234234565), getData()) .bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream() .convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND) - .cache2D("bridgeStream", engine, cacheSize).waste("Bridge Stream"); + .cache2D("bridgeStream", engine, cacheSize); baseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome, Interpolated.of(a -> 0D, a -> focusBiome)) : bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode) - .cache2D("baseBiomeStream", engine, cacheSize).waste("Base Biome Stream"); + .cache2D("baseBiomeStream", engine, cacheSize); heightStream = ProceduralStream.of((x, z) -> { IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z); return getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); - }, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize).waste("Height Stream"); + }, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize); roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) - .round().waste("Rounded Height Stream"); + .round(); slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) - .slope(3).cache2DDouble("slopeStream", engine, cacheSize).waste("Slope Stream"); + .slope(3).cache2DDouble("slopeStream", engine, cacheSize); trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream .convertAware2D((h, x, z) -> fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.contextInjecting(engine, (c, xx, zz) -> c.getRegion().get(xx, zz)).get(x, z), x, z, fluidHeight)) - .cache2D("trueBiomeStream", engine, cacheSize).waste("True Biome Stream"); + .cache2D("trueBiomeStream", engine, cacheSize); trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize).waste("True Biome Derivative Stream"); + .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize); heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) - .max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize).waste("Height Fluid Stream"); - maxHeightStream = ProceduralStream.ofDouble((x, z) -> height).waste("Max Height Stream"); + .max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize); + maxHeightStream = ProceduralStream.ofDouble((x, z) -> height); terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize).waste("Surface Decoration Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize); terrainCeilingDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize).waste("Ceiling Decoration Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize); terrainCaveSurfaceDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize).waste("Cave Surface Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize); terrainCaveCeilingDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize).waste("Cave Ceiling Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize); shoreSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize).waste("Shore Surface Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize); seaSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize).waste("Sea Surface Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize); seaFloorDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) - .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize).waste("Sea Floor Stream"); + .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize); baseBiomeIDStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) .convertAware2D((b, x, z) -> { UUID d = regionIDStream.get(x, z); return new UUID(b.getLoadKey().hashCode() * 818223L, d.hashCode()); }) - .cache2D("", engine, cacheSize).waste("Biome ID Stream"); + .cache2D("", engine, cacheSize); //@done } @@ -598,7 +602,7 @@ public class IrisComplex implements DataProvider { return cachedPlan; } - synchronized (childSelectionPlans) { + synchronized (childSelectionPlanLock) { ChildSelectionPlan synchronizedPlan = childSelectionPlans.get(biome); if (synchronizedPlan != null) { return synchronizedPlan; @@ -614,7 +618,9 @@ public class IrisComplex implements DataProvider { options.add(biome); ChildSelectionPlan createdPlan = ChildSelectionPlan.create(options); - childSelectionPlans.put(biome, createdPlan); + IdentityHashMap next = new IdentityHashMap<>(childSelectionPlans); + next.put(biome, createdPlan); + childSelectionPlans = next; return createdPlan; } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index cd56aea7d..58690dae0 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -389,12 +389,21 @@ public class IrisEngineMantle implements EngineMantle { return worker.read(name, (regionName, in) -> TectonicPlate.read(worldHeight, in, regionName.startsWith("pv."), adapter, hooks)); } finally { - if (TectonicPlate.hasError() && IrisSettings.get().getGeneral().isDumpMantleOnError()) { - File dump = IrisPlatforms.get().dataFile("dump", name + ".bin"); - worker.dumpDecoded(name, dump.toPath()); - } else { - IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2)); + // hasError() is a consuming ThreadLocal read: evaluate exactly once. The + // dump must never throw out of this finally — that would replace the + // successfully parsed plate with the dump failure, and the caller would + // overwrite 1024 good chunks with a fresh empty plate. + boolean errored = TectonicPlate.hasError(); + if (errored && IrisSettings.get().getGeneral().isDumpMantleOnError()) { + try { + File dump = IrisPlatforms.get().dataFile("dump", name + ".bin"); + worker.dumpDecoded(name, dump.toPath()); + } catch (Throwable dumpFailure) { + IrisLogging.warn("Failed to dump mantle region " + name + " for diagnostics"); + IrisLogging.reportError(dumpFailure); + } } + IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2)); } } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java index 88e9d732d..5a8d808c8 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java @@ -519,18 +519,7 @@ final class WorldEntitySpawner { } private KList spawnRandomly(List types) { - KList rarityTypes = new KList<>(); - int totalRarity = 0; - - for (IrisEntitySpawn i : types) { - totalRarity += IRare.get(i); - } - - for (IrisEntitySpawn i : types) { - rarityTypes.addMultiple(i, totalRarity / IRare.get(i)); - } - - return rarityTypes; + return IRare.expandWeighted(types); } @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 663459f27..0bc50bcec 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 @@ -51,42 +51,40 @@ public class IrisBiomeActuator extends EngineAssignedActuator { @BlockCoordinates @Override public void onActuate(int x, int z, Hunk h, boolean multicore, ChunkContext context) { - try { - PrecisionStopwatch p = PrecisionStopwatch.start(); - int width = h.getWidth(); - int depth = h.getDepth(); - int height = h.getHeight(); - Engine engine = getEngine(); - Mantle mantle = engine.getMantle().getMantle(); - ChunkedDataCache biomeCache = context.getBiome(); + // No catch here: a partial biome write must fail the chunk (EngineAssignedActuator + // rethrows), or the chunk is flagged REAL with default plains columns forever. + PrecisionStopwatch p = PrecisionStopwatch.start(); + int width = h.getWidth(); + int depth = h.getDepth(); + int height = h.getHeight(); + Engine engine = getEngine(); + Mantle mantle = engine.getMantle().getMantle(); + ChunkedDataCache biomeCache = context.getBiome(); - for (int xf = 0; xf < width; xf++) { - IrisBiome ib; - for (int zf = 0; zf < depth; zf++) { - ib = biomeCache.get(xf, zf); - String key; + for (int xf = 0; xf < width; xf++) { + IrisBiome ib; + for (int zf = 0; zf < depth; zf++) { + ib = biomeCache.get(xf, zf); + String key; - if (ib.isCustom()) { - IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf); - key = getDimension().getLoadKey() + ":" + custom.getId(); - } else { - key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf); - } - - ResolvedBiome resolved = resolve(key); - PlatformBiome biome = resolved.biome(); - - if (biome != null) { - h.set(xf, 0, zf, xf, height - 1, zf, biome); - } - - mantle.set(x + xf, 0, z + zf, resolved.matter()); + if (ib.isCustom()) { + IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf); + key = getDimension().getLoadKey() + ":" + custom.getId(); + } else { + key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf); } + + ResolvedBiome resolved = resolve(key); + PlatformBiome biome = resolved.biome(); + + if (biome != null) { + h.set(xf, 0, zf, xf, height - 1, zf, biome); + } + + mantle.set(x + xf, 0, z + zf, resolved.matter()); } - engine.getMetrics().getBiome().put(p.getMilliseconds()); - } catch (Throwable e) { - e.printStackTrace(); } + engine.getMetrics().getBiome().put(p.getMilliseconds()); } /** diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index 2a1b4d5f4..fe066ad39 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -102,6 +102,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator rockCache = context.getRock(); int realX = xf + x; UpperDimensionContext upperContext = getEngine().getUpperContext(); + // Dimension-level ore lookups are chunk-invariant; resolving them per column paid + // four accessor chains times 256 per chunk for constants. + KList dimensionSurfaceOres = hideOres ? null : dimension.getSurfaceOreGenerators(); + KList dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators(); + IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds(); + IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds(); for (int zf = 0; zf < chunkDepth; zf++) { int realZ = zf + z; @@ -118,16 +124,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); KList regionSurfaceOres = hideOres ? null : region.getSurfaceOreGenerators(); - KList dimensionSurfaceOres = hideOres ? null : dimension.getSurfaceOreGenerators(); KList biomeUndergroundOres = hideOres ? null : biome.getUndergroundOreGenerators(); KList regionUndergroundOres = hideOres ? null : region.getUndergroundOreGenerators(); - KList dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators(); IrisOreGeneratorBounds biomeSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : biome.getSurfaceOreGeneratorBounds(); IrisOreGeneratorBounds regionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : region.getSurfaceOreGeneratorBounds(); - IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds(); IrisOreGeneratorBounds biomeUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : biome.getUndergroundOreGeneratorBounds(); IrisOreGeneratorBounds regionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : region.getUndergroundOreGeneratorBounds(); - IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds(); boolean hasSurfaceOres = biomeSurfaceOreBounds.hasOres() || regionSurfaceOreBounds.hasOres() || dimensionSurfaceOreBounds.hasOres(); boolean hasUndergroundOres = biomeUndergroundOreBounds.hasOres() || regionUndergroundOreBounds.hasOres() || dimensionUndergroundOreBounds.hasOres(); KList blocks = null; diff --git a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java index 7855f9a99..b1fac57af 100644 --- a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java +++ b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java @@ -68,6 +68,34 @@ public class AtomicCache { }); } + /** + * Like {@link #aquire(Supplier)} but propagates a supplier failure to the caller instead + * of swallowing it into a null return. For values that are mandatory: a caller of a + * "@NotNull" accessor should see the supplier's real exception, not a downstream NPE. + */ + public T aquireOrThrow(Supplier t) { + Object v = value; + + if (v != null) { + return unwrap(v); + } + + synchronized (initLock) { + v = value; + + if (v != null) { + return unwrap(v); + } + + T computed = t.get(); + if (computed == null) { + throw new IllegalStateException("Atomic cache supplier produced null"); + } + value = computed; + return computed; + } + } + public T aquire(Supplier t) { Object v = value; diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index 74197c727..5c24626f2 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -98,41 +98,6 @@ final class DecoratorCore { return picked; } - static void placeSingleUp(IrisDecorator decorator, int x, int z, - int realX, int height, int realZ, Hunk data, - RNG rng, IrisData irisData, boolean caveSkipFluid, EngineMantle mantle) { - PlatformBlockState bd = decorator.pickBlockData(rng, irisData, realX, realZ); - if (bd == null) { - return; - } - - String half = IrisProceduralBlocks.propertyValue(bd, "half"); - if (half != null) { - int lowerY = height + 1; - int upperY = height + 2; - if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) { - return; - } - - try { - PlatformBlockState upper = bd.withProperty("half", topHalfValue(half)); - PlatformBlockState lower = fixFacesForHunk( - bd.withProperty("half", bottomHalfValue(half)), - data, x, z, realX, lowerY, realZ, mantle); - data.set(x, lowerY, z, lower); - data.set(x, upperY, z, upper); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - return; - } - - int targetY = height + 1; - if (targetY < data.getHeight() && B.isAir(data.get(x, targetY, z))) { - data.set(x, targetY, z, fixFacesForHunk(bd, data, x, z, realX, targetY, realZ, mantle)); - } - } - private static String topHalfValue(String half) { return half.equals("upper") || half.equals("lower") ? "upper" : "top"; } 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 cb42e385a..6f2adc84f 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 @@ -394,142 +394,6 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return r.get(); } - default IrisPosition lookForBiome(IrisBiome biome, long timeout, Consumer triesc) { - if (!getWorld().hasPlatformWorld()) { - IrisLogging.error("Cannot GOTO without a bound world (headless mode)"); - return null; - } - - ChronoLatch cl = new ChronoLatch(250, false); - long s = M.ms(); - int cpus = (Runtime.getRuntime().availableProcessors()); - - if (!getDimension().getReachableBiomes(this).contains(biome)) { - return null; - } - - AtomicInteger tries = new AtomicInteger(0); - AtomicBoolean found = new AtomicBoolean(false); - AtomicBoolean running = new AtomicBoolean(true); - AtomicReference location = new AtomicReference<>(); - for (int i = 0; i < cpus; i++) { - J.a(() -> { - try { - Engine e; - IrisBiome b; - int x, z; - - while (!found.get() && running.get()) { - try { - x = RNG.r.i(-29999970, 29999970); - z = RNG.r.i(-29999970, 29999970); - b = getSurfaceBiome(x, z); - - if (b != null && b.getLoadKey() == null) { - continue; - } - - if (b != null && b.getLoadKey().equals(biome.getLoadKey())) { - found.lazySet(true); - location.lazySet(new IrisPosition(x, getHeight(x, z), z)); - } - - tries.getAndIncrement(); - } catch (Throwable ex) { - IrisLogging.reportError(ex); - ex.printStackTrace(); - return; - } - } - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - }); - } - - while (!found.get() || location.get() == null) { - J.sleep(50); - - if (cl.flip()) { - triesc.accept(tries.get()); - } - - if (M.ms() - s > timeout) { - running.set(false); - return null; - } - } - - running.set(false); - return location.get(); - } - - default IrisPosition lookForRegion(IrisRegion reg, long timeout, Consumer triesc) { - if (!getWorld().hasPlatformWorld()) { - IrisLogging.error("Cannot GOTO without a bound world (headless mode)"); - return null; - } - - ChronoLatch cl = new ChronoLatch(3000, false); - long s = M.ms(); - int cpus = (Runtime.getRuntime().availableProcessors()); - - if (!getDimension().getRegions().contains(reg.getLoadKey())) { - return null; - } - - AtomicInteger tries = new AtomicInteger(0); - AtomicBoolean found = new AtomicBoolean(false); - AtomicBoolean running = new AtomicBoolean(true); - AtomicReference location = new AtomicReference<>(); - - for (int i = 0; i < cpus; i++) { - J.a(() -> { - Engine e; - IrisRegion b; - int x, z; - - while (!found.get() && running.get()) { - try { - x = RNG.r.i(-29999970, 29999970); - z = RNG.r.i(-29999970, 29999970); - b = getRegion(x, z); - - if (b != null && b.getLoadKey() != null && b.getLoadKey().equals(reg.getLoadKey())) { - found.lazySet(true); - location.lazySet(new IrisPosition(x, getHeight(x, z), z)); - } - - tries.getAndIncrement(); - } catch (Throwable xe) { - IrisLogging.reportError(xe); - xe.printStackTrace(); - return; - } - } - }); - } - - while (!found.get() || location.get() != null) { - J.sleep(50); - - if (cl.flip()) { - triesc.accept(tries.get()); - } - - if (M.ms() - s > timeout) { - triesc.accept(tries.get()); - running.set(false); - return null; - } - } - - triesc.accept(tries.get()); - running.set(false); - return location.get(); - } - double getGeneratedPerSecond(); default int getHeight() { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedActuator.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedActuator.java index 211e2a3ac..6ce3c16b4 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedActuator.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.iris.util.project.hunk.Hunk; @@ -31,6 +32,21 @@ public abstract class EngineAssignedActuator extends EngineAssignedComponent @BlockCoordinates @Override public void actuate(int x, int z, Hunk output, boolean multicore, ChunkContext context) { - onActuate(x, z, output, multicore, context); + try { + onActuate(x, z, output, multicore, context); + } catch (Throwable e) { + // Same contract as EngineAssignedModifier.modify: a failed actuator leaves a + // half-written chunk. Never continue as if it succeeded; let the chunk + // generation failure path abort the chunk instead of persisting it. + IrisLogging.error("Actuator Failure: " + getName()); + IrisLogging.reportError(e); + if (e instanceof Error error) { + throw error; + } + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Actuator Failure: " + getName(), e); + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index 5fd9e6059..d152e8ed2 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -24,16 +24,12 @@ import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.iris.util.project.hunk.Hunk; -import art.arcane.volmlib.util.math.RollingSequence; import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; public interface EngineMode extends Staged { - RollingSequence r = new RollingSequence(64); - RollingSequence r2 = new RollingSequence(256); - void close(); Engine getEngine(); @@ -46,16 +42,45 @@ public interface EngineMode extends Staged { return (x, z, blocks, biomes, multicore, ctx) -> { BurstExecutor e = burst().burst(stages.length); e.setMulticore(multicore); + // BurstExecutor.complete() logs-and-swallows stage failures; without re-propagation + // a multicore run would commit a half-written chunk that the inline (production) + // path correctly aborts. + java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); for (EngineStage i : stages) { e.queue(() -> { + if (failure.get() != null) { + return; + } try (IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) { i.generate(x, z, blocks, biomes, multicore, ctx); + } catch (Throwable t) { + failure.compareAndSet(null, t); + // Rethrow so the inline (multicore=false) path still aborts out of + // queue() on the first failure, exactly as before. + if (t instanceof Error error) { + throw error; + } + if (t instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException(t); } }); } e.complete(); + + Throwable t = failure.get(); + if (t != null) { + if (t instanceof Error error) { + throw error; + } + if (t instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Burst stage failure during chunk generation", t); + } }; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java index c5eeb8bc5..c4d6c0945 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java @@ -166,7 +166,11 @@ public final class LootResolver { if (mode == IrisLootMode.FALLBACK && !fallback) { return; } - if (mode == IrisLootMode.CLEAR || mode == IrisLootMode.REPLACE) { + if (mode == IrisLootMode.CLEAR) { + sources.clear(); + return; + } + if (mode == IrisLootMode.REPLACE) { sources.clear(); } sources.addAll(additions); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 210eb8f4f..348ef617e 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -258,13 +258,13 @@ public interface EngineMantle extends MatterGenerator { MantleChunk chunk = getMantle().getChunk(x, z).use(); try { chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { - chunk.deleteSlices(PlatformBlockState.class); - chunk.deleteSlices(String.class); - chunk.deleteSlices(UpdateMatter.class); - chunk.deleteSlices(MatterCavern.class); - chunk.deleteSlices(MatterFluidBody.class); - chunk.deleteSlices(MatterMarker.class); - chunk.deleteSlices(TreeBlockMaterial.class); + MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class); + MantleSliceRetention.deleteUnlessRetained(chunk, String.class); + MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterMarker.class); + MantleSliceRetention.deleteUnlessRetained(chunk, TreeBlockMaterial.class); chunk.trimSlices(); }); } finally { @@ -276,10 +276,10 @@ public interface EngineMantle extends MatterGenerator { MantleChunk chunk = getMantle().getChunk(x, z).use(); try { chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { - chunk.deleteSlices(PlatformBlockState.class); - chunk.deleteSlices(UpdateMatter.class); - chunk.deleteSlices(MatterCavern.class); - chunk.deleteSlices(MatterFluidBody.class); + MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class); + MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class); chunk.trimSlices(); }); } finally { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleSliceRetention.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleSliceRetention.java new file mode 100644 index 000000000..eec7c30dc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleSliceRetention.java @@ -0,0 +1,71 @@ +/* + * 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.engine.mantle; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Process-wide registry of mantle slice types that survive chunk cleanup. Declared through + * IrisToolbelt/IrisModdedAPI's retainMantleDataForSlice; honored by both EngineMantle cleanup + * paths (normal trim and pregen force-cleanup). Retained slices are not a leak: they persist to + * tectonic plates and unload with the region - the cost is larger region files. + * + *

The block-state slice is deliberately never retainable: it is the largest mantle consumer + * and is fully regenerable, so retaining it would balloon every region file with no consumer. + */ +public final class MantleSliceRetention { + private static final Set retained = ConcurrentHashMap.newKeySet(); + + private MantleSliceRetention() { + } + + public static void retain(String className) { + if (className == null || PlatformBlockState.class.getCanonicalName().equals(className)) { + return; + } + if (retained.add(className)) { + IrisLogging.info("Mantle slice retained across chunk cleanup: " + className); + } + } + + public static boolean isRetained(String className) { + return className != null && retained.contains(className); + } + + public static boolean isRetained(Class sliceType) { + return sliceType != null && retained.contains(sliceType.getCanonicalName()); + } + + static void deleteUnlessRetained(MantleChunk chunk, Class sliceType) { + if (isRetained(sliceType)) { + return; + } + chunk.deleteSlices(sliceType); + } + + static void clearForTesting() { + retained.clear(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index bccb86322..48b2c6ed3 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -81,14 +81,21 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (foliaMaintenance && IrisSettings.get().getGeneral().isDebug()) { IrisLogging.info("MantleWriter using sequential chunk prefetch for maintenance regen at " + x + "," + z + "."); } - mantle.getChunks( - x - radius, - x + radius, - z - radius, - z + radius, - parallelism, - this::storePrefetchedChunk - ); + // try-with-resources never calls close() when the initializer throws, so a failed + // prefetch must release the permits already pinned into the window here. + try { + mantle.getChunks( + x - radius, + x + radius, + z - radius, + z + radius, + parallelism, + this::storePrefetchedChunk + ); + } catch (Throwable e) { + close(); + throw e; + } } private static Set getBallooned(Set vset, double radius) { @@ -403,7 +410,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public PlatformBlockState get(int x, int y, int z) { - PlatformBlockState block = getData(x, y, z, PlatformBlockState.class); + // Read-only probe: getDataIfPresent returns the identical answer without materializing + // a 16^3 section + slice on a miss the way getData's getOrCreate path does. + PlatformBlockState block = getDataIfPresent(x, y, z, PlatformBlockState.class); if (block == null) return AIR; return block; @@ -416,7 +425,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { - return getData(x, y, z, MatterCavern.class) != null; + return getDataIfPresent(x, y, z, MatterCavern.class) != null; } public byte[] getCarvedColumn(int x, int z, int height) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java index 0f890d5f1..065fb7c60 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java @@ -38,6 +38,15 @@ public final class CarveOrphanSweep { boolean isCarved(int localX, int y, int localZ); void markCarved(int localX, int y, int localZ); + + /** + * Cheap pre-check: may any block in [minY, maxY] be carved at all? Default true keeps + * every implementor correct; the mantle-backed access answers from section slice + * presence so an uncarved chunk skips the full band fill + scan. + */ + default boolean mayContainCarvedCells(int minY, int maxY) { + return true; + } } private CarveOrphanSweep() { @@ -74,6 +83,12 @@ public final class CarveOrphanSweep { return 0; } + if (!access.mayContainCarvedCells(bandFloor, bandTop)) { + // Same early return the carvedPresent check reaches, without zero-filling and + // scanning the whole band first. + return 0; + } + int bandHeight = bandTop - bandFloor + 1; int cellCount = bandHeight * CHUNK_AREA; SweepScratch scratch = SCRATCH.get(); @@ -225,6 +240,19 @@ public final class CarveOrphanSweep { cachedSlice = null; } + @Override + public boolean mayContainCarvedCells(int minY, int maxY) { + int minSection = Math.max(0, minY >> 4); + int maxSection = Math.min(chunk.sectionCount() - 1, maxY >> 4); + for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { + Matter section = chunk.get(sectionIndex); + if (section != null && section.getSlice(MatterCavern.class) != null) { + return true; + } + } + return false; + } + private MatterSlice resolveSlice(int sectionIndex) { if (sectionIndex == cachedSectionIndex) { return cachedSlice; 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 3a970814e..ba8ad110a 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 @@ -82,6 +82,17 @@ public class MantleCarvingComponent extends IrisMantleComponent { super(engineMantle, ReservedFlag.CARVED, 0); } + @Override + public void hotload() { + super.hotload(); + // Hotload swaps in fresh IrisCaveProfile instances (identity keys), so retained + // entries would strand a full carver set pinning the closed IrisData per reload. + // Carvers rebuild deterministically from the carve seed, so output is unchanged. + synchronized (profileCarverLock) { + profileCarvers = new IdentityHashMap<>(); + } + } + @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); @@ -259,15 +270,16 @@ public class MantleCarvingComponent extends IrisMantleComponent { continue; } - SimdKernels kernels = SimdSupport.kernels(); - double totalWeight = kernels.sum(weights, weights.length); - double maxWeight = kernels.max(weights, weights.length); + // averageWeight ranks and orders carve passes, so it must be bit-identical across + // installations: kernels.sum reassociates FP addition under the vector kernel, + // making profile order depend on a JVM flag. max() is order-independent and safe. + double maxWeight = SimdSupport.kernels().max(weights, weights.length); if (maxWeight < MIN_WEIGHT) { continue; } - double averageWeight = totalWeight / CHUNK_AREA; + double averageWeight = computeAverageWeight(weights); columnWeightedProfiles.add(new WeightedProfile(profile, weights, averageWeight, null, columnWeightedProfiles.size())); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index e966ce265..c351b8928 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -85,23 +85,33 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } + // The IdentityHashMaps are lookup indices only. Iteration MUST follow the encounter + // order of the deterministic 0..255 column scan: identity-hash order changes per JVM + // run, and the shared chunkRng makes placement output depend on iteration order. IdentityHashMap> entryColumns = new IdentityHashMap<>(); IdentityHashMap> bottomEntryColumns = new IdentityHashMap<>(); + KList entryOrder = new KList<>(); + KList bottomEntryOrder = new KList<>(); for (int i = 0; i < 256; i++) { FloatingIslandSample s = samples[i]; if (s == null || s.entry == null) { continue; } - entryColumns.computeIfAbsent(s.entry, e -> new KList<>()).add(i); + entryColumns.computeIfAbsent(s.entry, e -> { + entryOrder.add(e); + return new KList<>(); + }).add(i); IrisFloatingChildBiomes bottomEntry = s.bottomEntry(); if (bottomEntry != null) { - bottomEntryColumns.computeIfAbsent(bottomEntry, e -> new KList<>()).add(i); + bottomEntryColumns.computeIfAbsent(bottomEntry, e -> { + bottomEntryOrder.add(e); + return new KList<>(); + }).add(i); } } - for (Map.Entry> ec : entryColumns.entrySet()) { - IrisFloatingChildBiomes entry = ec.getKey(); - KList columns = ec.getValue(); + for (IrisFloatingChildBiomes entry : entryOrder) { + KList columns = entryColumns.get(entry); if (columns.isEmpty()) { continue; } @@ -137,9 +147,8 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } - for (Map.Entry> ec : bottomEntryColumns.entrySet()) { - IrisFloatingChildBiomes entry = ec.getKey(); - KList columns = ec.getValue(); + for (IrisFloatingChildBiomes entry : bottomEntryOrder) { + KList columns = bottomEntryColumns.get(entry); if (columns.isEmpty()) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index 43fadf3e2..1ed14014e 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -103,11 +103,13 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { registerStage(sCave); registerStage(sPost); registerStage(sFloatingTerrainSolid); - registerStage(burst( - sDeposit, - sInsertMatter, - sDecorant - )); + // Never burst these three: all of them write the same block hunk (and sDecorant reads + // the surface sInsertMatter writes), so parallel order is scheduler-dependent. The + // production path already runs them inline in this order; sequential registration + // makes studio (the only multicore path) match production and the goldenhash baseline. + registerStage(sDeposit); + registerStage(sInsertMatter); + registerStage(sDecorant); registerStage(sFloatingDecorate); registerStage(sPerfection); registerStage(sCustom); 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 053b45b2b..60d4345a0 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 @@ -563,6 +563,18 @@ public class IrisCarveModifier extends EngineAssignedModifier>> 30)) * 0xBF58476D1CE4E5B9L; + h = (h ^ (h >>> 27)) * 0x94D049BB133111EBL; + h ^= h >>> 31; + return (h & 15L) == 0L; + } + private void processZone(Hunk output, MantleChunk mc, Mantle mantle, CaveZone zone, int rx, int rz, int xx, int zz, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { int center = (zone.floor + zone.ceiling) / 2; int maxY = output.getHeight(); @@ -576,11 +588,14 @@ public class IrisCarveModifier extends EngineAssignedModifier { - for (int rX = 0; rX < output.getWidth(); rX++) { - for (int rZ = 0; rZ < output.getDepth(); rZ++) { - PlatformBlockState b = output.get(rX, finalY, rZ); - if (b == null || !b.isCustom()) { - continue; - } - String placementKey = b.deferredPlacementKey(); - PlatformBlockState baseState = b.placementBaseState(); - if (placementKey == null || baseState == null) { - continue; - } + // complete() must run before release() even when queueing throws — detached burst + // tasks must never write into a released chunk, and a lost permit wedges close(). + try { + for (int y = 0; y < output.getHeight(); y++) { + int finalY = y; + burst.queue(() -> { + for (int rX = 0; rX < output.getWidth(); rX++) { + for (int rZ = 0; rZ < output.getDepth(); rZ++) { + PlatformBlockState b = output.get(rX, finalY, rZ); + if (b == null || !b.isCustom()) { + continue; + } + String placementKey = b.deferredPlacementKey(); + PlatformBlockState baseState = b.placementBaseState(); + if (placementKey == null || baseState == null) { + continue; + } - mc.getOrCreate(finalY >> 4) - .slice(Identifier.class) - .set(rX, finalY & 15, rZ, Identifier.fromString(placementKey)); - output.set(rX, finalY, rZ, baseState); + mc.getOrCreate(finalY >> 4) + .slice(Identifier.class) + .set(rX, finalY & 15, rZ, Identifier.fromString(placementKey)); + output.set(rX, finalY, rZ, baseState); + } } - } - }); + }); + } + } finally { + try { + burst.complete(); + } finally { + mc.release(); + } } - burst.complete(); - mc.release(); } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index 2dcba97fd..1de113c16 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -77,9 +77,14 @@ public class IrisDepositModifier extends EngineAssignedModifier generate(k, chunk, terrain, rng.nextParallelRNG(finalSeed), x, z, false, context)); } - burst.complete(); } finally { - chunk.release(); + // complete() must run before release() even when queueing throws — already + // submitted burst tasks must never write into a released chunk. + try { + burst.complete(); + } finally { + chunk.release(); + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java index 11be36b57..a641a0d3a 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java @@ -48,7 +48,9 @@ public class IrisPostModifier extends EngineAssignedModifier @Override public void onModify(int x, int z, Hunk output, boolean multicore, ChunkContext context) { PrecisionStopwatch p = PrecisionStopwatch.start(); - Hunk sync = output.synchronize(); + // The post stage runs sequentially on production (multicore false); an uncontended + // monitor per probe is still a monitor times ~10k probes per chunk. + Hunk sync = multicore ? output.synchronize() : output; int width = output.getWidth(); int depth = output.getDepth(); int planeWidth = width + 2; diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java index c288bd13b..45ab1b21c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java @@ -20,11 +20,8 @@ package art.arcane.iris.engine.object; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; public class FloatingObjectFootprint { - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - private final int lowestSolidKeyY; private final int highestSolidKeyY; private final int centerX; @@ -49,12 +46,17 @@ public class FloatingObjectFootprint { this.footprintXZ = footprintXZ; } + /** + * Memoized per object instance, never in a global map: loadKey + declared dimensions is + * not unique across packs or across hotloads (a block edit keeps the header W/H/D), so a + * shared key space served stale or foreign footprints. A reload builds a new IrisObject, + * which drops the memo for free. + */ public static FloatingObjectFootprint compute(IrisObject obj) { - String cacheKey = obj.getLoadKey() + "@" + obj.getW() + "x" + obj.getH() + "x" + obj.getD(); - return CACHE.computeIfAbsent(cacheKey, k -> doCompute(obj, k)); + return obj.floatingFootprint.aquire(() -> doCompute(obj)); } - private static FloatingObjectFootprint doCompute(IrisObject obj, String cacheKey) { + private static FloatingObjectFootprint doCompute(IrisObject obj) { int cx = obj.getCenter().getBlockX(); int cy = obj.getCenter().getBlockY(); int cz = obj.getCenter().getBlockZ(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IRare.java b/core/src/main/java/art/arcane/iris/engine/object/IRare.java index 963d80d27..2c13b2708 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IRare.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IRare.java @@ -93,5 +93,23 @@ public interface IRare { return v instanceof IRare ? Math.max(1, ((IRare) v).getRarity()) : 1; } + /** + * Expands a candidate list into a rarity-weighted list: each entry appears totalRarity/rarity + * times, so picking uniformly from the result applies rarity exactly once. Both the Bukkit and + * modded entity spawners select through this expansion; rarity must never be applied a second + * time per spawn position. + */ + static KList expandWeighted(List possibilities) { + KList rarityTypes = new KList<>(); + int totalRarity = 0; + for (T i : possibilities) { + totalRarity += get(i); + } + for (T i : possibilities) { + rarityTypes.addMultiple(i, totalRarity / get(i)); + } + return rarityTypes; + } + int getRarity(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisAxisRotationClamp.java b/core/src/main/java/art/arcane/iris/engine/object/IrisAxisRotationClamp.java index 7a7c00d65..1fbbd8c82 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisAxisRotationClamp.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisAxisRotationClamp.java @@ -83,11 +83,8 @@ public class IrisAxisRotationClamp { } if (isUnlimited()) { - if (interval < 1) { - interval = 1; - } - - return Math.toRadians((interval * (Math.ceil(Math.abs((rng % 360D) / interval)))) % 360D); + double resolvedInterval = interval < 1 ? 1 : interval; + return Math.toRadians((resolvedInterval * (Math.ceil(Math.abs((rng % 360D) / resolvedInterval)))) % 360D); } if (min == max && min != 0) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 68cdef1ea..9b2105248 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -250,8 +250,18 @@ public class IrisBiome extends IrisRegistrant implements IRare { return this; } - public synchronized IrisBiome withInferredType(InferredType type) { + public IrisBiome withInferredType(InferredType type) { Objects.requireNonNull(type, "type"); + // Lock-free fast path on the volatile field: this runs per column per implode level + // against biome instances shared by every burst thread. The variant map below stays + // monitor-guarded (plain EnumMap, not safe for concurrent reads during writes). + if (inferredType == type) { + return this; + } + return withInferredTypeSlow(type); + } + + private synchronized IrisBiome withInferredTypeSlow(InferredType type) { if (inferredType == null) { inferredType = type; return this; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java index 3103aad37..6bd9f17e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java @@ -125,8 +125,10 @@ final class IrisBiomeLayerGenerator { } KList heightGenerators = getLayerHeightGenerators(biome, random, rdata); + // Ceiling layers reuse the surface-layer height generators, so entries beyond layers.size() have no generator; skip them. + int usableLayers = Math.min(layerCount, heightGenerators.size()); - for (int i = 0; i < layerCount; i++) { + for (int i = 0; i < usableLayers; i++) { IrisBiomePaletteLayer layer = ceilingLayers.get(i); double zoom = layer.getZoom(); CNG hgen = heightGenerators.get(i); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java index 39c6ac15a..ea8c71769 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java @@ -63,10 +63,20 @@ final class IrisBiomeOres { } static IrisOreGeneratorBounds getSurfaceOreGeneratorBounds(IrisBiome biome) { + // getIfPresent fast path: aquire allocates a capturing lambda even on a hit, and this + // runs per column on the terrain hot path. + IrisOreGeneratorBounds cached = biome.getSurfaceOreBoundsCache().getIfPresent(); + if (cached != null) { + return cached; + } return biome.getSurfaceOreBoundsCache().aquire(() -> IrisOreGeneratorBounds.of(getSurfaceOres(biome))); } static IrisOreGeneratorBounds getUndergroundOreGeneratorBounds(IrisBiome biome) { + IrisOreGeneratorBounds cached = biome.getUndergroundOreBoundsCache().getIfPresent(); + if (cached != null) { + return cached; + } return biome.getUndergroundOreBoundsCache().aquire(() -> IrisOreGeneratorBounds.of(getUndergroundOres(biome))); } 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 75859453f..f5147e22b 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 @@ -170,8 +170,6 @@ public class IrisDimension extends IrisRegistrant { @MaxNumber(16) @Desc("Minimum surface-support buffer, in blocks, applied to every surface object placement in this dimension. A placement may ask for more but never less.") private int objectSurfaceSupportBuffer = 2; - @Desc("Unused. This field is not read by the engine.") - private Boolean forceConvertTo320Height = false; @Desc("The world environment") private IrisEnvironment environment = IrisEnvironment.NORMAL; @RegistryListResource(IrisRegion.class) @@ -257,16 +255,10 @@ public class IrisDimension extends IrisRegistrant { @ArrayType(min = 1, type = IrisShapedGeneratorStyle.class) @Desc("Overlay additional noise on top of the interoplated terrain.") private KList overlayNoise = new KList<>(); - @MinNumber(0.0001) - @MaxNumber(512) - @Desc("Unused. This field is not read by the engine; rock palette styling comes from rockPalette itself.") - private double rockZoom = 5; @Desc("The palette of blocks for 'stone'") private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone"); @Desc("The dimension fluid block palette used for ocean columns and cave aquifers.") private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water"); - @Desc("Unused. This field is not read by the engine and no longer affects explorer maps.") - private boolean disableExplorerMaps = false; @Desc("Collection of ores to be generated") @ArrayType(type = IrisOreGenerator.class, min = 1) private KList ores = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionType.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionType.java index 0a287df32..1f56ad0b2 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionType.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionType.java @@ -30,6 +30,12 @@ public final class IrisDimensionType { private final int height; private final int minY; + public static final int MIN_HEIGHT = 16; + public static final int MAX_HEIGHT = 4064; + public static final int MIN_MIN_Y = -2032; + public static final int MAX_MIN_Y = 2031; + public static final int HEIGHT_STEP = 16; + public IrisDimensionType( @NonNull IDataFixer.Dimension base, @NonNull IrisDimensionTypeOptions options, @@ -39,10 +45,10 @@ public final class IrisDimensionType { ) { if (logicalHeight > height) throw new IllegalArgumentException("Logical height cannot be greater than height"); if (logicalHeight < 0) throw new IllegalArgumentException("Logical height cannot be less than zero"); - if (height < 16 || height > 4064 ) throw new IllegalArgumentException("Height must be between 16 and 4064"); - if ((height & 15) != 0) throw new IllegalArgumentException("Height must be a multiple of 16"); - if (minY < -2032 || minY > 2031) throw new IllegalArgumentException("Min Y must be between -2032 and 2031"); - if ((minY & 15) != 0) throw new IllegalArgumentException("Min Y must be a multiple of 16"); + if (height < MIN_HEIGHT || height > MAX_HEIGHT) throw new IllegalArgumentException("Height must be between 16 and 4064"); + if ((height & (HEIGHT_STEP - 1)) != 0) throw new IllegalArgumentException("Height must be a multiple of 16"); + if (minY < MIN_MIN_Y || minY > MAX_MIN_Y) throw new IllegalArgumentException("Min Y must be between -2032 and 2031"); + if ((minY & (HEIGHT_STEP - 1)) != 0) throw new IllegalArgumentException("Min Y must be a multiple of 16"); this.base = base; this.options = options; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java index 69689acc6..65d085979 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java @@ -77,8 +77,6 @@ public class IrisGeneratorStyle { @MinNumber(0.00001) @Desc("The Output multiplier. Only used if parent is fracture.") private double multiplier = 1; - @Desc("If set to true, each dimension will be fractured with a different order of input coordinates. This is usually 2 or 3 times slower than normal.") - private boolean axialFracturing = false; @Desc("Apply a generator to the coordinate field fed into this parent generator. I.e. Distort your generator with another generator.") private IrisGeneratorStyle fracture = null; @MinNumber(0.01562) @@ -123,7 +121,7 @@ public class IrisGeneratorStyle { } private int hash() { - return Objects.hash(expression, imageMapHash(), multiplier, axialFracturing, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style); + return Objects.hash(expression, imageMapHash(), multiplier, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style); } public int prebakeSignature() { @@ -191,7 +189,6 @@ public class IrisGeneratorStyle { } cng = cng.scale(1D / zoom).pow(exponent).bake(); - cng.setTrueFracturing(axialFracturing); if (fracture != null) { cng.fractureWith(fracture.createNoCache(rng.nextParallelRNG(2934), data, false, diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootMode.java index 9bc2ce262..e63a59e5e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootMode.java @@ -20,13 +20,13 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.Desc; -@Desc("A loot mode is used to describe what to do with the existing loot layers before adding this loot. Using ADD will simply add this table to the building list of tables (i.e. add dimension tables, region tables then biome tables). By using clear or replace, you remove the parent tables before and add just your tables.") +@Desc("A loot mode is used to describe what to do with the existing loot layers before adding this loot. ADD appends to the building list of tables (dimension tables, then region tables, then biome tables). REPLACE swaps the parent tables for this reference's tables. CLEAR suppresses loot entirely at this point.") public enum IrisLootMode { @Desc("Add to the existing parent loot tables") ADD, - @Desc("Clear all loot tables then add this table") + @Desc("Clear all parent loot tables and contribute nothing at this level. Any tables listed here are ignored.") CLEAR, - @Desc("Replace all loot tables with this table (same as clear)") + @Desc("Replace all parent loot tables with this reference's tables") REPLACE, @Desc("Only use when there was no loot table defined by an object") FALLBACK diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java index 2c2cb7da6..189876a40 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java @@ -42,7 +42,7 @@ public class IrisLootReference { public static final double MAX_MULTIPLIER = 16D; private final transient AtomicCache> tt = new AtomicCache<>(); - @Desc("ADD = add on top of parent tables. REPLACE and CLEAR both clear parent tables first, then add these. FALLBACK = only used when nothing else defined a table.") + @Desc("ADD = add on top of parent tables. REPLACE = clear parent tables, then add these. CLEAR = clear parent tables and add nothing, even if tables are listed. FALLBACK = only used when nothing else defined a table.") private IrisLootMode mode = IrisLootMode.ADD; @RegistryListResource(IrisLootTable.class) @ArrayType(min = 1, type = String.class) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 768cb2688..1549eb5cb 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -80,6 +80,7 @@ public class IrisObject extends IrisRegistrant { @Setter protected transient AtomicCache aabb = new AtomicCache<>(); transient final AtomicCache> surfaceSupportOffsets = new AtomicCache<>(); + transient final AtomicCache floatingFootprint = new AtomicCache<>(); @Getter VectorMap blocks; @Getter @@ -174,11 +175,25 @@ public class IrisObject extends IrisRegistrant { } public void shrinkwrap() { - IrisObjectShaping.shrinkwrap(this); + // Instances are loader cached and shared across generation threads; mutating the + // volume without the write lock let a concurrent placement mix pre-shrink anchors + // with post-shrink blocks. Rotation paths call the package-private statics while + // already holding this (reentrant) lock. + writeLock.lock(); + try { + IrisObjectShaping.shrinkwrap(this); + } finally { + writeLock.unlock(); + } } public void clean() { - IrisObjectShaping.clean(this); + writeLock.lock(); + try { + IrisObjectShaping.clean(this); + } finally { + writeLock.unlock(); + } } public IrisBlockVector getSigned(int x, int y, int z) { @@ -200,6 +215,7 @@ public class IrisObject extends IrisRegistrant { } surfaceSupportOffsets.reset(); + floatingFootprint.reset(); } public void setUnsignedTile(int x, int y, int z, TileData tile) { @@ -229,6 +245,7 @@ public class IrisObject extends IrisRegistrant { } surfaceSupportOffsets.reset(); + floatingFootprint.reset(); } public int place(int x, int z, IObjectPlacer placer, IrisObjectPlacement config, RNG rng, IrisData rdata) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index 6b87a8019..8e3f18229 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -94,6 +94,7 @@ public final class IrisObjectIO { static void readLegacy(IrisObject self, InputStream in) throws IOException { self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); DataInputStream din = new DataInputStream(in); self.w = din.readInt(); self.h = din.readInt(); @@ -126,6 +127,7 @@ public final class IrisObjectIO { static void read(IrisObject self, InputStream in) throws Throwable { self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); DataInputStream din = new DataInputStream(in); self.w = din.readInt(); self.h = din.readInt(); @@ -142,11 +144,18 @@ public final class IrisObjectIO { palette.add(din.readUTF()); } + // Resolve the palette once: B.getState per BLOCK was a registry lookup times the + // block count (tens of thousands) instead of times the palette size (hundreds). + PlatformBlockState[] resolved = new PlatformBlockState[palette.size()]; + for (i = 0; i < resolved.length; i++) { + resolved[i] = B.getState(palette.get(i)); + } + s = din.readInt(); for (i = 0; i < s; i++) { IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); - PlatformBlockState data = B.getState(palette.get(din.readShort())); + PlatformBlockState data = resolved[din.readShort()]; if (isStructureMarker(data)) { continue; } @@ -166,6 +175,10 @@ public final class IrisObjectIO { } catch (Throwable e) { if (!(e instanceof HeaderException)) IrisLogging.reportError(e); + // The V2 parse populates blocks/states incrementally; a mid-file failure must not + // leave those entries to be merged with the legacy parse of the same file. + self.blocks.clear(); + self.states.clear(); try (var fin = new BufferedInputStream(new FileInputStream(file))) { readLegacy(self, fin); } @@ -180,7 +193,44 @@ public final class IrisObjectIO { return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block") || material.equals("minecraft:structure_void"); } + /** + * The .iob V2 format stores the palette count, palette indices, and block coordinates as + * shorts. Values beyond the short range used to wrap silently and corrupt the object; every + * write path now rejects them with a descriptive error before any byte is written. + */ + static void validateWritable(IrisObject self) throws IOException { + KList palette = new KList<>(); + for (PlatformBlockState i : self.blocks.values()) { + palette.addIfMissing(i.key()); + } + if (palette.size() > MAX_PALETTE_ENTRIES) { + throw new IOException("Object '" + self.getLoadKey() + "' has " + palette.size() + + " distinct block states; the .iob format supports at most " + MAX_PALETTE_ENTRIES + "."); + } + for (var entry : self.blocks) { + requireShortCoordinates(self, "block", entry.getKey()); + } + for (var entry : self.states) { + requireShortCoordinates(self, "tile", entry.getKey()); + } + } + + private static void requireShortCoordinates(IrisObject self, String kind, IrisBlockVector position) throws IOException { + requireShort(self, kind, "x", position.getBlockX(), position); + requireShort(self, kind, "y", position.getBlockY(), position); + requireShort(self, kind, "z", position.getBlockZ(), position); + } + + private static void requireShort(IrisObject self, String kind, String axis, int value, IrisBlockVector position) throws IOException { + if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) { + throw new IOException("Object '" + self.getLoadKey() + "' " + kind + " at (" + position.getBlockX() + + "," + position.getBlockY() + "," + position.getBlockZ() + + ") exceeds the .iob coordinate range of ±32767 on the " + axis + " axis (" + value + ")."); + } + } + static void write(IrisObject self, OutputStream o) throws IOException { + validateWritable(self); DataOutputStream dos = new DataOutputStream(o); dos.writeInt(self.w); dos.writeInt(self.h); @@ -219,6 +269,7 @@ public final class IrisObjectIO { } static void write(IrisObject self, OutputStream o, VolmitSender sender) throws IOException { + validateWritable(self); AtomicReference ref = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); new Job() { @@ -310,6 +361,9 @@ public final class IrisObjectIO { return; } + // Validate before opening the stream: FileOutputStream truncates, and a rejected + // object must leave the existing .iob untouched. + validateWritable(self); try (FileOutputStream out = new FileOutputStream(file)) { write(self, out); } @@ -320,6 +374,7 @@ public final class IrisObjectIO { return; } + validateWritable(self); try (FileOutputStream out = new FileOutputStream(file)) { write(self, out, sender); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index a960bd0f7..e878a377b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -151,8 +151,6 @@ public class IrisObjectPlacement { private boolean bore = false; @Desc("Use a generator to warp the field of coordinates. Using simplex for example would make a square placement warp like a flag") private IrisGeneratorStyle warp = new IrisGeneratorStyle(NoiseStyle.FLAT); - @Desc("Unused. This field is not read by the placement engine.") - private boolean translateCenter = false; @Desc("The placement mode") private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT; @ArrayType(min = 1, type = IrisObjectReplace.class) @@ -189,7 +187,6 @@ public class IrisObjectPlacement { public IrisObjectPlacement toPlacement(String... place) { IrisObjectPlacement p = new IrisObjectPlacement(); p.setPlace(new KList<>(place)); - p.setTranslateCenter(translateCenter); p.setMode(mode); p.setEdit(edit); p.setTranslate(translate); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 51533a6f4..00cf0bc1c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -29,6 +29,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.data.VectorMap; import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.SimplexNoise; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; @@ -111,6 +112,13 @@ final class IrisObjectPlacementRunner { } boolean warped = !config.getWarp().isFlat(); + // Placement-invariant hoists off the per-voxel loop: the warp CNG resolution went + // through a keyed cache probe per block, and the two boolean gates below cut per-block + // property scans that can never match for this placement. + CNG surfaceWarp = warped ? config.getSurfaceWarp(rng, self.getLoader()) : null; + double warpHalf = warped ? config.getWarp().getMultiplier() / 2D : 0D; + boolean preventingDecay = placer.isPreventingDecay(); + boolean waterlogCandidate = config.isWaterloggable() || config.isUnderwater(); boolean rawStructurePiece = config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE; boolean organicFloor = config.getMode() == ObjectPlaceMode.ORGANIC_STILT; boolean ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG; @@ -401,16 +409,23 @@ final class IrisObjectPlacementRunner { markers = new KMap<>(); var list = StreamSupport.stream(blocks.keys().spliterator(), false) .collect(KList.collector()); + // Marker selection persists into the mantle, so it must be seed-deterministic. + // Derive a side stream keyed on the placement position instead of consuming the + // caller's rng: consuming draws there would shift every later placement in the + // chunk and invalidate existing worlds/goldenhashes. + RNG markerRng = rng.nextParallelRNG(((((long) x) << 32) | (z & 0xFFFFFFFFL)) * 31L + yv); + int markerIndex = 0; for (IrisObjectMarker j : config.getMarkers()) { IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker()); + int markerSalt = markerIndex++; if (marker == null) { continue; } int max = j.getMaximumMarkers(); - for (IrisBlockVector i : list.shuffle()) { + for (IrisBlockVector i : list.shuffleCopy(markerRng.nextParallelRNG(markerSalt))) { if (max <= 0) { break; } @@ -480,7 +495,7 @@ final class IrisObjectPlacementRunner { } } - if (placer.isPreventingDecay() && IrisProceduralBlocks.hasProperty(data, "distance") && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent"))) { + if (preventingDecay && IrisProceduralBlocks.hasProperty(data, "distance") && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent"))) { data = data.withProperty("persistent", "true"); } @@ -513,8 +528,8 @@ final class IrisObjectPlacementRunner { zz = z + (int) Math.round(i.getZ()); if (warped) { - xx += config.warp(rng, i.getX() + x, i.getY() + y, i.getZ() + z, self.getLoader()); - zz += config.warp(rng, i.getZ() + z, i.getY() + y, i.getX() + x, self.getLoader()); + xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z); + zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x); } if (yv < 0 && (config.getMode().equals(ObjectPlaceMode.PAINT)) && !B.isVineBlock(data)) { @@ -537,7 +552,7 @@ final class IrisObjectPlacementRunner { continue; } - if (IrisProceduralBlocks.hasProperty(data, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) { + if (waterlogCandidate && IrisProceduralBlocks.hasProperty(data, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) { data = data.withProperty("waterlogged", "true"); } @@ -545,8 +560,9 @@ final class IrisObjectPlacementRunner { data = attachVineFaces(placer, data, xx, yy, zz); } - PlatformBlockState existingState = placer.get(xx, yy, zz); - boolean wouldReplace = B.isSolid(existingState) && B.isVineBlock(data); + // Short-circuit order matters for cost only: the mantle read is paid solely + // for vine blocks. Both operands are pure, so the value is unchanged. + boolean wouldReplace = B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz)); String material = IrisObjectShaping.materialKey(data); boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace); @@ -702,8 +718,8 @@ final class IrisObjectPlacementRunner { zz = z + (int) Math.round(i.getZ()); if (warped) { - xx += config.warp(rng, i.getX() + x, i.getY() + y, i.getZ() + z, self.getLoader()); - zz += config.warp(rng, i.getZ() + z, i.getY() + y, i.getX() + x, self.getLoader()); + xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z); + zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x); } if (organic) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java index 68622d25d..a9a6839d5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java @@ -46,6 +46,16 @@ public class IrisObjectScale { .concurrencyLevel(32) .build(); + /** + * Coarse flush wired into IrisData dump/hotload: keys hold strong IrisObject references + * (and through them the owning IrisData), so without this every hotload or world unload + * pinned the previous pack graph for the process lifetime. Entries are pure derived data, + * so a flushed still-live entry just recomputes. + */ + public static void invalidate() { + cache.clear(); + } + @MinNumber(0.01) @MaxNumber(50) @Desc("Fixed scale multiplier for this object. 0.5 shrinks to half size, 2.0 doubles the size. When set to anything other than 1, this overrides minimumScale and maximumScale. Leave at 1 to use the minimumScale/maximumScale range.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java index cf1d94865..b75d57964 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java @@ -46,6 +46,20 @@ final class IrisObjectShaping { return; } + // Whole computation under the object's write lock: two racing callers would otherwise + // both compute, with the loser reading self.blocks while the winner merges into it. + self.writeLock.lock(); + try { + if (self.smartBored) { + return; + } + ensureSmartBoredLocked(self, debug); + } finally { + self.writeLock.unlock(); + } + } + + private static void ensureSmartBoredLocked(IrisObject self, boolean debug) { PrecisionStopwatch p = PrecisionStopwatch.start(); PlatformBlockState vair = debug ? IrisObject.States.VAIR_DEBUG : IrisObject.States.VAIR; AtomicInteger applied = new AtomicInteger(); @@ -75,7 +89,12 @@ final class IrisObjectShaping { } VectorMap bore = new VectorMap<>(); + // Inline on purpose: the three axis passes read AND write the shared bore map, so the + // bored volume must come from a fixed X->Y->Z order on the calling (lock-owning) + // thread, not from pool scheduling. This matches what burst-worker callers (studio, + // generation threads) already produced, so output is unchanged where it was stable. BurstExecutor burst = MultiBurst.burst.burst(); + burst.setMulticore(false); // Smash X for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { @@ -198,18 +217,25 @@ final class IrisObjectShaping { max.setZ(Math.max(max.getZ(), i.getZ())); } - self.w = max.getBlockX() - min.getBlockX() + 1; - self.h = max.getBlockY() - min.getBlockY() + 1; - self.d = max.getBlockZ() - min.getBlockZ() + 1; - self.center = new Vector3i(self.w / 2, self.h / 2, self.d / 2); + // Compute into locals and assign every field in one block at the end, so a reader can + // never observe post-shrink dimensions paired with pre-shrink volume (or vice versa). + int w = max.getBlockX() - min.getBlockX() + 1; + int h = max.getBlockY() - min.getBlockY() + 1; + int d = max.getBlockZ() - min.getBlockZ() + 1; + Vector3i center = new Vector3i(w / 2, h / 2, d / 2); Vector3i offset = new Vector3i( - -self.center.getBlockX() - min.getBlockX(), - -self.center.getBlockY() - min.getBlockY(), - -self.center.getBlockZ() - min.getBlockZ() + -center.getBlockX() - min.getBlockX(), + -center.getBlockY() - min.getBlockY(), + -center.getBlockZ() - min.getBlockZ() ); - if (offset.getBlockX() == 0 && offset.getBlockY() == 0 && offset.getBlockZ() == 0) + if (offset.getBlockX() == 0 && offset.getBlockY() == 0 && offset.getBlockZ() == 0) { + self.w = w; + self.h = h; + self.d = d; + self.center = center; return; + } VectorMap b = new VectorMap<>(); VectorMap s = new VectorMap<>(); @@ -225,10 +251,15 @@ final class IrisObjectShaping { s.put(vector, data); }); + self.w = w; + self.h = h; + self.d = d; + self.center = center; self.shrinkOffset = offset; self.blocks = b; self.states = s; self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } static void clean(IrisObject self) { @@ -241,6 +272,7 @@ final class IrisObjectShaping { self.blocks = d; self.states = dx; self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } static boolean shouldStilt(PlatformBlockState state) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java index 7b659508a..bf761b406 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java @@ -57,6 +57,7 @@ final class IrisObjectTransforms { self.states = dx; IrisObjectShaping.shrinkwrap(self); self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } finally { self.writeLock.unlock(); } @@ -155,6 +156,7 @@ final class IrisObjectTransforms { self.blocks = b; self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } finally { self.writeLock.unlock(); } @@ -190,6 +192,7 @@ final class IrisObjectTransforms { self.blocks = b; self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } finally { self.writeLock.unlock(); } @@ -229,6 +232,7 @@ final class IrisObjectTransforms { self.blocks = b; self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); } finally { self.writeLock.unlock(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 9d22123e7..7fd44ba09 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -59,14 +59,9 @@ public class IrisRegion extends IrisRegistrant implements IRare { private final transient AtomicCache> cacheSpot = new AtomicCache<>(); private final transient AtomicCache shoreHeightGenerator = new AtomicCache<>(); private final transient AtomicCache> realLandBiomes = new AtomicCache<>(); - private final transient AtomicCache> realLakeBiomes = new AtomicCache<>(); - private final transient AtomicCache> realRiverBiomes = new AtomicCache<>(); private final transient AtomicCache> realSeaBiomes = new AtomicCache<>(); private final transient AtomicCache> realShoreBiomes = new AtomicCache<>(); private final transient AtomicCache> realCaveBiomes = new AtomicCache<>(); - private final transient AtomicCache lakeGen = new AtomicCache<>(); - private final transient AtomicCache riverGen = new AtomicCache<>(); - private final transient AtomicCache riverChanceGen = new AtomicCache<>(); private final transient AtomicCache cacheColor = new AtomicCache<>(); private final transient AtomicCache> surfaceOreCache = new AtomicCache<>(); private final transient AtomicCache> undergroundOreCache = new AtomicCache<>(); @@ -146,10 +141,6 @@ public class IrisRegion extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisDepositVariant.class) @Desc("Deposit ore remap rules scoped to this region. Each entry declares a vertical band and a source->replacement block id map. Applied after biome rules but before dimension rules; first matching region rule wins.") private KList depositVariants = new KList<>(); - @Desc("Unused. This field is not read by the engine; rivers are not generated from it.") - private IrisGeneratorStyle riverStyle = NoiseStyle.VASCULAR_THIN.style().zoomed(7.77); - @Desc("Unused. This field is not read by the engine; lakes are not generated from it.") - private IrisGeneratorStyle lakeStyle = NoiseStyle.CELLULAR_IRIS_THICK.style(); @Desc("A color for visualizing this region with a color. I.e. #F13AF5. This will show up on the map.") private String color = null; @Desc("Collection of ores to be generated") @@ -276,23 +267,6 @@ public class IrisRegion extends IrisRegistrant implements IRare { }); } - public double getBiomeZoom(InferredType t) { - switch (t) { - case CAVE: - return caveBiomeZoom; - case LAND: - return landBiomeZoom; - case SEA: - return seaBiomeZoom; - case SHORE: - return shoreBiomeZoom; - default: - break; - } - - return 1; - } - public CNG getShoreHeightGenerator() { return shoreHeightGenerator.aquire(() -> CNG.signature(new RNG((long) (getName().length() + getLandBiomeZoom() + getLandBiomes().size() + 3458612)))); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java index 200c5e3ac..b00ae06e9 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java @@ -41,16 +41,12 @@ public class IrisTree { @ArrayType(min = 1, type = String.class) private KList treeTypes = new KList<>(); - @Desc("Unused. This flag is not read by the tree matcher; use treeTypes to control which TreeTypes match.") - private boolean anyTree = false; @Required @Desc("The size of the square of saplings this applies to (2 means a 2 * 2 sapling area)") @ArrayType(min = 1, type = IrisTreeSize.class) private KList sizes = new KList<>(); - @Desc("Unused. This flag is not read by the tree matcher; use sizes to control which sapling sizes match.") - private boolean anySize; public boolean matches(IrisTreeSize size, TreeType type) { if (!matchesSize(size)) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java index 1f7d7524c..b96769fd8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java @@ -46,7 +46,7 @@ public enum StudioMode { @Desc("Debug layout: every biome on a square grid with 36x36-chunk cells per biome, barrier floor past the last biome. Bukkit studio worlds only.") BIOME_BUFFET_36x36, - @Desc("Not implemented: currently generates exactly like NORMAL.") + @Desc("Deprecated: generates exactly like NORMAL and will be removed in a future release.") REGION_BUFFET, @Desc("Replaces terrain with the object studio: a flat polished-deepslate floor laying every pack object out on framed, end-rod-marked grid plinths. Bukkit studio worlds only.") diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index db2462601..be6a48bcd 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -160,6 +160,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @EventHandler(priority = EventPriority.LOWEST) public void onWorldInit(WorldInitEvent event) { if (!Objects.equals(world.identity(), WorldIdentity.key(event.getWorld()).toString())) return; + if (event.getWorld().getGenerator() != this) { + // A generator leaked by an earlier failed creation of a same-key world; only the + // instance Bukkit actually bound may attach an engine to this world. + BukkitPlatform.volmitPlugin().unregisterListener(this); + return; + } BukkitPlatform.volmitPlugin().unregisterListener(this); world.setRawWorldSeed(event.getWorld().getSeed()); if (initialize(event.getWorld())) return; @@ -306,7 +312,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun public EngineTarget getTarget() { if (engine != null) return engine.getTarget(); - return targetCache.aquire(() -> { + return targetCache.aquireOrThrow(() -> { IrisData data = IrisData.openRuntime(dataLocation); data.dump(); data.clearLists(); @@ -425,6 +431,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public CompletableFuture closeAsync() { closing = true; + // Outside the exclusive-control block so every close path detaches the WorldInit + // listener, including a rollback where the world never materialized. Guarded: with no + // hosted plugin (unit tests, teardown) volmitPlugin() throws, and that must not stop + // the close. + try { + BukkitPlatform.volmitPlugin().unregisterListener(this); + } catch (Throwable ignored) { + } CompletableFuture future = new CompletableFuture<>(); while (!closeFuture.compareAndSet(null, future)) { CompletableFuture existing = closeFuture.get(); @@ -458,6 +472,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } catch (Throwable throwable) { future.completeExceptionally(throwable); closeFuture.compareAndSet(future, null); + // A failed close must stay retryable; leaving closing latched would permanently + // reject generation for a world that may still be loaded. + closing = false; return future; } @@ -467,6 +484,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } else { future.completeExceptionally(throwable); closeFuture.compareAndSet(future, null); + closing = false; } }); return future; @@ -787,7 +805,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun lastJigsawStudioRequestId = null; lastMode = null; } - StudioMode desired = getEngine().getDimension().getStudioMode(); + // Gson nulls unknown enum names, so a pack carrying a removed or typo'd studioMode must not NPE the generator. + // Pack-declared studio modes are studio-only: a shipped pack that forgot to reset + // studioMode must never replace production terrain with a debug generator. + StudioMode desired = studio + ? java.util.Optional.ofNullable(getEngine().getDimension().getStudioMode()).orElse(StudioMode.NORMAL) + : StudioMode.NORMAL; if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { desired = StudioMode.OBJECT_BUFFET; } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java index 50b128673..a4631511f 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java @@ -34,6 +34,10 @@ import java.util.concurrent.ConcurrentHashMap; */ public final class BukkitBlockState implements PlatformBlockState { private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + // Front cache keyed on the BlockData itself (CraftBlockData equals/hashCode delegate to + // the canonical NMS state): a hit skips getAsString(), which built the full property + // string on EVERY of() call — the dominant cost of the 99.9% hit case. + private static final ConcurrentHashMap DATA_CACHE = new ConcurrentHashMap<>(); private final BlockData data; private final String key; @@ -68,8 +72,14 @@ public final class BukkitBlockState implements PlatformBlockState { if (data instanceof IrisCustomData custom) { return new BukkitBlockState(data, custom.getAsString()); } + BukkitBlockState fast = DATA_CACHE.get(data); + if (fast != null) { + return fast; + } String key = data.getAsString(); - return CACHE.computeIfAbsent(key, (String k) -> new BukkitBlockState(data, k)); + BukkitBlockState state = CACHE.computeIfAbsent(key, (String k) -> new BukkitBlockState(data, k)); + DATA_CACHE.putIfAbsent(data, state); + return state; } @Override @@ -361,6 +371,12 @@ public final class BukkitBlockState implements PlatformBlockState { public PlatformBlockState withProperty(String name, String value) { String merged = mergeProperty(key, name, value); BlockData resolved = Bukkit.createBlockData(merged); + // Re-attach the custom identity (as the proxy's own merge/clone cases do): the key of + // a custom state is the BASE block's string, so rebuilding from it alone silently + // downgraded custom blocks to vanilla on e.g. auto-waterlogging. + if (data instanceof IrisCustomData custom) { + return of(IrisCustomData.of(resolved, custom.getCustom())); + } return of(resolved); } diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/IrisStructureHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/IrisStructureHandler.java index 9127d0653..45526850f 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/IrisStructureHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/IrisStructureHandler.java @@ -20,7 +20,7 @@ public final class IrisStructureHandler implements DirectorParameterHandler(keys); diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java index afde6beb2..ec8669a4c 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java @@ -38,7 +38,7 @@ public class ObjectHandler implements DirectorParameterHandler { } for (File i : PackDirectoryResolver.listVisiblePackDirectories( - IrisPlatforms.get().dataFolder("packs"))) { + IrisPlatforms.get().packsFolder())) { data = IrisData.get(i); p.add(data.getObjectLoader().getPossibleKeys()); } diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java index a220e97d4..665c01533 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java @@ -42,7 +42,7 @@ public class ObjectTargetHandler implements DirectorParameterHandler { collectPrefixes(k, prefixes); } } else { - File packsFolder = IrisPlatforms.get().dataFolder("packs"); + File packsFolder = IrisPlatforms.get().packsFolder(); for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { IrisData d = IrisData.get(pack); for (String k : d.getObjectLoader().getPossibleKeys()) { diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java index 52ea862c4..c11a99aff 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java @@ -36,7 +36,7 @@ public abstract class RegistrantHandler implements Dir } for (File i : PackDirectoryResolver.listVisiblePackDirectories( - IrisPlatforms.get().dataFolder("packs"))) { + IrisPlatforms.get().packsFolder())) { data = IrisData.get(i); for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) { if (known.add(j.getLoadKey())) diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/getHardware.java b/core/src/main/java/art/arcane/iris/util/common/misc/getHardware.java index 552288c54..0c470beaf 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/getHardware.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/getHardware.java @@ -154,7 +154,7 @@ public class getHardware { for (Display display : displays) { systemEDID.add("Display: " + display.getEdid()); } - if (!systemEDID.isEmpty()) { + if (systemEDID.isEmpty()) { systemEDID.add("No displays"); } return systemEDID.copy(); @@ -174,8 +174,8 @@ public class getHardware { for (InetAddress ia : Collections.list(inetAddresses)) { interfaces.add("IP: %s", ia.getHostAddress()); } - return interfaces.copy(); } + return interfaces.copy(); } catch (Exception e) { e.printStackTrace(); } diff --git a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java index ddd1e5da8..5de5b9165 100644 --- a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java +++ b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java @@ -48,10 +48,8 @@ import org.bukkit.plugin.Plugin; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; /** * Represents a volume sender. A command sender with extra crap in it @@ -316,10 +314,12 @@ public class VolmitSender implements CommandSender { } public void showWaiting(String passive, CompletableFuture f) { - AtomicInteger v = new AtomicInteger(); - AtomicReference g = new AtomicReference<>(); + // isDone() alone is the completion signal: it covers normal, exceptional AND + // cancelled completion, where the old null-value gate spun forever on a + // CompletableFuture or a failed future. + AtomicInteger v = new AtomicInteger(-1); v.set(J.ar(() -> { - if (f.isDone() && g.get() != null) { + if (f.isDone()) { J.car(v.get()); sendAction(" "); return; @@ -327,16 +327,6 @@ public class VolmitSender implements CommandSender { sendProgress(-1, passive, HudSurface.TITLE, HudSurface.ACTION_BAR); }, 0)); - J.a(() -> { - try { - g.set(f.get()); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - }); - } @Override diff --git a/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/ChunkTickets.java b/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/ChunkTickets.java index 6d92a60c4..4e17412cf 100644 --- a/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/ChunkTickets.java +++ b/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/ChunkTickets.java @@ -11,11 +11,13 @@ import org.bukkit.event.Listener; import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class ChunkTickets implements Listener { - private final Map holders = new HashMap<>(); + // Concurrent + self-healing: mutated from event handlers and read from command threads, + // and a holder dropped by an aborted unload must recreate on next use, not NPE forever. + private final Map holders = new ConcurrentHashMap<>(); public ChunkTickets() { BukkitPlatform.volmitPlugin().registerListener(this); @@ -23,7 +25,7 @@ public class ChunkTickets implements Listener { } public TicketHolder getHolder(@NonNull World world) { - return holders.get(world); + return holders.computeIfAbsent(world, TicketHolder::new); } public void addTicket(@NonNull Chunk chunk) { @@ -50,8 +52,11 @@ public class ChunkTickets implements Listener { holders.put(event.getWorld(), new TicketHolder(event.getWorld())); } - @EventHandler(priority = EventPriority.MONITOR) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void on(@NonNull WorldUnloadEvent event) { - holders.remove(event.getWorld()); + TicketHolder holder = holders.remove(event.getWorld()); + if (holder != null) { + holder.releaseAll(); + } } } diff --git a/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/TicketHolder.java b/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/TicketHolder.java index db7472e26..29d3cfde4 100644 --- a/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/TicketHolder.java +++ b/core/src/main/java/art/arcane/iris/util/common/plugin/chunk/TicketHolder.java @@ -45,4 +45,16 @@ public class TicketHolder { return ref; }) == null; } + + public void releaseAll() { + tickets.forEach((key, refs) -> { + int x = Cache.keyX(key); + int z = Cache.keyZ(key); + try { + world.removePluginChunkTicket(x, z, BukkitPlatform.plugin()); + } catch (Throwable ignored) { + } + }); + tickets.clear(); + } } 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 e8b99e8fc..69bce7cbd 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 @@ -581,7 +581,14 @@ public class J { return; } - r.run(); + // A throwing body must not kill the loop (Bukkit's own runTaskTimer survives + // throws) or leak the canceller entry; the re-arm is unconditional. + try { + r.run(); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + } if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; @@ -590,7 +597,9 @@ public class J { s(loop[0], safeInterval); }; - s(loop[0]); + // Delay 1, never inline: an immediate first run can execute before the caller has + // stored the returned id, making the body's own cancel() target a nonexistent id. + s(loop[0], 1); return taskId; } @@ -662,7 +671,14 @@ public class J { return; } - r.run(); + // A throwing body must not kill the loop (Bukkit's own runTaskTimer survives + // throws) or leak the canceller entry; the re-arm is unconditional. + try { + r.run(); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + } if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; @@ -671,7 +687,7 @@ public class J { a(loop[0], safeInterval); }; - a(loop[0], 0); + a(loop[0], 1); return taskId; } diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicHunk.java b/core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicHunk.java index a1edac7b1..2df03cf79 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicHunk.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicHunk.java @@ -29,10 +29,16 @@ import java.util.concurrent.atomic.AtomicReferenceArray; @EqualsAndHashCode(callSuper = false) public class AtomicHunk extends StorageHunk implements Hunk { private final AtomicReferenceArray data; + // index() runs per block access on the hottest paths; two final-field multiplies beat + // re-deriving the strides through the accessors every call. + private final int xStride; + private final int zStride; public AtomicHunk(int w, int h, int d) { super(w, h, d); data = new AtomicReferenceArray<>(w * h * d); + xStride = w; + zStride = w * h; } @Override @@ -51,6 +57,6 @@ public class AtomicHunk extends StorageHunk implements Hunk { } private int index(int x, int y, int z) { - return (z * getWidth() * getHeight()) + (y * getWidth()) + x; + return (z * zStride) + (y * xStride) + x; } } diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java index e6d7c57b3..f9e1f7e56 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java @@ -49,8 +49,14 @@ public class ChunkDataHunkHolder extends AtomicHunk { } @Override - public int getHeight() { - return chunk.getMaxHeight() - chunk.getMinHeight(); + public void setRaw(int x, int y, int z, PlatformBlockState t) { + // Block-hunk contract: null means "no write", never "erase" — ChunkDataHunkView and + // the modded ModdedBlockBuffer already discard nulls, and storing one here made the + // Bukkit output diverge from the modded loaders for the same engine emission. + if (t == null) { + return; + } + super.setRaw(x, y, z, t); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java b/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java index a69e2071a..2bfaea3aa 100644 --- a/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java +++ b/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java @@ -602,15 +602,25 @@ public class IrisInterpolation { } public static double getNoise(InterpolationMethod method, int x, int z, double h, NoiseProvider noise) { - final NoiseProvider n; if (usesSampleCache(method)) { NoiseSampleCache2D cache = NOISE_SAMPLE_CACHE_2D.get(); - cache.clear(); - n = (x1, z1) -> cache.getOrSample(x1 - x, z1 - z, x1, z1, noise); - } else { - n = noise; + if (!cache.isInUse()) { + cache.beginUse(); + try { + return dispatch(method, x, z, h, (x1, z1) -> cache.getOrSample(x1 - x, z1 - z, x1, z1, noise)); + } finally { + cache.endUse(); + } + } + // Nested interpolation on this thread (image maps, interpolated noise styles): + // run unmemoized. Sharing the outer table would clear it mid-flight and serve + // this provider's samples to the outer pass. Providers are pure, so the + // unmemoized result is bit-identical. } + return dispatch(method, x, z, h, noise); + } + private static double dispatch(InterpolationMethod method, int x, int z, double h, NoiseProvider n) { return switch (method) { case BILINEAR -> getBilinearNoise(x, z, h, n); case STARCAST_3 -> Starcast.starcast(x, z, h, 3D, n); @@ -652,7 +662,12 @@ public class IrisInterpolation { public static NoiseBounds getNoiseBounds(InterpolationMethod method, int x, int z, double h, NoiseBoundsProvider noise) { NoiseBoundsSampleCache2D cache = NOISE_BOUNDS_SAMPLE_CACHE_2D.get(); - cache.clear(); + if (cache.isInUse()) { + // Nested bounds interpolation: a fresh table on the rare nested path, never the + // outer pass's table — its entries belong to a different bound provider. + cache = new NoiseBoundsSampleCache2D(64); + } + cache.beginUse(); NoiseBoundsProvider previous = cache.bindProvider(noise); try { double min = getNoise(method, x, z, h, cache.minView()); @@ -660,6 +675,7 @@ public class IrisInterpolation { return new NoiseBounds(min, max); } finally { cache.bindProvider(previous); + cache.endUse(); } } diff --git a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java index 49e30f610..7697153bc 100644 --- a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java @@ -42,6 +42,7 @@ final class NoiseBoundsSampleCache2D { private int mask; private int resizeThreshold; private int size; + private boolean inUse; public NoiseBoundsSampleCache2D(int initialCapacity) { int minimumCapacity = Math.max(8, initialCapacity); @@ -64,6 +65,23 @@ final class NoiseBoundsSampleCache2D { size = 0; } + /** + * Bounds interpolation nests on one thread; a nested pass must never reuse the outer + * pass's table, since entries belong to one specific bound provider. + */ + public boolean isInUse() { + return inUse; + } + + public void beginUse() { + inUse = true; + clear(); + } + + public void endUse() { + inUse = false; + } + /** * Binds the provider that {@link #minView()} and {@link #maxView()} delegate to, returning the * previously bound provider so callers can restore it. @@ -99,7 +117,8 @@ final class NoiseBoundsSampleCache2D { } NoiseBounds bounds = provider.noise(sampleX, sampleZ); - insert(slot, xBitsValue, zBitsValue, bounds.min(), bounds.max()); + // Recompute the slot: the provider call can mutate the table (clear/grow) via nesting. + insert(findSlot(xBitsValue, zBitsValue), xBitsValue, zBitsValue, bounds.min(), bounds.max()); return bounds.min(); } @@ -112,7 +131,8 @@ final class NoiseBoundsSampleCache2D { } NoiseBounds bounds = provider.noise(sampleX, sampleZ); - insert(slot, xBitsValue, zBitsValue, bounds.min(), bounds.max()); + // Recompute the slot: the provider call can mutate the table (clear/grow) via nesting. + insert(findSlot(xBitsValue, zBitsValue), xBitsValue, zBitsValue, bounds.min(), bounds.max()); return bounds.max(); } diff --git a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java index e8ecf5203..e88b168ee 100644 --- a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java @@ -35,6 +35,7 @@ final class NoiseSampleCache2D { private int mask; private int resizeThreshold; private int size; + private boolean inUse; public NoiseSampleCache2D(int initialCapacity) { int minimumCapacity = Math.max(8, initialCapacity); @@ -56,6 +57,24 @@ final class NoiseSampleCache2D { size = 0; } + /** + * Interpolation nests on one thread (image maps and interpolated noise styles re-enter + * {@code IrisInterpolation.getNoise} from inside a provider), and a nested pass sharing + * this table would clear it mid-flight and cross-serve another provider's samples. + */ + public boolean isInUse() { + return inUse; + } + + public void beginUse() { + inUse = true; + clear(); + } + + public void endUse() { + inUse = false; + } + public double getOrSample(double relativeX, double relativeZ, double sampleX, double sampleZ, NoiseProvider provider) { long rx = Double.doubleToLongBits(relativeX); long rz = Double.doubleToLongBits(relativeZ); @@ -65,7 +84,9 @@ final class NoiseSampleCache2D { } double value = provider.noise(sampleX, sampleZ); - insert(slot, rx, rz, value); + // Recompute the slot: the provider call may have mutated the table (clear/grow), so + // the pre-call index can point into stale or reallocated arrays. + insert(findSlot(rx, rz), rx, rz, value); return value; } diff --git a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java index 9574f36a4..a4b5eea2e 100644 --- a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java +++ b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java @@ -37,6 +37,8 @@ import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.List; @Data @@ -55,7 +57,6 @@ public class CNG { private double scale; private double bakedScale; private double fscale; - private boolean trueFracturing = false; private KList children; private CNG fracture; private FloatCache cache; @@ -278,16 +279,28 @@ public class CNG { } } + // Write to a unique temp name (excluded from the *.cnm stale-entry pruner) and + // move atomically: a throw mid-write must never leak the descriptor or leave a + // truncated .cnm that the read path would pick up by existence alone. The name is + // per-writer unique so two engines caching the same style key cannot interleave + // into one temp file. + File tmp = new File(f.getParentFile(), f.getName() + "." + Long.toUnsignedString(System.nanoTime(), 36) + + "-" + Long.toUnsignedString(Thread.currentThread().threadId(), 36) + ".tmp"); try { f.getParentFile().mkdirs(); - FileOutputStream fos = new FileOutputStream(f); - DataOutputStream dos = new DataOutputStream(fos); - fbc.writeCache(dos); - dos.close(); + try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(tmp))) { + fbc.writeCache(dos); + } + Files.move(tmp.toPath(), f.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); if (!quiet) { IrisLogging.info("Saved Noise Cache " + f.getName()); } } catch (IOException e) { + try { + Files.deleteIfExists(tmp.toPath()); + } catch (IOException ignored) { + } throw new RuntimeException(e); } } @@ -648,13 +661,6 @@ public class CNG { (dim.length > 2 ? dim[2] : 0D) * scale) * opacity; } - if (fracture.isTrueFracturing()) { - double x = dim.length > 0 ? dim[0] + ((fracture.noise(dim) - 0.5) * fscale) : 0D; - double y = dim.length > 1 ? dim[1] + ((fracture.noise(dim[1], dim[0]) - 0.5) * fscale) : 0D; - double z = dim.length > 2 ? dim[2] + ((fracture.noise(dim[2], dim[0], dim[1]) - 0.5) * fscale) : 0D; - return generator.noise(x * scale, y * scale, z * scale) * opacity; - } - double x = dim.length > 0 ? dim[0] : 0D; double y = dim.length > 1 ? dim[1] : 0D; double z = dim.length > 2 ? dim[2] : 0D; diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java index 5f5c24d1b..9d7f90946 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java @@ -67,8 +67,6 @@ import art.arcane.iris.util.project.stream.utility.CachedStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream3D; import art.arcane.iris.util.project.stream.utility.ContextInjectingStream; import art.arcane.iris.util.project.stream.utility.NullSafeStream; -import art.arcane.iris.util.project.stream.utility.ProfiledStream; -import art.arcane.iris.util.project.stream.utility.SemaphoreStream; import art.arcane.iris.util.project.stream.utility.SynchronizedStream; import java.util.ArrayList; @@ -117,14 +115,6 @@ public interface ProceduralStream extends ProceduralLayer, Interpolated { }, Interpolated.BOOLEAN); } - default ProceduralStream profile() { - return profile(256); - } - - default ProceduralStream profile(int memory) { - return new ProfiledStream<>(this, memory); - } - default ProceduralStream onNull(T v) { return new NullSafeStream<>(this, v); } @@ -150,11 +140,6 @@ public interface ProceduralStream extends ProceduralLayer, Interpolated { return add2D((x, z) -> a.get(x, z)); } - default ProceduralStream waste(String name) { - return this; - //return new WasteDetector(this, name); - } - default ProceduralStream subtract(ProceduralStream a) { return subtract2D((x, z) -> a.get(x, z)); } @@ -203,10 +188,6 @@ public interface ProceduralStream extends ProceduralLayer, Interpolated { return new SynchronizedStream<>(this); } - default ProceduralStream semaphore(int permits) { - return new SemaphoreStream<>(this, permits); - } - default ProceduralStream bitShiftCoordsLeft(int a) { return new CoordinateBitShiftLeftStream<>(this, a); } diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/ProfiledStream.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/ProfiledStream.java deleted file mode 100644 index c22f73bac..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/ProfiledStream.java +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 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.util.project.stream.utility; - -import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.math.RollingSequence; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import art.arcane.iris.util.project.stream.BasicStream; -import art.arcane.iris.util.project.stream.ProceduralStream; -import lombok.Data; - -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -public class ProfiledStream extends BasicStream { - public static final AtomicInteger ids = new AtomicInteger(); - public static final KList> profiles = new KList<>(); - private final int id; - private final RollingSequence metrics; - - public ProfiledStream(ProceduralStream stream, int memory) { - super(stream); - this.metrics = new RollingSequence(memory); - this.id = ids.getAndAdd(1); - profiles.add(this); - } - - public static void print(Consumer printer, ProceduralStream stream) { - KList tails = getTails(stream); - int ind = tails.size(); - for (ProfiledTail i : tails) { - printer.accept(Form.repeat(" ", ind) + i); - ind--; - } - } - - private static KList> getAllChildren(ProceduralStream s) { - KList> v = new KList<>(); - ProceduralStream cursor = s; - - for (int i = 0; i < 32; i++) { - v.add(cursor); - cursor = nextChuld(cursor); - - if (cursor == null) { - break; - } - } - - return v; - } - - private static ProceduralStream nextChuld(ProceduralStream s) { - ProceduralStream v = s.getTypedSource(); - return v == null ? s.getSource() : v; - } - - private static ProfiledTail getTail(ProceduralStream t) { - if (t instanceof ProfiledStream s) { - - return new ProfiledTail(s.getId(), s.getMetrics(), s.getClass().getSimpleName().replaceAll("\\QStream\\E", "")); - } - - return null; - } - - private static KList getTails(ProceduralStream t) { - KList tails = new KList<>(); - - for (ProceduralStream v : getAllChildren(t)) { - ProfiledTail p = getTail(v); - - if (p != null) { - tails.add(p); - } - } - - if (tails.isEmpty()) { - return null; - } - - ProfiledTail cursor = tails.popLast(); - KList tailx = new KList<>(); - tailx.add(cursor); - - while (tails.isNotEmpty()) { - tailx.add(cursor); - ProfiledTail parent = tails.popLast(); - parent.setChild(cursor); - cursor = parent; - tailx.add(cursor); - } - - return tailx; - } - - public int getId() { - return id; - } - - @Override - public double toDouble(T t) { - return getTypedSource().toDouble(t); - } - - @Override - public T fromDouble(double d) { - return getTypedSource().fromDouble(d); - } - - @Override - public T get(double x, double z) { - PrecisionStopwatch p = PrecisionStopwatch.start(); - T t = getTypedSource().get(x, z); - try { - metrics.put(p.getMilliseconds()); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - - return t; - } - - @Override - public T get(double x, double y, double z) { - PrecisionStopwatch p = PrecisionStopwatch.start(); - T t = getTypedSource().get(x, y, z); - try { - metrics.put(p.getMilliseconds()); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - - return t; - } - - public RollingSequence getMetrics() { - return metrics; - } - - @Data - private static class ProfiledTail { - private final int id; - private final RollingSequence metrics; - private final String name; - private ProfiledTail child; - - public ProfiledTail(int id, RollingSequence metrics, String name) { - this.id = id; - this.metrics = metrics; - this.name = name; - } - - public String toString() { - return id + "-" + name + ": " + Form.duration(metrics.getAverage(), 2); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/SemaphoreStream.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/SemaphoreStream.java deleted file mode 100644 index af4f89222..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/SemaphoreStream.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 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.util.project.stream.utility; - -import art.arcane.iris.util.project.stream.BasicStream; -import art.arcane.iris.util.project.stream.ProceduralStream; -import java.util.concurrent.Semaphore; - -public class SemaphoreStream extends BasicStream { - private final Semaphore semaphore; - - public SemaphoreStream(ProceduralStream stream, int permits) { - super(stream); - this.semaphore = new Semaphore(permits); - } - - @Override - public double toDouble(T t) { - return getTypedSource().toDouble(t); - } - - @Override - public T fromDouble(double d) { - return getTypedSource().fromDouble(d); - } - - @Override - public T get(double x, double z) { - try { - semaphore.acquire(); - T t = getTypedSource().get(x, z); - semaphore.release(); - return t; - } catch (InterruptedException e) { - e.printStackTrace(); - } - - return null; - } - - @Override - public T get(double x, double y, double z) { - try { - semaphore.acquire(); - T t = getTypedSource().get(x, y, z); - semaphore.release(); - return t; - } catch (InterruptedException e) { - e.printStackTrace(); - } - - return null; - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/WasteDetector.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/WasteDetector.java deleted file mode 100644 index 74b351e37..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/WasteDetector.java +++ /dev/null @@ -1,73 +0,0 @@ -package art.arcane.iris.util.project.stream.utility; - -import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.iris.util.project.stream.BasicStream; -import art.arcane.iris.util.project.stream.ProceduralStream; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -public class WasteDetector extends BasicStream { - public static final boolean checking = false; - private static final KMap allAccesses = new KMap<>(); - private static final KMap> allThrows = new KMap<>(); - private final AtomicInteger accesses; - private final String name; - - public WasteDetector(ProceduralStream stream, String name) { - super(stream); - this.name = name; - accesses = new AtomicInteger(0); - } - - public static void printAll() { - if (checking) { - IrisLogging.warn("========================================================="); - for (String i : allAccesses.sortKNumber().reverse()) { - IrisLogging.warn(i + ": " + allAccesses.get(i) + " Time(s)"); - } - IrisLogging.warn("========================================================="); - for (String i : allAccesses.sortKNumber().reverse()) { - IrisLogging.warn("======== " + i + " ========"); - for (Throwable j : allThrows.get(i)) { - j.printStackTrace(); - } - IrisLogging.warn("---------------------------------------------------------"); - } - IrisLogging.warn("========================================================="); - } - } - - @Override - public T get(double x, double z) { - if (checking) { - if (x == 7 && z == 7) { - // AHHHAAA! - allAccesses.compute(name, (k, v) -> v == null ? 1 : v + 1); - try { - throw new RuntimeException(); - } catch (RuntimeException e) { - allThrows.computeIfAbsent(name, (k) -> new KList<>()).add(e); - } - } - - } - return getTypedSource().get(x, z); - } - - @Override - public T get(double x, double y, double z) { - return getTypedSource().get(x, y, z); - } - - @Override - public double toDouble(T t) { - return getTypedSource().toDouble(t); - } - - @Override - public T fromDouble(double d) { - return getTypedSource().fromDouble(d); - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/U.java b/core/src/main/java/art/arcane/iris/util/project/uniques/U.java deleted file mode 100644 index 5bce53eb2..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/U.java +++ /dev/null @@ -1,13 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import java.io.File; - -public class U { - public static void main(String[] a) { - UniqueRenderer r = new UniqueRenderer("helloworld", 2560, 1440); - - r.writeCollectionFrames(new File("collection"), 1, 1024); - - System.exit(0); - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UBufferedImage.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UBufferedImage.java deleted file mode 100644 index 8f5fc0c5b..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UBufferedImage.java +++ /dev/null @@ -1,46 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import java.awt.Color; -import java.awt.image.BufferedImage; -import java.awt.image.ColorModel; -import java.awt.image.WritableRaster; - -public class UBufferedImage implements UImage { - private final BufferedImage buf; - - public UBufferedImage(BufferedImage buf) { - this.buf = buf; - } - - @Override - public int getWidth() { - return buf.getWidth(); - } - - @Override - public int getHeight() { - return buf.getHeight(); - } - - @Override - public UImage copy() { - ColorModel cm = buf.getColorModel(); - boolean isAlphaPremultiplied = cm.isAlphaPremultiplied(); - WritableRaster raster = buf.copyData(null); - return new UBufferedImage(new BufferedImage(cm, raster, isAlphaPremultiplied, null)); - } - - @Override - public Color get(int x, int y) { - return new Color(buf.getRGB(x, y)); - } - - @Override - public void set(int x, int y, Color color) { - try { - buf.setRGB(x, y, color.getRGB()); - } catch (Throwable e) { - - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UFeature.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UFeature.java deleted file mode 100644 index 981366424..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UFeature.java +++ /dev/null @@ -1,64 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import art.arcane.volmlib.util.function.NoiseInjector; -import art.arcane.iris.util.project.interpolation.InterpolationMethod; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import java.awt.Color; -import java.util.List; -import java.util.function.Consumer; - -public interface UFeature { - List injectors = List.of( - CNG.ADD, - CNG.DST_MOD, - CNG.DST_POW, - CNG.DST_SUBTRACT, - CNG.MAX, - CNG.MIN, - CNG.SRC_MOD, - CNG.SRC_POW, - CNG.SRC_SUBTRACT, - CNG.MULTIPLY - ); - - void render(UImage image, RNG rng, double time, Consumer progressor, UFeatureMeta meta); - - default Color color(CNG hue, CNG saturation, CNG brightness, double x, double y, double t) { - return Color.getHSBColor((float) hue.fitDouble(0, 1, x + t, y + t), - (float) saturation.fitDouble(0, 1, x + t, y + t), - (float) brightness.fitDouble(0, 1, x + t, y + t)); - } - - default InterpolationMethod interpolator(RNG rng) { - return rng.pick( - UniqueRenderer.renderer.getInterpolators() - ); - } - - default CNG generator(String key, RNG rng, double scaleMod, long salt, UFeatureMeta meta) { - return generator(key, rng, scaleMod, rng.i(1, 3), rng.i(1, 5), salt, meta); - } - - default CNG generator(String key, RNG rng, double scaleMod, int fractures, int composites, long salt, UFeatureMeta meta) { - RNG rngg = rng.nextParallelRNG(salt); - CNG cng = rng.pick(UniqueRenderer.renderer.getStyles()).create(rngg).oct(rng.i(1, 5)); - RNG rngf = rngg.nextParallelRNG(-salt); - cng.scale(rngf.d(0.33 * scaleMod, 1.66 * scaleMod)); - - if (fractures > 0) { - cng.fractureWith(generator(null, rngf.nextParallelRNG(salt + fractures), scaleMod / rng.d(4, 17), fractures - 1, composites, salt + fractures + 55, null), scaleMod * rngf.nextDouble(16, 256)); - } - - for (int i = 0; i < composites; i++) { - CNG sub = generator(null, rngf.nextParallelRNG(salt + fractures), scaleMod * rngf.d(0.4, 3.3), fractures / 3, 0, salt + fractures + composites + 78, null); - sub.setInjector(rng.pick(injectors)); - cng.child(sub); - } - - if (key != null && meta != null) { - meta.registerGenerator(key, cng); - } - return cng; - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UFeatureMeta.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UFeatureMeta.java deleted file mode 100644 index 64970e01b..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UFeatureMeta.java +++ /dev/null @@ -1,101 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.iris.util.project.interpolation.InterpolationMethod; -import art.arcane.iris.util.project.noise.CNG; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Data -@NoArgsConstructor -public class UFeatureMeta { - private KMap interpolators; - private KMap generators; - private String feature; - - public void registerInterpolator(String key, InterpolationMethod method, double radius) { - if (interpolators == null) { - interpolators = new KMap<>(); - } - interpolators.put(key, new UFeatureMetaInterpolator(method, radius)); - } - - public void registerGenerator(String key, CNG cng) { - if (generators == null) { - generators = new KMap<>(); - } - generators.put(key, buildGenerator(cng)); - } - - public UFeatureMetaGenerator buildGenerator(CNG cng) { - UFeatureMetaGenerator g = new UFeatureMetaGenerator(); - g.setScale(cng.getScale()); - g.setOctaves(cng.getOct()); - - if (cng.getFracture() != null) { - g.setFracture(buildGenerator(cng.getFracture())); - g.setFractureMultiplier(cng.getFscale()); - } - - if (cng.getChildren() != null && cng.getChildren().isNotEmpty()) { - g.setChildren(new KList<>()); - - for (CNG i : cng.getChildren()) { - g.getChildren().add(buildGenerator(i)); - } - } - - if (cng.getInjector() == CNG.ADD) { - g.setParentInject("add"); - } else if (cng.getInjector() == CNG.SRC_SUBTRACT) { - g.setParentInject("src_subtract"); - } else if (cng.getInjector() == CNG.DST_SUBTRACT) { - g.setParentInject("dst_subtract"); - } else if (cng.getInjector() == CNG.MULTIPLY) { - g.setParentInject("multiply"); - } else if (cng.getInjector() == CNG.MAX) { - g.setParentInject("max"); - } else if (cng.getInjector() == CNG.MIN) { - g.setParentInject("min"); - } else if (cng.getInjector() == CNG.SRC_MOD) { - g.setParentInject("src_mod"); - } else if (cng.getInjector() == CNG.SRC_POW) { - g.setParentInject("src_pow"); - } else if (cng.getInjector() == CNG.DST_MOD) { - g.setParentInject("dst_mod"); - } else if (cng.getInjector() == CNG.DST_POW) { - g.setParentInject("dst_pow"); - } - - return g; - } - - public boolean isEmpty() { - return (interpolators == null || interpolators.isEmpty()) && (generators == null || generators.isEmpty()); - } - - @Data - @NoArgsConstructor - @AllArgsConstructor - static class UFeatureMetaInterpolator { - private InterpolationMethod interpolator; - private double radius; - } - - @Data - @NoArgsConstructor - static class UFeatureMetaGenerator { - private NoiseStyle style; - private int octaves = 1; - private double scale = 1; - private String parentInject; - private UFeatureMetaGenerator fracture; - private Double fractureMultiplier; - private List children; - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UImage.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UImage.java deleted file mode 100644 index 30dc4d39d..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UImage.java +++ /dev/null @@ -1,19 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import java.awt.Color; - -public interface UImage { - int getWidth(); - - int getHeight(); - - default boolean isInBounds(int x, int y) { - return x >= 0 && x < getWidth() && y >= 0 && y < getHeight(); - } - - UImage copy(); - - Color get(int x, int y); - - void set(int x, int y, Color color); -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UMeta.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UMeta.java deleted file mode 100644 index 7053371ef..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UMeta.java +++ /dev/null @@ -1,45 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import com.google.gson.GsonBuilder; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.volmlib.util.io.IO; -import lombok.Data; -import lombok.NoArgsConstructor; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; - -@Data -@NoArgsConstructor -public class UMeta { - private transient BufferedImage image; - private KMap features; - private long id; - private double time; - private int width; - private int height; - - public void registerFeature(String key, UFeatureMeta feature) { - if (features == null) { - features = new KMap<>(); - } - - features.put(key, feature); - } - - public void export(File destination) throws IOException { - - for (String i : features.k()) { - if (features.get(i).isEmpty()) { - features.remove(i); - } - } - - width = image.getWidth(); - height = image.getHeight(); - ImageIO.write(image, "PNG", destination); - IO.writeAll(new File(destination.getParentFile(), destination.getName() + ".json"), new GsonBuilder().setPrettyPrinting().create().toJson(this)); - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/UniqueRenderer.java b/core/src/main/java/art/arcane/iris/util/project/uniques/UniqueRenderer.java deleted file mode 100644 index 97a230a56..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/UniqueRenderer.java +++ /dev/null @@ -1,302 +0,0 @@ -package art.arcane.iris.util.project.uniques; - -import art.arcane.iris.engine.framework.PreservationRegistry; -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.spi.IrisServices; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.function.NoiseProvider; -import art.arcane.iris.util.project.interpolation.InterpolationMethod; -import art.arcane.iris.util.project.interpolation.IrisInterpolation; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.volmlib.util.scheduling.ChronoLatch; -import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import art.arcane.iris.util.project.stream.ProceduralStream; -import art.arcane.iris.util.project.uniques.features.UFInterpolator; -import art.arcane.iris.util.project.uniques.features.UFNOOP; -import art.arcane.iris.util.project.uniques.features.UFWarpedBackground; -import art.arcane.iris.util.project.uniques.features.UFWarpedCircle; -import art.arcane.iris.util.project.uniques.features.UFWarpedDisc; -import art.arcane.iris.util.project.uniques.features.UFWarpedDots; -import art.arcane.iris.util.project.uniques.features.UFWarpedLines; - -import java.awt.image.BufferedImage; -import java.io.File; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -public class UniqueRenderer { - static final List backgrounds = List.of(new UFWarpedBackground()); - static final List interpolators = List.of(new UFInterpolator(), new UFNOOP()); - static final List features = List.of(new UFWarpedLines(), new UFWarpedDisc(), new UFWarpedDots(), new UFWarpedCircle()); - static UniqueRenderer renderer; - private final String seed; - private final ProceduralStream spatialSeed; - private final int width; - private final int height; - private final KMap writing = new KMap<>(); - private final ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); - private final KList sortedStyles = new KList(); - private final KList sortedInterpolators = new KList(); - int cores = Runtime.getRuntime().availableProcessors(); - - public UniqueRenderer(String seed, int width, int height) { - renderer = this; - PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); - if (preservation != null) { - preservation.register(executor); - } - computeNoiseStyles(3000, 2); - computeInterpolationMethods(3000, 2); - this.seed = seed; - this.width = width; - this.height = height; - spatialSeed = NoiseStyle.FRACTAL_WATER.stream(new RNG(seed)).convert((d) -> new RNG(Math.round(seed.hashCode() + (d * 934321234D)))); - new Thread(() -> { - while (true) { - J.sleep(5000); - - if (!writing.isEmpty()) { - IrisLogging.info("%s", Form.repeat("\n", 60)); - IrisLogging.info("%s", Form.memSize(Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(), 2) + " of " + Form.memSize(Runtime.getRuntime().totalMemory(), 2)); - KMap c = writing.copy(); - - for (String i : writing.k().sort()) { - String prog = ""; - String f = writing.get(i); - - if (f.contains("%")) { - String v = f.split("\\Q%\\E")[0]; - try { - prog = drawProgress(Double.valueOf(Integer.parseInt(v.substring(v.length() - 2))) / 100D, 30); - } catch (Throwable e) { - try { - prog = drawProgress(Double.valueOf(Integer.parseInt(v.substring(v.length() - 1))) / 100D, 30); - } catch (Throwable ee) { - try { - prog = drawProgress(Double.valueOf(Integer.parseInt(v.substring(v.length() - 3))) / 100D, 30); - } catch (Throwable eee) { - - } - } - } - } - - IrisLogging.info("%s", prog + " " + i + " => " + f); - } - } - } - }).start(); - } - - public UMeta renderFrameBuffer(long id, double t) { - UMeta meta = new UMeta(); - meta.setId(id); - meta.setTime(t); - RNG rng = spatialSeed.get(id, id + ((id * id) % (id / 3D))); - RNG rngbg = spatialSeed.get(id, -id + ((id * id) % (id / 4D))); - BufferedImage buf = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - BufferedImage bufFG = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - UImage image = new UBufferedImage(buf); - UImage imageFG = new UBufferedImage(bufFG); - ChronoLatch cl = new ChronoLatch(250); - UFeature background = rng.pick(backgrounds); - UFeature interpolator = rng.pick(interpolators); - UFeature foreground = rng.pick(features); - UFeature foregroundInterpolator = rng.pick(interpolators); - UFeatureMeta backgroundMeta = new UFeatureMeta(); - UFeatureMeta foregroundMeta = new UFeatureMeta(); - UFeatureMeta backgroundInterpolatorMeta = new UFeatureMeta(); - UFeatureMeta foregroundInterpolatorMeta = new UFeatureMeta(); - background.render(image, rngbg, t, (p) -> { - if (cl.flip()) { - writing.put("#" + id + ":" + t, Form.pc(p / 4D) + " [" + background.getClass().getSimpleName() + " " + Form.pc(p) + "]"); - } - }, backgroundMeta); - backgroundMeta.setFeature(background.getClass().getSimpleName()); - meta.registerFeature("background", backgroundMeta); - interpolator.render(image, rng, t, (p) -> { - if (cl.flip()) { - writing.put("#" + id + ":" + t, Form.pc(0.25 + (p / 4d)) + " [" + interpolator.getClass().getSimpleName() + " " + Form.pc(p) + "]"); - } - }, backgroundInterpolatorMeta); - backgroundInterpolatorMeta.setFeature(interpolator.getClass().getSimpleName()); - meta.registerFeature("backgroundInterpolator", backgroundInterpolatorMeta); - foreground.render(imageFG, rng, t, (p) -> { - if (cl.flip()) { - writing.put("#" + id + ":" + t, Form.pc(0.5 + (p / 4d)) + " [" + foreground.getClass().getSimpleName() + " " + Form.pc(p) + "]"); - } - }, foregroundMeta); - foregroundMeta.setFeature(foreground.getClass().getSimpleName()); - meta.registerFeature("foreground", foregroundMeta); - overlay(imageFG, bufFG, image); - foregroundInterpolator.render(image, rng, t, (p) -> { - if (cl.flip()) { - writing.put("#" + id + ":" + t, Form.pc(0.75 + (p / 4d)) + " [" + interpolator.getClass().getSimpleName() + " " + Form.pc(p) + "]"); - } - }, foregroundInterpolatorMeta); - foregroundInterpolatorMeta.setFeature(foregroundInterpolator.getClass().getSimpleName()); - meta.registerFeature("foregroundInterpolator", foregroundInterpolatorMeta); - overlay(imageFG, bufFG, image); - meta.setImage(buf); - writing.remove("#" + id + ":" + t); - return meta; - } - - private void overlay(UImage layer, BufferedImage layerBuf, UImage onto) { - for (int i = 0; i < onto.getWidth(); i++) { - for (int j = 0; j < onto.getHeight(); j++) { - if (layerBuf.getRGB(i, j) != 0) { - onto.set(i, j, layer.get(i, j)); - } - } - } - } - - private String drawProgress(double progress, int len) { - int max = len; - int in = (int) Math.round(progress * max); - max -= in; - - return "[" + Form.repeat("=", in) + Form.repeat(" ", max) + "]"; - } - - private void computeNoiseStyles(double time, double scope) { - List allowedStyles = new KList<>(NoiseStyle.values()); - allowedStyles.remove(NoiseStyle.FLAT); - KMap speeds = new KMap<>(); - double allocateMS = time; - double maxTestDuration = allocateMS / allowedStyles.size(); - IrisLogging.info("%s", "Running Noise Style Benchmark for " + Form.duration(allocateMS, 0) + "."); - IrisLogging.info("%s", "Benchmarking " + allowedStyles.size() + " + Noise Styles for " + Form.duration(maxTestDuration, 1) + " each."); - IrisLogging.info(""); - - for (NoiseStyle i : allowedStyles) { - int score = 0; - CNG cng = i.create(new RNG("renderspeedtest")); - PrecisionStopwatch p = PrecisionStopwatch.start(); - double g = 0; - while (p.getMilliseconds() < maxTestDuration) { - cng.noise(g, -g * 2); - g += 0.1; - g *= 1.25; - score++; - } - - speeds.put(i, score); - } - - for (NoiseStyle i : speeds.sortKNumber()) { - IrisLogging.info("%s", Form.capitalizeWords(i.name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " ")) + " => " + Form.f(speeds.get(i))); - } - IrisLogging.info(""); - int takeUpTo = (int) Math.max(1, scope * speeds.size()); - IrisLogging.info("%s", "Choosing the fastest " + Form.pc(scope) + " styles (" + takeUpTo + ")"); - - for (NoiseStyle i : speeds.sortKNumber().reverse()) { - if (takeUpTo-- <= 0) { - break; - } - - sortedStyles.add(i); - IrisLogging.info("%s", "- " + Form.capitalizeWords(i.name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "))); - } - } - - private void computeInterpolationMethods(double time, double scope) { - List allowedStyles = new KList<>(InterpolationMethod.values()); - allowedStyles.remove(InterpolationMethod.NONE); - KMap speeds = new KMap<>(); - double allocateMS = time; - double maxTestDuration = allocateMS / allowedStyles.size(); - IrisLogging.info("%s", "Running Interpolation Method Benchmark for " + Form.duration(allocateMS, 0) + "."); - IrisLogging.info("%s", "Benchmarking " + allowedStyles.size() + " + Interpolation Methods for " + Form.duration(maxTestDuration, 1) + " each."); - IrisLogging.info(""); - - RNG r = new RNG("renderspeedtestinterpolation"); - CNG cng = NoiseStyle.SIMPLEX.create(r); - NoiseProvider np = (x, z) -> cng.noise(x, z); - - for (InterpolationMethod i : allowedStyles) { - int score = 0; - - PrecisionStopwatch p = PrecisionStopwatch.start(); - double g = 0; - while (p.getMilliseconds() < maxTestDuration) { - IrisInterpolation.getNoise(i, (int) g, (int) (-g * 2.225), r.d(4, 64), np); - cng.noise(g, -g * 2); - g += 1.1; - g *= 1.25; - score++; - } - - speeds.put(i, score); - } - - for (InterpolationMethod i : speeds.sortKNumber()) { - IrisLogging.info("%s", Form.capitalizeWords(i.name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " ")) + " => " + Form.f(speeds.get(i))); - } - IrisLogging.info(""); - int takeUpTo = (int) Math.max(1, scope * speeds.size()); - IrisLogging.info("%s", "Choosing the fastest " + Form.pc(scope) + " interpolators (" + takeUpTo + ")"); - - for (InterpolationMethod i : speeds.sortKNumber().reverse()) { - if (takeUpTo-- <= 0) { - break; - } - - sortedInterpolators.add(i); - IrisLogging.info("%s", "- " + Form.capitalizeWords(i.name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "))); - } - } - - public void writeCollectionFrames(File folder, int fromId, int toId) { - folder.mkdirs(); - BurstExecutor burst = new BurstExecutor(executor, Math.min(toId - fromId, 1000)); - burst.setMulticore(true); - AtomicInteger ai = new AtomicInteger(0); - int max = toId - fromId; - - for (int i = fromId; i <= toId; i++) { - int ii = i; - burst.queue(() -> { - - writing.put("!#[" + fromId + "-" + toId + "] Collection", ai.get() + " of " + max + " (" + Form.pc(ai.get() / (double) max, 0) + ")"); - writeFrame(new File(folder, ii + ".png"), ii, 0); - ai.incrementAndGet(); - writing.put("!#[" + fromId + "-" + toId + "] Collection", ai.get() + " of " + max + " (" + Form.pc(ai.get() / (double) max, 0) + ")"); - }); - } - - burst.complete(); - writing.remove("!#[" + fromId + "-" + toId + "] Collection"); - } - - public void writeFrame(File destination, long id, double t) { - try { - renderFrameBuffer(id, t).export(destination); - } catch (Throwable e) { - e.printStackTrace(); - } - } - - public void report(String s) { - IrisLogging.info("%s", s); - } - - public KList getStyles() { - return sortedStyles; - } - - public List getInterpolators() { - return sortedInterpolators; - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFInterpolator.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFInterpolator.java deleted file mode 100644 index 4b3a38e1e..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFInterpolator.java +++ /dev/null @@ -1,59 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.function.NoiseProvider; -import art.arcane.iris.util.project.interpolation.InterpolationMethod; -import art.arcane.iris.util.project.interpolation.IrisInterpolation; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.awt.Color; -import java.util.function.Consumer; - -public class UFInterpolator implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - UImage ref = image.copy(); - CNG rmod = generator("interpolator_radius", rng, 1, 33004, meta); - - NoiseProvider nHue = (x, y) -> { - int ix = Math.abs(((int) x) % ref.getWidth()); - int iy = Math.abs(((int) y) % ref.getHeight()); - Color color = ref.get(ix, iy); - float[] hsv = new float[3]; - Color.RGBtoHSB(color.getRed(), color.getGreen(), color.getGreen(), hsv); - return hsv[0]; - }; - NoiseProvider nSat = (x, y) -> { - int ix = Math.abs(((int) x) % ref.getWidth()); - int iy = Math.abs(((int) y) % ref.getHeight()); - Color color = ref.get(ix, iy); - float[] hsv = new float[3]; - Color.RGBtoHSB(color.getRed(), color.getGreen(), color.getGreen(), hsv); - return hsv[1]; - }; - NoiseProvider nBri = (x, y) -> { - int ix = Math.abs(((int) x) % ref.getWidth()); - int iy = Math.abs(((int) y) % ref.getHeight()); - Color color = ref.get(ix, iy); - float[] hsv = new float[3]; - Color.RGBtoHSB(color.getRed(), color.getGreen(), color.getGreen(), hsv); - return hsv[2]; - }; - InterpolationMethod method = interpolator(rng); - int sizeMin = Math.min(image.getWidth(), image.getHeight()); - double radius = Math.max(4, rmod.fit(sizeMin / 256, sizeMin / 4, t * rng.d(0.03, 1.25), t * rng.d(0.01, 2.225))); - for (int i = 0; i < image.getWidth(); i++) { - for (int j = 0; j < image.getHeight(); j++) { - image.set(i, j, Color.getHSBColor( - (float) Math.max(Math.min(1D, IrisInterpolation.getNoise(method, i, j, radius, nHue)), 0D), - (float) Math.max(Math.min(1D, IrisInterpolation.getNoise(method, i, j, radius, nSat)), 0D), - (float) Math.max(Math.min(1D, IrisInterpolation.getNoise(method, i, j, radius, nBri)), 0D) - )); - } - - progressor.accept(i / (double) image.getWidth()); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFNOOP.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFNOOP.java deleted file mode 100644 index 9b341f8a9..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFNOOP.java +++ /dev/null @@ -1,13 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.util.function.Consumer; - -public class UFNOOP implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedBackground.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedBackground.java deleted file mode 100644 index e16d45db9..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedBackground.java +++ /dev/null @@ -1,26 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.util.function.Consumer; - -public class UFWarpedBackground implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - CNG hue = generator("color_hue", rng, rng.d(0.001, rng.d(2, 5)), rng.i(0, 3), rng.i(0, 3), 31007, meta); - CNG sat = generator("color_sat", rng, rng.d(0.001, rng.d(2, 5)), rng.i(0, 2), rng.i(0, 2), 33004, meta); - CNG bri = generator("color_bri", rng, rng.d(0.001, rng.d(2, 5)), rng.i(0, 1), rng.i(0, 1), 32005, meta).patch(0.145); - double tcf = rng.d(0.15, 0.55); - - for (int i = 0; i < image.getWidth(); i++) { - for (int j = 0; j < image.getHeight(); j++) { - image.set(i, j, color(hue, sat, bri, i, j, tcf * t)); - } - - progressor.accept(i / (double) image.getWidth()); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedCircle.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedCircle.java deleted file mode 100644 index e7fc5c1fa..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedCircle.java +++ /dev/null @@ -1,44 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.util.function.Consumer; - -public class UFWarpedCircle implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - double r = Math.min(image.getWidth(), image.getHeight()) / 2.5D; - double i, angle, x1, y1; - CNG xShift = generator("x_warp", rng, 0.6, 1001, meta); - CNG yShift = generator("y_warp", rng, 0.6, 1002, meta); - CNG hue = generator("color_hue", rng, rng.d(0.25, 2.5), 1003, meta); - CNG sat = generator("color_sat", rng, rng.d(0.25, 2.5), 1004, meta); - CNG bri = generator("color_bri", rng, rng.d(0.25, 2.5), 1005, meta); - double tcf = rng.d(0.75, 11.25); - double rcf = rng.d(7.75, 16.25); - int x = image.getWidth() / 2; - int y = image.getHeight() / 2; - - for (int d = 0; d < 256; d++) { - r -= Math.min(image.getWidth(), image.getHeight()) / 300D; - - if (r <= 0) { - return; - } - - for (i = 0; i < 360; i += 0.1) { - angle = i; - x1 = r * Math.cos(angle * Math.PI / 180); - y1 = r * Math.sin(angle * Math.PI / 180); - image.set((int) Math.round(x + x1 + xShift.fit(-r / 2, r / 2, x1 + (t + (d * 8)), -y1 + (t + (d * 8)))), - (int) Math.round(y + y1 + yShift.fit(-r / 2, r / 2, y1 + (t + (d * 8)), -x1 + (t + (d * 8)))), - color(hue, sat, bri, x1, y1, (t * tcf) + (d * rcf))); - } - - progressor.accept(d / 256D); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDisc.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDisc.java deleted file mode 100644 index 702f7ce14..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDisc.java +++ /dev/null @@ -1,35 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.util.function.Consumer; - -public class UFWarpedDisc implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - double r = Math.min(image.getWidth(), image.getHeight()) / 3D; - CNG xShift = generator("x_warp", rng, 0.6, 1001, meta); - CNG yShift = generator("y_warp", rng, 0.6, 1002, meta); - CNG hue = generator("color_hue", rng, rng.d(0.25, 2.5), 1003, meta); - CNG sat = generator("color_sat", rng, rng.d(0.25, 2.5), 1004, meta); - CNG bri = generator("color_bri", rng, rng.d(0.25, 2.5), 1005, meta); - double tcf = rng.d(0.75, 11.25); - int x = image.getWidth() / 2; - int y = image.getHeight() / 2; - - for (int i = (int) (x - r); i < x + r; i++) { - for (int j = (int) (y - r); j < y + r; j++) { - if (image.isInBounds(i, j) && Math.pow(x - i, 2) + Math.pow(y - j, 2) <= r * r) { - image.set(Math.round(i + xShift.fit(-r / 2, r / 2, i + t, -j + t)), - Math.round(j + yShift.fit(-r / 2, r / 2, j + t, -i + t)), - color(hue, sat, bri, i, j, tcf * t)); - } - } - - progressor.accept(i / (x + r)); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDots.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDots.java deleted file mode 100644 index df79d6ca6..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedDots.java +++ /dev/null @@ -1,43 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.awt.Color; -import java.util.function.Consumer; - -public class UFWarpedDots implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - CNG genX = generator("x_pos", rng, 4, 2000, meta); - CNG genY = generator("y_pos", rng, 4, 2001, meta); - - double tcf = rng.d(0.75, 11.25); - - for (int i = 1; i <= 8; i++) { - CNG xShift = generator("x_warp_" + i, rng, 2, 2006 + i, meta); - CNG yShift = generator("y_warp_" + i, rng, 2, 2007 + i, meta); - CNG hue = generator("color_hue_" + i, rng, rng.d(0.55, 3.5), 2003 + i, meta); - CNG sat = generator("color_sat_" + i, rng, rng.d(0.55, 3.5), 2004 + i, meta); - CNG bri = generator("color_bri_" + i, rng, rng.d(0.55, 3.5), 2005 + i, meta); - int x = genX.fit(0, image.getWidth(), i * 128, i * 5855); - int y = genY.fit(0, image.getHeight(), i * 128, i * 5855); - Color color = color(hue, sat, bri, x, y, t); - double r = Math.max(genX.fit(image.getWidth() / 10, image.getWidth() / 6, x, y), genY.fit(image.getHeight() / 10, image.getHeight() / 6, x, y)); - - for (int j = (int) (x - r); j < x + r; j++) { - for (int k = (int) (y - r); k < y + r; k++) { - if (image.isInBounds(j, k) && Math.pow(x - j, 2) + Math.pow(y - k, 2) <= r * r) { - image.set(Math.round(j + xShift.fit(-r / 2, r / 2, j + t, -k + t)), - Math.round(k + yShift.fit(-r / 2, r / 2, k + t, -j + t)), - color(hue, sat, bri, j, k, tcf * t)); - } - } - } - - progressor.accept(i / 32D); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedLines.java b/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedLines.java deleted file mode 100644 index 4e64e0ec3..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/uniques/features/UFWarpedLines.java +++ /dev/null @@ -1,42 +0,0 @@ -package art.arcane.iris.util.project.uniques.features; - -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.uniques.UFeature; -import art.arcane.iris.util.project.uniques.UFeatureMeta; -import art.arcane.iris.util.project.uniques.UImage; -import java.util.function.Consumer; - -public class UFWarpedLines implements UFeature { - @Override - public void render(UImage image, RNG rng, double t, Consumer progressor, UFeatureMeta meta) { - for (int g = 1; g < 5; g++) { - CNG xShift = generator("x_warp_" + g, rng, 0.6, 1001 * g, meta); - CNG yShift = generator("y_warp_" + g, rng, 0.6, 1002 * g, meta); - CNG cX = generator("x_clip_" + g, rng, rng.d(0.035, 0.6), 77001 * g, meta); - CNG cY = generator("y_clip" + g, rng, rng.d(0.035, 0.6), 77002, meta); - CNG hue = generator("color_hue_" + g, rng, rng.d(0.25, 2.5), 1003 * g, meta); - CNG sat = generator("color_sat_" + g, rng, rng.d(0.25, 2.5), 1004 * g, meta); - CNG bri = generator("color_bri_" + g, rng, rng.d(0.25, 2.5), 1005 * g, meta); - double tcf = rng.d(0.75, 11.25 + (g * 4)); - double rcf = rng.d(7.75, 16.25 + (g * 5)); - double xcf = rng.d(0.15, 0.55 + (g * 0.645)); - double w = rng.d(64, 186 + (g * 8)); - double ww = image.getWidth() / rng.d(3, 9); - double hh = image.getHeight() / rng.d(3, 9); - boolean wh = rng.nextBoolean(); - double sa = rng.d(0.35, 0.66); - double sb = rng.d(0.35, 0.66); - - for (int i = 0; i < image.getWidth(); i += (wh ? image.getWidth() / w : 1)) { - for (int j = 0; j < image.getHeight(); j += (!wh ? image.getHeight() / w : 1)) { - if (cX.fitDouble(0, 1, i, -j, t * xcf) > sa && cY.fitDouble(0, 1, -j, i, t * xcf) > sb) { - image.set(Math.round(i + xShift.fit(-ww, ww, i, j, (t * rcf))), - Math.round(j + yShift.fit(-hh, hh, -j, i, (t * rcf))), - color(hue, sat, bri, i, j, (t * tcf))); - } - } - } - } - } -} diff --git a/core/src/main/java/art/arcane/iris/util/simd/VectorSimdKernels.java b/core/src/main/java/art/arcane/iris/util/simd/VectorSimdKernels.java index 0b9d73bcf..3d2c4605b 100644 --- a/core/src/main/java/art/arcane/iris/util/simd/VectorSimdKernels.java +++ b/core/src/main/java/art/arcane/iris/util/simd/VectorSimdKernels.java @@ -36,16 +36,11 @@ public final class VectorSimdKernels implements SimdKernels { @Override public double sum(double[] values, int length) { - int lanes = DOUBLE_SPECIES.length(); - int bound = DOUBLE_SPECIES.loopBound(length); - DoubleVector accumulator = DoubleVector.zero(DOUBLE_SPECIES); - int index = 0; - for (; index < bound; index += lanes) { - accumulator = accumulator.add(DoubleVector.fromArray(DOUBLE_SPECIES, values, index)); - } - - double total = accumulator.reduceLanes(VectorOperators.ADD); - for (; index < length; index++) { + // Deliberately scalar: lane-wise accumulation reassociates FP addition, so the vector + // and scalar kernels returned different low bits for the same input. Generation-path + // reductions must be bit-exact regardless of which kernel set is installed. + double total = 0D; + for (int index = 0; index < length; index++) { total += values[index]; } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index a646326f0..10edd0041 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Prüfung", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Nahe gelegene Chunks löschen und direkt mit der Iris-Generierung neu erzeugen", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Der Radius der nahe gelegenen Chunks", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Chunks in Puffern generieren, ohne die Welt zu schreiben, und Blöcke sowie Biome hashen; erfasst eine Golden-Datei oder prüft gegen eine vorhandene. Setzt Mantle im gescannten Bereich zurück – nur in entbehrlichen Testwelten verwenden.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Chunks in Puffern generieren, ohne die Welt zu schreiben, und Blöcke sowie Biome hashen; erfasst eine Golden-Datei oder prüft gegen eine vorhandene. Löscht den gesamten Mantle der Welt – nur in entbehrlichen Testwelten verwenden.", "iris.director.commanddeveloper.param.world_scan": "Die Welt zum Scannen", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Radius in Chunks um die Mitte", "iris.director.commanddeveloper.param.center_chunk_x_2": "X-Koordinate des zentralen Chunk", "iris.director.commanddeveloper.param.center_chunk_z_2": "Z-Koordinate des zentralen Chunk", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Löschen Sie zuerst die Mantle-Daten im Scanbereich für eine vollständige Regeneration von Grund auf neu", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Löscht zuerst den gesamten Mantle-Ordner der Welt für eine vollständige Regeneration von Grund auf. Nicht auf den Scanbereich beschränkt", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "gleichzeitige Chunk-Generationen; 1 = streng seriell für Orderabhängigkeitstests", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Dump auch Full Per-Chunk Non-Air Blockstates für Offline Diffing", "iris.director.commandedit.director.edit_something": "Etwas bearbeiten", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index c7f8a3d94..a8a757b08 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Prueba", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Eliminar y regenerar chunks cercanos en el mismo lugar con la generación de Iris", "iris.director.commanddeveloper.param.radius_nearby_chunks": "El radio de chunks cercanos", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generar chunks en búferes (sin escribir en el mundo) y calcular el hash de bloques y biomas; captura un archivo de referencia o lo verifica contra uno existente. Restablece Mantle en el área analizada; úsalo solo en mundos de prueba desechables.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generar chunks en búferes (sin escribir en el mundo) y calcular el hash de bloques y biomas; captura un archivo de referencia o lo verifica contra uno existente. Elimina el Mantle completo del mundo; úsalo solo en mundos de prueba desechables.", "iris.director.commanddeveloper.param.world_scan": "El mundo que se analizará", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Radio en chunks alrededor del centro", "iris.director.commanddeveloper.param.center_chunk_x_2": "Chunk central X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Chunk central Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Eliminar primero los datos de Mantle del área analizada para regenerarla completamente desde cero", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Elimina primero la carpeta Mantle completa del mundo para una regeneración total desde cero. No se limita al área analizada", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Generaciones de chunks simultáneas; 1 = ejecución estrictamente secuencial para probar la dependencia del orden", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Volcar también todos los estados de bloque que no sean aire de cada chunk para compararlos sin conexión", "iris.director.commandedit.director.edit_something": "Editar algo", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index 7aab41c2a..594186221 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Testi", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Poista ja regeneroi lähellä olevat palaset paikoillaan käyttäen Iris tuotanto", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Säde lähistöllä chunkia", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Luo palasia puskureiksi (ei maailmankirjoituksia) ja hash lohkoja+biomes; kaappaa kultaisen tiedoston tai tarkastaa olemassa olevaa. Palauttaa Mantlen skannatun alueen - käyttää kertakäyttöinen testi maailmoissa.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Luo palasia puskureiksi (ei maailmankirjoituksia) ja hash lohkoja+biomes; kaappaa kultaisen tiedoston tai tarkastaa olemassa olevaa. Poistaa maailman koko Mantlen - käytä kertakäyttöisissä testimaailmoissa.", "iris.director.commanddeveloper.param.world_scan": "Skannattava maailma", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Radius palasina keskellä", "iris.director.commanddeveloper.param.center_chunk_x_2": "Keskiosa X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Keskiosa Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Poista Mantlen tiedot skannausalueelta ensin täysi regenerointi tyhjästä", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Poistaa ensin maailman koko Mantle-kansion täyttä uudelleengenerointia varten. Ei rajoitu skannausalueeseen", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "rinnakkaiset lohkosukupolvet; 1 = tilausriippumattomuuden testauksessa käytettävä sarja", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Myös kaataa täysi per-chunk ei-ilma-blockstates offline diffings", "iris.director.commandedit.director.edit_something": "Muokkaa jotain", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index f809d3df1..85a258e48 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Test", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Supprimer et régénérer les chunks proches sur place avec la génération Iris", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Le rayon des chunks proches", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Générer des chunks dans des tampons (sans écrire dans le monde) et calculer le hash des blocs et des biomes ; capture un fichier de référence ou le vérifie par rapport à un fichier existant. Réinitialise Mantle dans la zone analysée ; à utiliser uniquement dans des mondes de test jetables.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Générer des chunks dans des tampons (sans écrire dans le monde) et calculer le hash des blocs et des biomes ; capture un fichier de référence ou le vérifie par rapport à un fichier existant. Supprime l'intégralité du Mantle du monde ; à utiliser uniquement dans des mondes de test jetables.", "iris.director.commanddeveloper.param.world_scan": "Le monde à analyser", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Rayon en chunks autour du centre", "iris.director.commanddeveloper.param.center_chunk_x_2": "Chunk central X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Chunk central Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Supprimer d'abord les données de Mantle dans la zone analysée afin de la régénérer entièrement depuis zéro", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Supprime d'abord l'intégralité du dossier Mantle du monde afin de tout régénérer depuis zéro. Ne se limite pas à la zone analysée", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Générations de chunks simultanées ; 1 = exécution strictement séquentielle pour tester la dépendance à l'ordre", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Vider également tous les états de blocs hors air de chaque chunk pour une comparaison hors ligne", "iris.director.commandedit.director.edit_something": "Modifier quelque chose", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index f52d85161..6396b749d 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "מבחן", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "למחוק ולחדש חלקים סמוכים במקום באמצעות דור Iris", "iris.director.commanddeveloper.param.radius_nearby_chunks": "רדיוס של גושים סמוכים", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "יוצר לתוך מאגרים (ללא עולם כותב) ו hash חוסם +biomes; ללכוד קובץ זהב או אימותים נגד קיים. איפוס mantle באזור הסריקה - להשתמש בעולמות מבחן חד פעמי.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "יוצר לתוך מאגרים (ללא עולם כותב) ו hash חוסם +biomes; ללכוד קובץ זהב או אימותים נגד קיים. מוחק את כל ה-mantle של העולם - להשתמש בעולמות מבחן חד פעמי.", "iris.director.commanddeveloper.param.world_scan": "העולם לסרוק", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "רדינוס בחלקים סביב המרכז", "iris.director.commanddeveloper.param.center_chunk_x_2": "מרכז X", "iris.director.commanddeveloper.param.center_chunk_z_2": "מרכז Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "מחיקת נתונים בתחום הסריקה תחילה להתחדשות מלאה מאפס", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "מוחק תחילה את כל תיקיית ה-Mantle של העולם ליצירה מחדש מלאה מאפס. לא מוגבל לאזור הסריקה", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "דורות מקבילים; 1 המונחים: order-dependence test", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "כמו כן, לזרוק מלא קופות ללא אוויר עבור דיפרינג לא מקוון", "iris.director.commandedit.director.edit_something": "עריכה משהו", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 02f0ac2d5..35cbaecf7 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Test di prova", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Eliminare e rigenerare i chunk vicini in posizione utilizzando Iris generazione", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Il raggio dei chunk vicini", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Genera Chunk in buffer senza scrivere nel mondo e calcola l'hash di blocchi e biomi; acquisisce un file Golden o verifica quello esistente. Reimposta Mantle nell'area esaminata: usare solo su mondi di test eliminabili.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Genera Chunk in buffer senza scrivere nel mondo e calcola l'hash di blocchi e biomi; acquisisce un file Golden o verifica quello esistente. Elimina l'intero Mantle del mondo: usare solo su mondi di test eliminabili.", "iris.director.commanddeveloper.param.world_scan": "Il mondo per la scansione", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Radio in chunk intorno al centro", "iris.director.commanddeveloper.param.center_chunk_x_2": "Centro chunk X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Coordinata Z del Chunk centrale", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Eliminare i dati del Mantle nell'area di scansione prima per la rigenerazione completa da zero", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Elimina prima l'intera cartella Mantle del mondo per una rigenerazione completa da zero. Non è limitato all'area esaminata", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Generazioni chunk corrente; 1 = strettamente seriale per i test di indipendenza dell'ordine", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Anche discaricare completamente per-chunk non-air blockstates per il diffing offline", "iris.director.commandedit.director.edit_something": "Modifica qualcosa", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 7e8905100..db4fbd530 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "テスト", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "周囲のチャンクを削除し、Iris の生成処理でその場に再生成します", "iris.director.commanddeveloper.param.radius_nearby_chunks": "周囲のチャンクの半径", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "チャンクをバッファー内に生成し(ワールドへの書き込みなし)、ブロックとバイオームをハッシュ化します。Golden ファイルの作成または既存ファイルとの検証に使用します。スキャン範囲の Mantle はリセットされるため、使い捨てのテストワールドで使用してください。", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "チャンクをバッファー内に生成し(ワールドへの書き込みなし)、ブロックとバイオームをハッシュ化します。Golden ファイルの作成または既存ファイルとの検証に使用します。ワールド全体の Mantle が削除されるため、使い捨てのテストワールドで使用してください。", "iris.director.commanddeveloper.param.world_scan": "スキャンするワールド", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "中心からの半径(チャンク単位)", "iris.director.commanddeveloper.param.center_chunk_x_2": "中心チャンクの X", "iris.director.commanddeveloper.param.center_chunk_z_2": "中心チャンクの Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "最初にスキャン範囲の Mantle データを削除し、完全に新規生成します", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "最初にワールドの Mantle フォルダー全体を削除し、ゼロから完全に再生成します。スキャン範囲に限定されません", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "同時チャンク生成数。1 = 順序依存性テスト用の厳密な直列処理", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "オフライン比較用に、チャンクごとの空気以外の全ブロック状態もダンプします", "iris.director.commandedit.director.edit_something": "リソースを編集します", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index cc738c22a..11d01782e 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "테스트", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "주변 청크를 삭제하고 Iris 생성 방식으로 제자리에서 다시 생성합니다", "iris.director.commanddeveloper.param.radius_nearby_chunks": "주변 청크 반경", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "청크를 버퍼에서 생성하고(월드 쓰기 없음) 블록과 바이옴을 해시합니다. Golden 파일을 캡처하거나 기존 파일과 검증합니다. 검사 영역의 Mantle이 초기화되므로 폐기 가능한 테스트 월드에서 사용하세요.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "청크를 버퍼에서 생성하고(월드 쓰기 없음) 블록과 바이옴을 해시합니다. Golden 파일을 캡처하거나 기존 파일과 검증합니다. 월드 전체의 Mantle이 삭제되므로 폐기 가능한 테스트 월드에서 사용하세요.", "iris.director.commanddeveloper.param.world_scan": "검사할 월드", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "중심 기준 반경(청크 단위)", "iris.director.commanddeveloper.param.center_chunk_x_2": "중심 청크 X", "iris.director.commanddeveloper.param.center_chunk_z_2": "중심 청크 Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "먼저 검사 영역의 Mantle 데이터를 삭제해 완전히 새로 생성합니다", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "먼저 월드의 전체 Mantle 폴더를 삭제하여 처음부터 완전히 다시 생성합니다. 스캔 영역에 국한되지 않습니다", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "동시 청크 생성 수. 1 = 순서 의존성 검사용 완전 직렬 처리", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "오프라인 비교를 위해 청크별 비공기 블록 상태 전체도 덤프합니다", "iris.director.commandedit.director.edit_something": "리소스를 편집합니다", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 25266adf4..54578b851 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Bandymas", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Pašalinti ir regeneruoti netoliese chunkai vietoje naudojant Iris generavimas", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Šalia esančių chunkų spindulys", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generuoti gabaliukai į buferių (ne pasaulis rašo) ir maišų blokai + biomes; fiksuoja aukso failą arba patikrina prieš esamą. Atstato manekeną nuskenuotoje vietoje - naudoti vienkartiniuose bandymų pasauliuose.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generuoti gabaliukai į buferių (ne pasaulis rašo) ir maišų blokai + biomes; fiksuoja aukso failą arba patikrina prieš esamą. Ištrina visą pasaulio Mantle - naudoti vienkartiniuose bandymų pasauliuose.", "iris.director.commanddeveloper.param.world_scan": "Name", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Spindulys į gabaliukus aplink centrą", "iris.director.commanddeveloper.param.center_chunk_x_2": "Centruotas mazgas X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Centrinis chunk Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Pašalinti Mantle duomenis skenavimo srityje pirmą kartą visiškai regeneruoti iš nulio", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Pirmiausia ištrina visą pasaulio Mantle aplanką, kad viskas būtų sugeneruota iš naujo. Neapsiriboja skenavimo sritimi", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Gretutinės įpjovos kartos; 1 = griežtai serijinis, kad būtų galima atlikti priklausomybės nuo narkotikų tyrimus", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Taip pat mesti visą per-chunk non- oro blokada už atsijungęs diffing", "iris.director.commandedit.director.edit_something": "Keisti kažką", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 2770c2dd1..87d3e5fcf 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Testen", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Verwijderen en regenereren nabijgelegen chunks in plaats met behulp van Iris generatie", "iris.director.commanddeveloper.param.radius_nearby_chunks": "De straal van nabijgelegen chunks", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Genereer chunks in buffers (geen wereld schrijft) en hash blokken + biomes; vangt een gouden bestand of controleert tegen een bestaande. Reset Mantle in het gescande gebied - gebruik op wegwerp testwerelden.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Genereer chunks in buffers (geen wereld schrijft) en hash blokken + biomes; vangt een gouden bestand of controleert tegen een bestaande. Verwijdert de volledige Mantle van de wereld - gebruik op wegwerp testwerelden.", "iris.director.commanddeveloper.param.world_scan": "De wereld om te scannen", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Straal in stukjes rond het centrum", "iris.director.commanddeveloper.param.center_chunk_x_2": "Middendeel X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Middenstuk Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Verwijder Mantle-gegevens in het scangebied eerst voor volledige regeneratie vanaf nul", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Verwijdert eerst de volledige Mantle-map van de wereld voor volledige regeneratie vanaf nul. Niet beperkt tot het gescande gebied", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "gelijktijdige chunkgeneraties; 1 = strikt serieel voor orderafhankelijkheidstests", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Ook dump volledige per-chunk non-air blockstates voor offline diffing", "iris.director.commandedit.director.edit_something": "Iets bewerken", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index f5b6c3775..e4303dfaa 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Badanie", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Usuń i zregeneruj pobliskie chunki w miejscu za pomocą Iris generacja", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Promień pobliskich kawałków", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generować kawałki do buforów (nie pisze świat) i hash bloki + biomy; rejestruje złoty plik lub weryfikuje przed istniejącym. Resetuje Mantle w zeskanowanym obszarze - używać na jednorazowych światach testowych.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Generować kawałki do buforów (nie pisze świat) i hash bloki + biomy; rejestruje złoty plik lub weryfikuje przed istniejącym. Usuwa cały Mantle świata - używać na jednorazowych światach testowych.", "iris.director.commanddeveloper.param.world_scan": "Świat do skanowania", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Promień w chunkach wokół centrum", "iris.director.commanddeveloper.param.center_chunk_x_2": "Środek chunka X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Środek chunka Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Usuń dane Mantle w obszarze skanowania najpierw dla pełnej regeneracji od podstaw", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Najpierw usuwa cały folder Mantle świata w celu pełnej regeneracji od zera. Nie ogranicza się do skanowanego obszaru", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Równoczesne pokolenia kawałków; 1 = ściśle szeregowy dla testów uzależnienia od zamówień", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Również wrzucić pełny per- chunk non-air blockstates do differing offline", "iris.director.commandedit.director.edit_something": "Edytuj coś", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 069a45a92..5974114a8 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Ensaio", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Apagar e regenerar chunks próximos no local usando Iris geração", "iris.director.commanddeveloper.param.radius_nearby_chunks": "O raio dos chunks próximos", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Gerar blocos em buffers (sem mundo escreve) e hash blocks+biomes; captura um arquivo dourado ou verifica contra um existente. Restaura o Mantle na área digitalizada - uso em mundos de teste descartáveis.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Gerar blocos em buffers (sem mundo escreve) e hash blocks+biomes; captura um arquivo dourado ou verifica contra um existente. Elimina o Mantle completo do mundo - uso em mundos de teste descartáveis.", "iris.director.commanddeveloper.param.world_scan": "O mundo para escanear", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Raio em chunks ao redor do centro", "iris.director.commanddeveloper.param.center_chunk_x_2": "Centralizar o chunk X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Centralizar o chunk Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Excluir os dados do Mantle na área de varredura primeiro para regeneração completa do zero", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Elimina primeiro a pasta Mantle completa do mundo para regeneração total do zero. Não se limita à área digitalizada", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Gerações de chunks simultâneos; 1 = estritamente serial para testes de dependência da ordem", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Também descarte chunks completos por-chunk não-air para off-line diffing", "iris.director.commandedit.director.edit_something": "Editar algo", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index e4b003afd..f4016ec9e 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Тест", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Удалить и регенерировать близлежащие чанки на месте с помощью Iris поколение", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Радиус близлежащих кусков", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Генерировать куски в буферы (не пишется мир) и хеш-блоки + биомы; захватывает золотой файл или проверяет против существующего. Сбрасывает мантию в сканируемую область — используют на одноразовых тестовых мирах.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Генерировать куски в буферы (не пишется мир) и хеш-блоки + биомы; захватывает золотой файл или проверяет против существующего. Удаляет всю мантию мира — используйте на одноразовых тестовых мирах.", "iris.director.commanddeveloper.param.world_scan": "Мир для сканирования", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Радиус в чанках вокруг центра", "iris.director.commanddeveloper.param.center_chunk_x_2": "Центрный чанк X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Обсуждение Center Bunk Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Удалите данные Mantle в области сканирования сначала для полной регенерации с нуля", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Сначала удаляет всю папку мантии мира для полной регенерации с нуля. Не ограничивается областью сканирования", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "одновременные поколения кусков; 1 = строго серийные испытания на зависимость от заказа", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Также сбрасывайте полные невоздушные блок-состояния для автономного диффинга", "iris.director.commandedit.director.edit_something": "Редактировать что-нибудь", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 32a7d8a20..f72c7b7d1 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Test Testi", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Iris nesli kullanılarak yakınlardaki kıvrımlar ve yenidenjenereler", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Yakındaki chunks'ın ikizi", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Chunk'ları tamponlarda üretir (dünyaya yazmaz) ve blokları+biome'ları hash'ler; bir golden dosyası yakalar veya var olanla doğrular. Taranan alanda mantle'ı sıfırlar - tek kullanımlık test dünyalarında kullanın.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Chunk'ları tamponlarda üretir (dünyaya yazmaz) ve blokları+biome'ları hash'ler; bir golden dosyası yakalar veya var olanla doğrular. Dünyanın tüm mantle'ını siler - tek kullanımlık test dünyalarında kullanın.", "iris.director.commanddeveloper.param.world_scan": "Dünyayı taramak için", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Radius merkez çevresinde", "iris.director.commanddeveloper.param.center_chunk_x_2": "Merkez chunk X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Merkez chunk Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Sıfırdan tam yenileme için önce tarama alanındaki mantle verilerini sil", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Sıfırdan tam yeniden oluşturma için önce dünyanın tüm Mantle klasörünü siler. Tarama alanıyla sınırlı değildir", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Eş zamanlı chunk nesiller; 1 = kesinlikle düzen bağımsız test testi için seri", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Ayrıca çevrimdışı diffing", "iris.director.commandedit.director.edit_something": "Edit bir şey", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 25ff51662..43cd27f32 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "Thử", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "Xóa và tái tạo các mẫu gần đó được dùng Iris thế hệ", "iris.director.commanddeveloper.param.radius_nearby_chunks": "Bán kính", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Tạo ra các mảng thành bộ đệm (không có giấy viết) và hash block+biomes; lấy một tập tin vàng hoặc lời tiên đoán chống lại một cái đã có. Đặt lại lớp vỏ trong khu vực được quét trên thế giới thử dùng một lần.", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "Tạo ra các mảng thành bộ đệm (không có giấy viết) và hash block+biomes; lấy một tập tin vàng hoặc lời tiên đoán chống lại một cái đã có. Xóa toàn bộ lớp vỏ (mantle) của thế giới - dùng trong thế giới thử dùng một lần.", "iris.director.commanddeveloper.param.world_scan": "Thế giới sẽ quét qua", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "Bán kính trong mảng xung quanh trung tâm", "iris.director.commanddeveloper.param.center_chunk_x_2": "Chunk trung tâm X", "iris.director.commanddeveloper.param.center_chunk_z_2": "Chunk trung tâm Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Xóa dữ liệu trong vùng quét trước tiên để phục hồi hoàn toàn từ đầu", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "Xóa toàn bộ thư mục Mantle của thế giới trước để tạo lại hoàn toàn từ đầu. Không giới hạn ở khu vực quét", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "Thế hệ ghép liên tục; 1 = Hoàn toàn nối tiếp cho bài kiểm tra tự đặt hàng", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "Cũng đổ đầy đủ không gian khối không khí cho việc phân tách ngoại tuyến", "iris.director.commandedit.director.edit_something": "Sửa gì đó", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 2f314b7dc..0d08e7a61 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "测试", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "使用 Iris 生成", "iris.director.commanddeveloper.param.radius_nearby_chunks": "附近块的半径", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "在缓冲区中生成区块(不写入世界)并对方块和 biome 进行 hash;用于捕获 golden 文件或与现有文件进行验证。会重置扫描区域中的 mantle——请仅在一次性测试世界中使用。", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "在缓冲区中生成区块(不写入世界)并对方块和 biome 进行 hash;用于捕获 golden 文件或与现有文件进行验证。会删除该世界的整个 mantle——请仅在一次性测试世界中使用。", "iris.director.commanddeveloper.param.world_scan": "扫描世界", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "半径为中心周围的块", "iris.director.commanddeveloper.param.center_chunk_x_2": "居中块 X", "iris.director.commanddeveloper.param.center_chunk_z_2": "居中块 Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "从零开始完全再生,先删除扫描区域内的Mantle数据", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "先删除该世界的整个 mantle 文件夹,以便从头完全重新生成。不限于扫描区域", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "同时代的几代人; 1 = 严格序列进行订单依赖测试", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "还要把整个非空气区块都丢掉", "iris.director.commandedit.director.edit_something": "编辑一些东西", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index b54405a11..3e3bbf346 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -660,12 +660,12 @@ "iris.director.commanddeveloper.director.test_4": "測試", "iris.director.commanddeveloper.director.delete_regenerate_nearby_chunks_place_using_iris_generation": "使用 Iris 生成", "iris.director.commanddeveloper.param.radius_nearby_chunks": "附近塊的半徑", - "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "在緩衝區中生成區塊(不寫入世界)並對方塊和 biome 進行 hash;用於擷取 golden 檔案或與現有檔案進行驗證。會重設掃描區域中的 mantle——請僅在一次性測試世界中使用。", + "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden": "在緩衝區中生成區塊(不寫入世界)並對方塊和 biome 進行 hash;用於擷取 golden 檔案或與現有檔案進行驗證。會刪除該世界的整個 mantle——請僅在一次性測試世界中使用。", "iris.director.commanddeveloper.param.world_scan": "掃描世界", "iris.director.commanddeveloper.param.radius_chunks_around_center_2": "半徑為中心周圍的塊", "iris.director.commanddeveloper.param.center_chunk_x_2": "居中塊 X", "iris.director.commanddeveloper.param.center_chunk_z_2": "居中塊 Z", - "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "從零開始完全再生,先刪除掃描區域內的Mantle資料", + "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch": "先刪除該世界的整個 mantle 資料夾,以便從頭完全重新生成。不限於掃描區域", "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing": "同時代的幾代人; 1 = 嚴格序列進行訂單依賴測試", "iris.director.commanddeveloper.param.also_dump_full_per_chunk_non_air_blockstates_offline_diffing": "還要把整個非空氣區塊都丟掉", "iris.director.commandedit.director.edit_something": "編輯一些東西", diff --git a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java index 0e1a0e157..fcd792d6d 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java @@ -1,11 +1,32 @@ package art.arcane.iris.core; +import com.google.gson.Gson; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class IrisSettingsDefaultsTest { + @Test + public void concurrencyIsNotSerializedIntoSettingsJson() { + String json = new Gson().toJson(new IrisSettings()); + + assertFalse("settings.json must not advertise an unconfigurable concurrency block", + json.contains("\"concurrency\"")); + } + + @Test + public void legacyConcurrencyBlockStillLoadsAndKeepsDerivedValues() { + IrisSettings loaded = new Gson().fromJson( + "{\"concurrency\":{\"parallelism\":8,\"ioParallelism\":4}}", IrisSettings.class); + + assertNotNull(loaded.getConcurrency()); + assertEquals(Math.max(2, Runtime.getRuntime().availableProcessors()), + loaded.getConcurrency().getParallelism()); + } + @Test public void datapackIngestStaysAutomaticWhileEditableConversionIsOptIn() { IrisSettings.IrisSettingsGeneral settings = new IrisSettings.IrisSettingsGeneral(); diff --git a/core/src/test/java/art/arcane/iris/core/link/WorldEditLinkDetectionTest.java b/core/src/test/java/art/arcane/iris/core/link/WorldEditLinkDetectionTest.java new file mode 100644 index 000000000..de0b613e4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/link/WorldEditLinkDetectionTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.core.link; + +import org.junit.After; +import org.junit.Test; + +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 WorldEditLinkDetectionTest { + @After + public void resetDetectionState() { + WorldEditLink.invalidate(); + } + + @Test + public void negativeDetectionIsNotMemoized() { + AtomicInteger calls = new AtomicInteger(); + boolean[] answers = {false, true}; + + assertFalse(WorldEditLink.hasWorldEdit(() -> answers[calls.getAndIncrement()])); + assertTrue("WorldEdit enabling after a negative probe must be picked up without a restart", + WorldEditLink.hasWorldEdit(() -> answers[calls.getAndIncrement()])); + assertEquals(2, calls.get()); + } + + @Test + public void positiveDetectionIsMemoized() { + AtomicInteger calls = new AtomicInteger(); + + assertTrue(WorldEditLink.hasWorldEdit(() -> { + calls.incrementAndGet(); + return true; + })); + assertTrue(WorldEditLink.hasWorldEdit(() -> { + throw new AssertionError("memoized positive must not re-probe"); + })); + assertEquals(1, calls.get()); + } + + @Test + public void selectionFailureDoesNotLatchFalse() { + assertTrue(WorldEditLink.hasWorldEdit(() -> true)); + WorldEditLink.invalidate(); + assertTrue(WorldEditLink.hasWorldEdit(() -> true)); + } + + @Test + public void detectorFailureIsNotCachedAndDoesNotThrow() { + assertFalse(WorldEditLink.hasWorldEdit(() -> { + throw new IllegalStateException("server not ready"); + })); + assertTrue(WorldEditLink.hasWorldEdit(() -> true)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/ResetMantleHelpTextTest.java b/core/src/test/java/art/arcane/iris/core/localization/ResetMantleHelpTextTest.java new file mode 100644 index 000000000..580244259 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/localization/ResetMantleHelpTextTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.core.localization; + +import org.junit.Test; + +import java.util.Locale; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ResetMantleHelpTextTest { + @Test + public void resetMantleParamDescribesTheWholeWorldMantle() { + String english = DirectorCommandMessages + .COMMAND_DEVELOPER_PARAM_DELETE_MANTLE_DATA_SCAN_AREA_FIRST_FULL_REGENERATION_FROM_SCRATCH + .english().toLowerCase(Locale.ROOT); + + assertTrue("help text must say the entire world mantle is deleted", english.contains("entire")); + assertFalse("help text must not claim the deletion is scoped to the scan area", english.contains("scan area")); + } + + @Test + public void goldenhashDirectorDescribesTheWholeWorldMantle() { + String english = DirectorCommandMessages + .COMMAND_DEVELOPER_DIRECTOR_GENERATE_CHUNKS_INTO_BUFFERS_NO_WORLD_WRITES_HASH_BLOCKS_BIOMES_CAPTURES_GOLDEN + .english().toLowerCase(Locale.ROOT); + + assertFalse("director text must not claim the mantle reset is scoped to the scan area", + english.contains("scanned area")); + assertTrue("director text must say the entire world mantle is deleted", english.contains("entire")); + } + + @Test + public void keyIdsStayStableAcrossTheWordingFix() { + assertTrue(DirectorCommandMessages + .COMMAND_DEVELOPER_PARAM_DELETE_MANTLE_DATA_SCAN_AREA_FIRST_FULL_REGENERATION_FROM_SCRATCH + .id().endsWith("delete_mantle_data_scan_area_first_full_regeneration_from_scratch")); + assertTrue(DirectorCommandMessages + .COMMAND_DEVELOPER_DIRECTOR_GENERATE_CHUNKS_INTO_BUFFERS_NO_WORLD_WRITES_HASH_BLOCKS_BIOMES_CAPTURES_GOLDEN + .id().endsWith("generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java new file mode 100644 index 000000000..f92126efb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java @@ -0,0 +1,83 @@ +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 PackBiomeLayerValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rejectsMoreCeilingLayersThanLayers() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/nested/cave.json", + "{\"layers\":[{}],\"caveCeilingLayers\":[{},{},{}]}"); + + assertEquals(List.of( + "Biome 'nested/cave' declares 3 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries." + ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + } + + @Test + public void respectsSingleEntryDefaultsWhenFieldsAreAbsent() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + // Both fields default to one entry, so omitting both is legal... + write(pack, "biomes/defaulted.json", "{\"name\":\"Defaulted\"}"); + // ...and two ceiling entries against the implicit single default layer is not. + write(pack, "biomes/implicit.json", "{\"caveCeilingLayers\":[{},{}]}"); + + assertEquals(List.of( + "Biome 'implicit' declares 2 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries." + ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + } + + @Test + public void acceptsEqualOrFewerCeilingLayers() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/ok.json", "{\"layers\":[{},{},{}],\"caveCeilingLayers\":[{},{}]}"); + + assertTrue(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes")).isEmpty()); + } + + @Test + public void rejectsNonArrayLayerFields() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/bad.json", "{\"layers\":7,\"caveCeilingLayers\":\"nope\"}"); + + assertEquals(List.of( + "Biome 'bad' layers must be an array.", + "Biome 'bad' caveCeilingLayers must be an array." + ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + } + + @Test + public void ceilingLayerOverflowBlocksFullPackValidation() 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\",\"layers\":[{}],\"caveCeilingLayers\":[{},{}]}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Biome 'biome' declares 2 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries.")); + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloadBranchDefaultTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloadBranchDefaultTest.java new file mode 100644 index 000000000..d2684bd11 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloadBranchDefaultTest.java @@ -0,0 +1,25 @@ +package art.arcane.iris.core.pack; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackDownloadBranchDefaultTest { + @Test + public void sharedDefaultBranchIsStable() { + assertEquals("stable", PackDownloader.DEFAULT_BRANCH); + } + + @Test + public void packRepositoryDefaultsToTheSharedBranch() { + assertEquals(PackDownloader.DEFAULT_BRANCH, IrisPackRepository.builder().build().getBranch()); + assertEquals(PackDownloader.DEFAULT_BRANCH, IrisPackRepository.from("overworld").getBranch()); + } + + @Test + public void managedPacksIgnoreTheBranchEntirely() { + assertTrue(PackDownloader.isManagedPack("overworld")); + assertTrue(PackDownloader.isManagedPack("underworld")); + } +} 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 index e41fb572e..b5488afea 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -420,7 +420,9 @@ public class PackDownloaderTest { } @Test - public void forceOverwriteRefusesLoadedPackData() throws Exception { + public void forceOverwriteReplacesEnginelessLoadedPackData() throws Exception { + // A registered loader with no engines is a stale catalog registration (startup + // validation registers one per visible pack); it must not block a forced update. File packsFolder = temp.newFolder("loaded-packs"); File target = writePack(packsFolder.toPath().resolve("active_pack"), "active_pack", "old"); File extracted = writePack(temp.newFolder("loaded-update").toPath(), "active_pack", "new"); @@ -435,10 +437,41 @@ public class PackDownloaderTest { } ); + assertNotNull(result); + assertEquals("new", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTransactionStateClean(packsFolder); + } finally { + loaded.close(); + } + } + + @Test + public void forceOverwriteRefusesPackWithLiveEngines() throws Exception { + File packsFolder = temp.newFolder("engine-packs"); + File target = writePack(packsFolder.toPath().resolve("active_pack"), "active_pack", "old"); + File extracted = writePack(temp.newFolder("engine-update").toPath(), "active_pack", "new"); + // Engines attach to detached openRuntime loaders in production, never to the cached + // IrisData.get instance; the refusal gate must see this registration through the + // engine index. + IrisData loaded = IrisData.openRuntime(target); + art.arcane.iris.engine.framework.Engine engine = + org.mockito.Mockito.mock(art.arcane.iris.engine.framework.Engine.class); + loaded.registerEngine(engine); + try { + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "active_pack", + ignored -> { + } + ); + assertNull(result); assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); assertTransactionStateClean(packsFolder); } finally { + loaded.unregisterEngine(engine); loaded.close(); } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java new file mode 100644 index 000000000..b416edf31 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisObjectMarker; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +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 PackExportClosureTest { + private static IrisObjectPlacement placement(String objectKey, String... markerKeys) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(new KList<>(objectKey)); + KList markers = new KList<>(); + for (String markerKey : markerKeys) { + IrisObjectMarker marker = new IrisObjectMarker(); + marker.setMarker(markerKey); + markers.add(marker); + } + placement.setMarkers(markers); + return placement; + } + + @Test + public void collectsMarkerAndObjectKeysFromPlacements() { + List placements = List.of( + placement("houses/hut", "spawn-point", "loot-marker"), + placement("trees/oak"), + placement("houses/hut", "spawn-point")); + + assertEquals(List.of("loot-marker", "spawn-point"), + PackExportClosure.collectMarkerKeys(placements).stream().sorted().toList()); + assertEquals(List.of("houses/hut", "trees/oak"), + PackExportClosure.collectObjectKeys(placements).stream().sorted().toList()); + } + + @Test + public void toleratesNullPlacementsAndBlankKeys() { + KList placements = new KList<>(); + placements.add(placement("", "")); + placements.add((IrisObjectPlacement) null); + + assertTrue(PackExportClosure.collectMarkerKeys(placements).isEmpty()); + assertTrue(PackExportClosure.collectObjectKeys(placements).isEmpty()); + } + + /** + * Source guard: both packagers must export the ambient-spawning graph. Spawner and marker + * folders were silently omitted from exports, leaving dangling entitySpawners references. + */ + @Test + public void bothPackagersExportSpawnersMarkersAndTheFullEntityGraph() throws Exception { + String bukkit = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java")); + assertTrue("Bukkit packager must write spawners/", bukkit.contains("\"spawners/\"")); + assertTrue("Bukkit packager must write markers/", bukkit.contains("\"markers/\"")); + assertTrue("Bukkit packager must export region objects alongside biome objects", + bukkit.contains("regions.forEach((r) -> allPlacements.addAll(r.getObjects()))")); + assertTrue("Bukkit packager must export entity loot tables", bukkit.contains("getLoot().getTables()")); + + String modded = Files.readString(Path.of( + "../adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java")); + assertTrue("modded packager must write spawners/", modded.contains("\"spawners\"")); + assertTrue("modded packager must write markers/", modded.contains("\"markers\"")); + assertTrue("modded packager must include initial spawns", modded.contains("getInitialSpawns")); + assertTrue("modded packager must export region objects", modded.contains("region.getObjects()")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidatorTest.java new file mode 100644 index 000000000..0e24d9e8f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidatorTest.java @@ -0,0 +1,110 @@ +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 PackGeneratorDuplicateValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void warnsWhenTwoReferencedGeneratorsAreContentIdentical() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/a.json", "{\"seed\": 1,\n \"zoom\": 2}"); + write(pack, "generators/b.json", "{\"zoom\":2,\"seed\":1}"); + write(pack, "biomes/one.json", "{\"generators\":[{\"generator\":\"a\"}]}"); + write(pack, "biomes/two.json", "{\"generators\":[{\"generator\":\"b\"}]}"); + + List warnings = PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack); + + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).startsWith("Generators a, b have identical content")); + } + + @Test + public void doesNotWarnWhenOnlyOneDuplicateIsReferenced() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/a.json", "{\"seed\":1}"); + write(pack, "generators/b.json", "{\"seed\":1}"); + write(pack, "biomes/one.json", "{\"generators\":[{\"generator\":\"a\"}]}"); + + assertTrue(PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack).isEmpty()); + } + + @Test + public void doesNotWarnWhenGeneratorsDifferBySeed() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/a.json", "{\"seed\":1}"); + write(pack, "generators/b.json", "{\"seed\":2}"); + write(pack, "biomes/one.json", "{\"generators\":[{\"generator\":\"a\"},{\"generator\":\"b\"}]}"); + + assertTrue(PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack).isEmpty()); + } + + @Test + public void treatsNumericFormattingAsIdentical() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/a.json", "{\"zoom\":1}"); + write(pack, "generators/b.json", "{\"zoom\":1.0}"); + write(pack, "biomes/one.json", "{\"generators\":[{\"generator\":\"a\"},{\"generator\":\"b\"}]}"); + + assertEquals(1, PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack).size()); + } + + @Test + public void resolvesSnippetGeneratorLinks() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/a.json", "{\"seed\":1}"); + write(pack, "generators/b.json", "{\"seed\":1}"); + write(pack, "biomes/one.json", "{\"generators\":[\"snippet/generator-layer/x\",{\"generator\":\"b\"}]}"); + write(pack, "snippet/generator-layer/x.json", "{\"generator\":\"a\"}"); + + assertEquals(1, PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack).size()); + } + + @Test + public void warningOrderIsStableAndSorted() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "generators/c.json", "{\"seed\":1}"); + write(pack, "generators/a.json", "{\"seed\":1}"); + write(pack, "generators/b.json", "{\"seed\":1}"); + write(pack, "biomes/one.json", + "{\"generators\":[{\"generator\":\"c\"},{\"generator\":\"a\"},{\"generator\":\"b\"}]}"); + + List warnings = PackGeneratorDuplicateValidator.validateDuplicateGenerators(pack); + + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).startsWith("Generators a, b, c have identical content")); + } + + @Test + public void duplicatesSurfaceAsPackWarningsNotErrors() 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\",\"generators\":[{\"generator\":\"a\"},{\"generator\":\"b\"}]}"); + write(pack, "generators/a.json", "{\"seed\":1}"); + write(pack, "generators/b.json", "{\"seed\":1}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertTrue(result.isLoadable()); + assertTrue(result.getWarnings().stream().anyMatch(w -> w.startsWith("Generators a, b have identical content"))); + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidatorTest.java new file mode 100644 index 000000000..da8643075 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidatorTest.java @@ -0,0 +1,99 @@ +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackStyledRangeDefaultValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void flagsEmptyDensityStyleAsSharedDefault() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/x.json", "{\"objects\":[{\"place\":[\"o\"],\"densityStyle\":{}}]}"); + + PackStyledRangeDefaultValidator.Validation validation = PackStyledRangeDefaultValidator.validate(pack); + + assertEquals(1, validation.errors().size()); + assertTrue(validation.errors().get(0).startsWith( + "Biome 'x' objects[0].densityStyle omits both min and max")); + assertTrue(validation.warnings().isEmpty()); + } + + @Test + public void acceptsExplicitDensityStyle() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/x.json", + "{\"objects\":[{\"place\":[\"o\"],\"densityStyle\":{\"min\":1,\"max\":3,\"style\":{\"style\":\"SIMPLEX\"}}}]}"); + + PackStyledRangeDefaultValidator.Validation validation = PackStyledRangeDefaultValidator.validate(pack); + + assertTrue(validation.errors().isEmpty()); + assertTrue(validation.warnings().isEmpty()); + } + + @Test + public void warnsOnPartialDensityStyle() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/x.json", "{\"objects\":[{\"place\":[\"o\"],\"densityStyle\":{\"min\":1}}]}"); + + PackStyledRangeDefaultValidator.Validation validation = PackStyledRangeDefaultValidator.validate(pack); + + assertTrue(validation.errors().isEmpty()); + assertEquals(1, validation.warnings().size()); + assertTrue(validation.warnings().get(0).contains("omits max")); + assertTrue(validation.warnings().get(0).contains("32")); + } + + @Test + public void resolvesSnippetReferences() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/x.json", "{\"objects\":[{\"place\":[\"o\"],\"densityStyle\":\"snippet/style-range/empty\"}]}"); + write(pack, "snippet/style-range/empty.json", "{}"); + + PackStyledRangeDefaultValidator.Validation validation = PackStyledRangeDefaultValidator.validate(pack); + + assertEquals(1, validation.errors().size()); + assertTrue(validation.errors().get(0).contains("via snippet 'snippet/style-range/empty'")); + } + + @Test + public void flagsEmptyCaveDensityThreshold() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"caveProfile\":{\"densityThreshold\":{}}}"); + + PackStyledRangeDefaultValidator.Validation validation = PackStyledRangeDefaultValidator.validate(pack); + + assertEquals(1, validation.errors().size()); + assertTrue(validation.errors().get(0).startsWith( + "Dimension 'main' caveProfile.densityThreshold omits both min and max")); + } + + @Test + public void emptyDensityStyleBlocksFullPackValidation() 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\",\"objects\":[{\"place\":[\"o\"],\"densityStyle\":{}}]}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertFalse(result.isLoadable()); + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionHeightTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionHeightTest.java new file mode 100644 index 000000000..e30796b22 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionHeightTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.core.nms.datapack.IDataFixer; +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.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorDimensionHeightTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rejectsNonMultipleOfSixteenSpan() throws Exception { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"dimensionHeight\":{\"min\":-64,\"max\":300}}"); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' dimensionHeight span (max - min) is 364; it must be a multiple of 16.")); + } + + @Test + public void rejectsMinYNotMultipleOfSixteen() throws Exception { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"dimensionHeight\":{\"min\":-60,\"max\":324}}"); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' dimensionHeight.min is -60; it must be a multiple of 16.")); + } + + @Test + public void rejectsSpanOutsideBounds() throws Exception { + assertTrue(validate("{\"regions\":[\"region\"],\"dimensionHeight\":{\"min\":0,\"max\":0}}") + .getBlockingErrors().contains( + "Dimension 'main' dimensionHeight span (max - min) is 0; it must be between 16 and 4064.")); + assertTrue(validate("{\"regions\":[\"region\"],\"dimensionHeight\":{\"min\":-2032,\"max\":2048}}") + .getBlockingErrors().contains( + "Dimension 'main' dimensionHeight span (max - min) is 4080; it must be between 16 and 4064.")); + } + + @Test + public void rejectsMinYOutsideBounds() throws Exception { + assertTrue(validate("{\"regions\":[\"region\"],\"dimensionHeight\":{\"min\":-2048,\"max\":-1664}}") + .getBlockingErrors().contains( + "Dimension 'main' dimensionHeight.min is -2048; it must be between -2032 and 2031.")); + } + + @Test + public void rejectsLogicalHeightAboveTotalHeight() throws Exception { + PackValidationResult result = validate( + "{\"regions\":[\"region\"],\"logicalHeight\":512,\"dimensionHeight\":{\"min\":-64,\"max\":320}}"); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' logicalHeight is 512; it cannot be greater than the dimension height of 384.")); + } + + @Test + public void acceptsShippingOverworldShape() throws Exception { + PackValidationResult result = validate( + "{\"regions\":[\"region\"],\"logicalHeight\":512,\"dimensionHeight\":{\"min\":-256,\"max\":512}}"); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void acceptsDimensionWithoutExplicitHeight() throws Exception { + PackValidationResult result = validate("{\"regions\":[\"region\"]}"); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void resolvesDimensionHeightSnippet() throws Exception { + File pack = pack("{\"regions\":[\"region\"],\"dimensionHeight\":\"snippet/range/tall\"}"); + write(pack, "snippet/range/tall.json", "{\"min\":-60,\"max\":324}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' dimensionHeight.min is -60; it must be a multiple of 16.")); + } + + @Test + public void validatorAgreesWithIrisDimensionTypeExactly() { + int[][] triples = { + // {minY, maxY, logicalHeight} + {-64, 320, 256}, {-256, 512, 512}, {-64, 300, 256}, {-60, 324, 256}, + {0, 0, 0}, {-2032, 2048, 256}, {-2048, -1664, 0}, {0, 16, 16}, + {0, 16, 17}, {0, 16, -1}, {16, 32, 16}, {-64, 320, 384}, + {-64, 320, 385}, {2016, 2032, 16}, {2032, 2048, 16}, {-2032, -2016, 16}, + {8, 24, 16}, {0, 4064, 4064}, {0, 4080, 4064}, {-64, 4000, 4064}, + }; + + for (int[] triple : triples) { + int minY = triple[0]; + int maxY = triple[1]; + int logicalHeight = triple[2]; + + boolean constructorRejects; + try { + new IrisDimensionType(IDataFixer.Dimension.OVERWORLD, new IrisDimensionTypeOptions(), + logicalHeight, maxY - minY, minY); + constructorRejects = false; + } catch (IllegalArgumentException e) { + constructorRejects = true; + } + + List errors = new ArrayList<>(); + PackDimensionValidator.validateDimensionHeights(null, "main", new art.arcane.volmlib.util.json.JSONObject( + "{\"logicalHeight\":" + logicalHeight + + ",\"dimensionHeight\":{\"min\":" + minY + ",\"max\":" + maxY + "}}"), errors); + + assertEquals("triple (" + minY + "," + maxY + "," + logicalHeight + ") errors=" + errors, + constructorRejects, !errors.isEmpty()); + } + } + + private PackValidationResult validate(String dimensionJson) throws Exception { + return PackValidator.validate(pack(dimensionJson)); + } + + private File pack(String dimensionJson) throws Exception { + File pack = temporaryFolder.newFolder("pack-" + System.nanoTime()); + write(pack, "dimensions/main.json", dimensionJson); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + return pack; + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java index 50edbcca7..4ea1202b0 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java @@ -28,7 +28,7 @@ public class PackValidatorLootTest { write(pack, "dimensions/main.json", "{\"loot\":{\"mode\":\"FALLBACK\",\"multiplier\":0.5," + "\"tables\":[\"global/clutter\"]}}"); - assertTrue(PackLootValidator.validateLootGraph(pack).isEmpty()); + assertTrue(PackLootValidator.validateLootGraph(pack).errors().isEmpty()); } @Test @@ -37,7 +37,7 @@ public class PackValidatorLootTest { write(pack, "dimensions/main.json", "{\"loot\":{\"tables\":[\"missing\"]}}"); assertEquals(List.of("Dimension 'main'.loot.tables[0] references missing loot table 'missing'."), - PackLootValidator.validateLootGraph(pack)); + PackLootValidator.validateLootGraph(pack).errors()); } @Test @@ -47,7 +47,7 @@ public class PackValidatorLootTest { + "\"loot\":[{\"type\":\"\",\"rarity\":0,\"minAmount\":3,\"maxAmount\":1," + "\"enchantments\":[{\"enchantment\":\"\",\"minLevel\":4,\"maxLevel\":2,\"chance\":2}]}]}"); - List errors = PackLootValidator.validateLootGraph(pack); + List errors = PackLootValidator.validateLootGraph(pack).errors(); assertTrue(errors.stream().anyMatch(error -> error.contains(".rarity must be at least 1"))); assertTrue(errors.stream().anyMatch(error -> error.contains(".minPicked must not exceed"))); @@ -63,7 +63,7 @@ public class PackValidatorLootTest { File pack = temporaryFolder.newFolder("pack"); write(pack, "dimensions/main.json", "{\"loot\":{\"mode\":\"MERGE\",\"multiplier\":-1,\"tables\":[]}}"); - List errors = PackLootValidator.validateLootGraph(pack); + List errors = PackLootValidator.validateLootGraph(pack).errors(); assertEquals(2, errors.size()); assertTrue(errors.get(0).contains(".mode must be")); @@ -74,12 +74,12 @@ public class PackValidatorLootTest { public void enforcesPublishedLootMultiplierCap() throws Exception { File acceptedPack = temporaryFolder.newFolder("accepted-pack"); write(acceptedPack, "dimensions/main.json", "{\"loot\":{\"multiplier\":16,\"tables\":[]}}"); - assertTrue(PackLootValidator.validateLootGraph(acceptedPack).isEmpty()); + assertTrue(PackLootValidator.validateLootGraph(acceptedPack).errors().isEmpty()); File rejectedPack = temporaryFolder.newFolder("rejected-pack"); write(rejectedPack, "dimensions/main.json", "{\"loot\":{\"multiplier\":16.01,\"tables\":[]}}"); assertEquals(List.of("Dimension 'main'.loot.multiplier must be a finite number from 0 to 16."), - PackLootValidator.validateLootGraph(rejectedPack)); + PackLootValidator.validateLootGraph(rejectedPack).errors()); } @Test @@ -88,7 +88,7 @@ public class PackValidatorLootTest { write(pack, "loot/boundary.json", "{\"minPicked\":64,\"maxPicked\":64,\"maxTries\":256," + "\"loot\":[{\"type\":\"stone\",\"minAmount\":64,\"maxAmount\":64}]}"); - assertTrue(PackLootValidator.validateLootGraph(pack).isEmpty()); + assertTrue(PackLootValidator.validateLootGraph(pack).errors().isEmpty()); } @Test @@ -97,7 +97,7 @@ public class PackValidatorLootTest { write(pack, "loot/excessive.json", "{\"minPicked\":65,\"maxPicked\":65,\"maxTries\":257," + "\"loot\":[{\"type\":\"stone\",\"minAmount\":65,\"maxAmount\":65}]}"); - List errors = PackLootValidator.validateLootGraph(pack); + List errors = PackLootValidator.validateLootGraph(pack).errors(); assertTrue(errors.contains("Loot table 'excessive'.minPicked must be at most 64.")); assertTrue(errors.contains("Loot table 'excessive'.maxPicked must be at most 64.")); @@ -121,6 +121,31 @@ public class PackValidatorLootTest { assertEquals(expected, maximum.value(), 0D); } + @Test + public void clearModeWithTablesWarnsWithoutBlocking() throws Exception { + File pack = temporaryFolder.newFolder("clear-warn"); + write(pack, "loot/t.json", "{\"loot\":[{\"type\":\"minecraft:stone\"}]}"); + write(pack, "biomes/x.json", "{\"loot\":{\"mode\":\"CLEAR\",\"tables\":[\"t\"]}}"); + + PackLootValidator.LootGraphIssues issues = PackLootValidator.validateLootGraph(pack); + + assertTrue(issues.errors().isEmpty()); + assertEquals(List.of("Biome 'x'.loot uses mode CLEAR and lists 1 table(s); CLEAR clears parent tables" + + " and contributes no tables of its own, so these entries are dead. Use REPLACE to substitute them."), + issues.warnings()); + } + + @Test + public void clearModeWithMissingTableStillBlocksOnTheDanglingKey() throws Exception { + File pack = temporaryFolder.newFolder("clear-dangling"); + write(pack, "biomes/x.json", "{\"loot\":{\"mode\":\"CLEAR\",\"tables\":[\"missing\"]}}"); + + PackLootValidator.LootGraphIssues issues = PackLootValidator.validateLootGraph(pack); + + assertTrue(issues.errors().contains( + "Biome 'x'.loot.tables[0] references missing loot table 'missing'.")); + } + private void write(File root, String relative, String content) throws Exception { File file = new File(root, relative); Files.createDirectories(file.toPath().getParent()); diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java index bb3d29be8..423cd9c7c 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java @@ -315,26 +315,6 @@ public class DecoratorCoreTest { assertSame(occupied, output.get(0, 2, 0)); } - @Test - public void tallSinglePlantDoesNotPlaceWhenLowerTargetIsOccupied() { - IrisDecorator decorator = mock(IrisDecorator.class); - IrisData data = mock(IrisData.class); - PlatformBlockState occupied = mock(PlatformBlockState.class); - PlatformBlockState air = airState(); - PlatformBlockState plant = tallPlantState(); - when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(plant); - - Hunk output = Hunk.newArrayHunk(1, 4, 1); - output.set(0, 1, 0, occupied); - output.set(0, 2, 0, air); - - DecoratorCore.placeSingleUp( - decorator, 0, 0, 0, 0, 0, output, new RNG(1L), data, false, null); - - assertSame(occupied, output.get(0, 1, 0)); - assertSame(air, output.get(0, 2, 0)); - } - @Test public void tallFloatingPlantDoesNotPlaceWhenUpperTargetIsOccupied() { IrisDecorator decorator = mock(IrisDecorator.class); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/LootResolverClearModeTest.java b/core/src/test/java/art/arcane/iris/engine/framework/LootResolverClearModeTest.java new file mode 100644 index 000000000..8f1a6ff64 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/LootResolverClearModeTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisLootMode; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class LootResolverClearModeTest { + @Test + public void clearSuppressesItsOwnTablesAndReplaceDoesNot() { + List cleared = new ArrayList<>(List.of("placement", "dimension")); + LootResolver.injectSources(cleared, List.of("biome"), IrisLootMode.CLEAR, false); + assertTrue("CLEAR must clear parents and contribute nothing", cleared.isEmpty()); + + List replaced = new ArrayList<>(List.of("placement", "dimension")); + LootResolver.injectSources(replaced, List.of("biome"), IrisLootMode.REPLACE, false); + assertEquals(List.of("biome"), replaced); + } + + @Test + public void clearIgnoresTheFallbackFlag() { + List sources = new ArrayList<>(List.of("placement")); + LootResolver.injectSources(sources, List.of("biome"), IrisLootMode.CLEAR, true); + assertTrue(sources.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java index 4a0b90ec1..cc1c8090f 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -43,6 +44,57 @@ public class EngineMantleCleanupTest { @After public void unbindPlatform() { IrisPlatforms.unbind(); + MantleSliceRetention.clearForTesting(); + } + + @Test + public void retainedSlicesSurviveCoveredCleanup() { + MantleSliceRetention.retain(MatterCavern.class.getCanonicalName()); + CleanupFixture fixture = cleanupFixture(); + doReturn(true).when(fixture.engineMantle()).isCovered(7, -4); + + fixture.engineMantle().cleanupChunk(7, -4); + + verify(fixture.chunk(), never()).deleteSlices(MatterCavern.class); + verify(fixture.chunk()).deleteSlices(PlatformBlockState.class); + } + + @Test + public void retainedSlicesSurvivePregenForceCleanup() { + MantleSliceRetention.retain(TreeBlockMaterial.class.getCanonicalName()); + MantleSliceRetention.retain(String.class.getCanonicalName()); + CleanupFixture fixture = cleanupFixture(); + + fixture.engineMantle().forceCleanupChunk(7, -4); + + verify(fixture.chunk(), never()).deleteSlices(TreeBlockMaterial.class); + verify(fixture.chunk(), never()).deleteSlices(String.class); + verify(fixture.chunk()).deleteSlices(MatterCavern.class); + verify(fixture.chunk()).deleteSlices(PlatformBlockState.class); + } + + @Test + public void bukkitBootRetentionsSurvivePregen() { + // Exactly the slice types the Bukkit plugin registers at boot: markers and tree materials + // must keep working in pregenerated chunks. + MantleSliceRetention.retain(String.class.getCanonicalName()); + MantleSliceRetention.retain(TreeBlockMaterial.class.getCanonicalName()); + CleanupFixture fixture = cleanupFixture(); + + fixture.engineMantle().forceCleanupChunk(7, -4); + + verify(fixture.chunk(), never()).deleteSlices(String.class); + verify(fixture.chunk(), never()).deleteSlices(TreeBlockMaterial.class); + } + + @Test + public void blockStateSliceIsNeverRetainable() { + MantleSliceRetention.retain(PlatformBlockState.class.getCanonicalName()); + CleanupFixture fixture = cleanupFixture(); + + fixture.engineMantle().forceCleanupChunk(7, -4); + + verify(fixture.chunk()).deleteSlices(PlatformBlockState.class); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisAxisRotationClampPurityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisAxisRotationClampPurityTest.java new file mode 100644 index 000000000..9cf2edeb6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisAxisRotationClampPurityTest.java @@ -0,0 +1,70 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisAxisRotationClampPurityTest { + private static IrisAxisRotationClamp unlimited(double interval) { + IrisAxisRotationClamp clamp = new IrisAxisRotationClamp(); + clamp.setEnabled(true); + clamp.setInterval(interval); + return clamp; + } + + private static double reference(double interval, int rng) { + double resolved = interval < 1 ? 1 : interval; + return Math.toRadians((resolved * (Math.ceil(Math.abs((rng % 360D) / resolved)))) % 360D); + } + + @Test + public void getRadiansDoesNotMutateIntervalWhenUnlimited() { + IrisAxisRotationClamp clamp = unlimited(0); + clamp.getRadians(37); + assertEquals(0D, clamp.getInterval(), 0D); + } + + @Test + public void getRadiansValuesAreUnchangedByTheFix() { + double[] intervals = {0, 0.5, 1, 90}; + int[] rngs = {0, 1, 37, 91, 359, 720, -13}; + for (double interval : intervals) { + for (int rng : rngs) { + assertEquals("interval=" + interval + " rng=" + rng, + reference(interval, rng), unlimited(interval).getRadians(rng), 0D); + } + } + } + + @Test + public void equalsAndHashCodeSurviveRepeatedGetRadians() { + IrisAxisRotationClamp a = unlimited(0); + IrisAxisRotationClamp b = unlimited(0); + a.getRadians(123); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void concurrentGetRadiansLeavesIntervalUntouched() throws InterruptedException { + IrisAxisRotationClamp shared = unlimited(0); + List threads = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + Thread thread = new Thread(() -> { + for (int i = 0; i < 10_000; i++) { + shared.getRadians(i); + } + }); + threads.add(thread); + thread.start(); + } + for (Thread thread : threads) { + thread.join(); + } + assertTrue("interval must stay at the authored value", shared.getInterval() == 0D); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java new file mode 100644 index 000000000..cbe387229 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java @@ -0,0 +1,50 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisBiomeCeilingLayerGuardTest { + private static IrisBiome biome(int layers, int ceilingLayers) { + IrisBiome biome = new IrisBiome(); + KList surface = new KList<>(); + for (int i = 0; i < layers; i++) { + surface.add(new IrisBiomePaletteLayer().zero()); + } + KList ceiling = new KList<>(); + for (int i = 0; i < ceilingLayers; i++) { + IrisBiomePaletteLayer layer = new IrisBiomePaletteLayer().zero(); + layer.setMinHeight(0); + layer.setMaxHeight(0); + ceiling.add(layer); + } + biome.setLayers(surface); + biome.setCaveCeilingLayers(ceiling); + return biome; + } + + @Test + public void moreCeilingLayersThanLayersMustNotThrow() { + IrisBiome biome = biome(1, 3); + + KList result = biome.generateCeilingLayers( + new IrisDimension(), 0, 0, new RNG(1), 8, 64, mock(IrisData.class), null); + + assertTrue("extra ceiling layers are skipped, not crashed on", result.isEmpty()); + } + + @Test + public void inRangeCeilingLayersKeepWorking() { + IrisBiome biome = biome(3, 2); + + KList result = biome.generateCeilingLayers( + new IrisDimension(), 0, 0, new RNG(1), 8, 64, mock(IrisData.class), null); + + assertTrue("zeroed palettes with zero heights produce no blocks", result.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java new file mode 100644 index 000000000..56d1f8c07 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.nio.file.Files; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class IrisObjectIoWriteLimitsTest { + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private static PlatformBlockState state(String key) { + return (PlatformBlockState) Proxy.newProxyInstance( + IrisObjectIoWriteLimitsTest.class.getClassLoader(), + new Class[]{PlatformBlockState.class}, + (proxy, method, args) -> switch (method.getName()) { + case "key" -> key; + case "toString" -> key; + case "hashCode" -> key.hashCode(); + case "equals" -> proxy == args[0]; + default -> switch (method.getReturnType().getName()) { + case "boolean" -> false; + case "int" -> 0; + case "double" -> 0D; + default -> null; + }; + }); + } + + private static IrisObject objectWithDistinctStates(int paletteSize) { + IrisObject object = new IrisObject(64, 64, 64); + object.setLoadKey("limits-test"); + int placed = 0; + outer: + for (int x = 0; x < 64; x++) { + for (int y = 0; y < 64; y++) { + for (int z = 0; z < 64; z++) { + if (placed >= paletteSize) { + break outer; + } + object.blocks.put(new IrisBlockVector(x, y, z), state("iris:test_" + placed)); + placed++; + } + } + } + return object; + } + + @Test + public void rejectsPaletteOverflowInsteadOfWrappingTheShort() { + IrisObject object = objectWithDistinctStates(32_768); + + try { + IrisObjectIO.write(object, new ByteArrayOutputStream()); + fail("expected the oversized palette to be rejected"); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("limits-test")); + assertTrue(e.getMessage(), e.getMessage().contains("32768")); + } + } + + @Test + public void acceptsPaletteAtExactlyTheCap() throws IOException { + IrisObject object = objectWithDistinctStates(32_767); + File file = folder.newFile("cap.iob"); + + IrisObjectIO.write(object, file); + + assertEquals(32_767, IrisObjectIO.readPaletteKeys(file).size()); + } + + @Test + public void rejectsCoordinateBeyondShortRange() { + IrisObject object = new IrisObject(64, 64, 64); + object.setLoadKey("limits-test"); + object.blocks.put(new IrisBlockVector(40_000, 0, 0), state("iris:test")); + + try { + IrisObjectIO.write(object, new ByteArrayOutputStream()); + fail("expected the out-of-range coordinate to be rejected"); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("40000")); + assertTrue(e.getMessage(), e.getMessage().contains("x")); + } + } + + @Test + public void failedWriteLeavesExistingFileIntact() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObject valid = objectWithDistinctStates(3); + IrisObjectIO.write(valid, file); + byte[] before = Files.readAllBytes(file.toPath()); + + IrisObject oversized = objectWithDistinctStates(32_768); + try { + IrisObjectIO.write(oversized, file); + fail("expected the oversized write to be rejected"); + } catch (IOException expected) { + } + + assertArrayEquals("a rejected write must not truncate the previous object", before, + Files.readAllBytes(file.toPath())); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementToPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementToPlacementTest.java index 02bcf1a96..d6fdf00b0 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementToPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementToPlacementTest.java @@ -67,7 +67,6 @@ public class IrisObjectPlacementToPlacementTest { source.setBottom(true); source.setBore(true); source.setWarp(warp); - source.setTranslateCenter(true); source.setMode(ObjectPlaceMode.FAST_MIN_STILT); source.setEdit(edit); source.setTranslate(translate); @@ -112,7 +111,6 @@ public class IrisObjectPlacementToPlacementTest { assertTrue(copy.isBottom()); assertTrue(copy.isBore()); assertSame(warp, copy.getWarp()); - assertTrue(copy.isTranslateCenter()); assertEquals(ObjectPlaceMode.FAST_MIN_STILT, copy.getMode()); assertSame(edit, copy.getEdit()); assertSame(translate, copy.getTranslate()); diff --git a/core/src/test/java/art/arcane/iris/engine/object/RemovedDeadFieldsTest.java b/core/src/test/java/art/arcane/iris/engine/object/RemovedDeadFieldsTest.java new file mode 100644 index 000000000..0535448d3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/RemovedDeadFieldsTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.IrisSettings; +import com.google.gson.Gson; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +public class RemovedDeadFieldsTest { + @Test + public void removedFieldsStayRemoved() { + assertThrows(NoSuchFieldException.class, () -> IrisDimension.class.getDeclaredField("forceConvertTo320Height")); + assertThrows(NoSuchFieldException.class, () -> IrisDimension.class.getDeclaredField("rockZoom")); + assertThrows(NoSuchFieldException.class, () -> IrisDimension.class.getDeclaredField("disableExplorerMaps")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("riverStyle")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("lakeStyle")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("riverGen")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("lakeGen")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("riverChanceGen")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("realRiverBiomes")); + assertThrows(NoSuchFieldException.class, () -> IrisRegion.class.getDeclaredField("realLakeBiomes")); + assertThrows(NoSuchFieldException.class, () -> IrisObjectPlacement.class.getDeclaredField("translateCenter")); + assertThrows(NoSuchFieldException.class, () -> IrisTree.class.getDeclaredField("anyTree")); + assertThrows(NoSuchFieldException.class, () -> IrisTree.class.getDeclaredField("anySize")); + assertThrows(NoSuchMethodException.class, () -> IrisRegion.class.getDeclaredMethod("getBiomeZoom", InferredType.class)); + } + + @Test + public void legacyKeysAreIgnoredNotFatal() { + IrisDimension dimension = new Gson().fromJson( + "{\"name\":\"x\",\"rockZoom\":9,\"disableExplorerMaps\":true,\"forceConvertTo320Height\":true}", + IrisDimension.class); + + assertNotNull(dimension); + assertEquals("x", dimension.getName()); + + IrisTree tree = new Gson().fromJson("{\"anyTree\":true,\"anySize\":true}", IrisTree.class); + assertNotNull(tree); + + IrisRegion region = new Gson().fromJson( + "{\"name\":\"r\",\"riverStyle\":{\"zoom\":7.77},\"lakeStyle\":{}}", IrisRegion.class); + assertNotNull(region); + assertEquals("r", region.getName()); + } + + @Test + public void studioSettingsKeepTheirLiveToggles() throws Exception { + // disableTimeAndWeather is read by WorldRuntimeControlService, and autoStartDefaultStudio + // by Iris.autoStartStudio() at boot - both are live and must NOT be removed. + assertNotNull(IrisSettings.IrisSettingsStudio.class.getDeclaredField("disableTimeAndWeather")); + assertNotNull(IrisSettings.IrisSettingsStudio.class.getDeclaredField("autoStartDefaultStudio")); + } +} diff --git a/core/src/test/java/art/arcane/iris/spi/PacksFolderContractTest.java b/core/src/test/java/art/arcane/iris/spi/PacksFolderContractTest.java new file mode 100644 index 000000000..7d51b4d12 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/spi/PacksFolderContractTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.spi; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +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.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PacksFolderContractTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void defaultPacksFolderIsDataFolderPacks() { + File data = temporaryFolder.getRoot(); + IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(data); + + assertEquals(new File(data, "packs"), platform.packsFolder()); + assertEquals(new File(data, "packs" + File.separator + "overworld"), platform.packsFolderNoCreate("overworld")); + } + + /** + * Source guard: nothing outside the SPI defaults and platform overrides may spell the packs + * root by hand. New code goes through packsFolder()/packsFolderNoCreate(). + */ + @Test + public void noCallSiteResolvesThePacksRootByHand() throws Exception { + List roots = List.of( + Path.of("src/main/java"), + Path.of("../adapters/bukkit/plugin/src/main/java")); + for (Path root : roots) { + try (var stream = Files.walk(root)) { + for (Path file : stream.filter(p -> p.toString().endsWith(".java")).toList()) { + String text = Files.readString(file); + assertFalse(file + " must use packsFolder() instead of dataFolder(\"packs\")", + text.contains("dataFolder(\"packs\")")); + assertFalse(file + " must use packsFolderNoCreate() instead of the WORKSPACE_NAME indirection", + text.contains("dataFolderNoCreate(StudioSVC.WORKSPACE_NAME")); + } + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/util/simd/SimdKernelsTest.java b/core/src/test/java/art/arcane/iris/util/simd/SimdKernelsTest.java index e0751e29a..9c2a4c474 100644 --- a/core/src/test/java/art/arcane/iris/util/simd/SimdKernelsTest.java +++ b/core/src/test/java/art/arcane/iris/util/simd/SimdKernelsTest.java @@ -81,14 +81,16 @@ public class SimdKernelsTest { } @Test - public void vectorSumMatchesScalarWithinTolerance() { + public void vectorSumMatchesScalarExactly() { + // Zero tolerance on purpose: sum feeds generation-path ranking, so any kernel that + // reassociates FP addition must fail here instead of shipping a parity break. SimdKernels vector = requireVectorKernels(); ScalarSimdKernels scalar = new ScalarSimdKernels(); for (int length : new int[]{256, 251, 1, 7}) { double[] values = terrainLikeValues(length); double expected = scalar.sum(values, length); double actual = vector.sum(values, length); - assertEquals("length " + length, expected, actual, Math.max(1e-9D, Math.abs(expected) * 1e-12D)); + assertEquals("length " + length, expected, actual, 0D); } } diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java index 76f6891a9..d729b5d31 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java @@ -103,6 +103,36 @@ public interface IrisPlatform { */ File dataFile(String... path); + /** + * Root under which installed packs live, resolved against optional sub-segments and created if + * missing. Defaults to {@code dataFolder("packs")}; platforms whose packs live outside the data + * folder (mod loaders) override this. Never null. + */ + default File packsFolder(String... sub) { + if (sub == null || sub.length == 0) { + return dataFolder("packs"); + } + + String[] path = new String[sub.length + 1]; + path[0] = "packs"; + System.arraycopy(sub, 0, path, 1, sub.length); + return dataFolder(path); + } + + /** + * Same resolution as {@link #packsFolder(String...)} without creating anything on disk. Never null. + */ + default File packsFolderNoCreate(String... sub) { + if (sub == null || sub.length == 0) { + return dataFolderNoCreate("packs"); + } + + String[] path = new String[sub.length + 1]; + path[0] = "packs"; + System.arraycopy(sub, 0, path, 1, sub.length); + return dataFolderNoCreate(path); + } + /** * The Iris artifact this runtime was loaded from: the plugin jar on Bukkit, the mod jar on a mod * loader. The Bukkit-flavoured name is retained for source compatibility. Never null; adapters that