From aa706d027baf4461985383c424693a7040ef4ee9 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 15 Apr 2026 09:05:41 -0400 Subject: [PATCH] WIP Building for latest. Still Quantizing, WOrking on that next also removed prebake --- build.gradle | 15 +- buildSrc/src/main/java/NMSBinding.java | 30 +- core/build.gradle | 2 + core/plugins/Iris/cache/instance | 2 +- core/src/main/java/art/arcane/iris/Iris.java | 217 +++- .../iris/core/IrisRuntimeSchedulerMode.java | 3 + .../art/arcane/iris/core/IrisSettings.java | 1 - .../arcane/iris/core/ServerConfigurator.java | 82 +- .../iris/core/commands/CommandDeveloper.java | 1 + .../iris/core/commands/CommandIris.java | 13 +- .../iris/core/commands/CommandSmoke.java | 186 +++ .../iris/core/commands/CommandStudio.java | 51 +- .../iris/core/events/IrisLootEvent.java | 34 +- .../core/lifecycle/BukkitPublicBackend.java | 58 + .../core/lifecycle/CapabilityResolution.java | 235 ++++ .../core/lifecycle/CapabilitySnapshot.java | 612 ++++++++++ .../lifecycle/PaperLikeRuntimeBackend.java | 89 ++ .../iris/core/lifecycle/ServerFamily.java | 21 + .../core/lifecycle/WorldLifecycleBackend.java | 17 + .../core/lifecycle/WorldLifecycleCaller.java | 7 + .../core/lifecycle/WorldLifecycleRequest.java | 51 + .../core/lifecycle/WorldLifecycleService.java | 178 +++ .../core/lifecycle/WorldLifecycleStaging.java | 60 + .../core/lifecycle/WorldLifecycleSupport.java | 520 +++++++++ .../core/lifecycle/WorldsProviderBackend.java | 95 ++ .../iris/core/link/ExternalDataProvider.java | 2 +- .../iris/core/link/FoliaWorldsLink.java | 841 -------------- .../link/data/CraftEngineDataProvider.java | 182 +++ .../java/art/arcane/iris/core/nms/INMS.java | 96 +- .../art/arcane/iris/core/nms/INMSBinding.java | 49 +- .../iris/core/nms/MinecraftVersion.java | 114 ++ .../iris/core/nms/NmsBindingProbeSupport.java | 29 + .../core/nms/container/BlockProperty.java | 48 +- .../nms/datapack/v1217/DataFixerV1217.java | 7 +- .../arcane/iris/core/project/IrisProject.java | 241 ++-- .../BukkitPublicRuntimeControlBackend.java | 77 ++ .../core/runtime/DatapackReadinessResult.java | 97 ++ .../PaperLikeRuntimeControlBackend.java | 319 +++++ .../core/runtime/SmokeDiagnosticsService.java | 368 ++++++ .../iris/core/runtime/SmokeTestService.java | 418 +++++++ .../core/runtime/StudioOpenCoordinator.java | 660 +++++++++++ .../runtime/TransientWorldCleanupSupport.java | 91 ++ .../runtime/WorldRuntimeControlBackend.java | 21 + .../runtime/WorldRuntimeControlService.java | 483 ++++++++ .../art/arcane/iris/core/safeguard/Mode.java | 8 +- .../arcane/iris/core/service/StudioSVC.java | 178 ++- .../arcane/iris/core/tools/IrisCreator.java | 728 +++--------- .../iris/core/tools/IrisPackBenchmarking.java | 6 +- .../art/arcane/iris/engine/IrisEngine.java | 68 +- .../engine/IrisNoisemapPrebakePipeline.java | 1029 ----------------- .../arcane/iris/engine/framework/Engine.java | 22 + .../iris/engine/framework/EngineMode.java | 5 +- .../framework/GenerationSessionException.java | 18 + .../framework/GenerationSessionLease.java | 35 + .../framework/GenerationSessionManager.java | 116 ++ .../arcane/iris/engine/framework/Locator.java | 8 +- .../framework/WrongEngineBroException.java | 7 + .../components/MantleCarvingComponent.java | 18 +- .../components/MantleFluidBodyComponent.java | 6 +- .../components/MantleObjectComponent.java | 157 +-- .../engine/object/BlockDataMergeSupport.java | 51 + .../iris/engine/object/IrisDimension.java | 73 +- .../arcane/iris/engine/object/IrisObject.java | 4 +- .../engine/platform/BukkitChunkGenerator.java | 167 ++- .../platform/PlatformChunkGenerator.java | 13 + .../iris/util/common/plugin/VolmitSender.java | 33 +- .../arcane/iris/util/common/scheduling/J.java | 24 +- .../util/project/context/ChunkContext.java | 29 +- .../util/project/context/IrisContext.java | 2 + .../art/arcane/iris/IrisDiagnosticsTest.java | 74 ++ .../IrisRuntimeSchedulerModeRoutingTest.java | 10 + .../ServerConfiguratorDatapackFolderTest.java | 44 + .../iris/core/StudioRuntimeCleanupTest.java | 47 + .../lifecycle/CapabilityResolutionTest.java | 164 +++ .../WorldLifecycleDiagnosticsTest.java | 38 + .../WorldLifecycleRuntimeLevelStemTest.java | 142 +++ .../WorldLifecycleSelectionTest.java | 64 + .../lifecycle/WorldLifecycleStagingTest.java | 46 + .../core/nms/INMSBindingProbeCodesTest.java | 31 + .../iris/core/nms/MinecraftVersionTest.java | 63 + .../DataFixerV1217DimensionTypeTest.java | 28 + .../runtime/DatapackReadinessResultTest.java | 45 + ...SmokeDiagnosticsServiceCloseStateTest.java | 28 + .../TransientWorldCleanupSupportTest.java | 63 + ...rldRuntimeControlServiceSafeEntryTest.java | 59 + ...orldRuntimeControlServiceTimeLockTest.java | 239 ++++ .../GenerationSessionManagerTest.java | 47 + .../object/IrisDimensionTypeKeyTest.java | 23 + .../object/IrisObjectBlockDataMergeTest.java | 81 ++ .../BurstExecutorSupportReentrantTest.java | 36 + .../VolmitSenderMiniMessageEscapeTest.java | 24 + .../context/ChunkContextPrefillPlanTest.java | 40 + .../world-lifecycle-smoke-matrix.txt | 29 + gradle/libs.versions.toml | 3 + .../core/nms/v1_21_R7/CustomBiomeSource.java | 4 +- .../core/nms/v1_21_R7/IrisChunkGenerator.java | 4 +- .../iris/core/nms/v1_21_R7/NMSBinding.java | 86 +- 97 files changed, 8003 insertions(+), 3087 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/commands/CommandSmoke.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/CapabilityResolution.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/ServerFamily.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleCaller.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java delete mode 100644 core/src/main/java/art/arcane/iris/core/link/FoliaWorldsLink.java create mode 100644 core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java create mode 100644 core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java create mode 100644 core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/DatapackReadinessResult.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/SmokeDiagnosticsService.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/SmokeTestService.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java delete mode 100644 core/src/main/java/art/arcane/iris/engine/IrisNoisemapPrebakePipeline.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionException.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionLease.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java create mode 100644 core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFolderTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/StudioRuntimeCleanupTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/CapabilityResolutionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleDiagnosticsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleRuntimeLevelStemTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/nms/MinecraftVersionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217DimensionTypeTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/DatapackReadinessResultTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/SmokeDiagnosticsServiceCloseStateTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupportTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectBlockDataMergeTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/common/parallel/BurstExecutorSupportReentrantTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderMiniMessageEscapeTest.java create mode 100644 core/src/test/resources/art/arcane/iris/core/lifecycle/world-lifecycle-smoke-matrix.txt diff --git a/build.gradle b/build.gradle index b324a9f9d..b0f7958ae 100644 --- a/build.gradle +++ b/build.gradle @@ -77,20 +77,10 @@ nmsBindings.each { key, value -> def nmsConfig = new Config() nmsConfig.jvm = 25 nmsConfig.version = value - nmsConfig.type = Enum.valueOf(nmsTypeClass, 'DIRECT') + nmsConfig.type = Enum.valueOf(nmsTypeClass, 'USER_DEV') extensions.extraProperties.set('nms', nmsConfig) plugins.apply(NMSBinding) - TaskProvider updateSpecialSource = tasks.register('updateSpecialSource', Download) { - src('https://repo1.maven.org/maven2/net/md-5/SpecialSource/1.11.6/SpecialSource-1.11.6-shaded.jar') - dest(layout.buildDirectory.file('tools/SpecialSource-1.11.4.jar')) - overwrite(true) - } - - tasks.named('remap').configure { - dependsOn(updateSpecialSource) - } - dependencies { compileOnly(project(':core')) compileOnly(volmLibCoordinate) { @@ -107,7 +97,7 @@ def included = configurations.create('included') def jarJar = configurations.create('jarJar') dependencies { nmsBindings.keySet().each { key -> - add('included', project(path: ":nms:${key}", configuration: 'reobf')) + add('included', project(path: ":nms:${key}", configuration: 'runtimeElements')) } add('included', project(path: ':core', configuration: 'shadow')) add('jarJar', project(':core:agent')) @@ -195,6 +185,7 @@ allprojects { maven { url = uri('https://mvn.lumine.io/repository/maven-public/') } // mythic maven { url = uri('https://nexus.phoenixdevt.fr/repository/maven-public/') } //MMOItems maven { url = uri('https://repo.onarandombox.com/content/groups/public/') } //Multiverse Core + maven { url = uri('https://repo.momirealms.net/releases/') } // CraftEngine } dependencies { diff --git a/buildSrc/src/main/java/NMSBinding.java b/buildSrc/src/main/java/NMSBinding.java index 61016f589..19ca21a39 100644 --- a/buildSrc/src/main/java/NMSBinding.java +++ b/buildSrc/src/main/java/NMSBinding.java @@ -10,6 +10,7 @@ import org.gradle.api.GradleException; import org.gradle.api.Named; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; import org.gradle.api.attributes.Bundling; import org.gradle.api.attributes.Category; import org.gradle.api.attributes.LibraryElements; @@ -89,19 +90,26 @@ public class NMSBinding implements Plugin { extension.getVersion().set(config.version); }); - ObjectFactory objects = target.getObjects(); - target.getConfigurations().register(REOBF_CONFIG, configuration -> { - configuration.setCanBeConsumed(true); - configuration.setCanBeResolved(false); - configuration.getAttributes().attribute(Usage.USAGE_ATTRIBUTE, named(objects, Usage.class, Usage.JAVA_RUNTIME)); - configuration.getAttributes().attribute(Category.CATEGORY_ATTRIBUTE, named(objects, Category.class, Category.LIBRARY)); - configuration.getAttributes().attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, named(objects, LibraryElements.class, LibraryElements.JAR)); - configuration.getAttributes().attribute(Bundling.BUNDLING_ATTRIBUTE, named(objects, Bundling.class, Bundling.EXTERNAL)); - configuration.getAttributes().attribute(Obfuscation.Companion.getOBFUSCATION_ATTRIBUTE(), named(objects, Obfuscation.class, Obfuscation.OBFUSCATED)); - configuration.getOutgoing().artifact(target.getTasks().named("remap")); - }); } + String outgoingArtifactTask = type == Type.USER_DEV ? "jar" : "remap"; + ObjectFactory objects = target.getObjects(); + Configuration reobfConfiguration = target.getConfigurations().findByName(REOBF_CONFIG); + if (reobfConfiguration == null) { + reobfConfiguration = target.getConfigurations().create(REOBF_CONFIG); + } + + target.getConfigurations().named(REOBF_CONFIG).configure(configuration -> { + configuration.setCanBeConsumed(true); + configuration.setCanBeResolved(false); + configuration.getAttributes().attribute(Usage.USAGE_ATTRIBUTE, named(objects, Usage.class, Usage.JAVA_RUNTIME)); + configuration.getAttributes().attribute(Category.CATEGORY_ATTRIBUTE, named(objects, Category.class, Category.LIBRARY)); + configuration.getAttributes().attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, named(objects, LibraryElements.class, LibraryElements.JAR)); + configuration.getAttributes().attribute(Bundling.BUNDLING_ATTRIBUTE, named(objects, Bundling.class, Bundling.EXTERNAL)); + configuration.getAttributes().attribute(Obfuscation.Companion.getOBFUSCATION_ATTRIBUTE(), named(objects, Obfuscation.class, Obfuscation.OBFUSCATED)); + configuration.getOutgoing().artifact(target.getTasks().named(outgoingArtifactTask)); + }); + int[] version = parseVersion(config.version); int major = version[0]; int minor = version[1]; diff --git a/core/build.gradle b/core/build.gradle index 1e1870722..4c71a5619 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -79,6 +79,8 @@ dependencies { transitive = false } compileOnly(libs.multiverseCore) + compileOnly(libs.craftengine.core) + compileOnly(libs.craftengine.bukkit) // Shaded implementation('de.crazydev22.slimjar.helper:spigot:2.1.9') diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index db2642a95..339b5379d 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ -2117487583 \ No newline at end of file +466077434 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/Iris.java b/core/src/main/java/art/arcane/iris/Iris.java index 61960cbef..635e4907c 100644 --- a/core/src/main/java/art/arcane/iris/Iris.java +++ b/core/src/main/java/art/arcane/iris/Iris.java @@ -24,6 +24,10 @@ import com.google.gson.JsonParser; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; import art.arcane.iris.core.link.IrisPapiExpansion; import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.loader.IrisData; @@ -78,7 +82,6 @@ import java.io.*; import java.lang.annotation.Annotation; import java.net.URI; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -98,9 +101,6 @@ public class Iris extends VolmitPlugin implements Listener { private static File settingsFile; private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt"; private static final StackWalker DEBUG_STACK_WALKER = StackWalker.getInstance(); - private static final Map stagedRuntimeGenerators = new ConcurrentHashMap<>(); - private static final Map stagedRuntimeBiomeProviders = new ConcurrentHashMap<>(); - static { try { InstanceState.updateInstanceId(); @@ -122,40 +122,30 @@ public class Iris extends VolmitPlugin implements Listener { return sender; } - public static void stageRuntimeWorldGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator, @Nullable BiomeProvider biomeProvider) { - stagedRuntimeGenerators.put(worldName, generator); - if (biomeProvider != null) { - stagedRuntimeBiomeProviders.put(worldName, biomeProvider); - } else { - stagedRuntimeBiomeProviders.remove(worldName); - } - } - - @Nullable - private static ChunkGenerator consumeRuntimeWorldGenerator(@NotNull String worldName) { - return stagedRuntimeGenerators.remove(worldName); - } - - @Nullable - private static BiomeProvider consumeRuntimeBiomeProvider(@NotNull String worldName) { - return stagedRuntimeBiomeProviders.remove(worldName); - } - - public static void clearStagedRuntimeWorldGenerator(@NotNull String worldName) { - stagedRuntimeGenerators.remove(worldName); - stagedRuntimeBiomeProviders.remove(worldName); - } - @SuppressWarnings("unchecked") public static T service(Class c) { return (T) instance.services.get(c); } public static void callEvent(Event e) { + Runnable dispatcher = () -> { + try { + Bukkit.getPluginManager().callEvent(e); + } catch (Throwable ex) { + reportError("Event dispatch failed for \"" + e.getEventName() + "\".", ex); + if (ex instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (ex instanceof Error error) { + throw error; + } + throw new IllegalStateException(ex); + } + }; if (!e.isAsynchronous()) { - J.s(() -> Bukkit.getPluginManager().callEvent(e)); + J.s(dispatcher); } else { - Bukkit.getPluginManager().callEvent(e); + dispatcher.run(); } } @@ -443,8 +433,22 @@ public class Iris extends VolmitPlugin implements Listener { } public static void reportError(Throwable e) { + if (e == null) { + return; + } + Bindings.capture(e); - if (IrisSettings.get().getGeneral().isDebug()) { + boolean debug = false; + if (instance != null) { + try { + IrisSettings currentSettings = IrisSettings.settings != null ? IrisSettings.settings : IrisSettings.get(); + debug = currentSettings != null && currentSettings.getGeneral().isDebug(); + } catch (Throwable ignored) { + debug = false; + } + } + + if (debug) { String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber(); if (e.getCause() != null) { @@ -467,6 +471,25 @@ public class Iris extends VolmitPlugin implements Listener { } } + public static void reportError(String context, Throwable e) { + Throwable error = e == null ? new IllegalStateException("Unknown Iris failure") : e; + String message = context == null || context.isBlank() ? "Unhandled Iris failure." : context; + + try { + if (instance != null) { + Iris.error(message); + } else { + System.err.println("[Iris] " + message); + } + } catch (Throwable inner) { + System.err.println("[Iris] " + message); + inner.printStackTrace(System.err); + } + + reportError(error); + error.printStackTrace(System.err); + } + public static void dump() { try { File fi = Iris.instance.getDataFile("dump", "td-" + new java.sql.Date(M.ms()) + ".txt"); @@ -533,6 +556,8 @@ public class Iris extends VolmitPlugin implements Listener { addShutdownHook(); processPendingStartupWorldDeletes(); IrisToolbelt.applyPregenPerformanceProfile(); + WorldLifecycleService.get(); + WorldRuntimeControlService.get(); if (J.isFolia()) { checkForBukkitWorlds(s -> true); @@ -614,11 +639,10 @@ public class Iris extends VolmitPlugin implements Listener { Iris.error("Failed to load world " + s + "!"); Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase."); Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml."); - reportError(e); + reportError("Failed to load staged startup world \"" + s + "\".", e); return; } - Iris.error("Failed to load world " + s + "!"); - e.printStackTrace(); + reportError("Failed to load startup world \"" + s + "\".", e); } }); if (!deferredStartupWorlds.isEmpty()) { @@ -626,8 +650,7 @@ public class Iris extends VolmitPlugin implements Listener { Iris.warn("Bukkit.createWorld is intentionally unavailable in this startup phase. Worlds remain staged in bukkit.yml."); } } catch (Throwable e) { - e.printStackTrace(); - reportError(e); + reportError("Failed while loading startup Iris worlds.", e); } } @@ -673,6 +696,9 @@ public class Iris extends VolmitPlugin implements Listener { private void processPendingStartupWorldDeletes() { try { LinkedHashMap queue = loadPendingWorldDeleteMap(); + for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(Bukkit.getWorldContainer())) { + queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld); + } if (queue.isEmpty()) { return; } @@ -690,20 +716,33 @@ public class Iris extends VolmitPlugin implements Listener { continue; } - File worldFolder = new File(Bukkit.getWorldContainer(), worldName); - if (!worldFolder.exists()) { + boolean foundAny = false; + boolean deletedAll = true; + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + File worldFolder = new File(Bukkit.getWorldContainer(), familyWorldName); + if (!worldFolder.exists()) { + continue; + } + + foundAny = true; + IO.delete(worldFolder); + if (worldFolder.exists()) { + deletedAll = false; + Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup."); + } else { + Iris.info("Deleted queued world folder \"" + familyWorldName + "\"."); + } + } + + if (!foundAny) { Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing)."); continue; } - IO.delete(worldFolder); - if (worldFolder.exists()) { - Iris.warn("Failed to delete queued world folder \"" + worldName + "\". Retrying on next startup."); + if (!deletedAll) { remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); continue; } - - Iris.info("Deleted queued world folder \"" + worldName + "\"."); } writePendingWorldDeleteMap(remaining); @@ -952,7 +991,7 @@ public class Iris extends VolmitPlugin implements Listener { @Nullable @Override public BiomeProvider getDefaultBiomeProvider(@NotNull String worldName, @Nullable String id) { - BiomeProvider stagedBiomeProvider = consumeRuntimeBiomeProvider(worldName); + org.bukkit.generator.BiomeProvider stagedBiomeProvider = WorldLifecycleStaging.consumeBiomeProvider(worldName); if (stagedBiomeProvider != null) { Iris.debug("Using staged runtime biome provider for " + worldName); return stagedBiomeProvider; @@ -963,7 +1002,7 @@ public class Iris extends VolmitPlugin implements Listener { @Override public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { - ChunkGenerator stagedGenerator = consumeRuntimeWorldGenerator(worldName); + ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName); if (stagedGenerator != null) { Iris.debug("Using staged runtime generator for " + worldName); return stagedGenerator; @@ -1029,27 +1068,81 @@ public class Iris extends VolmitPlugin implements Listener { private void printPacks() { File packFolder = Iris.service(StudioSVC.class).getWorkspaceFolder(); - File[] packs = packFolder.listFiles(File::isDirectory); - if (packs == null || packs.length == 0) + List packs = collectSplashPacks(packFolder); + if (packs.isEmpty()) return; - Iris.info("Custom Dimensions: " + packs.length); - for (File f : packs) - printPack(f); + Iris.info("Custom Dimensions: " + packs.size()); + for (SplashPackMetadata pack : packs) { + printPack(pack); + } } - private void printPack(File pack) { - String dimName = pack.getName(); - String version = "???"; - try (FileReader r = new FileReader(new File(pack, "dimensions/" + dimName + ".json"))) { - JsonObject json = JsonParser.parseReader(r).getAsJsonObject(); - if (json.has("version")) - version = json.get("version").getAsString(); - } catch (IOException | JsonParseException ex) { - Iris.verbose("Failed to read dimension version metadata for " + dimName + ": " - + ex.getClass().getSimpleName() - + (ex.getMessage() == null ? "" : " - " + ex.getMessage())); + static List collectSplashPacks(File packFolder) { + if (packFolder == null || !packFolder.isDirectory()) { + return Collections.emptyList(); + } + + File[] folders = packFolder.listFiles(File::isDirectory); + if (folders == null || folders.length == 0) { + return Collections.emptyList(); + } + + List packs = new ArrayList<>(); + for (File folder : folders) { + SplashPackMetadata metadata = readSplashPack(folder); + if (metadata != null) { + packs.add(metadata); + } + } + + packs.sort(Comparator.comparing(SplashPackMetadata::name)); + return packs; + } + + static SplashPackMetadata readSplashPack(File pack) { + if (pack == null || !pack.isDirectory()) { + return null; + } + + String dimName = pack.getName(); + File dimensionFile = new File(pack, "dimensions/" + dimName + ".json"); + if (!dimensionFile.isFile()) { + return null; + } + + try (FileReader r = new FileReader(dimensionFile)) { + JsonObject json = JsonParser.parseReader(r).getAsJsonObject(); + if (!json.has("version")) { + return null; + } + + return new SplashPackMetadata(dimName, json.get("version").getAsString()); + } catch (IOException | JsonParseException ex) { + reportError("Failed to read splash metadata for dimension pack \"" + dimName + "\".", ex); + return null; + } + } + + private void printPack(SplashPackMetadata pack) { + Iris.info(" " + pack.name() + " v" + pack.version()); + } + + static final class SplashPackMetadata { + private final String name; + private final String version; + + SplashPackMetadata(String name, String version) { + this.name = name; + this.version = version; + } + + String name() { + return name; + } + + String version() { + return version; } - Iris.info(" " + dimName + " v" + version); } public int getIrisVersion() { diff --git a/core/src/main/java/art/arcane/iris/core/IrisRuntimeSchedulerMode.java b/core/src/main/java/art/arcane/iris/core/IrisRuntimeSchedulerMode.java index bc026f6f9..402c55148 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisRuntimeSchedulerMode.java +++ b/core/src/main/java/art/arcane/iris/core/IrisRuntimeSchedulerMode.java @@ -38,6 +38,9 @@ public enum IrisRuntimeSchedulerMode { if (containsIgnoreCase(bukkitName, "purpur") || containsIgnoreCase(bukkitVersion, "purpur") || containsIgnoreCase(serverClassName, "purpur") + || containsIgnoreCase(bukkitName, "canvas") + || containsIgnoreCase(bukkitVersion, "canvas") + || containsIgnoreCase(serverClassName, "canvas") || containsIgnoreCase(bukkitName, "paper") || containsIgnoreCase(bukkitVersion, "paper") || containsIgnoreCase(serverClassName, "paper") 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 d223f8f07..99b5430ba 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -159,7 +159,6 @@ public class IrisSettings { public int chunkLoadTimeoutSeconds = 15; public int timeoutWarnIntervalMs = 500; public int saveIntervalMs = 120_000; - public boolean startupNoisemapPrebake = true; public boolean enablePregenPerformanceProfile = true; public int pregenProfileNoiseCacheSize = 4_096; public boolean pregenProfileEnableFastCache = true; 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 5912ed131..2111dd4ea 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -24,7 +24,6 @@ import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; -import art.arcane.iris.engine.IrisNoisemapPrebakePipeline; import art.arcane.iris.engine.object.*; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -77,10 +76,7 @@ public class ServerConfigurator { } deferredInstallPending = false; - boolean datapacksMissing = installDataPacks(true); - if (!datapacksMissing) { - IrisNoisemapPrebakePipeline.scheduleInstalledPacksPrebakeAsync(); - } + installDataPacks(true); } public static void configureIfDeferred() { @@ -129,8 +125,15 @@ public class ServerConfigurator { return new KList().qadd(new File(Bukkit.getWorldContainer(), IrisSettings.get().getGeneral().forceMainWorld + "/datapacks")); } KList worlds = new KList<>(); - Bukkit.getServer().getWorlds().forEach(w -> worlds.add(new File(w.getWorldFolder(), "datapacks"))); - if (worlds.isEmpty()) worlds.add(new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME + "/datapacks")); + Bukkit.getServer().getWorlds().forEach(w -> { + File folder = resolveDatapacksFolder(w.getWorldFolder()); + if (!worlds.contains(folder)) { + worlds.add(folder); + } + }); + if (worlds.isEmpty()) { + worlds.add(new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME + "/datapacks")); + } return worlds; } @@ -180,9 +183,10 @@ public class ServerConfigurator { Iris.verbose("Checking Data Packs..."); } DimensionHeight height = new DimensionHeight(fixer); - KList folders = getDatapacksFolder(); + KList baseFolders = getDatapacksFolder(); + KList folders = collectInstallDatapackFolders(baseFolders, extraWorldDatapackFoldersByPack); if (includeExternal) { - installExternalDataPacks(folders, extraWorldDatapackFoldersByPack); + installExternalDataPacks(baseFolders, extraWorldDatapackFoldersByPack); } KMap> biomes = new KMap<>(); @@ -205,6 +209,34 @@ public class ServerConfigurator { return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()); } + static KList collectInstallDatapackFolders( + KList baseFolders, + KMap> extraWorldDatapackFoldersByPack + ) { + KList folders = new KList<>(); + if (baseFolders != null) { + for (File folder : baseFolders) { + if (folder != null && !folders.contains(folder)) { + folders.add(folder); + } + } + } + if (extraWorldDatapackFoldersByPack == null || extraWorldDatapackFoldersByPack.isEmpty()) { + return folders; + } + for (KList extraFolders : extraWorldDatapackFoldersByPack.values()) { + if (extraFolders == null || extraFolders.isEmpty()) { + continue; + } + for (File folder : extraFolders) { + if (folder != null && !folders.contains(folder)) { + folders.add(folder); + } + } + } + return folders; + } + private static void installExternalDataPacks( KList folders, KMap> extraWorldDatapackFoldersByPack @@ -915,7 +947,10 @@ public class ServerConfigurator { if (packName.isBlank()) { continue; } - File datapacksFolder = new File(Bukkit.getWorldContainer(), worldName + File.separator + "datapacks"); + org.bukkit.World world = Bukkit.getWorld(worldName); + File datapacksFolder = world == null + ? new File(Bukkit.getWorldContainer(), worldName + File.separator + "datapacks") + : resolveDatapacksFolder(world.getWorldFolder()); addWorldDatapackFolder(foldersByPack, packName, datapacksFolder); } @@ -929,7 +964,7 @@ public class ServerConfigurator { if (packName.isBlank()) { continue; } - File datapacksFolder = new File(world.getWorldFolder(), "datapacks"); + File datapacksFolder = resolveDatapacksFolder(world.getWorldFolder()); addWorldDatapackFolder(foldersByPack, packName, datapacksFolder); } @@ -969,6 +1004,31 @@ public class ServerConfigurator { } } + public static File resolveDatapacksFolder(File worldFolder) { + File rootFolder = resolveWorldRootFolder(worldFolder); + return new File(rootFolder, "datapacks"); + } + + static File resolveWorldRootFolder(File worldFolder) { + if (worldFolder == null) { + return new File(Bukkit.getWorldContainer(), ServerProperties.LEVEL_NAME); + } + + File current = worldFolder.getAbsoluteFile(); + while (current != null) { + if ("dimensions".equals(current.getName())) { + File parent = current.getParentFile(); + if (parent != null) { + return parent; + } + break; + } + current = current.getParentFile(); + } + + return worldFolder.getAbsoluteFile(); + } + private static String sanitizePackName(String value) { if (value == null) { return ""; diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index e89b24b8c..86e4bc089 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -87,6 +87,7 @@ public class CommandDeveloper implements DirectorExecutor { private static final Set ACTIVE_DELETE_CHUNK_WORLDS = ConcurrentHashMap.newKeySet(); private CommandTurboPregen turboPregen; private CommandLazyPregen lazyPregen; + private CommandSmoke smoke; @Director(description = "Get Loaded TectonicPlates Count", origin = DirectorOrigin.BOTH, sync = true) public void EngineStatus() { diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 62adf9e04..fb34df604 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -22,7 +22,7 @@ import art.arcane.iris.Iris; import art.arcane.iris.core.ExternalDataPackPipeline; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; -import art.arcane.iris.core.link.FoliaWorldsLink; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.service.StudioSVC; @@ -196,8 +196,7 @@ public class CommandIris implements DirectorExecutor { } } catch (Throwable e) { sender().sendMessage(C.RED + "Exception raised during creation. See the console for more details."); - Iris.error("Exception raised during world creation: " + e.getMessage()); - Iris.reportError(e); + Iris.reportError("Exception raised during world creation for \"" + name + "\".", e); worldCreation = false; return; } @@ -742,7 +741,7 @@ public class CommandIris implements DirectorExecutor { return; } - if (!FoliaWorldsLink.get().unloadWorld(world, false)) { + if (!WorldLifecycleService.get().unload(world, false)) { sender().sendMessage(C.RED + "Failed to unload world: " + world.getName()); return; } @@ -755,7 +754,7 @@ public class CommandIris implements DirectorExecutor { } } catch (IOException e) { sender().sendMessage(C.RED + "Failed to save bukkit.yml because of " + e.getMessage()); - e.printStackTrace(); + Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e); } IrisToolbelt.evacuate(world, "Deleting world"); deletingWorld = true; @@ -2144,7 +2143,7 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.GREEN + "Unloading world: " + world.getName()); try { IrisToolbelt.evacuate(world); - boolean unloaded = FoliaWorldsLink.get().unloadWorld(world, false); + boolean unloaded = WorldLifecycleService.get().unload(world, false); if (unloaded) { sender().sendMessage(C.GREEN + "World unloaded successfully."); } else { @@ -2152,7 +2151,7 @@ public class CommandIris implements DirectorExecutor { } } catch (Exception e) { sender().sendMessage(C.RED + "Failed to unload the world: " + e.getMessage()); - e.printStackTrace(); + Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); } } diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandSmoke.java b/core/src/main/java/art/arcane/iris/core/commands/CommandSmoke.java new file mode 100644 index 000000000..180210e14 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandSmoke.java @@ -0,0 +1,186 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.runtime.DatapackReadinessResult; +import art.arcane.iris.core.runtime.SmokeDiagnosticsService; +import art.arcane.iris.core.runtime.SmokeTestService; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.format.C; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.format.Form; + +import java.util.List; + +@Director(name = "smoke", description = "Run Iris developer smoke diagnostics") +public class CommandSmoke implements DirectorExecutor { + @Director(description = "Run the full smoke suite", sync = true) + public void full( + @Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate") + String dimension, + @Param(description = "The seed to use", defaultValue = "1337") + long seed, + @Param(description = "Optional player validation target or none", defaultValue = "none") + String player, + @Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false") + boolean retainOnFailure + ) { + String runId = SmokeTestService.get().startFullSmoke(sender(), dimension, seed, player, retainOnFailure); + announceRun(runId, "full"); + } + + @Director(description = "Run the studio smoke flow", sync = true) + public void studio( + @Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate") + String dimension, + @Param(description = "The seed to use", defaultValue = "1337") + long seed, + @Param(description = "Optional player validation target or none", defaultValue = "none") + String player, + @Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false") + boolean retainOnFailure + ) { + String runId = SmokeTestService.get().startStudioSmoke(sender(), dimension, seed, player, retainOnFailure); + announceRun(runId, "studio"); + } + + @Director(description = "Run the create/unload smoke flow", sync = true) + public void create( + @Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate") + String dimension, + @Param(description = "The seed to use", defaultValue = "1337") + long seed, + @Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false") + boolean retainOnFailure + ) { + String runId = SmokeTestService.get().startCreateSmoke(sender(), dimension, seed, retainOnFailure); + announceRun(runId, "create"); + } + + @Director(description = "Run the benchmark create/unload smoke flow", sync = true) + public void benchmark( + @Param(name = "dimension", aliases = {"pack"}, description = "The dimension/pack key to validate") + String dimension, + @Param(description = "The seed to use", defaultValue = "1337") + long seed, + @Param(name = "retain-on-failure", aliases = {"retain"}, description = "Retain the temp world after failure", defaultValue = "false") + boolean retainOnFailure + ) { + String runId = SmokeTestService.get().startBenchmarkSmoke(sender(), dimension, seed, retainOnFailure); + announceRun(runId, "benchmark"); + } + + @Director(description = "Show live or persisted smoke status", sync = true) + public void status( + @Param(description = "Use latest or a specific run id", defaultValue = "latest") + String run + ) { + SmokeDiagnosticsService.SmokeRunReport report = resolveReport(run); + if (report == null) { + sender().sendMessage(C.RED + "No smoke report found for \"" + run + "\"."); + return; + } + + sendReport(report); + } + + @Director(description = "Inspect a currently loaded smoke/studio world", sync = true) + public void inspect( + @Param(description = "The loaded world name to inspect") + String world + ) { + SmokeTestService.WorldInspection inspection = SmokeTestService.get().inspectWorld(world); + if (inspection == null) { + sender().sendMessage(C.RED + "World \"" + world + "\" is not currently loaded."); + return; + } + + sender().sendMessage(C.GREEN + "Smoke inspection for " + C.GOLD + inspection.worldName()); + sender().sendMessage(C.GRAY + "Lifecycle backend: " + C.WHITE + inspection.lifecycleBackend()); + sender().sendMessage(C.GRAY + "Runtime backend: " + C.WHITE + inspection.runtimeBackend()); + sender().sendMessage(C.GRAY + "Studio: " + C.WHITE + inspection.studio() + C.GRAY + " | Maintenance active: " + C.WHITE + inspection.maintenanceActive()); + sender().sendMessage(C.GRAY + "Engine closed: " + C.WHITE + inspection.engineClosed() + C.GRAY + " | Engine failing: " + C.WHITE + inspection.engineFailing()); + sender().sendMessage(C.GRAY + "Generation session: " + C.WHITE + inspection.generationSessionId() + C.GRAY + " | Active leases: " + C.WHITE + inspection.activeLeaseCount()); + sender().sendMessage(C.GRAY + "Datapack folders: " + C.WHITE + joinList(inspection.datapackFolders())); + } + + private void announceRun(String runId, String mode) { + sender().sendMessage(C.GREEN + "Started " + C.GOLD + mode + C.GREEN + " smoke run " + C.GOLD + runId + C.GREEN + "."); + sender().sendMessage(C.GREEN + "Use " + C.GOLD + "/iris developer smoke status run=" + runId + C.GREEN + " to monitor progress."); + sender().sendMessage(C.GREEN + "Latest report: " + C.GOLD + latestReportPath()); + } + + private SmokeDiagnosticsService.SmokeRunReport resolveReport(String run) { + if (run == null || run.isBlank() || run.equalsIgnoreCase("latest")) { + return SmokeTestService.get().latest(); + } + + return SmokeTestService.get().get(run); + } + + private void sendReport(SmokeDiagnosticsService.SmokeRunReport report) { + String elapsed = Form.duration(Math.max(0L, report.getElapsedMs()), 0); + sender().sendMessage(C.GREEN + "Smoke run " + C.GOLD + report.getRunId() + C.GREEN + " (" + C.GOLD + report.getMode() + C.GREEN + ")"); + sender().sendMessage(C.GRAY + "World: " + C.WHITE + fallback(report.getWorldName()) + C.GRAY + " | Outcome: " + C.WHITE + fallback(report.getOutcome())); + sender().sendMessage(C.GRAY + "Stage: " + C.WHITE + fallback(report.getStage()) + C.GRAY + " | Elapsed: " + C.WHITE + elapsed); + if (report.getStageDetail() != null && !report.getStageDetail().isBlank()) { + sender().sendMessage(C.GRAY + "Stage detail: " + C.WHITE + report.getStageDetail()); + } + sender().sendMessage(C.GRAY + "Lifecycle backend: " + C.WHITE + fallback(report.getLifecycleBackend())); + sender().sendMessage(C.GRAY + "Runtime backend: " + C.WHITE + fallback(report.getRuntimeBackend())); + sender().sendMessage(C.GRAY + "Generation session: " + C.WHITE + report.getGenerationSessionId() + C.GRAY + " | Active leases: " + C.WHITE + report.getGenerationActiveLeases()); + if (report.getEntryChunkX() != null && report.getEntryChunkZ() != null) { + sender().sendMessage(C.GRAY + "Entry chunk: " + C.WHITE + report.getEntryChunkX() + "," + report.getEntryChunkZ()); + } + sender().sendMessage(C.GRAY + "Headless: " + C.WHITE + report.isHeadless() + C.GRAY + " | Player: " + C.WHITE + fallback(report.getPlayerName())); + sender().sendMessage(C.GRAY + "Retain on failure: " + C.WHITE + report.isRetainOnFailure() + C.GRAY + " | Cleanup applied: " + C.WHITE + report.isCleanupApplied()); + sendDatapackReadiness(report.getDatapackReadiness()); + if (!report.getNotes().isEmpty()) { + sender().sendMessage(C.GRAY + "Notes: " + C.WHITE + joinList(report.getNotes())); + } + if (report.getFailureType() != null && !report.getFailureType().isBlank()) { + sender().sendMessage(C.RED + "Failure: " + report.getFailureType() + C.GRAY + " - " + C.WHITE + fallback(report.getFailureMessage())); + if (!report.getFailureChain().isEmpty()) { + sender().sendMessage(C.RED + "Failure chain: " + C.WHITE + joinList(report.getFailureChain())); + } + } + } + + private void sendDatapackReadiness(DatapackReadinessResult readiness) { + if (readiness == null) { + return; + } + + sender().sendMessage(C.GRAY + "Datapack pack key: " + C.WHITE + fallback(readiness.getRequestedPackKey())); + sender().sendMessage(C.GRAY + "Datapack folders: " + C.WHITE + joinList(readiness.getResolvedDatapackFolders())); + sender().sendMessage(C.GRAY + "External datapack result: " + C.WHITE + fallback(readiness.getExternalDatapackInstallResult())); + sender().sendMessage(C.GRAY + "Verification passed: " + C.WHITE + readiness.isVerificationPassed() + C.GRAY + " | Restart required: " + C.WHITE + readiness.isRestartRequired()); + if (!readiness.getMissingPaths().isEmpty()) { + sender().sendMessage(C.RED + "Missing datapack paths: " + C.WHITE + joinList(readiness.getMissingPaths())); + } + } + + private String latestReportPath() { + if (Iris.instance == null) { + return "plugins/Iris/diagnostics/smoke/latest.json"; + } + + return Iris.instance.getDataFile("diagnostics", "smoke", "latest.json").getAbsolutePath(); + } + + private String joinList(List values) { + if (values == null || values.isEmpty()) { + return "none"; + } + + return String.join(", ", values); + } + + private String fallback(String value) { + if (value == null || value.isBlank()) { + return "none"; + } + + return value; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 0eb013dfc..953aef98b 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -26,9 +26,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.IrisNoisemapPrebakePipeline; import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.object.*; import art.arcane.iris.engine.platform.ChunkReplacementListener; import art.arcane.iris.engine.platform.ChunkReplacementOptions; @@ -141,8 +139,25 @@ public class CommandStudio implements DirectorExecutor { return; } - Iris.service(StudioSVC.class).close(); - sender().sendMessage(C.GREEN + "Project Closed."); + sender().sendMessage(C.YELLOW + "Closing studio..."); + Iris.service(StudioSVC.class).close().whenComplete((result, throwable) -> J.s(() -> { + if (throwable != null) { + sender().sendMessage(C.RED + "Studio close failed: " + throwable.getMessage()); + return; + } + + if (result != null && result.failureCause() != null) { + sender().sendMessage(C.RED + "Studio close failed: " + result.failureCause().getMessage()); + return; + } + + if (result != null && result.startupCleanupQueued()) { + sender().sendMessage(C.YELLOW + "Studio closed. Remaining world-family cleanup was queued for startup fallback."); + return; + } + + sender().sendMessage(C.GREEN + "Studio closed."); + })); } @Director(description = "Create a new studio project", aliases = "+", sync = true) @@ -455,17 +470,13 @@ public class CommandStudio implements DirectorExecutor { IrisData data = IrisData.get(pack); PlatformChunkGenerator activeGenerator = resolveProfileGenerator(dimension); Engine activeEngine = activeGenerator == null ? null : activeGenerator.getEngine(); - long profileSeed = IrisNoisemapPrebakePipeline.dynamicStartupSeed(); if (activeEngine != null) { - profileSeed = activeEngine.getSeedManager().getSeed(); IrisToolbelt.applyPregenPerformanceProfile(activeEngine); } else { IrisToolbelt.applyPregenPerformanceProfile(); } - IrisNoisemapPrebakePipeline.prebake(data, new SeedManager(profileSeed), "studio-profile", dimension.getLoadKey()); - KList fileText = new KList<>(); KMap styleTimings = new KMap<>(); @@ -644,30 +655,6 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(C.GREEN + "Done! " + report.getPath()); } - @Director(description = "Profiles a dimension with a cache warm-up pass", origin = DirectorOrigin.PLAYER) - public void profilecache( - @Param(description = "The dimension to profile", contextual = true, defaultValue = "default", customHandler = DimensionHandler.class) - IrisDimension dimension - ) { - File pack = dimension.getLoadFile().getParentFile().getParentFile(); - IrisData data = IrisData.get(pack); - PlatformChunkGenerator activeGenerator = resolveProfileGenerator(dimension); - Engine activeEngine = activeGenerator == null ? null : activeGenerator.getEngine(); - long profileSeed = IrisNoisemapPrebakePipeline.dynamicStartupSeed(); - - if (activeEngine != null) { - profileSeed = activeEngine.getSeedManager().getSeed(); - IrisToolbelt.applyPregenPerformanceProfile(activeEngine); - } else { - IrisToolbelt.applyPregenPerformanceProfile(); - } - - sender().sendMessage(C.YELLOW + "Warming noisemap cache for profile..."); - IrisNoisemapPrebakePipeline.prebakeForced(data, new SeedManager(profileSeed), "studio-profilecache", dimension.getLoadKey()); - sender().sendMessage(C.YELLOW + "Running measured profile pass..."); - profile(dimension); - } - @Director(description = "List pack noise generators as pack/generator", aliases = {"pack-noise", "packnoises"}) public void packnoise() { LinkedHashSet packFolders = new LinkedHashSet<>(); diff --git a/core/src/main/java/art/arcane/iris/core/events/IrisLootEvent.java b/core/src/main/java/art/arcane/iris/core/events/IrisLootEvent.java index 46c06bccf..0173eb7eb 100644 --- a/core/src/main/java/art/arcane/iris/core/events/IrisLootEvent.java +++ b/core/src/main/java/art/arcane/iris/core/events/IrisLootEvent.java @@ -105,9 +105,37 @@ public class IrisLootEvent extends Event { if (!Bukkit.isPrimaryThread()) { Iris.warn("LootGenerateEvent was not called on the main thread, please report this issue."); Thread.dumpStack(); - J.sfut(() -> Bukkit.getPluginManager().callEvent(event)).join(); - } else Bukkit.getPluginManager().callEvent(event); + J.sfut(() -> { + try { + Bukkit.getPluginManager().callEvent(event); + } catch (Throwable e) { + Iris.reportError("LootGenerateEvent dispatch failed at " + + world.getName() + " [" + x + "," + y + "," + z + "].", e); + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error error) { + throw error; + } + throw new IllegalStateException(e); + } + }).join(); + } else { + try { + Bukkit.getPluginManager().callEvent(event); + } catch (Throwable e) { + Iris.reportError("LootGenerateEvent dispatch failed at " + + world.getName() + " [" + x + "," + y + "," + z + "].", e); + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error error) { + throw error; + } + throw new IllegalStateException(e); + } + } return event.isCancelled(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java new file mode 100644 index 000000000..5f3c03aaf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java @@ -0,0 +1,58 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.WorldCreator; + +import java.util.concurrent.CompletableFuture; + +final class BukkitPublicBackend implements WorldLifecycleBackend { + private final CapabilitySnapshot capabilities; + + BukkitPublicBackend(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + } + + @Override + public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) { + return true; + } + + @Override + public CompletableFuture create(WorldLifecycleRequest request) { + World existing = Bukkit.getWorld(request.worldName()); + if (existing != null) { + return CompletableFuture.completedFuture(existing); + } + + WorldCreator creator = request.toWorldCreator(); + if (request.generator() != null) { + WorldLifecycleStaging.stageGenerator(request.worldName(), request.generator(), request.biomeProvider()); + WorldLifecycleStaging.stageStemGenerator(request.worldName(), request.generator()); + } + + try { + World world = creator.createWorld(); + return CompletableFuture.completedFuture(world); + } catch (Throwable e) { + return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e)); + } finally { + WorldLifecycleStaging.clearAll(request.worldName()); + } + } + + @Override + public boolean unload(World world, boolean save) { + return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + } + + @Override + public String backendName() { + return "bukkit_public"; + } + + @Override + public String describeSelectionReason() { + return "public Bukkit world lifecycle path"; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilityResolution.java b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilityResolution.java new file mode 100644 index 000000000..909eac039 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilityResolution.java @@ -0,0 +1,235 @@ +package art.arcane.iris.core.lifecycle; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.function.Predicate; + +final class CapabilityResolution { + private CapabilityResolution() { + } + + static Method resolveCreateLevelMethod(Class owner) throws NoSuchMethodException { + Method current = resolveMethod(owner, "createLevel", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 3 + && "LevelStem".equals(params[0].getSimpleName()) + && "WorldLoadingInfoAndData".equals(params[1].getSimpleName()) + && "WorldDataAndGenSettings".equals(params[2].getSimpleName()); + }); + if (current != null) { + return current; + } + + Method legacy = resolveMethod(owner, "createLevel", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 4 + && "LevelStem".equals(params[0].getSimpleName()) + && "WorldLoadingInfo".equals(params[1].getSimpleName()) + && "LevelStorageAccess".equals(params[2].getSimpleName()) + && "PrimaryLevelData".equals(params[3].getSimpleName()); + }); + if (legacy != null) { + return legacy; + } + + throw new NoSuchMethodException(owner.getName() + "#createLevel"); + } + + static Method resolveLevelStorageAccessMethod(Class owner) throws NoSuchMethodException { + Method exactValidate = resolveMethod(owner, "validateAndCreateAccess", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 2 + && String.class.equals(params[0]) + && "ResourceKey".equals(params[1].getSimpleName()) + && "LevelStorageAccess".equals(method.getReturnType().getSimpleName()); + }); + if (exactValidate != null) { + return exactValidate; + } + + Method oneArgValidate = resolveMethod(owner, "validateAndCreateAccess", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 + && String.class.equals(params[0]) + && "LevelStorageAccess".equals(method.getReturnType().getSimpleName()); + }); + if (oneArgValidate != null) { + return oneArgValidate; + } + + Method exactCreate = resolveMethod(owner, "createAccess", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 2 + && String.class.equals(params[0]) + && "ResourceKey".equals(params[1].getSimpleName()) + && "LevelStorageAccess".equals(method.getReturnType().getSimpleName()); + }); + if (exactCreate != null) { + return exactCreate; + } + + Method oneArgCreate = resolveMethod(owner, "createAccess", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 + && String.class.equals(params[0]) + && "LevelStorageAccess".equals(method.getReturnType().getSimpleName()); + }); + if (oneArgCreate != null) { + return oneArgCreate; + } + + throw new NoSuchMethodException(owner.getName() + "#validateAndCreateAccess/createAccess"); + } + + static Method resolvePaperWorldDataMethod(Class owner) throws NoSuchMethodException { + Method current = resolveMethod(owner, "loadWorldData", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 3 + && "MinecraftServer".equals(params[0].getSimpleName()) + && "ResourceKey".equals(params[1].getSimpleName()) + && String.class.equals(params[2]) + && "LoadedWorldData".equals(method.getReturnType().getSimpleName()); + }); + if (current != null) { + return current; + } + + Method legacy = resolveMethod(owner, "getLevelData", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && "LevelStorageAccess".equals(params[0].getSimpleName()); + }); + if (legacy != null) { + return legacy; + } + + throw new NoSuchMethodException(owner.getName() + "#loadWorldData/getLevelData"); + } + + static Constructor resolveWorldLoadingInfoConstructor(Class owner) throws NoSuchMethodException { + Constructor current = resolveConstructor(owner, constructor -> { + Class[] params = constructor.getParameterTypes(); + return params.length == 4 + && "Environment".equals(params[0].getSimpleName()) + && "ResourceKey".equals(params[1].getSimpleName()) + && "ResourceKey".equals(params[2].getSimpleName()) + && boolean.class.equals(params[3]); + }); + if (current != null) { + return current; + } + + Constructor legacy = resolveConstructor(owner, constructor -> { + Class[] params = constructor.getParameterTypes(); + return params.length == 5 + && int.class.equals(params[0]) + && String.class.equals(params[1]) + && String.class.equals(params[2]) + && "ResourceKey".equals(params[3].getSimpleName()) + && boolean.class.equals(params[4]); + }); + if (legacy != null) { + return legacy; + } + + throw new NoSuchMethodException(owner.getName() + "#"); + } + + static Constructor resolveWorldLoadingInfoAndDataConstructor(Class owner) throws NoSuchMethodException { + Constructor constructor = resolveConstructor(owner, candidate -> { + Class[] params = candidate.getParameterTypes(); + return params.length == 2 + && "WorldLoadingInfo".equals(params[0].getSimpleName()) + && "LoadedWorldData".equals(params[1].getSimpleName()); + }); + if (constructor == null) { + throw new NoSuchMethodException(owner.getName() + "#"); + } + return constructor; + } + + static Method resolveCreateNewWorldDataMethod(Class owner) throws NoSuchMethodException { + Method method = resolveMethod(owner, "createNewWorldData", candidate -> { + Class[] params = candidate.getParameterTypes(); + return params.length == 5 + && "DedicatedServerSettings".equals(params[0].getSimpleName()) + && "DataLoadContext".equals(params[1].getSimpleName()) + && "Registry".equals(params[2].getSimpleName()) + && boolean.class.equals(params[3]) + && boolean.class.equals(params[4]); + }); + if (method == null) { + throw new NoSuchMethodException(owner.getName() + "#createNewWorldData"); + } + return method; + } + + static Method resolveServerRegistryAccessMethod(Class owner) throws NoSuchMethodException { + Method method = resolveMethod(owner, "registryAccess", candidate -> candidate.getParameterCount() == 0 + && !void.class.equals(candidate.getReturnType())); + if (method == null) { + throw new NoSuchMethodException(owner.getName() + "#registryAccess"); + } + return method; + } + + static Method resolveMethod(Class owner, String name, Predicate predicate) { + Method selected = scanMethods(owner.getMethods(), name, predicate); + if (selected != null) { + return selected; + } + + Class current = owner; + while (current != null) { + selected = scanMethods(current.getDeclaredMethods(), name, predicate); + if (selected != null) { + selected.setAccessible(true); + return selected; + } + current = current.getSuperclass(); + } + + return null; + } + + static Field resolveField(Class owner, String name) throws NoSuchFieldException { + Class current = owner; + while (current != null) { + try { + Field field = current.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(owner.getName() + "#" + name); + } + + private static Method scanMethods(Method[] methods, String name, Predicate predicate) { + for (Method method : methods) { + if (!method.getName().equals(name)) { + continue; + } + if (predicate.test(method)) { + return method; + } + } + return null; + } + + private static Constructor resolveConstructor(Class owner, Predicate> predicate) { + for (Constructor constructor : owner.getConstructors()) { + if (predicate.test(constructor)) { + return constructor; + } + } + for (Constructor constructor : owner.getDeclaredConstructors()) { + if (predicate.test(constructor)) { + constructor.setAccessible(true); + return constructor; + } + } + return null; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java new file mode 100644 index 000000000..6edba406a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java @@ -0,0 +1,612 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.scheduling.FoliaScheduler; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.plugin.RegisteredServiceProvider; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Locale; + +public final class CapabilitySnapshot { + public enum PaperLikeFlavor { + CURRENT_INFO_AND_DATA, + LEGACY_STORAGE_ACCESS, + UNSUPPORTED + } + + private final ServerFamily serverFamily; + private final boolean regionizedRuntime; + private final Object worldsProvider; + private final Class worldsLevelStemClass; + private final Class worldsGeneratorTypeClass; + private final String worldsProviderResolution; + private final Object bukkitServer; + private final Object minecraftServer; + private final Method createLevelMethod; + private final PaperLikeFlavor paperLikeFlavor; + private final Class paperWorldLoaderClass; + private final Method paperWorldDataMethod; + private final Constructor worldLoadingInfoConstructor; + private final Constructor worldLoadingInfoAndDataConstructor; + private final Method createNewWorldDataMethod; + private final Method levelStorageAccessMethod; + private final Field worldLoaderContextField; + private final Method serverRegistryAccessMethod; + private final Field settingsField; + private final Field optionsField; + private final Method isDemoMethod; + private final Method unloadWorldAsyncMethod; + private final Method chunkAtAsyncMethod; + private final Method removeLevelMethod; + private final String paperLikeResolution; + + private CapabilitySnapshot( + ServerFamily serverFamily, + boolean regionizedRuntime, + Object worldsProvider, + Class worldsLevelStemClass, + Class worldsGeneratorTypeClass, + String worldsProviderResolution, + Object bukkitServer, + Object minecraftServer, + Method createLevelMethod, + PaperLikeFlavor paperLikeFlavor, + Class paperWorldLoaderClass, + Method paperWorldDataMethod, + Constructor worldLoadingInfoConstructor, + Constructor worldLoadingInfoAndDataConstructor, + Method createNewWorldDataMethod, + Method levelStorageAccessMethod, + Field worldLoaderContextField, + Method serverRegistryAccessMethod, + Field settingsField, + Field optionsField, + Method isDemoMethod, + Method unloadWorldAsyncMethod, + Method chunkAtAsyncMethod, + Method removeLevelMethod, + String paperLikeResolution + ) { + this.serverFamily = serverFamily; + this.regionizedRuntime = regionizedRuntime; + this.worldsProvider = worldsProvider; + this.worldsLevelStemClass = worldsLevelStemClass; + this.worldsGeneratorTypeClass = worldsGeneratorTypeClass; + this.worldsProviderResolution = worldsProviderResolution; + this.bukkitServer = bukkitServer; + this.minecraftServer = minecraftServer; + this.createLevelMethod = createLevelMethod; + this.paperLikeFlavor = paperLikeFlavor; + this.paperWorldLoaderClass = paperWorldLoaderClass; + this.paperWorldDataMethod = paperWorldDataMethod; + this.worldLoadingInfoConstructor = worldLoadingInfoConstructor; + this.worldLoadingInfoAndDataConstructor = worldLoadingInfoAndDataConstructor; + this.createNewWorldDataMethod = createNewWorldDataMethod; + this.levelStorageAccessMethod = levelStorageAccessMethod; + this.worldLoaderContextField = worldLoaderContextField; + this.serverRegistryAccessMethod = serverRegistryAccessMethod; + this.settingsField = settingsField; + this.optionsField = optionsField; + this.isDemoMethod = isDemoMethod; + this.unloadWorldAsyncMethod = unloadWorldAsyncMethod; + this.chunkAtAsyncMethod = chunkAtAsyncMethod; + this.removeLevelMethod = removeLevelMethod; + this.paperLikeResolution = paperLikeResolution; + } + + public static CapabilitySnapshot probe() { + Server server = Bukkit.getServer(); + Object bukkitServer = server; + boolean regionizedRuntime = FoliaScheduler.isRegionizedRuntime(server); + ServerFamily serverFamily = detectServerFamily(server, regionizedRuntime); + + Object worldsProvider = null; + Class worldsLevelStemClass = null; + Class worldsGeneratorTypeClass = null; + String worldsProviderResolution = "inactive"; + try { + Object[] worldsProviderData = resolveWorldsProvider(); + worldsProvider = worldsProviderData[0]; + worldsLevelStemClass = (Class) worldsProviderData[1]; + worldsGeneratorTypeClass = (Class) worldsProviderData[2]; + worldsProviderResolution = (String) worldsProviderData[3]; + } catch (Throwable e) { + worldsProviderResolution = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage()); + } + + Object minecraftServer = null; + Method createLevelMethod = null; + PaperLikeFlavor paperLikeFlavor = PaperLikeFlavor.UNSUPPORTED; + Class paperWorldLoaderClass = null; + Method paperWorldDataMethod = null; + Constructor worldLoadingInfoConstructor = null; + Constructor worldLoadingInfoAndDataConstructor = null; + Method createNewWorldDataMethod = null; + Method levelStorageAccessMethod = null; + Field worldLoaderContextField = null; + Method serverRegistryAccessMethod = null; + Field settingsField = null; + Field optionsField = null; + Method isDemoMethod = null; + Method removeLevelMethod = null; + String paperLikeResolution = "inactive"; + + try { + if (bukkitServer != null) { + Method getServerMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "getServer", method -> method.getParameterCount() == 0); + if (getServerMethod != null) { + minecraftServer = getServerMethod.invoke(bukkitServer); + } + } + + if (minecraftServer != null) { + Class minecraftServerClass = Class.forName("net.minecraft.server.MinecraftServer"); + if (!minecraftServerClass.isInstance(minecraftServer)) { + throw new IllegalStateException("resolved server is not a MinecraftServer: " + minecraftServer.getClass().getName()); + } + + createLevelMethod = CapabilityResolution.resolveCreateLevelMethod(minecraftServer.getClass()); + removeLevelMethod = CapabilityResolution.resolveMethod(minecraftServer.getClass(), "removeLevel", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && "ServerLevel".equals(params[0].getSimpleName()); + }); + worldLoaderContextField = CapabilityResolution.resolveField(minecraftServer.getClass(), "worldLoaderContext"); + serverRegistryAccessMethod = CapabilityResolution.resolveServerRegistryAccessMethod(minecraftServer.getClass()); + settingsField = CapabilityResolution.resolveField(minecraftServer.getClass(), "settings"); + optionsField = CapabilityResolution.resolveField(minecraftServer.getClass(), "options"); + isDemoMethod = CapabilityResolution.resolveMethod(minecraftServer.getClass(), "isDemo", method -> method.getParameterCount() == 0 && boolean.class.equals(method.getReturnType())); + + Class mainClass = Class.forName("net.minecraft.server.Main"); + createNewWorldDataMethod = CapabilityResolution.resolveCreateNewWorldDataMethod(mainClass); + + Class paperLoaderCandidate = Class.forName("io.papermc.paper.world.PaperWorldLoader"); + paperWorldLoaderClass = paperLoaderCandidate; + paperWorldDataMethod = CapabilityResolution.resolvePaperWorldDataMethod(paperLoaderCandidate); + Class worldLoadingInfoClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo"); + worldLoadingInfoConstructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(worldLoadingInfoClass); + + if (createLevelMethod.getParameterCount() == 3) { + Class worldLoadingInfoAndDataClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfoAndData"); + worldLoadingInfoAndDataConstructor = CapabilityResolution.resolveWorldLoadingInfoAndDataConstructor(worldLoadingInfoAndDataClass); + paperLikeFlavor = PaperLikeFlavor.CURRENT_INFO_AND_DATA; + } else { + Class levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource"); + levelStorageAccessMethod = CapabilityResolution.resolveLevelStorageAccessMethod(levelStorageSourceClass); + paperLikeFlavor = PaperLikeFlavor.LEGACY_STORAGE_ACCESS; + } + + paperLikeResolution = "available(flavor=" + paperLikeFlavor.name().toLowerCase(Locale.ROOT) + + ", createLevel=" + createLevelMethod.toGenericString() + ")"; + } + } catch (Throwable e) { + paperLikeResolution = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage()); + createLevelMethod = null; + paperLikeFlavor = PaperLikeFlavor.UNSUPPORTED; + paperWorldLoaderClass = null; + paperWorldDataMethod = null; + worldLoadingInfoConstructor = null; + worldLoadingInfoAndDataConstructor = null; + createNewWorldDataMethod = null; + levelStorageAccessMethod = null; + worldLoaderContextField = null; + serverRegistryAccessMethod = null; + settingsField = null; + optionsField = null; + isDemoMethod = null; + removeLevelMethod = null; + } + + Method unloadWorldAsyncMethod = null; + try { + if (bukkitServer != null) { + unloadWorldAsyncMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "unloadWorldAsync", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 3 + && World.class.equals(params[0]) + && boolean.class.equals(params[1]) + && "Consumer".equals(params[2].getSimpleName()); + }); + } + } catch (Throwable ignored) { + unloadWorldAsyncMethod = null; + } + + Method chunkAtAsyncMethod = null; + try { + chunkAtAsyncMethod = CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 3 + && int.class.equals(params[0]) + && int.class.equals(params[1]) + && boolean.class.equals(params[2]); + }); + } catch (Throwable ignored) { + chunkAtAsyncMethod = null; + } + + return new CapabilitySnapshot( + serverFamily, + regionizedRuntime, + worldsProvider, + worldsLevelStemClass, + worldsGeneratorTypeClass, + worldsProviderResolution, + bukkitServer, + minecraftServer, + createLevelMethod, + paperLikeFlavor, + paperWorldLoaderClass, + paperWorldDataMethod, + worldLoadingInfoConstructor, + worldLoadingInfoAndDataConstructor, + createNewWorldDataMethod, + levelStorageAccessMethod, + worldLoaderContextField, + serverRegistryAccessMethod, + settingsField, + optionsField, + isDemoMethod, + unloadWorldAsyncMethod, + chunkAtAsyncMethod, + removeLevelMethod, + paperLikeResolution + ); + } + + public static CapabilitySnapshot forTesting(ServerFamily serverFamily, boolean regionizedRuntime, boolean worldsProviderHealthy, boolean paperLikeRuntimeHealthy) { + Object minecraftServer = paperLikeRuntimeHealthy ? new TestingPaperLikeServer("datapack-registry", "server-registry") : null; + Method createLevelMethod = null; + Field worldLoaderContextField = null; + Method serverRegistryAccessMethod = null; + try { + createLevelMethod = paperLikeRuntimeHealthy + ? TestingPaperLikeServer.class.getDeclaredMethod("createLevel", Object.class, Object.class, Object.class) + : null; + worldLoaderContextField = paperLikeRuntimeHealthy + ? CapabilityResolution.resolveField(TestingPaperLikeServer.class, "worldLoaderContext") + : null; + serverRegistryAccessMethod = paperLikeRuntimeHealthy + ? CapabilityResolution.resolveServerRegistryAccessMethod(TestingPaperLikeServer.class) + : null; + } catch (NoSuchMethodException | NoSuchFieldException e) { + throw new IllegalStateException(e); + } + return new CapabilitySnapshot( + serverFamily, + regionizedRuntime, + worldsProviderHealthy ? new Object() : null, + worldsProviderHealthy ? Object.class : null, + worldsProviderHealthy ? Object.class : null, + worldsProviderHealthy ? "test-provider" : "inactive", + null, + minecraftServer, + createLevelMethod, + paperLikeRuntimeHealthy ? PaperLikeFlavor.CURRENT_INFO_AND_DATA : PaperLikeFlavor.UNSUPPORTED, + null, + null, + null, + null, + null, + null, + worldLoaderContextField, + serverRegistryAccessMethod, + null, + null, + null, + null, + null, + null, + paperLikeRuntimeHealthy ? "available(test)" : "unsupported(test)" + ); + } + + public static CapabilitySnapshot forTestingRuntimeRegistries(ServerFamily serverFamily, boolean regionizedRuntime, Object datapackDimensions, Object serverRegistryAccess) { + TestingPaperLikeServer minecraftServer = new TestingPaperLikeServer(datapackDimensions, serverRegistryAccess); + Method createLevelMethod; + Field worldLoaderContextField; + Method registryAccessMethod; + try { + createLevelMethod = TestingPaperLikeServer.class.getDeclaredMethod("createLevel", Object.class, Object.class, Object.class); + worldLoaderContextField = CapabilityResolution.resolveField(TestingPaperLikeServer.class, "worldLoaderContext"); + registryAccessMethod = CapabilityResolution.resolveServerRegistryAccessMethod(TestingPaperLikeServer.class); + } catch (NoSuchMethodException | NoSuchFieldException e) { + throw new IllegalStateException(e); + } + return new CapabilitySnapshot( + serverFamily, + regionizedRuntime, + null, + null, + null, + "inactive", + null, + minecraftServer, + createLevelMethod, + PaperLikeFlavor.CURRENT_INFO_AND_DATA, + null, + null, + null, + null, + null, + null, + worldLoaderContextField, + registryAccessMethod, + null, + null, + null, + null, + null, + null, + "available(test-runtime-registries)" + ); + } + + public ServerFamily serverFamily() { + return serverFamily; + } + + public boolean regionizedRuntime() { + return regionizedRuntime; + } + + public Object worldsProvider() { + return worldsProvider; + } + + public Class worldsLevelStemClass() { + return worldsLevelStemClass; + } + + public Class worldsGeneratorTypeClass() { + return worldsGeneratorTypeClass; + } + + public Object bukkitServer() { + return bukkitServer; + } + + public Object minecraftServer() { + return minecraftServer; + } + + public Method createLevelMethod() { + return createLevelMethod; + } + + public PaperLikeFlavor paperLikeFlavor() { + return paperLikeFlavor; + } + + public Class paperWorldLoaderClass() { + return paperWorldLoaderClass; + } + + public Method paperWorldDataMethod() { + return paperWorldDataMethod; + } + + public Constructor worldLoadingInfoConstructor() { + return worldLoadingInfoConstructor; + } + + public Constructor worldLoadingInfoAndDataConstructor() { + return worldLoadingInfoAndDataConstructor; + } + + public Method createNewWorldDataMethod() { + return createNewWorldDataMethod; + } + + public Method levelStorageAccessMethod() { + return levelStorageAccessMethod; + } + + public Field worldLoaderContextField() { + return worldLoaderContextField; + } + + public Method serverRegistryAccessMethod() { + return serverRegistryAccessMethod; + } + + public Field settingsField() { + return settingsField; + } + + public Field optionsField() { + return optionsField; + } + + public Method isDemoMethod() { + return isDemoMethod; + } + + public Method unloadWorldAsyncMethod() { + return unloadWorldAsyncMethod; + } + + public Method chunkAtAsyncMethod() { + return chunkAtAsyncMethod; + } + + public Method removeLevelMethod() { + return removeLevelMethod; + } + + public boolean hasWorldsProvider() { + return worldsProvider != null && worldsLevelStemClass != null && worldsGeneratorTypeClass != null; + } + + public boolean hasPaperLikeRuntime() { + return minecraftServer != null + && createLevelMethod != null + && serverRegistryAccessMethod != null + && paperLikeFlavor != PaperLikeFlavor.UNSUPPORTED; + } + + public String worldsProviderResolution() { + return worldsProviderResolution; + } + + public String paperLikeResolution() { + return paperLikeResolution; + } + + public String describe() { + return "family=" + serverFamily.id() + + ", regionizedRuntime=" + regionizedRuntime + + ", worldsProvider=" + worldsProviderResolution + + ", paperLike=" + paperLikeResolution + + ", serverRegistryAccess=" + (serverRegistryAccessMethod != null) + + ", unloadAsync=" + (unloadWorldAsyncMethod != null) + + ", chunkAsync=" + (chunkAtAsyncMethod != null); + } + + private static ServerFamily detectServerFamily(Server server, boolean regionizedRuntime) { + String bukkitName = server == null ? "" : server.getName(); + String bukkitVersion = server == null ? "" : server.getVersion(); + String serverClassName = server == null ? "" : server.getClass().getName(); + boolean canvasRuntime = hasCanvasRuntime(); + + if (containsIgnoreCase(bukkitName, "folia") + || containsIgnoreCase(bukkitVersion, "folia") + || containsIgnoreCase(serverClassName, "folia")) { + return ServerFamily.FOLIA; + } + + if (canvasRuntime + || containsIgnoreCase(bukkitName, "canvas") + || containsIgnoreCase(bukkitVersion, "canvas") + || containsIgnoreCase(serverClassName, "canvas")) { + return regionizedRuntime ? ServerFamily.CANVAS : ServerFamily.CANVAS; + } + + if (containsIgnoreCase(bukkitName, "purpur") + || containsIgnoreCase(bukkitVersion, "purpur") + || containsIgnoreCase(serverClassName, "purpur")) { + return ServerFamily.PURPUR; + } + + if (containsIgnoreCase(bukkitName, "paper") + || containsIgnoreCase(bukkitVersion, "paper") + || containsIgnoreCase(serverClassName, "paper") + || containsIgnoreCase(bukkitName, "pufferfish") + || containsIgnoreCase(bukkitVersion, "pufferfish") + || containsIgnoreCase(serverClassName, "pufferfish")) { + return ServerFamily.PAPER; + } + + if (containsIgnoreCase(bukkitName, "spigot") + || containsIgnoreCase(bukkitVersion, "spigot") + || containsIgnoreCase(serverClassName, "spigot")) { + return ServerFamily.SPIGOT; + } + + if (containsIgnoreCase(bukkitName, "craftbukkit") + || containsIgnoreCase(bukkitVersion, "craftbukkit") + || containsIgnoreCase(serverClassName, "craftbukkit") + || containsIgnoreCase(bukkitName, "bukkit") + || containsIgnoreCase(bukkitVersion, "bukkit")) { + return ServerFamily.BUKKIT; + } + + if (regionizedRuntime || J.isFolia()) { + return ServerFamily.FOLIA; + } + + return ServerFamily.UNKNOWN; + } + + private static boolean hasCanvasRuntime() { + try { + Class.forName("io.canvasmc.canvas.region.WorldRegionizer"); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean containsIgnoreCase(String value, String needle) { + if (value == null || needle == null || needle.isEmpty()) { + return false; + } + return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT)); + } + + private static Object[] resolveWorldsProvider() throws Throwable { + try { + Class worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider"); + Class levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem"); + Class generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType"); + Object provider = Bukkit.getServicesManager().load(worldsProviderClass); + String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")"; + return new Object[]{provider, levelStemClass, generatorTypeClass, resolution}; + } catch (Throwable ignored) { + } + + Collection> knownServices = Bukkit.getServicesManager().getKnownServices(); + for (Class serviceClass : knownServices) { + if (!"net.thenextlvl.worlds.api.WorldsProvider".equals(serviceClass.getName())) { + continue; + } + + RegisteredServiceProvider registration = Bukkit.getServicesManager().getRegistration(serviceClass); + if (registration == null) { + continue; + } + + Object provider = registration.getProvider(); + ClassLoader loader = serviceClass.getClassLoader(); + if (loader == null && provider != null) { + loader = provider.getClass().getClassLoader(); + } + if (loader == null) { + continue; + } + + Class levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem", false, loader); + Class generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType", false, loader); + return new Object[]{provider, levelStemClass, generatorTypeClass, "active(service-scan=" + provider.getClass().getName() + ")"}; + } + + return new Object[]{null, null, null, "inactive(service scan found nothing)"}; + } + + private static final class TestingPaperLikeServer { + private final TestingWorldLoaderContext worldLoaderContext; + private final Object registryAccess; + + private TestingPaperLikeServer(Object datapackDimensions, Object registryAccess) { + this.worldLoaderContext = new TestingWorldLoaderContext(datapackDimensions); + this.registryAccess = registryAccess; + } + + @SuppressWarnings("unused") + private void createLevel(Object levelStem, Object worldLoadingInfoAndData, Object worldDataAndGenSettings) { + } + + @SuppressWarnings("unused") + private Object registryAccess() { + return registryAccess; + } + } + + private static final class TestingWorldLoaderContext { + private final Object datapackDimensions; + + private TestingWorldLoaderContext(Object datapackDimensions) { + this.datapackDimensions = datapackDimensions; + } + + @SuppressWarnings("unused") + private Object datapackDimensions() { + return datapackDimensions; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java new file mode 100644 index 000000000..75655d239 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java @@ -0,0 +1,89 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.Iris; +import org.bukkit.Bukkit; +import org.bukkit.World; + +import java.lang.reflect.Method; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +final class PaperLikeRuntimeBackend implements WorldLifecycleBackend { + private final CapabilitySnapshot capabilities; + + PaperLikeRuntimeBackend(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + } + + @Override + public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) { + return request.studio() + && capabilities.serverFamily().isPaperLike() + && capabilities.hasPaperLikeRuntime(); + } + + @Override + public CompletableFuture create(WorldLifecycleRequest request) { + Object legacyStorageAccess = null; + try { + World existing = Bukkit.getWorld(request.worldName()); + if (existing != null) { + return CompletableFuture.completedFuture(existing); + } + + if (request.generator() == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Runtime world creation requires a non-null chunk generator.")); + } + + WorldLifecycleStaging.stageGenerator(request.worldName(), request.generator(), request.biomeProvider()); + WorldLifecycleSupport.stageRuntimeConfiguration(request.worldName()); + + Iris.info("WorldLifecycle runtime LevelStem: world=" + request.worldName() + + ", backend=paper_like_runtime, flavor=" + capabilities.paperLikeFlavor().name().toLowerCase(Locale.ROOT) + + ", registrySource=" + WorldLifecycleSupport.runtimeLevelStemRegistrySource(request)); + Object levelStem = WorldLifecycleSupport.resolveRuntimeLevelStem(capabilities, request); + Object stemKey = WorldLifecycleSupport.createRuntimeLevelStemKey(request.worldName()); + + if (capabilities.paperLikeFlavor() == CapabilitySnapshot.PaperLikeFlavor.CURRENT_INFO_AND_DATA) { + Object dimensionKey = WorldLifecycleSupport.createDimensionKey(stemKey); + Object loadedWorldData = capabilities.paperWorldDataMethod().invoke(null, capabilities.minecraftServer(), dimensionKey, request.worldName()); + Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(request.environment(), stemKey, dimensionKey, true); + Object worldLoadingInfoAndData = capabilities.worldLoadingInfoAndDataConstructor().newInstance(worldLoadingInfo, loadedWorldData); + Object worldDataAndGenSettings = WorldLifecycleSupport.createCurrentWorldDataAndSettings(capabilities, request.worldName()); + capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfoAndData, worldDataAndGenSettings); + } else { + legacyStorageAccess = WorldLifecycleSupport.createLegacyStorageAccess(capabilities, request.worldName()); + Object primaryLevelData = WorldLifecycleSupport.createLegacyPrimaryLevelData(capabilities, legacyStorageAccess, request.worldName()); + Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(0, request.worldName(), request.environment().name().toLowerCase(Locale.ROOT), stemKey, true); + capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfo, legacyStorageAccess, primaryLevelData); + } + + World loadedWorld = Bukkit.getWorld(request.worldName()); + if (loadedWorld == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Paper-like runtime backend did not load world \"" + request.worldName() + "\".")); + } + + return CompletableFuture.completedFuture(loadedWorld); + } catch (Throwable e) { + return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e)); + } finally { + WorldLifecycleStaging.clearGenerator(request.worldName()); + WorldLifecycleSupport.closeLevelStorageAccess(legacyStorageAccess); + } + } + + @Override + public boolean unload(World world, boolean save) { + return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + } + + @Override + public String backendName() { + return "paper_like_runtime"; + } + + @Override + public String describeSelectionReason() { + return "server family " + capabilities.serverFamily().id() + " exposes paper-like runtime world lifecycle capabilities"; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/ServerFamily.java b/core/src/main/java/art/arcane/iris/core/lifecycle/ServerFamily.java new file mode 100644 index 000000000..0bfaf801f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/ServerFamily.java @@ -0,0 +1,21 @@ +package art.arcane.iris.core.lifecycle; + +import java.util.Locale; + +public enum ServerFamily { + BUKKIT, + SPIGOT, + PAPER, + PURPUR, + FOLIA, + CANVAS, + UNKNOWN; + + public boolean isPaperLike() { + return this == PAPER || this == PURPUR || this == FOLIA || this == CANVAS; + } + + public String id() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java new file mode 100644 index 000000000..109656193 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java @@ -0,0 +1,17 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.World; + +import java.util.concurrent.CompletableFuture; + +public interface WorldLifecycleBackend { + boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities); + + CompletableFuture create(WorldLifecycleRequest request); + + boolean unload(World world, boolean save); + + String backendName(); + + String describeSelectionReason(); +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleCaller.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleCaller.java new file mode 100644 index 000000000..b295e0a93 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleCaller.java @@ -0,0 +1,7 @@ +package art.arcane.iris.core.lifecycle; + +public enum WorldLifecycleCaller { + STUDIO, + CREATE, + BENCHMARK +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java new file mode 100644 index 000000000..d45016136 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java @@ -0,0 +1,51 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.WorldType; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; + +public record WorldLifecycleRequest( + String worldName, + World.Environment environment, + ChunkGenerator generator, + BiomeProvider biomeProvider, + WorldType worldType, + boolean generateStructures, + boolean hardcore, + long seed, + boolean studio, + boolean benchmark, + WorldLifecycleCaller callerKind +) { + public static WorldLifecycleRequest fromCreator(WorldCreator creator, boolean studio, boolean benchmark, WorldLifecycleCaller callerKind) { + return new WorldLifecycleRequest( + creator.name(), + creator.environment(), + creator.generator(), + creator.biomeProvider(), + creator.type(), + creator.generateStructures(), + creator.hardcore(), + creator.seed(), + studio, + benchmark, + callerKind + ); + } + + public WorldCreator toWorldCreator() { + WorldCreator creator = new WorldCreator(worldName) + .environment(environment) + .generateStructures(generateStructures) + .hardcore(hardcore) + .type(worldType) + .seed(seed) + .generator(generator); + if (biomeProvider != null) { + creator.biomeProvider(biomeProvider); + } + return creator; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java new file mode 100644 index 000000000..b5fc3cea9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java @@ -0,0 +1,178 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.Iris; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.World; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; + +public final class WorldLifecycleService { + private static volatile WorldLifecycleService instance; + + private final CapabilitySnapshot capabilities; + private final WorldsProviderBackend worldsProviderBackend; + private final PaperLikeRuntimeBackend paperLikeRuntimeBackend; + private final BukkitPublicBackend bukkitPublicBackend; + private final List backends; + private final Map worldBackendByName; + + public WorldLifecycleService(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + this.worldsProviderBackend = new WorldsProviderBackend(capabilities); + this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities); + this.bukkitPublicBackend = new BukkitPublicBackend(capabilities); + this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend); + this.worldBackendByName = new ConcurrentHashMap<>(); + } + + public static WorldLifecycleService get() { + WorldLifecycleService current = instance; + if (current != null) { + return current; + } + + synchronized (WorldLifecycleService.class) { + if (instance != null) { + return instance; + } + + CapabilitySnapshot capabilities = CapabilitySnapshot.probe(); + instance = new WorldLifecycleService(capabilities); + Iris.info("WorldLifecycle capabilities: %s", capabilities.describe()); + return instance; + } + } + + public CapabilitySnapshot capabilities() { + return capabilities; + } + + public CompletableFuture create(WorldLifecycleRequest request) { + WorldLifecycleBackend backend; + try { + backend = selectCreateBackend(request); + } catch (Throwable e) { + Iris.reportError("WorldLifecycle create backend selection failed for world=\"" + request.worldName() + + "\", caller=" + request.callerKind().name().toLowerCase() + ".", e); + return CompletableFuture.failedFuture(e); + } + Iris.info("WorldLifecycle create: world=%s, caller=%s, backend=%s, reason=%s", + request.worldName(), + request.callerKind().name().toLowerCase(), + backend.backendName(), + backend.describeSelectionReason()); + return backend.create(request).whenComplete((world, throwable) -> { + if (throwable != null) { + Throwable cause = WorldLifecycleSupport.unwrap(throwable); + Iris.reportError("WorldLifecycle create failed: world=\"" + request.worldName() + + "\", caller=" + request.callerKind().name().toLowerCase() + + ", backend=" + backend.backendName() + + ", family=" + capabilities.serverFamily().id() + ".", cause); + return; + } + if (world != null) { + worldBackendByName.put(world.getName(), backend.backendName()); + } + }); + } + + public World createBlocking(WorldLifecycleRequest request) { + try { + return create(request).join(); + } catch (CompletionException e) { + throw new IllegalStateException(WorldLifecycleSupport.unwrap(e)); + } + } + + public boolean unload(World world, boolean save) { + if (!J.isPrimaryThread()) { + CompletableFuture future = new CompletableFuture<>(); + J.s(() -> { + try { + future.complete(unloadDirect(world, save)); + } catch (Throwable e) { + future.completeExceptionally(e); + } + }); + return future.join(); + } + + return unloadDirect(world, save); + } + + private boolean unloadDirect(World world, boolean save) { + WorldLifecycleBackend backend = selectUnloadBackend(world.getName()); + Iris.info("WorldLifecycle unload: world=%s, backend=%s, reason=%s", + world.getName(), + backend.backendName(), + backend.describeSelectionReason()); + boolean unloaded; + try { + unloaded = backend.unload(world, save); + } catch (Throwable e) { + Iris.reportError("WorldLifecycle unload failed: world=\"" + world.getName() + + "\", backend=" + backend.backendName() + + ", family=" + capabilities.serverFamily().id() + ".", e); + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error error) { + throw error; + } + throw new IllegalStateException(e); + } + if (unloaded) { + worldBackendByName.remove(world.getName()); + } + return unloaded; + } + + public String backendNameForWorld(String worldName) { + return selectUnloadBackend(worldName).backendName(); + } + + WorldLifecycleBackend selectCreateBackend(WorldLifecycleRequest request) { + if (worldsProviderBackend.supports(request, capabilities)) { + return worldsProviderBackend; + } + + if (request.studio() && capabilities.serverFamily().isPaperLike()) { + if (!paperLikeRuntimeBackend.supports(request, capabilities)) { + throw new IllegalStateException("World lifecycle backend paper_like_runtime is unavailable for studio create on " + + capabilities.serverFamily().id() + ": " + capabilities.paperLikeResolution()); + } + return paperLikeRuntimeBackend; + } + + for (WorldLifecycleBackend backend : backends) { + if (backend.supports(request, capabilities)) { + return backend; + } + } + + throw new IllegalStateException("No world lifecycle backend supports request for \"" + request.worldName() + "\"."); + } + + WorldLifecycleBackend selectUnloadBackend(String worldName) { + String backendName = worldBackendByName.get(worldName); + if (backendName == null) { + return bukkitPublicBackend; + } + + for (WorldLifecycleBackend backend : backends) { + if (backend.backendName().equals(backendName)) { + return backend; + } + } + + return bukkitPublicBackend; + } + + void rememberBackend(String worldName, String backendName) { + worldBackendByName.put(worldName, backendName); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java new file mode 100644 index 000000000..68c3f86da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java @@ -0,0 +1,60 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class WorldLifecycleStaging { + private static final Map stagedGenerators = new ConcurrentHashMap<>(); + private static final Map stagedBiomeProviders = new ConcurrentHashMap<>(); + private static final Map stagedStemGenerators = new ConcurrentHashMap<>(); + + private WorldLifecycleStaging() { + } + + public static void stageGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator, @Nullable BiomeProvider biomeProvider) { + stagedGenerators.put(worldName, generator); + if (biomeProvider != null) { + stagedBiomeProviders.put(worldName, biomeProvider); + } else { + stagedBiomeProviders.remove(worldName); + } + } + + public static void stageStemGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator) { + stagedStemGenerators.put(worldName, generator); + } + + @Nullable + public static ChunkGenerator consumeGenerator(@NotNull String worldName) { + return stagedGenerators.remove(worldName); + } + + @Nullable + public static BiomeProvider consumeBiomeProvider(@NotNull String worldName) { + return stagedBiomeProviders.remove(worldName); + } + + @Nullable + public static ChunkGenerator consumeStemGenerator(@NotNull String worldName) { + return stagedStemGenerators.remove(worldName); + } + + public static void clearGenerator(@NotNull String worldName) { + stagedGenerators.remove(worldName); + stagedBiomeProviders.remove(worldName); + } + + public static void clearStem(@NotNull String worldName) { + stagedStemGenerators.remove(worldName); + } + + public static void clearAll(@NotNull String worldName) { + clearGenerator(worldName); + clearStem(worldName); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java new file mode 100644 index 000000000..87329200f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java @@ -0,0 +1,520 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.generator.ChunkGenerator; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +final class WorldLifecycleSupport { + private WorldLifecycleSupport() { + } + + static Throwable unwrap(Throwable throwable) { + if (throwable instanceof InvocationTargetException invocationTargetException && invocationTargetException.getCause() != null) { + return unwrap(invocationTargetException.getCause()); + } + if (throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null) { + return unwrap(completionException.getCause()); + } + if (throwable instanceof ExecutionException executionException && executionException.getCause() != null) { + return unwrap(executionException.getCause()); + } + return throwable; + } + + static Object invoke(Method method, Object target, Object... args) throws ReflectiveOperationException { + return method.invoke(target, args); + } + + static Object invokeNamed(Object target, String methodName, Class[] parameterTypes, Object... args) throws ReflectiveOperationException { + Method method = target.getClass().getMethod(methodName, parameterTypes); + return method.invoke(target, args); + } + + static Object read(Field field, Object target) throws IllegalAccessException { + return field.get(target); + } + + static void stageRuntimeConfiguration(String worldName) throws ReflectiveOperationException { + Object bukkitServer = Bukkit.getServer(); + if (bukkitServer == null) { + throw new IllegalStateException("Bukkit server is unavailable."); + } + + Field configurationField = CapabilityResolution.resolveField(bukkitServer.getClass(), "configuration"); + Object rawConfiguration = configurationField.get(bukkitServer); + if (!(rawConfiguration instanceof YamlConfiguration configuration)) { + throw new IllegalStateException("CraftServer configuration field is unavailable."); + } + + ConfigurationSection worldsSection = configuration.getConfigurationSection("worlds"); + if (worldsSection == null) { + worldsSection = configuration.createSection("worlds"); + } + + ConfigurationSection worldSection = worldsSection.getConfigurationSection(worldName); + if (worldSection == null) { + worldSection = worldsSection.createSection(worldName); + } + + worldSection.set("generator", "Iris:runtime"); + } + + static Object getRuntimeDatapackDimensions(CapabilitySnapshot capabilities) throws ReflectiveOperationException { + Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer()); + Method datapackDimensionsMethod = CapabilityResolution.resolveMethod(worldLoaderContext.getClass(), "datapackDimensions", method -> method.getParameterCount() == 0); + if (datapackDimensionsMethod == null) { + throw new IllegalStateException("DataLoadContext does not expose datapackDimensions()."); + } + Object datapackDimensions = datapackDimensionsMethod.invoke(worldLoaderContext); + if (datapackDimensions == null) { + throw new IllegalStateException("DataLoadContext.datapackDimensions() returned null."); + } + return datapackDimensions; + } + + static Object getRuntimeServerRegistryAccess(CapabilitySnapshot capabilities) throws ReflectiveOperationException { + Method registryAccessMethod = capabilities.serverRegistryAccessMethod(); + if (registryAccessMethod == null) { + throw new IllegalStateException("MinecraftServer does not expose registryAccess()."); + } + Object registryAccess = registryAccessMethod.invoke(capabilities.minecraftServer()); + if (registryAccess == null) { + throw new IllegalStateException("MinecraftServer.registryAccess() returned null."); + } + return registryAccess; + } + + static Object getRuntimeLevelStemRegistry(CapabilitySnapshot capabilities) throws ReflectiveOperationException { + Object datapackDimensions = getRuntimeDatapackDimensions(capabilities); + Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries") + .getField("LEVEL_STEM") + .get(null); + Method lookupMethod = CapabilityResolution.resolveMethod(datapackDimensions.getClass(), "lookupOrThrow", method -> method.getParameterCount() == 1); + if (lookupMethod == null) { + throw new IllegalStateException("Registry access does not expose lookupOrThrow(...)."); + } + return lookupMethod.invoke(datapackDimensions, levelStemRegistryKey); + } + + static Object createRuntimeLevelStemKey(String worldName) throws ReflectiveOperationException { + String sanitized = worldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/_-]", "_"); + String path = "runtime/" + sanitized; + Identifier identifier = new Identifier("iris", path); + Object rawIdentifier = Class.forName("net.minecraft.resources.Identifier") + .getMethod("fromNamespaceAndPath", String.class, String.class) + .invoke(null, identifier.namespace(), identifier.key()); + Object registryKey = Class.forName("net.minecraft.core.registries.Registries") + .getField("LEVEL_STEM") + .get(null); + Method createMethod = Class.forName("net.minecraft.resources.ResourceKey") + .getMethod("create", registryKey.getClass(), rawIdentifier.getClass()); + return createMethod.invoke(null, registryKey, rawIdentifier); + } + + static Object createDimensionKey(Object stemKey) throws ReflectiveOperationException { + Class resourceKeyClass = Class.forName("net.minecraft.resources.ResourceKey"); + Method identifierMethod = CapabilityResolution.resolveMethod(resourceKeyClass, "identifier", method -> method.getParameterCount() == 0); + Object identifier = identifierMethod.invoke(stemKey); + Object dimensionRegistryKey = Class.forName("net.minecraft.core.registries.Registries") + .getField("DIMENSION") + .get(null); + Method createMethod = resourceKeyClass.getMethod("create", dimensionRegistryKey.getClass(), identifier.getClass()); + return createMethod.invoke(null, dimensionRegistryKey, identifier); + } + + static Object resolveRuntimeLevelStem(CapabilitySnapshot capabilities, WorldLifecycleRequest request) throws ReflectiveOperationException { + return resolveRuntimeLevelStem(capabilities, request, INMS.get()); + } + + static Object resolveRuntimeLevelStem(CapabilitySnapshot capabilities, WorldLifecycleRequest request, INMSBinding binding) throws ReflectiveOperationException { + ChunkGenerator generator = request.generator(); + if (generator instanceof PlatformChunkGenerator) { + Object registryAccess = getRuntimeServerRegistryAccess(capabilities); + try { + Object levelStem = binding.createRuntimeLevelStem(registryAccess, generator); + if (levelStem == null) { + throw new IllegalStateException("Iris NMS binding returned null runtime LevelStem."); + } + return levelStem; + } catch (Throwable e) { + throw new IllegalStateException("Failed to create runtime LevelStem from full server registry access for world \"" + request.worldName() + "\".", unwrap(e)); + } + } + + try { + Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities); + Object overworldKey = Class.forName("net.minecraft.world.level.dimension.LevelStem") + .getField("OVERWORLD") + .get(null); + Method getValueMethod = CapabilityResolution.resolveMethod(levelStemRegistry.getClass(), "getValue", method -> method.getParameterCount() == 1); + if (getValueMethod != null) { + Object resolved = getValueMethod.invoke(levelStemRegistry, overworldKey); + if (resolved != null) { + return resolved; + } + } + + Method getMethod = CapabilityResolution.resolveMethod(levelStemRegistry.getClass(), "get", method -> method.getParameterCount() == 1); + if (getMethod == null) { + throw new IllegalStateException("Unable to resolve OVERWORLD LevelStem from registry."); + } + Object raw = getMethod.invoke(levelStemRegistry, overworldKey); + return extractRegistryValue(raw); + } catch (Throwable e) { + throw new IllegalStateException("Failed to resolve fallback OVERWORLD LevelStem from datapack registry access for world \"" + request.worldName() + "\".", unwrap(e)); + } + } + + static String runtimeLevelStemRegistrySource(WorldLifecycleRequest request) { + if (request.generator() instanceof PlatformChunkGenerator) { + return "full_server_registry"; + } + return "datapack_level_stem_registry"; + } + + static Object extractRegistryValue(Object raw) throws ReflectiveOperationException { + if (raw == null) { + return null; + } + if (raw instanceof Optional optional) { + Object nested = optional.orElse(null); + if (nested == null) { + return null; + } + return extractRegistryValue(nested); + } + Method valueMethod = CapabilityResolution.resolveMethod(raw.getClass(), "value", method -> method.getParameterCount() == 0); + if (valueMethod != null) { + return valueMethod.invoke(raw); + } + return raw; + } + + static void applyWorldDataNameAndModInfo(CapabilitySnapshot capabilities, Object worldDataAndGenSettings, String worldName) throws ReflectiveOperationException { + Method dataMethod = CapabilityResolution.resolveMethod(worldDataAndGenSettings.getClass(), "data", method -> method.getParameterCount() == 0); + if (dataMethod == null) { + return; + } + + Object worldData = dataMethod.invoke(worldDataAndGenSettings); + if (worldData == null) { + return; + } + + Method checkNameMethod = CapabilityResolution.resolveMethod(worldData.getClass(), "checkName", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && String.class.equals(params[0]); + }); + if (checkNameMethod != null) { + checkNameMethod.invoke(worldData, worldName); + } + + Method getModdedStatusMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getModdedStatus", method -> method.getParameterCount() == 0); + Method getServerModNameMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getServerModName", method -> method.getParameterCount() == 0); + if (getModdedStatusMethod == null || getServerModNameMethod == null) { + return; + } + + Object modCheck = getModdedStatusMethod.invoke(capabilities.minecraftServer()); + Method shouldReportAsModifiedMethod = CapabilityResolution.resolveMethod(modCheck.getClass(), "shouldReportAsModified", method -> method.getParameterCount() == 0); + Method setModdedInfoMethod = CapabilityResolution.resolveMethod(worldData.getClass(), "setModdedInfo", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 2 && String.class.equals(params[0]) && boolean.class.equals(params[1]); + }); + if (shouldReportAsModifiedMethod == null || setModdedInfoMethod == null) { + return; + } + + boolean modified = Boolean.TRUE.equals(shouldReportAsModifiedMethod.invoke(modCheck)); + String modName = (String) getServerModNameMethod.invoke(capabilities.minecraftServer()); + setModdedInfoMethod.invoke(worldData, modName, modified); + } + + static Object createCurrentWorldDataAndSettings(CapabilitySnapshot capabilities, String worldName) throws ReflectiveOperationException { + Object settings = read(capabilities.settingsField(), capabilities.minecraftServer()); + Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer()); + Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities); + boolean demo = Boolean.TRUE.equals(capabilities.isDemoMethod().invoke(capabilities.minecraftServer())); + Object options = read(capabilities.optionsField(), capabilities.minecraftServer()); + Method hasMethod = CapabilityResolution.resolveMethod(options.getClass(), "has", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && String.class.equals(params[0]); + }); + boolean bonusChest = hasMethod != null && Boolean.TRUE.equals(hasMethod.invoke(options, "bonusChest")); + Object dataLoadOutput = capabilities.createNewWorldDataMethod().invoke(null, settings, worldLoaderContext, levelStemRegistry, demo, bonusChest); + Method cookieMethod = CapabilityResolution.resolveMethod(dataLoadOutput.getClass(), "cookie", method -> method.getParameterCount() == 0); + if (cookieMethod == null) { + throw new IllegalStateException("WorldLoader.DataLoadOutput does not expose cookie()."); + } + Object worldDataAndGenSettings = cookieMethod.invoke(dataLoadOutput); + applyWorldDataNameAndModInfo(capabilities, worldDataAndGenSettings, worldName); + return worldDataAndGenSettings; + } + + static Object createLegacyPrimaryLevelData(CapabilitySnapshot capabilities, Object levelStorageAccess, String worldName) throws ReflectiveOperationException { + Object levelDataResult = capabilities.paperWorldDataMethod().invoke(null, levelStorageAccess); + Method fatalErrorMethod = CapabilityResolution.resolveMethod(levelDataResult.getClass(), "fatalError", method -> method.getParameterCount() == 0); + Method dataTagMethod = CapabilityResolution.resolveMethod(levelDataResult.getClass(), "dataTag", method -> method.getParameterCount() == 0); + if (fatalErrorMethod != null && Boolean.TRUE.equals(fatalErrorMethod.invoke(levelDataResult))) { + throw new IllegalStateException("Paper runtime world-data helper reported a fatal error for \"" + worldName + "\"."); + } + if (dataTagMethod != null && dataTagMethod.invoke(levelDataResult) != null) { + throw new IllegalStateException("Runtime world \"" + worldName + "\" already contains level data."); + } + + Object settings = read(capabilities.settingsField(), capabilities.minecraftServer()); + Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer()); + Object levelStemRegistry = getRuntimeLevelStemRegistry(capabilities); + boolean demo = Boolean.TRUE.equals(capabilities.isDemoMethod().invoke(capabilities.minecraftServer())); + Object options = read(capabilities.optionsField(), capabilities.minecraftServer()); + Method hasMethod = CapabilityResolution.resolveMethod(options.getClass(), "has", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && String.class.equals(params[0]); + }); + boolean bonusChest = hasMethod != null && Boolean.TRUE.equals(hasMethod.invoke(options, "bonusChest")); + Object dataLoadOutput = capabilities.createNewWorldDataMethod().invoke(null, settings, worldLoaderContext, levelStemRegistry, demo, bonusChest); + Method cookieMethod = CapabilityResolution.resolveMethod(dataLoadOutput.getClass(), "cookie", method -> method.getParameterCount() == 0); + if (cookieMethod == null) { + throw new IllegalStateException("WorldLoader.DataLoadOutput does not expose cookie()."); + } + Object primaryLevelData = cookieMethod.invoke(dataLoadOutput); + + Method checkNameMethod = CapabilityResolution.resolveMethod(primaryLevelData.getClass(), "checkName", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && String.class.equals(params[0]); + }); + if (checkNameMethod != null) { + checkNameMethod.invoke(primaryLevelData, worldName); + } + + Method getModdedStatusMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getModdedStatus", method -> method.getParameterCount() == 0); + Method getServerModNameMethod = CapabilityResolution.resolveMethod(capabilities.minecraftServer().getClass(), "getServerModName", method -> method.getParameterCount() == 0); + if (getModdedStatusMethod != null && getServerModNameMethod != null) { + Object modCheck = getModdedStatusMethod.invoke(capabilities.minecraftServer()); + Method shouldReportAsModifiedMethod = CapabilityResolution.resolveMethod(modCheck.getClass(), "shouldReportAsModified", method -> method.getParameterCount() == 0); + Method setModdedInfoMethod = CapabilityResolution.resolveMethod(primaryLevelData.getClass(), "setModdedInfo", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 2 && String.class.equals(params[0]) && boolean.class.equals(params[1]); + }); + if (shouldReportAsModifiedMethod != null && setModdedInfoMethod != null) { + boolean modified = Boolean.TRUE.equals(shouldReportAsModifiedMethod.invoke(modCheck)); + String modName = (String) getServerModNameMethod.invoke(capabilities.minecraftServer()); + setModdedInfoMethod.invoke(primaryLevelData, modName, modified); + } + } + + return primaryLevelData; + } + + static Object createLegacyStorageAccess(CapabilitySnapshot capabilities, String worldName) throws ReflectiveOperationException { + Class levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource"); + Method createDefaultMethod = levelStorageSourceClass.getMethod("createDefault", Path.class); + Object levelStorageSource = createDefaultMethod.invoke(null, Bukkit.getWorldContainer().toPath()); + Method storageAccessMethod = capabilities.levelStorageAccessMethod(); + if (storageAccessMethod.getParameterCount() == 1) { + return storageAccessMethod.invoke(levelStorageSource, worldName); + } + Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem") + .getField("OVERWORLD") + .get(null); + return storageAccessMethod.invoke(levelStorageSource, worldName, overworldStemKey); + } + + static void closeLevelStorageAccess(Object levelStorageAccess) { + if (levelStorageAccess == null) { + return; + } + try { + Method closeMethod = levelStorageAccess.getClass().getMethod("close"); + closeMethod.invoke(levelStorageAccess); + } catch (Throwable ignored) { + } + } + + static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) { + if (world == null) { + return false; + } + + CompletableFuture asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save); + if (asyncUnload != null) { + return resolveAsyncUnload(asyncUnload); + } + + try { + return Bukkit.unloadWorld(world, save); + } catch (UnsupportedOperationException unsupported) { + if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) { + throw unsupported; + } + } + + try { + if (save) { + world.save(); + } + + Method getHandleMethod = world.getClass().getMethod("getHandle"); + Object serverLevel = getHandleMethod.invoke(world); + closeServerLevel(world, serverLevel); + detachServerLevel(capabilities, serverLevel, world.getName()); + return Bukkit.getWorld(world.getName()) == null; + } catch (Throwable e) { + throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e)); + } + } + + private static CompletableFuture unloadWorldViaAsyncApi(CapabilitySnapshot capabilities, World world, boolean save) { + if (capabilities.unloadWorldAsyncMethod() == null || capabilities.bukkitServer() == null) { + return null; + } + + CompletableFuture callbackFuture = new CompletableFuture<>(); + Runnable invokeTask = () -> { + Consumer callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result)); + try { + capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback); + } catch (Throwable e) { + callbackFuture.completeExceptionally(unwrap(e)); + } + }; + + if (J.isFolia() && !isGlobalTickThread()) { + CompletableFuture scheduled = J.sfut(invokeTask); + if (scheduled == null) { + callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task.")); + return callbackFuture; + } + scheduled.whenComplete((unused, throwable) -> { + if (throwable != null) { + callbackFuture.completeExceptionally(unwrap(throwable)); + } + }); + return callbackFuture; + } + + invokeTask.run(); + return callbackFuture; + } + + private static boolean resolveAsyncUnload(CompletableFuture asyncUnload) { + if (J.isPrimaryThread()) { + if (!asyncUnload.isDone()) { + return true; + } + + try { + return Boolean.TRUE.equals(asyncUnload.join()); + } catch (Throwable e) { + throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e)); + } + } + + try { + return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS)); + } catch (Throwable e) { + throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e)); + } + } + + private static void closeServerLevel(World world, Object serverLevel) throws Throwable { + Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0); + if (closeMethod == null) { + return; + } + + if (!J.isFolia()) { + closeMethod.invoke(serverLevel); + return; + } + + Location spawn = world.getSpawnLocation(); + int chunkX = spawn == null ? 0 : spawn.getBlockX() >> 4; + int chunkZ = spawn == null ? 0 : spawn.getBlockZ() >> 4; + CompletableFuture closeFuture = new CompletableFuture<>(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + closeMethod.invoke(serverLevel); + closeFuture.complete(null); + } catch (Throwable e) { + closeFuture.completeExceptionally(unwrap(e)); + } + }); + if (!scheduled) { + throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\"."); + } + closeFuture.get(90, TimeUnit.SECONDS); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void removeWorldFromCraftServerMap(String worldName) throws ReflectiveOperationException { + Object bukkitServer = Bukkit.getServer(); + if (bukkitServer == null) { + return; + } + + Field worldsField = CapabilityResolution.resolveField(bukkitServer.getClass(), "worlds"); + Object rawWorlds = worldsField.get(bukkitServer); + if (rawWorlds instanceof Map map) { + map.remove(worldName); + map.remove(worldName.toLowerCase(Locale.ROOT)); + } + } + + private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, String worldName) throws Throwable { + Runnable detachTask = () -> { + try { + capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel); + removeWorldFromCraftServerMap(worldName); + } catch (Throwable e) { + throw new RuntimeException(e); + } + }; + + if (!J.isFolia() || isGlobalTickThread()) { + detachTask.run(); + return; + } + + CompletableFuture detachFuture = J.sfut(detachTask); + if (detachFuture == null) { + throw new IllegalStateException("Failed to schedule global detach task for world \"" + worldName + "\"."); + } + detachFuture.get(15, TimeUnit.SECONDS); + } + + static boolean isGlobalTickThread() { + Object server = Bukkit.getServer(); + if (server == null) { + return false; + } + try { + Method method = server.getClass().getMethod("isGlobalTickThread"); + return Boolean.TRUE.equals(method.invoke(server)); + } catch (Throwable ignored) { + return false; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java new file mode 100644 index 000000000..aec57b696 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java @@ -0,0 +1,95 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.WorldType; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +final class WorldsProviderBackend implements WorldLifecycleBackend { + private final CapabilitySnapshot capabilities; + + WorldsProviderBackend(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + } + + @Override + public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) { + return request.studio() && capabilities.hasWorldsProvider(); + } + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture create(WorldLifecycleRequest request) { + try { + Path worldPath = new File(Bukkit.getWorldContainer(), request.worldName()).toPath(); + Object builder = WorldLifecycleSupport.invokeNamed(capabilities.worldsProvider(), "levelBuilder", new Class[]{Path.class}, worldPath); + builder = WorldLifecycleSupport.invokeNamed(builder, "name", new Class[]{String.class}, request.worldName()); + builder = WorldLifecycleSupport.invokeNamed(builder, "seed", new Class[]{long.class}, request.seed()); + builder = WorldLifecycleSupport.invokeNamed(builder, "levelStem", new Class[]{capabilities.worldsLevelStemClass()}, resolveLevelStem(request.environment())); + builder = WorldLifecycleSupport.invokeNamed(builder, "chunkGenerator", new Class[]{org.bukkit.generator.ChunkGenerator.class}, request.generator()); + builder = WorldLifecycleSupport.invokeNamed(builder, "biomeProvider", new Class[]{org.bukkit.generator.BiomeProvider.class}, request.biomeProvider()); + builder = WorldLifecycleSupport.invokeNamed(builder, "generatorType", new Class[]{capabilities.worldsGeneratorTypeClass()}, resolveGeneratorType(request.worldType())); + builder = WorldLifecycleSupport.invokeNamed(builder, "structures", new Class[]{boolean.class}, request.generateStructures()); + builder = WorldLifecycleSupport.invokeNamed(builder, "hardcore", new Class[]{boolean.class}, request.hardcore()); + Object levelBuilder = WorldLifecycleSupport.invokeNamed(builder, "build", new Class[0]); + Object async = WorldLifecycleSupport.invokeNamed(levelBuilder, "createAsync", new Class[0]); + if (async instanceof CompletableFuture future) { + return future.thenApply(world -> (World) world); + } + return CompletableFuture.failedFuture(new IllegalStateException("Worlds provider createAsync did not return CompletableFuture.")); + } catch (Throwable e) { + return CompletableFuture.failedFuture(WorldLifecycleSupport.unwrap(e)); + } + } + + @Override + public boolean unload(World world, boolean save) { + return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + } + + @Override + public String backendName() { + return "worlds_provider"; + } + + @Override + public String describeSelectionReason() { + return "external Worlds provider is registered and healthy"; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object resolveLevelStem(World.Environment environment) { + String key; + if (environment == World.Environment.NETHER) { + key = "NETHER"; + } else if (environment == World.Environment.THE_END) { + key = "END"; + } else { + key = "OVERWORLD"; + } + Class enumClass = capabilities.worldsLevelStemClass().asSubclass(Enum.class); + return Enum.valueOf(enumClass, key); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object resolveGeneratorType(WorldType worldType) { + String typeName = worldType == null ? "NORMAL" : worldType.getName(); + String key; + if ("FLAT".equalsIgnoreCase(typeName)) { + key = "FLAT"; + } else if ("AMPLIFIED".equalsIgnoreCase(typeName)) { + key = "AMPLIFIED"; + } else if ("LARGE_BIOMES".equalsIgnoreCase(typeName) || "LARGEBIOMES".equalsIgnoreCase(typeName)) { + key = "LARGE_BIOMES"; + } else { + key = "NORMAL"; + } + Class enumClass = capabilities.worldsGeneratorTypeClass().asSubclass(Enum.class); + return Enum.valueOf(enumClass, key.toUpperCase(Locale.ROOT)); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java index 251d9b803..bb94049f0 100644 --- a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java @@ -157,7 +157,7 @@ public abstract class ExternalDataProvider implements Listener { protected static List YAW_FACE_BIOME_PROPERTIES = List.of( BlockProperty.ofEnum(BiomeColor.class, "matchBiome", null), BlockProperty.ofBoolean("randomYaw", false), - BlockProperty.ofFloat("yaw", 0, 0, 360f, false, true), + BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true), BlockProperty.ofBoolean("randomFace", true), new BlockProperty( "face", diff --git a/core/src/main/java/art/arcane/iris/core/link/FoliaWorldsLink.java b/core/src/main/java/art/arcane/iris/core/link/FoliaWorldsLink.java deleted file mode 100644 index 931e2c16f..000000000 --- a/core/src/main/java/art/arcane/iris/core/link/FoliaWorldsLink.java +++ /dev/null @@ -1,841 +0,0 @@ -package art.arcane.iris.core.link; - -import art.arcane.iris.Iris; -import art.arcane.iris.core.nms.INMS; -import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.util.common.scheduling.J; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.Server; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.plugin.RegisteredServiceProvider; -import org.bukkit.World; -import org.bukkit.WorldCreator; -import org.bukkit.WorldType; -import org.bukkit.generator.ChunkGenerator; - -import java.io.File; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; - -public class FoliaWorldsLink { - private static volatile FoliaWorldsLink instance; - private final Object provider; - private final Class levelStemClass; - private final Class generatorTypeClass; - private final Object minecraftServer; - private final Method minecraftServerCreateLevelMethod; - - private FoliaWorldsLink( - Object provider, - Class levelStemClass, - Class generatorTypeClass, - Object minecraftServer, - Method minecraftServerCreateLevelMethod - ) { - this.provider = provider; - this.levelStemClass = levelStemClass; - this.generatorTypeClass = generatorTypeClass; - this.minecraftServer = minecraftServer; - this.minecraftServerCreateLevelMethod = minecraftServerCreateLevelMethod; - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - public static FoliaWorldsLink get() { - FoliaWorldsLink current = instance; - if (current != null && current.isActive()) { - return current; - } - - synchronized (FoliaWorldsLink.class) { - if (instance != null && instance.isActive()) { - return instance; - } - - Object loadedProvider = null; - Class loadedLevelStemClass = null; - Class loadedGeneratorTypeClass = null; - Object loadedMinecraftServer = null; - Method loadedMinecraftServerCreateLevelMethod = null; - - try { - Server.class.getDeclaredMethod("isGlobalTickThread"); - try { - Class worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider"); - loadedLevelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem"); - loadedGeneratorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType"); - loadedProvider = Bukkit.getServicesManager().load((Class) worldsProviderClass); - } catch (Throwable ignored) { - Object[] resolved = resolveProviderFromServices(); - loadedProvider = resolved[0]; - loadedLevelStemClass = (Class) resolved[1]; - loadedGeneratorTypeClass = (Class) resolved[2]; - } - } catch (Throwable ignored) { - } - - try { - Object bukkitServer = Bukkit.getServer(); - if (bukkitServer != null) { - Method getServerMethod = bukkitServer.getClass().getMethod("getServer"); - Object candidateMinecraftServer = getServerMethod.invoke(bukkitServer); - Class minecraftServerClass = Class.forName("net.minecraft.server.MinecraftServer"); - if (minecraftServerClass.isInstance(candidateMinecraftServer)) { - loadedMinecraftServerCreateLevelMethod = minecraftServerClass.getMethod( - "createLevel", - Class.forName("net.minecraft.world.level.dimension.LevelStem"), - Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo"), - Class.forName("net.minecraft.world.level.storage.LevelStorageSource$LevelStorageAccess"), - Class.forName("net.minecraft.world.level.storage.PrimaryLevelData") - ); - loadedMinecraftServer = candidateMinecraftServer; - } - } - } catch (Throwable ignored) { - } - - instance = new FoliaWorldsLink( - loadedProvider, - loadedLevelStemClass, - loadedGeneratorTypeClass, - loadedMinecraftServer, - loadedMinecraftServerCreateLevelMethod - ); - return instance; - } - } - - public boolean isActive() { - if (!J.isFolia()) { - return false; - } - - return isWorldsProviderActive() || isPaperWorldLoaderActive(); - } - - public CompletableFuture createWorld(WorldCreator creator) { - if (isWorldsProviderActive()) { - CompletableFuture providerFuture = createWorldViaProvider(creator); - if (providerFuture != null) { - return providerFuture; - } - } - - if (isPaperWorldLoaderActive()) { - return createWorldViaPaperWorldLoader(creator); - } - - return null; - } - - public boolean unloadWorld(World world, boolean save) { - if (world == null) { - return false; - } - - CompletableFuture asyncWorldUnload = unloadWorldViaAsyncApi(world, save); - if (asyncWorldUnload != null) { - return resolveAsyncUnload(asyncWorldUnload); - } - - try { - return Bukkit.unloadWorld(world, save); - } catch (UnsupportedOperationException unsupported) { - if (minecraftServer == null) { - throw unsupported; - } - } - - try { - if (save) { - world.save(); - } - - Object serverLevel = invoke(world, "getHandle"); - closeServerLevel(world, serverLevel); - detachServerLevel(serverLevel, world.getName()); - return Bukkit.getWorld(world.getName()) == null; - } catch (Throwable e) { - throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" via Folia runtime world-loader bridge.", unwrap(e)); - } - } - - private boolean resolveAsyncUnload(CompletableFuture asyncWorldUnload) { - if (J.isPrimaryThread()) { - if (!asyncWorldUnload.isDone()) { - return true; - } - - try { - return Boolean.TRUE.equals(asyncWorldUnload.join()); - } catch (Throwable e) { - throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e)); - } - } - - try { - return Boolean.TRUE.equals(asyncWorldUnload.get(120, TimeUnit.SECONDS)); - } catch (Throwable e) { - throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e)); - } - } - - private CompletableFuture unloadWorldViaAsyncApi(World world, boolean save) { - Object bukkitServer = Bukkit.getServer(); - if (bukkitServer == null) { - return null; - } - - Method unloadWorldAsyncMethod; - try { - unloadWorldAsyncMethod = bukkitServer.getClass().getMethod("unloadWorldAsync", World.class, boolean.class, Consumer.class); - } catch (Throwable ignored) { - return null; - } - - CompletableFuture callbackFuture = new CompletableFuture<>(); - Runnable invokeTask = () -> { - Consumer callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result)); - try { - unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback); - } catch (Throwable e) { - callbackFuture.completeExceptionally(unwrap(e)); - } - }; - - if (J.isFolia() && !isGlobalTickThread()) { - CompletableFuture scheduled = J.sfut(invokeTask); - if (scheduled == null) { - callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global world-unload task.")); - return callbackFuture; - } - scheduled.whenComplete((unused, throwable) -> { - if (throwable != null) { - callbackFuture.completeExceptionally(unwrap(throwable)); - } - }); - } else { - invokeTask.run(); - } - - return callbackFuture; - } - - private boolean isWorldsProviderActive() { - return provider != null && levelStemClass != null && generatorTypeClass != null; - } - - private boolean isPaperWorldLoaderActive() { - return minecraftServer != null && minecraftServerCreateLevelMethod != null; - } - - private CompletableFuture createWorldViaProvider(WorldCreator creator) { - try { - Path worldPath = new File(Bukkit.getWorldContainer(), creator.name()).toPath(); - Object builder = invoke(provider, "levelBuilder", worldPath); - builder = invoke(builder, "name", creator.name()); - builder = invoke(builder, "seed", creator.seed()); - builder = invoke(builder, "levelStem", resolveLevelStem(creator.environment())); - builder = invoke(builder, "chunkGenerator", creator.generator()); - builder = invoke(builder, "biomeProvider", creator.biomeProvider()); - builder = invoke(builder, "generatorType", resolveGeneratorType(creator.type())); - builder = invoke(builder, "structures", creator.generateStructures()); - builder = invoke(builder, "hardcore", creator.hardcore()); - Object levelBuilder = invoke(builder, "build"); - Object async = invoke(levelBuilder, "createAsync"); - if (async instanceof CompletableFuture future) { - return future.thenApply(world -> (World) world); - } - - return CompletableFuture.failedFuture(new IllegalStateException("Worlds provider createAsync did not return CompletableFuture.")); - } catch (Throwable e) { - return CompletableFuture.failedFuture(e); - } - } - - private CompletableFuture createWorldViaPaperWorldLoader(WorldCreator creator) { - Object levelStorageAccess = null; - try { - if (creator.environment() != World.Environment.NORMAL) { - return CompletableFuture.failedFuture(new UnsupportedOperationException("PaperWorldLoader fallback only supports OVERWORLD worlds.")); - } - - World existing = Bukkit.getWorld(creator.name()); - if (existing != null) { - return CompletableFuture.completedFuture(existing); - } - - stageRuntimeGenerator(creator); - levelStorageAccess = createRuntimeStorageAccess(creator.name()); - Object primaryLevelData = createPrimaryLevelData(levelStorageAccess, creator.name()); - Object runtimeStemKey = createRuntimeLevelStemKey(creator.name()); - Object worldLoadingInfo = createWorldLoadingInfo(creator.name(), runtimeStemKey); - Object levelStem = resolveCreateLevelStem(creator); - Object[] createLevelArgs = new Object[]{levelStem, worldLoadingInfo, levelStorageAccess, primaryLevelData}; - Method createLevelMethod = minecraftServerCreateLevelMethod; - if (createLevelMethod == null || !matches(createLevelMethod.getParameterTypes(), createLevelArgs)) { - createLevelMethod = resolveMethod(minecraftServer.getClass(), "createLevel", createLevelArgs); - } - - try { - createLevelMethod.invoke(minecraftServer, createLevelArgs); - } catch (IllegalArgumentException exception) { - throw new IllegalStateException("createLevel argument mismatch. Method=" + formatMethod(createLevelMethod) + " Args=" + formatArgs(createLevelArgs), exception); - } - - World loaded = Bukkit.getWorld(creator.name()); - if (loaded == null) { - Iris.clearStagedRuntimeWorldGenerator(creator.name()); - closeLevelStorageAccess(levelStorageAccess); - return CompletableFuture.failedFuture(new IllegalStateException("PaperWorldLoader did not load world \"" + creator.name() + "\".")); - } - - Iris.clearStagedRuntimeWorldGenerator(creator.name()); - return CompletableFuture.completedFuture(loaded); - } catch (Throwable e) { - Iris.clearStagedRuntimeWorldGenerator(creator.name()); - closeLevelStorageAccess(levelStorageAccess); - return CompletableFuture.failedFuture(unwrap(e)); - } - } - - private Object createRuntimeStorageAccess(String worldName) throws ReflectiveOperationException { - Class levelStorageSourceClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource"); - Object levelStorageSource = levelStorageSourceClass - .getMethod("createDefault", Path.class) - .invoke(null, Bukkit.getWorldContainer().toPath()); - - Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem") - .getField("OVERWORLD") - .get(null); - Method validateAndCreateAccess = resolveMethod(levelStorageSourceClass, "validateAndCreateAccess", worldName, overworldStemKey); - return validateAndCreateAccess.invoke(levelStorageSource, worldName, overworldStemKey); - } - - private Object createPrimaryLevelData(Object levelStorageAccess, String worldName) throws ReflectiveOperationException { - Class paperWorldLoaderClass = Class.forName("io.papermc.paper.world.PaperWorldLoader"); - Class levelStorageAccessClass = Class.forName("net.minecraft.world.level.storage.LevelStorageSource$LevelStorageAccess"); - Object levelDataResult = paperWorldLoaderClass - .getMethod("getLevelData", levelStorageAccessClass) - .invoke(null, levelStorageAccess); - boolean fatalError = (boolean) invoke(levelDataResult, "fatalError"); - if (fatalError) { - throw new IllegalStateException("PaperWorldLoader reported a fatal world-data error for \"" + worldName + "\"."); - } - - Object dataTag = invoke(levelDataResult, "dataTag"); - if (dataTag != null) { - throw new IllegalStateException("Runtime studio world folder \"" + worldName + "\" already contains level data."); - } - - Object worldLoaderContext = getPublicField(minecraftServer, "worldLoaderContext"); - Object datapackDimensions = invoke(worldLoaderContext, "datapackDimensions"); - Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries") - .getField("LEVEL_STEM") - .get(null); - Object levelStemRegistry = invoke(datapackDimensions, "lookupOrThrow", levelStemRegistryKey); - Object dedicatedSettings = getPublicField(minecraftServer, "settings"); - boolean demo = (boolean) invoke(minecraftServer, "isDemo"); - Object options = getPublicField(minecraftServer, "options"); - boolean bonusChest = (boolean) invoke(options, "has", "bonusChest"); - - Class mainClass = Class.forName("net.minecraft.server.Main"); - Method createNewWorldDataMethod = resolveMethod(mainClass, "createNewWorldData", dedicatedSettings, worldLoaderContext, levelStemRegistry, demo, bonusChest); - Object dataLoadOutput = createNewWorldDataMethod.invoke(null, dedicatedSettings, worldLoaderContext, levelStemRegistry, demo, bonusChest); - - Object primaryLevelData = invoke(dataLoadOutput, "cookie"); - invoke(primaryLevelData, "checkName", worldName); - Object modCheck = invoke(minecraftServer, "getModdedStatus"); - boolean modified = (boolean) invoke(modCheck, "shouldReportAsModified"); - String modName = (String) invoke(minecraftServer, "getServerModName"); - invoke(primaryLevelData, "setModdedInfo", modName, modified); - return primaryLevelData; - } - - private Object createWorldLoadingInfo(String worldName, Object runtimeStemKey) throws ReflectiveOperationException { - Class worldLoadingInfoClass = Class.forName("io.papermc.paper.world.PaperWorldLoader$WorldLoadingInfo"); - Constructor constructor = resolveConstructor(worldLoadingInfoClass, 0, worldName, "normal", runtimeStemKey, true); - return constructor.newInstance(0, worldName, "normal", runtimeStemKey, true); - } - - private Object createRuntimeLevelStemKey(String worldName) throws ReflectiveOperationException { - String sanitized = worldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/_-]", "_"); - String path = "runtime/" + sanitized; - Object identifier = Class.forName("net.minecraft.resources.Identifier") - .getMethod("fromNamespaceAndPath", String.class, String.class) - .invoke(null, "iris", path); - Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries") - .getField("LEVEL_STEM") - .get(null); - Class resourceKeyClass = Class.forName("net.minecraft.resources.ResourceKey"); - Method createMethod = resolveMethod(resourceKeyClass, "create", levelStemRegistryKey, identifier); - return createMethod.invoke(null, levelStemRegistryKey, identifier); - } - - private Object resolveCreateLevelStem(WorldCreator creator) throws ReflectiveOperationException { - Object irisLevelStem = resolveIrisLevelStem(creator); - if (irisLevelStem != null) { - return irisLevelStem; - } - - return getOverworldLevelStem(); - } - - private Object resolveIrisLevelStem(WorldCreator creator) throws ReflectiveOperationException { - ChunkGenerator generator = creator.generator(); - if (!(generator instanceof PlatformChunkGenerator)) { - return null; - } - - Object registryAccess = invoke(minecraftServer, "registryAccess"); - Object binding = INMS.get(); - Method levelStemMethod; - try { - levelStemMethod = resolveMethod(binding.getClass(), "levelStem", registryAccess, generator); - } catch (NoSuchMethodException e) { - throw new IllegalStateException("Iris NMS binding does not expose levelStem(RegistryAccess, ChunkGenerator) for runtime world \"" + creator.name() + "\".", e); - } - - Object levelStem; - try { - levelStem = levelStemMethod.invoke(binding, registryAccess, generator); - } catch (InvocationTargetException e) { - Throwable cause = unwrap(e); - throw new IllegalStateException("Iris failed to resolve runtime level stem for world \"" + creator.name() + "\".", cause); - } - - if (levelStem == null) { - throw new IllegalStateException("Iris resolved a null runtime level stem for world \"" + creator.name() + "\"."); - } - return levelStem; - } - - private Object getOverworldLevelStem() throws ReflectiveOperationException { - Object levelStemRegistryKey = Class.forName("net.minecraft.core.registries.Registries") - .getField("LEVEL_STEM") - .get(null); - Object registryAccess = invoke(minecraftServer, "registryAccess"); - Object levelStemRegistry = invoke(registryAccess, "lookupOrThrow", levelStemRegistryKey); - Object overworldStemKey = Class.forName("net.minecraft.world.level.dimension.LevelStem") - .getField("OVERWORLD") - .get(null); - Object levelStem; - try { - levelStem = invoke(levelStemRegistry, "getValue", overworldStemKey); - } catch (NoSuchMethodException ignored) { - Object rawLevelStem = invoke(levelStemRegistry, "get", overworldStemKey); - levelStem = extractRegistryValue(rawLevelStem); - } - if (levelStem == null) { - throw new IllegalStateException("Unable to resolve OVERWORLD LevelStem from registry."); - } - return levelStem; - } - - private static Object extractRegistryValue(Object rawValue) throws ReflectiveOperationException { - if (rawValue == null) { - return null; - } - if (rawValue instanceof java.util.Optional optionalValue) { - Object nestedValue = optionalValue.orElse(null); - if (nestedValue == null) { - return null; - } - return extractRegistryValue(nestedValue); - } - - try { - Method valueMethod = rawValue.getClass().getMethod("value"); - return valueMethod.invoke(rawValue); - } catch (NoSuchMethodException ignored) { - return rawValue; - } - } - - private static Object getPublicField(Object target, String fieldName) throws ReflectiveOperationException { - Field field = target.getClass().getField(fieldName); - return field.get(target); - } - - private static void closeLevelStorageAccess(Object levelStorageAccess) { - if (levelStorageAccess == null) { - return; - } - - try { - Method close = levelStorageAccess.getClass().getMethod("close"); - close.invoke(levelStorageAccess); - } catch (Throwable ignored) { - } - } - - private static void stageRuntimeGenerator(WorldCreator creator) throws ReflectiveOperationException { - ChunkGenerator generator = creator.generator(); - if (generator == null) { - throw new IllegalStateException("Runtime world creation requires a non-null chunk generator."); - } - - Iris.stageRuntimeWorldGenerator(creator.name(), generator, creator.biomeProvider()); - Object bukkitServer = Bukkit.getServer(); - if (bukkitServer == null) { - throw new IllegalStateException("Bukkit server is unavailable."); - } - - Field configurationField = bukkitServer.getClass().getDeclaredField("configuration"); - configurationField.setAccessible(true); - Object rawConfiguration = configurationField.get(bukkitServer); - if (!(rawConfiguration instanceof YamlConfiguration configuration)) { - throw new IllegalStateException("CraftServer configuration field is unavailable."); - } - - ConfigurationSection worldsSection = configuration.getConfigurationSection("worlds"); - if (worldsSection == null) { - worldsSection = configuration.createSection("worlds"); - } - - ConfigurationSection worldSection = worldsSection.getConfigurationSection(creator.name()); - if (worldSection == null) { - worldSection = worldsSection.createSection(creator.name()); - } - - worldSection.set("generator", "Iris:runtime"); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static void removeWorldFromCraftServerMap(String worldName) throws ReflectiveOperationException { - Object bukkitServer = Bukkit.getServer(); - if (bukkitServer == null) { - return; - } - - Field worldsField = bukkitServer.getClass().getDeclaredField("worlds"); - worldsField.setAccessible(true); - Object worldsRaw = worldsField.get(bukkitServer); - if (worldsRaw instanceof Map worldsMap) { - worldsMap.remove(worldName); - worldsMap.remove(worldName.toLowerCase(Locale.ROOT)); - } - } - - private static void closeServerLevel(World world, Object serverLevel) throws Throwable { - Method closeLevelMethod = resolveMethod(serverLevel.getClass(), "close"); - if (!J.isFolia()) { - closeLevelMethod.invoke(serverLevel); - return; - } - - Location spawn = world.getSpawnLocation(); - int chunkX = spawn == null ? 0 : spawn.getBlockX() >> 4; - int chunkZ = spawn == null ? 0 : spawn.getBlockZ() >> 4; - CompletableFuture closeFuture = new CompletableFuture<>(); - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - try { - closeLevelMethod.invoke(serverLevel); - closeFuture.complete(null); - } catch (Throwable e) { - closeFuture.completeExceptionally(unwrap(e)); - } - }); - if (!scheduled) { - throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\"."); - } - closeFuture.get(90, TimeUnit.SECONDS); - } - - private void detachServerLevel(Object serverLevel, String worldName) throws Throwable { - Runnable detachTask = () -> { - try { - Method removeLevelMethod = resolveMethod(minecraftServer.getClass(), "removeLevel", serverLevel); - removeLevelMethod.invoke(minecraftServer, serverLevel); - removeWorldFromCraftServerMap(worldName); - } catch (Throwable e) { - throw new RuntimeException(e); - } - }; - - if (!J.isFolia()) { - detachTask.run(); - return; - } - - if (isGlobalTickThread()) { - detachTask.run(); - return; - } - - CompletableFuture detachFuture = J.sfut(() -> detachTask.run()); - if (detachFuture == null) { - throw new IllegalStateException("Failed to schedule global detach task for world \"" + worldName + "\"."); - } - detachFuture.get(15, TimeUnit.SECONDS); - } - - private static boolean isGlobalTickThread() { - Server server = Bukkit.getServer(); - if (server == null) { - return false; - } - - try { - Method isGlobalTickThreadMethod = server.getClass().getMethod("isGlobalTickThread"); - return Boolean.TRUE.equals(isGlobalTickThreadMethod.invoke(server)); - } catch (Throwable ignored) { - return false; - } - } - - private static Throwable unwrap(Throwable throwable) { - if (throwable instanceof InvocationTargetException invocationTargetException && invocationTargetException.getCause() != null) { - return unwrap(invocationTargetException.getCause()); - } - if (throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null) { - return unwrap(completionException.getCause()); - } - if (throwable instanceof java.util.concurrent.ExecutionException executionException && executionException.getCause() != null) { - return unwrap(executionException.getCause()); - } - return throwable; - } - - private static Object[] resolveProviderFromServices() { - Object provider = null; - Class levelStem = null; - Class generatorType = null; - - try { - Collection> knownServices = Bukkit.getServicesManager().getKnownServices(); - for (Class serviceClass : knownServices) { - if (!"net.thenextlvl.worlds.api.WorldsProvider".equals(serviceClass.getName())) { - continue; - } - - RegisteredServiceProvider registration = Bukkit.getServicesManager().getRegistration((Class) serviceClass); - if (registration == null) { - continue; - } - - provider = registration.getProvider(); - ClassLoader loader = serviceClass.getClassLoader(); - if (loader == null && provider != null) { - loader = provider.getClass().getClassLoader(); - } - if (loader != null) { - levelStem = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem", false, loader); - generatorType = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType", false, loader); - } - break; - } - } catch (Throwable ignored) { - } - - return new Object[]{provider, levelStem, generatorType}; - } - - private Object resolveLevelStem(World.Environment environment) { - String key; - if (environment == World.Environment.NETHER) { - key = "NETHER"; - } else if (environment == World.Environment.THE_END) { - key = "END"; - } else { - key = "OVERWORLD"; - } - - return enumValue(levelStemClass, key); - } - - private Object resolveGeneratorType(WorldType worldType) { - String typeName = worldType == null ? "NORMAL" : worldType.getName(); - String key; - if ("FLAT".equalsIgnoreCase(typeName)) { - key = "FLAT"; - } else if ("AMPLIFIED".equalsIgnoreCase(typeName)) { - key = "AMPLIFIED"; - } else if ("LARGE_BIOMES".equalsIgnoreCase(typeName) || "LARGEBIOMES".equalsIgnoreCase(typeName)) { - key = "LARGE_BIOMES"; - } else { - key = "NORMAL"; - } - - return enumValue(generatorTypeClass, key); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private static Object enumValue(Class enumClass, String key) { - Class typed = enumClass.asSubclass(Enum.class); - return Enum.valueOf(typed, key); - } - - private static Method resolveMethod(Class owner, String methodName, Object... args) throws NoSuchMethodException { - Method selected = findMatchingMethod(owner.getMethods(), methodName, args); - if (selected != null) { - return selected; - } - - Class current = owner; - while (current != null) { - selected = findMatchingMethod(current.getDeclaredMethods(), methodName, args); - if (selected != null) { - selected.setAccessible(true); - return selected; - } - current = current.getSuperclass(); - } - - throw new NoSuchMethodException(owner.getName() + "#" + methodName); - } - - private static Constructor resolveConstructor(Class owner, Object... args) throws NoSuchMethodException { - Constructor selected = findMatchingConstructor(owner.getConstructors(), args); - if (selected != null) { - return selected; - } - - selected = findMatchingConstructor(owner.getDeclaredConstructors(), args); - if (selected != null) { - selected.setAccessible(true); - return selected; - } - - throw new NoSuchMethodException(owner.getName() + "#"); - } - - private static Method findMatchingMethod(Method[] methods, String methodName, Object... args) { - Method selected = null; - for (Method method : methods) { - if (!method.getName().equals(methodName)) { - continue; - } - Class[] params = method.getParameterTypes(); - if (params.length != args.length) { - continue; - } - if (matches(params, args)) { - selected = method; - break; - } - } - - return selected; - } - - private static String formatMethod(Method method) { - if (method == null) { - return ""; - } - - StringBuilder builder = new StringBuilder(); - builder.append(method.getDeclaringClass().getName()) - .append("#") - .append(method.getName()) - .append("("); - Class[] parameterTypes = method.getParameterTypes(); - for (int i = 0; i < parameterTypes.length; i++) { - if (i > 0) { - builder.append(", "); - } - builder.append(parameterTypes[i].getName()); - } - builder.append(")"); - return builder.toString(); - } - - private static String formatArgs(Object... args) { - if (args == null) { - return ""; - } - - StringBuilder builder = new StringBuilder(); - builder.append("["); - for (int i = 0; i < args.length; i++) { - if (i > 0) { - builder.append(", "); - } - Object argument = args[i]; - builder.append(argument == null ? "null" : argument.getClass().getName()); - } - builder.append("]"); - return builder.toString(); - } - - private static Constructor findMatchingConstructor(Constructor[] constructors, Object... args) { - Constructor selected = null; - for (Constructor constructor : constructors) { - Class[] params = constructor.getParameterTypes(); - if (params.length != args.length) { - continue; - } - if (matches(params, args)) { - selected = constructor; - break; - } - } - - return selected; - } - - private static Object invoke(Object target, String methodName, Object... args) throws ReflectiveOperationException { - Method selected = resolveMethod(target.getClass(), methodName, args); - return selected.invoke(target, args); - } - - private static boolean matches(Class[] params, Object[] args) { - for (int i = 0; i < params.length; i++) { - Object arg = args[i]; - Class parameterType = params[i]; - if (arg == null) { - if (parameterType.isPrimitive()) { - return false; - } - continue; - } - Class boxedParameterType = box(parameterType); - if (!boxedParameterType.isAssignableFrom(arg.getClass())) { - return false; - } - } - - return true; - } - - private static Class box(Class type) { - if (!type.isPrimitive()) { - return type; - } - if (type == boolean.class) { - return Boolean.class; - } - if (type == byte.class) { - return Byte.class; - } - if (type == short.class) { - return Short.class; - } - if (type == int.class) { - return Integer.class; - } - if (type == long.class) { - return Long.class; - } - if (type == float.class) { - return Float.class; - } - if (type == double.class) { - return Double.class; - } - if (type == char.class) { - return Character.class; - } - return Void.class; - } -} diff --git a/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java new file mode 100644 index 000000000..6e9ed81df --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java @@ -0,0 +1,182 @@ +package art.arcane.iris.core.link.data; + +import art.arcane.iris.core.link.ExternalDataProvider; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.nms.container.BlockProperty; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.RNG; +import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks; +import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture; +import net.momirealms.craftengine.bukkit.api.CraftEngineItems; +import net.momirealms.craftengine.core.block.ImmutableBlockState; +import net.momirealms.craftengine.core.block.properties.BooleanProperty; +import net.momirealms.craftengine.core.block.properties.IntegerProperty; +import net.momirealms.craftengine.core.block.properties.Property; +import net.momirealms.craftengine.core.util.Key; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.MissingResourceException; +import java.util.function.Function; +import java.util.stream.Stream; + +public class CraftEngineDataProvider extends ExternalDataProvider { + private static final BlockProperty[] FURNITURE_PROPERTIES = new BlockProperty[]{ + BlockProperty.ofBoolean("randomYaw", false), + BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true), + BlockProperty.ofBoolean("randomPitch", false), + BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true), + }; + + public CraftEngineDataProvider() { + super("CraftEngine"); + } + + @Override + public void init() { + } + + @Override + public @NotNull List getBlockProperties(@NotNull Identifier blockId) throws MissingResourceException { + Key key = Key.of(blockId.namespace(), blockId.key()); + net.momirealms.craftengine.core.block.CustomBlock block = CraftEngineBlocks.byId(key); + if (block != null) { + return block.properties().stream().map(CraftEngineDataProvider::convert).toList(); + } + + net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key); + if (furniture != null) { + BlockProperty[] properties = Arrays.copyOf(FURNITURE_PROPERTIES, 5); + properties[4] = new BlockProperty( + "variant", + String.class, + furniture.anyVariantName(), + furniture.variants().keySet(), + Function.identity() + ); + return List.of(properties); + } + + throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); + } + + @Override + public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { + net.momirealms.craftengine.core.item.CustomItem item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key())); + if (item == null) { + throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); + } + + return item.buildItemStack(); + } + + @Override + public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap state) throws MissingResourceException { + Key key = Key.of(blockId.namespace(), blockId.key()); + if (CraftEngineBlocks.byId(key) == null && CraftEngineFurniture.byId(key) == null) { + throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); + } + + return IrisCustomData.of(B.getAir(), ExternalDataSVC.buildState(blockId, state)); + } + + @Override + public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + art.arcane.iris.core.nms.container.Pair> statePair = ExternalDataSVC.parseState(blockId); + Identifier baseBlockId = statePair.getA(); + KMap state = statePair.getB(); + Key key = Key.of(baseBlockId.namespace(), baseBlockId.key()); + + net.momirealms.craftengine.core.block.CustomBlock customBlock = CraftEngineBlocks.byId(key); + if (customBlock != null) { + ImmutableBlockState blockState = customBlock.defaultState(); + + for (Map.Entry entry : state.entrySet()) { + Property property = customBlock.getProperty(entry.getKey()); + if (property == null) { + continue; + } + + Comparable tag = property.optional(entry.getValue()).orElse(null); + if (tag == null) { + continue; + } + + blockState = ImmutableBlockState.with(blockState, property, tag); + } + + CraftEngineBlocks.place(block.getLocation(), blockState, false); + return; + } + + net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key); + if (furniture == null) { + return; + } + + Location location = parseYawAndPitch(engine, block, state); + String variant = state.getOrDefault("variant", furniture.anyVariantName()); + CraftEngineFurniture.place(location, furniture, variant, false); + } + + private static Location parseYawAndPitch(@NotNull Engine engine, @NotNull Block block, @NotNull Map state) { + Location location = block.getLocation(); + long seed = engine.getSeedManager().getSeed() + Cache.key(block.getX(), block.getZ()) + block.getY(); + RNG rng = new RNG(seed); + + if ("true".equals(state.get("randomYaw"))) { + location.setYaw(rng.f(0, 360)); + } else if (state.containsKey("yaw")) { + location.setYaw(Float.parseFloat(state.get("yaw"))); + } + + if ("true".equals(state.get("randomPitch"))) { + location.setPitch(rng.f(0, 360)); + } else if (state.containsKey("pitch")) { + location.setPitch(Float.parseFloat(state.get("pitch"))); + } + + return location; + } + + @Override + public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { + Stream keys = switch (dataType) { + case ENTITY -> Stream.empty(); + case ITEM -> CraftEngineItems.loadedItems().keySet().stream(); + case BLOCK -> Stream.concat(CraftEngineBlocks.loadedBlocks().keySet().stream(), + CraftEngineFurniture.loadedFurniture().keySet().stream()); + }; + return keys.map(key -> new Identifier(key.namespace(), key.value())).toList(); + } + + @Override + public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { + Key key = Key.of(id.namespace(), id.key()); + return switch (dataType) { + case ENTITY -> false; + case ITEM -> CraftEngineItems.byId(key) != null; + case BLOCK -> CraftEngineBlocks.byId(key) != null || CraftEngineFurniture.byId(key) != null; + }; + } + + private static > BlockProperty convert(Property raw) { + return switch (raw) { + case BooleanProperty property -> BlockProperty.ofBoolean(property.name(), property.defaultValue()); + case IntegerProperty property -> BlockProperty.ofLong(property.name(), property.defaultValue(), property.min, property.max, false, false); + default -> new BlockProperty(raw.name(), raw.valueClass(), raw.defaultValue(), raw.possibleValues(), raw::valueName); + }; + } +} 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 374ae1d83..23248903f 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 @@ -23,7 +23,9 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.v1X.NMSBinding1X; import org.bukkit.Bukkit; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public class INMS { private static final Version CURRENT = Boolean.getBoolean("iris.no-version-limit") ? @@ -69,51 +71,42 @@ public class INMS { private static INMSBinding bind() { String code = getNMSTag(); - Iris.info("Locating NMS Binding for " + code); - - try { - Class clazz = Class.forName("art.arcane.iris.core.nms." + code + ".NMSBinding"); - try { - Object b = clazz.getConstructor().newInstance(); - if (b instanceof INMSBinding binding) { - Iris.info("Craftbukkit " + code + " <-> " + b.getClass().getSimpleName() + " Successfully Bound"); - return binding; - } - } catch (Throwable e) { - Iris.reportError(e); - e.printStackTrace(); - } - } catch (ClassNotFoundException | NoClassDefFoundError classNotFoundException) { - Iris.warn("Failed to load NMS binding class for " + code + ": " + classNotFoundException.getMessage()); + boolean disableNms = IrisSettings.get().getGeneral().isDisableNMS(); + List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes(code, disableNms, getFallbackBindingCodes()); + if ("BUKKIT".equals(code) && !disableNms) { + Iris.info("NMS tag resolution fell back to Bukkit; probing supported revision bindings."); } - if (IrisSettings.get().getGeneral().isDisableNMS()) { + for (int i = 0; i < probeCodes.size(); i++) { + INMSBinding resolvedBinding = tryBind(probeCodes.get(i), i == 0); + if (resolvedBinding != null) { + return resolvedBinding; + } + } + + if (disableNms) { Iris.info("Craftbukkit " + code + " <-> " + NMSBinding1X.class.getSimpleName() + " Successfully Bound"); Iris.warn("Note: NMS support is disabled. Iris is running in limited Bukkit fallback mode."); return new NMSBinding1X(); } - String serverVersion = Bukkit.getServer().getBukkitVersion().split("-")[0]; + MinecraftVersion detectedVersion = getMinecraftVersion(); + String serverVersion = detectedVersion == null ? Bukkit.getServer().getVersion() : detectedVersion.value(); throw new IllegalStateException("Iris requires Minecraft 1.21.11 or newer. Detected server version: " + serverVersion); } private static String getTag(List versions, String def) { - String[] version = Bukkit.getServer().getBukkitVersion().split("-")[0].split("\\.", 3); - int major = 0; - int minor = 0; - - if (version.length > 2) { - major = Integer.parseInt(version[1]); - minor = Integer.parseInt(version[2]); - } else if (version.length == 2) { - major = Integer.parseInt(version[1]); + MinecraftVersion detectedVersion = getMinecraftVersion(); + if (detectedVersion == null) { + return def; } - if (CURRENT.major < major || CURRENT.minor < minor) { + + if (detectedVersion.isNewerThan(CURRENT.major, CURRENT.minor)) { return versions.getFirst().tag; } for (Version p : versions) { - if (p.major > major || p.minor > minor) { + if (!detectedVersion.isAtLeast(p.major, p.minor)) { continue; } return p.tag; @@ -121,5 +114,50 @@ public class INMS { return def; } + private static MinecraftVersion getMinecraftVersion() { + try { + return MinecraftVersion.detect(Bukkit.getServer()); + } catch (Throwable e) { + Iris.reportError(e); + Iris.error("Failed to determine server minecraft version!"); + e.printStackTrace(); + return null; + } + } + + private static INMSBinding tryBind(String code, boolean announce) { + if (announce) { + Iris.info("Locating NMS Binding for " + code); + } else { + Iris.info("Probing NMS Binding for " + code); + } + + try { + Class clazz = Class.forName("art.arcane.iris.core.nms." + code + ".NMSBinding"); + Object candidate = clazz.getConstructor().newInstance(); + if (candidate instanceof INMSBinding binding) { + Iris.info("Craftbukkit " + code + " <-> " + candidate.getClass().getSimpleName() + " Successfully Bound"); + return binding; + } + } catch (ClassNotFoundException | NoClassDefFoundError classNotFoundException) { + Iris.warn("Failed to load NMS binding class for " + code + ": " + classNotFoundException.getMessage()); + } catch (Throwable e) { + Iris.reportError(e); + e.printStackTrace(); + } + + return null; + } + + private static Set getFallbackBindingCodes() { + Set codes = new LinkedHashSet<>(); + for (Version version : REVISION) { + if (version.tag != null && !version.tag.isBlank()) { + codes.add(version.tag); + } + } + return codes; + } + private record Version(int major, int minor, String tag) {} } 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 35a2f11af..69fea385e 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 @@ -18,8 +18,10 @@ package art.arcane.iris.core.nms; -import art.arcane.iris.core.link.FoliaWorldsLink; import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.lifecycle.WorldLifecycleCaller; +import art.arcane.iris.core.lifecycle.WorldLifecycleRequest; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.nms.container.BiomeColor; import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.container.StructurePlacement; @@ -40,6 +42,7 @@ import org.bukkit.block.Biome; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.generator.ChunkGenerator; import org.bukkit.inventory.ItemStack; import java.awt.Color; @@ -96,34 +99,33 @@ public interface INMSBinding { MCABiomeContainer newBiomeContainer(int min, int max); default World createWorld(WorldCreator c) { - if (c.generator() instanceof PlatformChunkGenerator gen - && missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) - throw new IllegalStateException("Missing dimension types to create world"); - return c.createWorld(); + WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(c, false, false, WorldLifecycleCaller.CREATE); + return createWorld(c, request); } default CompletableFuture createWorldAsync(WorldCreator c) { - try { - if (c.generator() instanceof PlatformChunkGenerator gen - && missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) { - return CompletableFuture.failedFuture(new IllegalStateException("Missing dimension types to create world")); - } + WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(c, false, false, WorldLifecycleCaller.CREATE); + return createWorldAsync(c, request); + } - if (J.isFolia()) { - FoliaWorldsLink link = FoliaWorldsLink.get(); - if (link.isActive()) { - CompletableFuture future = link.createWorld(c); - if (future != null) { - return future; - } - } - } - return CompletableFuture.completedFuture(createWorld(c)); + default World createWorld(WorldCreator c, WorldLifecycleRequest request) { + validateDimensionTypes(c); + return WorldLifecycleService.get().createBlocking(request); + } + + default CompletableFuture createWorldAsync(WorldCreator c, WorldLifecycleRequest request) { + try { + validateDimensionTypes(c); + return WorldLifecycleService.get().create(request); } catch (Throwable e) { return CompletableFuture.failedFuture(e); } } + default Object createRuntimeLevelStem(Object registryAccess, ChunkGenerator raw) { + throw new UnsupportedOperationException("Active NMS binding does not support runtime LevelStem creation."); + } + int countCustomBiomes(); default boolean supportsDataPacks() { @@ -169,4 +171,11 @@ public interface INMSBinding { void placeStructures(Chunk chunk); KMap collectStructures(); + + private void validateDimensionTypes(WorldCreator c) { + if (c.generator() instanceof PlatformChunkGenerator gen + && missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) { + throw new IllegalStateException("Missing dimension types to create world"); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java b/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java new file mode 100644 index 000000000..c6c326118 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java @@ -0,0 +1,114 @@ +package art.arcane.iris.core.nms; + +import org.bukkit.Server; + +import java.lang.reflect.Method; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class MinecraftVersion { + private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)"); + + private final String value; + private final int major; + private final int minor; + + private MinecraftVersion(String value, int major, int minor) { + this.value = value; + this.major = major; + this.minor = minor; + } + + public static MinecraftVersion detect(Server server) { + if (server == null) { + return null; + } + + MinecraftVersion runtimeVersion = fromRuntimeMinecraftVersion(server); + if (runtimeVersion != null) { + return runtimeVersion; + } + + MinecraftVersion decoratedVersion = fromDecoratedVersion(server.getVersion()); + if (decoratedVersion != null) { + return decoratedVersion; + } + + return fromBukkitVersion(server.getBukkitVersion()); + } + + static MinecraftVersion fromRuntimeMinecraftVersion(Server server) { + try { + Method method = server.getClass().getMethod("getMinecraftVersion"); + Object value = method.invoke(server); + if (value instanceof String version) { + return fromVersionToken(version); + } + } catch (ReflectiveOperationException ignored) { + return null; + } + + return null; + } + + static MinecraftVersion fromDecoratedVersion(String input) { + if (input == null || input.isBlank()) { + return null; + } + + Matcher matcher = DECORATED_VERSION_PATTERN.matcher(input); + if (!matcher.find()) { + return null; + } + + return fromVersionToken(matcher.group(1)); + } + + static MinecraftVersion fromBukkitVersion(String input) { + if (input == null || input.isBlank()) { + return null; + } + + String versionToken = input.split("-", 2)[0].trim(); + return fromVersionToken(versionToken); + } + + private static MinecraftVersion fromVersionToken(String input) { + if (input == null || input.isBlank()) { + return null; + } + + String[] parts = input.split("\\."); + if (parts.length < 2 || !"1".equals(parts[0])) { + return null; + } + + try { + int major = Integer.parseInt(parts[1]); + int minor = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + return new MinecraftVersion(input, major, minor); + } catch (NumberFormatException ignored) { + return null; + } + } + + public String value() { + return value; + } + + public int major() { + return major; + } + + public int minor() { + return minor; + } + + public boolean isAtLeast(int major, int minor) { + return this.major > major || (this.major == major && this.minor >= minor); + } + + public boolean isNewerThan(int major, int minor) { + return this.major > major || (this.major == major && this.minor > minor); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java b/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java new file mode 100644 index 000000000..397528d14 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java @@ -0,0 +1,29 @@ +package art.arcane.iris.core.nms; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +final class NmsBindingProbeSupport { + private NmsBindingProbeSupport() { + } + + static List getBindingProbeCodes(String code, boolean disableNms, Collection fallbackCodes) { + List probeCodes = new ArrayList<>(); + if (code == null || code.isBlank()) { + return probeCodes; + } + + if (!"BUKKIT".equals(code)) { + probeCodes.add(code); + return probeCodes; + } + + if (disableNms || fallbackCodes == null) { + return probeCodes; + } + + probeCodes.addAll(fallbackCodes); + return probeCodes; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/nms/container/BlockProperty.java b/core/src/main/java/art/arcane/iris/core/nms/container/BlockProperty.java index 35ffc678b..5fe6f5f22 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/container/BlockProperty.java +++ b/core/src/main/java/art/arcane/iris/core/nms/container/BlockProperty.java @@ -17,7 +17,7 @@ public class BlockProperty { private final Function nameFunction; private final Function jsonFunction; - public > BlockProperty( + public > BlockProperty( String name, Class type, T defaultValue, @@ -42,7 +42,7 @@ public class BlockProperty { ); } - public static BlockProperty ofFloat(String name, float defaultValue, float min, float max, boolean exclusiveMin, boolean exclusiveMax) { + public static BlockProperty ofDouble(String name, float defaultValue, float min, float max, boolean exclusiveMin, boolean exclusiveMax) { return new BoundedDouble( name, defaultValue, @@ -54,6 +54,18 @@ public class BlockProperty { ); } + public static BlockProperty ofLong(String name, long defaultValue, long min, long max, boolean exclusiveMin, boolean exclusiveMax) { + return new BoundedLong( + name, + defaultValue, + min, + max, + exclusiveMin, + exclusiveMax, + value -> Long.toString(value) + ); + } + public static BlockProperty ofBoolean(String name, boolean defaultValue) { return new BlockProperty( name, @@ -122,6 +134,38 @@ public class BlockProperty { return Objects.hash(name, values, type); } + private static class BoundedLong extends BlockProperty { + private final long min; + private final long max; + private final boolean exclusiveMin; + private final boolean exclusiveMax; + + public BoundedLong( + String name, + long defaultValue, + long min, + long max, + boolean exclusiveMin, + boolean exclusiveMax, + Function nameFunction + ) { + super(name, Long.class, defaultValue, List.of(), nameFunction); + this.min = min; + this.max = max; + this.exclusiveMin = exclusiveMin; + this.exclusiveMax = exclusiveMax; + } + + @Override + public JSONObject buildJson() { + return super.buildJson() + .put("minimum", min) + .put("maximum", max) + .put("exclusiveMinimum", exclusiveMin) + .put("exclusiveMaximum", exclusiveMax); + } + } + private static class BoundedDouble extends BlockProperty { private final double min, max; private final boolean exclusiveMin, exclusiveMax; diff --git a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java index 48cafda41..d7b239388 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java +++ b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java @@ -12,6 +12,7 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.OVERWORLD, """ { "ambient_light": 0.0, + "has_ender_dragon_fight": false, "attributes": { "minecraft:audio/ambient_sounds": { "mood": { @@ -42,6 +43,7 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.NETHER, """ { "ambient_light": 0.1, + "has_ender_dragon_fight": false, "attributes": { "minecraft:gameplay/sky_light_level": 4.0, "minecraft:gameplay/snow_golem_melts": true, @@ -57,6 +59,7 @@ public class DataFixerV1217 extends DataFixerV1213 { Dimension.END, """ { "ambient_light": 0.25, + "has_ender_dragon_fight": true, "attributes": { "minecraft:audio/ambient_sounds": { "mood": { @@ -96,9 +99,9 @@ public class DataFixerV1217 extends DataFixerV1213 { JSONObject particle = (JSONObject) effects.remove("particle"); if (particle != null) { + particle.put("particle", particle.remove("options")); attributes.put("minecraft:visual/ambient_particles", new JSONArray() - .put(particle.getJSONObject("options") - .put("probability", particle.get("probability")))); + .put(particle)); } json.put("attributes", attributes); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java index 9ef11c3e5..3a6af8cab 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java @@ -21,10 +21,11 @@ package art.arcane.iris.core.project; import com.google.gson.Gson; import art.arcane.iris.Iris; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.link.FoliaWorldsLink; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.*; import art.arcane.iris.engine.object.annotations.Snippet; @@ -63,6 +64,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -228,209 +231,117 @@ public class IrisProject { return foundWork; } - public void open(VolmitSender sender, long seed, Consumer onDone) throws IrisException { + public CompletableFuture open(VolmitSender sender, long seed, Consumer onDone) throws IrisException { if (isOpen()) { - close(); + return close().thenCompose(ignored -> openInternal(sender, seed, onDone)); } + return openInternal(sender, seed, onDone); + } + + private CompletableFuture openInternal(VolmitSender sender, long seed, Consumer onDone) { AtomicReference stage = new AtomicReference<>("Queued"); AtomicReference progress = new AtomicReference<>(0.01D); AtomicBoolean complete = new AtomicBoolean(false); AtomicBoolean failed = new AtomicBoolean(false); + CompletableFuture future = StudioOpenCoordinator.get().open( + StudioOpenCoordinator.StudioOpenRequest.studioProject( + this, + sender, + seed, + update -> { + if (update.stage() != null && !update.stage().isBlank()) { + stage.set(update.stage()); + } + progress.set(Math.max(0D, Math.min(0.99D, update.progress()))); + }, + onDone + ) + ); startStudioOpenReporter(sender, stage, progress, complete, failed); - - J.a(() -> { + future.whenComplete((result, throwable) -> { World maintenanceWorld = null; boolean maintenanceActive = false; try { - stage.set("Loading dimension"); - progress.set(0.05D); - IrisDimension d = IrisData.loadAnyDimension(getName(), null); - if (d == null) { + if (throwable != null) { failed.set(true); - sender.sendMessage(C.RED + "Can't find dimension: " + getName()); + Throwable error = throwable instanceof java.util.concurrent.CompletionException completionException && completionException.getCause() != null + ? completionException.getCause() + : throwable; + Iris.reportError("Studio open failed for project \"" + getName() + "\".", error); + sender.sendMessage(C.RED + "Studio open failed: " + error.getMessage()); return; - } else if (sender.isPlayer()) { + } + + if (sender.isPlayer() && sender.player() != null) { J.runEntity(sender.player(), () -> sender.player().setGameMode(GameMode.SPECTATOR)); } - - stage.set("Creating world"); - progress.set(0.12D); - activeProvider = (PlatformChunkGenerator) IrisToolbelt.createWorld() - .seed(seed) - .sender(sender) - .studio(true) - .name("iris-" + UUID.randomUUID()) - .dimension(d.getLoadKey()) - .studioProgressConsumer((value, currentStage) -> { - if (currentStage != null && !currentStage.isBlank()) { - stage.set(currentStage); - } - progress.set(Math.max(0D, Math.min(0.99D, value))); - }) - .create().getGenerator(); - - if (activeProvider != null) { - maintenanceWorld = activeProvider.getTarget().getWorld().realWorld(); - if (maintenanceWorld != null) { - IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open"); - maintenanceActive = true; - } - onDone.accept(maintenanceWorld); + activeProvider = IrisToolbelt.access(result.world()); + maintenanceWorld = result.world(); + if (maintenanceWorld != null) { + IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open"); + maintenanceActive = true; } - } catch (IrisException e) { - failed.set(true); - Iris.reportError(e); - sender.sendMessage(C.RED + "Failed to open studio world: " + e.getMessage()); - } catch (Throwable e) { - failed.set(true); - Iris.reportError(e); - sender.sendMessage(C.RED + "Studio open failed: " + e.getMessage()); } finally { - if (activeProvider != null) { - stage.set("Opening workspace"); - progress.set(Math.max(progress.get(), 0.95D)); - openVSCode(sender); - } - if (maintenanceActive && maintenanceWorld != null) { World worldToRelease = maintenanceWorld; - J.a(() -> { - J.sleep(15000); - IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"); - }); - maintenanceActive = false; - } - - if (maintenanceActive && maintenanceWorld != null) { - IrisToolbelt.endWorldMaintenance(maintenanceWorld, "studio-open"); + J.a(() -> IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"), 300); } complete.set(true); } }); + return future; } private void startStudioOpenReporter(VolmitSender sender, AtomicReference stage, AtomicReference progress, AtomicBoolean complete, AtomicBoolean failed) { - J.a(() -> { - String[] spinner = {"|", "/", "-", "\\"}; - int spinIndex = 0; - long nextConsoleUpdate = 0L; + String[] spinner = {"|", "/", "-", "\\"}; + AtomicInteger spinIndex = new AtomicInteger(0); + AtomicLong nextConsoleUpdate = new AtomicLong(0L); + AtomicInteger taskId = new AtomicInteger(-1); - while (!complete.get()) { - double currentProgress = Math.max(0D, Math.min(0.97D, progress.get())); - String currentStage = stage.get(); - String currentSpinner = spinner[spinIndex % spinner.length]; - - if (sender.isPlayer()) { - sender.sendProgress(currentProgress, "Studio " + currentSpinner + " " + currentStage); - } else { - long now = System.currentTimeMillis(); - if (now >= nextConsoleUpdate) { - sender.sendMessage(C.WHITE + "Studio " + Form.pc(currentProgress, 0) + C.GRAY + " - " + currentStage); - nextConsoleUpdate = now + 1500L; + int scheduledTaskId = J.ar(() -> { + if (complete.get()) { + J.car(taskId.get()); + if (failed.get()) { + if (sender.isPlayer()) { + sender.sendProgress(1D, "Studio open failed"); + } else { + sender.sendMessage(C.RED + "Studio open failed."); } - } - - spinIndex++; - J.sleep(120); - } - - if (failed.get()) { - if (sender.isPlayer()) { - sender.sendProgress(1D, "Studio open failed"); + } else if (sender.isPlayer()) { + sender.sendProgress(1D, "Studio ready"); } else { - sender.sendMessage(C.RED + "Studio open failed."); + sender.sendMessage(C.GREEN + "Studio ready."); } return; } + double currentProgress = Math.max(0D, Math.min(0.97D, progress.get())); + String currentStage = stage.get(); + String currentSpinner = spinner[Math.floorMod(spinIndex.getAndIncrement(), spinner.length)]; + if (sender.isPlayer()) { - sender.sendProgress(1D, "Studio ready"); - } else { - sender.sendMessage(C.GREEN + "Studio ready."); - } - }); - } - - public void close() { - if (activeProvider == null) { - return; - } - - Iris.debug("Closing Active Provider"); - final PlatformChunkGenerator provider = activeProvider; - final World studioWorld = provider.getTarget().getWorld().realWorld(); - final File folder = provider.getTarget().getWorld().worldFolder(); - final String worldName = provider.getTarget().getWorld().name(); - - final Runnable closeTask = () -> { - IrisToolbelt.beginWorldMaintenance(studioWorld, "studio-close", true); - try { - IrisToolbelt.evacuate(studioWorld); - provider.close(); - Iris.linkMultiverseCore.removeFromConfig(worldName); - boolean unloaded = FoliaWorldsLink.get().unloadWorld(studioWorld, false); - if (!unloaded) { - Iris.warn("Failed to unload studio world \"" + worldName + "\"."); - } - } finally { - IrisToolbelt.endWorldMaintenance(studioWorld, "studio-close"); - } - }; - - if (J.isPrimaryThread()) { - closeTask.run(); - } else { - final CompletableFuture closeFuture = J.sfut(closeTask); - if (closeFuture != null) { - try { - closeFuture.join(); - } catch (Throwable e) { - Iris.reportError(e); - e.printStackTrace(); - } - } else { - closeTask.run(); - } - } - - J.attemptAsync(() -> deleteStudioFolderWithRetry(folder, worldName)); - Iris.debug("Closed Active Provider " + worldName); - activeProvider = null; - } - - private static void deleteStudioFolderWithRetry(File folder, String worldName) { - if (folder == null) { - return; - } - - long unloadWaitDeadlineMs = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20); - while (Bukkit.getWorld(worldName) != null && System.currentTimeMillis() < unloadWaitDeadlineMs) { - J.sleep(100); - } - - int attempts = 0; - while (folder.exists() && attempts < 40) { - IO.delete(folder); - if (!folder.exists()) { + sender.sendProgress(currentProgress, "Studio " + currentSpinner + " " + currentStage); return; } - attempts++; - J.sleep(250); + long now = System.currentTimeMillis(); + long nextUpdate = nextConsoleUpdate.get(); + if (now >= nextUpdate) { + sender.sendMessage(C.WHITE + "Studio " + Form.pc(currentProgress, 0) + C.GRAY + " - " + currentStage); + nextConsoleUpdate.set(now + 1500L); + } + }, 3); + + taskId.set(scheduledTaskId); + } + + public CompletableFuture close() { + if (activeProvider == null) { + return CompletableFuture.completedFuture(new StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null, null)); } - if (!folder.exists()) { - return; - } - - try { - Iris.queueWorldDeletionOnStartup(java.util.Collections.singleton(worldName)); - Iris.warn("Queued deferred deletion for studio world folder \"" + worldName + "\"."); - } catch (IOException e) { - Iris.warn("Failed to queue deferred deletion for studio world folder \"" + worldName + "\"."); - Iris.reportError(e); - } + return StudioOpenCoordinator.get().closeProject(this); } public File getCodeWorkspaceFile() { diff --git a/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java new file mode 100644 index 000000000..ddada2db9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java @@ -0,0 +1,77 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import io.papermc.lib.PaperLib; +import org.bukkit.Chunk; +import org.bukkit.World; + +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; + +final class BukkitPublicRuntimeControlBackend implements WorldRuntimeControlBackend { + private final CapabilitySnapshot capabilities; + + BukkitPublicRuntimeControlBackend(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + } + + @Override + public String backendName() { + return "bukkit_public_runtime"; + } + + @Override + public String describeCapabilities() { + String chunkAsync = capabilities.chunkAtAsyncMethod() != null ? "world#getChunkAtAsync" : "paperlib"; + return "time=bukkit_world#setTime, chunkAsync=" + chunkAsync + ", teleport=entity_scheduler"; + } + + @Override + public OptionalLong readDayTime(World world) { + if (world == null) { + return OptionalLong.empty(); + } + + return OptionalLong.of(world.getTime()); + } + + @Override + public boolean writeDayTime(World world, long dayTime) { + if (world == null) { + return false; + } + + world.setTime(dayTime); + return true; + } + + @Override + public void syncTime(World world) { + } + + @Override + public CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) { + if (world == null) { + return CompletableFuture.failedFuture(new IllegalStateException("World is null.")); + } + + if (capabilities.chunkAtAsyncMethod() != null) { + try { + Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate); + if (result instanceof CompletableFuture) { + @SuppressWarnings("unchecked") + CompletableFuture future = (CompletableFuture) result; + return future; + } + } catch (Throwable ignored) { + } + } + + CompletableFuture future = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate); + if (future == null) { + return CompletableFuture.failedFuture(new IllegalStateException("PaperLib did not return a chunk future.")); + } + + return future; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/DatapackReadinessResult.java b/core/src/main/java/art/arcane/iris/core/runtime/DatapackReadinessResult.java new file mode 100644 index 000000000..27b2cba8e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/DatapackReadinessResult.java @@ -0,0 +1,97 @@ +package art.arcane.iris.core.runtime; + +import com.google.gson.GsonBuilder; +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import lombok.Data; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +@Data +public final class DatapackReadinessResult { + private final String requestedPackKey; + private final List resolvedDatapackFolders; + private final String externalDatapackInstallResult; + private final boolean verificationPassed; + private final List verifiedPaths; + private final List missingPaths; + private final boolean restartRequired; + + public String toJson() { + return new GsonBuilder().setPrettyPrinting().create().toJson(this); + } + + public static DatapackReadinessResult installForStudioWorld( + String requestedPackKey, + String dimensionTypeKey, + File worldFolder, + boolean verifyDataPacks, + boolean includeExternalDataPacks, + KMap> extraWorldDatapackFoldersByPack + ) { + ArrayList resolvedFolders = new ArrayList<>(); + File datapacksFolder = ServerConfigurator.resolveDatapacksFolder(worldFolder); + resolvedFolders.add(datapacksFolder.getAbsolutePath()); + if (extraWorldDatapackFoldersByPack != null) { + KList extraFolders = extraWorldDatapackFoldersByPack.get(requestedPackKey); + if (extraFolders != null) { + for (File extraFolder : extraFolders) { + if (extraFolder == null) { + continue; + } + String path = extraFolder.getAbsolutePath(); + if (!resolvedFolders.contains(path)) { + resolvedFolders.add(path); + } + } + } + } + + String externalResult = "ok"; + boolean restartRequired = false; + try { + restartRequired = ServerConfigurator.installDataPacks(verifyDataPacks, includeExternalDataPacks, extraWorldDatapackFoldersByPack); + } catch (Throwable e) { + externalResult = e.getClass().getSimpleName() + ": " + String.valueOf(e.getMessage()); + } + + ArrayList verifiedPaths = new ArrayList<>(); + ArrayList missingPaths = new ArrayList<>(); + String verificationDimensionTypeKey = (dimensionTypeKey == null || dimensionTypeKey.isBlank()) + ? requestedPackKey + : dimensionTypeKey; + for (String folderPath : resolvedFolders) { + File folder = new File(folderPath); + collectVerificationPaths(folder, verificationDimensionTypeKey, verifiedPaths, missingPaths); + } + + boolean verificationPassed = missingPaths.isEmpty() && "ok".equals(externalResult); + return new DatapackReadinessResult( + requestedPackKey, + List.copyOf(resolvedFolders), + externalResult, + verificationPassed, + List.copyOf(verifiedPaths), + List.copyOf(missingPaths), + restartRequired + ); + } + + static void collectVerificationPaths(File folder, String dimensionTypeKey, List verifiedPaths, List missingPaths) { + File packMeta = new File(folder, "iris/pack.mcmeta"); + File dimensionType = new File(folder, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json"); + if (packMeta.exists()) { + verifiedPaths.add(packMeta.getAbsolutePath()); + } else { + missingPaths.add(packMeta.getAbsolutePath()); + } + if (dimensionType.exists()) { + verifiedPaths.add(dimensionType.getAbsolutePath()); + } else { + missingPaths.add(dimensionType.getAbsolutePath()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java new file mode 100644 index 000000000..377113068 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java @@ -0,0 +1,319 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import io.papermc.lib.PaperLib; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.World; + +import java.lang.reflect.Method; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend { + private final CapabilitySnapshot capabilities; + private final AtomicReference timeAccessStrategy; + + PaperLikeRuntimeControlBackend(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + this.timeAccessStrategy = new AtomicReference<>(); + } + + @Override + public String backendName() { + return "paper_like_runtime"; + } + + @Override + public String describeCapabilities() { + TimeAccessStrategy strategy = timeAccessStrategy.get(); + String timeAccess = strategy == null ? "deferred" : strategy.description(); + String chunkAsync = capabilities.chunkAtAsyncMethod() != null ? "world#getChunkAtAsync" : "paperlib"; + return "time=" + timeAccess + ", chunkAsync=" + chunkAsync + ", teleport=entity_scheduler"; + } + + @Override + public OptionalLong readDayTime(World world) { + if (world == null) { + return OptionalLong.empty(); + } + + TimeAccessStrategy strategy = resolveTimeAccessStrategy(world); + if (strategy == null) { + return OptionalLong.empty(); + } + + try { + Object handle = strategy.handleMethod().invoke(world); + if (handle == null) { + return OptionalLong.empty(); + } + + Object value = strategy.readMethod().invoke(strategy.readOwner(handle), strategy.readArguments(handle)); + if (value instanceof Long longValue) { + return OptionalLong.of(longValue.longValue()); + } + if (value instanceof Number number) { + return OptionalLong.of(number.longValue()); + } + } catch (Throwable ignored) { + } + + return OptionalLong.empty(); + } + + @Override + public boolean writeDayTime(World world, long dayTime) throws ReflectiveOperationException { + if (world == null) { + return false; + } + + TimeAccessStrategy strategy = resolveTimeAccessStrategy(world); + if (strategy == null || !strategy.writable()) { + return false; + } + + Object handle = strategy.handleMethod().invoke(world); + if (handle == null) { + return false; + } + + Object writeOwner = strategy.writeOwner(handle); + if (writeOwner == null) { + return false; + } + + strategy.writeMethod().invoke(writeOwner, dayTime); + return true; + } + + @Override + public void syncTime(World world) { + TimeAccessStrategy strategy = timeAccessStrategy.get(); + if (strategy == null || strategy.syncMethod() == null) { + return; + } + + try { + Object craftServer = Bukkit.getServer(); + if (craftServer == null) { + return; + } + + Object serverHandle = strategy.serverHandleMethod() == null ? null : strategy.serverHandleMethod().invoke(craftServer); + if (serverHandle == null) { + return; + } + + strategy.syncMethod().invoke(serverHandle); + } catch (Throwable ignored) { + } + } + + @Override + public CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) { + if (world == null) { + return CompletableFuture.failedFuture(new IllegalStateException("World is null.")); + } + + if (capabilities.chunkAtAsyncMethod() != null) { + try { + Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate); + if (result instanceof CompletableFuture) { + @SuppressWarnings("unchecked") + CompletableFuture future = (CompletableFuture) result; + return future; + } + } catch (Throwable e) { + return CompletableFuture.failedFuture(e); + } + } + + CompletableFuture future = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate); + if (future == null) { + return CompletableFuture.failedFuture(new IllegalStateException("PaperLib did not return a chunk future.")); + } + + return future; + } + + private TimeAccessStrategy resolveTimeAccessStrategy(World world) { + TimeAccessStrategy current = timeAccessStrategy.get(); + if (current != null) { + return current; + } + + synchronized (timeAccessStrategy) { + current = timeAccessStrategy.get(); + if (current != null) { + return current; + } + + TimeAccessStrategy resolved = probeTimeAccessStrategy(world); + timeAccessStrategy.set(resolved); + return resolved; + } + } + + private TimeAccessStrategy probeTimeAccessStrategy(World world) { + if (world == null) { + return TimeAccessStrategy.unsupported(); + } + + try { + Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle"); + if (handleMethod == null) { + return TimeAccessStrategy.unsupported(); + } + + Object handle = handleMethod.invoke(world); + if (handle == null) { + return TimeAccessStrategy.unsupported(); + } + + Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime"); + Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime"); + if (readMethod != null && writeMethod != null) { + return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime"); + } + + Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData"); + if (levelDataMethod == null) { + levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData"); + } + if (levelDataMethod != null) { + Object levelData = levelDataMethod.invoke(handle); + if (levelData != null) { + Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime"); + Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime"); + if (levelDataReadMethod != null && levelDataWriteMethod != null) { + return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime"); + } + } + } + + return TimeAccessStrategy.unsupported(handleMethod); + } catch (Throwable ignored) { + return TimeAccessStrategy.unsupported(); + } + } + + private static Method resolveZeroArgMethod(Class type, String name) { + Class current = type; + while (current != null) { + try { + Method method = current.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException ignored) { + current = current.getSuperclass(); + } + } + + return null; + } + + private static Method resolveLongArgMethod(Class type, String name) { + Class current = type; + while (current != null) { + try { + Method method = current.getDeclaredMethod(name, long.class); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException ignored) { + current = current.getSuperclass(); + } + } + + return null; + } + + private record TimeAccessStrategy( + Method handleMethod, + Method levelDataMethod, + Method readMethod, + Method writeMethod, + Method serverHandleMethod, + Method syncMethod, + String description + ) { + static TimeAccessStrategy forHandle(Method handleMethod, Method readMethod, Method writeMethod, String description) { + Method serverHandleMethod = resolveCraftServerMethod("getHandle"); + Method syncMethod = resolveServerMethod(serverHandleMethod, "forceTimeSynchronization"); + return new TimeAccessStrategy(handleMethod, null, readMethod, writeMethod, serverHandleMethod, syncMethod, description); + } + + static TimeAccessStrategy forLevelData(Method handleMethod, Method levelDataMethod, Method readMethod, Method writeMethod, String description) { + Method serverHandleMethod = resolveCraftServerMethod("getHandle"); + Method syncMethod = resolveServerMethod(serverHandleMethod, "forceTimeSynchronization"); + return new TimeAccessStrategy(handleMethod, levelDataMethod, readMethod, writeMethod, serverHandleMethod, syncMethod, description); + } + + static TimeAccessStrategy unsupported() { + return new TimeAccessStrategy(null, null, null, null, null, null, "unsupported"); + } + + static TimeAccessStrategy unsupported(Method handleMethod) { + return new TimeAccessStrategy(handleMethod, null, null, null, null, null, "unsupported"); + } + + boolean writable() { + return handleMethod != null && readMethod != null && writeMethod != null; + } + + Object readOwner(Object handle) throws ReflectiveOperationException { + if (levelDataMethod == null) { + return handle; + } + + return levelDataMethod.invoke(handle); + } + + Object[] readArguments(Object handle) { + return new Object[0]; + } + + Object writeOwner(Object handle) throws ReflectiveOperationException { + if (levelDataMethod == null) { + return handle; + } + + return levelDataMethod.invoke(handle); + } + + private static Method resolveCraftServerMethod(String name) { + try { + Method method = Bukkit.getServer().getClass().getMethod(name); + method.setAccessible(true); + return method; + } catch (Throwable ignored) { + return null; + } + } + + private static Method resolveServerMethod(Method serverHandleMethod, String name) { + if (serverHandleMethod == null) { + return null; + } + + try { + Object craftServer = Bukkit.getServer(); + if (craftServer == null) { + return null; + } + + Object serverHandle = serverHandleMethod.invoke(craftServer); + if (serverHandle == null) { + return null; + } + + Method method = serverHandle.getClass().getMethod(name); + method.setAccessible(true); + return method; + } catch (Throwable ignored) { + return null; + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/SmokeDiagnosticsService.java b/core/src/main/java/art/arcane/iris/core/runtime/SmokeDiagnosticsService.java new file mode 100644 index 000000000..418d2e26a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/SmokeDiagnosticsService.java @@ -0,0 +1,368 @@ +package art.arcane.iris.core.runtime; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import art.arcane.iris.Iris; +import art.arcane.volmlib.util.io.IO; +import lombok.Data; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +public final class SmokeDiagnosticsService { + private static volatile SmokeDiagnosticsService instance; + + private final ConcurrentHashMap reports; + private final AtomicReference latestRunId; + private final AtomicLong runCounter; + private final Gson gson; + + private SmokeDiagnosticsService() { + this.reports = new ConcurrentHashMap<>(); + this.latestRunId = new AtomicReference<>(); + this.runCounter = new AtomicLong(1L); + this.gson = new GsonBuilder().setPrettyPrinting().create(); + } + + public static SmokeDiagnosticsService get() { + SmokeDiagnosticsService current = instance; + if (current != null) { + return current; + } + + synchronized (SmokeDiagnosticsService.class) { + if (instance != null) { + return instance; + } + + instance = new SmokeDiagnosticsService(); + return instance; + } + } + + public SmokeRunHandle beginRun(SmokeRunMode mode, String worldName, boolean studio, boolean headless, String playerName, boolean retainOnFailure) { + long ordinal = runCounter.getAndIncrement(); + String runId = String.format("%s-%05d", mode.id(), ordinal); + SmokeRunReport report = new SmokeRunReport(); + report.setRunId(runId); + report.setMode(mode.id()); + report.setWorldName(worldName); + report.setStudio(studio); + report.setHeadless(headless); + report.setPlayerName(playerName); + report.setRetainOnFailure(retainOnFailure); + report.setStartedAt(System.currentTimeMillis()); + report.setOutcome("running"); + report.setStage("queued"); + report.setLifecycleBackend(art.arcane.iris.core.lifecycle.WorldLifecycleService.get().capabilities().serverFamily().id()); + report.setRuntimeBackend(WorldRuntimeControlService.get().backendName()); + reports.put(runId, report); + latestRunId.set(runId); + persist(report); + return new SmokeRunHandle(report); + } + + public SmokeRunReport latest() { + String runId = latestRunId.get(); + if (runId == null) { + return null; + } + + return get(runId); + } + + public SmokeRunReport get(String runId) { + if (runId == null || runId.isBlank()) { + return null; + } + + SmokeRunReport report = reports.get(runId); + if (report != null) { + return snapshot(report); + } + + return load(runId); + } + + public SmokeRunReport latestPersisted() { + File latestFile = latestFile(); + if (!latestFile.exists()) { + return null; + } + + try { + return gson.fromJson(IO.readAll(latestFile), SmokeRunReport.class); + } catch (Throwable e) { + return null; + } + } + + private SmokeRunReport load(String runId) { + File file = reportFile(runId); + if (!file.exists()) { + return null; + } + + try { + return gson.fromJson(IO.readAll(file), SmokeRunReport.class); + } catch (Throwable e) { + return null; + } + } + + private void persist(SmokeRunReport report) { + if (report == null || !SmokeRunMode.shouldPersist(report.getMode())) { + return; + } + + try { + String json = gson.toJson(report); + File file = reportFile(report.getRunId()); + IO.writeAll(file, json); + IO.writeAll(latestFile(), json); + } catch (Throwable e) { + Iris.reportError("Failed to persist smoke report \"" + report.getRunId() + "\".", e); + } + } + + private SmokeRunReport snapshot(SmokeRunReport report) { + String json = gson.toJson(report); + return gson.fromJson(json, SmokeRunReport.class); + } + + private File reportFile(String runId) { + if (Iris.instance == null) { + File root = new File("plugins/Iris/diagnostics/smoke"); + root.mkdirs(); + return new File(root, runId + ".json"); + } + + return Iris.instance.getDataFile("diagnostics", "smoke", runId + ".json"); + } + + private File latestFile() { + if (Iris.instance == null) { + File root = new File("plugins/Iris/diagnostics/smoke"); + root.mkdirs(); + return new File(root, "latest.json"); + } + + return Iris.instance.getDataFile("diagnostics", "smoke", "latest.json"); + } + + public enum SmokeRunMode { + FULL("full", true), + STUDIO("studio", true), + CREATE("create", true), + BENCHMARK("benchmark", true), + STUDIO_OPEN("studio_open", false), + STUDIO_CLOSE("studio_close", false); + + private final String id; + private final boolean persisted; + + SmokeRunMode(String id, boolean persisted) { + this.id = id; + this.persisted = persisted; + } + + public String id() { + return id; + } + + static boolean shouldPersist(String id) { + for (SmokeRunMode mode : values()) { + if (mode.id.equals(id)) { + return mode.persisted; + } + } + + return false; + } + } + + public final class SmokeRunHandle { + private final SmokeRunReport report; + + private SmokeRunHandle(SmokeRunReport report) { + this.report = report; + } + + public String runId() { + return report.getRunId(); + } + + public SmokeRunReport snapshot() { + synchronized (report) { + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + return SmokeDiagnosticsService.this.snapshot(report); + } + } + + public void setWorldName(String worldName) { + synchronized (report) { + report.setWorldName(worldName); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void setLifecycleBackend(String backend) { + synchronized (report) { + report.setLifecycleBackend(backend); + persist(report); + } + } + + public void setRuntimeBackend(String backend) { + synchronized (report) { + report.setRuntimeBackend(backend); + persist(report); + } + } + + public void setEntryChunk(int chunkX, int chunkZ) { + synchronized (report) { + report.setEntryChunkX(chunkX); + report.setEntryChunkZ(chunkZ); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void setGenerationSession(long sessionId, int activeLeases) { + synchronized (report) { + report.setGenerationSessionId(sessionId); + report.setGenerationActiveLeases(activeLeases); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void setDatapackReadiness(DatapackReadinessResult readiness) { + synchronized (report) { + report.setDatapackReadiness(readiness); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void setCloseState(boolean unloadCompletedLive, boolean folderDeletionCompletedLive, boolean startupCleanupQueued) { + synchronized (report) { + report.setCloseUnloadCompletedLive(unloadCompletedLive); + report.setCloseFolderDeletionCompletedLive(folderDeletionCompletedLive); + report.setCloseStartupCleanupQueued(startupCleanupQueued); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void note(String text) { + synchronized (report) { + ArrayList notes = new ArrayList<>(report.getNotes()); + notes.add(text); + report.setNotes(List.copyOf(notes)); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void stage(String stage) { + stage(stage, null); + } + + public void stage(String stage, String detail) { + synchronized (report) { + report.setStage(stage); + report.setStageDetail(detail); + report.setElapsedMs(System.currentTimeMillis() - report.getStartedAt()); + persist(report); + } + } + + public void completeSuccess(String finalStage, boolean cleanupApplied) { + synchronized (report) { + report.setStage(finalStage); + report.setOutcome("success"); + report.setCleanupApplied(cleanupApplied); + report.setCompletedAt(System.currentTimeMillis()); + report.setElapsedMs(report.getCompletedAt() - report.getStartedAt()); + persist(report); + } + } + + public void completeFailure(String finalStage, Throwable throwable, boolean cleanupApplied) { + synchronized (report) { + report.setStage(finalStage); + report.setOutcome("failed"); + report.setCleanupApplied(cleanupApplied); + report.setCompletedAt(System.currentTimeMillis()); + report.setElapsedMs(report.getCompletedAt() - report.getStartedAt()); + if (throwable != null) { + report.setFailureType(throwable.getClass().getName()); + report.setFailureMessage(String.valueOf(throwable.getMessage())); + report.setFailureChain(failureChain(throwable)); + report.setFailureStacktrace(stacktrace(throwable)); + } + persist(report); + } + } + + private List failureChain(Throwable throwable) { + ArrayList chain = new ArrayList<>(); + Throwable cursor = throwable; + while (cursor != null) { + chain.add(cursor.getClass().getName() + ": " + String.valueOf(cursor.getMessage())); + cursor = cursor.getCause(); + } + return List.copyOf(chain); + } + + private String stacktrace(Throwable throwable) { + StringWriter writer = new StringWriter(); + PrintWriter printWriter = new PrintWriter(writer); + throwable.printStackTrace(printWriter); + printWriter.flush(); + return writer.toString(); + } + } + + @Data + public static final class SmokeRunReport { + private String runId; + private String mode; + private String worldName; + private String stage; + private String stageDetail; + private long startedAt; + private long completedAt; + private long elapsedMs; + private String outcome; + private String lifecycleBackend; + private String runtimeBackend; + private long generationSessionId; + private int generationActiveLeases; + private Integer entryChunkX; + private Integer entryChunkZ; + private boolean studio; + private boolean headless; + private String playerName; + private boolean retainOnFailure; + private boolean cleanupApplied; + private boolean closeUnloadCompletedLive; + private boolean closeFolderDeletionCompletedLive; + private boolean closeStartupCleanupQueued; + private DatapackReadinessResult datapackReadiness; + private String failureType; + private String failureMessage; + private List failureChain = List.of(); + private String failureStacktrace; + private List notes = List.of(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/SmokeTestService.java b/core/src/main/java/art/arcane/iris/core/runtime/SmokeTestService.java new file mode 100644 index 000000000..1095a95e7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/SmokeTestService.java @@ -0,0 +1,418 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.tools.IrisCreator; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.exceptions.IrisException; +import art.arcane.volmlib.util.io.IO; +import org.bukkit.Bukkit; +import org.bukkit.World; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public final class SmokeTestService { + private static volatile SmokeTestService instance; + + private final SmokeDiagnosticsService diagnostics; + + private SmokeTestService() { + this.diagnostics = SmokeDiagnosticsService.get(); + } + + public static SmokeTestService get() { + SmokeTestService current = instance; + if (current != null) { + return current; + } + + synchronized (SmokeTestService.class) { + if (instance != null) { + return instance; + } + + instance = new SmokeTestService(); + return instance; + } + } + + public String startCreateSmoke(VolmitSender sender, String dimensionKey, long seed, boolean retainOnFailure) { + SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun( + SmokeDiagnosticsService.SmokeRunMode.CREATE, + nextWorldName("create"), + false, + true, + null, + retainOnFailure + ); + J.a(() -> executeCreateSmoke(handle, sender, dimensionKey, seed, false, true)); + return handle.runId(); + } + + public String startBenchmarkSmoke(VolmitSender sender, String dimensionKey, long seed, boolean retainOnFailure) { + SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun( + SmokeDiagnosticsService.SmokeRunMode.BENCHMARK, + nextWorldName("benchmark"), + false, + true, + null, + retainOnFailure + ); + J.a(() -> executeCreateSmoke(handle, sender, dimensionKey, seed, true, true)); + return handle.runId(); + } + + public String startStudioSmoke(VolmitSender sender, String dimensionKey, long seed, String playerName, boolean retainOnFailure) { + String normalizedPlayer = normalizePlayerName(playerName); + SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun( + SmokeDiagnosticsService.SmokeRunMode.STUDIO, + nextWorldName("studio"), + true, + normalizedPlayer == null, + normalizedPlayer, + retainOnFailure + ); + J.a(() -> executeStudioSmoke(handle, sender, dimensionKey, seed, normalizedPlayer, retainOnFailure, true)); + return handle.runId(); + } + + public String startFullSmoke(VolmitSender sender, String dimensionKey, long seed, String playerName, boolean retainOnFailure) { + String normalizedPlayer = normalizePlayerName(playerName); + SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun( + SmokeDiagnosticsService.SmokeRunMode.FULL, + nextWorldName("full"), + false, + normalizedPlayer == null, + normalizedPlayer, + retainOnFailure + ); + J.a(() -> executeFullSmoke(handle, sender, dimensionKey, seed, normalizedPlayer, retainOnFailure)); + return handle.runId(); + } + + public SmokeDiagnosticsService.SmokeRunReport latest() { + SmokeDiagnosticsService.SmokeRunReport latest = diagnostics.latest(); + if (latest != null) { + return latest; + } + + return diagnostics.latestPersisted(); + } + + public SmokeDiagnosticsService.SmokeRunReport get(String runId) { + return diagnostics.get(runId); + } + + public WorldInspection inspectWorld(String worldName) { + World world = Bukkit.getWorld(worldName); + if (world == null) { + return null; + } + + PlatformChunkGenerator provider = IrisToolbelt.access(world); + boolean studio = provider != null && provider.isStudio(); + boolean engineClosed = false; + boolean engineFailing = false; + long generationSessionId = 0L; + int activeLeases = 0; + if (provider != null && provider.getEngine() instanceof IrisEngine irisEngine) { + engineClosed = irisEngine.isClosed(); + engineFailing = irisEngine.isFailing(); + generationSessionId = irisEngine.getGenerationSessionId(); + activeLeases = irisEngine.getGenerationSessions().activeLeases(); + } + + ArrayList datapackFolders = new ArrayList<>(); + File datapacksFolder = ServerConfigurator.resolveDatapacksFolder(world.getWorldFolder()); + datapackFolders.add(datapacksFolder.getAbsolutePath()); + return new WorldInspection( + world.getName(), + WorldLifecycleService.get().backendNameForWorld(world.getName()), + WorldRuntimeControlService.get().backendName(), + studio, + engineClosed, + engineFailing, + generationSessionId, + activeLeases, + List.copyOf(datapackFolders), + IrisToolbelt.isWorldMaintenanceActive(world) + ); + } + + private void executeFullSmoke( + SmokeDiagnosticsService.SmokeRunHandle handle, + VolmitSender sender, + String dimensionKey, + long seed, + String playerName, + boolean retainOnFailure + ) { + try { + handle.stage("create"); + executeCreateSmoke(handle, sender, dimensionKey, seed, false, false); + handle.note("create smoke complete"); + + handle.stage("benchmark"); + executeCreateSmoke(handle, sender, dimensionKey, seed, true, false); + handle.note("benchmark smoke complete"); + + handle.stage("studio"); + executeStudioSmoke(handle, sender, dimensionKey, seed, playerName, retainOnFailure, false); + handle.note("studio smoke complete"); + + handle.completeSuccess("cleanup", true); + } catch (Throwable e) { + handle.completeFailure("cleanup", e, !retainOnFailure); + } + } + + private void executeCreateSmoke( + SmokeDiagnosticsService.SmokeRunHandle handle, + VolmitSender sender, + String dimensionKey, + long seed, + boolean benchmark, + boolean completeHandle + ) { + String worldName = nextWorldName(benchmark ? "benchmark" : "create"); + handle.setWorldName(worldName); + cleanupTransientPrefix("iris-smoke-"); + World world = null; + PlatformChunkGenerator provider = null; + boolean cleanupApplied = false; + try { + IrisCreator creator = IrisToolbelt.createWorld() + .dimension(dimensionKey) + .name(worldName) + .seed(seed) + .sender(sender) + .studio(false) + .benchmark(benchmark) + .studioProgressConsumer((progress, stage) -> handle.stage(mapCreateStage(stage))); + world = creator.create(); + provider = IrisToolbelt.access(world); + handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName())); + handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName()); + handle.setDatapackReadiness(creator.getLastDatapackReadinessResult()); + captureGenerationSession(provider, handle); + + if (benchmark) { + handle.stage("apply_world_rules"); + WorldRuntimeControlService.get().applyStudioWorldRules(world); + } + + handle.stage("cleanup"); + cleanupWorld(world, worldName); + cleanupApplied = true; + if (completeHandle) { + handle.completeSuccess("cleanup", true); + } + } catch (Throwable e) { + Iris.reportError("Smoke create failed for world \"" + worldName + "\".", e); + if (!handle.snapshot().isRetainOnFailure()) { + try { + cleanupWorld(world, worldName); + cleanupApplied = true; + } catch (Throwable cleanupError) { + Iris.reportError("Smoke cleanup failed for world \"" + worldName + "\".", cleanupError); + } + } + if (completeHandle) { + handle.completeFailure("cleanup", e, cleanupApplied); + } else { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + + throw new RuntimeException(e); + } + } + } + + private void executeStudioSmoke( + SmokeDiagnosticsService.SmokeRunHandle handle, + VolmitSender sender, + String dimensionKey, + long seed, + String playerName, + boolean retainOnFailure, + boolean completeHandle + ) { + String worldName = nextWorldName("studio"); + handle.setWorldName(worldName); + cleanupTransientPrefix("iris-smoke-"); + World world = null; + boolean cleanupApplied = false; + CompletableFuture future = StudioOpenCoordinator.get().open( + new StudioOpenCoordinator.StudioOpenRequest( + dimensionKey, + null, + sender, + seed, + worldName, + playerName, + false, + retainOnFailure, + SmokeDiagnosticsService.SmokeRunMode.STUDIO, + handle, + completeHandle, + update -> handle.stage(update.stage()), + openedWorld -> { + } + ) + ); + try { + StudioOpenCoordinator.StudioOpenResult result = future.join(); + world = result == null ? null : result.world(); + handle.stage("cleanup"); + cleanupWorld(world, worldName); + cleanupApplied = true; + if (completeHandle) { + handle.completeSuccess("cleanup", true); + } + } catch (Throwable e) { + if (world != null && !cleanupApplied) { + try { + cleanupWorld(world, worldName); + cleanupApplied = true; + } catch (Throwable cleanupError) { + Iris.reportError("Smoke cleanup failed for world \"" + worldName + "\".", cleanupError); + } + } + if (completeHandle && !"failed".equalsIgnoreCase(handle.snapshot().getOutcome())) { + handle.completeFailure("cleanup", e, cleanupApplied); + } + if (!completeHandle) { + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + + throw new RuntimeException(e); + } + } + } + + private void captureGenerationSession(PlatformChunkGenerator provider, SmokeDiagnosticsService.SmokeRunHandle handle) { + if (provider == null || provider.getEngine() == null) { + return; + } + + if (provider.getEngine() instanceof IrisEngine irisEngine) { + handle.setGenerationSession(irisEngine.getGenerationSessionId(), irisEngine.getGenerationSessions().activeLeases()); + } + } + + private void cleanupWorld(World world, String worldName) { + if (world != null) { + PlatformChunkGenerator provider = IrisToolbelt.access(world); + if (provider != null) { + provider.close(); + } + WorldLifecycleService.get().unload(world, false); + } + + File container = Bukkit.getWorldContainer(); + deleteFolder(new File(container, worldName), worldName); + deleteFolder(new File(container, worldName + "_nether"), null); + deleteFolder(new File(container, worldName + "_the_end"), null); + } + + private void deleteFolder(File folder, String worldName) { + if (folder == null) { + return; + } + + IO.delete(folder); + if (!folder.exists()) { + return; + } + + if (worldName == null) { + return; + } + + try { + Iris.queueWorldDeletionOnStartup(Collections.singleton(worldName)); + } catch (IOException e) { + Iris.reportError("Failed to queue smoke world deletion for \"" + worldName + "\".", e); + } + } + + private void cleanupTransientPrefix(String prefix) { + File container = Bukkit.getWorldContainer(); + File[] children = container.listFiles(); + if (children == null) { + return; + } + + for (File child : children) { + if (!child.isDirectory()) { + continue; + } + if (!child.getName().startsWith(prefix)) { + continue; + } + if (Bukkit.getWorld(child.getName()) != null) { + continue; + } + IO.delete(child); + } + } + + private String nextWorldName(String mode) { + return "iris-smoke-" + mode + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private String normalizePlayerName(String playerName) { + if (playerName == null) { + return null; + } + + String trimmed = playerName.trim(); + if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("none")) { + return null; + } + + return trimmed; + } + + private String mapCreateStage(String stage) { + if (stage == null || stage.isBlank()) { + return "create_world"; + } + + String normalized = stage.trim().toLowerCase(); + return switch (normalized) { + case "resolve_dimension", "resolving dimension" -> "resolve_dimension"; + case "prepare_world_pack", "preparing world pack" -> "prepare_world_pack"; + case "install_datapacks", "installing datapacks" -> "install_datapacks"; + case "create_world", "creating world", "world created" -> "create_world"; + default -> normalized.replace(' ', '_'); + }; + } + + public record WorldInspection( + String worldName, + String lifecycleBackend, + String runtimeBackend, + boolean studio, + boolean engineClosed, + boolean engineFailing, + long generationSessionId, + int activeLeaseCount, + List datapackFolders, + boolean maintenanceActive + ) { + } +} 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 new file mode 100644 index 000000000..79ae63b49 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -0,0 +1,660 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.tools.IrisCreator; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.exceptions.IrisException; +import art.arcane.volmlib.util.io.IO; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +public final class StudioOpenCoordinator { + private static volatile StudioOpenCoordinator instance; + + private final SmokeDiagnosticsService diagnostics; + + private StudioOpenCoordinator() { + this.diagnostics = SmokeDiagnosticsService.get(); + } + + public static StudioOpenCoordinator get() { + StudioOpenCoordinator current = instance; + if (current != null) { + return current; + } + + synchronized (StudioOpenCoordinator.class) { + if (instance != null) { + return instance; + } + + instance = new StudioOpenCoordinator(); + return instance; + } + } + + public CompletableFuture open(StudioOpenRequest request) { + CompletableFuture future = new CompletableFuture<>(); + J.aBukkit(() -> executeOpen(request, future)); + return future; + } + + public CompletableFuture closeProject(IrisProject project) { + CompletableFuture future = new CompletableFuture<>(); + J.aBukkit(() -> future.complete(executeClose(project))); + return future; + } + + private StudioCloseResult executeClose(IrisProject project) { + if (project == null) { + return new StudioCloseResult(null, true, true, false, null, null); + } + + PlatformChunkGenerator provider = project.getActiveProvider(); + if (provider == null) { + return new StudioCloseResult(null, true, true, false, null, null); + } + + World world = provider.getTarget().getWorld().realWorld(); + String worldName = world == null ? provider.getTarget().getWorld().name() : world.getName(); + SmokeDiagnosticsService.SmokeRunHandle handle = diagnostics.beginRun( + SmokeDiagnosticsService.SmokeRunMode.STUDIO_CLOSE, + worldName, + true, + true, + null, + false + ); + StudioCloseResult result; + try { + handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName()); + if (world != null) { + handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName())); + captureGenerationSession(provider, handle); + } + result = closeWorld(provider, worldName, world, true, handle, project); + handle.setCloseState(result.unloadCompletedLive(), result.folderDeletionCompletedLive(), result.startupCleanupQueued()); + if (result.failureCause() != null) { + handle.completeFailure("finalize_close", result.failureCause(), result.folderDeletionCompletedLive() || result.startupCleanupQueued()); + } else { + handle.completeSuccess("finalize_close", result.folderDeletionCompletedLive() || result.startupCleanupQueued()); + } + } catch (Throwable e) { + project.setActiveProvider(null); + handle.completeFailure("finalize_close", e, false); + result = new StudioCloseResult(worldName, false, false, false, e, handle.runId()); + } + + return result; + } + + private void executeOpen(StudioOpenRequest request, CompletableFuture future) { + boolean ownsHandle = request.runHandle() == null; + SmokeDiagnosticsService.SmokeRunHandle handle = ownsHandle + ? diagnostics.beginRun( + request.mode(), + request.worldName(), + true, + request.playerName() == null || request.playerName().isBlank(), + request.playerName(), + request.retainOnFailure() + ) + : request.runHandle(); + World world = null; + PlatformChunkGenerator provider = null; + boolean cleanupApplied = false; + try { + updateStage(handle, request, "resolve_dimension", 0.04D); + if (IrisToolbelt.getDimension(request.dimensionKey()) == null) { + throw new IrisException("Dimension cannot be found for id " + request.dimensionKey() + "."); + } + + updateStage(handle, request, "prepare_world_pack", 0.10D); + cleanupStaleTransientWorlds(request.worldName()); + + updateStage(handle, request, "install_datapacks", 0.18D); + IrisCreator creator = IrisToolbelt.createWorld() + .seed(request.seed()) + .sender(request.sender()) + .studio(true) + .name(request.worldName()) + .dimension(request.dimensionKey()) + .studioProgressConsumer((progress, stage) -> updateStage(handle, request, mapCreatorStage(stage), progress)); + world = creator.create(); + provider = IrisToolbelt.access(world); + if (provider == null) { + throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\"."); + } + + handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName())); + handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName()); + handle.setDatapackReadiness(creator.getLastDatapackReadinessResult()); + captureGenerationSession(provider, handle); + + updateStage(handle, request, "apply_world_rules", 0.72D); + WorldRuntimeControlService.get().applyStudioWorldRules(world); + + updateStage(handle, request, "prepare_generator", 0.78D); + WorldRuntimeControlService.get().prepareGenerator(world); + + Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world); + if (entryAnchor == null) { + throw new IllegalStateException("Studio entry anchor could not be resolved."); + } + + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(120L); + updateStage(handle, request, "request_entry_chunk", 0.84D); + requestEntryChunk(world, entryAnchor, deadline, handle); + + updateStage(handle, request, "resolve_safe_entry", 0.90D); + Location safeEntry = resolveSafeEntry(world, entryAnchor, deadline); + if (safeEntry == null) { + throw new IllegalStateException("Studio safe entry resolution timed out."); + } + + if (request.playerName() != null && !request.playerName().isBlank()) { + updateStage(handle, request, "teleport_player", 0.96D); + Player player = resolvePlayer(request.playerName()); + if (player == null) { + throw new IllegalStateException("Player \"" + request.playerName() + "\" is not online."); + } + + long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); + Boolean teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(remaining, TimeUnit.MILLISECONDS); + if (!Boolean.TRUE.equals(teleported)) { + throw new IllegalStateException("Studio teleport did not complete successfully."); + } + } + + updateStage(handle, request, "finalize_open", 1.00D); + if (request.project() != null) { + request.project().setActiveProvider(provider); + } + if (request.openWorkspace() && request.project() != null) { + request.project().openVSCode(request.sender()); + } + if (request.onDone() != null) { + request.onDone().accept(world); + } + + if (request.completeHandle()) { + handle.completeSuccess("finalize_open", false); + } else { + handle.stage("finalize_open"); + } + future.complete(new StudioOpenResult(world, handle.runId(), safeEntry, creator.getLastDatapackReadinessResult())); + } catch (Throwable e) { + Iris.reportError("Studio open failed for world \"" + request.worldName() + "\".", e); + if (!request.retainOnFailure()) { + try { + updateStage(handle, request, "cleanup", 1.00D); + StudioCloseResult cleanupResult = closeWorld(provider, request.worldName(), world, true, handle, request.project()); + cleanupApplied = cleanupResult.folderDeletionCompletedLive() || cleanupResult.startupCleanupQueued(); + } catch (Throwable cleanupError) { + Iris.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError); + } + } + if (request.completeHandle()) { + handle.completeFailure("cleanup", e, cleanupApplied); + } else { + handle.stage("cleanup", String.valueOf(e.getMessage())); + } + future.completeExceptionally(e); + } + } + + private void requestEntryChunk(World world, Location entryAnchor, long deadline, SmokeDiagnosticsService.SmokeRunHandle handle) throws Exception { + int chunkX = entryAnchor.getBlockX() >> 4; + int chunkZ = entryAnchor.getBlockZ() >> 4; + handle.setEntryChunk(chunkX, chunkZ); + long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); + waitForEntryChunk(world, chunkX, chunkZ, deadline, null).get(remaining, TimeUnit.MILLISECONDS); + } + + private Location resolveSafeEntry(World world, Location entryAnchor, long deadline) throws Exception { + long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); + return waitForSafeEntry(world, entryAnchor, deadline, null).get(remaining, TimeUnit.MILLISECONDS); + } + + private StudioCloseResult closeWorld( + PlatformChunkGenerator provider, + String worldName, + World world, + boolean deleteFolder, + SmokeDiagnosticsService.SmokeRunHandle handle, + IrisProject project + ) { + Throwable failure = null; + boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName); + boolean folderDeletionCompletedLive = !deleteFolder; + boolean startupCleanupQueued = false; + CompletableFuture closeFuture = provider == null ? CompletableFuture.completedFuture(null) : CompletableFuture.completedFuture(null); + + updateCloseStage(handle, "prepare_close"); + if (world != null) { + handle.setWorldName(world.getName()); + handle.setLifecycleBackend(WorldLifecycleService.get().backendNameForWorld(world.getName())); + handle.setRuntimeBackend(WorldRuntimeControlService.get().backendName()); + captureGenerationSession(provider, handle); + } + + if (world != null) { + updateCloseStage(handle, "evacuate_players"); + try { + evacuatePlayers(world); + } catch (Throwable e) { + failure = e; + } + } + + if (world != null) { + IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); + } + + try { + updateCloseStage(handle, "seal_runtime"); + if (project != null) { + project.setActiveProvider(null); + } + if (provider != null) { + captureGenerationSession(provider, handle); + closeFuture = provider.closeAsync(); + } + + updateCloseStage(handle, "request_unload"); + if (worldName != null && !worldName.isBlank()) { + requestWorldFamilyUnload(worldName); + } + + updateCloseStage(handle, "await_unload"); + if (worldName != null && !worldName.isBlank()) { + long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L); + CompletableFuture unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline); + try { + unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS); + unloadCompletedLive = true; + } catch (TimeoutException e) { + unloadCompletedLive = !isWorldFamilyLoaded(worldName); + } catch (Throwable e) { + failure = failure == null ? unwrapFailure(e) : failure; + } + } + + try { + closeFuture.get(20L, TimeUnit.SECONDS); + } catch (Throwable e) { + Throwable cause = unwrapFailure(e); + if (failure == null) { + failure = cause; + } + } + + if (deleteFolder && worldName != null && !worldName.isBlank()) { + updateCloseStage(handle, "delete_world_family"); + WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive); + folderDeletionCompletedLive = deleteResult.liveDeleted(); + startupCleanupQueued = deleteResult.startupCleanupQueued(); + } + + updateCloseStage(handle, "finalize_close"); + } finally { + if (world != null) { + IrisToolbelt.endWorldMaintenance(world, "studio-close"); + } + } + + handle.setCloseState(unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued); + return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure, handle.runId()); + } + + private void evacuatePlayers(World world) throws Exception { + if (world == null) { + return; + } + + CompletableFuture future = J.sfut(() -> { + IrisToolbelt.evacuate(world); + return null; + }); + if (future != null) { + future.get(10L, TimeUnit.SECONDS); + } + } + + private void requestWorldFamilyUnload(String worldName) { + if (worldName == null || worldName.isBlank()) { + return; + } + + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + World familyWorld = Bukkit.getWorld(familyWorldName); + if (familyWorld == null) { + continue; + } + + Iris.linkMultiverseCore.removeFromConfig(familyWorld); + WorldLifecycleService.get().unload(familyWorld, false); + } + } + + private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) { + if (worldName == null || worldName.isBlank()) { + return new WorldFamilyDeleteResult(true, false); + } + + File container = Bukkit.getWorldContainer(); + boolean liveDeleted = true; + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + File folder = new File(container, familyWorldName); + if (!folder.exists()) { + continue; + } + + try { + deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS); + } catch (Throwable e) { + liveDeleted = false; + Iris.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e)); + } + + if (folder.exists()) { + liveDeleted = false; + } + } + + if (liveDeleted) { + return new WorldFamilyDeleteResult(true, false); + } + + try { + Iris.queueWorldDeletionOnStartup(Collections.singleton(worldName)); + return new WorldFamilyDeleteResult(false, true); + } catch (IOException e) { + if (unloadCompletedLive) { + Iris.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e); + } + return new WorldFamilyDeleteResult(false, false); + } + } + + private void cleanupStaleTransientWorlds(String worldName) { + File container = Bukkit.getWorldContainer(); + LinkedHashSet staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(container); + String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName); + if (requestedBaseName != null) { + staleWorldNames.add(requestedBaseName); + } + + for (String staleWorldName : staleWorldNames) { + if (Bukkit.getWorld(staleWorldName) != null) { + continue; + } + + deleteWorldFamily(staleWorldName, true); + } + } + + private void captureGenerationSession(PlatformChunkGenerator provider, SmokeDiagnosticsService.SmokeRunHandle handle) { + if (provider == null || provider.getEngine() == null) { + return; + } + + if (provider.getEngine() instanceof IrisEngine irisEngine) { + handle.setGenerationSession(irisEngine.getGenerationSessionId(), irisEngine.getGenerationSessions().activeLeases()); + } + } + + private void updateStage(SmokeDiagnosticsService.SmokeRunHandle handle, StudioOpenRequest request, String stage, double progress) { + handle.stage(stage); + if (request.progressConsumer() != null) { + request.progressConsumer().accept(new StudioOpenProgress(progress, stage)); + } + } + + private String mapCreatorStage(String stage) { + if (stage == null || stage.isBlank()) { + return "create_world"; + } + + String normalized = stage.trim().toLowerCase(); + return switch (normalized) { + case "resolve_dimension", "resolving dimension" -> "resolve_dimension"; + case "prepare_world_pack", "preparing world pack" -> "prepare_world_pack"; + case "install_datapacks", "installing datapacks", "datapacks ready" -> "install_datapacks"; + case "create_world", "creating world", "world created" -> "create_world"; + default -> normalized.replace(' ', '_'); + }; + } + + private CompletableFuture waitForEntryChunk(World world, int chunkX, int chunkZ, long deadline, Throwable lastFailure) { + long now = System.currentTimeMillis(); + if (now >= deadline) { + return CompletableFuture.failedFuture(timeoutFailure("Studio entry chunk request timed out.", lastFailure)); + } + + long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L); + CompletableFuture request = withAttemptTimeout( + WorldRuntimeControlService.get().requestChunkAsync(world, chunkX, chunkZ, true), + attemptTimeout, + "Studio entry chunk request attempt timed out." + ); + return request.handle((chunk, throwable) -> { + if (throwable == null && world.isChunkLoaded(chunkX, chunkZ)) { + return CompletableFuture.completedFuture(null); + } + + Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable); + if (System.currentTimeMillis() >= deadline) { + return CompletableFuture.failedFuture(timeoutFailure("Studio entry chunk request timed out.", nextFailure)); + } + + return delayFuture(1000L).thenCompose(ignored -> waitForEntryChunk(world, chunkX, chunkZ, deadline, nextFailure)); + }).thenCompose(next -> next); + } + + private CompletableFuture waitForSafeEntry(World world, Location entryAnchor, long deadline, Throwable lastFailure) { + long now = System.currentTimeMillis(); + if (now >= deadline) { + return CompletableFuture.failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", lastFailure)); + } + + long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L); + CompletableFuture resolve = withAttemptTimeout( + WorldRuntimeControlService.get().resolveSafeEntry(world, entryAnchor), + attemptTimeout, + "Studio safe-entry resolution attempt timed out." + ); + return resolve.handle((location, throwable) -> { + if (throwable == null && location != null) { + return CompletableFuture.completedFuture(location); + } + + Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable); + if (System.currentTimeMillis() >= deadline) { + return CompletableFuture.failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", nextFailure)); + } + + return delayFuture(250L).thenCompose(ignored -> waitForSafeEntry(world, entryAnchor, deadline, nextFailure)); + }).thenCompose(next -> next); + } + + private CompletableFuture waitForWorldFamilyUnload(String worldName, long deadline) { + if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) { + return CompletableFuture.completedFuture(null); + } + + return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline)); + } + + private CompletableFuture deleteWorldFolderAsync(File folder, int attemptsRemaining) { + if (folder == null || !folder.exists()) { + return CompletableFuture.completedFuture(null); + } + + IO.delete(folder); + if (!folder.exists()) { + return CompletableFuture.completedFuture(null); + } + + if (attemptsRemaining <= 1) { + return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath())); + } + + return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1)); + } + + private CompletableFuture delayFuture(long delayMillis) { + long safeDelay = Math.max(0L, delayMillis); + return CompletableFuture.runAsync(() -> { + }, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS)); + } + + private CompletableFuture withAttemptTimeout(CompletableFuture source, long timeoutMillis, String message) { + CompletableFuture future = new CompletableFuture<>(); + source.whenComplete((value, throwable) -> { + if (throwable != null) { + future.completeExceptionally(unwrapFailure(throwable)); + return; + } + + future.complete(value); + }); + delayFuture(timeoutMillis).whenComplete((ignored, throwable) -> { + if (!future.isDone()) { + future.completeExceptionally(new TimeoutException(message)); + } + }); + return future; + } + + private IllegalStateException timeoutFailure(String message, Throwable lastFailure) { + if (lastFailure == null) { + return new IllegalStateException(message); + } + + return new IllegalStateException(message, lastFailure); + } + + private Throwable unwrapFailure(Throwable throwable) { + Throwable cursor = throwable; + while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { + if (cursor.getCause() == null) { + break; + } + + cursor = cursor.getCause(); + } + + return cursor; + } + + private Player resolvePlayer(String playerName) { + Player exact = Bukkit.getPlayerExact(playerName); + if (exact != null) { + return exact; + } + + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().equalsIgnoreCase(playerName)) { + return player; + } + } + + return null; + } + + private void updateCloseStage(SmokeDiagnosticsService.SmokeRunHandle handle, String stage) { + handle.stage(stage); + } + + private boolean isWorldFamilyLoaded(String worldName) { + if (worldName == null || worldName.isBlank()) { + return false; + } + + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + if (Bukkit.getWorld(familyWorldName) != null) { + return true; + } + } + + return false; + } + + public record StudioOpenRequest( + String dimensionKey, + IrisProject project, + VolmitSender sender, + long seed, + String worldName, + String playerName, + boolean openWorkspace, + boolean retainOnFailure, + SmokeDiagnosticsService.SmokeRunMode mode, + SmokeDiagnosticsService.SmokeRunHandle runHandle, + boolean completeHandle, + Consumer progressConsumer, + Consumer onDone + ) { + public static StudioOpenRequest studioProject(IrisProject project, VolmitSender sender, long seed, Consumer progressConsumer, Consumer onDone) { + String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null; + return new StudioOpenRequest( + project.getName(), + project, + sender, + seed, + "iris-" + UUID.randomUUID(), + playerName, + true, + false, + SmokeDiagnosticsService.SmokeRunMode.STUDIO_OPEN, + null, + true, + progressConsumer, + onDone + ); + } + } + + public record StudioOpenProgress(double progress, String stage) { + } + + public record StudioOpenResult(World world, String runId, Location entryLocation, DatapackReadinessResult datapackReadiness) { + } + + public record StudioCloseResult( + String worldName, + boolean unloadCompletedLive, + boolean folderDeletionCompletedLive, + boolean startupCleanupQueued, + Throwable failureCause, + String runId + ) { + public boolean successful() { + return failureCause == null; + } + } + + private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java b/core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java new file mode 100644 index 000000000..9549ba9dd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java @@ -0,0 +1,91 @@ +package art.arcane.iris.core.runtime; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +public final class TransientWorldCleanupSupport { + private static final Pattern TRANSIENT_STUDIO_WORLD_PATTERN = Pattern.compile("^iris-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); + + private TransientWorldCleanupSupport() { + } + + public static boolean isTransientStudioWorldName(String worldName) { + return transientStudioBaseWorldName(worldName) != null; + } + + public static String transientStudioBaseWorldName(String worldName) { + if (worldName == null || worldName.isBlank()) { + return null; + } + + String candidate = worldName.trim(); + if (candidate.endsWith("_nether")) { + candidate = candidate.substring(0, candidate.length() - "_nether".length()); + } else if (candidate.endsWith("_the_end")) { + candidate = candidate.substring(0, candidate.length() - "_the_end".length()); + } + + if (!TRANSIENT_STUDIO_WORLD_PATTERN.matcher(candidate).matches()) { + return null; + } + + return candidate; + } + + public static List worldFamilyNames(String worldName) { + ArrayList names = new ArrayList<>(); + String normalized = normalizeWorldName(worldName); + if (normalized == null) { + return names; + } + + names.add(normalized); + names.add(normalized + "_nether"); + names.add(normalized + "_the_end"); + return names; + } + + public static LinkedHashSet collectTransientStudioWorldNames(File worldContainer) { + LinkedHashSet names = new LinkedHashSet<>(); + if (worldContainer == null) { + return names; + } + + File[] children = worldContainer.listFiles(); + if (children == null) { + return names; + } + + for (File child : children) { + if (child == null || !child.isDirectory()) { + continue; + } + + String baseName = transientStudioBaseWorldName(child.getName()); + if (baseName == null) { + continue; + } + + names.add(baseName); + } + + return names; + } + + private static String normalizeWorldName(String worldName) { + if (worldName == null) { + return null; + } + + String normalized = worldName.trim(); + if (normalized.isEmpty()) { + return null; + } + + return normalized.toLowerCase(Locale.ROOT).equals(normalized) ? normalized : normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java new file mode 100644 index 000000000..7721ce82f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java @@ -0,0 +1,21 @@ +package art.arcane.iris.core.runtime; + +import org.bukkit.Chunk; +import org.bukkit.World; + +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; + +interface WorldRuntimeControlBackend { + String backendName(); + + String describeCapabilities(); + + OptionalLong readDayTime(World world); + + boolean writeDayTime(World world, long dayTime) throws ReflectiveOperationException; + + void syncTime(World world); + + CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate); +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java new file mode 100644 index 000000000..61cde3b0c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -0,0 +1,483 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import art.arcane.iris.core.lifecycle.ServerFamily; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.service.BoardSVC; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.scheduling.J; +import io.papermc.lib.PaperLib; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.GameRule; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.world.TimeSkipEvent; +import org.bukkit.plugin.PluginManager; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +public final class WorldRuntimeControlService { + private static volatile WorldRuntimeControlService instance; + + private final CapabilitySnapshot capabilities; + private final WorldRuntimeControlBackend backend; + private final String capabilityDescription; + + private WorldRuntimeControlService(CapabilitySnapshot capabilities) { + this.capabilities = capabilities; + this.backend = selectBackend(capabilities); + this.capabilityDescription = "family=" + capabilities.serverFamily().id() + + ", backend=" + backend.backendName() + + ", " + backend.describeCapabilities(); + } + + public static WorldRuntimeControlService get() { + WorldRuntimeControlService current = instance; + if (current != null) { + return current; + } + + synchronized (WorldRuntimeControlService.class) { + if (instance != null) { + return instance; + } + + CapabilitySnapshot capabilities = WorldLifecycleService.get().capabilities(); + instance = new WorldRuntimeControlService(capabilities); + Iris.info("WorldRuntimeControl capabilities: %s", instance.capabilityDescription); + return instance; + } + } + + public String backendName() { + return backend.backendName(); + } + + public String capabilityDescription() { + return capabilityDescription; + } + + public OptionalLong readDayTime(World world) { + return backend.readDayTime(world); + } + + public boolean applyStudioWorldRules(World world) { + if (world == null) { + return false; + } + + Iris.linkMultiverseCore.removeFromConfig(world); + if (!IrisSettings.get().getStudio().isDisableTimeAndWeather()) { + return true; + } + + setBooleanGameRule(world, false, "ADVANCE_WEATHER", "DO_WEATHER_CYCLE", "WEATHER_CYCLE", "doWeatherCycle", "weatherCycle"); + setBooleanGameRule(world, false, "ADVANCE_TIME", "DO_DAYLIGHT_CYCLE", "DAYLIGHT_CYCLE", "doDaylightCycle", "daylightCycle"); + applyNoonTimeLock(world); + return true; + } + + public boolean applyNoonTimeLock(World world) { + if (world == null) { + return false; + } + + if (!hasMutableClock(world)) { + return false; + } + + OptionalLong currentTime = readDayTime(world); + if (currentTime.isEmpty()) { + return false; + } + + long skipAmount = (6000L - currentTime.getAsLong()) % 24000L; + if (skipAmount < 0L) { + skipAmount += 24000L; + } + + TimeSkipEvent event = new TimeSkipEvent(world, TimeSkipEvent.SkipReason.CUSTOM, skipAmount); + PluginManager pluginManager = Bukkit.getPluginManager(); + if (pluginManager != null) { + pluginManager.callEvent(event); + } + if (event.isCancelled()) { + return false; + } + + try { + boolean written = backend.writeDayTime(world, currentTime.getAsLong() + event.getSkipAmount()); + if (!written) { + return false; + } + backend.syncTime(world); + return true; + } catch (Throwable e) { + Iris.reportError("Runtime time control failed for world \"" + world.getName() + "\".", e); + return false; + } + } + + public CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) { + return backend.requestChunkAsync(world, chunkX, chunkZ, generate); + } + + public void prepareGenerator(World world) { + if (world == null) { + return; + } + + try { + art.arcane.iris.engine.platform.PlatformChunkGenerator provider = art.arcane.iris.core.tools.IrisToolbelt.access(world); + if (provider == null) { + return; + } + + art.arcane.iris.engine.framework.Engine engine = provider.getEngine(); + if (engine == null) { + return; + } + + engine.getMantle().getComponents(); + engine.getMantle().getRealRadius(); + } catch (Throwable e) { + Iris.reportError("Failed to prepare generator state for world \"" + world.getName() + "\".", e); + } + } + + public Location resolveEntryAnchor(World world) { + if (world == null) { + return null; + } + + PlatformChunkGenerator provider = IrisToolbelt.access(world); + return resolveEntryAnchor(world, provider); + } + + static Location resolveEntryAnchor(World world, PlatformChunkGenerator provider) { + if (world == null) { + return null; + } + + if (provider != null && provider.isStudio()) { + Location initialSpawn = provider.getInitialSpawnLocation(world); + if (initialSpawn != null) { + return initialSpawn.clone(); + } + } + + Location spawnLocation = world.getSpawnLocation(); + if (spawnLocation != null) { + return spawnLocation.clone(); + } + + int minY = world.getMinHeight() + 1; + int y = Math.max(minY, 96); + return new Location(world, 0.5D, y, 0.5D); + } + + public CompletableFuture resolveSafeEntry(World world, Location source) { + if (world == null || source == null) { + return CompletableFuture.completedFuture(null); + } + + int chunkX = source.getBlockX() >> 4; + int chunkZ = source.getBlockZ() >> 4; + return requestChunkAsync(world, chunkX, chunkZ, true).thenCompose(chunk -> { + CompletableFuture future = new CompletableFuture<>(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + future.complete(findTopSafeLocation(world, source)); + } catch (Throwable e) { + future.completeExceptionally(e); + } + }); + if (!scheduled) { + return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule safe-entry surface resolve for " + world.getName() + "@" + chunkX + "," + chunkZ + ".")); + } + + return future; + }); + } + + public CompletableFuture teleport(Player player, Location location) { + if (player == null || location == null) { + return CompletableFuture.completedFuture(false); + } + + CompletableFuture future = new CompletableFuture<>(); + boolean scheduled = J.runEntity(player, () -> { + CompletableFuture teleportFuture = PaperLib.teleportAsync(player, location); + if (teleportFuture == null) { + future.complete(false); + return; + } + + teleportFuture.whenComplete((success, throwable) -> { + if (throwable != null) { + future.completeExceptionally(throwable); + return; + } + + if (Boolean.TRUE.equals(success)) { + J.runEntity(player, () -> Iris.service(BoardSVC.class).updatePlayer(player)); + future.complete(true); + return; + } + + future.complete(false); + }); + }); + if (!scheduled) { + return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule teleport for " + player.getName() + ".")); + } + + return future; + } + + public boolean hasMutableClock(World world) { + try { + Object handle = invokeNoArg(world, "getHandle"); + if (handle == null) { + return false; + } + + Object dimensionTypeHolder = invokeNoArg(handle, "dimensionTypeRegistration"); + Object dimensionType = unwrapDimensionType(dimensionTypeHolder); + if (dimensionType == null) { + return false; + } + + return !dimensionTypeHasFixedTime(dimensionType); + } catch (Throwable e) { + return false; + } + } + + private static WorldRuntimeControlBackend selectBackend(CapabilitySnapshot capabilities) { + ServerFamily family = capabilities.serverFamily(); + if (family.isPaperLike()) { + return new PaperLikeRuntimeControlBackend(capabilities); + } + + return new BukkitPublicRuntimeControlBackend(capabilities); + } + + static Location findTopSafeLocation(World world, Location source) { + int x = source.getBlockX(); + int z = source.getBlockZ(); + float yaw = source.getYaw(); + float pitch = source.getPitch(); + + for (int y : buildSafeLocationScanOrder(world, source)) { + if (isSafeStandingLocation(world, x, y, z)) { + return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); + } + } + + return null; + } + + static int[] buildSafeLocationScanOrder(World world, Location source) { + int x = source.getBlockX(); + int z = source.getBlockZ(); + int minY = world.getMinHeight() + 1; + int maxY = world.getMaxHeight() - 2; + int highestY = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(x, z) + 1)); + int[] scanOrder = new int[maxY - minY + 1]; + int index = 0; + + for (int y = highestY; y >= minY; y--) { + scanOrder[index++] = y; + } + + for (int y = highestY + 1; y <= maxY; y++) { + scanOrder[index++] = y; + } + + return scanOrder; + } + + private static boolean isSafeStandingLocation(World world, int x, int y, int z) { + if (y <= world.getMinHeight() || y >= world.getMaxHeight() - 1) { + return false; + } + + Block below = world.getBlockAt(x, y - 1, z); + Block feet = world.getBlockAt(x, y, z); + Block head = world.getBlockAt(x, y + 1, z); + Material belowType = below.getType(); + if (!belowType.isSolid()) { + return false; + } + if (Tag.LEAVES.isTagged(belowType)) { + return false; + } + if (belowType == Material.LAVA + || belowType == Material.MAGMA_BLOCK + || belowType == Material.FIRE + || belowType == Material.SOUL_FIRE + || belowType == Material.CAMPFIRE + || belowType == Material.SOUL_CAMPFIRE) { + return false; + } + if (feet.getType().isSolid() || head.getType().isSolid()) { + return false; + } + if (feet.isLiquid() || head.isLiquid()) { + return false; + } + + return true; + } + + @SuppressWarnings("unchecked") + private static void setBooleanGameRule(World world, boolean value, String... names) { + GameRule gameRule = resolveBooleanGameRule(world, names); + if (gameRule != null) { + world.setGameRule(gameRule, value); + } + } + + @SuppressWarnings("unchecked") + private static GameRule resolveBooleanGameRule(World world, String... names) { + if (world == null || names == null || names.length == 0) { + return null; + } + + Set candidates = buildRuleNameCandidates(names); + for (String name : candidates) { + if (name == null || name.isBlank()) { + continue; + } + + try { + Field field = GameRule.class.getField(name); + Object value = field.get(null); + if (value instanceof GameRule gameRule && Boolean.class.equals(gameRule.getType())) { + return (GameRule) gameRule; + } + } catch (Throwable ignored) { + } + + try { + GameRule byName = GameRule.getByName(name); + if (byName != null && Boolean.class.equals(byName.getType())) { + return (GameRule) byName; + } + } catch (Throwable ignored) { + } + } + + String[] availableRules = world.getGameRules(); + if (availableRules == null || availableRules.length == 0) { + return null; + } + + Set normalizedCandidates = new LinkedHashSet<>(); + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + normalizedCandidates.add(normalizeRuleName(candidate)); + } + } + + for (String availableRule : availableRules) { + String normalizedAvailable = normalizeRuleName(availableRule); + if (!normalizedCandidates.contains(normalizedAvailable)) { + continue; + } + + try { + GameRule byName = GameRule.getByName(availableRule); + if (byName != null && Boolean.class.equals(byName.getType())) { + return (GameRule) byName; + } + } catch (Throwable ignored) { + } + } + + return null; + } + + private static Set buildRuleNameCandidates(String... names) { + Set candidates = new LinkedHashSet<>(); + for (String name : names) { + if (name == null || name.isBlank()) { + continue; + } + + candidates.add(name); + candidates.add(name.toUpperCase()); + candidates.add(name.toLowerCase()); + } + + return candidates; + } + + private static String normalizeRuleName(String name) { + if (name == null) { + return ""; + } + + StringBuilder builder = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char current = name.charAt(i); + if (Character.isLetterOrDigit(current)) { + builder.append(Character.toLowerCase(current)); + } + } + return builder.toString(); + } + + private static boolean dimensionTypeHasFixedTime(Object dimensionType) throws ReflectiveOperationException { + Object fixedTimeFlag; + try { + fixedTimeFlag = invokeNoArg(dimensionType, "hasFixedTime"); + } catch (NoSuchMethodException ignored) { + Object fixedTime = invokeNoArg(dimensionType, "fixedTime"); + if (fixedTime instanceof OptionalLong optionalLong) { + return optionalLong.isPresent(); + } + if (fixedTime instanceof Optional optional) { + return optional.isPresent(); + } + return false; + } + + return fixedTimeFlag instanceof Boolean && (Boolean) fixedTimeFlag; + } + + private static Object unwrapDimensionType(Object dimensionTypeHolder) throws ReflectiveOperationException { + if (dimensionTypeHolder == null) { + return null; + } + + Class holderClass = dimensionTypeHolder.getClass(); + if (holderClass.getName().startsWith("net.minecraft.world.level.dimension.")) { + return dimensionTypeHolder; + } + + Method valueMethod = holderClass.getMethod("value"); + return valueMethod.invoke(dimensionTypeHolder); + } + + private static Object invokeNoArg(Object instance, String methodName) throws ReflectiveOperationException { + Method method = instance.getClass().getMethod(methodName); + return method.invoke(instance); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java b/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java index a7fad6cfe..ddddb9e6a 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java @@ -74,7 +74,7 @@ public enum Mode { String[] info = new String[]{ "", - padd2 + color + " Iris, " + C.AQUA + "Dimension Engine " + C.RED + "[" + releaseTrain + " RELEASE]", + padd2 + color + " Iris, " + C.AQUA + "Dimension Engine " + C.RED + "[" + releaseTrain + " RC.1]", padd2 + C.GRAY + " Version: " + color + version, padd2 + C.GRAY + " By: " + color + "Volmit Software (Arcane Arts)", padd2 + C.GRAY + " Server: " + color + serverVersion, @@ -89,10 +89,8 @@ public enum Mode { StringBuilder builder = new StringBuilder("\n\n"); for (int i = 0; i < splash.length; i++) { builder.append(splash[i]); - if (i < info.length) { - builder.append(info[i]); - } - builder.append("\n"); + builder.append(info[i]); + builder.append("\n"); } Iris.info(builder.toString()); 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 3ac1cc042..3ca61b4a2 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 @@ -22,10 +22,12 @@ import com.google.gson.JsonSyntaxException; import art.arcane.iris.Iris; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.pack.IrisPack; import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.IrisDimension; @@ -46,7 +48,10 @@ import org.zeroturnaround.zip.commons.FileUtils; import java.io.File; import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; public class StudioSVC implements IrisService { @@ -55,6 +60,7 @@ public class StudioSVC implements IrisService { private static final AtomicCache counter = new AtomicCache<>(); private final KMap cacheListing = null; private IrisProject activeProject; + private CompletableFuture activeClose; @Override public void onEnable() { @@ -78,21 +84,41 @@ public class StudioSVC implements IrisService { public void onDisable() { Iris.debug("Studio Mode Active: Closing Projects"); boolean stopping = IrisToolbelt.isServerStopping(); + LinkedHashSet worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(Bukkit.getWorldContainer())); - for (World i : Bukkit.getWorlds()) { - if (IrisToolbelt.isIrisWorld(i)) { - if (IrisToolbelt.isStudio(i)) { - PlatformChunkGenerator generator = IrisToolbelt.access(i); - if (!stopping) { - IrisToolbelt.evacuate(i); - } - - if (generator != null) { - generator.close(); - } + if (activeProject != null) { + PlatformChunkGenerator activeProvider = activeProject.getActiveProvider(); + if (activeProvider != null) { + String activeWorldName = activeProvider.getTarget().getWorld().name(); + if (activeWorldName != null && !activeWorldName.isBlank()) { + worldNamesToDelete.add(activeWorldName); } } } + + for (World i : Bukkit.getWorlds()) { + if (!IrisToolbelt.isIrisWorld(i) || !IrisToolbelt.isStudio(i)) { + continue; + } + + worldNamesToDelete.add(i.getName()); + PlatformChunkGenerator generator = IrisToolbelt.access(i); + if (!stopping) { + destroyStudioWorld(i, generator); + continue; + } + + if (generator != null) { + try { + generator.close(); + } catch (Throwable e) { + Iris.reportError("Failed to close studio generator for \"" + i.getName() + "\" during shutdown.", e); + } + } + } + + activeProject = null; + queueStudioWorldDeletionOnStartup(worldNamesToDelete); } public IrisDimension installIntoWorld(VolmitSender sender, String type, File folder) { @@ -348,20 +374,46 @@ public class StudioSVC implements IrisService { open(sender, seed, dimm, (w) -> { }); } catch (Exception e) { - Iris.reportError(e); + Iris.reportError("Failed to open studio world \"" + dimm + "\".", e); sender.sendMessage("Failed to open studio world: " + e.getMessage()); - Iris.error("Studio world creation failed: " + e.getMessage()); } } public void open(VolmitSender sender, long seed, String dimm, Consumer onDone) throws IrisException { - if (isProjectOpen()) { - close(); - } + CompletableFuture pendingClose = close(); + pendingClose.whenComplete((closeResult, closeThrowable) -> { + if (closeThrowable != null) { + Iris.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", closeThrowable); + J.s(() -> sender.sendMessage("Failed to close the existing studio project: " + closeThrowable.getMessage())); + return; + } - IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimm)); - activeProject = project; - project.open(sender, seed, onDone); + if (closeResult != null && closeResult.failureCause() != null) { + Throwable failure = closeResult.failureCause(); + Iris.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", failure); + J.s(() -> sender.sendMessage("Failed to close the existing studio project: " + failure.getMessage())); + return; + } + + IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimm)); + activeProject = project; + try { + project.open(sender, seed, onDone).whenComplete((result, throwable) -> { + if (throwable == null) { + return; + } + + if (activeProject == project && !project.isOpen()) { + activeProject = null; + } + }); + } catch (IrisException e) { + if (activeProject == project) { + activeProject = null; + } + J.s(() -> sender.sendMessage("Failed to open studio world: " + e.getMessage())); + } + }); } public void openVSCode(VolmitSender sender, String dim) { @@ -376,11 +428,89 @@ public class StudioSVC implements IrisService { return Iris.instance.getDataFileList(WORKSPACE_NAME, sub); } - public void close() { - if (isProjectOpen()) { - Iris.debug("Closing Active Project"); - activeProject.close(); - activeProject = null; + public CompletableFuture close() { + if (activeClose != null && !activeClose.isDone()) { + return activeClose; + } + + if (activeProject == null) { + return CompletableFuture.completedFuture(new art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null, null)); + } + + Iris.debug("Closing Active Project"); + IrisProject project = activeProject; + activeProject = null; + activeClose = project.close(); + activeClose.whenComplete((result, throwable) -> activeClose = null); + return activeClose; + } + + private void destroyStudioWorld(World world, PlatformChunkGenerator generator) { + try { + IrisToolbelt.evacuate(world); + } catch (Throwable e) { + Iris.reportError("Failed to evacuate studio world \"" + world.getName() + "\" during shutdown cleanup.", e); + } + + if (generator != null) { + try { + generator.close(); + } catch (Throwable e) { + Iris.reportError("Failed to close studio generator for \"" + world.getName() + "\" during shutdown cleanup.", e); + } + } + + try { + WorldLifecycleService.get().unload(world, false); + } catch (Throwable e) { + Iris.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e); + } + + deleteTransientStudioFolders(world.getName()); + } + + private void deleteTransientStudioFolders(String worldName) { + if (worldName == null || worldName.isBlank()) { + return; + } + + File container = Bukkit.getWorldContainer(); + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + File folder = new File(container, familyWorldName); + if (!folder.exists()) { + continue; + } + + IO.delete(folder); + } + } + + private void queueStudioWorldDeletionOnStartup(LinkedHashSet worldNamesToDelete) { + if (worldNamesToDelete.isEmpty()) { + return; + } + + LinkedHashSet normalizedNames = new LinkedHashSet<>(); + for (String worldName : worldNamesToDelete) { + String baseWorldName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName); + if (baseWorldName != null) { + normalizedNames.add(baseWorldName); + continue; + } + + if (worldName != null && !worldName.isBlank()) { + normalizedNames.add(worldName); + } + } + + if (normalizedNames.isEmpty()) { + return; + } + + try { + Iris.queueWorldDeletionOnStartup(List.copyOf(normalizedNames)); + } catch (IOException e) { + Iris.reportError("Failed to queue studio world deletion on startup.", e); } } 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 6dc9f33ab..dfae20b5b 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 @@ -24,14 +24,16 @@ import art.arcane.iris.core.IrisRuntimeSchedulerMode; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; -import art.arcane.iris.core.link.FoliaWorldsLink; +import art.arcane.iris.core.lifecycle.WorldLifecycleCaller; +import art.arcane.iris.core.lifecycle.WorldLifecycleRequest; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.service.BoardSVC; import art.arcane.iris.core.service.StudioSVC; -import art.arcane.iris.engine.IrisNoisemapPrebakePipeline; -import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.core.runtime.DatapackReadinessResult; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; @@ -42,7 +44,6 @@ import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.IO; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.scheduling.O; import art.arcane.volmlib.util.scheduling.FoliaScheduler; import io.papermc.lib.PaperLib; import lombok.Data; @@ -52,21 +53,22 @@ import org.bukkit.block.Block; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; +import org.bukkit.event.world.TimeSkipEvent; import java.io.File; import java.io.IOException; import java.lang.reflect.Field; -import java.time.Duration; +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.OptionalLong; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.LinkedHashSet; -import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.IntSupplier; @@ -79,9 +81,6 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; @Data @Accessors(fluent = true, chain = true) public class IrisCreator { - private static final int STUDIO_PREWARM_RADIUS_CHUNKS = 1; - private static final Duration STUDIO_PREWARM_TIMEOUT = Duration.ofSeconds(45L); - /** * Specify an area to pregenerate during creation */ @@ -114,6 +113,11 @@ public class IrisCreator { */ private boolean benchmark = false; private BiConsumer studioProgressConsumer; + private DatapackReadinessResult lastDatapackReadinessResult; + + public DatapackReadinessResult getLastDatapackReadinessResult() { + return lastDatapackReadinessResult; + } public static boolean removeFromBukkitYml(String name) throws IOException { YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); @@ -144,7 +148,7 @@ public class IrisCreator { throw new IrisException("You cannot invoke create() on the main thread."); } - reportStudioProgress(0.02D, "Preparing studio open"); + reportStudioProgress(0.02D, "resolve_dimension"); if (studio()) { World existing = Bukkit.getWorld(name()); @@ -155,7 +159,7 @@ public class IrisCreator { } } - reportStudioProgress(0.08D, "Resolving dimension"); + reportStudioProgress(0.08D, "resolve_dimension"); IrisDimension d = IrisToolbelt.getDimension(dimension()); if (d == null) { @@ -165,7 +169,7 @@ public class IrisCreator { if (sender == null) sender = Iris.getSender(); - reportStudioProgress(0.16D, "Preparing world pack"); + reportStudioProgress(0.16D, "prepare_world_pack"); if (!studio() || benchmark) { Iris.service(StudioSVC.class).installIntoWorld(sender, d.getLoadKey(), new File(Bukkit.getWorldContainer(), name())); } @@ -174,12 +178,10 @@ public class IrisCreator { Iris.info("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT) + ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer())); } - prebakeNoisemapsBeforeWorldCreate(d); - reportStudioProgress(0.28D, "Installing datapacks"); + reportStudioProgress(0.28D, "install_datapacks"); AtomicDouble pp = new AtomicDouble(0); - O done = new O<>(); - done.set(false); + AtomicBoolean done = new AtomicBoolean(false); WorldCreator wc = new IrisWorldCreator() .dimension(dimension) .name(name) @@ -199,104 +201,63 @@ public class IrisCreator { extraWorldDatapackFoldersByPack = new KMap<>(); extraWorldDatapackFoldersByPack.put(d.getLoadKey(), studioDatapackFolders); } - if (ServerConfigurator.installDataPacks(verifyDataPacks, includeExternalDataPacks, extraWorldDatapackFoldersByPack)) { - throw new IrisException("Datapacks were missing!"); + lastDatapackReadinessResult = DatapackReadinessResult.installForStudioWorld( + d.getLoadKey(), + d.getDimensionTypeKey(), + new File(Bukkit.getWorldContainer(), name()), + verifyDataPacks, + includeExternalDataPacks, + extraWorldDatapackFoldersByPack + ); + if (!"ok".equals(lastDatapackReadinessResult.getExternalDatapackInstallResult())) { + throw new IrisException("Datapack external install failed: " + lastDatapackReadinessResult.getExternalDatapackInstallResult()); } - reportStudioProgress(0.40D, "Datapacks ready"); + if (lastDatapackReadinessResult.isRestartRequired()) { + throw new IrisException("Datapack install requested a server restart for " + + d.getLoadKey() + + ". folders=" + + lastDatapackReadinessResult.getResolvedDatapackFolders()); + } + if (!lastDatapackReadinessResult.isVerificationPassed()) { + throw new IrisException("Datapack readiness verification failed for " + + d.getLoadKey() + + ". missingPaths=" + + lastDatapackReadinessResult.getMissingPaths() + + ", folders=" + + lastDatapackReadinessResult.getResolvedDatapackFolders()); + } + reportStudioProgress(0.40D, "install_datapacks"); PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator(); if (access == null) throw new IrisException("Access is null. Something bad happened."); - - J.a(() -> { - IntSupplier g = () -> { - if (access.getEngine() == null) { - return 0; - } - return access.getEngine().getGenerated(); - }; - if(!benchmark) { - int req = access.getSpawnChunks().join(); - for (int c = 0; c < req && !done.get(); c = g.getAsInt()) { - double v = (double) c / req; - if (studioProgressConsumer != null) { - reportStudioProgress(0.40D + (0.42D * v), "Generating spawn"); - J.sleep(16); - } else if (sender.isPlayer()) { - sender.sendProgress(v, "Generating"); - J.sleep(16); - } else { - sender.sendMessage(C.WHITE + "Generating " + Form.pc(v) + ((C.GRAY + " (" + (req - c) + " Left)"))); - J.sleep(1000); - } - } - } - }); + AtomicInteger createProgressTask = startCreateProgressReporter(access, done); World world; - reportStudioProgress(0.46D, "Creating world"); + reportStudioProgress(0.46D, "create_world"); try { - world = J.sfut(() -> INMS.get().createWorldAsync(wc)) + WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE; + WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind); + world = J.sfut(() -> INMS.get().createWorldAsync(wc, request)) .thenCompose(Function.identity()) .get(); } catch (Throwable e) { done.set(true); + cancelRepeatingTask(createProgressTask); if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) { - if (FoliaWorldsLink.get().isActive()) { - throw new IrisException("Runtime world creation is blocked and async Folia runtime world-loader creation also failed.", e); - } - throw new IrisException("Runtime world creation is blocked and no async Folia runtime world-loader path is active.", e); + throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e); } - throw new IrisException("Failed to create world!", e); + throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e); } done.set(true); - reportStudioProgress(0.86D, "World created"); + cancelRepeatingTask(createProgressTask); + reportStudioProgress(0.86D, "create_world"); - if (sender.isPlayer() && !benchmark) { - Player senderPlayer = sender.player(); - if (senderPlayer == null) { - Iris.warn("Studio opened, but sender player reference is unavailable for teleport."); - } else { - Location studioEntryLocation = resolveStudioEntryLocation(world); - if (studioEntryLocation == null) { - sender.sendMessage(C.YELLOW + "Studio opened, but entry location could not be resolved safely."); - } else { - prewarmStudioEntryChunks(world, studioEntryLocation, STUDIO_PREWARM_RADIUS_CHUNKS, STUDIO_PREWARM_TIMEOUT); - CompletableFuture teleportFuture = PaperLib.teleportAsync(senderPlayer, studioEntryLocation); - if (teleportFuture != null) { - teleportFuture.thenAccept(success -> { - if (Boolean.TRUE.equals(success)) { - J.runEntity(senderPlayer, () -> Iris.service(BoardSVC.class).updatePlayer(senderPlayer)); - } - }); - teleportFuture.exceptionally(throwable -> { - Iris.warn("Failed to schedule studio teleport task for " + senderPlayer.getName() + "."); - Iris.reportError(throwable); - return false; - }); - } - } - } - } - - if (studio || benchmark) { - Runnable applyStudioWorldSettings = () -> { - Iris.linkMultiverseCore.removeFromConfig(world); - - if (IrisSettings.get().getStudio().isDisableTimeAndWeather()) { - setBooleanGameRule(world, false, "ADVANCE_WEATHER", "DO_WEATHER_CYCLE", "WEATHER_CYCLE", "doWeatherCycle", "weatherCycle"); - setBooleanGameRule(world, false, "ADVANCE_TIME", "DO_DAYLIGHT_CYCLE", "DAYLIGHT_CYCLE", "doDaylightCycle", "daylightCycle"); - world.setTime(6000); - } - }; - - J.s(applyStudioWorldSettings); - } else { + if (!studio && !benchmark) { addToBukkitYml(); J.s(() -> Iris.linkMultiverseCore.updateWorld(world, dimension)); } - reportStudioProgress(0.93D, "Applying world settings"); if (pregen != null) { CompletableFuture ff = new CompletableFuture<>(); @@ -305,28 +266,18 @@ public class IrisCreator { .onProgress(pp::set) .whenDone(() -> ff.complete(true)); + AtomicBoolean dx = new AtomicBoolean(false); + AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx); try { - AtomicBoolean dx = new AtomicBoolean(false); - - J.a(() -> { - while (!dx.get()) { - if (sender.isPlayer()) { - sender.sendProgress(pp.get(), "Pregenerating"); - J.sleep(16); - } else { - sender.sendMessage(C.WHITE + "Pregenerating " + Form.pc(pp.get())); - J.sleep(1000); - } - } - }); - ff.get(); dx.set(true); + cancelRepeatingTask(pregenProgressTask); } catch (Throwable e) { + dx.set(true); + cancelRepeatingTask(pregenProgressTask); e.printStackTrace(); } } - reportStudioProgress(0.98D, "Finalizing"); return world; } @@ -340,360 +291,89 @@ public class IrisCreator { try { consumer.accept(clamped, stage); } catch (Throwable e) { - Iris.reportError(e); + Iris.reportError("Studio progress consumer failed for world \"" + name() + "\".", e); } } - private void prebakeNoisemapsBeforeWorldCreate(IrisDimension dimension) { - IrisSettings.IrisSettingsPregen pregenSettings = IrisSettings.get().getPregen(); - if (!pregenSettings.isStartupNoisemapPrebake()) { + private AtomicInteger startCreateProgressReporter(PlatformChunkGenerator access, AtomicBoolean done) { + AtomicInteger taskId = new AtomicInteger(-1); + if (benchmark) { + return taskId; + } + + IntSupplier generatedSupplier = () -> { + if (access.getEngine() == null) { + return 0; + } + return access.getEngine().getGenerated(); + }; + access.getSpawnChunks().whenComplete((required, throwable) -> { + if (throwable != null) { + Iris.reportError("Failed to resolve studio spawn chunk target for world \"" + name() + "\".", throwable); + return; + } + + if (done.get() || required == null || required <= 0) { + return; + } + + int interval = studioProgressConsumer != null || sender.isPlayer() ? 1 : 20; + taskId.set(J.ar(() -> { + if (done.get()) { + cancelRepeatingTask(taskId); + return; + } + + int generated = generatedSupplier.getAsInt(); + if (generated >= required) { + cancelRepeatingTask(taskId); + return; + } + + double progress = (double) generated / required; + if (studioProgressConsumer != null) { + reportStudioProgress(0.40D + (0.42D * progress), "create_world"); + return; + } + + if (sender.isPlayer()) { + sender.sendProgress(progress, "Generating"); + return; + } + + sender.sendMessage(C.WHITE + "Generating " + Form.pc(progress) + ((C.GRAY + " (" + (required - generated) + " Left)"))); + }, interval)); + }); + return taskId; + } + + private AtomicInteger startPregenProgressReporter(AtomicDouble progress, AtomicBoolean done) { + AtomicInteger taskId = new AtomicInteger(-1); + int interval = sender.isPlayer() ? 1 : 20; + taskId.set(J.ar(() -> { + if (done.get()) { + cancelRepeatingTask(taskId); + return; + } + + if (sender.isPlayer()) { + sender.sendProgress(progress.get(), "Pregenerating"); + return; + } + + sender.sendMessage(C.WHITE + "Pregenerating " + Form.pc(progress.get())); + }, interval)); + return taskId; + } + + private void cancelRepeatingTask(AtomicInteger taskId) { + if (taskId == null) { return; } - if (studio() && !benchmark) { - boolean startupPrebakeReady = IrisNoisemapPrebakePipeline.awaitInstalledPacksPrebakeForStudio(); - if (startupPrebakeReady) { - return; - } - } - - try { - File targetDataFolder = new File(Bukkit.getWorldContainer(), name()); - if (studio() && !benchmark) { - IrisData studioData = dimension.getLoader(); - if (studioData != null) { - targetDataFolder = studioData.getDataFolder(); - } - } - - IrisData targetData = IrisData.get(targetDataFolder); - SeedManager seedManager = new SeedManager(seed()); - IrisNoisemapPrebakePipeline.prebake(targetData, seedManager, name(), dimension.getLoadKey()); - } catch (Throwable throwable) { - Iris.warn("Failed pre-create noisemap pre-bake for " + name() + "/" + dimension.getLoadKey() + ": " + throwable.getMessage()); - Iris.reportError(throwable); - } - } - - private Location resolveStudioEntryLocation(World world) { - CompletableFuture locationFuture = J.sfut(() -> { - Location spawnLocation = world.getSpawnLocation(); - if (spawnLocation != null) { - return spawnLocation.clone(); - } - - int x = 0; - int z = 0; - int y = Math.max(world.getMinHeight() + 1, 96); - return new Location(world, x + 0.5D, y, z + 0.5D); - }); - if (locationFuture == null) { - Iris.warn("Failed to schedule studio entry-location resolve task on the global scheduler for world \"" + world.getName() + "\"."); - return null; - } - - try { - Location rawLocation = locationFuture.get(15, TimeUnit.SECONDS); - return resolveTopSafeStudioLocation(world, rawLocation); - } catch (Throwable e) { - Iris.warn("Failed to resolve studio entry location for world \"" + world.getName() + "\"."); - Iris.reportError(e); - return null; - } - } - - private Location resolveTopSafeStudioLocation(World world, Location rawLocation) { - if (world == null || rawLocation == null) { - return rawLocation; - } - - int chunkX = rawLocation.getBlockX() >> 4; - int chunkZ = rawLocation.getBlockZ() >> 4; - try { - CompletableFuture chunkFuture = PaperLib.getChunkAtAsync(world, chunkX, chunkZ, false); - if (chunkFuture != null) { - chunkFuture.get(10, TimeUnit.SECONDS); - } - } catch (Throwable ignored) { - return rawLocation; - } - - if (!world.isChunkLoaded(chunkX, chunkZ)) { - return rawLocation; - } - - CompletableFuture regionFuture = new CompletableFuture<>(); - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - try { - regionFuture.complete(findTopSafeStudioLocation(world, rawLocation)); - } catch (Throwable e) { - regionFuture.completeExceptionally(e); - } - }); - if (!scheduled) { - return rawLocation; - } - - try { - Location resolved = regionFuture.get(15, TimeUnit.SECONDS); - return resolved == null ? rawLocation : resolved; - } catch (Throwable e) { - Iris.warn("Failed to resolve safe studio entry surface for world \"" + world.getName() + "\"."); - Iris.reportError(e); - return rawLocation; - } - } - - private Location findTopSafeStudioLocation(World world, Location source) { - int x = source.getBlockX(); - int z = source.getBlockZ(); - int minY = world.getMinHeight() + 1; - int maxY = world.getMaxHeight() - 2; - int sourceY = source.getBlockY(); - int startY = Math.max(minY, Math.min(maxY, sourceY)); - float yaw = source.getYaw(); - float pitch = source.getPitch(); - - int upperBound = Math.min(maxY, startY + 32); - for (int y = startY; y <= upperBound; y++) { - if (isSafeStandingLocation(world, x, y, z)) { - return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); - } - } - - int lowerBound = Math.max(minY, startY - 64); - for (int y = startY - 1; y >= lowerBound; y--) { - if (isSafeStandingLocation(world, x, y, z)) { - return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); - } - } - - int fallbackY = Math.max(minY, Math.min(maxY, source.getBlockY())); - return new Location(world, x + 0.5D, fallbackY, z + 0.5D, yaw, pitch); - } - - private boolean isSafeStandingLocation(World world, int x, int y, int z) { - if (y <= world.getMinHeight() || y >= world.getMaxHeight() - 1) { - return false; - } - - Block below = world.getBlockAt(x, y - 1, z); - Block feet = world.getBlockAt(x, y, z); - Block head = world.getBlockAt(x, y + 1, z); - - Material belowType = below.getType(); - if (!belowType.isSolid()) { - return false; - } - if (Tag.LEAVES.isTagged(belowType)) { - return false; - } - if (belowType == Material.LAVA - || belowType == Material.MAGMA_BLOCK - || belowType == Material.FIRE - || belowType == Material.SOUL_FIRE - || belowType == Material.CAMPFIRE - || belowType == Material.SOUL_CAMPFIRE) { - return false; - } - if (feet.getType().isSolid() || head.getType().isSolid()) { - return false; - } - if (feet.isLiquid() || head.isLiquid()) { - return false; - } - - return true; - } - - private void prewarmStudioEntryChunks(World world, Location entry, int radiusChunks, Duration timeout) throws IrisException { - if (world == null || entry == null) { - throw new IrisException("Studio prewarm failed: world or entry location is null."); - } - - int centerChunkX = entry.getBlockX() >> 4; - int centerChunkZ = entry.getBlockZ() >> 4; - List chunkTargets = resolveStudioPrewarmTargets(centerChunkX, centerChunkZ, radiusChunks); - if (chunkTargets.isEmpty()) { - throw new IrisException("Studio prewarm failed: no target chunks were resolved."); - } - - int loadedBefore = 0; - Map> futures = new LinkedHashMap<>(); - for (StudioChunkCoordinate coordinate : chunkTargets) { - if (world.isChunkLoaded(coordinate.getX(), coordinate.getZ())) { - loadedBefore++; - } - - CompletableFuture chunkFuture = PaperLib.getChunkAtAsync(world, coordinate.getX(), coordinate.getZ(), true); - if (chunkFuture == null) { - throw new IrisException("Studio prewarm failed: async chunk future was null for " + coordinate + "."); - } - - futures.put(coordinate, chunkFuture); - } - - int total = chunkTargets.size(); - int completed = 0; - Set remaining = new LinkedHashSet<>(chunkTargets); - long startNanos = System.nanoTime(); - long timeoutNanos = Math.max(1L, timeout.toNanos()); - reportStudioProgress(0.88D, "Prewarming entry chunks (0/" + total + ")"); - - while (!remaining.isEmpty()) { - long elapsedNanos = System.nanoTime() - startNanos; - if (elapsedNanos >= timeoutNanos) { - StudioPrewarmDiagnostics diagnostics = buildStudioPrewarmDiagnostics(world, chunkTargets, remaining, loadedBefore, elapsedNanos); - throw new IrisException("Studio prewarm timed out: " + diagnostics.toMessage()); - } - - boolean progressed = false; - List completedCoordinates = new ArrayList<>(); - for (StudioChunkCoordinate coordinate : remaining) { - CompletableFuture chunkFuture = futures.get(coordinate); - if (chunkFuture == null || !chunkFuture.isDone()) { - continue; - } - - try { - Chunk loadedChunk = chunkFuture.get(); - if (loadedChunk == null) { - throw new IrisException("Studio prewarm failed: chunk " + coordinate + " resolved to null."); - } - } catch (IrisException e) { - throw e; - } catch (Throwable e) { - throw new IrisException("Studio prewarm failed while loading chunk " + coordinate + ".", e); - } - - completedCoordinates.add(coordinate); - progressed = true; - } - - if (!completedCoordinates.isEmpty()) { - for (StudioChunkCoordinate completedCoordinate : completedCoordinates) { - remaining.remove(completedCoordinate); - } - - completed += completedCoordinates.size(); - double ratio = (double) completed / (double) total; - reportStudioProgress(0.88D + (0.04D * ratio), "Prewarming entry chunks (" + completed + "/" + total + ")"); - } - - if (!progressed) { - J.sleep(20); - } - } - - long elapsedNanos = System.nanoTime() - startNanos; - StudioPrewarmDiagnostics diagnostics = buildStudioPrewarmDiagnostics(world, chunkTargets, new LinkedHashSet<>(), loadedBefore, elapsedNanos); - Iris.info("Studio prewarm complete: " + diagnostics.toMessage()); - } - - private StudioPrewarmDiagnostics buildStudioPrewarmDiagnostics( - World world, - List chunkTargets, - Set timedOutChunks, - int loadedBefore, - long elapsedNanos - ) { - int loadedAfter = 0; - for (StudioChunkCoordinate coordinate : chunkTargets) { - if (world.isChunkLoaded(coordinate.getX(), coordinate.getZ())) { - loadedAfter++; - } - } - - int generatedDuring = Math.max(0, loadedAfter - loadedBefore); - List timedOut = new ArrayList<>(); - for (StudioChunkCoordinate timedOutChunk : timedOutChunks) { - timedOut.add(timedOutChunk.toString()); - } - - long elapsedMs = TimeUnit.NANOSECONDS.toMillis(Math.max(0L, elapsedNanos)); - return new StudioPrewarmDiagnostics(elapsedMs, loadedBefore, loadedAfter, generatedDuring, timedOut); - } - - private List resolveStudioPrewarmTargets(int centerChunkX, int centerChunkZ, int radiusChunks) { - int safeRadius = Math.max(0, radiusChunks); - List targets = new ArrayList<>(); - targets.add(new StudioChunkCoordinate(centerChunkX, centerChunkZ)); - - for (int x = -safeRadius; x <= safeRadius; x++) { - for (int z = -safeRadius; z <= safeRadius; z++) { - if (x == 0 && z == 0) { - continue; - } - - targets.add(new StudioChunkCoordinate(centerChunkX + x, centerChunkZ + z)); - } - } - - return targets; - } - - private static final class StudioChunkCoordinate { - private final int x; - private final int z; - - private StudioChunkCoordinate(int x, int z) { - this.x = x; - this.z = z; - } - - private int getX() { - return x; - } - - private int getZ() { - return z; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - - if (!(other instanceof StudioChunkCoordinate coordinate)) { - return false; - } - - return x == coordinate.x && z == coordinate.z; - } - - @Override - public int hashCode() { - return 31 * x + z; - } - - @Override - public String toString() { - return x + "," + z; - } - } - - private static final class StudioPrewarmDiagnostics { - private final long elapsedMs; - private final int loadedBefore; - private final int loadedAfter; - private final int generatedDuring; - private final List timedOutChunks; - - private StudioPrewarmDiagnostics(long elapsedMs, int loadedBefore, int loadedAfter, int generatedDuring, List timedOutChunks) { - this.elapsedMs = elapsedMs; - this.loadedBefore = loadedBefore; - this.loadedAfter = loadedAfter; - this.generatedDuring = generatedDuring; - this.timedOutChunks = new ArrayList<>(timedOutChunks); - } - - private String toMessage() { - return "elapsedMs=" + elapsedMs - + ", loadedBefore=" + loadedBefore - + ", loadedAfter=" + loadedAfter - + ", generatedDuring=" + generatedDuring - + ", timedOut=" + timedOutChunks; + int id = taskId.getAndSet(-1); + if (id >= 0) { + J.car(id); } } @@ -713,138 +393,6 @@ public class IrisCreator { return false; } - @SuppressWarnings("unchecked") - private static void setBooleanGameRule(World world, boolean value, String... names) { - GameRule gameRule = resolveBooleanGameRule(world, names); - if (gameRule != null) { - world.setGameRule(gameRule, value); - } - } - - @SuppressWarnings("unchecked") - private static GameRule resolveBooleanGameRule(World world, String... names) { - if (world == null || names == null || names.length == 0) { - return null; - } - - Set candidates = buildRuleNameCandidates(names); - for (String name : candidates) { - if (name == null || name.isBlank()) { - continue; - } - - try { - Field field = GameRule.class.getField(name); - Object value = field.get(null); - if (value instanceof GameRule gameRule && Boolean.class.equals(gameRule.getType())) { - return (GameRule) gameRule; - } - } catch (Throwable ignored) { - } - - try { - GameRule byName = GameRule.getByName(name); - if (byName != null && Boolean.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { - } - } - - String[] availableRules = world.getGameRules(); - if (availableRules == null || availableRules.length == 0) { - return null; - } - - Set normalizedCandidates = new LinkedHashSet<>(); - for (String candidate : candidates) { - if (candidate != null && !candidate.isBlank()) { - normalizedCandidates.add(normalizeRuleName(candidate)); - } - } - - for (String availableRule : availableRules) { - String normalizedAvailable = normalizeRuleName(availableRule); - if (!normalizedCandidates.contains(normalizedAvailable)) { - continue; - } - - try { - GameRule byName = GameRule.getByName(availableRule); - if (byName != null && Boolean.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { - } - } - - return null; - } - - private static Set buildRuleNameCandidates(String... names) { - Set candidates = new LinkedHashSet<>(); - for (String name : names) { - if (name == null || name.isBlank()) { - continue; - } - - candidates.add(name); - candidates.add(name.toLowerCase(Locale.ROOT)); - - String lowerCamel = toLowerCamel(name); - if (!lowerCamel.isEmpty()) { - candidates.add(lowerCamel); - } - } - - return candidates; - } - - private static String toLowerCamel(String name) { - if (name == null) { - return ""; - } - - String raw = name.trim(); - if (raw.isEmpty()) { - return ""; - } - - String[] parts = raw.split("_+"); - if (parts.length == 0) { - return raw; - } - - StringBuilder builder = new StringBuilder(); - builder.append(parts[0].toLowerCase(Locale.ROOT)); - for (int i = 1; i < parts.length; i++) { - String part = parts[i].toLowerCase(Locale.ROOT); - if (part.isEmpty()) { - continue; - } - builder.append(Character.toUpperCase(part.charAt(0))); - if (part.length() > 1) { - builder.append(part.substring(1)); - } - } - return builder.toString(); - } - - private static String normalizeRuleName(String name) { - if (name == null || name.isBlank()) { - return ""; - } - - StringBuilder builder = new StringBuilder(name.length()); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (Character.isLetterOrDigit(c)) { - builder.append(Character.toLowerCase(c)); - } - } - return builder.toString(); - } - private void addToBukkitYml() { YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); String gen = "Iris:" + dimension; diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java index 8b152638b..27ffa454c 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java @@ -2,7 +2,7 @@ package art.arcane.iris.core.tools; import art.arcane.iris.Iris; -import art.arcane.iris.core.link.FoliaWorldsLink; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; @@ -100,10 +100,10 @@ public class IrisPackBenchmarking { } J.s(() -> { - var world = Bukkit.getWorld("benchmark"); + org.bukkit.World world = Bukkit.getWorld("benchmark"); if (world == null) return; IrisToolbelt.evacuate(world); - FoliaWorldsLink.get().unloadWorld(world, true); + WorldLifecycleService.get().unload(world, true); }); stopwatch.end(); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index c03660ed1..e15e55952 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -90,9 +90,10 @@ public class IrisEngine implements Engine { private final int art; private final AtomicCache engineData = new AtomicCache<>(); private final AtomicBoolean cleaning; - private final AtomicBoolean noisemapPrebakeRunning; private final ChronoLatch cleanLatch; private final SeedManager seedManager; + private final GenerationSessionManager generationSessions; + private final AtomicBoolean closing; private CompletableFuture hash32; private EngineMode mode; private EngineEffects effects; @@ -113,6 +114,8 @@ public class IrisEngine implements Engine { getEngineData(); verifySeed(); this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed()); + this.generationSessions = new GenerationSessionManager(); + this.closing = new AtomicBoolean(false); bud = new AtomicInteger(0); buds = new AtomicInteger(0); metrics = new EngineMetrics(32); @@ -127,7 +130,6 @@ public class IrisEngine implements Engine { mantle = new IrisEngineMantle(this); context = new IrisContext(this); cleaning = new AtomicBoolean(false); - noisemapPrebakeRunning = new AtomicBoolean(false); modeFallbackLogged = new AtomicBoolean(false); if (studio) { getData().dump(); @@ -164,6 +166,13 @@ public class IrisEngine implements Engine { } private void prehotload() { + closing.set(true); + try { + generationSessions.sealAndAwait("hotload", 15000L); + } catch (GenerationSessionException e) { + throw new IllegalStateException(e); + } + EngineWorldManager currentWorldManager = worldManager; worldManager = null; if (currentWorldManager != null) { @@ -193,6 +202,8 @@ public class IrisEngine implements Engine { private void setupEngine() { try { + generationSessions.activateNextSession(); + closing.set(false); Iris.debug("Setup Engine " + getCacheID()); cacheId = RNG.r.nextInt(); complex = ensureComplex(); @@ -215,7 +226,6 @@ public class IrisEngine implements Engine { .toArray(File[]::new); hash32.complete(IO.hashRecursive(roots)); }); - scheduleStartupNoisemapPrebake(); } catch (Throwable e) { Iris.error("FAILED TO SETUP ENGINE!"); e.printStackTrace(); @@ -297,30 +307,6 @@ public class IrisEngine implements Engine { } } - private void scheduleStartupNoisemapPrebake() { - if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) { - return; - } - - if (studio) { - return; - } - - if (!noisemapPrebakeRunning.compareAndSet(false, true)) { - return; - } - - J.a(() -> { - try { - IrisNoisemapPrebakePipeline.prebake(this); - } catch (Throwable throwable) { - Iris.reportError(throwable); - } finally { - noisemapPrebakeRunning.set(false); - } - }); - } - @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { getMantle().generateMatter(x, z, multicore, context); @@ -532,8 +518,14 @@ public class IrisEngine implements Engine { @Override public void close() { PregeneratorJob.shutdownInstance(); + closing.set(true); closed = true; J.car(art); + try { + generationSessions.sealAndAwait("close", 15000L, true); + } catch (GenerationSessionException e) { + throw new IllegalStateException(e); + } EngineWorldManager currentWorldManager = getWorldManager(); if (currentWorldManager != null) { currentWorldManager.close(); @@ -574,6 +566,10 @@ public class IrisEngine implements Engine { return closed; } + public boolean isClosing() { + return closing.get(); + } + @Override public void recycle() { if (!cleanLatch.flip()) { @@ -602,13 +598,14 @@ public class IrisEngine implements Engine { @BlockCoordinates @Override public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException { - if (closed) { - throw new WrongEngineBroException(); + if (closing.get() || closed) { + throw new GenerationSessionException("Generation session is closed for world \"" + getWorld().name() + "\".", true); } - context.touch(); - getEngineData().getStatistics().generatedChunk(); - try { + try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate")) { + context.touch(); + context.setGenerationSessionId(lease.sessionId()); + getEngineData().getStatistics().generatedChunk(); PrecisionStopwatch p = PrecisionStopwatch.start(); Hunk blocks = vblocks.listen((xx, y, zz, t) -> catchBlockUpdates(x + xx, y, z + zz, t)); @@ -634,12 +631,19 @@ public class IrisEngine implements Engine { if (generated.get() == 661) { J.a(() -> getData().savePrefetch(this)); } + } catch (GenerationSessionException e) { + throw e; } catch (Throwable e) { Iris.reportError(e); fail("Failed to generate " + x + ", " + z, e); } } + @Override + public GenerationSessionManager getGenerationSessions() { + return generationSessions; + } + @Override public void saveEngineData() { //TODO: Method this file diff --git a/core/src/main/java/art/arcane/iris/engine/IrisNoisemapPrebakePipeline.java b/core/src/main/java/art/arcane/iris/engine/IrisNoisemapPrebakePipeline.java deleted file mode 100644 index b9bb90a19..000000000 --- a/core/src/main/java/art/arcane/iris/engine/IrisNoisemapPrebakePipeline.java +++ /dev/null @@ -1,1029 +0,0 @@ -package art.arcane.iris.engine; - -import art.arcane.iris.Iris; -import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.loader.IrisRegistrant; -import art.arcane.iris.core.loader.ResourceLoader; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.framework.SeedManager; -import art.arcane.iris.engine.object.IrisGeneratorStyle; -import art.arcane.iris.util.common.misc.ServerProperties; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.io.IO; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import org.bukkit.Bukkit; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Comparator; -import java.util.IdentityHashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import java.util.concurrent.CompletionService; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorCompletionService; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BooleanSupplier; - -public final class IrisNoisemapPrebakePipeline { - private static final long[] NO_SEEDS = new long[0]; - private static final long STARTUP_PROGRESS_INTERVAL_MS = Long.getLong("iris.prebake.progress.interval", 30000L); - private static final int STATE_VERSION = 1; - private static final String STATE_FILE = "noisemap-prebake.state"; - private static final AtomicBoolean STARTUP_PREBAKE_SCHEDULED = new AtomicBoolean(false); - private static final AtomicBoolean STARTUP_PREBAKE_FAILURE_REPORTED = new AtomicBoolean(false); - private static final AtomicInteger STARTUP_WORKER_SEQUENCE = new AtomicInteger(); - private static final AtomicReference> STARTUP_PREBAKE_COMPLETION = new AtomicReference<>(); - private static final ConcurrentHashMap, Field[]> FIELD_CACHE = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap SKIP_ONCE = new ConcurrentHashMap<>(); - private static final Set PREBAKE_LOADERS = Set.of( - "dimensions", - "regions", - "biomes", - "generators", - "caves", - "ravines", - "mods", - "expressions" - ); - private static final Set PREBAKE_STATE_FOLDERS = Set.of( - "dimensions", - "regions", - "biomes", - "generators", - "caves", - "ravines", - "mods", - "expressions", - "images", - "snippet" - ); - - private IrisNoisemapPrebakePipeline() { - } - - public static void scheduleInstalledPacksPrebakeAsync() { - if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) { - return; - } - - if (!STARTUP_PREBAKE_SCHEDULED.compareAndSet(false, true)) { - return; - } - - CompletableFuture completion = new CompletableFuture<>(); - STARTUP_PREBAKE_COMPLETION.set(completion); - Thread thread = new Thread(() -> { - try { - prebakeInstalledPacksAtStartup(); - completion.complete(null); - } catch (Throwable throwable) { - completion.completeExceptionally(throwable); - if (STARTUP_PREBAKE_FAILURE_REPORTED.compareAndSet(false, true)) { - Iris.warn("Startup noisemap pre-bake failed."); - Iris.reportError(throwable); - } - } - }, "Iris-StartupNoisemapPrebake"); - thread.setDaemon(true); - thread.start(); - } - - public static boolean awaitInstalledPacksPrebakeForStudio() { - if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) { - return false; - } - - scheduleInstalledPacksPrebakeAsync(); - CompletableFuture completion = STARTUP_PREBAKE_COMPLETION.get(); - if (completion == null) { - return false; - } - - try { - completion.join(); - return true; - } catch (CompletionException e) { - Throwable cause = e.getCause() == null ? e : e.getCause(); - if (STARTUP_PREBAKE_FAILURE_REPORTED.compareAndSet(false, true)) { - Iris.warn("Startup noisemap pre-bake failed."); - Iris.reportError(cause); - } - return false; - } catch (Throwable throwable) { - if (STARTUP_PREBAKE_FAILURE_REPORTED.compareAndSet(false, true)) { - Iris.warn("Startup noisemap pre-bake failed."); - Iris.reportError(throwable); - } - return false; - } - } - - public static void prebakeInstalledPacksAtStartup() { - List targets = collectStartupTargets(); - if (targets.isEmpty()) { - Iris.info("Startup noisemap pre-bake skipped (no installed or self-contained packs found)."); - return; - } - - PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); - long startupSeed = dynamicStartupSeed(); - SeedManager seedManager = new SeedManager(startupSeed); - int targetCount = targets.size(); - int workerCount = Math.min(targetCount, Math.max(1, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()))); - ExecutorService workers = Executors.newFixedThreadPool(workerCount, new StartupPrebakeThreadFactory()); - CompletionService completion = new ExecutorCompletionService<>(workers); - StartupProgress progress = new StartupProgress(targetCount); - Iris.info("Startup pack noisemap pre-bake running in background targets=" + targetCount + " workers=" + workerCount + " seed=" + startupSeed); - - for (PrebakeTarget target : targets) { - completion.submit(() -> new StartupTargetResult(target.label, - prebake(IrisData.get(target.folder), seedManager, "startup", target.label, () -> false, false, false, progress))); - } - - int completedTargets = 0; - long lastProgress = System.currentTimeMillis(); - while (completedTargets < targetCount) { - try { - Future future = completion.poll(2, TimeUnit.SECONDS); - if (future != null) { - StartupTargetResult result = future.get(); - completedTargets++; - progress.onTargetCompleted(result.result); - } - } catch (Throwable throwable) { - completedTargets++; - progress.onTargetFailed(); - Iris.reportError(throwable); - } - - long now = System.currentTimeMillis(); - if (completedTargets < targetCount && now - lastProgress >= STARTUP_PROGRESS_INTERVAL_MS) { - logStartupProgress(progress, stopwatch); - lastProgress = now; - } - } - - workers.shutdownNow(); - - Iris.info("Startup pack noisemap pre-bake scan completed targets=" - + targetCount - + " executed=" - + progress.executedTargets.get() - + " unchanged=" - + progress.unchangedTargets.get() - + " failed=" - + progress.failedTargets.get() - + " styles=" - + progress.stylesFinished.get() - + "/" - + progress.stylesDiscovered.get() - + " seed=" - + startupSeed - + " in " - + Form.duration(stopwatch.getMilliseconds(), 2)); - } - - public static int clearInstalledPackPrebakeStates() { - int cleared = 0; - List targets = collectStartupTargets(); - for (PrebakeTarget target : targets) { - File state = stateFile(target.folder); - if (state.exists() && state.delete()) { - cleared++; - } - } - - return cleared; - } - - private static void logStartupProgress(StartupProgress progress, PrecisionStopwatch stopwatch) { - int targetCount = progress.targetTotal; - if (targetCount <= 0) { - return; - } - - int completedTargets = progress.targetsCompleted.get(); - int discoveredStyles = progress.stylesDiscovered.get(); - int finishedStyles = progress.stylesFinished.get(); - int executedTargets = progress.executedTargets.get(); - int unchangedTargets = progress.unchangedTargets.get(); - int failedTargets = progress.failedTargets.get(); - double elapsed = stopwatch.getMilliseconds(); - if (discoveredStyles > 0) { - int remainingStyles = Math.max(0, discoveredStyles - finishedStyles); - int percent = (int) Math.round((finishedStyles * 100D) / discoveredStyles); - String eta = "estimating"; - if (finishedStyles > 0) { - long etaMillis = Math.max(0L, Math.round((elapsed / finishedStyles) * remainingStyles)); - eta = Form.duration(etaMillis, 2); - } - - Iris.info("Startup noisemap pre-bake progress " - + percent - + "% styles=" - + finishedStyles - + "/" - + discoveredStyles - + " targets=" - + completedTargets - + "/" - + targetCount - + " remaining=" - + remainingStyles - + " executed=" - + executedTargets - + " unchanged=" - + unchangedTargets - + " failed=" - + failedTargets - + " elapsed=" - + Form.duration(elapsed, 2) - + " eta=" - + eta); - return; - } - - int remainingTargets = Math.max(0, targetCount - completedTargets); - int percent = (int) Math.round((completedTargets * 100D) / targetCount); - String eta = "estimating"; - if (completedTargets > 0) { - long etaMillis = Math.max(0L, Math.round((elapsed / completedTargets) * remainingTargets)); - eta = Form.duration(etaMillis, 2); - } - - Iris.info("Startup noisemap pre-bake progress " - + percent - + "% targets=" - + completedTargets - + "/" - + targetCount - + " remaining=" - + remainingTargets - + " executed=" - + executedTargets - + " unchanged=" - + unchangedTargets - + " failed=" - + failedTargets - + " elapsed=" - + Form.duration(elapsed, 2) - + " eta=" - + eta); - } - - public static long dynamicStartupSeed() { - if (!Bukkit.getWorlds().isEmpty()) { - return Bukkit.getWorlds().get(0).getSeed(); - } - - String configuredSeed = ServerProperties.DATA.getProperty("level-seed", "").trim(); - if (!configuredSeed.isEmpty()) { - try { - return Long.parseLong(configuredSeed); - } catch (NumberFormatException ignored) { - return mixSeed(0x9E3779B97F4A7C15L, configuredSeed.hashCode()); - } - } - - return mixSeed(0x94D049BB133111EBL, ServerProperties.LEVEL_NAME.hashCode()); - } - - public static void prebake(Engine engine) { - if (engine == null || engine.isClosed()) { - return; - } - - if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) { - return; - } - - prebake(engine.getData(), - engine.getSeedManager(), - engine.getWorld().name(), - engine.getDimension().getLoadKey(), - engine::isClosed, - false, - true, - null); - } - - public static void prebake(IrisData data, SeedManager seedManager, String worldName, String dimensionKey) { - if (!IrisSettings.get().getPregen().isStartupNoisemapPrebake()) { - return; - } - - prebake(data, seedManager, worldName, dimensionKey, () -> false, true, true, null); - } - - public static void prebakeForced(IrisData data, SeedManager seedManager, String worldName, String dimensionKey) { - prebake(data, seedManager, worldName, dimensionKey, () -> false, false, true, null); - } - - private static PrebakeRunResult prebake(IrisData data, - SeedManager seedManager, - String worldName, - String dimensionKey, - BooleanSupplier shouldAbort, - boolean primeSkipOnce, - boolean logResult, - StartupProgress progress) { - if (data == null || seedManager == null) { - return PrebakeRunResult.SKIPPED; - } - - if (shouldAbort != null && shouldAbort.getAsBoolean()) { - return PrebakeRunResult.SKIPPED; - } - - PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); - String safeWorldName = worldName == null ? "unknown" : worldName; - String safeDimensionKey = dimensionKey == null ? "unknown" : dimensionKey; - boolean exhaustive = dynamicExhaustivePrebakeMode(); - int fallbackCacheSize = dynamicFallbackCacheSize(); - String key = prebakeKey(data, seedManager, safeDimensionKey, exhaustive, fallbackCacheSize); - - if (SKIP_ONCE.remove(key) != null) { - if (logResult) { - Iris.info("Startup noisemap pre-bake skipped for " + safeWorldName + "/" + safeDimensionKey + " (already pre-baked before engine init)."); - } - return PrebakeRunResult.SKIPPED; - } - - String stateToken = prebakeStateToken(data, seedManager, exhaustive, fallbackCacheSize); - if (isCurrentState(data, stateToken)) { - if (primeSkipOnce) { - SKIP_ONCE.put(key, Boolean.TRUE); - } - if (logResult) { - Iris.info("Startup noisemap pre-bake skipped for " + safeWorldName + "/" + safeDimensionKey + " (unchanged pack state)."); - } - return PrebakeRunResult.UNCHANGED; - } - - List styles = collectStyles(data, fallbackCacheSize, progress); - int styleCount = styles.size(); - - if (styleCount == 0) { - writeCurrentState(data, stateToken); - if (primeSkipOnce) { - SKIP_ONCE.put(key, Boolean.TRUE); - } - if (logResult) { - Iris.info("Startup noisemap pre-bake skipped for " + safeWorldName + "/" + safeDimensionKey + " (no cacheable styles found)."); - } - return PrebakeRunResult.SKIPPED; - } - - long[] domainSeeds = collectDomainSeeds(seedManager); - if (domainSeeds.length == 0) { - if (logResult) { - Iris.warn("Startup noisemap pre-bake skipped for " + safeWorldName + "/" + safeDimensionKey + " (no seed domains)."); - } - return PrebakeRunResult.SKIPPED; - } - - AtomicInteger prebakedStyles = new AtomicInteger(); - AtomicInteger prebakedVariants = new AtomicInteger(); - AtomicInteger failures = new AtomicInteger(); - BurstExecutor burst = MultiBurst.burst.burst(styleCount); - - for (StyleReference reference : styles) { - burst.queue(() -> { - if (shouldAbort != null && shouldAbort.getAsBoolean()) { - if (progress != null) { - progress.onStyleFinished(); - } - return; - } - - try { - int baked = prebakeStyle(reference, data, domainSeeds, exhaustive, fallbackCacheSize); - if (baked <= 0) { - return; - } - - prebakedStyles.incrementAndGet(); - prebakedVariants.addAndGet(baked); - } catch (Throwable throwable) { - failures.incrementAndGet(); - Iris.reportError(throwable); - } finally { - if (progress != null) { - progress.onStyleFinished(); - } - } - }); - } - - burst.complete(); - - if (failures.get() == 0) { - writeCurrentState(data, stateToken); - if (primeSkipOnce) { - SKIP_ONCE.put(key, Boolean.TRUE); - } - } - - if (logResult) { - Iris.info("Startup noisemap pre-bake completed for " - + safeWorldName - + "/" - + safeDimensionKey - + " styles=" - + styleCount - + " prebaked=" - + prebakedStyles.get() - + " variants=" - + prebakedVariants.get() - + " failures=" - + failures.get() - + " mode=" - + (exhaustive ? "exhaustive" : "targeted") - + " fallback=" - + fallbackCacheSize - + " in " - + Form.duration(stopwatch.getMilliseconds(), 2)); - } - return new PrebakeRunResult(true, false, failures.get()); - } - - private static boolean dynamicExhaustivePrebakeMode() { - int cores = Runtime.getRuntime().availableProcessors(); - long memoryGiB = Runtime.getRuntime().maxMemory() / (1024L * 1024L * 1024L); - return cores >= 24 && memoryGiB >= 64; - } - - private static int dynamicFallbackCacheSize() { - int cores = Runtime.getRuntime().availableProcessors(); - long memoryGiB = Runtime.getRuntime().maxMemory() / (1024L * 1024L * 1024L); - - if (memoryGiB >= 24 && cores >= 12) { - return 64; - } - - if (memoryGiB >= 16 && cores >= 8) { - return 48; - } - - if (memoryGiB >= 8 && cores >= 6) { - return 40; - } - - if (memoryGiB >= 4 && cores >= 4) { - return 32; - } - - return 24; - } - - private static String prebakeStateToken(IrisData data, SeedManager seedManager, boolean exhaustive, int fallbackCacheSize) { - File[] roots = prebakeStateRoots(data); - long stateHash = roots.length == 0 ? 0L : IO.hashRecursive(roots); - long seedHash = hashSeeds(collectDomainSeeds(seedManager)); - return STATE_VERSION - + "|" - + Long.toUnsignedString(stateHash) - + "|" - + Long.toUnsignedString(seedHash) - + "|" - + (exhaustive ? "X" : "T") - + "|" - + fallbackCacheSize; - } - - private static File[] prebakeStateRoots(IrisData data) { - File dataFolder = data.getDataFolder(); - List roots = new ArrayList<>(); - - for (String folder : PREBAKE_STATE_FOLDERS) { - File candidate = new File(dataFolder, folder); - if (candidate.exists()) { - roots.add(candidate); - } - } - - roots.sort(Comparator.comparing(File::getName)); - return roots.toArray(new File[0]); - } - - private static long hashSeeds(long[] seeds) { - long hash = 1125899906842597L; - for (long seed : seeds) { - hash = (hash * 31L) ^ seed; - } - return hash; - } - - private static File stateFile(IrisData data) { - return stateFile(data.getDataFolder()); - } - - private static File stateFile(File dataFolder) { - return new File(new File(dataFolder, ".cache"), STATE_FILE); - } - - private static boolean isCurrentState(IrisData data, String token) { - File stateFile = stateFile(data); - if (!stateFile.exists()) { - return false; - } - - Properties properties = new Properties(); - try (FileInputStream input = new FileInputStream(stateFile)) { - properties.load(input); - } catch (IOException e) { - return false; - } - - String previous = properties.getProperty("token"); - return token.equals(previous); - } - - private static void writeCurrentState(IrisData data, String token) { - File stateFile = stateFile(data); - File parent = stateFile.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - return; - } - - Properties properties = new Properties(); - properties.setProperty("token", token); - properties.setProperty("updated", Long.toString(System.currentTimeMillis())); - - try (FileOutputStream output = new FileOutputStream(stateFile)) { - properties.store(output, "Iris noisemap prebake state"); - } catch (IOException ignored) { - } - } - - private static String prebakeKey(IrisData data, SeedManager seedManager, String dimensionKey, boolean exhaustive, int fallbackCacheSize) { - return data.getDataFolder().getAbsolutePath() - + "|" - + seedManager.getSeed() - + "|" - + dimensionKey - + "|" - + fallbackCacheSize - + "|" - + (exhaustive ? "X" : "T"); - } - - private static int prebakeStyle(StyleReference reference, IrisData data, long[] domainSeeds, boolean exhaustive, int fallbackCacheSize) { - IrisGeneratorStyle style = reference.style; - if (!isCacheable(style, fallbackCacheSize)) { - return 0; - } - - int styleHash = reference.hash; - - if (exhaustive) { - int variants = 0; - - for (int i = 0; i < domainSeeds.length; i++) { - long mixedSeed = mixSeed(domainSeeds[i], styleHash + (i * 131)); - CNG baked = style.createForPrebake(new RNG(mixedSeed), data, fallbackCacheSize); - if (baked != null) { - variants++; - } - } - - return variants; - } - - int index = Math.floorMod(styleHash, domainSeeds.length); - long primarySeed = mixSeed(domainSeeds[index], styleHash); - int variants = 0; - - CNG primary = style.createForPrebake(new RNG(primarySeed), data, fallbackCacheSize); - if (primary != null) { - variants++; - } - - return variants; - } - - private static boolean isCacheable(IrisGeneratorStyle style, int fallbackCacheSize) { - if (style == null) { - return false; - } - return style.getCacheSize() > 0 || fallbackCacheSize > 0; - } - - private static List collectStyles(IrisData data, int fallbackCacheSize, StartupProgress progress) { - LinkedHashMap styles = new LinkedHashMap<>(); - Collection> loaders = data.getLoaders().values(); - - for (ResourceLoader loader : loaders) { - if (!PREBAKE_LOADERS.contains(loader.getFolderName())) { - continue; - } - - String[] keys = loader.getPossibleKeys(); - - for (String key : keys) { - IrisRegistrant registrant = loader.load(key, false); - if (registrant == null) { - continue; - } - - String rootPath = loader.getFolderName() + ":" + key; - collectFromObject(registrant, rootPath, styles, fallbackCacheSize, progress); - } - } - - return new ArrayList<>(styles.values()); - } - - private static void collectFromObject(Object root, String rootPath, LinkedHashMap styles, int fallbackCacheSize, StartupProgress progress) { - if (root == null) { - return; - } - - IdentityHashMap visited = new IdentityHashMap<>(); - ArrayDeque queue = new ArrayDeque<>(); - queue.add(new Node(root, rootPath)); - - while (!queue.isEmpty()) { - Node node = queue.removeFirst(); - Object value = node.value; - if (value == null) { - continue; - } - - if (value instanceof IrisGeneratorStyle style) { - if (isCacheable(style, fallbackCacheSize)) { - int styleSignature = style.prebakeSignature(); - if (styles.putIfAbsent(styleSignature, new StyleReference(style, styleSignature)) == null && progress != null) { - progress.onStylesDiscovered(1); - } - } - } - - Class type = value.getClass(); - if (isLeafType(type)) { - continue; - } - - if (visited.put(value, Boolean.TRUE) != null) { - continue; - } - - if (type.isArray()) { - int length = Array.getLength(value); - for (int i = 0; i < length; i++) { - Object element = Array.get(value, i); - queue.addLast(new Node(element, node.path + "[" + i + "]")); - } - continue; - } - - if (value instanceof Iterable iterable) { - int index = 0; - for (Object element : iterable) { - queue.addLast(new Node(element, node.path + "[" + index + "]")); - index++; - } - continue; - } - - if (value instanceof Map map) { - for (Map.Entry entry : map.entrySet()) { - Object key = entry.getKey(); - Object entryValue = entry.getValue(); - if (key != null) { - queue.addLast(new Node(key, node.path + "{k}")); - } - if (entryValue != null) { - queue.addLast(new Node(entryValue, node.path + "{v}")); - } - } - continue; - } - - Field[] fields = fieldsOf(type); - for (Field field : fields) { - if (skipField(field)) { - continue; - } - - Object fieldValue; - try { - if (!field.canAccess(value)) { - field.setAccessible(true); - } - fieldValue = field.get(value); - } catch (Throwable ignored) { - continue; - } - - if (fieldValue == null) { - continue; - } - - queue.addLast(new Node(fieldValue, node.path + "." + field.getName())); - } - } - } - - private static List collectStartupTargets() { - LinkedHashMap targets = new LinkedHashMap<>(); - File packsFolder = Iris.instance.getDataFolder("packs"); - File[] packs = packsFolder.listFiles(); - if (packs != null) { - Arrays.sort(packs, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File pack : packs) { - addStartupTarget(targets, pack); - } - } - - File worldContainer = Bukkit.getWorldContainer(); - File[] worlds = worldContainer.listFiles(); - if (worlds != null) { - Arrays.sort(worlds, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File world : worlds) { - if (world == null || !world.isDirectory()) { - continue; - } - - File selfContainedPack = new File(world, "iris/pack"); - addStartupTarget(targets, selfContainedPack); - } - } - - return new ArrayList<>(targets.values()); - } - - private static void addStartupTarget(LinkedHashMap targets, File folder) { - if (!isPrebakeDataFolder(folder)) { - return; - } - - String canonicalPath = toCanonicalPath(folder); - if (targets.containsKey(canonicalPath)) { - return; - } - - targets.put(canonicalPath, new PrebakeTarget(folder, startupLabel(folder))); - } - - private static boolean isPrebakeDataFolder(File folder) { - if (folder == null || !folder.exists() || !folder.isDirectory()) { - return false; - } - - for (String loaderFolder : PREBAKE_LOADERS) { - File candidate = new File(folder, loaderFolder); - if (candidate.exists() && candidate.isDirectory()) { - return true; - } - } - - return false; - } - - private static String startupLabel(File folder) { - File parent = folder.getParentFile(); - if (parent != null && "iris".equalsIgnoreCase(parent.getName())) { - File worldFolder = parent.getParentFile(); - if (worldFolder != null) { - return worldFolder.getName() + "/self-contained"; - } - } - - return folder.getName(); - } - - private static String toCanonicalPath(File folder) { - try { - return folder.getCanonicalPath(); - } catch (IOException e) { - return folder.getAbsolutePath(); - } - } - - private static long[] collectDomainSeeds(SeedManager seedManager) { - if (seedManager == null) { - return NO_SEEDS; - } - - return new long[]{ - seedManager.getSeed(), - seedManager.getComplex(), - seedManager.getComplexStreams(), - seedManager.getBasic(), - seedManager.getHeight(), - seedManager.getComponent(), - seedManager.getScript(), - seedManager.getMantle(), - seedManager.getEntity(), - seedManager.getBiome(), - seedManager.getDecorator(), - seedManager.getTerrain(), - seedManager.getSpawn(), - seedManager.getCarve(), - seedManager.getDeposit(), - seedManager.getPost(), - seedManager.getBodies(), - seedManager.getMode() - }; - } - - private static Field[] fieldsOf(Class type) { - return FIELD_CACHE.computeIfAbsent(type, IrisNoisemapPrebakePipeline::resolveFields); - } - - private static Field[] resolveFields(Class type) { - List fields = new ArrayList<>(); - Class cursor = type; - while (cursor != null && cursor != Object.class) { - Field[] declared = cursor.getDeclaredFields(); - for (Field field : declared) { - fields.add(field); - } - cursor = cursor.getSuperclass(); - } - return fields.toArray(new Field[0]); - } - - private static boolean skipField(Field field) { - int modifiers = field.getModifiers(); - if (Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers) || field.isSynthetic()) { - return true; - } - - Class type = field.getType(); - if (type.isPrimitive()) { - return true; - } - - return isLeafType(type); - } - - private static boolean isLeafType(Class type) { - if (type.isPrimitive() || type.isEnum()) { - return true; - } - - if (type == String.class - || type == Boolean.class - || type == Character.class - || Number.class.isAssignableFrom(type) - || type == Class.class - || type == Locale.class - || type == File.class) { - return true; - } - - String name = type.getName(); - return name.startsWith("java.time.") - || name.startsWith("java.util.concurrent.atomic.") - || name.startsWith("org.bukkit.") - || name.startsWith("net.minecraft.") - || name.startsWith("art.arcane.iris.engine.data.cache.") - || name.equals("art.arcane.volmlib.util.math.RNG"); - } - - private static long mixSeed(long seed, int hash) { - long mixed = seed ^ ((long) hash * 0x9E3779B97F4A7C15L); - mixed ^= mixed >>> 33; - mixed *= 0xff51afd7ed558ccdL; - mixed ^= mixed >>> 33; - mixed *= 0xc4ceb9fe1a85ec53L; - mixed ^= mixed >>> 33; - return mixed; - } - - private static final class Node { - private final Object value; - private final String path; - - private Node(Object value, String path) { - this.value = value; - this.path = path; - } - } - - private static final class StyleReference { - private final IrisGeneratorStyle style; - private final int hash; - - private StyleReference(IrisGeneratorStyle style, int hash) { - this.style = style; - this.hash = hash; - } - } - - private static final class PrebakeRunResult { - private static final PrebakeRunResult SKIPPED = new PrebakeRunResult(false, false, 0); - private static final PrebakeRunResult UNCHANGED = new PrebakeRunResult(false, true, 0); - - private final boolean executed; - private final boolean unchanged; - private final int failures; - - private PrebakeRunResult(boolean executed, boolean unchanged, int failures) { - this.executed = executed; - this.unchanged = unchanged; - this.failures = failures; - } - } - - private static final class PrebakeTarget { - private final File folder; - private final String label; - - private PrebakeTarget(File folder, String label) { - this.folder = folder; - this.label = label; - } - } - - private static final class StartupTargetResult { - private final String label; - private final PrebakeRunResult result; - - private StartupTargetResult(String label, PrebakeRunResult result) { - this.label = label; - this.result = result; - } - } - - private static final class StartupProgress { - private final int targetTotal; - private final AtomicInteger targetsCompleted = new AtomicInteger(); - private final AtomicInteger executedTargets = new AtomicInteger(); - private final AtomicInteger unchangedTargets = new AtomicInteger(); - private final AtomicInteger failedTargets = new AtomicInteger(); - private final AtomicInteger stylesDiscovered = new AtomicInteger(); - private final AtomicInteger stylesFinished = new AtomicInteger(); - - private StartupProgress(int targetTotal) { - this.targetTotal = targetTotal; - } - - private void onStylesDiscovered(int styleCount) { - if (styleCount > 0) { - stylesDiscovered.addAndGet(styleCount); - } - } - - private void onStyleFinished() { - stylesFinished.incrementAndGet(); - } - - private void onTargetCompleted(PrebakeRunResult result) { - targetsCompleted.incrementAndGet(); - if (result.executed) { - executedTargets.incrementAndGet(); - } - if (result.unchanged) { - unchangedTargets.incrementAndGet(); - } - if (result.failures > 0) { - failedTargets.incrementAndGet(); - } - } - - private void onTargetFailed() { - targetsCompleted.incrementAndGet(); - failedTargets.incrementAndGet(); - } - } - - private static final class StartupPrebakeThreadFactory implements ThreadFactory { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "Iris-NoisemapPrebake-" + STARTUP_WORKER_SEQUENCE.incrementAndGet()); - thread.setDaemon(true); - return thread; - } - } -} 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 22c0247da..fe68f1b87 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 @@ -111,6 +111,10 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat void close(); + default boolean isClosing() { + return isClosed(); + } + IrisContext getContext(); double getMaxBiomeObjectDensity(); @@ -121,6 +125,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat boolean isClosed(); + default GenerationSessionManager getGenerationSessions() { + return null; + } + + default GenerationSessionLease acquireGenerationLease(String operation) throws GenerationSessionException { + GenerationSessionManager generationSessions = getGenerationSessions(); + if (generationSessions == null) { + return GenerationSessionLease.noop(); + } + + return generationSessions.acquire(operation); + } + + default long getGenerationSessionId() { + GenerationSessionManager generationSessions = getGenerationSessions(); + return generationSessions == null ? 0L : generationSessions.currentSessionId(); + } + EngineWorldManager getWorldManager(); default UUID getBiomeID(int x, int z) { 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 bfa2832bc..3319e262c 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 @@ -71,6 +71,7 @@ public interface EngineMode extends Staged { @BlockCoordinates default void generate(int x, int z, Hunk blocks, Hunk biomes, boolean multicore) { + IrisContext context = IrisContext.getOr(getEngine()); boolean cacheContext = true; if (J.isFolia()) { org.bukkit.World world = getEngine().getWorld().realWorld(); @@ -79,8 +80,8 @@ public interface EngineMode extends Staged { } } ChunkContext.PrefillPlan prefillPlan = cacheContext ? ChunkContext.PrefillPlan.NO_CAVE : ChunkContext.PrefillPlan.NONE; - ChunkContext ctx = new ChunkContext(x, z, getComplex(), cacheContext, prefillPlan, getEngine().getMetrics()); - IrisContext.getOr(getEngine()).setChunkContext(ctx); + ChunkContext ctx = new ChunkContext(x, z, getComplex(), context.getGenerationSessionId(), cacheContext, prefillPlan, getEngine().getMetrics()); + context.setChunkContext(ctx); EngineStage[] stages = getStages().toArray(new EngineStage[0]); for (EngineStage i : stages) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionException.java b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionException.java new file mode 100644 index 000000000..e9a640112 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionException.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.framework; + +public class GenerationSessionException extends WrongEngineBroException { + private final boolean expectedTeardown; + + public GenerationSessionException(String message) { + this(message, false); + } + + public GenerationSessionException(String message, boolean expectedTeardown) { + super(message); + this.expectedTeardown = expectedTeardown; + } + + public boolean isExpectedTeardown() { + return expectedTeardown; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionLease.java b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionLease.java new file mode 100644 index 000000000..75e45132f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionLease.java @@ -0,0 +1,35 @@ +package art.arcane.iris.engine.framework; + +public final class GenerationSessionLease implements AutoCloseable { + private static final GenerationSessionLease NOOP = new GenerationSessionLease(null, null, 0L); + + private final GenerationSessionManager manager; + private final GenerationSessionManager.GenerationSessionState state; + private final long sessionId; + private boolean released; + + GenerationSessionLease(GenerationSessionManager manager, GenerationSessionManager.GenerationSessionState state, long sessionId) { + this.manager = manager; + this.state = state; + this.sessionId = sessionId; + this.released = false; + } + + public static GenerationSessionLease noop() { + return NOOP; + } + + public long sessionId() { + return sessionId; + } + + @Override + public void close() { + if (released || state == null) { + return; + } + + released = true; + manager.releaseLease(state); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java new file mode 100644 index 000000000..1651a9788 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java @@ -0,0 +1,116 @@ +package art.arcane.iris.engine.framework; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +public final class GenerationSessionManager { + private final AtomicLong sessionSequence; + private final AtomicReference current; + private final Object drainMonitor; + + public GenerationSessionManager() { + this.sessionSequence = new AtomicLong(0L); + this.current = new AtomicReference<>(new GenerationSessionState(nextSessionId(), new AtomicBoolean(true), new AtomicInteger(0), new AtomicBoolean(false), new AtomicReference<>(null))); + this.drainMonitor = new Object(); + } + + public GenerationSessionLease acquire(String operation) throws GenerationSessionException { + while (true) { + GenerationSessionState state = current.get(); + if (state == null || !state.accepting().get()) { + throw rejected(operation, state == null ? null : state); + } + + state.activeLeases().incrementAndGet(); + if (state != current.get()) { + state.activeLeases().decrementAndGet(); + continue; + } + + if (!state.accepting().get()) { + releaseLease(state); + throw rejected(operation, state); + } + + return new GenerationSessionLease(this, state, state.sessionId()); + } + } + + public long currentSessionId() { + GenerationSessionState state = current.get(); + return state == null ? 0L : state.sessionId(); + } + + public int activeLeases() { + GenerationSessionState state = current.get(); + return state == null ? 0 : state.activeLeases().get(); + } + + public void sealAndAwait(String reason, long timeoutMs) throws GenerationSessionException { + sealAndAwait(reason, timeoutMs, false); + } + + public void sealAndAwait(String reason, long timeoutMs, boolean teardown) throws GenerationSessionException { + GenerationSessionState state = current.get(); + if (state == null) { + return; + } + + state.accepting().set(false); + state.teardown().set(teardown); + state.sealReason().set(reason); + long deadline = System.currentTimeMillis() + Math.max(0L, timeoutMs); + synchronized (drainMonitor) { + while (state.activeLeases().get() > 0) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0L) { + break; + } + + try { + drainMonitor.wait(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GenerationSessionException("Generation session " + state.sessionId() + " was interrupted while draining for " + reason + ".", teardown); + } + } + } + + if (state.activeLeases().get() > 0) { + throw new GenerationSessionException("Generation session " + state.sessionId() + " failed to drain for " + reason + " after " + timeoutMs + "ms. Active leases=" + state.activeLeases().get() + ".", teardown); + } + } + + public void activateNextSession() { + current.set(new GenerationSessionState(nextSessionId(), new AtomicBoolean(true), new AtomicInteger(0), new AtomicBoolean(false), new AtomicReference<>(null))); + } + + private long nextSessionId() { + return sessionSequence.incrementAndGet(); + } + + void releaseLease(GenerationSessionState state) { + int remaining = state.activeLeases().decrementAndGet(); + if (remaining <= 0) { + synchronized (drainMonitor) { + drainMonitor.notifyAll(); + } + } + } + + private GenerationSessionException rejected(String operation, GenerationSessionState state) { + long sessionId = state == null ? currentSessionId() : state.sessionId(); + boolean teardown = state != null && state.teardown().get(); + String reason = state == null ? null : state.sealReason().get(); + if (teardown && reason != null && !reason.isBlank()) { + return new GenerationSessionException("Generation session " + sessionId + " rejected new work for " + operation + " during " + reason + ".", true); + } + + return new GenerationSessionException("Generation session " + sessionId + " rejected new work for " + operation + ".", teardown); + } + + record GenerationSessionState(long sessionId, AtomicBoolean accepting, AtomicInteger activeLeases, AtomicBoolean teardown, AtomicReference sealReason) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java index a1189e2dd..35cd18ab9 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; @@ -90,7 +91,12 @@ public interface Locator { static Locator caveOrMantleBiome(String loadKey) { return (e, c) -> { AtomicBoolean found = new AtomicBoolean(false); - e.generateMatter(c.getX(), c.getZ(), true, new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), false)); + try (GenerationSessionLease lease = e.acquireGenerationLease("locator_generate_matter")) { + IrisContext.getOr(e).setGenerationSessionId(lease.sessionId()); + e.generateMatter(c.getX(), c.getZ(), true, new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), lease.sessionId(), false, ChunkContext.PrefillPlan.NONE, null)); + } catch (GenerationSessionException sessionException) { + throw new IllegalStateException(sessionException); + } e.getMantle().getMantle().iterateChunk(c.getX(), c.getZ(), MatterCavern.class, (x, y, z, t) -> { if (found.get()) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java b/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java index 8f1b74749..1208684a5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java @@ -19,4 +19,11 @@ package art.arcane.iris.engine.framework; public class WrongEngineBroException extends Exception { + public WrongEngineBroException() { + super(); + } + + public WrongEngineBroException(String message) { + super(message); + } } 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 ce72b911b..d29f6feb2 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 @@ -18,6 +18,7 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; @@ -81,12 +82,13 @@ public class MantleCarvingComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + IrisComplex complex = context.getComplex(); IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); Long2ObjectOpenHashMap caveBiomeCache = new Long2ObjectOpenHashMap<>(FIELD_SIZE * FIELD_SIZE); BlendScratch blendScratch = BLEND_SCRATCH.get(); int[] chunkSurfaceHeights = prepareChunkSurfaceHeights(x, z, context, blendScratch.chunkSurfaceHeights); PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start(); - List weightedProfiles = resolveWeightedProfiles(x, z, resolverState, caveBiomeCache); + List weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState, caveBiomeCache); getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds()); for (WeightedProfile weightedProfile : weightedProfiles) { carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights); @@ -99,7 +101,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, weightedProfile.worldYRange, chunkSurfaceHeights); } - private List resolveWeightedProfiles(int chunkX, int chunkZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { + private List resolveWeightedProfiles(int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { BlendScratch blendScratch = BLEND_SCRATCH.get(); IrisCaveProfile[] profileField = blendScratch.profileField; Map tileProfileWeights = blendScratch.tileProfileWeights; @@ -107,7 +109,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { IrisCaveProfile[] kernelProfiles = blendScratch.kernelProfiles; double[] kernelProfileWeights = blendScratch.kernelProfileWeights; activeProfiles.clear(); - fillProfileField(profileField, chunkX, chunkZ, resolverState, caveBiomeCache); + fillProfileField(profileField, chunkX, chunkZ, complex, resolverState, caveBiomeCache); for (int tileX = 0; tileX < TILE_COUNT; tileX++) { for (int tileZ = 0; tileZ < TILE_COUNT; tileZ++) { @@ -313,7 +315,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { return (tileX * TILE_COUNT) + tileZ; } - private void fillProfileField(IrisCaveProfile[] profileField, int chunkX, int chunkZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { + private void fillProfileField(IrisCaveProfile[] profileField, int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { int startX = (chunkX << 4) - BLEND_RADIUS; int startZ = (chunkZ << 4) - BLEND_RADIUS; @@ -321,7 +323,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { int worldX = startX + fieldX; for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) { int worldZ = startZ + fieldZ; - profileField[(fieldX * FIELD_SIZE) + fieldZ] = resolveColumnProfile(worldX, worldZ, resolverState, caveBiomeCache); + profileField[(fieldX * FIELD_SIZE) + fieldZ] = resolveColumnProfile(worldX, worldZ, complex, resolverState, caveBiomeCache); } } } @@ -336,14 +338,14 @@ public class MantleCarvingComponent extends IrisMantleComponent { return -1; } - private IrisCaveProfile resolveColumnProfile(int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { + private IrisCaveProfile resolveColumnProfile(int worldX, int worldZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache) { IrisCaveProfile resolved = null; IrisCaveProfile dimensionProfile = getDimension().getCaveProfile(); if (isProfileEnabled(dimensionProfile)) { resolved = dimensionProfile; } - IrisRegion region = getComplex().getRegionStream().get(worldX, worldZ); + IrisRegion region = complex.getRegionStream().get(worldX, worldZ); if (region != null) { IrisCaveProfile regionProfile = region.getCaveProfile(); if (isProfileEnabled(regionProfile)) { @@ -351,7 +353,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { } } - IrisBiome surfaceBiome = getComplex().getTrueBiomeStream().get(worldX, worldZ); + IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(worldX, worldZ); if (surfaceBiome != null) { IrisCaveProfile surfaceProfile = surfaceBiome.getCaveProfile(); if (isProfileEnabled(surfaceProfile)) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java index 6b016309d..3987d7fed 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; @@ -39,11 +40,12 @@ public class MantleFluidBodyComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + IrisComplex complex = context.getComplex(); RNG rng = new RNG(Cache.key(x, z) + seed() + 405666); int xxx = 8 + (x << 4); int zzz = 8 + (z << 4); - IrisRegion region = getComplex().getRegionStream().get(xxx, zzz); - IrisBiome biome = getComplex().getTrueBiomeStream().get(xxx, zzz); + IrisRegion region = complex.getRegionStream().get(xxx, zzz); + IrisBiome biome = complex.getTrueBiomeStream().get(xxx, zzz); generate(writer, rng, x, z, region, biome); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 90c3c2df2..277ea364e 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -20,8 +20,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.Iris; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; @@ -40,8 +40,6 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterStructurePOI; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.NoiseType; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.scheduling.J; import org.bukkit.util.BlockVector; import java.io.IOException; @@ -64,12 +62,13 @@ public class MantleObjectComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + IrisComplex complex = context.getComplex(); boolean traceRegen = isRegenTraceThread(); RNG rng = applyNoise(x, z, Cache.key(x, z) + seed()); int xxx = 8 + (x << 4); int zzz = 8 + (z << 4); - IrisRegion region = getComplex().getRegionStream().get(xxx, zzz); - IrisBiome surfaceBiome = getComplex().getTrueBiomeStream().get(xxx, zzz); + IrisRegion region = complex.getRegionStream().get(xxx, zzz); + IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(xxx, zzz); int surfaceY = getEngineMantle().getEngine().getHeight(xxx, zzz, true); IrisBiome caveBiome = resolveCaveObjectBiome(xxx, zzz, surfaceY, surfaceBiome); if (traceRegen) { @@ -82,7 +81,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " regionSurfacePlacers=" + region.getSurfaceObjects().size() + " regionCavePlacers=" + region.getCarvingObjects().size()); } - ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, traceRegen); + ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen); if (traceRegen) { Iris.info("Regen object layer done: chunk=" + x + "," + z + " biomeSurfacePlacersChecked=" + summary.biomeSurfacePlacersChecked() @@ -142,7 +141,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, boolean traceRegen) { + private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen) { int biomeSurfaceChecked = 0; int biomeSurfaceTriggered = 0; int biomeCaveChecked = 0; @@ -175,7 +174,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { biomeSurfaceTriggered++; try { - ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, biomeSurfaceExclusionDepth, traceRegen, x, z, "biome-surface"); + ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, biomeSurfaceExclusionDepth, complex, traceRegen, x, z, "biome-surface"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -209,7 +208,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { biomeCaveTriggered++; try { - ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, biomeCaveProfile, traceRegen, x, z, "biome-cave"); + ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, biomeCaveProfile, complex, traceRegen, x, z, "biome-cave"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -240,7 +239,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { regionSurfaceTriggered++; try { - ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, regionSurfaceExclusionDepth, traceRegen, x, z, "region-surface"); + ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, regionSurfaceExclusionDepth, complex, traceRegen, x, z, "region-surface"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -274,7 +273,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { regionCaveTriggered++; try { - ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, regionCaveProfile, traceRegen, x, z, "region-cave"); + ObjectPlacementResult result = placeCaveObject(writer, rng, x, z, i, regionCaveProfile, complex, traceRegen, x, z, "region-cave"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -316,6 +315,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int z, IrisObjectPlacement objectPlacement, int surfaceObjectExclusionDepth, + IrisComplex complex, boolean traceRegen, int chunkX, int chunkZ, @@ -330,7 +330,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (int i = 0; i < density; i++) { attempts++; - IrisObject v = objectPlacement.getScale().get(rng, objectPlacement.getObject(getComplex(), rng)); + IrisObject v = objectPlacement.getScale().get(rng, objectPlacement.getObject(complex, rng)); if (v == null) { nullObjects++; if (traceRegen) { @@ -398,6 +398,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int chunkZ, IrisObjectPlacement objectPlacement, IrisCaveProfile caveProfile, + IrisComplex complex, boolean traceRegen, int metricChunkX, int metricChunkZ, @@ -419,7 +420,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (int i = 0; i < density; i++) { attempts++; - IrisObject object = objectPlacement.getScale().get(rng, objectPlacement.getObject(getComplex(), rng)); + IrisObject object = objectPlacement.getScale().get(rng, objectPlacement.getObject(complex, rng)); if (object == null) { nullObjects++; if (traceRegen) { @@ -903,15 +904,15 @@ public class MantleObjectComponent extends IrisMantleComponent { } protected int computeRadius() { - var dimension = getDimension(); + IrisDimension dimension = getDimension(); AtomicInteger xg = new AtomicInteger(); AtomicInteger zg = new AtomicInteger(); KSet objects = new KSet<>(); KMap> scalars = new KMap<>(); - for (var region : dimension.getAllRegions(this::getData)) { - for (var j : region.getObjects()) { + for (IrisRegion region : dimension.getAllRegions(this::getData)) { + for (IrisObjectPlacement j : region.getObjects()) { if (j.getScale().canScaleBeyond()) { scalars.put(j.getScale(), j.getPlace()); } else { @@ -919,8 +920,8 @@ public class MantleObjectComponent extends IrisMantleComponent { } } } - for (var biome : dimension.getAllBiomes(this::getData)) { - for (var j : biome.getObjects()) { + for (IrisBiome biome : dimension.getAllBiomes(this::getData)) { + for (IrisObjectPlacement j : biome.getObjects()) { if (j.getScale().canScaleBeyond()) { scalars.put(j.getScale(), j.getPlace()); } else { @@ -929,93 +930,59 @@ public class MantleObjectComponent extends IrisMantleComponent { } } - BurstExecutor e = getEngineMantle().getTarget().getBurster().burst(objects.size()); - boolean maintenanceFolia = false; - if (J.isFolia()) { - var world = getEngineMantle().getEngine().getWorld().realWorld(); - maintenanceFolia = world != null && IrisToolbelt.isWorldMaintenanceActive(world); - } - if (maintenanceFolia) { - Iris.info("MantleObjectComponent radius scan using single-threaded mode during maintenance regen."); - e.setMulticore(false); - } KMap sizeCache = new KMap<>(); for (String i : objects) { - e.queue(() -> { - try { - BlockVector bv = sizeCache.computeIfAbsent(i, (k) -> { - try { - return IrisObject.sampleSize(getData().getObjectLoader().findFile(i)); - } catch (IOException ex) { - Iris.reportError(ex); - ex.printStackTrace(); - } - - return null; - }); - - if (bv == null) { - throw new RuntimeException(); - } - - if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) { - Iris.warn("Object " + i + " has a large size (" + bv + ") and may increase memory usage!"); - } - - synchronized (xg) { - xg.getAndSet(Math.max(bv.getBlockX(), xg.get())); - } - - synchronized (zg) { - zg.getAndSet(Math.max(bv.getBlockZ(), zg.get())); - } - } catch (Throwable ed) { - Iris.reportError(ed); - - } - }); + updateRadiusBounds(sizeCache, xg, zg, i, 1D); } for (Map.Entry> entry : scalars.entrySet()) { double ms = entry.getKey().getMaximumScale(); for (String j : entry.getValue()) { - e.queue(() -> { - try { - BlockVector bv = sizeCache.computeIfAbsent(j, (k) -> { - try { - return IrisObject.sampleSize(getData().getObjectLoader().findFile(j)); - } catch (IOException ioException) { - Iris.reportError(ioException); - ioException.printStackTrace(); - } - - return null; - }); - - if (bv == null) { - throw new RuntimeException(); - } - - if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) { - Iris.warn("Object " + j + " has a large size (" + bv + ") and may increase memory usage! (Object scaled up to " + Form.pc(ms, 2) + ")"); - } - - synchronized (xg) { - xg.getAndSet((int) Math.max(Math.ceil(bv.getBlockX() * ms), xg.get())); - } - - synchronized (zg) { - zg.getAndSet((int) Math.max(Math.ceil(bv.getBlockZ() * ms), zg.get())); - } - } catch (Throwable ee) { - Iris.reportError(ee); - - } - }); + updateRadiusBounds(sizeCache, xg, zg, j, ms); } } - e.complete(); return Math.max(xg.get(), zg.get()); } + + private void updateRadiusBounds( + KMap sizeCache, + AtomicInteger xg, + AtomicInteger zg, + String objectKey, + double scale + ) { + try { + BlockVector bv = loadObjectSize(sizeCache, objectKey); + if (bv == null) { + throw new RuntimeException(); + } + + if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) { + if (scale > 1D) { + Iris.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage! (Object scaled up to " + Form.pc(scale, 2) + ")"); + } else { + Iris.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage!"); + } + } + + xg.getAndSet(Math.max((int) Math.ceil(bv.getBlockX() * scale), xg.get())); + zg.getAndSet(Math.max((int) Math.ceil(bv.getBlockZ() * scale), zg.get())); + } catch (Throwable e) { + Iris.reportError(e); + } + } + + private BlockVector loadObjectSize(KMap sizeCache, String objectKey) { + return sizeCache.computeIfAbsent(objectKey, k -> { + try { + return IrisObject.sampleSize(getData().getObjectLoader().findFile(objectKey)); + } catch (IOException e) { + Iris.reportError(e); + e.printStackTrace(); + } + + return null; + }); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java b/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java new file mode 100644 index 000000000..6e33ebf53 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java @@ -0,0 +1,51 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.util.common.data.B; +import org.bukkit.block.data.BlockData; + +import java.util.function.Function; + +final class BlockDataMergeSupport { + private BlockDataMergeSupport() { + } + + static BlockData merge(BlockData base, BlockData update) { + return merge(base, update, B::get); + } + + static BlockData merge(BlockData base, BlockData update, Function resolver) { + try { + return base.merge(update); + } catch (IllegalArgumentException e) { + BlockData normalizedBase = resolve(base, resolver); + BlockData normalizedUpdate = resolve(update, resolver); + + if (normalizedBase != null && normalizedUpdate != null) { + try { + return normalizedBase.merge(normalizedUpdate); + } catch (IllegalArgumentException ignored) { + return normalizedUpdate; + } + } + + if (normalizedUpdate != null) { + return normalizedUpdate; + } + + return update; + } + } + + private static BlockData resolve(BlockData data, Function resolver) { + if (data == null || resolver == null) { + return null; + } + + String serialized = data.getAsString(false); + if (serialized == null || serialized.isBlank()) { + return null; + } + + return resolver.apply(serialized); + } +} 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 a368568bd..369351397 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 @@ -564,33 +564,52 @@ public class IrisDimension extends IrisRegistrant { } } - public Dimension getBaseDimension() { - return switch (getEnvironment()) { - case NETHER -> Dimension.NETHER; - case THE_END -> Dimension.END; - default -> Dimension.OVERWORLD; - }; - } - - public String getDimensionTypeKey() { - return getDimensionType().key(); - } - - public IrisDimensionType getDimensionType() { - return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight()); - } - - public void installDimensionType(IDataFixer fixer, KList folders) { - IrisDimensionType type = getDimensionType(); - String json = type.toJson(fixer); - - Iris.verbose(" Installing Data Pack Dimension Type: \"iris:" + type.key() + '"'); - for (File datapacks : folders) { - File output = new File(datapacks, "iris/data/iris/dimension_type/" + type.key() + ".json"); - output.getParentFile().mkdirs(); - try { - IO.writeAll(output, json); - } catch (IOException e) { + public Dimension getBaseDimension() { + return switch (getEnvironment()) { + case NETHER -> Dimension.NETHER; + case THE_END -> Dimension.END; + default -> Dimension.OVERWORLD; + }; + } + + public String getDimensionTypeKey() { + return sanitizeDimensionTypeKeyValue(getLoadKey()); + } + + public static String sanitizeDimensionTypeKeyValue(String value) { + if (value == null || value.isBlank()) { + return "dimension"; + } + + String sanitized = value.trim().toLowerCase(Locale.ROOT).replace("\\", "/"); + sanitized = sanitized.replaceAll("[^a-z0-9_\\-./]", "_"); + sanitized = sanitized.replaceAll("/+", "/"); + sanitized = sanitized.replaceAll("^/+", ""); + sanitized = sanitized.replaceAll("/+$", ""); + if (sanitized.contains("..")) { + sanitized = sanitized.replace("..", "_"); + } + + sanitized = sanitized.replace("/", "_"); + return sanitized.isBlank() ? "dimension" : sanitized; + } + + public IrisDimensionType getDimensionType() { + return new IrisDimensionType(getBaseDimension(), getDimensionOptions(), getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight()); + } + + public void installDimensionType(IDataFixer fixer, KList folders) { + IrisDimensionType type = getDimensionType(); + String json = type.toJson(fixer); + String dimensionTypeKey = getDimensionTypeKey(); + + Iris.verbose(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"'); + for (File datapacks : folders) { + File output = new File(datapacks, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json"); + output.getParentFile().mkdirs(); + try { + IO.writeAll(output, json); + } catch (IOException e) { Iris.reportError(e); e.printStackTrace(); } 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 181fc100c..062d6f60c 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 @@ -969,7 +969,7 @@ public class IrisObject extends IrisRegistrant { BlockData newData = j.getReplace(rng, i.getX() + x, i.getY() + y, i.getZ() + z, rdata).clone(); if (newData.getMaterial() == data.getMaterial() && !(newData instanceof IrisCustomData || data instanceof IrisCustomData)) - data = data.merge(newData); + data = BlockDataMergeSupport.merge(data, newData); else data = newData; @@ -1093,7 +1093,7 @@ public class IrisObject extends IrisRegistrant { BlockData newData = j.getReplace(rng, i.getX() + x, i.getY() + y, i.getZ() + z, rdata).clone(); if (newData.getMaterial() == d.getMaterial()) { - d = d.merge(newData); + d = BlockDataMergeSupport.merge(d, newData); } else { d = newData; } 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 66ab73b42..c911f987e 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 @@ -30,6 +30,7 @@ import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.StudioMode; @@ -93,10 +94,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final AtomicInteger a = new AtomicInteger(0); private final CompletableFuture spawnChunks = new CompletableFuture<>(); private final AtomicCache targetCache = new AtomicCache<>(); + private final AtomicReference> closeFuture = new AtomicReference<>(); private volatile Engine engine; private volatile Looper hotloader; private volatile StudioMode lastMode; private volatile DummyBiomeProvider dummyBiomeProvider; + private volatile boolean closing; @Setter private volatile StudioGenerator studioGenerator; @@ -118,6 +121,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun new KList<>(".iris"), new KList<>() ); + this.closing = false; Bukkit.getServer().getPluginManager().registerEvents(this, Iris.instance); } @@ -142,6 +146,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun try { INMS.get().inject(world.getSeed(), engine, world); Iris.info("Injected Iris Biome Source into " + world.getName()); + J.s(() -> updateSpawnLocation(world), 1); } catch (Throwable e) { Iris.reportError(e); Iris.error("Failed to inject biome source into " + world.getName()); @@ -156,15 +161,65 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Nullable @Override public Location getFixedSpawnLocation(@NotNull World world, @NotNull Random random) { - Location location = new Location(world, 0, 64, 0); - PaperLib.getChunkAtAsync(location) - .thenAccept(c -> { - World w = c.getWorld(); - if (!w.getSpawnLocation().equals(location)) - return; - w.setSpawnLocation(location.add(0, w.getHighestBlockYAt(location) - 64, 0)); - }); - return location; + return getInitialSpawnLocation(world); + } + + @Override + public Location getInitialSpawnLocation(World world) { + int minY = world.getMinHeight() + 1; + int maxY = world.getMaxHeight() - 2; + int y = Math.max(minY, Math.min(maxY, 96)); + return new Location(world, 0.5D, y, 0.5D); + } + + private void updateSpawnLocation(World world) { + Location initialSpawn = getInitialSpawnLocation(world); + int chunkX = initialSpawn.getBlockX() >> 4; + int chunkZ = initialSpawn.getBlockZ() >> 4; + CompletableFuture chunkFuture = requestChunkAsync(world, chunkX, chunkZ, true); + if (chunkFuture == null) { + return; + } + + chunkFuture.thenAccept(chunk -> + J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> applySpawnLocation(chunk.getWorld(), initialSpawn))); + } + + private void applySpawnLocation(World world, Location initialSpawn) { + Location currentSpawn = world.getSpawnLocation(); + if (currentSpawn == null) { + return; + } + + if (!studio && (currentSpawn.getBlockX() != initialSpawn.getBlockX() || currentSpawn.getBlockZ() != initialSpawn.getBlockZ())) { + return; + } + + int minY = world.getMinHeight() + 1; + int maxY = world.getMaxHeight() - 2; + int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn))); + world.setSpawnLocation(new Location(world, initialSpawn.getX(), y, initialSpawn.getZ(), initialSpawn.getYaw(), initialSpawn.getPitch())); + } + + @SuppressWarnings("unchecked") + private CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) { + try { + Object result = World.class + .getMethod("getChunkAtAsync", int.class, int.class, boolean.class) + .invoke(world, chunkX, chunkZ, generate); + if (result instanceof CompletableFuture) { + return (CompletableFuture) result; + } + if (PaperLib.isPaper()) { + return CompletableFuture.failedFuture(new IllegalStateException("Paper World#getChunkAtAsync returned a non-future result.")); + } + } catch (Throwable e) { + if (PaperLib.isPaper()) { + return CompletableFuture.failedFuture(new IllegalStateException("Paper World#getChunkAtAsync is unavailable.", e)); + } + } + + return PaperLib.getChunkAtAsync(world, chunkX, chunkZ, generate); } private void setupEngine() { @@ -530,18 +585,48 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void close() { - withExclusiveControl(() -> { - if (isStudio()) { - hotloader.interrupt(); + closeAsync(); + } + + @Override + public CompletableFuture closeAsync() { + CompletableFuture existing = closeFuture.get(); + if (existing != null && !existing.isDone()) { + return existing; + } + + closing = true; + CompletableFuture future = withExclusiveControlFuture(() -> { + Looper activeHotloader = hotloader; + hotloader = null; + if (isStudio() && activeHotloader != null) { + activeHotloader.interrupt(); + try { + activeHotloader.join(1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Iris.reportError(e); + } } - final Engine engine = getEngine(); - if (engine != null && !engine.isClosed()) - engine.close(); + Engine currentEngine = engine; + if (currentEngine != null && !currentEngine.isClosed()) { + currentEngine.close(); + } folder.clear(); populators.clear(); - }); + if (!closeFuture.compareAndSet(existing, future)) { + CompletableFuture winningFuture = closeFuture.get(); + return winningFuture == null ? future : winningFuture; + } + + future.whenComplete((ignored, throwable) -> { + if (throwable != null) { + closeFuture.compareAndSet(future, null); + } + }); + return future; } @Override @@ -551,7 +636,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void hotload() { - if (!isStudio()) { + if (!isStudio() || closing) { return; } @@ -570,6 +655,22 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun }); } + public CompletableFuture withExclusiveControlFuture(Runnable r) { + CompletableFuture future = new CompletableFuture<>(); + J.a(() -> { + try { + loadLock.acquire(LOAD_LOCKS); + r.run(); + future.complete(null); + } catch (Throwable e) { + future.completeExceptionally(e); + } finally { + loadLock.release(LOAD_LOCKS); + } + }); + return future; + } + @Override public void touch(World world) { getEngine(world); @@ -577,6 +678,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void generateNoise(@NotNull WorldInfo world, @NotNull Random random, int x, int z, @NotNull ChunkGenerator.ChunkData d) { + if (closing) { + return; + } + try { Engine engine = getEngine(world); computeStudioGenerator(); @@ -592,6 +697,21 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } Iris.debug("Generated " + x + " " + z); + } catch (GenerationSessionException e) { + if (closing || isExpectedTeardown(engine, e)) { + return; + } + + Iris.error("======================================"); + e.printStackTrace(); + Iris.reportErrorChunk(x, z, e, "CHUNK"); + Iris.error("======================================"); + + for (int i = 0; i < 16; i++) { + for (int j = 0; j < 16; j++) { + d.setBlock(i, 0, j, Material.RED_GLAZED_TERRACOTTA.createBlockData()); + } + } } catch (Throwable e) { Iris.error("======================================"); e.printStackTrace(); @@ -606,6 +726,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + private boolean isExpectedTeardown(Engine currentEngine, Throwable throwable) { + if (throwable instanceof GenerationSessionException generationSessionException && generationSessionException.isExpectedTeardown()) { + return true; + } + + if (currentEngine != null && currentEngine.isClosing()) { + return true; + } + + World realWorld = this.world.realWorld(); + return realWorld != null && IrisToolbelt.isWorldMaintenanceActive(realWorld); + } + @Override public int getBaseHeight(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull HeightMap heightMap) { Engine currentEngine = engine; diff --git a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java index efa68be4c..74aaa6340 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.Hotloadable; import art.arcane.iris.util.common.data.DataProvider; +import org.bukkit.Location; import org.bukkit.World; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -53,9 +54,21 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { void close(); + default CompletableFuture closeAsync() { + close(); + return CompletableFuture.completedFuture(null); + } + boolean isStudio(); + default boolean isClosing() { + return false; + } + void touch(World world); CompletableFuture getSpawnChunks(); + + @Nullable + Location getInitialSpawnLocation(World world); } 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 8d41307a2..5e3a5542e 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 @@ -431,6 +431,10 @@ public class VolmitSender implements CommandSender { return m.removeDuplicates().convert((iff) -> iff.replaceAll("\\Q \\E", " ")).toString("\n"); } + static String escapeMiniMessageQuotedText(String text) { + return text.replace("\\", "\\\\").replace("'", "\\'"); + } + public void sendHeader(String name, int overrideLength) { int len = overrideLength; int h = name.length() + 2; @@ -469,7 +473,8 @@ public class VolmitSender implements CommandSender { if (v.getNodes().isNotEmpty()) { sendHeader(v.getPath() + (page > 0 ? (" {" + (page + 1) + "}") : "")); if (isPlayer() && v.getParent() != null) { - sendMessageRaw("Click to go back to <#32bfad>" + Form.capitalize(v.getParent().getName()) + " Help" + "'><#6fe98f>〈 Back"); + String backHover = escapeMiniMessageQuotedText("<#2b7a3f>Click to go back to <#32bfad>" + Form.capitalize(v.getParent().getName()) + " Help"); + sendMessageRaw("<#6fe98f>〈 Back"); } AtomicBoolean next = new AtomicBoolean(false); @@ -481,13 +486,15 @@ public class VolmitSender implements CommandSender { int l = 75 - (page > 0 ? 10 : 0) - (next.get() ? 10 : 0); if (page > 0) { - s += "Click to go back to page " + page + "'>〈 Page " + page + " "; + String previousPageHover = escapeMiniMessageQuotedText("Click to go back to page " + page); + s += "〈 Page " + page + " "; } s += "" + Form.repeat(" ", l) + ""; if (next.get()) { - s += " Click to go to back to page " + (page + 2) + "'>Page " + (page + 2) + " ❭"; + String nextPageHover = escapeMiniMessageQuotedText("Click to go to back to page " + (page + 2)); + s += " Page " + (page + 2) + " ❭"; } sendMessageRaw(s); @@ -550,13 +557,11 @@ public class VolmitSender implements CommandSender { nUsage = "<#3fbe6f>✔ <#9de5b6>This parameter is optional."; } String type = "<#4fbf7f>✢ <#8ad9af>This parameter is of type " + p.getType().getSimpleName() + "."; + String parameterHover = escapeMiniMessageQuotedText(nHoverTitle + newline + nDescription + newline + nUsage + newline + type); nodes .append("") .append(fullTitle) .append(""); @@ -565,12 +570,16 @@ public class VolmitSender implements CommandSender { nodes = new StringBuilder(" - Category of Commands"); } - return "" + " height; private final ChunkedDataCache biome; private final ChunkedDataCache cave; @@ -23,20 +25,26 @@ public class ChunkContext { private final ChunkedDataCache region; public ChunkContext(int x, int z, IrisComplex complex) { - this(x, z, complex, true, PrefillPlan.NO_CAVE, null); + this(x, z, complex, 0L, true, PrefillPlan.NO_CAVE, null); } public ChunkContext(int x, int z, IrisComplex complex, boolean cache) { - this(x, z, complex, cache, PrefillPlan.NO_CAVE, null); + this(x, z, complex, 0L, cache, PrefillPlan.NO_CAVE, null); } public ChunkContext(int x, int z, IrisComplex complex, boolean cache, EngineMetrics metrics) { - this(x, z, complex, cache, PrefillPlan.NO_CAVE, metrics); + this(x, z, complex, 0L, cache, PrefillPlan.NO_CAVE, metrics); } public ChunkContext(int x, int z, IrisComplex complex, boolean cache, PrefillPlan prefillPlan, EngineMetrics metrics) { + this(x, z, complex, 0L, cache, prefillPlan, metrics); + } + + public ChunkContext(int x, int z, IrisComplex complex, long generationSessionId, boolean cache, PrefillPlan prefillPlan, EngineMetrics metrics) { this.x = x; this.z = z; + this.complex = complex; + this.generationSessionId = generationSessionId; this.height = new ChunkedDataCache<>(complex.getHeightStream(), x, z, cache); this.biome = new ChunkedDataCache<>(complex.getTrueBiomeStream(), x, z, cache); this.cave = new ChunkedDataCache<>(complex.getCaveBiomeStream(), x, z, cache); @@ -68,7 +76,7 @@ public class ChunkContext { fillTasks.add(new PrefillFillTask(cave)); } - if (fillTasks.size() <= 1 || Iris.instance == null) { + if (!shouldPrefillAsync(fillTasks.size())) { for (PrefillFillTask fillTask : fillTasks) { fillTask.run(); } @@ -88,6 +96,15 @@ public class ChunkContext { } } + static boolean shouldPrefillAsync(int fillTaskCount) { + if (fillTaskCount <= 1 || Iris.instance == null) { + return false; + } + + String threadName = Thread.currentThread().getName(); + return threadName != null && threadName.startsWith("Iris "); + } + public int getX() { return x; } @@ -96,6 +113,10 @@ public class ChunkContext { return z; } + public IrisComplex getComplex() { + return complex; + } + public ChunkedDataCache getHeight() { return height; } diff --git a/core/src/main/java/art/arcane/iris/util/project/context/IrisContext.java b/core/src/main/java/art/arcane/iris/util/project/context/IrisContext.java index a763b699e..8f024d918 100644 --- a/core/src/main/java/art/arcane/iris/util/project/context/IrisContext.java +++ b/core/src/main/java/art/arcane/iris/util/project/context/IrisContext.java @@ -32,6 +32,7 @@ public class IrisContext { private static final ChronoLatch cl = new ChronoLatch(60000); private final Engine engine; private ChunkContext chunkContext; + private long generationSessionId; public IrisContext(Engine engine) { this.engine = engine; @@ -100,6 +101,7 @@ public class IrisContext { return new KMap() .qput("studio", engine.isStudio()) .qput("closed", engine.isClosed()) + .qput("generationSessionId", generationSessionId) .qput("pack", new KMap<>() .qput("key", dimension == null ? "" : dimension.getLoadKey()) .qput("version", dimension == null ? "" : dimension.getVersion()) diff --git a/core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java b/core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java new file mode 100644 index 000000000..0d3b768e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisDiagnosticsTest { + @Test + public void reportErrorWithContextPrintsFullStacktrace() { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); + try { + Iris.reportError("Runtime world creation failed.", new IllegalStateException("outer", new IllegalArgumentException("inner"))); + } finally { + System.setErr(originalErr); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("Runtime world creation failed.")); + assertTrue(text.contains("IllegalStateException")); + assertTrue(text.contains("IllegalArgumentException")); + assertTrue(text.contains("inner")); + } + + @Test + public void collectSplashPacksSkipsInternalAndInvalidFolders() throws Exception { + Path root = Files.createTempDirectory("iris-splash"); + try { + Path validPack = root.resolve("overworld"); + Files.createDirectories(validPack.resolve("dimensions")); + Files.writeString(validPack.resolve("dimensions").resolve("overworld.json"), "{\"version\":\"4000\"}"); + + Files.createDirectories(root.resolve("datapack-imports")); + + Path brokenPack = root.resolve("broken"); + Files.createDirectories(brokenPack.resolve("dimensions")); + Files.writeString(brokenPack.resolve("dimensions").resolve("broken.json"), "{"); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); + List packs; + try { + packs = Iris.collectSplashPacks(root.toFile()); + } finally { + System.setErr(originalErr); + } + + assertEquals(1, packs.size()); + assertEquals("overworld", packs.get(0).name()); + assertEquals("4000", packs.get(0).version()); + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("Failed to read splash metadata for dimension pack \"broken\".")); + assertTrue(text.contains("Json")); + } finally { + Files.walk(root) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisRuntimeSchedulerModeRoutingTest.java b/core/src/test/java/art/arcane/iris/core/IrisRuntimeSchedulerModeRoutingTest.java index 659df6d0b..44d17c23e 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisRuntimeSchedulerModeRoutingTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisRuntimeSchedulerModeRoutingTest.java @@ -37,6 +37,16 @@ public class IrisRuntimeSchedulerModeRoutingTest { assertEquals(IrisRuntimeSchedulerMode.FOLIA, resolved); } + @Test + public void autoResolvesToPaperLikeOnCanvasBranding() { + installServer("Canvas", "git-Canvas-101 (MC: 1.21.11)"); + IrisSettings.IrisSettingsPregen pregen = new IrisSettings.IrisSettingsPregen(); + pregen.runtimeSchedulerMode = IrisRuntimeSchedulerMode.AUTO; + + IrisRuntimeSchedulerMode resolved = IrisRuntimeSchedulerMode.resolve(pregen); + assertEquals(IrisRuntimeSchedulerMode.PAPER_LIKE, resolved); + } + @Test public void explicitModeBypassesAutoDetection() { installServer("Purpur", "git-Purpur-2562 (MC: 1.21.11)"); diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFolderTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFolderTest.java new file mode 100644 index 000000000..7fbd17956 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFolderTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.core; + +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ServerConfiguratorDatapackFolderTest { + @Test + public void resolvesDimensionWorldFolderBackToRootDatapacks() { + File folder = new File("/tmp/server/world/dimensions/minecraft/overworld"); + File datapacks = ServerConfigurator.resolveDatapacksFolder(folder); + assertEquals(new File("/tmp/server/world/datapacks").getAbsolutePath(), datapacks.getAbsolutePath()); + } + + @Test + public void keepsStandaloneWorldFolderDatapacksUnchanged() { + File folder = new File("/tmp/server/custom_world"); + File datapacks = ServerConfigurator.resolveDatapacksFolder(folder); + assertEquals(new File("/tmp/server/custom_world/datapacks").getAbsolutePath(), datapacks.getAbsolutePath()); + } + + @Test + public void installFoldersIncludeExtraStudioWorldDatapackTargets() { + File baseFolder = new File("/tmp/server/world/datapacks"); + File extraFolder = new File("/tmp/server/iris-studio/datapacks"); + KList baseFolders = new KList<>(); + baseFolders.add(baseFolder); + KList extraFolders = new KList<>(); + extraFolders.add(extraFolder); + KMap> extrasByPack = new KMap<>(); + extrasByPack.put("overworld", extraFolders); + + KList folders = ServerConfigurator.collectInstallDatapackFolders(baseFolders, extrasByPack); + + assertEquals(2, folders.size()); + assertTrue(folders.contains(baseFolder)); + assertTrue(folders.contains(extraFolder)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/StudioRuntimeCleanupTest.java b/core/src/test/java/art/arcane/iris/core/StudioRuntimeCleanupTest.java new file mode 100644 index 000000000..fe6ee5b9c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/StudioRuntimeCleanupTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.commands.CommandStudio; +import art.arcane.iris.core.tools.IrisCreator; +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.assertFalse; + +public class StudioRuntimeCleanupTest { + @Test + public void pregenSettingsNoLongerExposeStartupNoisemapPrebake() { + boolean found = Arrays.stream(IrisSettings.IrisSettingsPregen.class.getDeclaredFields()) + .anyMatch(field -> field.getName().equals("startupNoisemapPrebake")); + + assertFalse(found); + } + + @Test + public void studioCommandNoLongerExposesProfilecache() { + boolean found = Arrays.stream(CommandStudio.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().equals("profilecache")); + + assertFalse(found); + } + + @Test + public void studioCreatorNoLongerContainsPrewarmOrPrebakeHelpers() { + boolean found = Arrays.stream(IrisCreator.class.getDeclaredMethods()) + .map(method -> method.getName().toLowerCase()) + .anyMatch(name -> name.contains("prewarm") || name.contains("prebake")); + + assertFalse(found); + } + + @Test + public void noisemapPrebakePipelineClassIsRemoved() { + try { + Class.forName("art.arcane.iris.engine.IrisNoisemapPrebakePipeline"); + } catch (ClassNotFoundException ignored) { + return; + } + + throw new AssertionError("IrisNoisemapPrebakePipeline should not exist."); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/CapabilityResolutionTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/CapabilityResolutionTest.java new file mode 100644 index 000000000..6209c77d3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/CapabilityResolutionTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; + +public class CapabilityResolutionTest { + @Test + public void resolvesExactCreateLevelMethod() throws Exception { + Method method = CapabilityResolution.resolveCreateLevelMethod(CurrentCreateLevelOwner.class); + + assertEquals("createLevel", method.getName()); + assertEquals(3, method.getParameterCount()); + } + + @Test + public void resolvesDeclaredLegacyCreateLevelMethod() throws Exception { + Method method = CapabilityResolution.resolveCreateLevelMethod(DeclaredLegacyCreateLevelOwner.class); + + assertEquals("createLevel", method.getName()); + assertEquals(4, method.getParameterCount()); + } + + @Test + public void resolvesTwoArgLevelStorageAccessMethod() throws Exception { + Method method = CapabilityResolution.resolveLevelStorageAccessMethod(TwoArgLevelStorageSource.class); + + assertEquals("validateAndCreateAccess", method.getName()); + assertEquals(2, method.getParameterCount()); + } + + @Test + public void resolvesOneArgLevelStorageAccessMethod() throws Exception { + Method method = CapabilityResolution.resolveLevelStorageAccessMethod(OneArgLevelStorageSource.class); + + assertEquals("validateAndCreateAccess", method.getName()); + assertEquals(1, method.getParameterCount()); + } + + @Test + public void resolvesPublicWorldDataHelper() throws Exception { + Method method = CapabilityResolution.resolvePaperWorldDataMethod(PublicWorldLoader.class); + + assertEquals("loadWorldData", method.getName()); + assertEquals(3, method.getParameterCount()); + } + + @Test + public void resolvesDeclaredWorldDataHelper() throws Exception { + Method method = CapabilityResolution.resolvePaperWorldDataMethod(DeclaredWorldLoader.class); + + assertEquals("loadWorldData", method.getName()); + assertEquals(3, method.getParameterCount()); + } + + @Test + public void resolvesDeclaredServerRegistryAccessMethod() throws Exception { + Method method = CapabilityResolution.resolveServerRegistryAccessMethod(DeclaredRegistryAccessOwner.class); + + assertEquals("registryAccess", method.getName()); + assertEquals(0, method.getParameterCount()); + } + + @Test + public void resolvesCurrentWorldLoadingInfoConstructor() throws Exception { + Constructor constructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(CurrentWorldLoadingInfo.class); + + assertEquals(4, constructor.getParameterCount()); + } + + @Test + public void resolvesLegacyWorldLoadingInfoConstructor() throws Exception { + Constructor constructor = CapabilityResolution.resolveWorldLoadingInfoConstructor(LegacyWorldLoadingInfo.class); + + assertEquals(5, constructor.getParameterCount()); + } + + public static final class LevelStem { + } + + public static final class WorldLoadingInfo { + } + + public static final class WorldLoadingInfoAndData { + } + + public static final class WorldDataAndGenSettings { + } + + public static final class PrimaryLevelData { + } + + public static final class LevelStorageAccess { + } + + public static final class ResourceKey { + } + + public static final class LoadedWorldData { + } + + public static final class MinecraftServer { + } + + public static final class RegistryAccess { + } + + public enum Environment { + NORMAL + } + + public static final class CurrentCreateLevelOwner { + public void createLevel(LevelStem levelStem, WorldLoadingInfoAndData worldLoadingInfoAndData, WorldDataAndGenSettings worldDataAndGenSettings) { + } + } + + public static final class DeclaredLegacyCreateLevelOwner { + private void createLevel(LevelStem levelStem, WorldLoadingInfo worldLoadingInfo, LevelStorageAccess levelStorageAccess, PrimaryLevelData primaryLevelData) { + } + } + + public static final class TwoArgLevelStorageSource { + public LevelStorageAccess validateAndCreateAccess(String worldName, ResourceKey resourceKey) { + return null; + } + } + + public static final class OneArgLevelStorageSource { + public LevelStorageAccess validateAndCreateAccess(String worldName) { + return null; + } + } + + public static final class PublicWorldLoader { + public static LoadedWorldData loadWorldData(MinecraftServer minecraftServer, ResourceKey dimensionKey, String worldName) { + return null; + } + } + + public static final class DeclaredWorldLoader { + private static LoadedWorldData loadWorldData(MinecraftServer minecraftServer, ResourceKey dimensionKey, String worldName) { + return null; + } + } + + public static final class DeclaredRegistryAccessOwner { + private RegistryAccess registryAccess() { + return null; + } + } + + public static final class CurrentWorldLoadingInfo { + public CurrentWorldLoadingInfo(Environment environment, ResourceKey stemKey, ResourceKey dimensionKey, boolean enabled) { + } + } + + public static final class LegacyWorldLoadingInfo { + private LegacyWorldLoadingInfo(int index, String worldName, String environment, ResourceKey stemKey, boolean enabled) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleDiagnosticsTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleDiagnosticsTest.java new file mode 100644 index 000000000..6f0603606 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleDiagnosticsTest.java @@ -0,0 +1,38 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.World; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletionException; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class WorldLifecycleDiagnosticsTest { + @Test + public void studioCreateSelectionFailurePrintsFullStacktrace() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, false)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); + try { + try { + service.create(request).join(); + fail("Expected lifecycle create to fail when paper_like_runtime is unavailable."); + } catch (CompletionException | IllegalStateException ignored) { + } + } finally { + System.setErr(originalErr); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("WorldLifecycle create backend selection failed")); + assertTrue(text.contains("paper_like_runtime")); + assertTrue(text.contains("IllegalStateException")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleRuntimeLevelStemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleRuntimeLevelStemTest.java new file mode 100644 index 000000000..2efa9bd0a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleRuntimeLevelStemTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.INMSBinding; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.platform.ChunkReplacementListener; +import art.arcane.iris.engine.platform.ChunkReplacementOptions; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; +import org.junit.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +public class WorldLifecycleRuntimeLevelStemTest { + @Test + public void runtimeStemUsesFullServerRegistryAccessForPlatformGenerators() throws Exception { + Object datapackDimensions = new MissingDimensionTypeRegistry(); + Object serverRegistryAccess = new Object(); + CapabilitySnapshot capabilities = CapabilitySnapshot.forTestingRuntimeRegistries(ServerFamily.PURPUR, false, datapackDimensions, serverRegistryAccess); + WorldLifecycleRequest request = new WorldLifecycleRequest( + "studio", + World.Environment.NORMAL, + new TestingPlatformChunkGenerator(), + null, + null, + true, + false, + 1337L, + true, + false, + WorldLifecycleCaller.STUDIO + ); + AtomicReference seenRegistryAccess = new AtomicReference<>(); + INMSBinding binding = createBinding((registryAccess, generator) -> { + seenRegistryAccess.set(registryAccess); + return "runtime-stem"; + }); + + Object levelStem = WorldLifecycleSupport.resolveRuntimeLevelStem(capabilities, request, binding); + + assertEquals("runtime-stem", levelStem); + assertSame(serverRegistryAccess, seenRegistryAccess.get()); + assertNotSame(datapackDimensions, seenRegistryAccess.get()); + } + + private static INMSBinding createBinding(RuntimeStemFactory factory) { + InvocationHandler handler = (proxy, method, args) -> { + if ("createRuntimeLevelStem".equals(method.getName())) { + return factory.create(args[0], (ChunkGenerator) args[1]); + } + Class returnType = method.getReturnType(); + if (boolean.class.equals(returnType)) { + return false; + } + if (int.class.equals(returnType)) { + return 0; + } + if (long.class.equals(returnType)) { + return 0L; + } + if (float.class.equals(returnType)) { + return 0F; + } + if (double.class.equals(returnType)) { + return 0D; + } + return null; + }; + return (INMSBinding) Proxy.newProxyInstance( + INMSBinding.class.getClassLoader(), + new Class[]{INMSBinding.class}, + handler + ); + } + + @FunctionalInterface + private interface RuntimeStemFactory { + Object create(Object registryAccess, ChunkGenerator generator); + } + + private static final class MissingDimensionTypeRegistry { + } + + private static final class TestingPlatformChunkGenerator extends ChunkGenerator implements PlatformChunkGenerator { + @Override + public Engine getEngine() { + return null; + } + + @Override + public IrisData getData() { + return null; + } + + @Override + public EngineTarget getTarget() { + return null; + } + + @Override + public void injectChunkReplacement(World world, int x, int z, Executor syncExecutor, ChunkReplacementOptions options, ChunkReplacementListener listener) { + } + + @Override + public void close() { + } + + @Override + public boolean isStudio() { + return true; + } + + @Override + public void touch(World world) { + } + + @Override + public CompletableFuture getSpawnChunks() { + return CompletableFuture.completedFuture(0); + } + + @Override + public Location getInitialSpawnLocation(World world) { + return null; + } + + @Override + public void hotload() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java new file mode 100644 index 000000000..e9376d69c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.World; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class WorldLifecycleSelectionTest { + @Test + public void studioSelectsPaperLikeBackendOnPaper() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void studioSelectsPaperLikeBackendOnPurpur() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void studioSelectsPaperLikeBackendOnCanvas() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.CANVAS, true, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void studioSelectsPaperLikeBackendOnFolia() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.FOLIA, true, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void studioSelectsBukkitBackendOnSpigot() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.SPIGOT, false, false, false)); + WorldLifecycleRequest request = new WorldLifecycleRequest("studio", World.Environment.NORMAL, null, null, null, true, false, 1337L, true, false, WorldLifecycleCaller.STUDIO); + + assertEquals("bukkit_public", service.selectCreateBackend(request).backendName()); + } + + @Test + public void persistentCreatePrefersBukkitBackendOnPaperLikeServers() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("persistent", World.Environment.NORMAL, null, null, null, true, false, 1337L, false, false, WorldLifecycleCaller.CREATE); + + assertEquals("bukkit_public", service.selectCreateBackend(request).backendName()); + } + + @Test + public void unloadUsesRememberedBackendFamily() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true)); + + service.rememberBackend("studio", "paper_like_runtime"); + assertEquals("paper_like_runtime", service.selectUnloadBackend("studio").backendName()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java new file mode 100644 index 000000000..ba69f8983 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; + +public class WorldLifecycleStagingTest { + @Test + public void stagedGeneratorIsConsumedExactlyOnce() { + ChunkGenerator generator = mock(ChunkGenerator.class); + + WorldLifecycleStaging.stageGenerator("world", generator, null); + + assertSame(generator, WorldLifecycleStaging.consumeGenerator("world")); + assertNull(WorldLifecycleStaging.consumeGenerator("world")); + } + + @Test + public void stagedStemGeneratorIsIndependentFromGeneratorConsumption() { + ChunkGenerator generator = mock(ChunkGenerator.class); + + WorldLifecycleStaging.stageGenerator("world", generator, null); + WorldLifecycleStaging.stageStemGenerator("world", generator); + + assertSame(generator, WorldLifecycleStaging.consumeGenerator("world")); + assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world")); + } + + @Test + public void clearAllRemovesGeneratorBiomeAndStemState() { + ChunkGenerator generator = mock(ChunkGenerator.class); + BiomeProvider biomeProvider = mock(BiomeProvider.class); + + WorldLifecycleStaging.stageGenerator("world", generator, biomeProvider); + WorldLifecycleStaging.stageStemGenerator("world", generator); + WorldLifecycleStaging.clearAll("world"); + + assertNull(WorldLifecycleStaging.consumeGenerator("world")); + assertNull(WorldLifecycleStaging.consumeBiomeProvider("world")); + assertNull(WorldLifecycleStaging.consumeStemGenerator("world")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java b/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java new file mode 100644 index 000000000..7a67bba86 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java @@ -0,0 +1,31 @@ +package art.arcane.iris.core.nms; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class INMSBindingProbeCodesTest { + @Test + public void skipsSyntheticBukkitBindingWhenNmsIsEnabled() { + List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", false, List.of("v1_21_R7")); + + assertEquals(List.of("v1_21_R7"), probeCodes); + } + + @Test + public void leavesBukkitFallbackEmptyWhenNmsIsDisabled() { + List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", true, List.of("v1_21_R7")); + + assertTrue(probeCodes.isEmpty()); + } + + @Test + public void keepsConcreteBindingCodesAsPrimaryProbe() { + List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("v1_21_R7", false, List.of("v1_21_R7")); + + assertEquals(List.of("v1_21_R7"), probeCodes); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/MinecraftVersionTest.java b/core/src/test/java/art/arcane/iris/core/nms/MinecraftVersionTest.java new file mode 100644 index 000000000..da9d93fd9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/MinecraftVersionTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.core.nms; + +import org.bukkit.Server; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class MinecraftVersionTest { + private interface PaperLikeServer extends Server { + String getMinecraftVersion(); + } + + @Test + public void detectsMinecraftVersionFromPurpurDecoratedVersion() { + Server server = mock(Server.class); + doReturn("git-Purpur-2570 (MC: 1.21.11)").when(server).getVersion(); + doReturn("26.1.2.build.2570-experimental").when(server).getBukkitVersion(); + + MinecraftVersion version = MinecraftVersion.detect(server); + assertEquals("1.21.11", version.value()); + assertEquals(21, version.major()); + assertEquals(11, version.minor()); + } + + @Test + public void prefersRuntimeMinecraftVersionMethodWhenPresent() { + PaperLikeServer server = mock(PaperLikeServer.class); + doReturn("1.21.11").when(server).getMinecraftVersion(); + doReturn("26.1.2-2570-e64b1b2 (MC: 26.1.2)").when(server).getVersion(); + doReturn("26.1.2.build.2570-experimental").when(server).getBukkitVersion(); + + MinecraftVersion version = MinecraftVersion.detect(server); + assertEquals("1.21.11", version.value()); + assertEquals(21, version.major()); + assertEquals(11, version.minor()); + } + + @Test + public void rejectsPurpurApiBuildNumbersAsMinecraftVersion() { + MinecraftVersion version = MinecraftVersion.fromBukkitVersion("26.1.2.build.2570-experimental"); + assertNull(version); + } + + @Test + public void parsesStandardBukkitSnapshotVersion() { + MinecraftVersion version = MinecraftVersion.fromBukkitVersion("1.21.11-R0.1-SNAPSHOT"); + assertEquals("1.21.11", version.value()); + assertEquals(21, version.major()); + assertEquals(11, version.minor()); + } + + @Test + public void comparesMajorBeforeMinor() { + MinecraftVersion version = MinecraftVersion.fromBukkitVersion("1.20.12-R0.1-SNAPSHOT"); + assertFalse(version.isAtLeast(21, 11)); + assertTrue(version.isNewerThan(20, 11)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217DimensionTypeTest.java b/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217DimensionTypeTest.java new file mode 100644 index 000000000..3ff57544a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217DimensionTypeTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.core.nms.datapack.v1217; + +import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DataFixerV1217DimensionTypeTest { + private final DataFixerV1217 fixer = new DataFixerV1217(); + + @Test + public void createsOverworldDimensionWithDragonFightDisabled() { + JSONObject json = fixer.createDimension(Dimension.OVERWORLD, -256, 768, 512, null); + + assertTrue(json.has("has_ender_dragon_fight")); + assertEquals(false, json.getBoolean("has_ender_dragon_fight")); + } + + @Test + public void createsEndDimensionWithDragonFightEnabled() { + JSONObject json = fixer.createDimension(Dimension.END, 0, 256, 256, null); + + assertTrue(json.has("has_ender_dragon_fight")); + assertEquals(true, json.getBoolean("has_ender_dragon_fight")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/DatapackReadinessResultTest.java b/core/src/test/java/art/arcane/iris/core/runtime/DatapackReadinessResultTest.java new file mode 100644 index 000000000..08d30c9c2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/DatapackReadinessResultTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DatapackReadinessResultTest { + @Test + public void verificationUsesDimensionTypeKeyPath() throws Exception { + Path root = Files.createTempDirectory("iris-datapack-readiness"); + Path datapackRoot = root.resolve("iris"); + Files.createDirectories(datapackRoot.resolve("data/iris/dimension_type")); + Files.writeString(datapackRoot.resolve("pack.mcmeta"), "{}"); + Files.writeString(datapackRoot.resolve("data/iris/dimension_type/runtime-key.json"), "{}"); + + ArrayList verifiedPaths = new ArrayList<>(); + ArrayList missingPaths = new ArrayList<>(); + DatapackReadinessResult.collectVerificationPaths(root.toFile(), "runtime-key", verifiedPaths, missingPaths); + + assertTrue(missingPaths.isEmpty()); + assertEquals(2, verifiedPaths.size()); + } + + @Test + public void verificationMarksMissingDimensionTypePath() throws Exception { + Path root = Files.createTempDirectory("iris-datapack-readiness-missing"); + Path datapackRoot = root.resolve("iris"); + Files.createDirectories(datapackRoot); + Files.writeString(datapackRoot.resolve("pack.mcmeta"), "{}"); + + ArrayList verifiedPaths = new ArrayList<>(); + ArrayList missingPaths = new ArrayList<>(); + DatapackReadinessResult.collectVerificationPaths(root.toFile(), "runtime-key", verifiedPaths, missingPaths); + + assertEquals(1, verifiedPaths.size()); + assertEquals(1, missingPaths.size()); + assertTrue(missingPaths.get(0).endsWith(File.separator + "iris" + File.separator + "data" + File.separator + "iris" + File.separator + "dimension_type" + File.separator + "runtime-key.json")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/SmokeDiagnosticsServiceCloseStateTest.java b/core/src/test/java/art/arcane/iris/core/runtime/SmokeDiagnosticsServiceCloseStateTest.java new file mode 100644 index 000000000..9fbf011d6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/SmokeDiagnosticsServiceCloseStateTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class SmokeDiagnosticsServiceCloseStateTest { + @Test + public void closeStateIsPersistedIntoRunSnapshot() { + SmokeDiagnosticsService service = SmokeDiagnosticsService.get(); + SmokeDiagnosticsService.SmokeRunHandle handle = service.beginRun( + SmokeDiagnosticsService.SmokeRunMode.STUDIO_CLOSE, + "iris-test-world", + true, + true, + null, + false + ); + + handle.setCloseState(true, false, true); + + SmokeDiagnosticsService.SmokeRunReport report = handle.snapshot(); + assertTrue(report.isCloseUnloadCompletedLive()); + assertFalse(report.isCloseFolderDeletionCompletedLive()); + assertTrue(report.isCloseStartupCleanupQueued()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupportTest.java b/core/src/test/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupportTest.java new file mode 100644 index 000000000..8782ad64f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupportTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TransientWorldCleanupSupportTest { + @Test + public void identifiesTransientStudioBaseNamesAndSidecars() { + String baseName = "iris-123e4567-e89b-12d3-a456-426614174000"; + + assertTrue(TransientWorldCleanupSupport.isTransientStudioWorldName(baseName)); + assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName)); + assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName + "_nether")); + assertEquals(baseName, TransientWorldCleanupSupport.transientStudioBaseWorldName(baseName + "_the_end")); + assertFalse(TransientWorldCleanupSupport.isTransientStudioWorldName("iris-smoke-studio-deadbeef")); + assertNull(TransientWorldCleanupSupport.transientStudioBaseWorldName("overworld")); + } + + @Test + public void expandsWorldFamilyNamesForDeletion() { + String baseName = "iris-123e4567-e89b-12d3-a456-426614174000"; + + List names = TransientWorldCleanupSupport.worldFamilyNames(baseName); + + assertEquals(List.of(baseName, baseName + "_nether", baseName + "_the_end"), names); + } + + @Test + public void collectsOnlyTransientStudioWorldFamiliesFromContainer() throws IOException { + File container = Files.createTempDirectory("transient-world-cleanup-test").toFile(); + String baseName = "iris-123e4567-e89b-12d3-a456-426614174000"; + File baseFolder = new File(container, baseName); + File netherFolder = new File(container, baseName + "_nether"); + File smokeFolder = new File(container, "iris-smoke-studio-deadbeef"); + File regularFolder = new File(container, "overworld"); + baseFolder.mkdirs(); + netherFolder.mkdirs(); + smokeFolder.mkdirs(); + regularFolder.mkdirs(); + + try { + LinkedHashSet names = TransientWorldCleanupSupport.collectTransientStudioWorldNames(container); + + assertEquals(new LinkedHashSet<>(List.of(baseName)), names); + } finally { + Files.deleteIfExists(baseFolder.toPath()); + Files.deleteIfExists(netherFolder.toPath()); + Files.deleteIfExists(smokeFolder.toPath()); + Files.deleteIfExists(regularFolder.toPath()); + Files.deleteIfExists(container.toPath()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java new file mode 100644 index 000000000..95b14ce41 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class WorldRuntimeControlServiceSafeEntryTest { + @Test + public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() { + World world = mock(World.class); + PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class); + Location initialSpawn = new Location(world, 0.5D, 96D, 0.5D); + Location mutableWorldSpawn = new Location(world, 128.5D, 80D, -64.5D); + + doReturn(true).when(provider).isStudio(); + doReturn(initialSpawn).when(provider).getInitialSpawnLocation(world); + doReturn(mutableWorldSpawn).when(world).getSpawnLocation(); + + Location resolved = WorldRuntimeControlService.resolveEntryAnchor(world, provider); + + assertEquals(initialSpawn, resolved); + } + + @Test + public void fallsBackToWorldSpawnWhenGeneratorIsNotStudio() { + World world = mock(World.class); + PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class); + Location mutableWorldSpawn = new Location(world, 128.5D, 80D, -64.5D); + + doReturn(false).when(provider).isStudio(); + doReturn(mutableWorldSpawn).when(world).getSpawnLocation(); + + Location resolved = WorldRuntimeControlService.resolveEntryAnchor(world, provider); + + assertEquals(mutableWorldSpawn, resolved); + } + + @Test + public void scansFromRuntimeSurfaceBeforeFallingThroughRemainingWorldHeight() { + World world = mock(World.class); + + doReturn(0).when(world).getMinHeight(); + doReturn(256).when(world).getMaxHeight(); + doReturn(179).when(world).getHighestBlockYAt(0, 0); + + int[] scanOrder = WorldRuntimeControlService.buildSafeLocationScanOrder(world, new Location(world, 0.5D, 96D, 0.5D)); + + assertEquals(180, scanOrder[0]); + assertEquals(179, scanOrder[1]); + assertEquals(1, scanOrder[179]); + assertEquals(181, scanOrder[180]); + assertEquals(254, scanOrder[scanOrder.length - 1]); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java new file mode 100644 index 000000000..3d6e48cc2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java @@ -0,0 +1,239 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import art.arcane.iris.core.lifecycle.ServerFamily; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.plugin.PluginManager; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class WorldRuntimeControlServiceTimeLockTest { + @Before + public void ensureBukkitServer() { + if (Bukkit.getServer() != null) { + return; + } + + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + doReturn(pluginManager).when(server).getPluginManager(); + doReturn(Logger.getLogger("WorldRuntimeControlServiceTimeLockTest")).when(server).getLogger(); + Bukkit.setServer(server); + } + + @Test + public void skipsTimeLockWhenWorldDoesNotExposeMutableClock() throws Exception { + AtomicBoolean setTimeCalled = new AtomicBoolean(false); + AtomicLong dayTime = new AtomicLong(0L); + World world = createWorldProxy("fixed", true, setTimeCalled, dayTime, false); + + boolean applied = createService().applyNoonTimeLock(world); + + assertFalse(applied); + assertFalse(setTimeCalled.get()); + } + + @Test + public void skipsTimeLockWhenRuntimeSetterRejectsClockMutation() throws Exception { + AtomicBoolean setTimeCalled = new AtomicBoolean(false); + AtomicLong dayTime = new AtomicLong(0L); + World world = createWorldProxy("no-clock", false, setTimeCalled, dayTime, true); + + boolean applied = createService().applyNoonTimeLock(world); + + assertFalse(applied); + assertTrue(setTimeCalled.get()); + } + + @Test + public void appliesTimeLockWhenWorldHasMutableClock() throws Exception { + AtomicBoolean setTimeCalled = new AtomicBoolean(false); + AtomicLong dayTime = new AtomicLong(0L); + World world = createWorldProxy("mutable", false, setTimeCalled, dayTime, false); + + boolean applied = createService().applyNoonTimeLock(world); + + assertTrue(applied); + assertTrue(setTimeCalled.get()); + assertTrue(dayTime.get() == 6000L); + } + + private WorldRuntimeControlService createService() throws Exception { + Constructor snapshotConstructor = CapabilitySnapshot.class.getDeclaredConstructor( + ServerFamily.class, + boolean.class, + Object.class, + Class.class, + Class.class, + String.class, + Object.class, + Object.class, + java.lang.reflect.Method.class, + CapabilitySnapshot.PaperLikeFlavor.class, + Class.class, + java.lang.reflect.Method.class, + java.lang.reflect.Constructor.class, + java.lang.reflect.Constructor.class, + java.lang.reflect.Method.class, + java.lang.reflect.Method.class, + java.lang.reflect.Field.class, + java.lang.reflect.Method.class, + java.lang.reflect.Field.class, + java.lang.reflect.Field.class, + java.lang.reflect.Method.class, + java.lang.reflect.Method.class, + java.lang.reflect.Method.class, + java.lang.reflect.Method.class, + String.class + ); + snapshotConstructor.setAccessible(true); + CapabilitySnapshot snapshot = snapshotConstructor.newInstance( + ServerFamily.PAPER, + false, + null, + null, + null, + "test", + Bukkit.getServer(), + null, + null, + CapabilitySnapshot.PaperLikeFlavor.UNSUPPORTED, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "test" + ); + + Constructor serviceConstructor = WorldRuntimeControlService.class.getDeclaredConstructor(CapabilitySnapshot.class); + serviceConstructor.setAccessible(true); + return serviceConstructor.newInstance(snapshot); + } + + private World createWorldProxy(String name, boolean fixedTime, AtomicBoolean setTimeCalled, AtomicLong dayTime, boolean throwOnSetTime) { + Object dimensionType = Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{DimensionTypeProbe.class}, + (proxy, method, args) -> { + if ("hasFixedTime".equals(method.getName())) { + return fixedTime; + } + if ("fixedTime".equals(method.getName())) { + return fixedTime ? OptionalLong.of(6000L) : OptionalLong.empty(); + } + return null; + } + ); + Object holder = Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{HolderProbe.class}, + (proxy, method, args) -> { + if ("value".equals(method.getName())) { + return dimensionType; + } + return null; + } + ); + Object handle = Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{HandleProbe.class}, + (proxy, method, args) -> { + if ("dimensionTypeRegistration".equals(method.getName())) { + return holder; + } + if ("getDayTime".equals(method.getName())) { + return dayTime.get(); + } + if ("setDayTime".equals(method.getName())) { + setTimeCalled.set(true); + if (throwOnSetTime) { + throw new IllegalArgumentException("Cannot set time in world without world clock"); + } + dayTime.set(((Long) args[0]).longValue()); + return null; + } + return null; + } + ); + return (World) Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{World.class, WorldHandleProbe.class}, + (proxy, method, args) -> { + if ("getName".equals(method.getName())) { + return name; + } + if ("getHandle".equals(method.getName())) { + return handle; + } + if ("getFullTime".equals(method.getName())) { + return dayTime.get(); + } + + Class returnType = method.getReturnType(); + if (boolean.class.equals(returnType)) { + return false; + } + if (int.class.equals(returnType)) { + return 0; + } + if (long.class.equals(returnType)) { + return 0L; + } + if (float.class.equals(returnType)) { + return 0F; + } + if (double.class.equals(returnType)) { + return 0D; + } + + return null; + } + ); + } + + private interface WorldHandleProbe { + Object getHandle(); + } + + private interface HandleProbe { + Object dimensionTypeRegistration(); + + long getDayTime(); + + void setDayTime(long time); + } + + private interface HolderProbe { + Object value(); + } + + private interface DimensionTypeProbe { + boolean hasFixedTime(); + + OptionalLong fixedTime(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java new file mode 100644 index 000000000..1e5aef8ba --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/GenerationSessionManagerTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertTrue; + +public class GenerationSessionManagerTest { + @Test + public void teardownSealMarksRejectedWorkAsExpected() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + + manager.sealAndAwait("close", 1000L, true); + + try { + manager.acquire("chunk_generate"); + } catch (GenerationSessionException e) { + assertTrue(e.isExpectedTeardown()); + assertTrue(e.getMessage().contains("during close")); + return; + } + + throw new AssertionError("Expected teardown rejection."); + } + + @Test + public void sealAndAwaitCompletesWhenOutstandingLeaseReleases() throws Exception { + GenerationSessionManager manager = new GenerationSessionManager(); + GenerationSessionLease lease = manager.acquire("chunk_generate"); + CountDownLatch latch = new CountDownLatch(1); + + Thread releaser = new Thread(() -> { + try { + latch.await(200L, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + lease.close(); + }); + releaser.start(); + latch.countDown(); + + manager.sealAndAwait("close", 1000L, true); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java new file mode 100644 index 000000000..ae0c15bf6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java @@ -0,0 +1,23 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisDimensionTypeKeyTest { + @Test + public void dimensionTypeKeyUsesSanitizedSemanticPackKey() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("Overworld"); + + assertEquals("overworld", dimension.getDimensionTypeKey()); + } + + @Test + public void dimensionTypeKeySanitizesUnsafePackCharacters() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("Worlds/My Pack"); + + assertEquals("worlds_my_pack", dimension.getDimensionTypeKey()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectBlockDataMergeTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectBlockDataMergeTest.java new file mode 100644 index 000000000..53e68e7c7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectBlockDataMergeTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.engine.object; + +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +public class IrisObjectBlockDataMergeTest { + @Test + public void reparsesBlockDataBeforeRetryingMerge() { + BlockData base = mock(BlockData.class); + BlockData update = mock(BlockData.class); + BlockData parsedBase = mock(BlockData.class); + BlockData parsedUpdate = mock(BlockData.class); + BlockData merged = mock(BlockData.class); + Function resolver = createResolver( + "minecraft:oak_log[axis=x]", parsedBase, + "minecraft:oak_log[axis=y]", parsedUpdate + ); + + doThrow(new IllegalArgumentException("Block data not created via string parsing")).when(base).merge(update); + doReturn("minecraft:oak_log[axis=x]").when(base).getAsString(false); + doReturn("minecraft:oak_log[axis=y]").when(update).getAsString(false); + doReturn(merged).when(parsedBase).merge(parsedUpdate); + + BlockData result = BlockDataMergeSupport.merge(base, update, resolver); + + assertSame(merged, result); + } + + @Test + public void fallsBackToNormalizedUpdateWhenRetryMergeStillFails() { + BlockData base = mock(BlockData.class); + BlockData update = mock(BlockData.class); + BlockData parsedBase = mock(BlockData.class); + BlockData parsedUpdate = mock(BlockData.class); + Function resolver = createResolver( + "minecraft:stone", parsedBase, + "minecraft:stone[waterlogged=true]", parsedUpdate + ); + + doThrow(new IllegalArgumentException("Block data not created via string parsing")).when(base).merge(update); + doReturn("minecraft:stone").when(base).getAsString(false); + doReturn("minecraft:stone[waterlogged=true]").when(update).getAsString(false); + doThrow(new IllegalArgumentException("normalized merge failed")).when(parsedBase).merge(parsedUpdate); + + BlockData result = BlockDataMergeSupport.merge(base, update, resolver); + + assertSame(parsedUpdate, result); + } + + @Test + public void keepsDirectMergeWhenBukkitAcceptsIt() { + BlockData base = mock(BlockData.class); + BlockData update = mock(BlockData.class); + BlockData merged = mock(BlockData.class); + + doReturn(Material.STONE).when(base).getMaterial(); + doReturn(Material.STONE).when(update).getMaterial(); + doReturn(merged).when(base).merge(update); + + BlockData result = BlockDataMergeSupport.merge(base, update, key -> null); + + assertSame(merged, result); + } + + private Function createResolver(String firstKey, BlockData firstValue, String secondKey, BlockData secondValue) { + Map resolved = new HashMap<>(); + resolved.put(firstKey, firstValue); + resolved.put(secondKey, secondValue); + return resolved::get; + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/parallel/BurstExecutorSupportReentrantTest.java b/core/src/test/java/art/arcane/iris/util/common/parallel/BurstExecutorSupportReentrantTest.java new file mode 100644 index 000000000..6353637d6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/parallel/BurstExecutorSupportReentrantTest.java @@ -0,0 +1,36 @@ +package art.arcane.iris.util.common.parallel; + +import art.arcane.volmlib.util.parallel.BurstExecutorSupport; +import org.junit.Test; + +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertTrue; + +public class BurstExecutorSupportReentrantTest { + @Test + public void runsNestedBurstInlineOnSameForkJoinPoolWorker() throws Exception { + ForkJoinPool pool = new ForkJoinPool(1); + AtomicBoolean nestedExecuted = new AtomicBoolean(false); + + try { + Future future = pool.submit(() -> { + BurstExecutorSupport burst = new BurstExecutorSupport(pool, 1); + burst.queue(() -> { + BurstExecutorSupport nested = new BurstExecutorSupport(pool, 1); + nested.queue(() -> nestedExecuted.set(true)); + nested.complete(); + }); + burst.complete(); + }); + + future.get(5, TimeUnit.SECONDS); + assertTrue(nestedExecuted.get()); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderMiniMessageEscapeTest.java b/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderMiniMessageEscapeTest.java new file mode 100644 index 000000000..ba385e950 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderMiniMessageEscapeTest.java @@ -0,0 +1,24 @@ +package art.arcane.iris.util.common.plugin; + +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class VolmitSenderMiniMessageEscapeTest { + @Test + public void escapesApostrophesForQuotedHoverText() { + String escaped = VolmitSender.escapeMiniMessageQuotedText("This world's dimension config"); + + assertEquals("This world\\'s dimension config", escaped); + MiniMessage.miniMessage().deserialize("ok"); + } + + @Test + public void escapesBackslashesBeforeQuotedHoverText() { + String escaped = VolmitSender.escapeMiniMessageQuotedText("Path \\\\ data"); + + assertEquals("Path \\\\\\\\ data", escaped); + MiniMessage.miniMessage().deserialize("ok"); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java b/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java index ca9120866..cc040cdba 100644 --- a/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java +++ b/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.util.project.context; +import art.arcane.iris.Iris; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; @@ -7,9 +8,16 @@ import art.arcane.iris.util.project.stream.ProceduralStream; import org.bukkit.block.data.BlockData; import org.junit.Test; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; @@ -76,6 +84,16 @@ public class ChunkContextPrefillPlanTest { assertEquals(256, caveCalls.get()); } + @Test + public void paperCommonWorkerThreadsDisableAsyncPrefillWhenPluginLoaded() throws Exception { + assertPrefillAsyncDecision("Paper Common Worker #0", false); + } + + @Test + public void irisWorkerThreadsKeepAsyncPrefillWhenPluginLoaded() throws Exception { + assertPrefillAsyncDecision("Iris 42", true); + } + private ChunkContext createContext( ChunkContext.PrefillPlan prefillPlan, AtomicInteger caveCalls, @@ -145,4 +163,26 @@ public class ChunkContextPrefillPlanTest { return new ChunkContext(32, 48, complex, true, prefillPlan, null); } + + private void assertPrefillAsyncDecision(String threadName, boolean expected) throws InterruptedException, ExecutionException, java.util.concurrent.TimeoutException { + Iris previous = Iris.instance; + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable); + thread.setName(threadName); + return thread; + }); + try { + Iris.instance = mock(Iris.class); + Future future = executor.submit(() -> ChunkContext.shouldPrefillAsync(2)); + boolean actual = future.get(10, TimeUnit.SECONDS); + if (expected) { + assertTrue(actual); + } else { + assertFalse(actual); + } + } finally { + Iris.instance = previous; + executor.shutdownNow(); + } + } } diff --git a/core/src/test/resources/art/arcane/iris/core/lifecycle/world-lifecycle-smoke-matrix.txt b/core/src/test/resources/art/arcane/iris/core/lifecycle/world-lifecycle-smoke-matrix.txt new file mode 100644 index 000000000..cf5348099 --- /dev/null +++ b/core/src/test/resources/art/arcane/iris/core/lifecycle/world-lifecycle-smoke-matrix.txt @@ -0,0 +1,29 @@ +Paper 1.21.11 +/iris create matrix-paper overworld +/iris std o overworld +close Studio world +benchmark create/unload + +Purpur 1.21.11 +/iris create matrix-purpur overworld +/iris std o overworld +close Studio world +benchmark create/unload + +Canvas 1.21.11 +/iris create matrix-canvas overworld +/iris std o overworld +close Studio world +benchmark create/unload + +Folia 1.21.11 +/iris create matrix-folia overworld +/iris std o overworld +close Studio world +benchmark create/unload + +Spigot 1.21.11 +/iris create matrix-spigot overworld +/iris std o overworld +close Studio world +unload/remove diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43b7993bf..c7de7aa3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ mythic = "5.9.5" mythic-chrucible = "2.1.0" kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml multiverseCore = "5.1.0" +craftengine = "0.0.67" # https://github.com/Xiao-MoMi/craft-engine/releases [libraries] # Core Libraries @@ -104,6 +105,8 @@ mythic = { module = "io.lumine:Mythic-Dist", version.ref = "mythic" } mythicChrucible = { module = "io.lumine:MythicCrucible-Dist", version.ref = "mythic-chrucible" } kgenerators = { module = "me.kryniowesegryderiusz:kgenerators-core", version.ref = "kgenerators" } multiverseCore = { module = "org.mvplugins.multiverse.core:multiverse-core", version.ref = "multiverseCore" } +craftengine-core = { module = "net.momirealms:craft-engine-core", version.ref = "craftengine" } +craftengine-bukkit = { module = "net.momirealms:craft-engine-bukkit", version.ref = "craftengine" } [plugins] shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java index b0d0a85cb..ab5b213bd 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java @@ -19,8 +19,8 @@ import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.Climate; import org.bukkit.Bukkit; import org.bukkit.World; -import org.bukkit.craftbukkit.v1_21_R7.CraftServer; -import org.bukkit.craftbukkit.v1_21_R7.CraftWorld; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.CraftWorld; import java.lang.reflect.Field; import java.lang.reflect.Method; diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java index be7605629..a34d2deee 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java @@ -36,8 +36,8 @@ import net.minecraft.world.level.levelgen.structure.StructureSet; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import org.bukkit.Bukkit; import org.bukkit.World; -import org.bukkit.craftbukkit.v1_21_R7.CraftWorld; -import org.bukkit.craftbukkit.v1_21_R7.generator.CustomChunkGenerator; +import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.craftbukkit.generator.CustomChunkGenerator; import org.spigotmc.SpigotWorldConfig; import javax.annotation.Nullable; diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java index 50824c17c..e66d3408d 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java @@ -42,7 +42,6 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.commands.data.BlockDataAccessor; import net.minecraft.server.level.ServerLevel; import net.minecraft.tags.TagKey; -import net.minecraft.world.RandomSequences; import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.entity.EntityType; import net.minecraft.world.item.component.CustomData; @@ -65,19 +64,19 @@ import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; import net.minecraft.world.level.storage.LevelStorageSource; -import net.minecraft.world.level.storage.PrimaryLevelData; +import net.minecraft.world.level.storage.ServerLevelData; import org.bukkit.*; import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; -import org.bukkit.craftbukkit.v1_21_R7.CraftChunk; -import org.bukkit.craftbukkit.v1_21_R7.CraftServer; -import org.bukkit.craftbukkit.v1_21_R7.CraftWorld; -import org.bukkit.craftbukkit.v1_21_R7.block.CraftBlockState; -import org.bukkit.craftbukkit.v1_21_R7.block.CraftBlockStates; -import org.bukkit.craftbukkit.v1_21_R7.block.data.CraftBlockData; -import org.bukkit.craftbukkit.v1_21_R7.inventory.CraftItemStack; -import org.bukkit.craftbukkit.v1_21_R7.util.CraftMagicNumbers; -import org.bukkit.craftbukkit.v1_21_R7.util.CraftNamespacedKey; +import org.bukkit.craftbukkit.CraftChunk; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.craftbukkit.block.CraftBlockState; +import org.bukkit.craftbukkit.block.CraftBlockStates; +import org.bukkit.craftbukkit.block.data.CraftBlockData; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.craftbukkit.util.CraftNamespacedKey; import org.bukkit.entity.Entity; import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.generator.BiomeProvider; @@ -562,8 +561,17 @@ public class NMSBinding implements INMSBinding { worldGenContextField.setAccessible(true); var worldGenContext = (WorldGenContext) worldGenContextField.get(chunkMap); var dimensionType = chunkMap.level.dimensionTypeRegistration().unwrapKey().orElse(null); - if (dimensionType != null && !dimensionType.identifier().getNamespace().equals("iris")) - Iris.error("Loaded world %s with invalid dimension type! (%s)", world.getName(), dimensionType.identifier().toString()); + String expectedDimensionType = "iris:" + engine.getDimension().getDimensionTypeKey(); + if (dimensionType != null) { + String actualDimensionType = dimensionType.identifier().toString(); + if (!dimensionType.identifier().getNamespace().equals("iris")) { + Iris.error("Loaded world %s with invalid dimension type! expected=%s actual=%s", world.getName(), expectedDimensionType, actualDimensionType); + } else { + Iris.info("Loaded world %s with Iris dimension type %s", world.getName(), actualDimensionType); + } + } else { + Iris.error("Loaded world %s with unknown dimension type! expected=%s", world.getName(), expectedDimensionType); + } var newContext = new WorldGenContext( worldGenContext.level(), new IrisChunkGenerator(worldGenContext.generator(), seed, engine, world), @@ -786,9 +794,8 @@ public class NMSBinding implements INMSBinding { var buddy = new ByteBuddy(); buddy.redefine(ServerLevel.class) .visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor().and(ElementMatchers.takesArguments( - MinecraftServer.class, Executor.class, LevelStorageSource.LevelStorageAccess.class, PrimaryLevelData.class, - ResourceKey.class, LevelStem.class, boolean.class, long.class, List.class, - boolean.class, RandomSequences.class, World.Environment.class, ChunkGenerator.class, BiomeProvider.class)))) + MinecraftServer.class, Executor.class, LevelStorageSource.LevelStorageAccess.class, ServerLevelData.class, + ResourceKey.class, LevelStem.class, boolean.class, long.class, List.class, boolean.class)))) .make() .load(ServerLevel.class.getClassLoader(), Agent.installed()); for (Class clazz : List.of(ChunkAccess.class, ProtoChunk.class)) { @@ -896,12 +903,18 @@ public class NMSBinding implements INMSBinding { .collect(Collectors.toMap(Pair::getA, Pair::getB, (a, b) -> a, KMap::new)); } - public LevelStem levelStem(RegistryAccess access, ChunkGenerator raw) { - if (!(raw instanceof PlatformChunkGenerator gen)) + @Override + public Object createRuntimeLevelStem(Object registryAccess, ChunkGenerator raw) { + if (!(registryAccess instanceof RegistryAccess access)) { + throw new IllegalStateException("Runtime LevelStem creation requires a RegistryAccess instance."); + } + if (!(raw instanceof PlatformChunkGenerator generator)) { throw new IllegalStateException("Generator is not platform chunk generator!"); + } - var dimensionKey = Identifier.fromNamespaceAndPath("iris", gen.getTarget().getDimension().getDimensionTypeKey()); - var dimensionType = access.lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, dimensionKey)); + Identifier dimensionKey = Identifier.fromNamespaceAndPath("iris", generator.getTarget().getDimension().getDimensionTypeKey()); + Holder.Reference dimensionType = access.lookupOrThrow(Registries.DIMENSION_TYPE) + .getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, dimensionKey)); return new LevelStem(dimensionType, chunkGenerator(access)); } @@ -923,25 +936,42 @@ public class NMSBinding implements INMSBinding { @Advice.OnMethodEnter static void enter( @Advice.Argument(0) MinecraftServer server, - @Advice.Argument(3) PrimaryLevelData levelData, + @Advice.Argument(2) LevelStorageSource.LevelStorageAccess levelStorageAccess, @Advice.Argument(value = 5, readOnly = false) LevelStem levelStem, - @Advice.Argument(11) World.Environment env, - @Advice.Argument(12) ChunkGenerator gen + @Advice.Argument(3) ServerLevelData levelData ) { - if (gen == null || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) + if (levelStorageAccess == null) return; try { + String levelId = levelStorageAccess.getLevelId(); + if (levelId == null || levelId.isBlank()) { + return; + } + + Object generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, Bukkit.getPluginManager().getPlugin("Iris") + .getClass() + .getClassLoader()) + .getDeclaredMethod("consumeStemGenerator", String.class) + .invoke(null, levelId); + if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) { + return; + } + Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, Bukkit.getPluginManager().getPlugin("Iris") .getClass() .getClassLoader()) .getDeclaredMethod("get") .invoke(null); - levelStem = (LevelStem) bindings.getClass() - .getDeclaredMethod("levelStem", RegistryAccess.class, ChunkGenerator.class) - .invoke(bindings, server.registryAccess(), gen); + if (!(bindings instanceof INMSBinding binding)) { + throw new IllegalStateException("Iris failed to resolve an INMSBinding instance."); + } - levelData.customDimensions = null; + Object resolvedStem = binding.createRuntimeLevelStem(server.registryAccess(), gen); + if (!(resolvedStem instanceof LevelStem runtimeStem)) { + throw new IllegalStateException("Iris runtime LevelStem binding returned " + (resolvedStem == null ? "null" : resolvedStem.getClass().getName()) + "."); + } + levelStem = runtimeStem; } catch (Throwable e) { throw new RuntimeException("Iris failed to replace the levelStem", e instanceof InvocationTargetException ex ? ex.getCause() : e); }