diff --git a/.gitignore b/.gitignore index 3b51b24f3..40ca3d447 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ service-account*.json .qa/ .repro/ +.perf/ +.release-gate/ # Throwaway worktree copies used by the API / PlaceholderAPI rebuild lanes. Generated, never source. .apiwt/ diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 101823f62..4d61690c4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -11,6 +11,7 @@ import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; @@ -521,6 +522,16 @@ public class CustomBiomeSource extends BiomeSource { int y, int z, Climate.Sampler sampler + ) { + return getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler, null); + } + + Holder getVisibleNoiseBiomeWithActiveGenerationLease( + int x, + int y, + int z, + Climate.Sampler sampler, + IrisDimensionCarvingResolver.State resolverState ) { long cacheKey = packNoiseKey(x, y, z); Holder cachedHolder = noiseBiomeCache.get(cacheKey); @@ -528,7 +539,7 @@ public class CustomBiomeSource extends BiomeSource { return cachedHolder; } - Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z); + Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z, resolverState); Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); if (existingHolder != null) { return existingHolder; @@ -602,8 +613,13 @@ public class CustomBiomeSource extends BiomeSource { return holder; } - private Holder resolveVisibleBiomeHolder(int x, int y, int z) { - BiomeResolution resolution = resolveBiomeResolution(x, y, z); + private Holder resolveVisibleBiomeHolder( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State resolverState + ) { + BiomeResolution resolution = resolveBiomeResolution(x, y, z, resolverState); if (resolution == null) { return getFallbackBiome(); } @@ -636,6 +652,15 @@ public class CustomBiomeSource extends BiomeSource { } private BiomeResolution resolveBiomeResolution(int x, int y, int z) { + return resolveBiomeResolution(x, y, z, null); + } + + private BiomeResolution resolveBiomeResolution( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State resolverState + ) { if (engine == null || engine.isClosed()) { return null; } @@ -657,7 +682,7 @@ public class CustomBiomeSource extends BiomeSource { int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); underground = internalY <= surfaceInternalY - 8; irisBiome = underground - ? engine.getCaveBiome(blockX, internalY, blockZ) + ? engine.getCaveBiome(blockX, internalY, blockZ, resolverState) : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); } else { irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 1a3a31eed..755b84571 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -10,6 +10,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.nativegen.NativeStructureGenerationException; @@ -643,8 +644,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator { GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { customBiomeSource.prepareVisibleBiomeBatch(); + IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); ichunkaccess.fillBiomesFromNoise( - customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease, + (x, y, z, sampler) -> customBiomeSource.getVisibleNoiseBiomeWithActiveGenerationLease( + x, y, z, sampler, resolverState), randomstate.sampler()); return CompletableFuture.completedFuture(ichunkaccess); } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index 5dc1e6159..aaeb3b7ab 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -258,7 +258,8 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(createBiomes.contains("requireGenerationStage(\"bukkit_nms_create_biomes\")")); assertTrue(createBiomes.contains("customBiomeSource.prepareVisibleBiomeBatch()")); - assertTrue(createBiomes.contains("customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease")); + assertTrue(createBiomes.contains("new IrisDimensionCarvingResolver.State()")); + assertTrue(createBiomes.contains("sampler, resolverState")); assertTrue(buildSurface.contains("delegate.buildSurface(")); assertTrue(carvers.contains("delegate.applyCarvers(")); assertTrue(noise.contains("requireNoiseGenerationStage(")); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 2a7eb8452..15ddaacf6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -573,7 +573,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private static String logPrefix(Iris plugin) { - return plugin == null ? "[Iris] " : plugin.getTag(); + return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag(); } /** diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 039b3a5f5..c6faf44b0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -9,6 +9,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult; +import art.arcane.iris.util.common.misc.SlimJar; import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.PluginBootstrap; import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent; @@ -26,6 +27,7 @@ public final class IrisBootstrap implements PluginBootstrap { public void bootstrap(BootstrapContext context) { WorldReplacementBootstrapMarker.markBootstrapped(); try { + loadRuntimeLibraries(context); BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent(); reconcilePendingWorldReplacements(context, startupPaths); quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message)); @@ -38,6 +40,27 @@ public final class IrisBootstrap implements PluginBootstrap { } } + private static void loadRuntimeLibraries(BootstrapContext context) { + SlimJar.loadBootstrap( + context.getDataDirectory().resolve("cache").resolve("libraries"), + new SlimJar.BootstrapLogger() { + @Override + public void info(String message) { + context.getLogger().info(message); + } + + @Override + public void error(String message) { + context.getLogger().error(message); + } + + @Override + public void debug(String message) { + context.getLogger().debug(message); + } + }); + } + private static void reconcilePendingWorldReplacements( BootstrapContext context, BukkitStartupPaths startupPaths diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisPluginLoader.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisPluginLoader.java new file mode 100644 index 000000000..1ab909f78 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisPluginLoader.java @@ -0,0 +1,69 @@ +package art.arcane.iris; + +import art.arcane.iris.util.common.misc.SlimJar; +import io.papermc.paper.plugin.bootstrap.PluginProviderContext; +import io.papermc.paper.plugin.loader.PluginClasspathBuilder; +import io.papermc.paper.plugin.loader.PluginLoader; +import io.papermc.paper.plugin.loader.library.impl.JarLibrary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +@SuppressWarnings("UnstableApiUsage") +public final class IrisPluginLoader implements PluginLoader { + @Override + public void classloader(PluginClasspathBuilder classpathBuilder) { + PluginProviderContext context = classpathBuilder.getContext(); + Path libraryRoot = context.getDataDirectory().resolve("cache").resolve("libraries"); + SlimJar.loadBootstrap(libraryRoot, new SlimJar.BootstrapLogger() { + @Override + public void info(String message) { + context.getLogger().info(message); + } + + @Override + public void error(String message) { + context.getLogger().error(message); + } + + @Override + public void debug(String message) { + context.getLogger().debug(message); + } + }); + for (Path library : relocatedLibraries(libraryRoot)) { + classpathBuilder.addLibrary(new JarLibrary(library)); + } + } + + static List relocatedLibraries(Path libraryRoot) { + try (Stream paths = Files.walk(libraryRoot)) { + List libraries = paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".jar")) + .filter(IrisPluginLoader::isRelocatedLibrary) + .sorted() + .toList(); + if (libraries.isEmpty()) { + throw new IllegalStateException("Iris runtime library provisioning produced no relocated libraries."); + } + return libraries; + } catch (IOException failure) { + throw new IllegalStateException("Unable to enumerate provisioned Iris runtime libraries.", failure); + } + } + + private static boolean isRelocatedLibrary(Path path) { + int count = path.getNameCount(); + for (int i = 0; i < count - 2; i++) { + if ("relocated".equals(path.getName(i).toString()) + && "Iris".equals(path.getName(i + 1).toString())) { + return true; + } + } + return false; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index bcb9d0ce6..8c6b9f03c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -96,6 +96,7 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -412,7 +413,8 @@ public class CommandStudio implements DirectorExecutor { return; } - VisionGUI.launch(IrisToolbelt.access(world).getEngine()); + UUID openerId = sender().isPlayer() ? player().getUniqueId() : null; + VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP)); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java index 8eb3b800a..e10599847 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java @@ -30,6 +30,7 @@ import org.bukkit.event.Listener; import java.util.ArrayList; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public final class BukkitGuiHost implements GuiHost.Provider { @@ -72,8 +73,8 @@ public final class BukkitGuiHost implements GuiHost.Provider { } @Override - public GuiOverlay overlayFor(Engine engine) { - return engine == null ? null : new BukkitVisionOverlay(engine); + public GuiOverlay overlayFor(Engine engine, UUID openerId) { + return engine == null ? null : new BukkitVisionOverlay(engine, openerId); } private static final class HotloadListener implements Listener { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java index 946f35dc3..ff21f5bc4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java @@ -18,7 +18,6 @@ package art.arcane.iris.core.gui; -import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.RenderType; @@ -28,7 +27,6 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.format.Form; -import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.LivingEntity; @@ -39,6 +37,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -50,14 +49,16 @@ import static art.arcane.iris.util.common.data.registry.Attributes.MAX_HEALTH; public final class BukkitVisionOverlay implements GuiOverlay { private final Engine engine; + private final UUID openerId; private final AtomicBoolean nativeTeleportActive = new AtomicBoolean(); private final AtomicBoolean playerRefreshQueued = new AtomicBoolean(); private final AtomicLong teleportSequence = new AtomicLong(); private final AtomicReference latestTeleport = new AtomicReference<>(); private volatile List playerMarkers = List.of(); - public BukkitVisionOverlay(Engine engine) { + public BukkitVisionOverlay(Engine engine, UUID openerId) { this.engine = engine; + this.openerId = openerId; } /** @@ -177,67 +178,20 @@ public final class BukkitVisionOverlay implements GuiOverlay { return; } List players = BukkitWorldBinding.players(target); - if (players.isEmpty()) { + Player player = selectPlayer(players); + if (player == null) { finish(request); return; } - Player player = players.get(0); - requestTeleportChunk(request, target, player, world); - }); - if (!scheduled) { - finish(request); - } - } - - private void requestTeleportChunk( - VisionTeleportRequest request, - IrisWorld target, - Player player, - World world - ) { - int blockX = request.blockX; - int blockZ = request.blockZ; - int chunkX = blockX >> 4; - int chunkZ = blockZ >> 4; - CompletableFuture requested; - try { - requested = WorldRuntimeControlService.get().requestChunkAsync( - world, - chunkX, - chunkZ, - true, - true - ); - } catch (Throwable failure) { - fail(request, target, world, failure); - return; - } - if (requested == null) { - fail(request, target, world, new IllegalStateException( - "Vision destination chunk request returned no future.")); - return; - } - requested.whenComplete((chunk, failure) -> { - if (!isCurrent(request, target)) { - finish(request); - return; - } - if (failure != null) { - fail(request, target, world, failure); - return; - } - if (chunk == null || chunk.getWorld() != world) { - fail(request, target, world, new IllegalStateException( - "Vision destination chunk request returned no chunk.")); - return; - } - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - if (!isCurrent(request, target)) { - finish(request); - return; - } - int yy = world.getHighestBlockYAt(blockX, blockZ) + 1; - Location destination = new Location(world, blockX, yy, blockZ); + int blockX = request.blockX; + int blockZ = request.blockZ; + try { + int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; + Location destination = new Location( + world, + blockX + 0.5D, + blockY, + blockZ + 0.5D); if (!J.runEntity(player, () -> delegateTeleport( request, target, @@ -247,12 +201,25 @@ public final class BukkitVisionOverlay implements GuiOverlay { fail(request, target, world, new IllegalStateException( "Failed to schedule the Vision teleport on the player entity.")); } - }); - if (!scheduled) { - fail(request, target, world, new IllegalStateException( - "Failed to schedule the Vision surface lookup on its owning region.")); + } catch (Throwable failure) { + fail(request, target, world, failure); } }); + if (!scheduled) { + finish(request); + } + } + + private Player selectPlayer(List players) { + if (openerId == null) { + return players.isEmpty() ? null : players.get(0); + } + for (Player player : players) { + if (openerId.equals(player.getUniqueId())) { + return player; + } + } + return null; } private void delegateTeleport( diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index 025e0363e..e42021fe2 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -2,6 +2,7 @@ name: ${name} version: ${version} main: ${main} bootstrapper: ${bootstrapper} +loader: art.arcane.iris.IrisPluginLoader folia-supported: true api-version: '${apiVersion}' load: STARTUP diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisPluginLoaderTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisPluginLoaderTest.java new file mode 100644 index 000000000..ddfa94791 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisPluginLoaderTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class IrisPluginLoaderTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void exposesOnlyRelocatedRuntimeLibrariesInStableOrder() throws Exception { + Path root = temporaryFolder.newFolder("libraries").toPath(); + Path original = Files.createDirectories(root.resolve("gson/2.14.0")) + .resolve("gson.jar"); + Path relocatedGson = Files.createDirectories(root.resolve("gson/2.14.0/relocated/Iris")) + .resolve("gson.jar"); + Path relocatedCaffeine = Files.createDirectories(root.resolve("caffeine/3.2.4/relocated/Iris")) + .resolve("caffeine.jar"); + Files.createFile(original); + Files.createFile(relocatedGson); + Files.createFile(relocatedCaffeine); + + List libraries = IrisPluginLoader.relocatedLibraries(root); + + assertEquals(List.of(relocatedCaffeine, relocatedGson), libraries); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index c85e359a9..ef02d7427 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -53,6 +53,7 @@ public class PaperPluginMetadataTest { } assertTrue(metadata.contains("bootstrapper: " + IrisBootstrap.class.getName())); + assertTrue(metadata.contains("loader: " + IrisPluginLoader.class.getName())); assertTrue(metadata.contains("folia-supported: true")); assertTrue(metadata.contains("load: STARTUP")); assertFalse(metadata.contains("commands:")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/gui/BukkitVisionOverlayFoliaContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/gui/BukkitVisionOverlayFoliaContractTest.java index 055b6e83e..8a5eef363 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/gui/BukkitVisionOverlayFoliaContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/gui/BukkitVisionOverlayFoliaContractTest.java @@ -1,108 +1,67 @@ package art.arcane.iris.core.gui; -import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.util.common.scheduling.J; -import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; import org.junit.Test; import org.mockito.MockedStatic; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class BukkitVisionOverlayFoliaContractTest { @Test - public void teleportLoadsTheDestinationChunkBeforeItsOwningRegionReadsTheSurface() throws Exception { - String source = Files.readString(Path.of( - "src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java" - )).replace("\r\n", "\n"); - String request = method(source, "private void requestTeleportChunk("); - - assertBefore(request, "requestChunkAsync(", "requested.whenComplete("); - assertBefore(request, "requested.whenComplete(", "J.runRegion("); - assertBefore(request, "J.runRegion(", "world.getHighestBlockYAt("); - assertBefore(request, "world.getHighestBlockYAt(", "J.runEntity("); - assertTrue(request.contains("chunkX,\n chunkZ,\n true,\n true")); - assertEquals(1, occurrences(request, "world.getHighestBlockYAt(")); - } - - @Test - public void teleportReportsAsyncAndSchedulingFailuresWithDestinationContext() throws Exception { - String source = Files.readString(Path.of( - "src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java" - )).replace("\r\n", "\n"); - String request = method(source, "private void requestTeleportChunk("); - String reporter = method(source, "private void reportTeleportFailure("); - - assertTrue(request.contains("if (requested == null)")); - assertTrue(request.contains("if (failure != null)")); - assertTrue(request.contains("if (chunk == null ||")); - assertTrue(request.contains("if (!J.runEntity(")); - assertTrue(request.contains("if (!scheduled)")); - assertTrue(reporter.contains("IrisLogging.reportError(")); - assertTrue(reporter.contains("world.getName()")); - assertTrue(reporter.contains("blockX + \",\" + blockZ")); - } - - @Test - public void teleportObservesNativeCompletionAndRejectsFalseSettlement() throws Exception { - String source = Files.readString(Path.of( - "src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java" - )).replace("\r\n", "\n"); - String delegate = method(source, "private void delegateTeleport("); - - assertTrue(delegate.contains("teleport.whenComplete(")); - assertTrue(delegate.contains("!Boolean.TRUE.equals(success)")); - assertTrue(delegate.contains("restartLatest(request)")); - } - - @Test - public void staleChunkCompletionCannotTeleportOverTheLatestRequest() { + public void teleportDelegatesImmediatelyToTheNativeAsyncPath() { VisionHarness harness = new VisionHarness(); - CompletableFuture firstChunk = new CompletableFuture<>(); - CompletableFuture secondChunk = new CompletableFuture<>(); - harness.stubChunk(0, 0, firstChunk); - harness.stubChunk(2, 2, secondChunk); try (harness) { - harness.overlay.teleport(1.5D, 1.5D); harness.overlay.teleport(33.5D, 33.5D); - firstChunk.complete(harness.chunk); - assertEquals(0, harness.destinations.size()); - secondChunk.complete(harness.chunk); + assertEquals(1, harness.destinations.size()); + Location destination = harness.destinations.get(0); + assertEquals(33.5D, destination.getX(), 0D); + assertEquals(76D, destination.getY(), 0D); + assertEquals(33.5D, destination.getZ(), 0D); + verify(harness.engine).getHeight(33, 33, false); + } + } + + @Test + public void teleportFloorsNegativeCoordinatesBeforeCenteringTheDestination() { + VisionHarness harness = new VisionHarness(); + + try (harness) { + harness.overlay.teleport(-0.25D, -16.01D); assertEquals(1, harness.destinations.size()); - assertEquals(33, harness.destinations.get(0).getBlockX()); - assertEquals(33, harness.destinations.get(0).getBlockZ()); + Location destination = harness.destinations.get(0); + assertEquals(-0.5D, destination.getX(), 0D); + assertEquals(-16.5D, destination.getZ(), 0D); + verify(harness.engine).getHeight(-1, -17, false); } } @Test public void latestRequestRunsAfterAnOlderNativeTeleportSettles() { VisionHarness harness = new VisionHarness(); - harness.stubChunk(0, 0, CompletableFuture.completedFuture(harness.chunk)); - harness.stubChunk(2, 2, CompletableFuture.completedFuture(harness.chunk)); CompletableFuture firstTeleport = new CompletableFuture<>(); CompletableFuture secondTeleport = new CompletableFuture<>(); harness.nativeTeleports.add(firstTeleport); @@ -121,42 +80,17 @@ public class BukkitVisionOverlayFoliaContractTest { } } - private static void assertBefore(String source, String first, String second) { - int firstIndex = source.indexOf(first); - int secondIndex = source.indexOf(second); - assertTrue("Missing source contract token: " + first, firstIndex >= 0); - assertTrue("Missing source contract token: " + second, secondIndex >= 0); - assertTrue(first + " must occur before " + second, firstIndex < secondIndex); - } + @Test + public void missingOpenerDoesNotTeleportAnotherPlayer() { + VisionHarness harness = new VisionHarness(); + harness.binding.when(() -> BukkitWorldBinding.players(harness.target)) + .thenReturn(List.of(harness.otherPlayer)); - private static int occurrences(String source, String match) { - int count = 0; - int offset = 0; - while ((offset = source.indexOf(match, offset)) >= 0) { - count++; - offset += match.length(); - } - return count; - } + try (harness) { + harness.overlay.teleport(1.5D, 1.5D); - private static String method(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("Missing source contract signature: " + signature, start >= 0); - int openBrace = source.indexOf('{', start); - assertTrue("Missing source contract method body: " + signature, openBrace >= 0); - int depth = 0; - for (int index = openBrace; index < source.length(); index++) { - char current = source.charAt(index); - if (current == '{') { - depth++; - } else if (current == '}') { - depth--; - if (depth == 0) { - return source.substring(start, index + 1); - } - } + assertEquals(0, harness.destinations.size()); } - throw new IllegalArgumentException("Unclosed source contract method: " + signature); } private static final class VisionHarness implements AutoCloseable { @@ -164,11 +98,10 @@ public class BukkitVisionOverlayFoliaContractTest { private final IrisWorld target; private final World world; private final Player player; - private final Chunk chunk; - private final WorldRuntimeControlService runtime; + private final Player otherPlayer; + private final UUID openerId; private final MockedStatic scheduling; private final MockedStatic binding; - private final MockedStatic runtimeAccess; private final MockedStatic platform; private final List destinations; private final List> nativeTeleports; @@ -180,33 +113,26 @@ public class BukkitVisionOverlayFoliaContractTest { target = mock(IrisWorld.class); world = mock(World.class); player = mock(Player.class); - chunk = mock(Chunk.class); - runtime = mock(WorldRuntimeControlService.class); + otherPlayer = mock(Player.class); + openerId = UUID.randomUUID(); destinations = new ArrayList<>(); nativeTeleports = new ArrayList<>(); nativeTeleportIndex = new AtomicInteger(); when(engine.getWorld()).thenReturn(target); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(138); when(target.hasPlatformWorld()).thenReturn(true); when(player.isOnline()).thenReturn(true); when(player.getWorld()).thenReturn(world); - when(chunk.getWorld()).thenReturn(world); - when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(70); + when(player.getUniqueId()).thenReturn(openerId); + when(otherPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); scheduling = mockStatic(J.class); scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> { invocation.getArgument(0, Runnable.class).run(); return true; }); - scheduling.when(() -> J.runRegion( - same(world), - anyInt(), - anyInt(), - any(Runnable.class))) - .thenAnswer(invocation -> { - invocation.getArgument(3, Runnable.class).run(); - return true; - }); scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> { invocation.getArgument(1, Runnable.class).run(); return true; @@ -214,10 +140,7 @@ public class BukkitVisionOverlayFoliaContractTest { binding = mockStatic(BukkitWorldBinding.class); binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world); - binding.when(() -> BukkitWorldBinding.players(target)).thenReturn(List.of(player)); - - runtimeAccess = mockStatic(WorldRuntimeControlService.class); - runtimeAccess.when(WorldRuntimeControlService::get).thenReturn(runtime); + binding.when(() -> BukkitWorldBinding.players(target)).thenReturn(List.of(otherPlayer, player)); platform = mockStatic(BukkitPlatform.class); platform.when(() -> BukkitPlatform.teleportAsync(same(player), any(Location.class))) @@ -228,26 +151,12 @@ public class BukkitVisionOverlayFoliaContractTest { ? nativeTeleports.get(index) : CompletableFuture.completedFuture(true); }); - overlay = new BukkitVisionOverlay(engine); - } - - private void stubChunk( - int chunkX, - int chunkZ, - CompletableFuture requested - ) { - when(runtime.requestChunkAsync( - same(world), - eq(chunkX), - eq(chunkZ), - eq(true), - eq(true))).thenReturn(requested); + overlay = new BukkitVisionOverlay(engine, openerId); } @Override public void close() { platform.close(); - runtimeAccess.close(); binding.close(); scheduling.close(); } diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java index 8856e5cf9..ca6d91d55 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java @@ -21,6 +21,7 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.ModdedLoader; import art.arcane.iris.modded.service.ModdedTreeFellerService; import net.fabricmc.api.EnvType; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents; import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; import net.fabricmc.fabric.api.permission.v1.PermissionContextOwner; import net.fabricmc.loader.api.FabricLoader; @@ -71,6 +72,16 @@ public final class FabricModdedLoader implements ModdedLoader { // map). Off-thread readers rely on the ModdedServerLevels snapshot, which the caller republishes. } + @Override + public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) { + ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level); + } + + @Override + public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) { + ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level); + } + @Override public boolean clientEnvironment() { return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT; diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java index 663cfacb6..e7f40264b 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java @@ -28,6 +28,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.block.state.BlockState; import net.minecraftforge.common.util.Result; import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.event.level.LevelEvent; import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLLoader; @@ -77,6 +78,16 @@ public final class ForgeModdedLoader implements ModdedLoader { server.markWorldsDirty(); } + @Override + public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) { + LevelEvent.Load.BUS.post(new LevelEvent.Load(level)); + } + + @Override + public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) { + LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level)); + } + @Override public boolean clientEnvironment() { return FMLEnvironment.dist.isClient(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index 8065d6d15..ac835d03e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -56,13 +56,16 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedDimensionManager { + private static final int TELEPORT_WARM_RADIUS = 0; private static final Object LOCK = new Object(); private static final ConcurrentHashMap HANDLES = new ConcurrentHashMap<>(); private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE); - private static final long TELEPORT_WARM_TIMEOUT_SECONDS = 30L; + private static final long TELEPORT_TIMEOUT_SECONDS = 10L; private static volatile ModdedServerAccess access; private ModdedDimensionManager() { @@ -213,15 +216,16 @@ public final class ModdedDimensionManager { instanceof IrisModdedChunkGenerator irisGenerator ? irisGenerator : null; - boolean generatorUnbound = false; + boolean unloadEventStarted = false; try { evacuate(server, level); + level.save(null, true, false); + unloadEventStarted = true; + ModdedEngineBootstrap.loader().fireDynamicLevelUnload(server, level); if (generator != null) { generator.unbindEngine(level); - generatorUnbound = true; } ModdedWorldEngines.evictOrThrow(level); - level.save(null, true, false); serverAccess.removeLevel(server, key); // Undo snapshots pin the ServerLevel and could replay into the dead level. art.arcane.iris.modded.command.ModdedObjectUndo.forget(level); @@ -233,7 +237,7 @@ public final class ModdedDimensionManager { ModdedIrisLog.info("Iris removed runtime dimension '{}'", dimensionId); return true; } catch (Throwable e) { - rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e); + rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e); ModdedIrisLog.error("Iris failed to remove runtime dimension '{}'", dimensionId, e); throw new IllegalStateException("Iris runtime dimension removal failed for " + dimensionId, e); } @@ -242,13 +246,16 @@ public final class ModdedDimensionManager { private static void rollbackRemoval(MinecraftServer server, ModdedServerAccess serverAccess, ResourceKey key, ServerLevel level, - IrisModdedChunkGenerator generator, boolean generatorUnbound, - Throwable failure) { + IrisModdedChunkGenerator generator, boolean unloadEventStarted, + Throwable failure) { try { - if (!generatorUnbound || generator == null || !serverAccess.hasLevel(server, key)) { + if (!unloadEventStarted || !serverAccess.hasLevel(server, key)) { return; } - generator.bindLevel(level); + if (generator != null) { + generator.bindLevel(level); + } + ModdedEngineBootstrap.loader().fireDynamicLevelLoad(server, level); } catch (Throwable rollbackFailure) { if (rollbackFailure != failure) { failure.addSuppressed(rollbackFailure); @@ -258,44 +265,263 @@ public final class ModdedDimensionManager { } } - public static boolean teleport(ServerPlayer player, MinecraftServer server, String dimensionId, double x, double y, double z) { - ServerLevel level = level(server, dimensionId); - if (level == null) { - return false; - } - int blockX = (int) Math.floor(x); - int blockZ = (int) Math.floor(z); - ChunkPos chunkPos = new ChunkPos(blockX >> 4, blockZ >> 4); - if (level.getChunkSource().hasChunk(chunkPos.x(), chunkPos.z())) { - completeTeleport(player, level, x, y, z, blockX, blockZ); - return true; - } - UUID playerId = player.getUUID(); - CompletableFuture - .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server) - .thenCompose((CompletableFuture inner) -> inner) - // The ticket has no timeout of its own: bound the wait so the release below always runs. - .orTimeout(TELEPORT_WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .whenComplete((Object result, Throwable error) -> server.execute(() -> { - level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1); - if (error != null) { - ModdedIrisLog.warn("Iris chunk warm for teleport into '{}' at {},{} failed: {}", dimensionId, chunkPos.x(), chunkPos.z(), error.toString()); - } - ServerPlayer target = server.getPlayerList().getPlayer(playerId); - if (target == null) { - return; - } - completeTeleport(target, level, x, y, z, blockX, blockZ); - })); - return true; + public static CompletableFuture teleportAsync( + ServerPlayer player, + MinecraftServer server, + String dimensionId, + double x, + double y, + double z + ) { + return teleportAsync(player, server, dimensionId, x, y, z, + System.nanoTime() + TimeUnit.SECONDS.toNanos(TELEPORT_TIMEOUT_SECONDS)); } - private static void completeTeleport(ServerPlayer player, ServerLevel level, double x, double y, double z, int blockX, int blockZ) { - double targetY = y; - if (y == Double.MIN_VALUE) { - targetY = level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ); + public static CompletableFuture teleportAsync( + ServerPlayer player, + MinecraftServer server, + String dimensionId, + double x, + double y, + double z, + long deadlineNanos + ) { + ServerLevel level = level(server, dimensionId); + if (level == null) { + return CompletableFuture.completedFuture(false); } - player.teleportTo(level, x, targetY, z, Set.of(), player.getYRot(), player.getXRot(), false); + return teleportAsync(player, server, level, x, y, z, deadlineNanos); + } + + public static CompletableFuture teleportAsync( + ServerPlayer player, + MinecraftServer server, + ServerLevel level, + double x, + double y, + double z + ) { + return teleportAsync(player, server, level, x, y, z, 0L); + } + + public static CompletableFuture teleportAsync( + ServerPlayer player, + MinecraftServer server, + ServerLevel level, + double x, + double y, + double z, + long deadlineNanos + ) { + CompletableFuture result = new CompletableFuture<>(); + if (player == null || server == null || level == null || level.getServer() != server) { + result.complete(false); + return result; + } + if (!Double.isFinite(x) || !Double.isFinite(z) + || (y != Double.MIN_VALUE && !Double.isFinite(y))) { + result.completeExceptionally(new IllegalArgumentException("Teleport coordinates must be finite.")); + return result; + } + if (deadlineNanos != 0L) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0L) { + result.completeExceptionally(teleportTimeout(level, x, z)); + return result; + } + result.orTimeout(remainingNanos, TimeUnit.NANOSECONDS); + } + UUID playerId = player.getUUID(); + runOnServer(server, () -> beginTeleport( + result, + playerId, + server, + level, + x, + y, + z, + deadlineNanos)); + return result; + } + + private static void beginTeleport( + CompletableFuture result, + UUID playerId, + MinecraftServer server, + ServerLevel level, + double x, + double y, + double z, + long deadlineNanos + ) { + if (result.isDone()) { + return; + } + if (deadlineNanos != 0L && System.nanoTime() >= deadlineNanos) { + result.completeExceptionally(teleportTimeout(level, x, z)); + return; + } + ServerLevel active = level(server, level.dimension().identifier().toString()); + if (active != level) { + result.complete(false); + return; + } + int blockX = ModdedTeleportBounds.blockCoordinate(x); + int blockZ = ModdedTeleportBounds.blockCoordinate(z); + ChunkPos chunkPos = new ChunkPos(blockX >> 4, blockZ >> 4); + if (level.getChunkSource().hasChunk(chunkPos.x(), chunkPos.z())) { + completeTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, deadlineNanos); + return; + } + warmAndTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, chunkPos, deadlineNanos); + } + + private static void warmAndTeleport( + CompletableFuture result, + UUID playerId, + MinecraftServer server, + ServerLevel level, + double x, + double y, + double z, + int blockX, + int blockZ, + ChunkPos chunkPos, + long deadlineNanos + ) { + AtomicBoolean ticketReleased = new AtomicBoolean(); + CompletableFuture chunkLoad; + try { + chunkLoad = level.getChunkSource().addTicketAndLoadWithRadius( + TELEPORT_WARM_TICKET, + chunkPos, + TELEPORT_WARM_RADIUS); + } catch (Throwable failure) { + result.completeExceptionally(failure); + return; + } + if (chunkLoad == null) { + releaseTeleportTicket(level, chunkPos, ticketReleased); + result.completeExceptionally(new IllegalStateException( + "Chunk warm returned no completion future for " + level.dimension().identifier() + + " at " + chunkPos.x() + "," + chunkPos.z() + ".")); + return; + } + result.whenComplete((success, failure) -> runOnServer(server, + () -> releaseTeleportTicket(level, chunkPos, ticketReleased))); + chunkLoad.whenComplete((ignored, failure) -> runOnServer(server, () -> { + releaseTeleportTicket(level, chunkPos, ticketReleased); + if (result.isDone()) { + return; + } + if (failure != null) { + result.completeExceptionally(failure); + return; + } + completeTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, deadlineNanos); + })); + } + + private static void releaseTeleportTicket( + ServerLevel level, + ChunkPos chunkPos, + AtomicBoolean ticketReleased + ) { + if (ticketReleased.compareAndSet(false, true)) { + level.getChunkSource().removeTicketWithRadius( + TELEPORT_WARM_TICKET, + chunkPos, + TELEPORT_WARM_RADIUS); + } + } + + private static void completeTeleport( + CompletableFuture result, + UUID playerId, + MinecraftServer server, + ServerLevel level, + double x, + double y, + double z, + int blockX, + int blockZ, + long deadlineNanos + ) { + if (result.isDone()) { + return; + } + if (deadlineNanos != 0L && System.nanoTime() >= deadlineNanos) { + result.completeExceptionally(teleportTimeout(level, x, z)); + return; + } + ServerLevel active = level(server, level.dimension().identifier().toString()); + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (active != level || player == null) { + result.complete(false); + return; + } + try { + int targetY = resolveSafeTeleportY(level, blockX, blockZ, y); + boolean teleported = player.teleportTo( + level, + x, + targetY, + z, + Set.of(), + player.getYRot(), + player.getXRot(), + false); + result.complete(teleported); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + } + + private static int resolveSafeTeleportY(ServerLevel level, int blockX, int blockZ, double requestedY) { + int initialY = requestedY == Double.MIN_VALUE + ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ) + : (int) Math.floor(requestedY); + int startY = ModdedTeleportBounds.clampY(level.getMinY(), level.getMaxY(), initialY); + int maximumY = ModdedTeleportBounds.maximumY(level.getMinY(), level.getMaxY()); + int minimumY = ModdedTeleportBounds.minimumY(level.getMinY(), level.getMaxY()); + for (int candidateY = startY; candidateY <= maximumY; candidateY++) { + if (isSafeStandingPosition(level, blockX, candidateY, blockZ)) { + return candidateY; + } + } + for (int candidateY = startY - 1; candidateY >= minimumY; candidateY--) { + if (isSafeStandingPosition(level, blockX, candidateY, blockZ)) { + return candidateY; + } + } + throw new IllegalStateException("No safe teleport position exists in " + + level.dimension().identifier() + " at " + blockX + "," + blockZ + "."); + } + + private static boolean isSafeStandingPosition(ServerLevel level, int blockX, int blockY, int blockZ) { + BlockPos feet = new BlockPos(blockX, blockY, blockZ); + BlockPos head = feet.above(); + BlockPos support = feet.below(); + return level.getBlockState(feet).getCollisionShape(level, feet).isEmpty() + && level.getBlockState(feet).getFluidState().isEmpty() + && level.getBlockState(head).getCollisionShape(level, head).isEmpty() + && level.getBlockState(head).getFluidState().isEmpty() + && !level.getBlockState(support).getCollisionShape(level, support).isEmpty(); + } + + private static TimeoutException teleportTimeout(ServerLevel level, double x, double z) { + return new TimeoutException("Teleport into " + level.dimension().identifier() + + " at " + ModdedTeleportBounds.blockCoordinate(x) + "," + + ModdedTeleportBounds.blockCoordinate(z) + + " exceeded " + TELEPORT_TIMEOUT_SECONDS + " seconds."); + } + + private static void runOnServer(MinecraftServer server, Runnable task) { + if (server.isSameThread()) { + task.run(); + return; + } + server.execute(task); } private static Holder resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) { @@ -362,6 +588,7 @@ public final class ModdedDimensionManager { List.of(), false); + boolean loadEventStarted = false; try { generator.bindLevel(level); Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); @@ -371,9 +598,11 @@ public final class ModdedDimensionManager { + "': the level was registered concurrently"); } server.getPlayerList().addWorldborderListener(level); + loadEventStarted = true; + ModdedEngineBootstrap.loader().fireDynamicLevelLoad(server, level); return handle; } catch (Throwable error) { - rollbackInjection(server, serverAccess, key, level, generator, error); + rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error); if (error instanceof RuntimeException runtimeException) { throw runtimeException; } @@ -386,7 +615,17 @@ public final class ModdedDimensionManager { private static void rollbackInjection(MinecraftServer server, ModdedServerAccess serverAccess, ResourceKey key, ServerLevel level, - IrisModdedChunkGenerator generator, Throwable failure) { + IrisModdedChunkGenerator generator, boolean loadEventStarted, + Throwable failure) { + if (loadEventStarted) { + try { + ModdedEngineBootstrap.loader().fireDynamicLevelUnload(server, level); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + ModdedIrisLog.error("Iris failed to publish rollback unload for {}", + key.identifier(), cleanupError); + } + } try { if (serverAccess.hasLevel(server, key)) { ServerLevel removed = serverAccess.removeLevel(server, key); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java index 03247425e..4ffa2b8b3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java @@ -38,6 +38,10 @@ public interface ModdedLoader { void invalidateLevelCache(MinecraftServer server); + void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level); + + void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level); + boolean clientEnvironment(); Path configDir(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index f824f247e..057489797 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -26,12 +26,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; public final class ModdedPrimaryWorldRouter { private static final int TICK_INTERVAL = 20; private static final Set routed = ConcurrentHashMap.newKeySet(); + private static final Set inFlight = ConcurrentHashMap.newKeySet(); private static int tickCounter = 0; private ModdedPrimaryWorldRouter() { @@ -39,6 +41,7 @@ public final class ModdedPrimaryWorldRouter { public static void clear() { routed.clear(); + inFlight.clear(); } /** @@ -48,6 +51,7 @@ public final class ModdedPrimaryWorldRouter { public static void forget(UUID player) { if (player != null) { routed.remove(player); + inFlight.remove(player); } } @@ -82,17 +86,35 @@ public final class ModdedPrimaryWorldRouter { List players = new ArrayList<>(server.getPlayerList().getPlayers()); for (ServerPlayer player : players) { UUID id = player.getUUID(); - if (routed.contains(id)) { + if (routed.contains(id) || !inFlight.add(id)) { continue; } if (player.level() != overworld) { + inFlight.remove(id); routed.add(id); continue; } try { - ModdedDimensionManager.teleport(player, server, primary, player.getX(), Double.MIN_VALUE, player.getZ()); - routed.add(id); + CompletableFuture teleport = ModdedDimensionManager.teleportAsync( + player, + server, + primary, + player.getX(), + Double.MIN_VALUE, + player.getZ()); + teleport.whenComplete((success, failure) -> { + inFlight.remove(id); + if (Boolean.TRUE.equals(success) && failure == null) { + routed.add(id); + return; + } + if (failure != null) { + ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", + id, primary, failure); + } + }); } catch (Throwable e) { + inFlight.remove(id); ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportBounds.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportBounds.java new file mode 100644 index 000000000..df51cc065 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportBounds.java @@ -0,0 +1,50 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +final class ModdedTeleportBounds { + private ModdedTeleportBounds() { + } + + static int blockCoordinate(double coordinate) { + return (int) Math.floor(coordinate); + } + + static int clampY(int minY, int maxY, int requestedY) { + int minimumY = minimumY(minY, maxY); + int maximumY = maximumY(minY, maxY); + return Math.max(minimumY, Math.min(maximumY, requestedY)); + } + + static int minimumY(int minY, int maxY) { + requireStandingSpace(minY, maxY); + return minY + 1; + } + + static int maximumY(int minY, int maxY) { + requireStandingSpace(minY, maxY); + return maxY - 2; + } + + private static void requireStandingSpace(int minY, int maxY) { + if (maxY - minY < 3) { + throw new IllegalArgumentException("Level height must provide support, feet, and head space."); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 92cf23f3e..1a76fd2cf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -131,10 +131,26 @@ public final class IrisModdedCommands { return 0; } String dimensionId = level.dimension().identifier().toString(); - if (!ModdedDimensionManager.teleport(player, source.getServer(), dimensionId, 8.5D, Double.MIN_VALUE, 8.5D)) { - fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, MessageArgument.untrusted("dimensionId", dimensionId))); - return 0; - } + MinecraftServer server = source.getServer(); + CompletableFuture teleport = ModdedDimensionManager.teleportAsync( + player, + server, + dimensionId, + 8.5D, + Double.MIN_VALUE, + 8.5D); + teleport.whenComplete((success, failure) -> { + if (Boolean.TRUE.equals(success) && failure == null) { + return; + } + if (failure != null) { + ModdedIrisLog.error("Iris teleport into '{}' failed for {}", + dimensionId, player.getUUID(), failure); + } + server.execute(() -> fail(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, + MessageArgument.untrusted("dimensionId", dimensionId)))); + }); ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.getScoreboardName()), MessageArgument.untrusted("dimensionId", dimensionId))); return 1; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java index 3f2b22bd1..61f39298b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java @@ -107,7 +107,7 @@ public final class ModdedGuiHost implements GuiHost.Provider { } @Override - public GuiOverlay overlayFor(Engine engine) { + public GuiOverlay overlayFor(Engine engine, UUID openerId) { if (engine == null) { return null; } @@ -115,6 +115,7 @@ public final class ModdedGuiHost implements GuiHost.Provider { if (level == null || server == null) { return null; } - return new ModdedVisionOverlay(server, level, engine, openers.get(engine)); + UUID resolvedOpenerId = openerId == null ? openers.get(engine) : openerId; + return new ModdedVisionOverlay(server, level, engine, resolvedOpenerId); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 81536d7fa..2acd6744c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -44,6 +44,7 @@ import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedWorkspaceGenerator; import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; @@ -67,7 +68,6 @@ import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.entity.Relative; import org.zeroturnaround.zip.ZipUtil; import java.awt.Desktop; @@ -79,6 +79,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; @@ -96,6 +98,7 @@ public final class ModdedStudioCommands { private static final String DEFAULT_TEMPLATE = "example"; private static final UUID CONSOLE_OWNER = new UUID(0L, 0L); private static final Map STUDIOS = new ConcurrentHashMap<>(); + private static final ModdedStudioTransitionQueue TRANSITIONS = new ModdedStudioTransitionQueue(); private static final SuggestionProvider GENERATOR_KEYS = (CommandContext context, SuggestionsBuilder builder) -> { ModdedCommandFeedback.tab(context.getSource()); try { @@ -192,6 +195,7 @@ public final class ModdedStudioCommands { } public static void clear() { + TRANSITIONS.clear(); STUDIOS.clear(); } @@ -272,7 +276,7 @@ public final class ModdedStudioCommands { } ServerPlayer player = source.getPlayer(); ModdedGuiHost.bindContext(source.getServer(), level, engine, player == null ? null : player.getUUID()); - VisionGUI.launch(engine); + VisionGUI.launch(engine, player == null ? null : player.getUUID()); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.dimension().identifier()))); return 1; } @@ -402,126 +406,244 @@ public final class ModdedStudioCommands { String dimensionId = player == null ? studioConsoleDimensionId() : studioDimensionId(player); MinecraftServer server = source.getServer(); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_STUDIO_SEED, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed))); - Thread thread = new Thread(() -> openAsync(source, server, owner, dimensionId, pack, seed), "Iris Studio Open"); - thread.setDaemon(true); - thread.start(); + CompletableFuture transition = TRANSITIONS.submit( + owner, + () -> openTransition(source, server, owner, dimensionId, pack, seed)); + reportOpenFailure(source, server, dimensionId, pack, transition); return 1; } - private static void openAsync(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) { + private static CompletableFuture openTransition( + CommandSourceStack source, + MinecraftServer server, + UUID owner, + String dimensionId, + String pack, + long seed + ) { + CompletableFuture transition = new CompletableFuture<>(); + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + transition.completeExceptionally(new IllegalStateException( + "Iris modded scheduler is unavailable for Studio open.")); + return transition; + } + scheduler.asyncIfRunning(() -> prepareStudioOpen( + source, + server, + owner, + dimensionId, + pack, + seed, + transition), + () -> transition.completeExceptionally(new IllegalStateException( + "Iris modded scheduler rejected Studio open."))); + return transition; + } + + private static void prepareStudioOpen( + CommandSourceStack source, + MinecraftServer server, + UUID owner, + String dimensionId, + String pack, + long seed, + CompletableFuture transition + ) { try { File packFolder = new File(ModdedPackCommands.packsRoot(), pack); if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) { - server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain( - ModdedCommandMessages.MODDED_STUDIO_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART, - MessageArgument.untrusted("pack", pack)))); - return; + throw new IllegalStateException("Required Studio pack '" + pack + + "' is not installed with dimensions/" + pack + ".json."); } IrisData data = IrisData.get(packFolder); IrisDimension dimension = data.getDimensionLoader().load(pack); if (dimension == null) { - server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_HAS_NO_DIMENSIONS_JSON, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); - return; + throw new IllegalStateException("Studio pack '" + pack + + "' has no dimensions/" + pack + ".json definition."); } - try { - ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true); - } catch (Throwable workspaceError) { - ModdedIrisLog.error("Iris workspace write failed for {}", packFolder, workspaceError); - server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain( - ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE, - MessageArgument.untrusted("value", packFolder.getAbsolutePath()), - MessageArgument.untrusted("value2", String.valueOf(workspaceError.getMessage()))))); - } - server.execute(() -> { - if (owner.equals(CONSOLE_OWNER)) { - injectConsole(source, server, dimensionId, pack, seed); - } else { - injectAndTeleport(source, server, owner, dimensionId, pack, seed); - } - }); - } catch (Throwable e) { - ModdedIrisLog.error("Iris studio open failed for {}", pack, e); - server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))))); + ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true); + server.execute(() -> executeStudioOpen( + source, + server, + owner, + dimensionId, + pack, + seed, + transition)); + } catch (Throwable failure) { + transition.completeExceptionally(failure); } } - private static void injectConsole(CommandSourceStack source, MinecraftServer server, String dimensionId, String pack, long seed) { - ModdedDimensionManager.Handle handle; + private static void executeStudioOpen( + CommandSourceStack source, + MinecraftServer server, + UUID owner, + String dimensionId, + String pack, + long seed, + CompletableFuture transition + ) { + ModdedDimensionManager.Handle handle = null; try { + replaceExistingStudio(server, owner, dimensionId); handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed); - } catch (Throwable e) { - ModdedIrisLog.error("Iris console studio injection failed for {} ({})", dimensionId, pack, e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_INJECTION_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); - return; - } - STUDIOS.put(CONSOLE_OWNER, dimensionId); - ServerLevel studio = handle.level(); - int surface = studio.getMaxY(); - try { - Engine engine = IrisModdedCommands.engineFor(studio); - if (engine != null) { - surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + STUDIOS.put(owner, dimensionId); + if (owner.equals(CONSOLE_OWNER)) { + completeConsoleOpen(source, dimensionId, pack, seed, handle); + transition.complete(null); + return; + } + ServerPlayer player = server.getPlayerList().getPlayer(owner); + if (player == null) { + throw new IllegalStateException("Studio owner disconnected before teleport."); + } + ServerLevel studio = handle.level(); + Engine engine = IrisModdedCommands.engineFor(studio); + if (engine == null) { + throw new IllegalStateException("Studio engine is unavailable for " + dimensionId + "."); + } + int surfaceY = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + CompletableFuture teleport = ModdedDimensionManager.teleportAsync( + player, + server, + studio, + 8.5D, + surfaceY, + 8.5D); + teleport.whenComplete((success, failure) -> server.execute(() -> { + if (transition.isDone()) { + return; + } + if (failure != null) { + transition.completeExceptionally(failure); + return; + } + if (!Boolean.TRUE.equals(success)) { + transition.completeExceptionally(new IllegalStateException( + "Studio native teleport did not complete successfully.")); + return; + } + IrisModdedCommands.ok(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN, + MessageArgument.untrusted("dimensionId", dimensionId), + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("seed", seed))); + transition.complete(null); + })); + } catch (Throwable failure) { + transition.completeExceptionally(failure); + if (handle != null && transition.isCompletedExceptionally()) { + cleanupFailedOpen(server, owner, dimensionId, failure); } - } catch (Throwable e) { - ModdedIrisLog.error("Iris console studio surface probe failed for {}", dimensionId, e); } + } + + private static void replaceExistingStudio(MinecraftServer server, UUID owner, String dimensionId) { + if (ModdedDimensionManager.level(server, dimensionId) != null + || ModdedDimensionManager.handle(dimensionId) != null) { + ModdedDimensionManager.remove(server, dimensionId, true); + } + STUDIOS.remove(owner, dimensionId); + } + + private static void cleanupFailedOpen( + MinecraftServer server, + UUID owner, + String dimensionId, + Throwable failure + ) { + try { + ModdedDimensionManager.remove(server, dimensionId, true); + STUDIOS.remove(owner, dimensionId); + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + ModdedIrisLog.error("Iris failed to clean up Studio '{}' after open failure", + dimensionId, cleanupFailure); + } + } + + private static void completeConsoleOpen( + CommandSourceStack source, + String dimensionId, + String pack, + long seed, + ModdedDimensionManager.Handle handle + ) { + ServerLevel studio = handle.level(); + Engine engine = IrisModdedCommands.engineFor(studio); + int surface = engine == null + ? studio.getMinY() + 1 + : engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + surface = Math.max(studio.getMinY() + 1, Math.min(studio.getMaxY() - 2, surface)); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_CONSOLE_STUDIO_OPEN_NOW_RUNS_SEED_TRANSIENT_NOT_WRITTEN_IRIS, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_ENTER_IT_WITH_EXECUTE_RUN_TP_S_8_5_8, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("surface", surface))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PREGEN_IT_WITH_IRIS_PREGEN_START_RADIUS, MessageArgument.untrusted("dimensionId", dimensionId))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_REMOVE_IT_WITH_IRIS_STUDIO_CLOSE)); } - private static void injectAndTeleport(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) { - ServerPlayer player = server.getPlayerList().getPlayer(owner); - if (player == null) { - return; - } - ModdedDimensionManager.Handle handle; - try { - handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed); - } catch (Throwable e) { - ModdedIrisLog.error("Iris studio injection failed for {} ({})", dimensionId, pack, e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_INJECTION_FAILED_2, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); - return; - } - STUDIOS.put(owner, dimensionId); - ServerLevel studio = handle.level(); - int surface = studio.getMaxY(); - try { - Engine engine = IrisModdedCommands.engineFor(studio); - if (engine != null) { - surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + private static void reportOpenFailure( + CommandSourceStack source, + MinecraftServer server, + String dimensionId, + String pack, + CompletableFuture transition + ) { + transition.whenComplete((ignored, failure) -> { + if (failure == null) { + return; } - } catch (Throwable e) { - ModdedIrisLog.error("Iris studio surface probe failed for {}", dimensionId, e); - } - player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed))); + Throwable cause = unwrapFailure(failure); + ModdedIrisLog.error("Iris Studio open failed for '{}' ({})", dimensionId, pack, cause); + server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED, + MessageArgument.untrusted("value", cause.getClass().getSimpleName()), + MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause))))); + }); } private static int close(CommandSourceStack source) { ServerPlayer player = source.getPlayer(); MinecraftServer server = source.getServer(); UUID owner = player == null ? CONSOLE_OWNER : player.getUUID(); - // Commit the ownership drop only after removal succeeds: dropping it first orphaned a - // still-registered studio that no command could ever remove again. - String dimensionId = STUDIOS.get(owner); - if (dimensionId == null) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN)); - return 0; - } - try { - ModdedDimensionManager.remove(server, dimensionId, true); - } catch (Throwable e) { - ModdedIrisLog.error("Iris studio close failed for {}", dimensionId, e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); - return 0; - } - STUDIOS.remove(owner, dimensionId); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, MessageArgument.untrusted("dimensionId", dimensionId))); + TRANSITIONS.submit(owner, () -> closeTransition(source, server, owner)); return 1; } + private static CompletableFuture closeTransition( + CommandSourceStack source, + MinecraftServer server, + UUID owner + ) { + CompletableFuture transition = new CompletableFuture<>(); + server.execute(() -> { + String dimensionId = STUDIOS.get(owner); + if (dimensionId == null) { + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN)); + transition.complete(null); + return; + } + try { + ModdedDimensionManager.remove(server, dimensionId, true); + STUDIOS.remove(owner, dimensionId); + IrisModdedCommands.ok(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, + MessageArgument.untrusted("dimensionId", dimensionId))); + transition.complete(null); + } catch (Throwable failure) { + ModdedIrisLog.error("Iris Studio close failed for {}", dimensionId, failure); + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, + MessageArgument.untrusted("value", failure.getClass().getSimpleName()), + MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(failure)))); + transition.completeExceptionally(failure); + } + }); + return transition; + } + private static int status(CommandSourceStack source) { MinecraftServer server = source.getServer(); List handles = ModdedDimensionManager.handles(); @@ -568,31 +690,102 @@ public final class ModdedStudioCommands { return 0; } MinecraftServer server = source.getServer(); - String dimensionId = STUDIOS.get(player.getUUID()); - if (dimensionId == null) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN_2)); - return 0; - } - ServerLevel studio = ModdedDimensionManager.level(server, dimensionId); - if (studio == null) { - STUDIOS.remove(player.getUUID()); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO)); - return 0; - } - int surface = studio.getMaxY(); - try { - Engine engine = IrisModdedCommands.engineFor(studio); - if (engine != null) { - surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; - } - } catch (Throwable e) { - ModdedIrisLog.error("Iris tpstudio surface probe failed", e); - } - player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TELEPORTED_YOUR_STUDIO, MessageArgument.untrusted("dimensionId", dimensionId))); + UUID owner = player.getUUID(); + CompletableFuture transition = TRANSITIONS.submit( + owner, + () -> teleportToStudio(source, server, owner)); + reportTeleportFailure(source, server, transition); return 1; } + private static CompletableFuture teleportToStudio( + CommandSourceStack source, + MinecraftServer server, + UUID owner + ) { + CompletableFuture transition = new CompletableFuture<>(); + server.execute(() -> { + if (transition.isDone()) { + return; + } + String dimensionId = STUDIOS.get(owner); + if (dimensionId == null) { + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN_2)); + transition.complete(null); + return; + } + ServerLevel studio = ModdedDimensionManager.level(server, dimensionId); + if (studio == null) { + STUDIOS.remove(owner, dimensionId); + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO)); + transition.complete(null); + return; + } + ServerPlayer activePlayer = server.getPlayerList().getPlayer(owner); + Engine engine = IrisModdedCommands.engineFor(studio); + if (activePlayer == null || engine == null) { + transition.completeExceptionally(new IllegalStateException( + "Studio player or engine is unavailable for teleport.")); + return; + } + int surfaceY = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + CompletableFuture teleport = ModdedDimensionManager.teleportAsync( + activePlayer, + server, + studio, + 8.5D, + surfaceY, + 8.5D); + teleport.whenComplete((success, failure) -> server.execute(() -> { + if (transition.isDone()) { + return; + } + if (failure != null) { + transition.completeExceptionally(failure); + return; + } + if (!Boolean.TRUE.equals(success)) { + transition.completeExceptionally(new IllegalStateException( + "Studio native teleport did not complete successfully.")); + return; + } + IrisModdedCommands.ok(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TELEPORTED_YOUR_STUDIO, + MessageArgument.untrusted("dimensionId", dimensionId))); + transition.complete(null); + })); + }); + return transition; + } + + private static void reportTeleportFailure( + CommandSourceStack source, + MinecraftServer server, + CompletableFuture transition + ) { + transition.whenComplete((ignored, failure) -> { + if (failure == null) { + return; + } + Throwable cause = unwrapFailure(failure); + ModdedIrisLog.error("Iris Studio teleport failed", cause); + server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED, + MessageArgument.untrusted("value", cause.getClass().getSimpleName()), + MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause))))); + }); + } + + private static Throwable unwrapFailure(Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + private static int version(CommandSourceStack source, String pack) { File folder = resolvePack(source, pack); if (folder == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueue.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueue.java new file mode 100644 index 000000000..5843a8713 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueue.java @@ -0,0 +1,58 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.command; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +final class ModdedStudioTransitionQueue { + private final Object lock = new Object(); + private final Map> tails = new HashMap<>(); + + CompletableFuture submit(UUID owner, Supplier> transition) { + Objects.requireNonNull(owner, "Studio transition owner"); + Objects.requireNonNull(transition, "Studio transition"); + synchronized (lock) { + CompletableFuture previous = tails.get(owner); + CompletableFuture admission = previous == null + ? CompletableFuture.completedFuture(null) + : previous.handle((ignored, failure) -> null); + CompletableFuture current = admission.thenCompose((ignored) -> transition.get()); + tails.put(owner, current); + current.whenComplete((ignored, failure) -> remove(owner, current)); + return current; + } + } + + void clear() { + synchronized (lock) { + tails.clear(); + } + } + + private void remove(UUID owner, CompletableFuture transition) { + synchronized (lock) { + tails.remove(owner, transition); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index 2ab8bd56c..fd0f55219 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -24,12 +24,13 @@ import art.arcane.iris.core.gui.GuiOverlay; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.iris.modded.ModdedIrisLog; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; -import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.phys.Vec3; import java.awt.Desktop; @@ -37,6 +38,8 @@ import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; public final class ModdedVisionOverlay implements GuiOverlay { @@ -80,11 +83,14 @@ public final class ModdedVisionOverlay implements GuiOverlay { @Override public void teleport(double worldX, double worldZ) { - int blockX = (int) worldX; - int blockZ = (int) worldZ; + int blockX = (int) Math.floor(worldX); + int blockZ = (int) Math.floor(worldZ); server.execute(() -> { ServerPlayer player = opener == null ? null : server.getPlayerList().getPlayer(opener); if (player == null) { + if (opener != null) { + return; + } List players = level.players(); if (players.isEmpty()) { return; @@ -92,8 +98,24 @@ public final class ModdedVisionOverlay implements GuiOverlay { player = players.get(0); } int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; - int safeY = Math.max(surfaceY, level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ) + 1); - player.teleportTo(level, blockX + 0.5D, safeY, blockZ + 0.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); + CompletableFuture teleport = ModdedDimensionManager.teleportAsync( + player, + server, + level, + blockX + 0.5D, + surfaceY, + blockZ + 0.5D, + System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)); + UUID playerId = player.getUUID(); + teleport.whenComplete((success, failure) -> { + if (failure != null) { + ModdedIrisLog.error("Iris Vision teleport failed for {} at {},{}", + playerId, blockX, blockZ, failure); + } else if (!Boolean.TRUE.equals(success)) { + ModdedIrisLog.warn("Iris Vision teleport did not complete for {} at {},{}", + playerId, blockX, blockZ); + } + }); }); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionManagerTeleportTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionManagerTeleportTest.java new file mode 100644 index 000000000..f63f9d5e9 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionManagerTeleportTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class ModdedDimensionManagerTeleportTest { + @Test + public void blockCoordinatesUseFloorAcrossZeroAndChunkBoundaries() { + assertEquals(0, ModdedTeleportBounds.blockCoordinate(0.99D)); + assertEquals(-1, ModdedTeleportBounds.blockCoordinate(-0.01D)); + assertEquals(-17, ModdedTeleportBounds.blockCoordinate(-16.01D)); + assertEquals(16, ModdedTeleportBounds.blockCoordinate(16D)); + } + + @Test + public void teleportYReservesSupportFeetAndHeadSpace() { + assertEquals(-63, ModdedTeleportBounds.clampY(-64, 320, -100)); + assertEquals(318, ModdedTeleportBounds.clampY(-64, 320, 400)); + assertEquals(72, ModdedTeleportBounds.clampY(-64, 320, 72)); + } + + @Test(expected = IllegalArgumentException.class) + public void teleportYRejectsWorldsWithoutThreeVerticalBlocks() { + ModdedTeleportBounds.clampY(0, 2, 1); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDynamicLevelLifecycleContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDynamicLevelLifecycleContractTest.java new file mode 100644 index 000000000..2ba2ce090 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDynamicLevelLifecycleContractTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class ModdedDynamicLevelLifecycleContractTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void sharedManagerPublishesDynamicLoadAndUnloadAroundRegistration() throws IOException { + String manager = commonSource("ModdedDimensionManager.java"); + String loader = commonSource("ModdedLoader.java"); + String injection = method(manager, "private static Handle inject("); + String removal = method(manager, "public static boolean remove("); + String injectionRollback = method(manager, "private static void rollbackInjection("); + String removalRollback = method(manager, "private static void rollbackRemoval("); + + assertTrue(loader.contains("void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level);")); + assertTrue(loader.contains("void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level);")); + assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);", + "fireDynamicLevelLoad(server, level);"); + assertBefore(removal, "fireDynamicLevelUnload(server, level);", + "serverAccess.removeLevel(server, key);"); + assertTrue(injectionRollback.contains("fireDynamicLevelUnload(server, level);")); + assertTrue(removalRollback.contains("fireDynamicLevelLoad(server, level);")); + } + + @Test + public void everyLoaderUsesItsNativeLifecycleBus() throws IOException { + String fabric = loaderSource("fabric", "art/arcane/iris/fabric/FabricModdedLoader.java"); + String forge = loaderSource("forge", "art/arcane/iris/forge/ForgeModdedLoader.java"); + String neoForge = loaderSource("neoforge", "art/arcane/iris/neoforge/NeoForgeModdedLoader.java"); + + assertTrue(fabric.contains("ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level);")); + assertTrue(fabric.contains("ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level);")); + assertTrue(forge.contains("LevelEvent.Load.BUS.post(new LevelEvent.Load(level));")); + assertTrue(forge.contains("LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level));")); + assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));")); + assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));")); + } + + private static String commonSource(String file) throws IOException { + return Files.readString(commonRoot().resolve("art/arcane/iris/modded").resolve(file)) + .replace("\r\n", "\n"); + } + + private static String loaderSource(String loader, String relative) throws IOException { + Path adaptersRoot = commonRoot().getParent().getParent().getParent().getParent(); + return Files.readString(adaptersRoot.resolve(loader).resolve("src/main/java").resolve(relative)) + .replace("\r\n", "\n"); + } + + private static Path commonRoot() { + String root = System.getProperty(SOURCE_ROOT_PROPERTY); + if (root == null || root.isBlank()) { + throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY); + } + return Path.of(root); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + if (start < 0) { + throw new IllegalArgumentException("Missing source method " + signature); + } + int open = source.indexOf('{', start); + int depth = 0; + for (int index = open; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}' && --depth == 0) { + return source.substring(start, index + 1); + } + } + throw new IllegalArgumentException("Unclosed source method " + signature); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source token " + first, firstIndex >= 0); + assertTrue("Missing source token " + second, secondIndex >= 0); + assertTrue(first + " must precede " + second, firstIndex < secondIndex); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java index d2fe583be..a7060b573 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java @@ -65,7 +65,7 @@ public class ModdedLifecycleFailureContractTest { String reinjection = method(source, "private static void reinjectPersistentDimensions("); String failure = catchBlock(reinjection); - assertTrue(failure.contains("LOGGER.error(")); + assertTrue(failure.contains("ModdedIrisLog.error(")); assertFalse(failure.contains("e.toString()")); assertFalse(failure.contains("throw new IllegalStateException(")); assertTrue(reinjection.contains("injected++;")); @@ -152,7 +152,7 @@ public class ModdedLifecycleFailureContractTest { assertBefore(stop, "\"world engines\"", "\"dimension manager\""); assertBefore(stop, "\"server state\"", "if (failure != null)"); assertFalse(stop.contains("throw")); - assertTrue(stop.contains("LOGGER.error(\"Iris modded shutdown completed with failures\", failure);")); + assertTrue(stop.contains("ModdedIrisLog.error(\"Iris modded shutdown completed with failures\", failure);")); String runStage = method(source, "private static Throwable runStopStage("); assertTrue(runStage.contains("catch (Throwable stageFailure)")); @@ -207,21 +207,21 @@ public class ModdedLifecycleFailureContractTest { String bootstrapSource = source("ModdedEngineBootstrap.java"); String unload = method(bootstrapSource, "public static void levelUnloaded(ServerLevel level)"); String failure = catchBlock(unload); - assertTrue(failure.contains("LOGGER.error(")); + assertTrue(failure.contains("ModdedIrisLog.error(")); assertTrue(failure.contains("throw ")); String managerSource = source("ModdedDimensionManager.java"); String remove = method(managerSource, "public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage)"); assertTrue(remove.contains("ModdedWorldEngines.evictOrThrow(level);")); assertFalse(remove.contains("ModdedWorldEngines.evict(level);")); - assertTrue(remove.contains("generatorUnbound = true;")); - assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e);")); + assertTrue(remove.contains("unloadEventStarted = true;")); + assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e);")); String rollback = method(managerSource, "private static void rollbackRemoval("); assertTrue(rollback.contains("serverAccess.hasLevel(server, key)")); assertTrue(rollback.contains("generator.bindLevel(level);")); assertTrue(rollback.contains("failure.addSuppressed(rollbackFailure);")); - assertTrue(rollback.contains("LOGGER.error(")); + assertTrue(rollback.contains("ModdedIrisLog.error(")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java index ffd0cba5f..e1adbc8dd 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java @@ -45,6 +45,14 @@ public class ModdedPlatformPathsTest { public void invalidateLevelCache(MinecraftServer server) { } + @Override + public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) { + } + + @Override + public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) { + } + @Override public boolean clientEnvironment() { return false; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java index 8e5a412b6..bd911e2a2 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -186,7 +186,7 @@ public class IrisModdedStructureCommandTest { assertTrue(command.contains("ModdedLocateCommands.registeredStructureUnavailableMessage(")); assertTrue(command.contains("engine.getData().getStructureLoader().getPossibleKeys()")); assertTrue(command.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)")); - assertTrue(command.contains("LOGGER.info(\"[Iris goto unregistered] [{}] {} - {}\"")); + assertTrue(command.contains("ModdedIrisLog.info(\"[Iris goto unregistered] [{}] {} - {}\"")); assertTrue(command.contains("UNREGISTERED(\"unregistered\")")); assertFalse(command.contains("DatapackIngestService")); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java new file mode 100644 index 000000000..cd809f183 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.modded.command; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedStudioTeleportContractTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void studioOpenSerializesReplacementAndWaitsForTheNativePath() throws IOException { + String source = source("command/ModdedStudioCommands.java"); + String open = method(source, "private static int open("); + String execute = method(source, "private static void executeStudioOpen("); + + assertTrue(open.contains("TRANSITIONS.submit(")); + assertFalse(open.contains("orTimeout(")); + assertFalse(open.contains("deadlineNanos")); + assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.create("); + assertBefore(execute, "ModdedDimensionManager.create(", "ModdedDimensionManager.teleportAsync("); + assertFalse(execute.contains("deadlineNanos")); + assertFalse(source.contains("player.teleportTo(studio")); + } + + @Test + public void studioTeleportAndVisionShareWarmFutureSemantics() throws IOException { + String studio = source("command/ModdedStudioCommands.java"); + String vision = source("command/ModdedVisionOverlay.java"); + String dimensions = source("ModdedDimensionManager.java"); + + assertTrue(studio.contains("private static CompletableFuture teleportToStudio(")); + assertTrue(studio.contains("ModdedDimensionManager.teleportAsync(")); + assertTrue(dimensions.contains("TELEPORT_WARM_RADIUS = 0")); + assertTrue(dimensions.contains("addTicketAndLoadWithRadius(\n" + + " TELEPORT_WARM_TICKET,\n" + + " chunkPos,\n" + + " TELEPORT_WARM_RADIUS)")); + assertTrue(dimensions.contains("removeTicketWithRadius(\n" + + " TELEPORT_WARM_TICKET,\n" + + " chunkPos,\n" + + " TELEPORT_WARM_RADIUS)")); + assertTrue(vision.contains("Math.floor(worldX)")); + assertTrue(vision.contains("Math.floor(worldZ)")); + assertTrue(vision.contains("if (opener != null)")); + assertTrue(vision.contains("ModdedDimensionManager.teleportAsync(")); + assertFalse(vision.contains("level.getHeight(")); + assertFalse(vision.contains("player.teleportTo(")); + } + + private static String source(String relative) throws IOException { + String root = System.getProperty(SOURCE_ROOT_PROPERTY); + if (root == null || root.isBlank()) { + throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY); + } + return Files.readString(Path.of(root).resolve("art/arcane/iris/modded").resolve(relative)) + .replace("\r\n", "\n"); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + if (start < 0) { + throw new IllegalArgumentException("Missing source method " + signature); + } + int open = source.indexOf('{', start); + int depth = 0; + for (int index = open; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}' && --depth == 0) { + return source.substring(start, index + 1); + } + } + throw new IllegalArgumentException("Unclosed source method " + signature); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source token " + first, firstIndex >= 0); + assertTrue("Missing source token " + second, secondIndex >= 0); + assertTrue(first + " must precede " + second, firstIndex < secondIndex); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueueTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueueTest.java new file mode 100644 index 000000000..d9828b446 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueueTest.java @@ -0,0 +1,70 @@ +package art.arcane.iris.modded.command; + +import org.junit.Test; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedStudioTransitionQueueTest { + @Test + public void sameOwnerTransitionsRunInSubmissionOrder() { + ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue(); + UUID owner = UUID.randomUUID(); + CompletableFuture firstGate = new CompletableFuture<>(); + AtomicInteger starts = new AtomicInteger(); + + CompletableFuture first = queue.submit(owner, () -> { + starts.incrementAndGet(); + return firstGate; + }); + CompletableFuture second = queue.submit(owner, () -> { + starts.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + + assertEquals(1, starts.get()); + assertFalse(second.isDone()); + firstGate.complete(null); + assertTrue(first.isDone()); + assertTrue(second.isDone()); + assertEquals(2, starts.get()); + } + + @Test + public void differentOwnersDoNotBlockEachOther() { + ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue(); + CompletableFuture firstGate = new CompletableFuture<>(); + + CompletableFuture first = queue.submit(UUID.randomUUID(), () -> firstGate); + CompletableFuture second = queue.submit( + UUID.randomUUID(), + () -> CompletableFuture.completedFuture(null)); + + assertFalse(first.isDone()); + assertTrue(second.isDone()); + } + + @Test + public void failedTransitionDoesNotPoisonTheOwnerQueue() { + ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue(); + UUID owner = UUID.randomUUID(); + CompletableFuture failure = CompletableFuture.failedFuture( + new IllegalStateException("expected")); + AtomicInteger starts = new AtomicInteger(); + + CompletableFuture first = queue.submit(owner, () -> failure); + CompletableFuture second = queue.submit(owner, () -> { + starts.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + + assertTrue(first.isCompletedExceptionally()); + assertTrue(second.isDone()); + assertEquals(1, starts.get()); + } +} diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java index 7bf882e72..bd17eea51 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java @@ -27,6 +27,7 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.block.state.BlockState; import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.LevelEvent; import net.neoforged.neoforge.event.level.block.BreakBlockEvent; import net.neoforged.fml.ModList; import net.neoforged.fml.loading.FMLEnvironment; @@ -77,6 +78,16 @@ public final class NeoForgeModdedLoader implements ModdedLoader { server.markWorldsDirty(); } + @Override + public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) { + NeoForge.EVENT_BUS.post(new LevelEvent.Load(level)); + } + + @Override + public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) { + NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); + } + @Override public boolean clientEnvironment() { return FMLEnvironment.getDist().isClient(); diff --git a/build.gradle b/build.gradle index ba4835b98..3ae113527 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,8 @@ import org.gradle.api.tasks.Copy import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.tasks.Jar import org.gradle.jvm.toolchain.JavaLanguageVersion +import groovy.json.JsonOutput +import groovy.json.JsonSlurper /* * Iris is a World Generator for Minecraft Bukkit Servers @@ -73,6 +75,7 @@ String bukkitArtifactName = irisArtifactName('CraftBukkit', bukkitMinecraftRange String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}") String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}") String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}") +long maximumBukkitArtifactBytes = 7_000_000L apply plugin: ApiGenerator // Where `buildAll` drops the per-platform jars for a local test server. Use the approved sibling @@ -145,6 +148,16 @@ nmsBindings.each { key, value -> def included = configurations.create('included') def jarJar = configurations.create('jarJar') +def bukkitLanguagesDirectory = layout.buildDirectory.dir('generated/bukkit-languages') +Set sharedNonEnglishBukkitMessages = [ + 'iris.desktop.vision.fps', + 'iris.desktop.vision.tiles', + 'iris.bukkit.runtime.commandpack.message_2', + 'iris.bukkit.commanddatapack.message_2', + 'iris.runtime.chunk_job.bossbar.progress', + 'iris.runtime.studio.action.progress', + 'iris.runtime.chunk_job.action.progress' +] as Set dependencies { nmsBindings.keySet().each { key -> add('included', project(path: ":adapters:bukkit:nms:${key}", configuration: 'runtimeElements')) @@ -154,14 +167,66 @@ dependencies { add('jarJar', project(':core:agent')) } +def prepareBukkitLanguages = tasks.register('prepareBukkitLanguages') { + inputs.files(fileTree(project(':core').file('src/main/resources/languages')) { + include('*.json') + }) + outputs.dir(bukkitLanguagesDirectory) + doLast { + File outputDirectory = bukkitLanguagesDirectory.get().asFile + delete(outputDirectory) + outputDirectory.mkdirs() + List sources = fileTree(project(':core').file('src/main/resources/languages')) { + include('*.json') + }.files.sort { File left, File right -> left.name <=> right.name } + List> catalogs = sources.collect { File source -> + (Map) new JsonSlurper().parse(source) + } + Map referenceMessages = (Map) catalogs.first().get('messages') + List messageIds = referenceMessages.keySet() + .findAll { String key -> !key.startsWith('iris.modded.') } + .sort() + Set sharedFallbackIds = new LinkedHashSet<>(messageIds) + catalogs.each { Map catalog -> + Map messages = (Map) catalog.get('messages') + if (!messages.keySet().containsAll(messageIds)) { + throw new GradleException("Bundled locale ${catalog.get('locale')} is missing Bukkit messages") + } + sharedFallbackIds.removeIf { String key -> messages.get(key) != referenceMessages.get(key) } + } + sharedFallbackIds.removeAll(sharedNonEnglishBukkitMessages) + + sources.eachWithIndex { File source, int index -> + Map catalog = catalogs.get(index) + Map messages = (Map) catalog.get('messages') + List compactMessages = messageIds.collect { String key -> + sharedFallbackIds.contains(key) ? null : messages.get(key) + } + new File(outputDirectory, source.name).setText( + JsonOutput.toJson([catalog.get('locale'), compactMessages]), + 'UTF-8') + } + } +} + tasks.named('jar', Jar).configure { inputs.files(included) duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(jarJar, provider { included.resolve().collect { zipTree(it) } }) + includeEmptyDirs = false + from(jarJar) + from(provider { included.resolve().collect { zipTree(it) } }) { + exclude('languages/**') + } + from(prepareBukkitLanguages) { + into('languages') + } doFirst { delete(layout.buildDirectory.file("libs/Iris-${project.version}.jar")) } archiveFileName.set(bukkitArtifactName) + doLast { + JarCompactor.compact(archiveFile.get().asFile) + } } tasks.register('iris', Copy) { @@ -537,10 +602,8 @@ List requiredBukkitArtifactEntries = [ 'art/arcane/iris/core/lifecycle/WorldLifecycleStaging.class', 'art/arcane/iris/util/simd/VectorSimdKernels.class', 'art/arcane/iris/util/simd/VectorNoiseKernels2D.class', - 'art/arcane/iris/util/paralithic/functions/Function.class', 'art/arcane/iris/util/project/agent/Agent.class', 'art/arcane/iris/util/common/misc/getHardware.class', - 'art/arcane/iris/util/caffeine/cache/Caffeine.class', 'art/arcane/volmlib/util/io/JarScanner.class', 'art/arcane/volmlib/util/director/runtime/DirectorRuntimeEngine.class', 'art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class', @@ -553,7 +616,12 @@ tasks.register('verifyBukkitArtifact') { File artifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile inputs.file(artifact) doLast { - BukkitArtifactVerifier.verify(artifact, requiredBukkitArtifactEntries, 17, 400, 16) + BukkitArtifactVerifier.verify( + artifact, + requiredBukkitArtifactEntries, + 17, + 16, + maximumBukkitArtifactBytes) logger.lifecycle("Verified ${artifact.name} packaging and class reference graph") } } diff --git a/buildSrc/src/main/java/BukkitArtifactVerifier.java b/buildSrc/src/main/java/BukkitArtifactVerifier.java index 635859b90..b86173d8c 100644 --- a/buildSrc/src/main/java/BukkitArtifactVerifier.java +++ b/buildSrc/src/main/java/BukkitArtifactVerifier.java @@ -21,9 +21,7 @@ public final class BukkitArtifactVerifier { // class under one of these is a NoClassDefFoundError waiting for the right code path. private static final List SHIPPED_PREFIXES = List.of( "art/arcane/iris/", - "art/arcane/volmlib/", - "com/google/gson/", - "com/googlecode/concurrentlinkedhashmap/" + "art/arcane/volmlib/" ); // Relocation targets for the libraries slimjar downloads and relocates at runtime. Compiled // references to them are correct and the classes are correctly absent from the jar. @@ -38,9 +36,12 @@ public final class BukkitArtifactVerifier { "art/arcane/iris/util/aether/", "art/arcane/iris/util/guice/", "art/arcane/iris/util/dom4j/", - "art/arcane/iris/util/jaxen/" + "art/arcane/iris/util/jaxen/", + "art/arcane/iris/util/gson/", + "art/arcane/iris/util/lru/", + "art/arcane/iris/util/caffeine/", + "art/arcane/iris/util/paralithic/" ); - private static final String CAFFEINE_CACHE_PACKAGE = "art/arcane/iris/util/caffeine/cache/"; private static final String MATTER_SLICE_PACKAGE = "art/arcane/volmlib/util/matter/slices/"; private static final String LANGUAGE_DIRECTORY = "languages/"; @@ -48,10 +49,14 @@ public final class BukkitArtifactVerifier { } public static void verify(File artifact, List requiredEntries, int minimumLocales, - int minimumCaffeineFactories, int minimumMatterSlices) { + int minimumMatterSlices, long maximumArtifactBytes) { if (!artifact.isFile()) { throw new GradleException("Missing Bukkit Iris artifact: " + artifact.getAbsolutePath()); } + if (artifact.length() > maximumArtifactBytes) { + throw new GradleException(artifact.getName() + " is " + artifact.length() + + " bytes; Bukkit artifacts must not exceed " + maximumArtifactBytes + " bytes"); + } try (JarFile jar = new JarFile(artifact)) { for (String requiredEntry : requiredEntries) { @@ -65,7 +70,6 @@ public final class BukkitArtifactVerifier { Set shippedClasses = new LinkedHashSet<>(); int locales = 0; - int caffeineFactories = 0; int matterSlices = 0; Enumeration entries = jar.entries(); while (entries.hasMoreElements()) { @@ -84,9 +88,6 @@ public final class BukkitArtifactVerifier { String internalName = name.substring(0, name.length() - ".class".length()); shippedClasses.add(internalName); - if (isGeneratedCaffeineFactory(internalName)) { - caffeineFactories++; - } if (internalName.startsWith(MATTER_SLICE_PACKAGE)) { matterSlices++; } @@ -96,14 +97,6 @@ public final class BukkitArtifactVerifier { throw new GradleException(artifact.getName() + " ships " + locales + " locale files; expected at least " + minimumLocales); } - // Caffeine picks its cache and node implementation with MethodHandles.Lookup.findClass on a - // name built from the builder's feature flags. Nothing references these statically, so only a - // population check can tell that an exclude or minimize() ate them. - if (caffeineFactories < minimumCaffeineFactories) { - throw new GradleException(artifact.getName() + " ships " + caffeineFactories - + " generated Caffeine cache classes; expected at least " + minimumCaffeineFactories - + ". Caffeine resolves these by name and cannot survive static pruning"); - } // Matter.read() resolves slice types from the canonical name stored in the payload. if (matterSlices < minimumMatterSlices) { throw new GradleException(artifact.getName() + " ships " + matterSlices @@ -148,23 +141,6 @@ public final class BukkitArtifactVerifier { } } - private static boolean isGeneratedCaffeineFactory(String internalName) { - if (!internalName.startsWith(CAFFEINE_CACHE_PACKAGE)) { - return false; - } - - String simpleName = internalName.substring(CAFFEINE_CACHE_PACKAGE.length()); - if (simpleName.isEmpty() || simpleName.indexOf('/') >= 0) { - return false; - } - for (int i = 0; i < simpleName.length(); i++) { - if (simpleName.charAt(i) < 'A' || simpleName.charAt(i) > 'Z') { - return false; - } - } - return true; - } - private static byte[] readEntryBytes(JarFile jar, JarEntry entry) throws IOException { try (InputStream input = jar.getInputStream(entry)) { return input.readAllBytes(); diff --git a/buildSrc/src/main/java/JarCompactor.java b/buildSrc/src/main/java/JarCompactor.java new file mode 100644 index 000000000..5ee5388b1 --- /dev/null +++ b/buildSrc/src/main/java/JarCompactor.java @@ -0,0 +1,97 @@ +import org.gradle.api.GradleException; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +public final class JarCompactor { + private static final int BUFFER_BYTES = 64 * 1024; + + private JarCompactor() { + } + + public static void compact(File artifact) { + if (artifact == null || !artifact.isFile()) { + throw new GradleException("Cannot compact missing jar artifact: " + artifact); + } + + Path source = artifact.toPath(); + Path temporary = null; + try { + temporary = Files.createTempFile(source.getParent(), artifact.getName(), ".compact"); + rewrite(source, temporary); + replace(temporary, source); + } catch (IOException exception) { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + exception.addSuppressed(cleanupFailure); + } + } + throw new GradleException("Unable to compact jar artifact " + artifact.getAbsolutePath(), exception); + } + } + + private static void rewrite(Path source, Path destination) throws IOException { + byte[] buffer = new byte[BUFFER_BYTES]; + try (InputStream rawInput = new BufferedInputStream(Files.newInputStream(source)); + ZipInputStream input = new ZipInputStream(rawInput); + OutputStream rawOutput = new BufferedOutputStream(Files.newOutputStream(destination)); + ZipOutputStream output = new ZipOutputStream(rawOutput)) { + output.setLevel(9); + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + ZipEntry compacted = copyMetadata(entry); + output.putNextEntry(compacted); + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + output.write(buffer, 0, read); + } + } + output.closeEntry(); + } + } + } + + private static ZipEntry copyMetadata(ZipEntry source) { + ZipEntry target = new ZipEntry(source.getName()); + target.setMethod(ZipEntry.DEFLATED); + if (source.getTime() >= 0L) { + target.setTime(source.getTime()); + } + if (source.getComment() != null) { + target.setComment(source.getComment()); + } + if (source.getExtra() != null) { + target.setExtra(source.getExtra()); + } + return target; + } + + private static void replace(Path temporary, Path source) throws IOException { + try { + Files.move( + temporary, + source, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, source, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/buildSrc/src/test/java/BukkitArtifactVerifierTest.java b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java index bb25a5e39..ad0715f40 100644 --- a/buildSrc/src/test/java/BukkitArtifactVerifierTest.java +++ b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java @@ -30,7 +30,6 @@ public class BukkitArtifactVerifierTest { NMS_BINDING + ".class" ); private static final int LOCALES = 2; - private static final int CAFFEINE_FACTORIES = 2; private static final int MATTER_SLICES = 1; @Rule @@ -40,7 +39,7 @@ public class BukkitArtifactVerifierTest { public void acceptsCompleteArtifact() throws Exception { File artifact = createArtifact(validEntries()); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); } @Test @@ -50,8 +49,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(PLUGIN_DESCRIPTOR)); } @@ -62,8 +60,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(SLIMJAR_DEPENDENCIES)); } @@ -74,8 +71,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(SLIMJAR_RESOLUTIONS)); } @@ -86,8 +82,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("art/arcane/volmlib/util/noise/CNG")); } @@ -96,21 +91,31 @@ public class BukkitArtifactVerifierTest { Map entries = validEntries(); entries.put("art/arcane/iris/Consumer.class", classReferencing("art/arcane/iris/Consumer", "art/arcane/iris/util/kyori/adventure/text/Component")); + entries.put("art/arcane/iris/GsonConsumer.class", + classReferencing("art/arcane/iris/GsonConsumer", "art/arcane/iris/util/gson/Gson")); + entries.put("art/arcane/iris/LruConsumer.class", + classReferencing("art/arcane/iris/LruConsumer", "art/arcane/iris/util/lru/ConcurrentLinkedHashMap")); + entries.put("art/arcane/iris/CaffeineConsumer.class", + classReferencing("art/arcane/iris/CaffeineConsumer", "art/arcane/iris/util/caffeine/cache/Caffeine")); + entries.put("art/arcane/iris/ParalithicConsumer.class", + classReferencing("art/arcane/iris/ParalithicConsumer", "art/arcane/iris/util/paralithic/functions/Function")); File artifact = createArtifact(entries); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); } @Test - public void rejectsStrippedCaffeineFactories() throws Exception { - Map entries = validEntries(); - entries.remove("art/arcane/iris/util/caffeine/cache/SSMS.class"); - File artifact = createArtifact(entries); + public void rejectsArtifactAboveConfiguredSize() throws Exception { + File artifact = createArtifact(validEntries()); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); - assertTrue(failure.getMessage().contains("generated Caffeine cache classes")); + () -> BukkitArtifactVerifier.verify( + artifact, + REQUIRED_ENTRIES, + LOCALES, + MATTER_SLICES, + artifact.length() - 1L)); + assertTrue(failure.getMessage().contains("must not exceed")); } @Test @@ -120,8 +125,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("locale files")); } @@ -132,8 +136,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, - MATTER_SLICES)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("Matter slice types")); } @@ -144,7 +147,7 @@ public class BukkitArtifactVerifierTest { classAnnotatedWith("art/arcane/iris/Annotated", "com/google/errorprone/annotations/CanIgnoreReturnValue")); File artifact = createArtifact(entries); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); } private Map validEntries() { @@ -159,10 +162,6 @@ public class BukkitArtifactVerifierTest { entries.put("art/arcane/volmlib/util/noise/CNG.class", emptyClass("art/arcane/volmlib/util/noise/CNG")); entries.put("art/arcane/volmlib/util/matter/slices/BlockMatter.class", emptyClass("art/arcane/volmlib/util/matter/slices/BlockMatter")); - entries.put("art/arcane/iris/util/caffeine/cache/SSMS.class", - emptyClass("art/arcane/iris/util/caffeine/cache/SSMS")); - entries.put("art/arcane/iris/util/caffeine/cache/SSLMS.class", - emptyClass("art/arcane/iris/util/caffeine/cache/SSLMS")); return entries; } diff --git a/buildSrc/src/test/java/JarCompactorTest.java b/buildSrc/src/test/java/JarCompactorTest.java new file mode 100644 index 000000000..ffd5c2704 --- /dev/null +++ b/buildSrc/src/test/java/JarCompactorTest.java @@ -0,0 +1,39 @@ +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNull; + +public class JarCompactorTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preservesFilesAndOmitsDirectoryEntries() throws Exception { + File artifact = temporaryFolder.newFile("artifact.jar"); + byte[] content = "Iris artifact content".getBytes(StandardCharsets.UTF_8); + try (JarOutputStream output = new JarOutputStream(new FileOutputStream(artifact))) { + output.putNextEntry(new JarEntry("example/")); + output.closeEntry(); + output.putNextEntry(new JarEntry("example/value.txt")); + output.write(content); + output.closeEntry(); + } + + JarCompactor.compact(artifact); + + try (JarFile jar = new JarFile(artifact)) { + assertNull(jar.getJarEntry("example/")); + assertArrayEquals(content, jar.getInputStream( + jar.getJarEntry("example/value.txt")).readAllBytes()); + } + } +} diff --git a/core/build.gradle b/core/build.gradle index a234f4775..d83c5df85 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -86,12 +86,11 @@ dependencies { implementation(volmLibCoordinate) { transitive = false } - implementation(libs.gson) - implementation(libs.lru) - implementation(libs.caffeine) - implementation(libs.paralithic) - // Dynamically Loaded + slim(libs.gson) + slim(libs.lru) + slim(libs.caffeine) + slim(libs.paralithic) slim(libs.paperlib) slim(libs.adventure.api) slim(libs.adventure.minimessage) @@ -154,6 +153,8 @@ slimJar { ] relocate('com.dfsek.paralithic', "${lib}.paralithic") + relocate('com.google.gson', "${lib}.gson") + relocate('com.googlecode.concurrentlinkedhashmap', "${lib}.lru") relocate('io.papermc.lib', "${lib}.paper") relocate('net.kyori', "${lib}.kyori") relocate('org.bstats', "${lib}.metrics") @@ -285,9 +286,149 @@ List supersededVolmLibPackages = [ 'art/arcane/volmlib/util/director/visual/**', 'art/arcane/volmlib/util/value/**', 'art/arcane/volmlib/util/api/**', - 'art/arcane/volmlib/util/entity/**' + 'art/arcane/volmlib/util/entity/**', + 'art/arcane/volmlib/util/config/**', + 'art/arcane/volmlib/util/reflect/**', + 'art/arcane/volmlib/util/documentation/**', + 'art/arcane/iris/core/report/**', + 'art/arcane/iris/util/common/inventorygui/**', + 'art/arcane/iris/util/common/board/**', + 'art/arcane/volmlib/integration/VaultEconomy*.class' ] +List unusedVolmLibClasses = [ + 'art/arcane/volmlib/util/nbt/io/ParseException', + 'art/arcane/volmlib/util/nbt/io/SNBTDeserializer', + 'art/arcane/volmlib/util/nbt/io/SNBTParser', + 'art/arcane/volmlib/util/nbt/io/SNBTSerializer', + 'art/arcane/volmlib/util/nbt/io/SNBTUtil', + 'art/arcane/volmlib/util/nbt/io/SNBTWriter', + 'art/arcane/volmlib/util/nbt/io/StringPointer', + 'art/arcane/volmlib/util/io/StringDeserializer', + 'art/arcane/volmlib/util/io/StringSerializer', + 'art/arcane/volmlib/util/json/EnumType', + 'art/arcane/volmlib/util/json/HTTP', + 'art/arcane/volmlib/util/json/HTTPTokener', + 'art/arcane/volmlib/util/json/JSONML', + 'art/arcane/volmlib/util/json/JSONStringer', + 'art/arcane/volmlib/util/json/JSONWriter', + 'art/arcane/volmlib/util/json/XML', + 'art/arcane/volmlib/util/json/XMLTokener', + 'art/arcane/volmlib/util/hud/HudBid', + 'art/arcane/volmlib/util/hud/HudBidder', + 'art/arcane/volmlib/util/hud/HudLocalLedger', + 'art/arcane/volmlib/util/hud/HudTitleClaim', + 'art/arcane/volmlib/util/hud/HudTitleService', + 'art/arcane/volmlib/util/scheduling/GroupedExecutor', + 'art/arcane/volmlib/util/scheduling/SchedulerRuntime', + 'art/arcane/volmlib/util/scheduling/SchedulerUtils', + 'art/arcane/volmlib/util/scheduling/TaskExecutor', + 'art/arcane/volmlib/util/parallel/StreamUtilsSupport', + 'art/arcane/volmlib/util/parallel/SyncExecutorSupport', + 'art/arcane/volmlib/util/cache/ByteBitCache', + 'art/arcane/volmlib/util/cache/DataBitCache', + 'art/arcane/volmlib/util/cache/FloatBitCache', + 'art/arcane/volmlib/util/cache/IntBitCache', + 'art/arcane/volmlib/util/cache/ShortBitCache', + 'art/arcane/volmlib/util/cache/UByteBitCache', + 'art/arcane/volmlib/util/data/ChunkCache', + 'art/arcane/volmlib/util/data/ComplexCache', + 'art/arcane/volmlib/util/data/IrisBiomeStorage', + 'art/arcane/volmlib/util/data/NibbleArray', + 'art/arcane/volmlib/util/data/NibbleDataPalette', + 'art/arcane/volmlib/util/data/Recycler', + 'art/arcane/volmlib/util/data/VanillaBiomeMap', + 'art/arcane/volmlib/util/data/Writable', + 'art/arcane/volmlib/util/data/base/ComplexCacheBase', + 'art/arcane/volmlib/util/director/DirectorNodeBase', + 'art/arcane/volmlib/util/director/DirectorParameterBase', + 'art/arcane/volmlib/util/director/theme/DirectorThemes', + 'art/arcane/volmlib/util/interpolation/InterpolationMethod', + 'art/arcane/volmlib/util/interpolation/IrisInterpolation', + 'art/arcane/volmlib/util/math/INode', + 'art/arcane/volmlib/util/math/IrisMathHelper', + 'art/arcane/volmlib/util/math/KochanekBartelsInterpolation', + 'art/arcane/volmlib/util/math/PathInterpolation', + 'art/arcane/volmlib/util/math/Spiral', + 'art/arcane/volmlib/util/board/BoardManager', + 'art/arcane/volmlib/util/board/BoardUpdateTask', + 'art/arcane/volmlib/util/bukkit/Placeholders', + 'art/arcane/volmlib/util/hunk/HunkFactory', + 'art/arcane/volmlib/util/hunk/storage/ArrayHunk', + 'art/arcane/volmlib/util/hunk/storage/AtomicDoubleHunk', + 'art/arcane/volmlib/util/hunk/storage/AtomicIntegerHunk', + 'art/arcane/volmlib/util/hunk/storage/AtomicLongHunk', + 'art/arcane/volmlib/util/hunk/storage/SynchronizedArrayHunk', + 'art/arcane/volmlib/util/hunk/view/BiomeForceBridge', + 'art/arcane/volmlib/util/hunk/view/BiomeGridForceSupport', + 'art/arcane/volmlib/util/hunk/view/BiomeGridHunkHolder', + 'art/arcane/volmlib/util/hunk/view/BiomeGridHunkView', + 'art/arcane/volmlib/util/hunk/view/ChunkDataHunkHolder', + 'art/arcane/volmlib/util/hunk/view/RotatedXHunkView', + 'art/arcane/volmlib/util/hunk/view/RotatedYHunkView', + 'art/arcane/volmlib/util/hunk/view/RotatedZHunkView', + 'art/arcane/volmlib/util/exceptions/MissingDimensionException', + 'art/arcane/volmlib/util/function/Supplier2', + 'art/arcane/volmlib/util/function/Supplier3', + 'art/arcane/volmlib/util/hud/HudPriority', + 'art/arcane/volmlib/util/interpolation/InterpolationMethod3D', + 'art/arcane/volmlib/util/interpolation/InterpolationType', + 'art/arcane/volmlib/util/inventorygui/UIRainbowDecorator', + 'art/arcane/volmlib/util/io/Converter', + 'art/arcane/volmlib/util/matter/MatterPlacer', + 'art/arcane/volmlib/util/plugin/SplashScreenSupport', + 'art/arcane/volmlib/util/scheduling/Contained', + 'art/arcane/volmlib/util/scheduling/IrisLock', + 'art/arcane/volmlib/util/scheduling/QueueExecutor', + 'art/arcane/volmlib/util/scheduling/S', + 'art/arcane/volmlib/util/scheduling/SBase', + 'art/arcane/volmlib/util/scheduling/SlidingWindowRateLimiter', + 'art/arcane/volmlib/util/scheduling/Switch', + 'art/arcane/volmlib/util/scheduling/ThreadMonitor', + 'art/arcane/volmlib/util/cache/ByteCache', + 'art/arcane/volmlib/util/cache/ChunkCache2DSimple', + 'art/arcane/volmlib/util/cache/IntCache', + 'art/arcane/volmlib/util/cache/ShortCache', + 'art/arcane/volmlib/util/cache/UByteCache', + 'art/arcane/volmlib/util/cache/WorldCache2DSimple', + 'art/arcane/volmlib/util/data/CuboidException', + 'art/arcane/volmlib/util/data/DUTF', + 'art/arcane/volmlib/util/data/Heafty', + 'art/arcane/volmlib/util/data/InvertedBiomeGrid', + 'art/arcane/volmlib/util/data/Shrinkwrap', + 'art/arcane/volmlib/util/data/WeightMap', + 'art/arcane/volmlib/util/data/WeightedRandom', + 'art/arcane/volmlib/util/director/compat/BukkitDirectorContext', + 'art/arcane/volmlib/util/director/handlers/base/DummyHandlerBase', + 'art/arcane/volmlib/util/director/handlers/base/OptionalWorldHandlerBase', + 'art/arcane/volmlib/util/parallel/BurstedHunk', + 'art/arcane/volmlib/util/parallel/GridLockSupport', + 'art/arcane/volmlib/util/parallel/NoopGridLockSupport', + 'art/arcane/volmlib/util/bukkit/ChunkPositionSet', + 'art/arcane/volmlib/util/bukkit/Events', + 'art/arcane/volmlib/util/json/SingleCollectionTypeFactory', + 'art/arcane/volmlib/util/collection/StateList', + 'art/arcane/iris/core/pack/IrisPack', + 'art/arcane/iris/core/pack/IrisPackRepository', + 'art/arcane/iris/core/jobs/DownloadJob', + 'art/arcane/iris/core/jobs/JobCollection', + 'art/arcane/iris/core/jobs/ParallelQueueJob', + 'art/arcane/iris/core/jobs/QueueJob', + 'art/arcane/iris/engine/framework/placer/WorldObjectPlacer', + 'art/arcane/iris/engine/object/IPostBlockAccess', + 'art/arcane/iris/engine/object/IrisPotionEffect', + 'art/arcane/iris/engine/platform/DummyChunkGenerator', + 'art/arcane/iris/util/common/math/VectorMath', + 'art/arcane/iris/util/common/parallel/GridLock', + 'art/arcane/iris/util/common/reflect/W', + 'art/arcane/iris/util/slimjar/BuildConstants', + 'art/arcane/iris/util/slimjar/relocation/meta/AttributeMetaMediator', + 'art/arcane/iris/util/slimjar/relocation/meta/AttributeMetaMediatorFactory' +] +List unusedVolmLibClassEntries = unusedVolmLibClasses.collectMany { String classPath -> + [classPath + '.class', classPath + '$*.class'] +} + // Annotation-only artifacts pulled in transitively by Gson and Caffeine. Their types appear solely // in annotation attributes, which the JVM skips silently when the type is absent, so nothing loads // or links against them at runtime. @@ -315,6 +456,7 @@ tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.Shadow relocate('io.github.slimjar', "${lib}.slimjar") exclude('modules/loader-agent.isolated-jar') exclude(supersededVolmLibPackages) + exclude(unusedVolmLibClassEntries) exclude(annotationOnlyArtifacts) exclude(dependencyBuildMetadata) from(embeddedAgentJar.map { it.archiveFile }) { 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 aad8c12ec..275fa7338 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -39,7 +39,6 @@ import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; -import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; @@ -90,6 +89,8 @@ public class ServerConfigurator { private static final Object DATAPACK_INSTALL_LOCK = new Object(); private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint"; + static final String POST_COMPILE_RESTART_WARNING = "Iris installed updated datapack registry entries; " + + "restart the server before creating worlds or opening Studios."; private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024; private static volatile boolean loadedDatapackRuntimeReady; private static volatile String loadedDatapackCompilerInputFingerprint = ""; @@ -419,13 +420,18 @@ public class ServerConfigurator { && !reusableRuntimeFingerprint( loadedDatapackCompilerInputFingerprint, current); + boolean loadedRegistryRestartRequired = fullInstall + && loadedCompilerInputsChanged + && currentRegistryRequiresRestart(); if (!current.isEmpty() && current.equals(cached)) { IrisLogging.debug("Data packs unchanged, skipping install."); - DatapackInstallResult result = fullInstall && loadedCompilerInputsChanged + DatapackInstallResult result = loadedRegistryRestartRequired ? DatapackInstallResult.restartRequiredResult() : resultForUnchangedFingerprint(fullInstall, reapply); if (result.restartRequired()) { requireDatapackRestart(); + } else if (result.succeeded()) { + loadedDatapackCompilerInputFingerprint = current; } reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); return result; @@ -435,7 +441,7 @@ public class ServerConfigurator { resolveDataFixer(), fullInstall, reapply); - if (fullInstall && loadedCompilerInputsChanged && result.succeeded()) { + if (loadedRegistryRestartRequired && result.succeeded()) { result = DatapackInstallResult.restartRequiredResult(); } if (result.restartRequired()) { @@ -444,6 +450,7 @@ public class ServerConfigurator { reportTiming(timingConsumer, "datapack_compile_publish", compileStart); if (result.succeeded() && !result.restartRequired()) { writeCompilerInputFingerprintCache(cacheFile.toPath(), current); + loadedDatapackCompilerInputFingerprint = current; } reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); return result; @@ -456,6 +463,22 @@ public class ServerConfigurator { IrisWorldStorage.levelRoot().toPath()); } + private static boolean currentRegistryRequiresRestart() { + try { + Map currentRequirements = IrisDatapackCompiler.computeRegistryRequirements( + collectCompilerPackRoots(), + resolveDataFixer()); + return runtimeRequiresRegistryRestart( + loadedDatapackRegistryRequirements, + currentRequirements); + } catch (IOException | RuntimeException exception) { + IrisLogging.reportError( + "Unable to compare loaded Iris datapack registry requirements.", + exception); + return true; + } + } + private static List collectConfiguredLevelStemBindings() throws IOException { File levelRoot = IrisWorldStorage.levelRoot(); String levelId = levelRoot.getName(); @@ -545,6 +568,19 @@ public class ServerConfigurator { return true; } + static boolean runtimeRequiresRegistryRestart( + Map loadedRequirements, + Map currentRequirements + ) { + if (currentRequirements == null) { + return true; + } + if (currentRequirements.isEmpty()) { + return false; + } + return !loadedRegistrySatisfies(loadedRequirements, currentRequirements); + } + static boolean reusableRuntimeFingerprint(String loadedFingerprint, String currentFingerprint) { return loadedFingerprint != null && !loadedFingerprint.isBlank() @@ -961,31 +997,29 @@ public class ServerConfigurator { private static boolean verifyDataPacksPost() { try (Stream stream = allPacks()) { - boolean bad = stream - .map(data -> { - IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath()); - ResourceLoader loader = data.getDimensionLoader(); - return loader.loadAll(loader.getPossibleKeys()) - .stream() - .filter(Objects::nonNull) - .map(ServerConfigurator::verifyDataPackInstalled) - .toList() - .contains(false); - }) - .toList() - .contains(true); - if (!bad) { - return false; - } + return verifyDataPacksPost(stream); } + } - + static boolean verifyDataPacksPost(Stream packs) { + boolean bad = Objects.requireNonNull(packs, "Iris packs") + .map(data -> { + IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath()); + ResourceLoader loader = data.getDimensionLoader(); + return loader.loadAll(loader.getPossibleKeys()) + .stream() + .filter(Objects::nonNull) + .map(dimension -> verifyDataPackInstalled(dimension, false)) + .toList() + .contains(false); + }) + .toList() + .contains(true); + if (!bad) { + return false; + } if (INMS.get().supportsDataPacks()) { - // Three sentences, no rules: a separator carries the record's severity too, so a box drawn - // out of equals signs became three more [SEVERE] lines saying nothing. - IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes."); - IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes."); - IrisLogging.error(C.UNDERLINE + "Restart the server before generating."); + IrisLogging.warn(POST_COMPILE_RESTART_WARNING); for (Player i : Bukkit.getOnlinePlayers()) { if (i.isOp() || i.hasPermission("iris.all")) { @@ -1054,6 +1088,10 @@ public class ServerConfigurator { } public static boolean verifyDataPackInstalled(IrisDimension dimension) { + return verifyDataPackInstalled(dimension, true); + } + + private static boolean verifyDataPackInstalled(IrisDimension dimension, boolean reportRuntimeFailure) { KSet keys = new KSet<>(); boolean warn = false; @@ -1082,17 +1120,21 @@ public class ServerConfigurator { Object o = INMS.get().getCustomBiomeBaseFor(i); if (o == null) { - IrisLogging.warn("The Biome " + i + " is not registered on the server."); + if (reportRuntimeFailure) { + IrisLogging.warn("The Biome " + i + " is not registered on the server."); + } warn = true; } } if (INMS.get().missingDimensionTypes(dimension.getDimensionTypeKey())) { - IrisLogging.warn("The Dimension Type for " + dimension.getLoadFile() + " is not registered on the server."); + if (reportRuntimeFailure) { + IrisLogging.warn("The Dimension Type for " + dimension.getLoadFile() + " is not registered on the server."); + } warn = true; } - if (warn) { + if (warn && reportRuntimeFailure) { IrisLogging.error("The Pack " + key + " is INCAPABLE of generating custom biomes"); IrisLogging.error("If not done automatically, restart your server before generating with this pack!"); } diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java index 2e5af710e..8ad6e8d62 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -31,6 +31,7 @@ import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.Locale; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -63,7 +64,7 @@ public final class GuiHost { default void unregisterHotloadHook(Runnable onHotload) { } - default GuiOverlay overlayFor(Engine engine) { + default GuiOverlay overlayFor(Engine engine, UUID openerId) { return null; } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 644fd06f7..352a1462d 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -73,6 +73,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; public final class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener { private static final long serialVersionUID = 2094606939770332040L; @@ -107,6 +108,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D; private final JFrame hostFrame; + private final UUID openerId; private final VisionRenderController controller; private final Runnable hotloadHook; private final Timer resizeTimer; @@ -147,11 +149,12 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi private boolean controlsUpdating; private boolean closed; - private VisionGUI(JFrame hostFrame, Engine engine) { + private VisionGUI(JFrame hostFrame, Engine engine, UUID openerId) { this.hostFrame = Objects.requireNonNull(hostFrame, "hostFrame"); this.engine = Objects.requireNonNull(engine, "engine"); + this.openerId = openerId; this.renderer = new IrisRenderer(engine); - this.overlay = GuiHost.get().overlayFor(engine); + this.overlay = GuiHost.get().overlayFor(engine, openerId); this.controller = new VisionRenderController(this::repaint); this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent); this.notifications = new LinkedHashMap<>(); @@ -201,14 +204,14 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi notificationTimer.start(); } - public static void launch(Engine engine) { - EventQueue.invokeLater(() -> createAndShowGUI(engine)); + public static void launch(Engine engine, UUID openerId) { + EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId)); } - private static void createAndShowGUI(Engine engine) { + private static void createAndShowGUI(Engine engine, UUID openerId) { JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE)); GuiHost.prepareFrame(frame); - VisionGUI vision = new VisionGUI(frame, engine); + VisionGUI vision = new VisionGUI(frame, engine, openerId); frame.getContentPane().setBackground(BACKGROUND); frame.setLayout(new BorderLayout()); frame.add(buildToolbar(vision), BorderLayout.NORTH); @@ -739,7 +742,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi } engine = reacquired; renderer = new IrisRenderer(reacquired); - overlay = GuiHost.get().overlayFor(reacquired); + overlay = GuiHost.get().overlayFor(reacquired, openerId); contentRevision++; captureHeightRange(); return true; @@ -758,7 +761,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi return; } renderer = new IrisRenderer(engine); - overlay = GuiHost.get().overlayFor(engine); + overlay = GuiHost.get().overlayFor(engine, openerId); contentRevision++; captureHeightRange(); requestRender(); diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index 4fab77ac0..b0446c0b1 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -56,6 +56,10 @@ public final class IrisLanguage { private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+"); private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); private static final MessageCatalog CATALOG = IrisMessages.catalog(); + private static final List BUKKIT_MESSAGE_IDS = CATALOG.ids().stream() + .filter(id -> !id.startsWith("iris.modded.")) + .sorted() + .toList(); private static final LocalizationManager MANAGER = new LocalizationManager( LocalizationCandidate.english(CATALOG, PluralSelector.oneOther()) ); @@ -369,8 +373,11 @@ public final class IrisLanguage { } } - private static LocaleOverlay parseOverlay(String source, String locale, String raw) { + static LocaleOverlay parseOverlay(String source, String locale, String raw) { JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw); + if (parsed.isJsonArray()) { + return parseCompactBukkitOverlay(source, locale, parsed.getAsJsonArray()); + } if (!parsed.isJsonObject()) { throw new IllegalArgumentException("Locale source is not a JSON object: " + source); } @@ -398,6 +405,49 @@ public final class IrisLanguage { return builder.build(); } + private static LocaleOverlay parseCompactBukkitOverlay( + String source, + String locale, + JsonArray root + ) { + if (root.size() != 2 || !root.get(0).isJsonPrimitive() || !root.get(1).isJsonArray()) { + throw new IllegalArgumentException("Compact locale source is invalid: " + source); + } + if (!locale.equals(normalizeLocale(root.get(0).getAsString()))) { + throw new IllegalArgumentException("Locale source declares a different locale than its file: " + source); + } + JsonArray messages = root.get(1).getAsJsonArray(); + if (messages.size() != BUKKIT_MESSAGE_IDS.size()) { + throw new IllegalArgumentException("Compact locale message count is invalid: " + source); + } + LocaleOverlay.Builder builder = LocaleOverlay.builder(source, locale); + for (int i = 0; i < messages.size(); i++) { + JsonElement value = messages.get(i); + if (value == null || value.isJsonNull()) { + continue; + } + appendCompactMessage(builder, BUKKIT_MESSAGE_IDS.get(i), value); + } + return builder.build(); + } + + private static void appendCompactMessage( + LocaleOverlay.Builder builder, + String key, + JsonElement value + ) { + MessageKey definition = CATALOG.key(key); + if (value.isJsonObject() && definition instanceof PluralKey) { + builder.plural(key, readPlural(key, value.getAsJsonObject())); + } else if (value.isJsonArray()) { + builder.lines(key, readLines(key, value.getAsJsonArray())); + } else if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + builder.text(key, value.getAsString()); + } else { + throw new IllegalArgumentException("Compact locale value has an invalid shape: " + key); + } + } + private static void appendMessages(LocaleOverlay.Builder builder, JsonObject object, String prefix) { for (Map.Entry entry : object.entrySet()) { String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java index 6d7dc7334..50ef7899f 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java @@ -13,7 +13,7 @@ import java.util.List; import java.util.Set; final class PackRiverValidator { - private static final Set WATER_MODES = Set.of("SEA_LEVEL", "TERRACED"); + private static final Set WATER_MODES = Set.of("FIXED", "TERRACED"); private static final Set TERMINAL_MODES = Set.of("SUPPRESS", "DRY_CHANNEL", "SINKHOLE_GROTTO"); private static final Set ROUTING_POLICIES = Set.of("ALLOW", "AVOID", "BLOCK"); private static final Set CAVE_MODES = Set.of( @@ -91,7 +91,7 @@ final class PackRiverValidator { validateTerrain(packFolder, path + ".terrain", terrain, errors, warnings); } if (water != null) { - validateWater(path + ".water", water, errors); + validateWater(path + ".water", water, context.dimension(), errors); } if (biomes != null) { validateBiomePools( @@ -107,7 +107,15 @@ final class PackRiverValidator { boolean sinkholeTerminal = terrain != null && "SINKHOLE_GROTTO".equals(stringValue(terrain, "terminalMode", "DRY_CHANNEL")); if (caves != null) { - validateCaves(packFolder, path + ".caves", caves, sinkholeTerminal, errors, warnings); + validateCaves( + packFolder, + path + ".caves", + caves, + context.dimension(), + sinkholeTerminal, + errors, + warnings + ); } if (topology != null && terrain != null) { @@ -168,6 +176,7 @@ final class PackRiverValidator { validateStyledRange(packFolder, terrain, "bankWidth", path, 0D, 2048D, errors, warnings); validateStyledRange(packFolder, terrain, "depth", path, 1D, 512D, errors, warnings); validateStyledRange(packFolder, terrain, "tunnelWidthMultiplier", path, 1D, 8D, errors, warnings); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "channelRadiusBonus", 0D, 64D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxChannelWidth", 1D, 2048D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxBankWidth", 0D, 2048D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxDepth", 1D, 512D, errors); @@ -211,18 +220,50 @@ final class PackRiverValidator { } } - private static void validateWater(String path, JSONObject water, List errors) { + private static void validateWater( + String path, + JSONObject water, + JSONObject dimension, + List errors + ) { PackJsonFieldChecks.validateOptionalEnum(path, water, "mode", WATER_MODES, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "poolLength", 8, 4096, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "maximumPoolRise", 0, 64, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "dropHeight", 1, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "fluidHeight", -2048, 2048, errors); + validateFluidPalette(path, water, errors); - String mode = stringValue(water, "mode", "SEA_LEVEL"); + String mode = stringValue(water, "mode", "FIXED"); + int fluidHeight = integerValue(water, "fluidHeight", 63); int maximumPoolRise = integerValue(water, "maximumPoolRise", 4); int dropHeight = integerValue(water, "dropHeight", 1); if ("TERRACED".equals(mode) && dropHeight > maximumPoolRise) { errors.add(path + ".dropHeight must not exceed maximumPoolRise in TERRACED mode."); } + JSONObject dimensionHeight = dimension.optJSONObject("dimensionHeight"); + int minimumHeight = dimensionHeight == null ? -64 : integerValue(dimensionHeight, "min", -64); + int maximumHeight = dimensionHeight == null ? 320 : integerValue(dimensionHeight, "max", 320); + if (fluidHeight < minimumHeight || fluidHeight > maximumHeight) { + errors.add(path + ".fluidHeight must remain inside dimensionHeight."); + } + if ("TERRACED".equals(mode) && fluidHeight + maximumPoolRise > maximumHeight) { + errors.add(path + ".fluidHeight plus maximumPoolRise must remain inside dimensionHeight."); + } + } + + private static void validateFluidPalette(String path, JSONObject water, List errors) { + if (!water.has("fluidPalette")) { + return; + } + Object rawPalette = water.opt("fluidPalette"); + if (!(rawPalette instanceof JSONObject palette)) { + errors.add(path + ".fluidPalette must be an object."); + return; + } + Object rawBlocks = palette.opt("palette"); + if (!(rawBlocks instanceof JSONArray blocks) || blocks.length() < 1) { + errors.add(path + ".fluidPalette.palette must contain at least one fluid block."); + } } private static void validateTopologyComplexity( @@ -345,9 +386,11 @@ final class PackRiverValidator { PackJsonFieldChecks.validateOptionalDoubleRange( wormPath, worm, "depthMultiplier", 0.125D, 8D, errors); PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bodyWavelength", 32D, 16384D, errors); + wormPath, worm, "bodyWavelength", 8D, 16384D, errors); PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bodyDetailWavelength", 32D, 16384D, errors); + wormPath, worm, "bodyDetailWavelength", 8D, 16384D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "bodyDetailInfluence", 0D, 1D, errors); PackJsonFieldChecks.validateOptionalDoubleRange( wormPath, worm, "widthVariation", 0D, 0.875D, errors); PackJsonFieldChecks.validateOptionalDoubleRange( @@ -452,6 +495,7 @@ final class PackRiverValidator { } private static void validateCaves(File packFolder, String path, JSONObject caves, + JSONObject dimension, boolean forceGeneratedGrotto, List errors, List warnings) { PackJsonFieldChecks.validateOptionalEnum(path, caves, "mode", CAVE_MODES, errors); @@ -473,6 +517,19 @@ final class PackRiverValidator { validateNoiseChance(packFolder, caves, "entry", path, errors); validateStyle(packFolder, caves, "grottoShapeStyle", path, errors); validateStyle(packFolder, caves, "grottoWarpStyle", path, errors); + JSONObject deepPools = caves.has("deepPools") + ? requireObject(caves, "deepPools", path + ".deepPools", errors) + : null; + if (deepPools != null) { + validateDeepPools( + packFolder, + path + ".deepPools", + deepPools, + dimension, + errors, + warnings + ); + } String mode = stringValue(caves, "mode", "SEALED"); if ("SEALED".equals(mode) && !forceGeneratedGrotto) { @@ -505,6 +562,86 @@ final class PackRiverValidator { } } + private static void validateDeepPools( + File packFolder, + String path, + JSONObject deepPools, + JSONObject dimension, + List errors, + List warnings + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, deepPools, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "minimumSpacing", 16, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "maximumPerReach", 0, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "minimumFluidY", -2048, 2048, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "maximumFluidY", -2048, 2048, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "searchRadius", 0, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "searchAttempts", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "horizontalRadius", 2, 128, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "verticalRadius", 2, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "dryHeadroom", 1, 63, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + path, deepPools, "shapeVariation", 0D, 0.75D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + path, deepPools, "warpStrength", 0D, 64D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, deepPools, "maximumVolume", 64, 1048576, errors); + validateNoiseChance(packFolder, deepPools, "reach", path, errors); + validateStyle(packFolder, deepPools, "shapeStyle", path, errors); + validateStyle(packFolder, deepPools, "warpStyle", path, errors); + validateFluidPalette(path, deepPools, errors); + + int minimumFluidY = integerValue(deepPools, "minimumFluidY", -224); + int maximumFluidY = integerValue(deepPools, "maximumFluidY", -104); + int searchRadius = integerValue(deepPools, "searchRadius", 16); + int horizontalRadius = integerValue(deepPools, "horizontalRadius", 18); + int verticalRadius = integerValue(deepPools, "verticalRadius", 8); + int dryHeadroom = integerValue(deepPools, "dryHeadroom", 4); + int maximumVolume = integerValue(deepPools, "maximumVolume", 32768); + if (minimumFluidY > maximumFluidY) { + errors.add(path + ".minimumFluidY must not exceed maximumFluidY."); + } + if (dryHeadroom >= verticalRadius) { + errors.add(path + ".dryHeadroom must be smaller than verticalRadius."); + } + if (searchRadius + horizontalRadius > 128) { + errors.add(path + ".searchRadius plus horizontalRadius must not exceed 128 blocks."); + } + long minimumVolume = grottoVolume(horizontalRadius, verticalRadius); + if (minimumVolume > maximumVolume) { + errors.add(path + ".maximumVolume must be at least " + minimumVolume + + " to contain the base deep-pool chamber."); + } + + if (!booleanValue(deepPools, "enabled", false)) { + return; + } + + JSONObject dimensionHeight = dimension.optJSONObject("dimensionHeight"); + int minimumHeight = dimensionHeight == null ? -64 : integerValue(dimensionHeight, "min", -64); + int maximumHeight = dimensionHeight == null ? 320 : integerValue(dimensionHeight, "max", 320); + int lowestBoundaryY = minimumFluidY - (verticalRadius * 2 - dryHeadroom) - 1; + int highestBoundaryY = maximumFluidY + dryHeadroom + 1; + if (lowestBoundaryY <= minimumHeight || highestBoundaryY >= maximumHeight) { + errors.add(path + " fluid range and chamber envelope must remain inside dimensionHeight."); + } + + int maximumPerReach = integerValue(deepPools, "maximumPerReach", 1); + double reachChance = noiseChanceValue(deepPools, "reach", 1D / 3D); + if (maximumPerReach == 0 || reachChance == 0D) { + warnings.add(path + " is enabled but its reach gate cannot accept any pools."); + } + } + private static void validateGrotto(String path, JSONObject caves, List errors) { int throatRadius = integerValue(caves, "throatRadius", 2); int dryHeadroom = integerValue(caves, "dryHeadroom", 4); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 6ae69c815..7b01f4418 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -65,7 +65,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger(); private static final int ADAPTIVE_SLOW_REQUEST_STEP = 3; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; - private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L; + private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L; private static final long FLUSH_TIMEOUT_SECONDS = 120L; private final World world; private final IrisRuntimeSchedulerMode runtimeSchedulerMode; @@ -773,17 +773,13 @@ public class AsyncPregenMethod implements PregeneratorMethod { // A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk. boolean interrupted = Thread.interrupted(); try { - boolean drained = false; - try { - drained = semaphore.tryAcquire(threads, CLOSE_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException e) { - interrupted = true; - } - - if (!drained) { - IrisLogging.warn("Async pregen close did not drain in " + CLOSE_DRAIN_TIMEOUT_SECONDS - + "s, continuing degraded. " + metricsSnapshot()); - } + interrupted |= awaitDrain( + semaphore, + threads, + CLOSE_DRAIN_WARNING_SECONDS, + TimeUnit.SECONDS, + () -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot()) + ); flushAllRemainingChunks(); executor.shutdown(); @@ -797,6 +793,26 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } + static boolean awaitDrain( + Semaphore semaphore, + int permits, + long warningInterval, + TimeUnit timeUnit, + Runnable onWait + ) { + boolean interrupted = false; + while (true) { + try { + if (semaphore.tryAcquire(permits, warningInterval, timeUnit)) { + return interrupted; + } + onWait.run(); + } catch (InterruptedException e) { + interrupted = true; + } + } + } + private boolean isCancelled() { return closing.get() || Thread.currentThread().isInterrupted(); } 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 8dabf6146..fae105f29 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 @@ -84,19 +84,35 @@ public class IrisProject { long seed, StudioOpenCoordinator.StudioOpenKind openKind, Consumer onDone + ) throws IrisException { + return open(sender, seed, openKind, onDone, System.nanoTime()); + } + + public CompletableFuture open( + VolmitSender sender, + long seed, + StudioOpenCoordinator.StudioOpenKind openKind, + Consumer onDone, + long requestedAtNanos ) throws IrisException { if (isOpen()) { - return close().thenCompose(ignored -> openInternal(sender, seed, openKind, onDone)); + return close().thenCompose(ignored -> openInternal( + sender, + seed, + openKind, + onDone, + requestedAtNanos)); } - return openInternal(sender, seed, openKind, onDone); + return openInternal(sender, seed, openKind, onDone, requestedAtNanos); } private CompletableFuture openInternal( VolmitSender sender, long seed, StudioOpenCoordinator.StudioOpenKind openKind, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { AtomicReference stage = new AtomicReference<>("Queued"); AtomicReference progress = new AtomicReference<>(0.01D); @@ -114,7 +130,8 @@ public class IrisProject { } progress.set(Math.max(0D, Math.min(0.99D, update.progress()))); }, - onDone + onDone, + requestedAtNanos ) ); StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed); diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index b5d4e8f09..fa3f36069 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -14,6 +14,7 @@ import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisCodeWorkspace; 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.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -98,16 +99,11 @@ public final class StudioOpenCoordinator { public CompletableFuture teleportPlayerToProject( IrisProject project, - Player player, - AtomicBoolean admission, - long deadlineNanos + Player player ) { if (project == null || player == null) { return CompletableFuture.completedFuture(false); } - AtomicBoolean activeAdmission = Objects.requireNonNull( - admission, - "Studio teleport admission"); PlatformChunkGenerator provider = project.getActiveProvider(); if (provider == null) { return CompletableFuture.failedFuture(new IllegalStateException( @@ -123,10 +119,6 @@ public final class StudioOpenCoordinator { return CompletableFuture.failedFuture(new IllegalStateException( "Studio entry point could not be resolved.")); } - if (System.nanoTime() >= deadlineNanos - || !activeAdmission.compareAndSet(true, false)) { - return studioTeleportDeadlineFailure("native teleport delegation"); - } CompletableFuture teleport = project.getActiveOpenKind() == StudioOpenKind.STANDARD ? WorldRuntimeControlService.get().teleportInMode(player, entry, GameMode.SPECTATOR) : WorldRuntimeControlService.get().teleport(player, entry); @@ -134,25 +126,12 @@ public final class StudioOpenCoordinator { return CompletableFuture.failedFuture(new IllegalStateException( "Studio native teleport returned no completion future.")); } - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0L) { - teleport.completeExceptionally(new TimeoutException( - "Studio teleport deadline expired before native teleport completion.")); - return teleport; - } - teleport.orTimeout(remainingNanos, TimeUnit.NANOSECONDS); return teleport; } - private CompletableFuture studioTeleportDeadlineFailure(String stageName) { - return CompletableFuture.failedFuture(new TimeoutException( - "Studio teleport deadline expired before " + stageName + ".")); - } - private void executeOpen(StudioOpenRequest request, CompletableFuture future) { World world = null; PlatformChunkGenerator provider = null; - CompletableFuture nativeTeleportFuture = null; try { long openStart = System.nanoTime(); long t = openStart; @@ -181,6 +160,10 @@ public final class StudioOpenCoordinator { if (provider == null) { throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\"."); } + World entryWorld = world; + PlatformChunkGenerator entryProvider = provider; + CompletableFuture entryBootstrap = J.afut( + () -> endStudioEntryBootstrap(entryWorld, entryProvider)); updateStage(request, "apply_world_rules", 0.72D); final World rulesWorld = world; @@ -204,9 +187,15 @@ public final class StudioOpenCoordinator { t = logStudioPhase(request, "resolve_entry_anchor", t, openStart); updateStage(request, "prepare_structure_rings", 0.79D); - endStudioEntryBootstrap(world, provider); + entryBootstrap.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); t = logStudioPhase(request, "prepare_structure_rings", t, openStart); + updateStage(request, "prepare_generation_caches", 0.88D); + if (entryProvider.getEngine() instanceof IrisEngine irisEngine) { + irisEngine.awaitGenerationCacheWarm(); + } + t = logStudioPhase(request, "prepare_generation_caches", t, openStart); + Location entryLocation = entryAnchor; if (request.openKind().teleportThroughStandardEntry() @@ -219,7 +208,7 @@ public final class StudioOpenCoordinator { } Boolean teleported; try { - nativeTeleportFuture = WorldRuntimeControlService.get().teleportInMode( + CompletableFuture nativeTeleportFuture = WorldRuntimeControlService.get().teleportInMode( player, entryLocation, GameMode.SPECTATOR); @@ -227,15 +216,20 @@ public final class StudioOpenCoordinator { throw new IllegalStateException( "Studio native teleport returned no completion future."); } - teleported = nativeTeleportFuture.get(60L, TimeUnit.SECONDS); - } catch (TimeoutException e) { - nativeTeleportFuture.completeExceptionally(e); - throw new IllegalStateException("Studio teleport timed out — destination region may still be generating."); + teleported = nativeTeleportFuture.get(); + } catch (ExecutionException e) { + Throwable failure = unwrapFailure(e); + throw new IllegalStateException("Studio teleport failed.", failure); } if (!Boolean.TRUE.equals(teleported)) { throw new IllegalStateException("Studio teleport did not complete successfully."); } t = logStudioPhase(request, "teleport_standard_entry", t, openStart); + IrisLogging.info("Studio player %s arrived in %dms: dimension=%s world=%s", + player.getName(), + elapsedMillis(request.requestedAtNanos()), + request.dimensionKey(), + world.getName()); } updateStage(request, "finalize_open", 1.00D); @@ -861,10 +855,14 @@ public final class StudioOpenCoordinator { StudioOpenKind openKind, boolean retainOnFailure, Consumer progressConsumer, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { public StudioOpenRequest { openKind = Objects.requireNonNull(openKind, "Studio open kind"); + if (requestedAtNanos <= 0L) { + throw new IllegalArgumentException("Studio request time must be positive."); + } } public static StudioOpenRequest studioProject( @@ -874,6 +872,25 @@ public final class StudioOpenCoordinator { StudioOpenKind openKind, Consumer progressConsumer, Consumer onDone + ) { + return studioProject( + project, + sender, + seed, + openKind, + progressConsumer, + onDone, + System.nanoTime()); + } + + public static StudioOpenRequest studioProject( + IrisProject project, + VolmitSender sender, + long seed, + StudioOpenKind openKind, + Consumer progressConsumer, + Consumer onDone, + long requestedAtNanos ) { String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null; return new StudioOpenRequest( @@ -886,7 +903,8 @@ public final class StudioOpenCoordinator { openKind, false, progressConsumer, - onDone + onDone, + requestedAtNanos ); } } 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 index 695261cc7..2015cadf0 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -10,6 +10,7 @@ import art.arcane.iris.core.service.BoardSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.util.common.scheduling.J; import io.papermc.lib.PaperLib; import org.bukkit.Bukkit; @@ -41,10 +42,13 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; public final class WorldRuntimeControlService { + private static final int STUDIO_ENTRY_VIEW_DISTANCE = 2; private static final int MAX_SAFE_ENTRY_HORIZONTAL_RADIUS = 15; - private static final int MAX_SAFE_ENTRY_VERTICAL_SEARCH = 64; + private static final int SAFE_ENTRY_UPWARD_ALLOWANCE = 32; private static final double BLOCK_CENTER = 0.5D; private static final double COLLISION_EPSILON = 0.000001D; + private static final Method PLAYER_GET_VIEW_DISTANCE_METHOD = findPlayerMethod("getViewDistance"); + private static final Method PLAYER_SET_VIEW_DISTANCE_METHOD = findPlayerMethod("setViewDistance", int.class); private static final Set UNSAFE_ENTRY_MATERIALS = Set.of( Material.CACTUS, Material.CAMPFIRE, @@ -302,7 +306,7 @@ public final class WorldRuntimeControlService { CompletableFuture future = new CompletableFuture<>(); boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { try { - future.complete(findTopSafeLocation(world, source)); + future.complete(findTopSafeLocationWithTicket(world, source, chunkX, chunkZ)); } catch (Throwable t) { future.completeExceptionally(t); } @@ -314,6 +318,22 @@ public final class WorldRuntimeControlService { return future; } + private static Location findTopSafeLocationWithTicket( + World world, + Location source, + int chunkX, + int chunkZ + ) { + boolean ticketAdded = world.addPluginChunkTicket(chunkX, chunkZ, BukkitPlatform.plugin()); + try { + return findTopSafeLocation(world, source); + } finally { + if (ticketAdded) { + world.removePluginChunkTicket(chunkX, chunkZ, BukkitPlatform.plugin()); + } + } + } + public CompletableFuture teleport(Player player, Location location) { return scheduleTeleport(player, location, null); } @@ -350,6 +370,7 @@ public final class WorldRuntimeControlService { CompletableFuture future = new CompletableFuture<>(); GameModeRestore modeRestore = new GameModeRestore(player); + PlayerViewDistanceRestore viewDistanceRestore = new PlayerViewDistanceRestore(player); AtomicReference> activeTeleport = new AtomicReference<>(); future.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success)) { @@ -359,6 +380,7 @@ public final class WorldRuntimeControlService { if (teleport != null && !teleport.isDone()) { teleport.cancel(false); } + viewDistanceRestore.restore(); modeRestore.restore(); }); boolean scheduled = J.runEntity(player, () -> { @@ -368,7 +390,9 @@ public final class WorldRuntimeControlService { } if (gameMode != null) { modeRestore.apply(gameMode); + viewDistanceRestore.apply(); if (future.isDone()) { + viewDistanceRestore.restore(); modeRestore.restore(); return; } @@ -393,6 +417,7 @@ public final class WorldRuntimeControlService { if (Boolean.TRUE.equals(success)) { if (future.complete(true)) { + viewDistanceRestore.restore(); J.runEntity(player, () -> IrisServices.get(BoardSVC.class).updatePlayer(player)); } return; @@ -479,6 +504,7 @@ public final class WorldRuntimeControlService { z, minimumFloorY, maximumFloorY, + source.getBlockY() - 1, yaw, pitch ); @@ -498,13 +524,14 @@ public final class WorldRuntimeControlService { int z, int minimumFloorY, int maximumFloorY, + int preferredFloorY, float yaw, float pitch ) { int highestY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); - int startingFloorY = Math.max(minimumFloorY, Math.min(maximumFloorY, highestY)); - int lowestFloorY = Math.max(minimumFloorY, startingFloorY - MAX_SAFE_ENTRY_VERTICAL_SEARCH + 1); - for (int floorY = startingFloorY; floorY >= lowestFloorY; floorY--) { + int preferredMaximumY = Math.min(maximumFloorY, preferredFloorY + SAFE_ENTRY_UPWARD_ALLOWANCE); + int startingFloorY = Math.max(minimumFloorY, Math.min(preferredMaximumY, highestY)); + for (int floorY = startingFloorY; floorY >= minimumFloorY; floorY--) { Block floor = world.getBlockAt(x, floorY, z); if (!isSafeFloor(floor)) { continue; @@ -777,6 +804,14 @@ public final class WorldRuntimeControlService { return method.invoke(instance); } + private static Method findPlayerMethod(String methodName, Class... parameterTypes) { + try { + return Player.class.getMethod(methodName, parameterTypes); + } catch (NoSuchMethodException ignored) { + return null; + } + } + @FunctionalInterface interface TeleportExecutor { CompletableFuture teleport(Player player, Location location); @@ -822,4 +857,56 @@ public final class WorldRuntimeControlService { } } } + + private static final class PlayerViewDistanceRestore { + private final Player player; + private final AtomicBoolean changed; + private final AtomicBoolean restored; + private int previousViewDistance; + + private PlayerViewDistanceRestore(Player player) { + this.player = player; + changed = new AtomicBoolean(false); + restored = new AtomicBoolean(false); + } + + private void apply() { + if (PLAYER_GET_VIEW_DISTANCE_METHOD == null || PLAYER_SET_VIEW_DISTANCE_METHOD == null) { + return; + } + try { + Object currentValue = PLAYER_GET_VIEW_DISTANCE_METHOD.invoke(player); + if (!(currentValue instanceof Number)) { + return; + } + int currentViewDistance = ((Number) currentValue).intValue(); + if (currentViewDistance <= STUDIO_ENTRY_VIEW_DISTANCE) { + return; + } + PLAYER_SET_VIEW_DISTANCE_METHOD.invoke(player, STUDIO_ENTRY_VIEW_DISTANCE); + previousViewDistance = currentViewDistance; + changed.set(true); + } catch (ReflectiveOperationException failure) { + IrisLogging.reportError("Failed to apply a temporary player view distance for a studio teleport.", failure); + } + } + + private void restore() { + if (!changed.get() || !restored.compareAndSet(false, true)) { + return; + } + Runnable restoration = () -> { + try { + PLAYER_SET_VIEW_DISTANCE_METHOD.invoke(player, previousViewDistance); + } catch (ReflectiveOperationException failure) { + IrisLogging.reportError("Failed to restore a player's view distance after a studio teleport.", failure); + } + }; + if (!J.runEntity(player, restoration)) { + IrisLogging.reportError( + "Failed to restore a player's view distance after a studio teleport.", + new IllegalStateException("The player entity scheduler rejected the view-distance restoration.")); + } + } + } } 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 49996e015..d5df852d7 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 @@ -83,7 +83,6 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -95,7 +94,6 @@ import art.arcane.volmlib.util.localization.MessageArgument; public class StudioSVC implements IrisService { public static final String WORKSPACE_NAME = "packs"; private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; - private static final long STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS = 10L; private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final AtomicCache counter = new AtomicCache<>(); private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); @@ -478,24 +476,14 @@ public class StudioSVC implements IrisService { public CompletableFuture teleportToActiveProject(Player player) { Player target = Objects.requireNonNull(player, "Studio teleport player"); - AtomicBoolean admission = new AtomicBoolean(true); - long deadlineNanos = System.nanoTime() - + TimeUnit.SECONDS.toNanos(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS); - CompletableFuture transition = studioTransitions.submit(() -> { + return studioTransitions.submit(() -> { IrisProject project = activeProject; if (project == null || !project.isOpen()) { return CompletableFuture.failedFuture(new IllegalStateException( "No active Studio project is available for teleport.")); } - return StudioOpenCoordinator.get().teleportPlayerToProject( - project, - target, - admission, - deadlineNanos); + return StudioOpenCoordinator.get().teleportPlayerToProject(project, target); }); - transition.orTimeout(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - transition.whenComplete((ignored, failure) -> admission.set(false)); - return transition; } public void open(VolmitSender sender, String dimm) { @@ -565,13 +553,20 @@ public class StudioSVC implements IrisService { StudioOpenCoordinator.StudioOpenKind openKind, Consumer onDone ) throws IrisException { + long requestedAtNanos = System.nanoTime(); if (reportPackAdmissionFailure(sender, dimm) != null) { return; } StudioOpenCoordinator.StudioOpenKind requiredOpenKind = Objects.requireNonNull( openKind, "Studio open kind"); - studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, requiredOpenKind, onDone)) + studioTransitions.submit(() -> replaceActiveProject( + sender, + seed, + dimm, + requiredOpenKind, + onDone, + requestedAtNanos)) .whenComplete((ignored, throwable) -> { if (throwable == null) { return; @@ -591,6 +586,7 @@ public class StudioSVC implements IrisService { Runnable beforeOpen, Consumer onDone ) { + long requestedAtNanos = System.nanoTime(); BrokenPackException failure = reportPackAdmissionFailure(sender, dimension); if (failure != null) { return CompletableFuture.failedFuture(failure); @@ -601,7 +597,8 @@ public class StudioSVC implements IrisService { dimension, Objects.requireNonNull(openKind, "Studio open kind"), Objects.requireNonNull(beforeOpen, "Studio before-open callback"), - Objects.requireNonNull(onDone, "Studio open completion callback"))); + Objects.requireNonNull(onDone, "Studio open completion callback"), + requestedAtNanos)); } private CompletableFuture replaceActiveProjectTracked( @@ -610,7 +607,8 @@ public class StudioSVC implements IrisService { String dimension, StudioOpenCoordinator.StudioOpenKind openKind, Runnable beforeOpen, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { return closeActiveProject().thenCompose(closeResult -> { if (closeResult == null) { @@ -621,7 +619,13 @@ public class StudioSVC implements IrisService { return CompletableFuture.failedFuture(closeResult.failureCause()); } beforeOpen.run(); - return beginStudioOpenTracked(sender, seed, dimension, openKind, onDone); + return beginStudioOpenTracked( + sender, + seed, + dimension, + openKind, + onDone, + requestedAtNanos); }); } @@ -630,13 +634,14 @@ public class StudioSVC implements IrisService { long seed, String dimension, StudioOpenCoordinator.StudioOpenKind openKind, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimension)); activeProject = project; CompletableFuture opening; try { - opening = project.open(sender, seed, openKind, onDone); + opening = project.open(sender, seed, openKind, onDone, requestedAtNanos); } catch (IrisException exception) { if (activeProject == project) { activeProject = null; @@ -663,7 +668,8 @@ public class StudioSVC implements IrisService { long seed, String dimension, StudioOpenCoordinator.StudioOpenKind openKind, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { return closeActiveProjectForReplacement(sender).handle((closeResult, closeThrowable) -> { if (closeThrowable != null) { @@ -691,7 +697,13 @@ public class StudioSVC implements IrisService { } return true; }).thenCompose(closed -> closed - ? beginStudioOpen(sender, seed, dimension, openKind, onDone) + ? beginStudioOpen( + sender, + seed, + dimension, + openKind, + onDone, + requestedAtNanos) : CompletableFuture.completedFuture(null)); } @@ -700,7 +712,8 @@ public class StudioSVC implements IrisService { long seed, String dimension, StudioOpenCoordinator.StudioOpenKind openKind, - Consumer onDone + Consumer onDone, + long requestedAtNanos ) { IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimension)); activeProject = project; @@ -710,7 +723,8 @@ public class StudioSVC implements IrisService { sender, seed, openKind, - onDone); + onDone, + requestedAtNanos); } catch (IrisException e) { if (activeProject == project) { activeProject = null; 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 17a7cfc5f..8f10caf73 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 @@ -306,6 +306,9 @@ public class IrisCreator { world = J.sfut(() -> INMS.get().createWorldAsync(wc, request)) .thenCompose(Function.identity()) .get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!studio && !benchmark) { + awaitInitialSpawnPreparation(access, name); + } } catch (Throwable e) { done.set(true); cancelRepeatingTask(createProgressTask); @@ -603,6 +606,21 @@ public class IrisCreator { return taskId; } + static void awaitInitialSpawnPreparation( + PlatformChunkGenerator generator, + String worldName + ) throws InterruptedException, ExecutionException, TimeoutException { + CompletableFuture initialSpawnReady = Objects.requireNonNull( + generator.getInitialSpawnReady(), + "Initial spawn preparation future"); + try { + initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException failure) { + throw new TimeoutException("Initial spawn preparation timed out for world \"" + + worldName + "\"."); + } + } + private AtomicInteger startPregenProgressReporter( AtomicDouble progress, AtomicBoolean done, diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 3cdc74ab6..75fad5035 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -29,10 +29,14 @@ import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisInterpolator; +import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverDeepPools; +import art.arcane.iris.engine.object.IrisRiverNetwork; import art.arcane.iris.engine.object.IrisRiverOverride; import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.object.IrisRiverWater; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; import art.arcane.iris.engine.river.runtime.IrisRiverRuntimeContext; @@ -40,6 +44,7 @@ import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.river.RiverSample; import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -66,6 +71,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @@ -132,6 +138,8 @@ public class IrisComplex implements DataProvider { private ProceduralStream shoreSurfaceDecoration; private ProceduralStream rockStream; private ProceduralStream fluidStream; + private ProceduralStream riverFluidStream; + private ProceduralStream riverDeepPoolFluidStream; private IrisBiome focusBiome; private IrisRegion focusRegion; private Map> generatorBounds; @@ -213,6 +221,29 @@ public class IrisComplex implements DataProvider { .select(engine.getDimension().getRockPalette().getBlockData(data)); fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream() .select(engine.getDimension().getFluidPalette().getBlockData(data)); + IrisRiverNetwork configuredRivers = engine.getDimension().getRivers(); + IrisRiverWater configuredRiverWater = configuredRivers == null ? null : configuredRivers.getWater(); + riverFluidStream = configuredRivers != null && configuredRivers.isEnabled() + ? configuredFluidStream( + Objects.requireNonNull(configuredRiverWater).getFluidPalette(), + rng.nextParallelRNG(79), + "River water" + ) + : fluidStream; + IrisRiverCaves configuredRiverCaves = configuredRivers == null ? null : configuredRivers.getCaves(); + IrisRiverDeepPools configuredDeepPools = configuredRiverCaves == null + ? null + : configuredRiverCaves.getDeepPools(); + riverDeepPoolFluidStream = configuredRivers != null + && configuredRivers.isEnabled() + && configuredDeepPools != null + && configuredDeepPools.isEnabled() + ? configuredFluidStream( + configuredDeepPools.getFluidPalette(), + rng.nextParallelRNG(80), + "River deep-pool" + ) + : riverFluidStream; regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() .zoom(engine.getDimension().getRegionZoom()); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); @@ -303,16 +334,16 @@ public class IrisComplex implements DataProvider { .cache2D("naturalTrueBiomeStream", engine, cacheSize); if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) { ProceduralStream naturalOceanStream = createNaturalOceanStream( - naturalHeightStream, bridgeStream, - focusBiome, - fluidHeight, - engine.getDimension().getRivers().getWater().getMode() + focusBiome ).cache2D("naturalOceanStream", engine, cacheSize); + int riverFluidHeight = engine.getDimension().getRivers().getWater().getFluidHeight() + - engine.getDimension().getMinHeight(); riverRuntime = new IrisRiverRuntime(new IrisRiverRuntimeContext( engine.getSeedManager().getBodies(), engine.getDimension().getRivers(), data, + riverFluidHeight, (int) Math.round(fluidHeight), IrisEngineMantle.isRiverHydrologyEnabled(engine.getDimension()), IrisEngineMantle.isRiverCaveHydrologyEnabled(engine.getDimension()), @@ -458,23 +489,49 @@ public class IrisComplex implements DataProvider { } static ProceduralStream createNaturalOceanStream( - ProceduralStream naturalHeightStream, ProceduralStream bridgeStream, - IrisBiome focusBiome, - double fluidHeight, - IrisRiverWaterMode waterMode + IrisBiome focusBiome ) { if (focusBiome != null) { boolean ocean = focusBiome.getInferredType() == InferredType.SEA; return ProceduralStream.of((x, z) -> ocean, Interpolated.BOOLEAN); } - if (waterMode == IrisRiverWaterMode.SEA_LEVEL) { - return bridgeStream.convert(type -> type == InferredType.SEA); + return bridgeStream.convert(type -> type == InferredType.SEA); + } + + private ProceduralStream configuredFluidStream( + IrisMaterialPalette palette, + RNG fluidRng, + String configurationName + ) { + Objects.requireNonNull(palette, configurationName + " fluidPalette must be configured"); + KList blocks = palette.getBlockData(data); + if (blocks.isEmpty()) { + throw new IllegalArgumentException( + configurationName + " fluidPalette must resolve at least one fluid block"); } - return ProceduralStream.of( - (x, z) -> naturalHeightStream.getDouble(x, z) < fluidHeight - 1D, - Interpolated.BOOLEAN - ); + for (PlatformBlockState block : blocks) { + if (block == null || !block.isFluid()) { + throw new IllegalArgumentException( + configurationName + " fluidPalette may contain only fluid blocks"); + } + } + return palette.getLayerGenerator(fluidRng, data).stream().select(blocks); + } + + public PlatformBlockState resolveRiverCaveFluid(RiverCaveFluidKind fluidKind, double x, double z) { + return switch (Objects.requireNonNull(fluidKind)) { + case RIVER -> riverFluidStream.get(x, z); + case DEEP_POOL -> riverDeepPoolFluidStream.get(x, z); + }; + } + + public PlatformBlockState resolveSurfaceFluid(double x, double z) { + IrisRiverSurfaceSample sample = riverSurfaceStream.get(x, z); + if (sample.river().present() && sample.surfaceFluid()) { + return riverFluidStream.get(x, z); + } + return fluidStream.get(x, z); } public ProceduralStream getBiomeStream(InferredType type) { 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 f8a15c44a..94ce8cc04 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -71,6 +71,7 @@ import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -93,6 +94,7 @@ public class IrisEngine implements Engine { private final ChronoLatch perSecondLatch; private final ChronoLatch perSecondBudLatch; private final EngineMetrics metrics; + private final CompletableFuture generationCacheWarm; private final boolean studio; private final AtomicRollingSequence wallClock; @Getter(AccessLevel.NONE) @@ -186,6 +188,7 @@ public class IrisEngine implements Engine { bud = new AtomicInteger(0); buds = new AtomicInteger(0); metrics = new EngineMetrics(32); + generationCacheWarm = new CompletableFuture<>(); cleanLatch = new ChronoLatch(10000); generatedLast = new AtomicInteger(0); perSecond = new AtomicDouble(0); @@ -232,16 +235,17 @@ public class IrisEngine implements Engine { runtimeBuilder.publishRuntime(initialRuntime, null); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); logStudioInitializationPhase("build_runtime", phaseStartedAt, false); - _t0 = M.ms(); phaseStartedAt = System.nanoTime(); if (requiredMode.warmGenerationCaches()) { - GenerationCacheWarmer.warm(this); + if (requiredMode.studio()) { + startGenerationCacheWarm(phaseStartedAt); + } else { + warmGenerationCaches(phaseStartedAt); + } + } else { + generationCacheWarm.complete(null); + logStudioInitializationPhase("generation_cache_warm", phaseStartedAt, true); } - IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms"); - logStudioInitializationPhase( - "generation_cache_warm", - phaseStartedAt, - !requiredMode.warmGenerationCaches()); EngineTickRegistry.registerTicking(this); } catch (Throwable e) { shutdownSequence.cleanupFailedConstruction(e); @@ -250,6 +254,21 @@ public class IrisEngine implements Engine { IrisLogging.debug("Engine Initialized " + getCacheID()); } + public void awaitGenerationCacheWarm() { + if (generationCacheWarm.isDone() && !generationCacheWarm.isCompletedExceptionally()) { + return; + } + try { + generationCacheWarm.join(); + } catch (CompletionException failure) { + throw new IllegalStateException("Iris generation caches did not become ready.", failure.getCause()); + } + } + + public boolean isGenerationCacheWarmPending() { + return !generationCacheWarm.isDone(); + } + private void logStudioInitializationPhase(String phase, long startedAtNanos, boolean skipped) { if (!studio) { return; @@ -262,6 +281,32 @@ public class IrisEngine implements Engine { Boolean.toString(skipped)); } + private void startGenerationCacheWarm(long phaseStartedAtNanos) { + if (!backgroundTasks.scheduleTrackedTask(() -> warmGenerationCaches(phaseStartedAtNanos))) { + throw new IllegalStateException("Iris background task admission closed before generation cache warming."); + } + } + + private void warmGenerationCaches(long phaseStartedAtNanos) { + long startedAtMillis = M.ms(); + try { + GenerationCacheWarmer.warm(this); + generationCacheWarm.complete(null); + } catch (Throwable failure) { + generationCacheWarm.completeExceptionally(failure); + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Generation cache warming failed.", failure); + } finally { + IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - startedAtMillis) + "ms"); + logStudioInitializationPhase("generation_cache_warm", phaseStartedAtNanos, false); + } + } + private void verifySeed() { if (getEngineData().getSeed() != null && getEngineData().getSeed() != target.getWorld().getRawWorldSeed()) { target.getWorld().setRawWorldSeed(getEngineData().getSeed()); @@ -323,6 +368,7 @@ public class IrisEngine implements Engine { @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { + awaitGenerationCacheWarm(); try (GenerationSessionLease lease = acquireGenerationLease("matter_generate"); IrisContext.Scope ignored = IrisContext.open(this, lease.sessionId(), context)) { IrisComplex activeComplex = getComplex(); @@ -574,6 +620,7 @@ public class IrisEngine implements Engine { @BlockCoordinates @Override public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException { + awaitGenerationCacheWarm(); try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate"); IrisContext.Scope generationScope = IrisContext.open(this, lease.sessionId(), null)) { getEngineData().getStatistics().generatedChunk(); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index 2fc6d0c60..af77a1014 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -48,7 +48,6 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; @EqualsAndHashCode(callSuper = true) @Data @@ -78,7 +77,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { final WorldBlockDropRouter blockDropRouter = new WorldBlockDropRouter(this); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - final WorldTeleportWarmup teleportWarmup = new WorldTeleportWarmup(this); + final WorldTeleportWarmup teleportWarmup = new WorldTeleportWarmup(); private boolean looperStopped; private volatile boolean cleanupServiceStopped; volatile int entityCount = 0; @@ -193,10 +192,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager { }; } - AtomicBoolean ignoreTeleport() { - return ignoreTP; - } - @Override public void onTick() { diff --git a/core/src/main/java/art/arcane/iris/engine/WorldTeleportWarmup.java b/core/src/main/java/art/arcane/iris/engine/WorldTeleportWarmup.java index a98a74075..e2c2979c8 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldTeleportWarmup.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldTeleportWarmup.java @@ -18,87 +18,53 @@ package art.arcane.iris.engine; -import art.arcane.iris.core.localization.IrisLanguage; -import art.arcane.iris.core.localization.RuntimeUiMessages; +import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.util.common.plugin.VolmitSender; -import art.arcane.iris.util.common.scheduling.J; -import art.arcane.iris.util.common.scheduling.jobs.QueueJob; -import art.arcane.volmlib.util.collection.KList; -import io.papermc.lib.PaperLib; -import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerTeleportEvent; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -/** - * Cancels a teleport, loads the destination chunks off the main thread and then replays the teleport - * on the thread that owns the player. The replay sets the manager's ignore flag so the reissued - * teleport is not intercepted again. - */ final class WorldTeleportWarmup { - private final IrisWorldManager manager; - - WorldTeleportWarmup(IrisWorldManager manager) { - this.manager = manager; - } - void teleportAsync(PlayerTeleportEvent e) { + Location destination = e.getTo(); + if (destination == null) { + return; + } + + Player player = e.getPlayer(); + PlayerTeleportEvent.TeleportCause cause = e.getCause(); e.setCancelled(true); - warmupAreaAsync(e.getPlayer(), e.getTo(), () -> J.runEntity(e.getPlayer(), manager.managedTask( - "bukkit_world_manager_teleport", - () -> { - manager.ignoreTeleport().set(true); - e.getPlayer().teleport(e.getTo(), e.getCause()); - manager.ignoreTeleport().set(false); - }))); + CompletableFuture teleport; + try { + teleport = BukkitPlatform.teleportAsync( + player, + destination.clone(), + cause); + } catch (Throwable failure) { + reportFailure(player, destination, failure); + return; + } + if (teleport == null) { + reportFailure(player, destination, new IllegalStateException( + "Async teleport returned no completion future.")); + return; + } + teleport.whenComplete((success, failure) -> { + if (failure != null) { + reportFailure(player, destination, failure); + } else if (!Boolean.TRUE.equals(success)) { + reportFailure(player, destination, new IllegalStateException( + "Async teleport did not complete successfully.")); + } + }); } - private void warmupAreaAsync(Player player, Location to, Runnable r) { - J.a(manager.managedTask("bukkit_world_manager_teleport_warmup", () -> { - int viewDistance = 2; - KList> futures = new KList<>(); - for (int i = -viewDistance; i <= viewDistance; i++) { - for (int j = -viewDistance; j <= viewDistance; j++) { - int finalJ = j; - int finalI = i; - - if (to.getWorld().isChunkLoaded((to.getBlockX() >> 4) + i, (to.getBlockZ() >> 4) + j)) { - futures.add(CompletableFuture.completedFuture(null)); - continue; - } - - futures.add(MultiBurst.burst.completeValue(() - -> PaperLib.getChunkAtAsync(to.getWorld(), - (to.getBlockX() >> 4) + finalI, - (to.getBlockZ() >> 4) + finalJ, - true, false).get())); - } - } - - new QueueJob>() { - @Override - public void execute(Future chunkFuture) { - try { - chunkFuture.get(); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - IrisLogging.debug("Chunk warmup interrupted while loading async teleport chunk."); - } catch (ExecutionException ex) { - IrisLogging.reportError(ex); - } - } - - @Override - public String getName() { - return IrisLanguage.text(RuntimeUiMessages.JOB_LOADING_CHUNKS); - } - }.queue(futures).execute(new VolmitSender(player), true, r); - })); + private void reportFailure(Player player, Location destination, Throwable failure) { + IrisLogging.error("Async teleport into Iris world failed for " + player.getName() + + " at " + destination.getBlockX() + ", " + destination.getBlockY() + ", " + + destination.getBlockZ() + "."); + IrisLogging.reportError(failure); } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index f17974f1c..e7f1dae4d 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -102,7 +102,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator biomeCache = context.getBiome(); ChunkedDataCache regionCache = context.getRegion(); - ChunkedDataCache fluidCache = context.getFluid(); ChunkedDataCache rockCache = context.getRock(); int realX = xf + x; UpperDimensionContext upperContext = getEngine().getUpperContext(); @@ -118,7 +117,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java index 292495ac4..3e32a4d16 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java @@ -26,12 +26,16 @@ import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Location; import org.bukkit.Sound; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.event.world.WorldSaveEvent; @@ -45,7 +49,6 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent private boolean listenerRegistered; private boolean closeRequested; private int taskId; - protected AtomicBoolean ignoreTP = new AtomicBoolean(false); public EngineAssignedWorldManager() { super(null, null); @@ -126,6 +129,29 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent }); } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void on(PlayerTeleportEvent e) { + if (!BukkitPlatform.isPaperServer() + || !getEngine().isStudio() + || e.getCause() != PlayerTeleportEvent.TeleportCause.COMMAND) { + return; + } + + Location destination = e.getTo(); + World targetWorld = BukkitWorldBinding.world(getTarget().getWorld()); + if (destination == null || targetWorld == null || !targetWorld.equals(destination.getWorld())) { + return; + } + + int chunkX = destination.getBlockX() >> 4; + int chunkZ = destination.getBlockZ() >> 4; + if (targetWorld.isChunkLoaded(chunkX, chunkZ)) { + return; + } + + runManagerTask("bukkit_world_manager_teleport_event", () -> teleportAsync(e)); + } + @EventHandler public void on(ChunkLoadEvent e) { runManagerTask("bukkit_world_manager_chunk_load", () -> { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java index 8239530ee..bdaa9abad 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java @@ -38,6 +38,19 @@ public interface MantleComponent extends Comparable { return 0; } + default int getInputRadius( + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius, + ChunkContext context + ) { + return getInputRadius(); + } + + default boolean isInputGenerationLazy() { + return false; + } + default IrisData getData() { return getEngineMantle().getData(); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 8e4389547..43cd97fae 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -66,9 +66,21 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { private final AtomicReferenceArray> window; public MantleWriter(EngineMantle engineMantle, Mantle mantle, int x, int z, int radius, boolean multicore) { + this(engineMantle, mantle, x, z, radius, radius * 2, multicore); + } + + public MantleWriter( + EngineMantle engineMantle, + Mantle mantle, + int x, + int z, + int prefetchRadius, + int accessRadius, + boolean multicore + ) { this.engineMantle = engineMantle; this.mantle = mantle; - this.radius = radius * 2; + this.radius = accessRadius; this.x = x; this.z = z; // Every coordinate acquireChunk accepts lives in this window, so a flat array replaces the @@ -78,7 +90,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { final boolean foliaMaintenance = J.isFolia() && WorldMaintenance.isWorldMaintenanceActive(engineMantle.getEngine().getWorld().identity()); - final int parallelism = foliaMaintenance ? 1 : (multicore ? Runtime.getRuntime().availableProcessors() / 2 : 4); + final int parallelism = resolvePrefetchParallelism( + foliaMaintenance, + multicore, + Runtime.getRuntime().availableProcessors()); if (foliaMaintenance && IrisSettings.get().getGeneral().isDebug()) { IrisLogging.info("MantleWriter using sequential chunk prefetch for maintenance regen at " + x + "," + z + "."); } @@ -86,10 +101,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { // prefetch must release the permits already pinned into the window here. try { mantle.getChunks( - x - radius, - x + radius, - z - radius, - z + radius, + x - prefetchRadius, + x + prefetchRadius, + z - prefetchRadius, + z + prefetchRadius, parallelism, this::storePrefetchedChunk ); @@ -99,6 +114,16 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } } + static int resolvePrefetchParallelism(boolean foliaMaintenance, boolean multicore, int availableProcessors) { + if (foliaMaintenance) { + return 1; + } + if (!multicore) { + return 4; + } + return Math.max(1, availableProcessors / 2); + } + private static Set getBallooned(Set vset, double radius) { Set returnset = new HashSet<>(); int ceilrad = (int) Math.ceil(radius); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java index 907dbea12..4be1cafc0 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java @@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -43,18 +44,31 @@ public interface MatterGenerator { return; } - int writeRadius = getRadius(); + MatterGenerationPlan generationPlan = resolveGenerationPlan(x, z, context); + MatterPassPlan[] passPlans = generationPlan.passPlans(); + if (passPlans.length == 0) { + return; + } + int prefetchRadius = passPlans[0].passChunkRadius(); LongOpenHashSet partialChunks = new LongOpenHashSet(); - try (MantleWriter writer = new MantleWriter(getEngine().getMantle(), getMantle(), x, z, writeRadius, multicore)) { + try (MantleWriter writer = new MantleWriter( + getEngine().getMantle(), + getMantle(), + x, + z, + prefetchRadius, + generationPlan.writerAccessRadius(), + multicore)) { // Every task launched below writes through this writer. They must all be finished before // close() releases the cached chunks, even when a pass throws, or detached pool threads // write into released chunks. List outstandingTasks = null; try { - for (MantlePass pass : getComponents()) { - int passRadius = pass.passChunkRadius(); + for (MatterPassPlan passPlan : passPlans) { + MantlePass pass = passPlan.pass(); + int passRadius = passPlan.passChunkRadius(); List passComponents = pass.components(); MantleComponent[] enabledComponents = new MantleComponent[passComponents.size()]; int[] componentPassRadii = new int[passComponents.size()]; @@ -63,7 +77,7 @@ public interface MatterGenerator { if (component.isEnabled()) { // A component must cover its own reach plus every later pass' reach, or a // later pass reads this component's data from chunks it never wrote. - int componentReach = component.getRadius() + pass.downstreamBlockRadius(); + int componentReach = component.getRadius() + passPlan.downstreamBlockRadius(); componentPassRadii[enabledComponentCount] = componentReach > 0 ? Math.ceilDiv(componentReach, 16) : 0; enabledComponents[enabledComponentCount++] = component; } @@ -112,7 +126,8 @@ public interface MatterGenerator { MantleChunk chunk = writer.acquireChunk(passX, passZ); if (chunk == null) { throw new IllegalStateException("Mantle pass chunk " + passX + "," + passZ - + " is outside the writer prepared at " + x + "," + z + " with radius " + writeRadius); + + " is outside the writer prepared at " + x + "," + z + + " with radius " + generationPlan.writerAccessRadius()); } if (chunk.isFlagged(MantleFlag.PLANNED)) { @@ -174,8 +189,9 @@ public interface MatterGenerator { } } - for (int i = -getRealRadius(); i <= getRealRadius(); i++) { - for (int j = -getRealRadius(); j <= getRealRadius(); j++) { + int realRadius = passPlans[passPlans.length - 1].passChunkRadius(); + for (int i = -realRadius; i <= realRadius; i++) { + for (int j = -realRadius; j <= realRadius; j++) { int realX = x + i; int realZ = z + j; long realKey = chunkKey(realX, realZ); @@ -195,6 +211,45 @@ public interface MatterGenerator { return (((long) x) << 32) ^ (z & 0xffffffffL); } + private MatterGenerationPlan resolveGenerationPlan(int x, int z, ChunkContext context) { + List passes = getComponents(); + MatterPassPlan[] plans = new MatterPassPlan[passes.size()]; + int accessDownstreamBlockRadius = 0; + int generationDownstreamBlockRadius = 0; + for (int passIndex = passes.size() - 1; passIndex >= 0; passIndex--) { + MantlePass pass = passes.get(passIndex); + int passBlockRadius = 0; + int passAccessInputRadius = 0; + int passGenerationInputRadius = 0; + for (MantleComponent component : pass.components()) { + if (!component.isEnabled()) { + continue; + } + int componentRadius = component.getRadius(); + passBlockRadius = Math.max(passBlockRadius, componentRadius); + int componentReach = componentRadius + generationDownstreamBlockRadius; + int invocationChunkRadius = componentReach > 0 + ? Math.ceilDiv(componentReach, 16) + : 0; + int componentInputRadius = component.getInputRadius(x, z, invocationChunkRadius, context); + passAccessInputRadius = Math.max(passAccessInputRadius, componentInputRadius); + if (!component.isInputGenerationLazy()) { + passGenerationInputRadius = Math.max(passGenerationInputRadius, componentInputRadius); + } + } + int accessInvocationRadius = accessDownstreamBlockRadius + passBlockRadius; + int generationInvocationRadius = generationDownstreamBlockRadius + passBlockRadius; + int passChunkRadius = generationInvocationRadius > 0 ? Math.ceilDiv(generationInvocationRadius, 16) : 0; + plans[passIndex] = new MatterPassPlan(pass, passChunkRadius, generationDownstreamBlockRadius); + accessDownstreamBlockRadius = accessInvocationRadius + passAccessInputRadius; + generationDownstreamBlockRadius = generationInvocationRadius + passGenerationInputRadius; + } + int writerChunkRadius = accessDownstreamBlockRadius > 0 + ? Math.ceilDiv(accessDownstreamBlockRadius, 16) + : 0; + return new MatterGenerationPlan(plans, writerChunkRadius * 2); + } + private MatterComponentTask runComponentAsync( MantleChunk chunk, MantleComponent component, @@ -217,11 +272,21 @@ public interface MatterGenerator { try { if (DISPATCHER.ownsCurrentThread()) { - completeComponentTask(future, key, chunk, component, writer, chunkX, chunkZ, context); + completeComponentTask(future, key, chunk, component, writer, chunkX, chunkZ, context, IrisContext.get()); return new MatterComponentTask(key, future, null); } - Future submission = DISPATCHER.submit(() -> completeComponentTask(future, key, chunk, component, writer, chunkX, chunkZ, context)); + IrisContext callerContext = IrisContext.get(); + Future submission = DISPATCHER.submit(() -> completeComponentTask( + future, + key, + chunk, + component, + writer, + chunkX, + chunkZ, + context, + callerContext)); return new MatterComponentTask(key, future, submission); } catch (Throwable throwable) { IN_FLIGHT_COMPONENTS.remove(key, future); @@ -329,10 +394,11 @@ public interface MatterGenerator { MantleWriter writer, int chunkX, int chunkZ, - ChunkContext context + ChunkContext context, + IrisContext callerContext ) { try { - runComponentInline(chunk, component, writer, chunkX, chunkZ, context); + runComponentWithContext(chunk, component, writer, chunkX, chunkZ, context, callerContext); future.complete(null); } catch (Throwable throwable) { future.completeExceptionally(throwable); @@ -342,6 +408,28 @@ public interface MatterGenerator { } } + private void runComponentWithContext( + MantleChunk chunk, + MantleComponent component, + MantleWriter writer, + int chunkX, + int chunkZ, + ChunkContext context, + IrisContext callerContext + ) { + if (callerContext == null) { + runComponentInline(chunk, component, writer, chunkX, chunkZ, context); + return; + } + + try (IrisContext.Scope ignored = IrisContext.open( + callerContext.getEngine(), + callerContext.getGenerationSessionId(), + callerContext.getChunkContext())) { + runComponentInline(chunk, component, writer, chunkX, chunkZ, context); + } + } + private void runComponentInline( MantleChunk chunk, MantleComponent component, @@ -360,6 +448,12 @@ public interface MatterGenerator { record MatterComponentTask(MatterTaskKey key, CompletableFuture future, Future submission) { } + record MatterPassPlan(MantlePass pass, int passChunkRadius, int downstreamBlockRadius) { + } + + record MatterGenerationPlan(MatterPassPlan[] passPlans, int writerAccessRadius) { + } + final class MatterTaskKey { private final Mantle mantle; private final int chunkX; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java index c83dc6bc7..912663a19 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java @@ -27,6 +27,7 @@ final class CaveCarveScratch { final int[] fluidMaxY = new int[256]; final int[] surfaceBreakFloorY = new int[256]; final boolean[] surfaceBreakColumn = new boolean[256]; + final boolean[] surfaceCeilingColumn = new boolean[256]; final double[] columnThreshold = new double[256]; final double[] passThreshold = new double[256]; final double[] fullWeights = new double[256]; @@ -62,6 +63,7 @@ final class CaveCarveScratch { int activeModulesY = Integer.MIN_VALUE; int activeModuleCount; double[] verticalEdgeFade = new double[0]; + double[] surfaceClosureThreshold = new double[0]; MatterCavern[] matterByY = new MatterCavern[0]; Matter[] sectionMatter = new Matter[0]; MatterSlice[] sectionSlices = new MatterSlice[0]; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java index 5a7f9a8c6..70c765349 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java @@ -20,13 +20,15 @@ final class ConfiguredRiverGrottoShape implements RiverCaveGrottoShape { private final CNG warpY; private final CNG warpZ; private final double warpStrength; + private final double boundaryVariation; ConfiguredRiverGrottoShape( long seed, IrisData data, IrisGeneratorStyle shapeStyle, IrisGeneratorStyle warpStyle, - double warpStrength + double warpStrength, + double boundaryVariation ) { IrisGeneratorStyle resolvedShape = shapeStyle == null ? new IrisGeneratorStyle(NoiseStyle.FLAT) @@ -39,6 +41,7 @@ final class ConfiguredRiverGrottoShape implements RiverCaveGrottoShape { warpY = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Y_SALT), data); warpZ = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Z_SALT), data); this.warpStrength = Math.max(0D, warpStrength); + this.boundaryVariation = Math.max(0D, Math.min(0.75D, boundaryVariation)); } @Override @@ -60,7 +63,7 @@ final class ConfiguredRiverGrottoShape implements RiverCaveGrottoShape { double normalized = (warpedX * warpedX / (horizontalRadius * horizontalRadius)) + (warpedY * warpedY / (verticalRadius * verticalRadius)) + (warpedZ * warpedZ / (horizontalRadius * horizontalRadius)); - double boundary = shape.fitDouble(-0.2D, 0.2D, worldX, worldY, worldZ); + double boundary = shape.fitDouble(-boundaryVariation, boundaryVariation, worldX, worldY, worldZ); return normalized <= 1D + boundary; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index bebd01f88..880c9a432 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -48,6 +48,8 @@ public class IrisCaveCarver3D { private static final int ADAPTIVE_DEEP_SAMPLE_STEP = 8; private static final double ADAPTIVE_LOCAL_RANGE_SCALE = 0.125D; private static final double ADAPTIVE_DEEP_MARGIN_BOOST = 0.015D; + private static final int SURFACE_CEILING_FADE_DEPTH = 12; + private static final double SURFACE_CEILING_SOLID_EPSILON = 0.000001D; private final Engine engine; private final IrisData data; @@ -250,9 +252,11 @@ public class IrisCaveCarver3D { int[] fluidMaxY = scratch.fluidMaxY; int[] surfaceBreakFloorY = scratch.surfaceBreakFloorY; boolean[] surfaceBreakColumn = scratch.surfaceBreakColumn; + boolean[] surfaceCeilingColumn = scratch.surfaceCeilingColumn; double[] columnThreshold = scratch.columnThreshold; double[] clampedWeights = scratch.clampedColumnWeights; double[] verticalEdgeFade = prepareVerticalEdgeFadeTable(scratch, minY, maxY); + prepareSurfaceClosureThresholdTable(scratch, minY, maxY); MatterCavern[] matterByY = prepareMatterByYTable(scratch, minY, maxY); prepareSectionCaches(scratch, minY, maxY); @@ -284,7 +288,8 @@ public class IrisCaveCarver3D { } else { columnSurfaceY = engine.getHeight(x, z); } - int clearanceTopY = Math.min(maxY, Math.max(minY, columnSurfaceY - surfaceClearance)); + int unclampedClearanceTopY = columnSurfaceY - surfaceClearance; + int clearanceTopY = Math.min(maxY, Math.max(minY, unclampedClearanceTopY)); boolean breakColumn = allowSurfaceBreak && surfaceBreakDensity.noiseFastSigned2D(x, z) >= surfaceBreakNoiseThreshold; int columnTopY = breakColumn @@ -297,6 +302,7 @@ public class IrisCaveCarver3D { : Integer.MIN_VALUE; surfaceBreakFloorY[index] = Math.max(minY, columnSurfaceY - surfaceBreakDepth); surfaceBreakColumn[index] = breakColumn; + surfaceCeilingColumn[index] = !breakColumn && unclampedClearanceTopY <= maxY; columnThreshold[index] = (thresholdDensity == null ? constantThreshold : thresholdDensity.fitDouble(thresholdMin, thresholdMax, x, z)) - thresholdBias; @@ -487,6 +493,7 @@ public class IrisCaveCarver3D { localThreshold += surfaceBreakThresholdBoost; } localThreshold -= verticalEdgeFade[y - minY]; + localThreshold = applySurfaceCeilingFade(scratch, localThreshold, columnIndex, y, minY); planeThresholdLimit[planeCount] = localThreshold * normalizationFactor; planeCount++; } @@ -514,7 +521,7 @@ public class IrisCaveCarver3D { } double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, columnIndex, fluidMaxY, localThreshold); writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; @@ -531,7 +538,7 @@ public class IrisCaveCarver3D { int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, columnIndex, fluidMaxY, localThreshold); writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; @@ -624,6 +631,7 @@ public class IrisCaveCarver3D { localThreshold += surfaceBreakThresholdBoost; } localThreshold -= verticalEdgeFade[y - minY]; + localThreshold = applySurfaceCeilingFade(scratch, localThreshold, columnIndex, y, minY); planeThresholdLimit[planeCount] = localThreshold * normalizationFactor; planeCount++; } @@ -662,7 +670,7 @@ public class IrisCaveCarver3D { } double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, columnIndex, fluidMaxY, localThreshold); writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; @@ -679,7 +687,7 @@ public class IrisCaveCarver3D { int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, columnIndex, fluidMaxY, localThreshold); writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; @@ -822,6 +830,7 @@ public class IrisCaveCarver3D { localThreshold += surfaceBreakThresholdBoost; } localThreshold -= verticalEdgeFade[fadeIndex]; + localThreshold = applySurfaceCeilingFade(scratch, localThreshold, index, yy, minY); if (density > localThreshold) { continue; } @@ -830,7 +839,7 @@ public class IrisCaveCarver3D { int localZ = tileLocalZ[columnIndex]; int worldX = x0 + localX; int worldZ = z0 + localZ; - MatterCavern matter = resolveMatter(verticalMatter, worldX, yy, worldZ, + MatterCavern matter = resolveMatter(scratch, verticalMatter, worldX, yy, worldZ, index, fluidMaxY, localThreshold); if (skipExistingCarved) { if (cavernSlice.get(localX, localY, localZ) == null) { @@ -897,23 +906,23 @@ public class IrisCaveCarver3D { double threshold = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); for (int y = minY; y <= columnTopY; y += sampleStep) { - double localThreshold = threshold; - if (breakColumn && y >= breakFloorY) { - localThreshold += surfaceBreakThresholdBoost; - } - - localThreshold -= verticalEdgeFade[y - minY]; - if (sampleDensityOptimized(scratch, x, y, z) > localThreshold) { - continue; - } - + double density = sampleDensityOptimized(scratch, x, y, z); int carveMaxY = Math.min(columnTopY, y + sampleStep - 1); for (int yy = y; yy <= carveMaxY; yy++) { if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, index, yy)) { continue; } + double localThreshold = threshold; + if (breakColumn && yy >= breakFloorY) { + localThreshold += surfaceBreakThresholdBoost; + } + localThreshold -= verticalEdgeFade[yy - minY]; + localThreshold = applySurfaceCeilingFade(scratch, localThreshold, index, yy, minY); + if (density > localThreshold) { + continue; + } MatterCavern verticalMatter = matterByY[yy - minY]; - MatterCavern matter = resolveMatter(verticalMatter, x, yy, z, + MatterCavern matter = resolveMatter(scratch, verticalMatter, x, yy, z, index, fluidMaxY, localThreshold); MatterSlice cavernSlice = resolveCavernSlice(scratch, chunk, PowerOfTwoCoordinates.floorDivPow2(yy, 4)); int localY = yy & 15; @@ -2191,57 +2200,61 @@ public class IrisCaveCarver3D { return matterByY; } - private MatterCavern resolveMatter(MatterCavern verticalMatter, int x, int y, int z, + private MatterCavern resolveMatter(CaveCarveScratch scratch, MatterCavern verticalMatter, int x, int y, int z, int columnIndex, int[] fluidMaxY, double localThreshold) { if (verticalMatter != carveLava && y <= fluidMaxY[columnIndex] - && isAquiferCandidate(x, y, z, localThreshold)) { + && isAquiferCandidate(scratch, x, y, z, localThreshold)) { return carveFluid; } return verticalMatter; } - private boolean isAquiferCandidate(int x, int y, int z, double localThreshold) { + private boolean isAquiferCandidate(CaveCarveScratch scratch, int x, int y, int z, double localThreshold) { double depthFactor = Math.max(0D, Math.min(1.5D, (fluidHeight - y) / 48D)); double cutoff = 0.35D + (depthFactor * 0.2D); if (detailDensity.noiseFastSigned3D(x, y * 0.5D, z) <= cutoff) { return false; } - return !fluidRequiresFloor || hasAquiferCupSupport(x, y, z, localThreshold); + return !fluidRequiresFloor || hasAquiferCupSupport(scratch, x, y, z, localThreshold); } - private boolean hasAquiferCupSupport(int x, int y, int z, double threshold) { + private boolean isAquiferCandidate(int x, int y, int z, double localThreshold) { + return isAquiferCandidate(scratchCache.get(), x, y, z, localThreshold); + } + + private boolean hasAquiferCupSupport(CaveCarveScratch scratch, int x, int y, int z, double threshold) { int floorY = Math.max(0, y - 1); int deepFloorY = Math.max(0, y - 2); int aboveY = Math.min(aquiferCeilingY, y + 1); - if (!isDensitySolid(x, floorY, z, threshold)) { + if (!isDensitySolid(scratch, x, floorY, z, threshold)) { return false; } - if (!isDensitySolid(x, deepFloorY, z, threshold - 0.05D)) { + if (!isDensitySolid(scratch, x, deepFloorY, z, threshold - 0.05D)) { return false; } int support = 0; - if (isDensitySolid(x + 1, y, z, threshold)) { + if (isDensitySolid(scratch, x + 1, y, z, threshold)) { support++; } - if (isDensitySolid(x - 1, y, z, threshold)) { + if (isDensitySolid(scratch, x - 1, y, z, threshold)) { support++; } - if (isDensitySolid(x, y, z + 1, threshold)) { + if (isDensitySolid(scratch, x, y, z + 1, threshold)) { support++; } - if (isDensitySolid(x, y, z - 1, threshold)) { + if (isDensitySolid(scratch, x, y, z - 1, threshold)) { support++; } - if (isDensitySolid(x, aboveY, z, threshold)) { + if (isDensitySolid(scratch, x, aboveY, z, threshold)) { support++; } return support >= 4; } - private boolean isDensitySolid(int x, int y, int z, double threshold) { - return sampleDensityOptimized(x, y, z) > threshold; + private boolean isDensitySolid(CaveCarveScratch scratch, int x, int y, int z, double threshold) { + return sampleDensityOptimized(scratch, x, y, z) > threshold; } private void writeCavern(MatterSlice cavernSlice, int localX, int y, int localZ, @@ -2288,6 +2301,52 @@ public class IrisCaveCarver3D { return (value * 2D) - 1D; } + private double applySurfaceCeilingFade( + CaveCarveScratch scratch, + double threshold, + int columnIndex, + int y, + int minY + ) { + if (!scratch.surfaceCeilingColumn[columnIndex]) { + return threshold; + } + + int ceilingDistance = scratch.columnMaxY[columnIndex] - y; + if (ceilingDistance < 0 || ceilingDistance >= SURFACE_CEILING_FADE_DEPTH) { + return threshold; + } + + double closureThreshold = scratch.surfaceClosureThreshold[y - minY]; + if (threshold <= closureThreshold) { + return threshold; + } + + double progress = ceilingDistance / (double) SURFACE_CEILING_FADE_DEPTH; + double smooth = progress * progress * (3D - (2D * progress)); + return closureThreshold + ((threshold - closureThreshold) * smooth); + } + + private void prepareSurfaceClosureThresholdTable(CaveCarveScratch scratch, int minY, int maxY) { + int size = Math.max(0, maxY - minY + 1); + if (scratch.surfaceClosureThreshold.length < size) { + scratch.surfaceClosureThreshold = new double[size]; + } + + double baseMinimum = -Math.abs(baseWeight) - Math.abs(detailWeight); + for (int y = minY; y <= maxY; y++) { + double minimumDensity = baseMinimum; + for (CaveFieldModuleState module : modules) { + if (y < module.minY || y > module.maxY) { + continue; + } + minimumDensity += Math.min(module.minContribution, module.maxContribution); + } + scratch.surfaceClosureThreshold[y - minY] = + (minimumDensity * inverseNormalization) - SURFACE_CEILING_SOLID_EPSILON; + } + } + private double[] prepareVerticalEdgeFadeTable(CaveCarveScratch scratch, int minY, int maxY) { int size = Math.max(0, maxY - minY + 1); if (scratch.verticalEdgeFade.length < size) { 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 4313d0343..44ab58f22 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 @@ -474,7 +474,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { private void prefillProfileFieldSamples(int startX, int startZ, IrisComplex complex, BlendScratch blendScratch) { fillFieldHeights(complex.getHeightStream(), startX, startZ, blendScratch.fieldSurfaceHeights); fillFieldHeights(complex.getRiverWaterSurfaceStream(), startX, startZ, blendScratch.fieldFluidHeights); - fillFieldFluidPresence(complex.getFluidStream(), startX, startZ, blendScratch.fieldSurfaceHeights, + fillFieldFluidPresence(complex, startX, startZ, blendScratch.fieldSurfaceHeights, blendScratch.fieldFluidHeights, blendScratch.fieldHasFluid); fillFieldObjects(complex.getRegionStream(), startX, startZ, blendScratch.fieldRegions); fillFieldObjects(complex.getTrueBiomeStream(), startX, startZ, blendScratch.fieldSurfaceBiomes); @@ -500,7 +500,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { } private void fillFieldFluidPresence( - ProceduralStream stream, + IrisComplex complex, int startX, int startZ, double[] surfaceHeights, @@ -511,7 +511,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { int worldX = startX + fieldX; for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) { int fieldIndex = (fieldX * FIELD_SIZE) + fieldZ; - target[fieldIndex] = B.isFluid(stream.get(worldX, startZ + fieldZ)) + target[fieldIndex] = B.isFluid(complex.resolveSurfaceFluid(worldX, startZ + fieldZ)) && Math.round(surfaceHeights[fieldIndex]) < Math.round(fluidHeights[fieldIndex]); } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java index a151d0345..68ffda007 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java @@ -3,7 +3,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.river.cave.CavePosition; import art.arcane.iris.engine.river.cave.CaveVoxel; import art.arcane.iris.engine.river.cave.CaveVoxelView; -import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.object.IrisProceduralBlocks; @@ -15,8 +15,10 @@ import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; import java.util.Objects; +import java.util.function.BiConsumer; final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { private static final int CLOSED_COLUMN = Integer.MAX_VALUE; @@ -26,6 +28,9 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu private final int worldHeight; private final Function2 surfaceHeight; private final Function2 compatibleFluid; + private final RiverCaveFluidKind planningFluidKind; + private final BiConsumer chunkLoader; + private final LongOpenHashSet loadedChunks; private final Long2IntOpenHashMap openFloorCache; private final Long2IntOpenHashMap surfaceHeightCache; @@ -33,12 +38,17 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu Mantle mantle, int worldHeight, Function2 surfaceHeight, - Function2 compatibleFluid + Function2 compatibleFluid, + RiverCaveFluidKind planningFluidKind, + BiConsumer chunkLoader ) { this.mantle = Objects.requireNonNull(mantle); this.worldHeight = worldHeight; this.surfaceHeight = Objects.requireNonNull(surfaceHeight); this.compatibleFluid = Objects.requireNonNull(compatibleFluid); + this.planningFluidKind = Objects.requireNonNull(planningFluidKind); + this.chunkLoader = Objects.requireNonNull(chunkLoader); + loadedChunks = new LongOpenHashSet(); openFloorCache = new Long2IntOpenHashMap(); openFloorCache.defaultReturnValue(CACHE_MISS); surfaceHeightCache = new Long2IntOpenHashMap(); @@ -52,10 +62,18 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu @Override public CaveVoxel voxelAt(CavePosition position) { + RiverCaveHydrology hydrology = dataIfPresent(position, RiverCaveHydrology.class); + if (hydrology != null && hydrology.fluidKind() != planningFluidKind) { + return CaveVoxel.INCOMPATIBLE_FLUID; + } MatterCavern cavern = dataIfPresent(position, MatterCavern.class); if (cavern != null) { if (cavern.isLava()) { - return CaveVoxel.LAVA; + PlatformBlockState expected = compatibleFluid.apply(position.x(), position.z()); + return expected != null + && IrisProceduralBlocks.materialKey(expected).endsWith(":lava") + ? CaveVoxel.COMPATIBLE_FLUID + : CaveVoxel.LAVA; } if (cavern.getLiquid() == 1) { return CaveVoxel.COMPATIBLE_FLUID; @@ -71,13 +89,13 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu if (!block.isFluid()) { return CaveVoxel.SOLID; } - if (IrisProceduralBlocks.materialKey(block).endsWith(":lava")) { - return CaveVoxel.LAVA; - } PlatformBlockState expected = compatibleFluid.apply(position.x(), position.z()); - return expected != null - && IrisProceduralBlocks.materialKey(expected).equals(IrisProceduralBlocks.materialKey(block)) - ? CaveVoxel.COMPATIBLE_FLUID + if (expected != null + && IrisProceduralBlocks.materialKey(expected).equals(IrisProceduralBlocks.materialKey(block))) { + return CaveVoxel.COMPATIBLE_FLUID; + } + return IrisProceduralBlocks.materialKey(block).endsWith(":lava") + ? CaveVoxel.LAVA : CaveVoxel.INCOMPATIBLE_FLUID; } @@ -99,9 +117,8 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu } @Override - public RiverCaveAction riverActionAt(CavePosition position) { - RiverCaveHydrology hydrology = dataIfPresent(position, RiverCaveHydrology.class); - return hydrology == null ? null : hydrology.action(); + public RiverCaveHydrology riverHydrologyAt(CavePosition position) { + return dataIfPresent(position, RiverCaveHydrology.class); } private int resolveOpenFloor(int x, int z) { @@ -131,6 +148,10 @@ final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.Tu private T dataIfPresent(CavePosition position, Class type) { int chunkX = position.x() >> 4; int chunkZ = position.z() >> 4; + long chunkKey = Mantle.key(chunkX, chunkZ); + if (loadedChunks.add(chunkKey)) { + chunkLoader.accept(chunkX, chunkZ); + } TectonicPlate plate = mantle.getLoadedRegions().get(Mantle.key(chunkX >> 5, chunkZ >> 5)); if (plate == null || plate.isClosed()) { return null; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java index 736ed1ab4..b85b60a44 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java @@ -1,17 +1,21 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; +import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.object.IrisRiverCaveFallback; import art.arcane.iris.engine.object.IrisRiverCaveMode; import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverDeepPools; import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRiverNetwork; import art.arcane.iris.engine.river.RiverAnchor; +import art.arcane.iris.engine.river.RiverNetwork; import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.river.RiverSample; import art.arcane.iris.engine.river.RiverSection; @@ -22,6 +26,7 @@ import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; import art.arcane.iris.engine.river.cave.CaveVoxelView; import art.arcane.iris.engine.river.cave.RiverCaveAction; import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.engine.river.cave.RiverCaveMode; @@ -35,8 +40,13 @@ import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.matter.Matter; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; @@ -47,6 +57,8 @@ import java.util.Set; @ComponentFlag(ReservedFlag.RIVER_HYDROLOGY) public final class MantleRiverHydrologyComponent extends IrisMantleComponent { private static final long CANDIDATE_SALT = 0x6A09E667F3BCC909L; + private static final long DEEP_POOL_CANDIDATE_SALT = 0xBB67AE8584CAA73BL; + private static final long DEEP_POOL_POSITION_SALT = 0x3C6EF372FE94F82BL; static final int PRIORITY = 1; private static final int[] FALLBACK_X = {0, 1, -1, 0, 0}; private static final int[] FALLBACK_Z = {0, 0, 0, 1, -1}; @@ -73,6 +85,11 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { return PREREQUISITES; } + @Override + public boolean isInputGenerationLazy() { + return true; + } + @Override public int getInputRadius() { if (!getDimension().isCarvingEnabled() @@ -87,6 +104,122 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { return inputRadius(runtime.caveSettings(), tunnelHalo(runtime)); } + @Override + public int getInputRadius( + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius, + ChunkContext context + ) { + if (!getDimension().isCarvingEnabled() + || getDimension().getRivers() == null + || !getDimension().getRivers().isEnabled()) { + return 0; + } + if (context == null || context.getComplex() == null) { + return getInputRadius(); + } + IrisRiverRuntime runtime = context.getComplex().getRiverRuntime(); + if (runtime == null) { + return getInputRadius(); + } + IrisRiverCaves caves = runtime.caveSettings(); + int tunnelRadius = tunnelHalo(runtime); + int radius = hasRiverFootprint( + runtime, + targetChunkX, + targetChunkZ, + invocationChunkRadius, + tunnelRadius + ) ? tunnelRadius : 0; + if (caves.getMode() != IrisRiverCaveMode.SEALED + && caves.getMaximumPerReach() > 0) { + radius = Math.max(radius, hasAcceptedCaveAnchor( + runtime, + caves, + targetChunkX, + targetChunkZ, + invocationChunkRadius + ) ? planningHalo(caves) : 0); + } + IrisRiverDeepPools deepPools = caves.getDeepPools(); + if (deepPools != null + && deepPools.isEnabled() + && deepPools.getMaximumPerReach() > 0) { + radius = Math.max(radius, hasAcceptedDeepPoolAnchor( + runtime, + deepPools, + targetChunkX, + targetChunkZ, + invocationChunkRadius + ) ? deepPoolPlanningHalo(deepPools) : 0); + } + return radius; + } + + private static boolean hasRiverFootprint( + IrisRiverRuntime runtime, + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius, + int tunnelRadius + ) { + return runtime.hasRiverFootprint( + ((targetChunkX - invocationChunkRadius) << 4) - tunnelRadius, + ((targetChunkZ - invocationChunkRadius) << 4) - tunnelRadius, + ((targetChunkX + invocationChunkRadius + 1) << 4) + tunnelRadius, + ((targetChunkZ + invocationChunkRadius + 1) << 4) + tunnelRadius + ); + } + + private static boolean hasAcceptedCaveAnchor( + IrisRiverRuntime runtime, + IrisRiverCaves caves, + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius + ) { + int candidateHalo = candidateHalo(caves); + List anchors = runtime.candidateAnchors( + ((targetChunkX - invocationChunkRadius) << 4) - candidateHalo, + ((targetChunkZ - invocationChunkRadius) << 4) - candidateHalo, + ((targetChunkX + invocationChunkRadius + 1) << 4) + candidateHalo, + ((targetChunkZ + invocationChunkRadius + 1) << 4) + candidateHalo, + caves.getMinimumSpacing(), + CANDIDATE_SALT + ); + for (RiverAnchor anchor : anchors) { + if (runtime.acceptsCaveAnchor(anchor)) { + return true; + } + } + return false; + } + + private static boolean hasAcceptedDeepPoolAnchor( + IrisRiverRuntime runtime, + IrisRiverDeepPools deepPools, + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius + ) { + int candidateHalo = deepPoolCandidateHalo(deepPools); + List anchors = runtime.candidateAnchors( + ((targetChunkX - invocationChunkRadius) << 4) - candidateHalo, + ((targetChunkZ - invocationChunkRadius) << 4) - candidateHalo, + ((targetChunkX + invocationChunkRadius + 1) << 4) + candidateHalo, + ((targetChunkZ + invocationChunkRadius + 1) << 4) + candidateHalo, + deepPools.getMinimumSpacing(), + DEEP_POOL_CANDIDATE_SALT + ); + for (RiverAnchor anchor : anchors) { + if (runtime.acceptsDeepPoolAnchor(anchor)) { + return true; + } + } + return false; + } + @Override public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { IrisRiverRuntime runtime = context.getComplex().getRiverRuntime(); @@ -96,11 +229,21 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { publishTunnels(writer, context, runtime, chunkX, chunkZ); IrisRiverCaves caves = runtime.caveSettings(); - if (caves.getMode() == IrisRiverCaveMode.SEALED || caves.getMaximumPerReach() <= 0) { - return; + if (caves.getMode() != IrisRiverCaveMode.SEALED && caves.getMaximumPerReach() > 0) { + publishCaveConnections(writer, context, runtime, caves, chunkX, chunkZ); } + publishDeepPools(writer, context, runtime, caves.getDeepPools(), chunkX, chunkZ); + } - MantleRiverCaveVoxelView view = createView(writer, context); + private void publishCaveConnections( + MantleWriter writer, + ChunkContext context, + IrisRiverRuntime runtime, + IrisRiverCaves caves, + int chunkX, + int chunkZ + ) { + MantleRiverCaveVoxelView view = createView(writer, context, RiverCaveFluidKind.RIVER); int candidateHalo = candidateHalo(caves); int minimumX = (chunkX << 4) - candidateHalo; int minimumZ = (chunkZ << 4) - candidateHalo; @@ -146,11 +289,202 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { } RiverCavePlanningResult result = planner.planAll(view, sources, settings); - MantleRiverCaveVoxelView revalidationView = createView(writer, context); + MantleRiverCaveVoxelView revalidationView = createView( + writer, + context, + RiverCaveFluidKind.RIVER + ); if (!preconditionsHold(revalidationView, result.baselinePreconditions())) { return; } - publishLocal(writer, chunkX, chunkZ, result, floodedBiomes); + publishLocal( + writer, + chunkX, + chunkZ, + result, + floodedBiomes, + RiverCaveFluidKind.RIVER + ); + } + + private void publishDeepPools( + MantleWriter writer, + ChunkContext context, + IrisRiverRuntime runtime, + IrisRiverDeepPools deepPools, + int chunkX, + int chunkZ + ) { + if (deepPools == null || !deepPools.isEnabled() || deepPools.getMaximumPerReach() <= 0) { + return; + } + MantleRiverCaveVoxelView view = createView( + writer, + context, + RiverCaveFluidKind.DEEP_POOL + ); + int candidateHalo = deepPoolCandidateHalo(deepPools); + int minimumX = (chunkX << 4) - candidateHalo; + int minimumZ = (chunkZ << 4) - candidateHalo; + int maximumX = ((chunkX + 1) << 4) + candidateHalo; + int maximumZ = ((chunkZ + 1) << 4) + candidateHalo; + List anchors = runtime.candidateAnchors( + minimumX, + minimumZ, + maximumX, + maximumZ, + deepPools.getMinimumSpacing(), + DEEP_POOL_CANDIDATE_SALT + ); + if (anchors.isEmpty()) { + return; + } + + RiverCavePlannerSettings settings = deepPoolPlannerSettings(deepPools, seed(), getData()); + List sources = new ArrayList<>(); + Map floodedBiomes = new HashMap<>(); + for (RiverAnchor anchor : anchors) { + if (!runtime.acceptsDeepPoolAnchor(anchor)) { + continue; + } + RiverCaveSource source = deepPoolSourceFor( + view, + deepPools, + anchor, + getDimension().getMinHeight(), + seed() + ); + if (source == null) { + continue; + } + sources.add(source); + floodedBiomes.put(source.sourceId(), runtime.selectFloodedCaveBiome(anchor)); + } + if (sources.isEmpty()) { + return; + } + + RiverCavePlanningResult result = planner.planAll(view, sources, settings); + MantleRiverCaveVoxelView revalidationView = createView( + writer, + context, + RiverCaveFluidKind.DEEP_POOL + ); + if (!preconditionsHold(revalidationView, result.baselinePreconditions())) { + return; + } + publishLocal( + writer, + chunkX, + chunkZ, + result, + floodedBiomes, + RiverCaveFluidKind.DEEP_POOL + ); + } + + static RiverCavePlannerSettings deepPoolPlannerSettings( + IrisRiverDeepPools deepPools, + long seed, + IrisData data + ) { + int verticalDepth = deepPools.getVerticalRadius() * 2 - deepPools.getDryHeadroom() + 1; + int proofRadius = (int) StrictMath.ceil( + StrictMath.sqrt(2D) * deepPools.getHorizontalRadius() + ) + 1; + return new RiverCavePlannerSettings( + proofRadius, + verticalDepth, + deepPools.getMaximumVolume(), + deepPools.getVerticalRadius(), + 1, + deepPools.getHorizontalRadius(), + deepPools.getVerticalRadius(), + deepPools.getDryHeadroom(), + RiverCaveFluidPolicy.REJECT_EXISTING, + new ConfiguredRiverGrottoShape( + seed ^ DEEP_POOL_POSITION_SALT, + data, + deepPools.getShapeStyle(), + deepPools.getWarpStyle(), + deepPools.getWarpStrength(), + deepPools.getShapeVariation() + ), + proofRadius, + verticalDepth + ); + } + + static RiverCaveSource deepPoolSourceFor( + CaveVoxelView view, + IrisRiverDeepPools deepPools, + RiverAnchor anchor, + int worldMinimumY, + long seed + ) { + int minimumHead = deepPools.getMinimumFluidY() - worldMinimumY; + int maximumHead = deepPools.getMaximumFluidY() - worldMinimumY; + int headRange = maximumHead - minimumHead + 1; + if (headRange <= 0) { + return null; + } + int searchRadius = deepPools.getSearchRadius(); + int searchWidth = searchRadius * 2 + 1; + int targetDepth = Math.max( + 1, + deepPools.getVerticalRadius() - deepPools.getDryHeadroom() + ); + for (int attempt = 0; attempt < deepPools.getSearchAttempts(); attempt++) { + long hash = RiverNetwork.mix( + seed + ^ anchor.stableId() + ^ DEEP_POOL_POSITION_SALT + ^ (long) attempt * 0x9E3779B97F4A7C15L + ); + int offsetX = searchRadius == 0 + ? 0 + : Math.floorMod((int) hash, searchWidth) - searchRadius; + int offsetZ = searchRadius == 0 + ? 0 + : Math.floorMod((int) (hash >>> 32), searchWidth) - searchRadius; + if ((long) offsetX * offsetX + (long) offsetZ * offsetZ + > (long) searchRadius * searchRadius) { + continue; + } + int x = (int) StrictMath.floor(anchor.x()) + offsetX; + int z = (int) StrictMath.floor(anchor.z()) + offsetZ; + int startOffset = (int) StrictMath.floor(unit(hash) * headRange); + for (int scanned = 0; scanned < headRange; scanned++) { + int headY = maximumHead - Math.floorMod(startOffset + scanned, headRange); + CavePosition floor = new CavePosition(x, headY, z); + CavePosition above = new CavePosition(x, headY + 1, z); + CavePosition target = new CavePosition(x, headY - targetDepth, z); + if (!view.isInWorld(target) + || !view.isInWorld(above) + || view.voxelAt(floor) != CaveVoxel.SOLID + || view.voxelAt(above) != CaveVoxel.CAVE_AIR + || view.isOpenToSurface(above)) { + continue; + } + long sourceId = RiverNetwork.mix( + anchor.stableId() + ^ BlockPosition.toLong(x, headY, z) + ^ DEEP_POOL_POSITION_SALT + ); + return new RiverCaveSource( + sourceId, + floor, + target, + headY, + RiverCaveMode.DEEP_POOL + ); + } + } + return null; + } + + private static double unit(long hash) { + return (hash >>> 11) * 0x1.0p-53; } @Override @@ -176,9 +510,15 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { return false; } IrisRiverCaves caves = dimension.getRivers().getCaves(); - return caves != null - && caves.getMode() != IrisRiverCaveMode.SEALED + if (caves == null) { + return false; + } + boolean caveConnections = caves.getMode() != IrisRiverCaveMode.SEALED && caves.getMaximumPerReach() > 0; + IrisRiverDeepPools deepPools = caves.getDeepPools(); + return caveConnections || deepPools != null + && deepPools.isEnabled() + && deepPools.getMaximumPerReach() > 0; } static int planningHalo(IrisRiverCaves caves) { @@ -186,16 +526,33 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { } static int inputRadius(IrisRiverCaves caves, int tunnelRadius) { - if (caves.getMode() == IrisRiverCaveMode.SEALED || caves.getMaximumPerReach() <= 0) { - return tunnelRadius; + int radius = tunnelRadius; + if (caves.getMode() != IrisRiverCaveMode.SEALED && caves.getMaximumPerReach() > 0) { + radius = Math.max(radius, planningHalo(caves)); } - return Math.max(tunnelRadius, planningHalo(caves)); + IrisRiverDeepPools deepPools = caves.getDeepPools(); + if (deepPools != null && deepPools.isEnabled() && deepPools.getMaximumPerReach() > 0) { + radius = Math.max(radius, deepPoolPlanningHalo(deepPools)); + } + return radius; } static int candidateHalo(IrisRiverCaves caves) { return cavePublicationRadius(caves) * 3; } + static int deepPoolPlanningHalo(IrisRiverDeepPools deepPools) { + return deepPoolPublicationRadius(deepPools) * 4; + } + + static int deepPoolCandidateHalo(IrisRiverDeepPools deepPools) { + return deepPoolPublicationRadius(deepPools) * 3; + } + + static int deepPoolPublicationRadius(IrisRiverDeepPools deepPools) { + return deepPools.getSearchRadius() + deepPools.getHorizontalRadius() + 1; + } + static int cavePublicationRadius(IrisRiverCaves caves) { int generatedRadius = generatedGrottoPublicationRadius(caves); return switch (caves.getMode()) { @@ -296,13 +653,13 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { boolean changed; do { changed = false; - Set candidateActions = mergeActions(containedColumns).keySet(); + Long2ObjectOpenHashMap candidateColumns = indexColumns(containedColumns); for (int index = containedColumns.size() - 1; index >= 0; index--) { TunnelColumn column = containedColumns.get(index); if (!isTunnelColumnContained( view, column, - candidateActions, + candidateColumns, dryHeadroom, surfaceSampler )) { @@ -348,7 +705,10 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { )); } } - return new TunnelPlan(Map.copyOf(actions), Map.copyOf(preconditions)); + return new TunnelPlan( + Collections.unmodifiableMap(actions), + Collections.unmodifiableMap(preconditions) + ); } private static TunnelColumn createTunnelColumn( @@ -360,8 +720,9 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { if (sample == null) { return null; } - LinkedHashMap actions = new LinkedHashMap<>(); - for (int y = sample.bedY() + 1; y <= sample.ceilingY(); y++) { + int minimumY = sample.bedY() + 1; + int maximumY = sample.ceilingY(); + for (int y = minimumY; y <= maximumY; y++) { CavePosition position = new CavePosition(x, y, z); RiverCaveAction action = y <= sample.waterHeadY() ? RiverCaveAction.WET_SOURCE @@ -371,22 +732,24 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { && !matchesPublishedAction(view, position, action))) { return null; } - actions.put(position, action); } - return actions.isEmpty() ? null : new TunnelColumn(actions); + return minimumY > maximumY + ? null + : new TunnelColumn(x, z, minimumY, sample.waterHeadY(), maximumY); } private static boolean isTunnelColumnContained( CaveVoxelView view, TunnelColumn column, - Set candidateActions, + Long2ObjectOpenHashMap candidateColumns, int dryHeadroom, SurfaceSampler surfaceSampler ) { - for (CavePosition position : column.actions().keySet()) { + for (int y = column.minimumY(); y <= column.maximumY(); y++) { + CavePosition position = new CavePosition(column.x(), y, column.z()); for (int[] offset : NEIGHBORS) { CavePosition neighbor = offset(position, offset); - if (candidateActions.contains(neighbor)) { + if (containsAction(candidateColumns, neighbor)) { continue; } if (!view.isInWorld(neighbor)) { @@ -417,7 +780,9 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { SurfaceSampler surfaceSampler ) { IrisRiverSurfaceSample sample = surfaceSampler.sample(position.x(), position.z()); - if (!isWetChannelBed(sample) || sample.subterranean()) { + if (!sample.river().present() + || sample.river().state() != RiverRouteState.WET + || sample.subterranean()) { return false; } int bedY = (int) Math.round(sample.terrainHeight()); @@ -428,18 +793,48 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { private static Map mergeActions(List columns) { LinkedHashMap actions = new LinkedHashMap<>(); for (TunnelColumn column : columns) { - actions.putAll(column.actions()); + for (int y = column.minimumY(); y <= column.maximumY(); y++) { + actions.put( + new CavePosition(column.x(), y, column.z()), + y <= column.waterHeadY() + ? RiverCaveAction.WET_SOURCE + : RiverCaveAction.DRY_AIR + ); + } } return actions; } + private static Long2ObjectOpenHashMap indexColumns(List columns) { + Long2ObjectOpenHashMap indexed = new Long2ObjectOpenHashMap<>(columns.size()); + for (TunnelColumn column : columns) { + indexed.put(Cache.key(column.x(), column.z()), column); + } + return indexed; + } + + private static boolean containsAction( + Long2ObjectOpenHashMap columns, + CavePosition position + ) { + TunnelColumn column = columns.get(Cache.key(position.x(), position.z())); + return column != null + && position.y() >= column.minimumY() + && position.y() <= column.maximumY(); + } + private static boolean matchesPublishedAction( CaveVoxelView view, CavePosition position, RiverCaveAction action ) { - return view instanceof TunnelVoxelView tunnelView - && tunnelView.riverActionAt(position) == action; + if (!(view instanceof TunnelVoxelView tunnelView)) { + return false; + } + RiverCaveHydrology hydrology = tunnelView.riverHydrologyAt(position); + return hydrology != null + && hydrology.action() == action + && hydrology.fluidKind() == RiverCaveFluidKind.RIVER; } private static CavePosition offset(CavePosition position, int[] offset) { @@ -450,15 +845,39 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { ); } - private MantleRiverCaveVoxelView createView(MantleWriter writer, ChunkContext context) { + private MantleRiverCaveVoxelView createView( + MantleWriter writer, + ChunkContext context, + RiverCaveFluidKind fluidKind + ) { return new MantleRiverCaveVoxelView( writer.getMantle(), writer.getMantle().getWorldHeight(), (x, z) -> context.getComplex().getRoundedHeighteightStream().get(x, z), - (x, z) -> context.getComplex().getFluidStream().get(x, z) + (x, z) -> context.getComplex().resolveRiverCaveFluid(fluidKind, x, z), + fluidKind, + (chunkX, chunkZ) -> generateCarvingInput(writer, context, chunkX, chunkZ) ); } + private void generateCarvingInput( + MantleWriter writer, + ChunkContext context, + int chunkX, + int chunkZ + ) { + MantleComponent carving = getEngineMantle().getRegisteredComponents().get(ReservedFlag.CARVED); + if (carving == null || !carving.isEnabled()) { + throw new IllegalStateException("River hydrology requires the carving component"); + } + MantleChunk chunk = writer.acquireChunk(chunkX, chunkZ); + if (chunk == null) { + throw new IllegalStateException("River hydrology read exceeded the prepared mantle radius at " + + chunkX + "," + chunkZ); + } + chunk.raiseFlagSuspend(ReservedFlag.CARVED, () -> carving.generateLayer(writer, chunkX, chunkZ, context)); + } + private void publishTunnels( MantleWriter writer, ChunkContext context, @@ -467,7 +886,11 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { int chunkZ ) { for (int attempt = 0; attempt < 2; attempt++) { - MantleRiverCaveVoxelView view = createView(writer, context); + MantleRiverCaveVoxelView view = createView( + writer, + context, + RiverCaveFluidKind.RIVER + ); TunnelPlan plan = planTunnels( view, chunkX, @@ -478,7 +901,11 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { runtime::sampleTunnel, runtime::sample ); - MantleRiverCaveVoxelView revalidationView = createView(writer, context); + MantleRiverCaveVoxelView revalidationView = createView( + writer, + context, + RiverCaveFluidKind.RIVER + ); if (preconditionsHold(revalidationView, plan.preconditions())) { publishTunnelLocal(writer, chunkX, chunkZ, plan); return; @@ -505,7 +932,8 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { data, caves.getGrottoShapeStyle(), caves.getGrottoWarpStyle(), - caves.getGrottoWarpStrength() + caves.getGrottoWarpStrength(), + 0.2D ), caves.getMaxFloodRadius(), caves.getMaxFloodDepth() @@ -673,7 +1101,8 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { int chunkX, int chunkZ, RiverCavePlanningResult result, - Map floodedBiomes + Map floodedBiomes, + RiverCaveFluidKind fluidKind ) { Map owners = actionOwners(result); ArrayList> actions = new ArrayList<>(result.actions().entrySet()); @@ -692,7 +1121,7 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { position.x(), position.y(), position.z(), - new RiverCaveHydrology(entry.getValue(), biome) + new RiverCaveHydrology(entry.getValue(), biome, fluidKind) ); } } @@ -708,7 +1137,12 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { for (Map.Entry entry : actions) { CavePosition position = entry.getKey(); if (owns(chunkX, chunkZ, position)) { - writer.setData(position.x(), position.y(), position.z(), RiverCaveHydrology.of(entry.getValue())); + writer.setData( + position.x(), + position.y(), + position.z(), + RiverCaveHydrology.of(entry.getValue(), RiverCaveFluidKind.RIVER) + ); } } } @@ -749,7 +1183,7 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { } interface TunnelVoxelView extends CaveVoxelView { - RiverCaveAction riverActionAt(CavePosition position); + RiverCaveHydrology riverHydrologyAt(CavePosition position); } record TunnelPlan( @@ -761,6 +1195,12 @@ public final class MantleRiverHydrologyComponent extends IrisMantleComponent { } } - private record TunnelColumn(Map actions) { + private record TunnelColumn( + int x, + int z, + int minimumY, + int waterHeadY, + int maximumY + ) { } } diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index 1ed14014e..a2f1e161c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -57,7 +57,11 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { if (shouldBypassMantleStages()) { return; } - generateMatter(x >> 4, z >> 4, m, c); + generateMatter( + x >> 4, + z >> 4, + m || getEngine().isStudio(), + c); }; EngineStage sTerrain = (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c); EngineStage sDecorant = (x, z, k, p, m, c) -> decorant.actuate(x, z, k, m, c); diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 60694e3f0..489af7a6b 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -117,7 +117,9 @@ public class IrisCarveModifier extends EngineAssignedModifier carveResolver.apply( @@ -166,8 +168,6 @@ public class IrisCarveModifier extends EngineAssignedModifier fluid; - case FALLING_WATER -> fallingFluidState(fluid); + case FALLING_FLUID -> fallingFluidState(fluid); case DRY_AIR -> air; case SEAL_GUARD -> normalizeWaterlogging(current, null); }; @@ -369,9 +368,16 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, - Map customBiomeCache, - PlatformBlockState columnFluid + Map customBiomeCache ) { if (columnMask == null || columnMask.isEmpty()) { return; @@ -601,7 +608,7 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, - Map customBiomeCache, - PlatformBlockState columnFluid) { + Map customBiomeCache) { int maxY = output.getHeight(); if (zone.ceiling + 1 < maxY && B.isDecorant(output.getRaw(rx, zone.ceiling + 1, rz))) { @@ -793,7 +799,7 @@ public class IrisCarveModifier extends EngineAssignedModifier b = new VectorMap<>(); IrisPosition min = self.getAABB().min(); IrisPosition max = self.getAABB().max(); + NearestBlockIndex nearestBlocks = NearestBlockIndex.create(v); for (int x = min.getX(); x <= max.getX(); x++) { for (int y = min.getY(); y <= max.getY(); y++) { @@ -146,7 +152,7 @@ final class IrisObjectTransforms { return 1; }) >= 0.5) { - b.put(new IrisBlockVector(x, y, z), nearestBlockData(self, x, y, z)); + b.put(new IrisBlockVector(x, y, z), nearestBlockData(v, nearestBlocks, x, y, z)); } else { b.put(new IrisBlockVector(x, y, z), IrisObject.States.AIR); } @@ -169,6 +175,7 @@ final class IrisObjectTransforms { VectorMap b = new VectorMap<>(); IrisPosition min = self.getAABB().min(); IrisPosition max = self.getAABB().max(); + NearestBlockIndex nearestBlocks = NearestBlockIndex.create(v); for (int x = min.getX(); x <= max.getX(); x++) { for (int y = min.getY(); y <= max.getY(); y++) { @@ -182,7 +189,7 @@ final class IrisObjectTransforms { return 1; }) >= 0.5) { - b.put(new IrisBlockVector(x, y, z), nearestBlockData(self, x, y, z)); + b.put(new IrisBlockVector(x, y, z), nearestBlockData(v, nearestBlocks, x, y, z)); } else { b.put(new IrisBlockVector(x, y, z), IrisObject.States.AIR); } @@ -209,6 +216,7 @@ final class IrisObjectTransforms { VectorMap b = new VectorMap<>(); IrisPosition min = self.getAABB().min(); IrisPosition max = self.getAABB().max(); + NearestBlockIndex nearestBlocks = NearestBlockIndex.create(v); for (int x = min.getX(); x <= max.getX(); x++) { for (int y = min.getY(); y <= max.getY(); y++) { @@ -222,7 +230,7 @@ final class IrisObjectTransforms { return 1; }, tension, bias) >= 0.5) { - b.put(new IrisBlockVector(x, y, z), nearestBlockData(self, x, y, z)); + b.put(new IrisBlockVector(x, y, z), nearestBlockData(v, nearestBlocks, x, y, z)); } else { b.put(new IrisBlockVector(x, y, z), IrisObject.States.AIR); } @@ -238,36 +246,124 @@ final class IrisObjectTransforms { } } - private static PlatformBlockState nearestBlockData(IrisObject self, int x, int y, int z) { + private static PlatformBlockState nearestBlockData(VectorMap blocks, + NearestBlockIndex nearestBlocks, + int x, int y, int z) { IrisBlockVector vv = new IrisBlockVector(x, y, z); - self.readLock.lock(); - try { - PlatformBlockState r = self.blocks.get(vv); + PlatformBlockState direct = blocks.get(vv); + if (!B.isAir(direct)) { + return direct; + } + return nearestBlocks.nearest(x, y, z, direct); + } - if (!B.isAir(r)) { - return r; + static final class NearestBlockIndex { + private static final Comparator X_ORDER = Comparator + .comparingInt(NearestBlock::x) + .thenComparingInt(NearestBlock::rank); + private static final Comparator Y_ORDER = Comparator + .comparingInt(NearestBlock::y) + .thenComparingInt(NearestBlock::rank); + private static final Comparator Z_ORDER = Comparator + .comparingInt(NearestBlock::z) + .thenComparingInt(NearestBlock::rank); + + private final NearestNode root; + private PlatformBlockState bestState; + private double bestDistance; + private int bestRank; + + private NearestBlockIndex(NearestNode root) { + this.root = root; + } + + static NearestBlockIndex create(VectorMap blocks) { + List points = new ArrayList<>(blocks.size()); + VectorMap.Cursor cursor = blocks.cursor(); + int rank = 0; + while (cursor.next()) { + PlatformBlockState state = cursor.value(); + if (!B.isAir(state)) { + IrisBlockVector position = cursor.key(); + points.add(new NearestBlock(position.getBlockX(), position.getBlockY(), position.getBlockZ(), + rank, state)); + } + rank++; } - double d = Double.MAX_VALUE; + NearestBlock[] pointArray = points.toArray(new NearestBlock[0]); + return new NearestBlockIndex(build(pointArray, 0, pointArray.length, 0)); + } - for (var entry : self.blocks) { - PlatformBlockState dat = entry.getValue(); - - if (B.isAir(dat)) { - continue; - } - - double dx = entry.getKey().distanceSquared(vv); - - if (dx < d) { - d = dx; - r = dat; - } + PlatformBlockState nearest(int x, int y, int z, PlatformBlockState fallback) { + if (root == null) { + return fallback; } - return r; - } finally { - self.readLock.unlock(); + bestState = fallback; + bestDistance = Double.MAX_VALUE; + bestRank = Integer.MAX_VALUE; + search(root, x, y, z); + return bestState; + } + + private static NearestNode build(NearestBlock[] points, int from, int to, int depth) { + if (from >= to) { + return null; + } + + int axis = depth % 3; + Arrays.sort(points, from, to, comparator(axis)); + int middle = (from + to) >>> 1; + return new NearestNode( + points[middle], + axis, + build(points, from, middle, depth + 1), + build(points, middle + 1, to, depth + 1) + ); + } + + private static Comparator comparator(int axis) { + return switch (axis) { + case 0 -> X_ORDER; + case 1 -> Y_ORDER; + default -> Z_ORDER; + }; + } + + private void search(NearestNode node, int x, int y, int z) { + if (node == null) { + return; + } + + NearestBlock point = node.point(); + double xDistance = point.x() - x; + double yDistance = point.y() - y; + double zDistance = point.z() - z; + double distance = (xDistance * xDistance) + (yDistance * yDistance) + (zDistance * zDistance); + if (distance < bestDistance || (distance == bestDistance && point.rank() < bestRank)) { + bestState = point.state(); + bestDistance = distance; + bestRank = point.rank(); + } + + double axisDistance = switch (node.axis()) { + case 0 -> x - point.x(); + case 1 -> y - point.y(); + default -> z - point.z(); + }; + NearestNode near = axisDistance <= 0D ? node.lower() : node.upper(); + NearestNode far = axisDistance <= 0D ? node.upper() : node.lower(); + search(near, x, y, z); + if ((axisDistance * axisDistance) <= bestDistance) { + search(far, x, y, z); + } } } + + private record NearestNode(NearestBlock point, int axis, NearestNode lower, NearestNode upper) { + } + + private record NearestBlock(int x, int y, int z, int rank, PlatformBlockState state) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java index 1ef93fda3..603a5dee7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java @@ -97,4 +97,7 @@ public class IrisRiverCaves { @Desc("The policy for fluid already present in a candidate contained cave body.") private IrisRiverExistingFluidPolicy existingFluidPolicy = IrisRiverExistingFluidPolicy.REJECT; + + @Desc("Sparse, independently filled cave-floor pools generated at deep river-network anchors.") + private IrisRiverDeepPools deepPools = new IrisRiverDeepPools(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java new file mode 100644 index 000000000..283a98989 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls sparse, river-anchored fluid pools attached to deep cave floors.") +@Data +public class IrisRiverDeepPools { + @Desc("Enables independently configured deep cave pools along eligible wet river reaches.") + private boolean enabled = false; + + @Desc("Selects complete wet river reaches that may host deep pools.") + private IrisRiverNoiseChance reach = new IrisRiverNoiseChance() + .setChance(1D / 3D) + .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(4096D)) + .setInfluence(0.08D); + + @MinNumber(16) + @MaxNumber(4096) + @Desc("The minimum distance in blocks between deep-pool candidates.") + private int minimumSpacing = 768; + + @MinNumber(0) + @MaxNumber(16) + @Desc("The maximum accepted deep pools on one river reach.") + private int maximumPerReach = 1; + + @MinNumber(-2048) + @MaxNumber(2048) + @Desc("The lowest absolute world Y considered for the pool fluid surface.") + private int minimumFluidY = -224; + + @MinNumber(-2048) + @MaxNumber(2048) + @Desc("The highest absolute world Y considered for the pool fluid surface.") + private int maximumFluidY = -104; + + @MinNumber(0) + @MaxNumber(256) + @Desc("The horizontal distance searched from a river anchor for a contained cave floor.") + private int searchRadius = 16; + + @MinNumber(1) + @MaxNumber(64) + @Desc("The number of deterministic nearby columns tested for a contained cave floor.") + private int searchAttempts = 12; + + @MinNumber(2) + @MaxNumber(128) + @Desc("The horizontal radius of the generated deep-pool chamber.") + private int horizontalRadius = 18; + + @MinNumber(2) + @MaxNumber(64) + @Desc("The vertical radius of the generated deep-pool chamber.") + private int verticalRadius = 8; + + @MinNumber(1) + @MaxNumber(63) + @Desc("The dry chamber height retained above the deep-pool fluid surface.") + private int dryHeadroom = 4; + + @Desc("Noise shaping the deep-pool chamber boundary.") + private IrisGeneratorStyle shapeStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(12D); + + @MinNumber(0) + @MaxNumber(0.75) + @Desc("The proportional noise displacement applied to the deep-pool chamber boundary.") + private double shapeVariation = 0.5D; + + @Desc("Noise warping the deep-pool chamber coordinate field.") + private IrisGeneratorStyle warpStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(24D); + + @MinNumber(0) + @MaxNumber(64) + @Desc("The maximum coordinate warp applied to the deep-pool chamber in blocks.") + private double warpStrength = 6D; + + @MinNumber(64) + @MaxNumber(1048576) + @Desc("The greatest generated deep-pool chamber volume that may be transactionally published.") + private int maximumVolume = 32768; + + @Desc("The fluid palette used only by accepted deep pools.") + private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("lava"); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java index 2f8fb04f0..4c94a056b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java @@ -24,6 +24,11 @@ public class IrisRiverTerrain { @Desc("The wet-bed depth below the local water surface, or dry-channel depth below natural terrain, in blocks.") private IrisStyledRange depth = range(2D, 7D, NoiseStyle.IRIS, 768D); + @MinNumber(0) + @MaxNumber(64) + @Desc("The radius added to every channel after worm, regional, local, and stream-order shaping.") + private double channelRadiusBonus = 0D; + @MinNumber(1) @MaxNumber(2048) @Desc("The final wet-channel width cap after region, biome, and stream-order scaling.") @@ -64,7 +69,7 @@ public class IrisRiverTerrain { @MinNumber(0) @MaxNumber(16) - @Desc("The extra lateral tunnel-mouth blend carved on each side where a surface river enters or exits solid terrain.") + @Desc("The longitudinal transition length and maximum lateral and roof flare where a surface river enters or exits solid terrain.") private double tunnelMouthBlend = 2D; @Desc("Noise modulating the submerged floor of river tunnels.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java index 34e597ea0..c66d6d36a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java @@ -13,7 +13,15 @@ import lombok.experimental.Accessors; @Data public class IrisRiverWater { @Desc("The strategy used to determine river water-surface height.") - private IrisRiverWaterMode mode = IrisRiverWaterMode.SEA_LEVEL; + private IrisRiverWaterMode mode = IrisRiverWaterMode.FIXED; + + @MinNumber(-2048) + @MaxNumber(2048) + @Desc("The base river fluid surface in absolute world Y, independent of the dimension ocean height.") + private int fluidHeight = 63; + + @Desc("The river fluid palette used by surface channels, contained tunnels, grottos, and waterfall throats.") + private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water"); @MinNumber(8) @MaxNumber(4096) @@ -22,7 +30,7 @@ public class IrisRiverWater { @MinNumber(0) @MaxNumber(64) - @Desc("The greatest river water height permitted above the dimension fluid height.") + @Desc("The greatest terraced river height permitted above fluidHeight.") private int maximumPoolRise = 4; @MinNumber(1) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java index 44cf71479..5e61e92f8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java @@ -4,8 +4,8 @@ import art.arcane.iris.engine.object.annotations.Desc; @Desc("Selects how a river determines its surface fluid height.") public enum IrisRiverWaterMode { - @Desc("Use the dimension fluid height for every wet river reach.") - SEA_LEVEL, + @Desc("Use the river water configuration's fixed fluid height for every wet reach.") + FIXED, @Desc("Use flat pools connected by controlled vertical drops.") TERRACED diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java index 9366ddb92..e00c0840c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java @@ -72,16 +72,21 @@ public class IrisRiverWorm { @Desc("Depth multiplier for reaches selecting this worm.") private double depthMultiplier = 1D; - @MinNumber(32) + @MinNumber(8) @MaxNumber(16384) @Desc("Primary world-space wavelength controlling longitudinal body swelling and pinching.") private double bodyWavelength = 512D; - @MinNumber(32) + @MinNumber(8) @MaxNumber(16384) @Desc("Detail wavelength adding smaller changes to the longitudinal body profile.") private double bodyDetailWavelength = 128D; + @MinNumber(0) + @MaxNumber(1) + @Desc("Share of the longitudinal body field supplied by bodyDetailWavelength; the remainder uses bodyWavelength.") + private double bodyDetailInfluence = 0.3D; + @MinNumber(0) @MaxNumber(0.875) @Desc("Maximum proportional channel-width variation along this style's body.") 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 e2364209f..d5640e4dd 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 @@ -121,6 +121,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final AtomicInteger a = new AtomicInteger(0); private volatile long lastChunkGenTime = 0L; private final CompletableFuture spawnChunks = new CompletableFuture<>(); + private final CompletableFuture initialSpawnReady = new CompletableFuture<>(); private final AtomicCache targetCache = new AtomicCache<>(); private final AtomicReference> closeFuture = new AtomicReference<>(); private volatile Engine engine; @@ -194,11 +195,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun engine.getPlatformHooks().applyWorldBoundary(engine); IrisLogging.debug("Injected Iris Biome Source into " + world.getName()); if (!studio) { - J.s(() -> updateSpawnLocation(world), 1); + J.sfut(() -> updateSpawnLocation(world), 1) + .whenComplete((ignored, failure) -> { + if (failure != null) { + initialSpawnReady.completeExceptionally(failure); + } + }); + } else { + initialSpawnReady.complete(null); } } catch (Throwable e) { initializationFailure = e; spawnChunks.completeExceptionally(e); + initialSpawnReady.completeExceptionally(e); IrisLogging.reportError(e); IrisLogging.error("Failed to initialize Iris generator for " + world.getName()); if (e instanceof RuntimeException runtimeException) { @@ -231,16 +240,58 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } 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; - } + try { + Location initialSpawn = getInitialSpawnLocation(world); + int chunkX = initialSpawn.getBlockX() >> 4; + int chunkZ = initialSpawn.getBlockZ() >> 4; + CompletableFuture chunkFuture = requestChunkAsync(world, chunkX, chunkZ, true); + if (chunkFuture == null) { + initialSpawnReady.completeExceptionally(new IllegalStateException( + "Initial spawn chunk request returned no completion future for world \"" + + world.getName() + "\".")); + return; + } - chunkFuture.thenAccept(chunk -> - J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> applySpawnLocation(chunk.getWorld(), initialSpawn))); + chunkFuture.whenComplete((chunk, failure) -> { + try { + if (failure != null) { + initialSpawnReady.completeExceptionally(failure); + return; + } + if (chunk == null) { + throw new IllegalStateException( + "Initial spawn chunk request completed without a chunk for world \"" + + world.getName() + "\"."); + } + J.runRegionFuture( + chunk.getWorld(), + chunk.getX(), + chunk.getZ(), + () -> completeSpawnLocation(chunk.getWorld(), initialSpawn)) + .whenComplete((ignored, scheduleFailure) -> { + if (scheduleFailure != null) { + initialSpawnReady.completeExceptionally(scheduleFailure); + } + }); + } catch (Throwable callbackFailure) { + initialSpawnReady.completeExceptionally(new IllegalStateException( + "Initial spawn preparation failed for world \"" + + world.getName() + "\".", + callbackFailure)); + } + }); + } catch (Throwable failure) { + initialSpawnReady.completeExceptionally(failure); + } + } + + private void completeSpawnLocation(World world, Location initialSpawn) { + try { + applySpawnLocation(world, initialSpawn); + initialSpawnReady.complete(null); + } catch (Throwable failure) { + initialSpawnReady.completeExceptionally(failure); + } } private void applySpawnLocation(World world, Location initialSpawn) { @@ -255,10 +306,27 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun int minY = world.getMinHeight() + 1; int maxY = world.getMaxHeight() - 2; - int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn) + 1)); + int y = resolveInitialSpawnY(world, initialSpawn, minY, maxY); world.setSpawnLocation(new Location(world, initialSpawn.getX(), y, initialSpawn.getZ(), initialSpawn.getYaw(), initialSpawn.getPitch())); } + private int resolveInitialSpawnY(World world, Location initialSpawn, int minY, int maxY) { + Engine activeEngine = engine; + if (activeEngine != null && activeEngine.getComplex() != null && activeEngine.getComplex().getHeightStream() != null) { + int generatedY = activeEngine.getMinHeight() + + activeEngine.getComplex().getHeightStream().get(initialSpawn.getX(), initialSpawn.getZ()).intValue() + + 1; + return Math.max(minY, Math.min(maxY, generatedY)); + } + + return Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn) + 1)); + } + + @Override + public CompletableFuture getInitialSpawnReady() { + return initialSpawnReady; + } + @SuppressWarnings("unchecked") private CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate) { try { @@ -377,6 +445,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun this.hotloader = shouldRunStudioHotload(studio, closing, jigsawStudioActive) ? new Looper() { @Override protected long loop() { + Engine activeEngine = engine; + if (activeEngine instanceof IrisEngine irisEngine + && irisEngine.isGenerationCacheWarmPending()) { + return HOTLOAD_LOOP_DELAY_MS; + } if (shouldThrottleHotload()) { return HOTLOAD_MAINTENANCE_DELAY_MS; } 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 6ed15e9ed..f39767c64 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 @@ -76,4 +76,8 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { } CompletableFuture getSpawnChunks(); + + default CompletableFuture getInitialSpawnReady() { + return CompletableFuture.completedFuture(null); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java index 74ff4c618..071a162ce 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java @@ -119,6 +119,15 @@ public final class RiverBodyProfile { return roofScales[index]; } + public int intervalIndex(double alongReach) { + double position = StrictMath.max(0D, StrictMath.min(1D, alongReach)); + int index = Arrays.binarySearch(positions, position); + if (index >= 0) { + return StrictMath.min(index, positions.length - 2); + } + return StrictMath.max(0, StrictMath.min(-index - 2, positions.length - 2)); + } + @Override public boolean equals(Object object) { if (this == object) { diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java index cdf42146f..9cfeb7723 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java @@ -38,7 +38,7 @@ public final class RiverNetwork { private static final long BRANCH_SLOT_SALT = 0x94D049BB133111EBL; private static final long BRANCH_GATE_SALT = 0x2545F4914F6CDD1DL; private static final int MINIMUM_BODY_PROFILE_SAMPLES = 12; - private static final int MAXIMUM_BODY_PROFILE_SAMPLES = 32; + private static final int MAXIMUM_BODY_PROFILE_SAMPLES = 512; private static final double PERLIN_NORMALIZATION = 1.4142135623730951D; private final RiverNetworkOptions options; @@ -670,7 +670,8 @@ public final class RiverNetwork { worm.bodyDetailWavelength(), worm.seed() ^ detailSalt ); - return primary * 0.7D + detail * 0.3D; + double detailInfluence = worm.bodyDetailInfluence(); + return primary * (1D - detailInfluence) + detail * detailInfluence; } private FlowTangent resolveFlowTangent(RiverNode node, RiverTerrainSampler terrain) { @@ -1263,6 +1264,7 @@ public final class RiverNetwork { BODY_WIDTH_DETAIL_SALT, worm.widthVariation() ) + + options.channelRadiusBonus() * 2D ) ); double baseBankWidth = nonNegativeOrFallback( diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java index dda9cf955..f4b7520b3 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java @@ -29,6 +29,7 @@ public record RiverNetworkOptions( double channelWidth, double bankWidth, double depth, + double channelRadiusBonus, double maxChannelWidth, double maxBankWidth, double maxDepth, @@ -60,6 +61,7 @@ public record RiverNetworkOptions( requirePositive(channelWidth, "channelWidth"); requireFiniteNonNegative(bankWidth, "bankWidth"); requirePositive(depth, "depth"); + requireFiniteNonNegative(channelRadiusBonus, "channelRadiusBonus"); requirePositive(maxChannelWidth, "maxChannelWidth"); requireFiniteNonNegative(maxBankWidth, "maxBankWidth"); requirePositive(maxDepth, "maxDepth"); @@ -215,6 +217,7 @@ public record RiverNetworkOptions( private double channelWidth; private double bankWidth; private double depth; + private double channelRadiusBonus; private double maxChannelWidth; private double maxBankWidth; private double maxDepth; @@ -269,6 +272,7 @@ public record RiverNetworkOptions( 1D, 512D, 128D, + 0.3D, 0D, 0D, 0D, @@ -397,6 +401,11 @@ public record RiverNetworkOptions( return this; } + public Builder channelRadiusBonus(double value) { + channelRadiusBonus = value; + return this; + } + public Builder maxChannelWidth(double value) { maxChannelWidth = value; return this; @@ -461,6 +470,7 @@ public record RiverNetworkOptions( channelWidth, bankWidth, depth, + channelRadiusBonus, maxChannelWidth, maxBankWidth, maxDepth, diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java index 91108dc20..8c9bbdd85 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java @@ -325,25 +325,35 @@ public final class RiverTile { double additionalRadius ) { RiverPolyline polyline = reach.polyline(); - if (polyline.length() == 0D) { + RiverBodyProfile bodyProfile = reach.bodyProfile(); + double polylineLength = polyline.length(); + if (polylineLength == 0D) { double distanceSquared = squared(x - polyline.x(0)) + squared(z - polyline.z(0)); double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D) + additionalRadius; return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null; } double nearest = Double.POSITIVE_INFINITY; double nearestAlong = 0.0; - for (int point = 0; point < polyline.size() - 1; point++) { - double segmentStartAlong = polyline.cumulativeLength(point) / polyline.length(); - double segmentEndAlong = polyline.cumulativeLength(point + 1) / polyline.length(); + int pointLimit = polyline.size() - 1; + int profileLimit = bodyProfile.size() - 1; + for (int point = 0; point < pointLimit; point++) { + double segmentStartAlong = polyline.cumulativeLength(point) / polylineLength; + double segmentEndAlong = polyline.cumulativeLength(point + 1) / polylineLength; double segmentAlongSpan = segmentEndAlong - segmentStartAlong; if (segmentAlongSpan == 0D) { continue; } - double deltaX = polyline.x(point + 1) - polyline.x(point); - double deltaZ = polyline.z(point + 1) - polyline.z(point); - for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) { - double profileStart = reach.bodyProfile().position(profileIndex); - double profileEnd = reach.bodyProfile().position(profileIndex + 1); + double startX = polyline.x(point); + double startZ = polyline.z(point); + double deltaX = polyline.x(point + 1) - startX; + double deltaZ = polyline.z(point + 1) - startZ; + int firstProfileIndex = bodyProfile.intervalIndex(segmentStartAlong); + for (int profileIndex = firstProfileIndex; + profileIndex < profileLimit + && bodyProfile.position(profileIndex) <= segmentEndAlong; + profileIndex++) { + double profileStart = bodyProfile.position(profileIndex); + double profileEnd = bodyProfile.position(profileIndex + 1); double overlapStart = StrictMath.max(segmentStartAlong, profileStart); double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); if (overlapStart > overlapEnd) { @@ -351,13 +361,16 @@ public final class RiverTile { } double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1) - - reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart); - double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1) - - reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart); - double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex) + double profileSpan = profileEnd - profileStart; + double profileWidth = bodyProfile.widthAtIndex(profileIndex); + double profileBankWidth = bodyProfile.bankWidthAtIndex(profileIndex); + double widthSlope = (bodyProfile.widthAtIndex(profileIndex + 1) + - profileWidth) / profileSpan; + double bankSlope = (bodyProfile.bankWidthAtIndex(profileIndex + 1) + - profileBankWidth) / profileSpan; + double radiusBase = (profileWidth + widthSlope * (segmentStartAlong - profileStart)) * 0.5D - + reach.bodyProfile().bankWidthAtIndex(profileIndex) + + profileBankWidth + bankSlope * (segmentStartAlong - profileStart) + additionalRadius; double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; @@ -365,9 +378,9 @@ public final class RiverTile { intervalStart, intervalEnd, deltaX, - polyline.x(point) - x, + startX - x, deltaZ, - polyline.z(point) - z, + startZ - z, radiusSlope, radiusBase, segmentStartAlong, @@ -390,7 +403,9 @@ public final class RiverTile { double maximumZ ) { RiverPolyline polyline = reach.polyline(); - if (polyline.length() == 0D) { + RiverBodyProfile bodyProfile = reach.bodyProfile(); + double polylineLength = polyline.length(); + if (polylineLength == 0D) { double distanceSquared = pointRectangleDistanceSquared( polyline.x(0), polyline.z(0), @@ -404,9 +419,11 @@ public final class RiverTile { } double nearest = Double.POSITIVE_INFINITY; double nearestAlong = 0.0; - for (int point = 0; point < polyline.size() - 1; point++) { - double segmentStartAlong = polyline.cumulativeLength(point) / polyline.length(); - double segmentEndAlong = polyline.cumulativeLength(point + 1) / polyline.length(); + int pointLimit = polyline.size() - 1; + int profileLimit = bodyProfile.size() - 1; + for (int point = 0; point < pointLimit; point++) { + double segmentStartAlong = polyline.cumulativeLength(point) / polylineLength; + double segmentEndAlong = polyline.cumulativeLength(point + 1) / polylineLength; double segmentAlongSpan = segmentEndAlong - segmentStartAlong; if (segmentAlongSpan == 0D) { continue; @@ -415,9 +432,13 @@ public final class RiverTile { double startZ = polyline.z(point); double deltaX = polyline.x(point + 1) - startX; double deltaZ = polyline.z(point + 1) - startZ; - for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) { - double profileStart = reach.bodyProfile().position(profileIndex); - double profileEnd = reach.bodyProfile().position(profileIndex + 1); + int firstProfileIndex = bodyProfile.intervalIndex(segmentStartAlong); + for (int profileIndex = firstProfileIndex; + profileIndex < profileLimit + && bodyProfile.position(profileIndex) <= segmentEndAlong; + profileIndex++) { + double profileStart = bodyProfile.position(profileIndex); + double profileEnd = bodyProfile.position(profileIndex + 1); double overlapStart = StrictMath.max(segmentStartAlong, profileStart); double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); if (overlapStart > overlapEnd) { @@ -425,13 +446,16 @@ public final class RiverTile { } double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1) - - reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart); - double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1) - - reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart); - double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex) + double profileSpan = profileEnd - profileStart; + double profileWidth = bodyProfile.widthAtIndex(profileIndex); + double profileBankWidth = bodyProfile.bankWidthAtIndex(profileIndex); + double widthSlope = (bodyProfile.widthAtIndex(profileIndex + 1) + - profileWidth) / profileSpan; + double bankSlope = (bodyProfile.bankWidthAtIndex(profileIndex + 1) + - profileBankWidth) / profileSpan; + double radiusBase = (profileWidth + widthSlope * (segmentStartAlong - profileStart)) * 0.5D - + reach.bodyProfile().bankWidthAtIndex(profileIndex) + + profileBankWidth + bankSlope * (segmentStartAlong - profileStart); double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; double cursor = intervalStart; @@ -626,8 +650,7 @@ public final class RiverTile { } private List indexedReaches(double x, double z) { - List indexed = spatialIndex.get(bucketKey(bucket(x), bucket(z))); - return indexed == null ? List.of() : indexed; + return spatialIndex.getOrDefault(bucketKey(bucket(x), bucket(z)), List.of()); } private List indexedReaches( @@ -641,12 +664,15 @@ public final class RiverTile { int maximumBucketX = bucket(StrictMath.nextDown(queryMaximumX)); int minimumBucketZ = bucket(queryMinimumZ); int maximumBucketZ = bucket(StrictMath.nextDown(queryMaximumZ)); + if (minimumBucketX == maximumBucketX && minimumBucketZ == maximumBucketZ) { + return spatialIndex.getOrDefault( + bucketKey(minimumBucketX, minimumBucketZ), + List.of() + ); + } for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { - List bucketReaches = spatialIndex.get(bucketKey(bucketX, bucketZ)); - if (bucketReaches != null) { - indexed.addAll(bucketReaches); - } + indexed.addAll(spatialIndex.getOrDefault(bucketKey(bucketX, bucketZ), List.of())); } } return List.copyOf(indexed); @@ -662,26 +688,13 @@ public final class RiverTile { int maximumBucketX = bucket(queryMaximumX); int minimumBucketZ = bucket(queryMinimumZ); int maximumBucketZ = bucket(queryMaximumZ); - if (spatialIndex.isEmpty()) { - return List.of(); - } if (minimumBucketX == maximumBucketX && minimumBucketZ == maximumBucketZ) { - List bucketReaches = spatialIndex.get(bucketKey(minimumBucketX, minimumBucketZ)); - return bucketReaches == null ? List.of() : bucketReaches; - } - long bucketWidth = (long) maximumBucketX - minimumBucketX + 1L; - long bucketDepth = (long) maximumBucketZ - minimumBucketZ + 1L; - if (bucketWidth > spatialIndex.size() / bucketDepth - || bucketWidth * bucketDepth >= spatialIndex.size()) { - return reaches; + return indexedReaches(queryMinimumX, queryMinimumZ); } LinkedHashSet indexed = new LinkedHashSet<>(); for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { - List bucketReaches = spatialIndex.get(bucketKey(bucketX, bucketZ)); - if (bucketReaches != null) { - indexed.addAll(bucketReaches); - } + indexed.addAll(spatialIndex.getOrDefault(bucketKey(bucketX, bucketZ), List.of())); } } return List.copyOf(indexed); diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java b/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java index 6c0d44a65..539d3b54b 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java @@ -17,6 +17,7 @@ public record RiverWorm( double depthMultiplier, double bodyWavelength, double bodyDetailWavelength, + double bodyDetailInfluence, double widthVariation, double bankVariation, double depthVariation, @@ -44,8 +45,9 @@ public record RiverWorm( requireRange(widthMultiplier, 0.125D, 8D, "widthMultiplier"); requireRange(bankMultiplier, 0.125D, 8D, "bankMultiplier"); requireRange(depthMultiplier, 0.125D, 8D, "depthMultiplier"); - requireRange(bodyWavelength, 32D, 16384D, "bodyWavelength"); - requireRange(bodyDetailWavelength, 32D, 16384D, "bodyDetailWavelength"); + requireRange(bodyWavelength, 8D, 16384D, "bodyWavelength"); + requireRange(bodyDetailWavelength, 8D, 16384D, "bodyDetailWavelength"); + requireRange(bodyDetailInfluence, 0D, 1D, "bodyDetailInfluence"); requireRange(widthVariation, 0D, 0.875D, "widthVariation"); requireRange(bankVariation, 0D, 0.875D, "bankVariation"); requireRange(depthVariation, 0D, 0.875D, "depthVariation"); diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java index 734997b8e..430fdd352 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java @@ -2,7 +2,7 @@ package art.arcane.iris.engine.river.cave; public enum RiverCaveAction { WET_SOURCE, - FALLING_WATER, + FALLING_FLUID, DRY_AIR, SEAL_GUARD } diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java index d4356b84f..2db8771e3 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java @@ -70,6 +70,7 @@ public final class RiverCaveContainmentPlanner { settings, throat.positions() ); + case DEEP_POOL -> planDeepPool(view, source, settings, throat.positions()); }; } @@ -302,6 +303,111 @@ public final class RiverCaveContainmentPlanner { return accepted(view, source, actions); } + private RiverCavePlan planDeepPool( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + GrottoResult grotto = buildGrotto(source, settings); + if (grotto.rejection() != RiverCaveRejection.NONE) { + return rejected(source, grotto.rejection()); + } + Set chamber = grotto.positions(); + Set carve = new HashSet<>(chamber.size() + throat.size()); + carve.addAll(chamber); + carve.addAll(throat); + + RiverCaveRejection carveRejection = validateDeepPoolCarve(view, source, settings, carve); + if (carveRejection != RiverCaveRejection.NONE) { + return rejected(source, carveRejection); + } + BoundaryResult boundary = validateDeepPoolBoundary(view, source, settings, carve); + if (boundary.rejection() != RiverCaveRejection.NONE) { + return rejected(source, boundary.rejection()); + } + + Map actions = new HashMap<>(); + addChamberActions(actions, chamber, source.waterHeadY()); + addThroatActions(actions, throat, source); + for (CavePosition position : boundary.sealGuards()) { + actions.put(position, RiverCaveAction.SEAL_GUARD); + } + return accepted(view, source, actions); + } + + private RiverCaveRejection validateDeepPoolCarve( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + Set carve + ) { + for (CavePosition position : carve) { + if (!view.isInWorld(position)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return hazard; + } + if (voxel == CaveVoxel.SOLID) { + continue; + } + if (position.y() > source.waterHeadY() + && voxel == CaveVoxel.CAVE_AIR + && !view.isOpenToSurface(position)) { + continue; + } + return RiverCaveRejection.GROTTO_INTERSECTION; + } + return RiverCaveRejection.NONE; + } + + private BoundaryResult validateDeepPoolBoundary( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + Set carve + ) { + Set guards = new HashSet<>(); + for (CavePosition position : carve) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (carve.contains(neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return BoundaryResult.rejected(RiverCaveRejection.WORLD_BOUNDARY); + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != RiverCaveRejection.NONE) { + return BoundaryResult.rejected(boundsRejection); + } + CaveVoxel voxel = voxelAt(view, neighbor); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return BoundaryResult.rejected(hazard); + } + if (voxel == CaveVoxel.SOLID) { + guards.add(neighbor); + continue; + } + if (neighbor.y() > source.waterHeadY() + && voxel == CaveVoxel.CAVE_AIR + && !view.isOpenToSurface(neighbor)) { + continue; + } + return BoundaryResult.rejected(RiverCaveRejection.GROTTO_SHELL_OPEN); + } + } + return BoundaryResult.accepted(guards); + } + private ComponentResult resolveClosedComponent( CaveVoxelView view, RiverCaveSource source, @@ -732,7 +838,7 @@ public final class RiverCaveContainmentPlanner { action = RiverCaveAction.WET_SOURCE; } else if (source.mode() == RiverCaveMode.WATERFALL_POOL || source.mode() == RiverCaveMode.GENERATED_GROTTO) { - action = RiverCaveAction.FALLING_WATER; + action = RiverCaveAction.FALLING_FLUID; } else { action = RiverCaveAction.DRY_AIR; } diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java new file mode 100644 index 000000000..34c93559b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java @@ -0,0 +1,6 @@ +package art.arcane.iris.engine.river.cave; + +public enum RiverCaveFluidKind { + RIVER, + DEEP_POOL +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java index 4e8ff8d38..5c1be7dc9 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java @@ -11,20 +11,30 @@ public final class RiverCaveHydrology { private final RiverCaveAction action; private final String floodedBiomeKey; + private final RiverCaveFluidKind fluidKind; private final MatterCavern cavern; - public RiverCaveHydrology(RiverCaveAction action, String floodedBiomeKey) { + public RiverCaveHydrology( + RiverCaveAction action, + String floodedBiomeKey, + RiverCaveFluidKind fluidKind + ) { this.action = Objects.requireNonNull(action); this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim(); + this.fluidKind = Objects.requireNonNull(fluidKind); this.cavern = switch (action) { - case WET_SOURCE, FALLING_WATER -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID); + case WET_SOURCE, FALLING_FLUID -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID); case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR); case SEAL_GUARD -> null; }; } public static RiverCaveHydrology of(RiverCaveAction action) { - return new RiverCaveHydrology(action, ""); + return new RiverCaveHydrology(action, "", RiverCaveFluidKind.RIVER); + } + + public static RiverCaveHydrology of(RiverCaveAction action, RiverCaveFluidKind fluidKind) { + return new RiverCaveHydrology(action, "", fluidKind); } public Optional floodedBiome() { @@ -36,11 +46,11 @@ public final class RiverCaveHydrology { } public boolean isWet() { - return action == RiverCaveAction.WET_SOURCE || action == RiverCaveAction.FALLING_WATER; + return action == RiverCaveAction.WET_SOURCE || action == RiverCaveAction.FALLING_FLUID; } public boolean isFalling() { - return action == RiverCaveAction.FALLING_WATER; + return action == RiverCaveAction.FALLING_FLUID; } public boolean protectsPlacement() { @@ -59,6 +69,10 @@ public final class RiverCaveHydrology { return floodedBiomeKey; } + public RiverCaveFluidKind fluidKind() { + return fluidKind; + } + @Override public boolean equals(Object object) { if (this == object) { @@ -67,16 +81,19 @@ public final class RiverCaveHydrology { if (!(object instanceof RiverCaveHydrology hydrology)) { return false; } - return action == hydrology.action && floodedBiomeKey.equals(hydrology.floodedBiomeKey); + return action == hydrology.action + && floodedBiomeKey.equals(hydrology.floodedBiomeKey) + && fluidKind == hydrology.fluidKind; } @Override public int hashCode() { - return (31 * action.hashCode()) + floodedBiomeKey.hashCode(); + return (31 * ((31 * action.hashCode()) + floodedBiomeKey.hashCode())) + fluidKind.hashCode(); } @Override public String toString() { - return "RiverCaveHydrology[action=" + action + ", floodedBiomeKey=" + floodedBiomeKey + "]"; + return "RiverCaveHydrology[action=" + action + ", floodedBiomeKey=" + floodedBiomeKey + + ", fluidKind=" + fluidKind + "]"; } } diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java index b91243732..a617b2baa 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java @@ -4,5 +4,6 @@ public enum RiverCaveMode { CLOSED_COMPONENT, GENERATED_GROTTO, GROTTO_OR_CLOSED_COMPONENT, - WATERFALL_POOL + WATERFALL_POOL, + DEEP_POOL } diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java index 4cd4efb9c..5c5a21cf5 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java @@ -9,6 +9,7 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRiverNetwork; import art.arcane.iris.engine.object.IrisRiverCaveMode; import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverDeepPools; import art.arcane.iris.engine.object.IrisRiverNoiseChance; import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; import art.arcane.iris.engine.object.IrisRiverTerminalMode; @@ -41,6 +42,8 @@ import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; import java.util.HashSet; @@ -48,6 +51,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReferenceArray; public final class IrisRiverRuntime implements AutoCloseable { static final int MAXIMUM_REACH_FEASIBILITY_SAMPLES = 65; @@ -66,17 +70,22 @@ public final class IrisRiverRuntime implements AutoCloseable { private static final long BIOME_NOISE_SALT = 0x2FFD72DBD01ADFB7L; private static final long CAVE_ENTRY_NOISE_SALT = 0xB8E1AFED6A267E96L; private static final long CAVE_ENTRY_GATE_SALT = 0xBA7C9045F12C7F99L; + private static final long DEEP_POOL_REACH_NOISE_SALT = 0x7137449123EF65CDL; + private static final long DEEP_POOL_REACH_GATE_SALT = 0xE9B5DBA58189DBBCL; private static final long TUNNEL_FLOOR_NOISE_SALT = 0x8CB92BA72F3D8DD7L; private static final long TUNNEL_ROOF_NOISE_SALT = 0xDB4F0B9175AE2165L; private static final long TUNNEL_WIDTH_NOISE_SALT = 0xC6EF372FE94F82BEL; private static final long FLOODED_CAVE_BIOME_SALT = 0x24A19947B3916CF7L; private static final long TERMINAL_CAVE_ANCHOR_SALT = 0x9E3779B97F4A7C15L; private static final int TILE_CACHE_SIZE = 32; + private static final int TUNNEL_SAMPLE_CHUNK_CACHE_SIZE = 4_096; + private static final Object ABSENT_TUNNEL_SAMPLE = new Object(); private final long seed; private final IrisRiverNetwork configuration; private final IrisData data; - private final int fluidHeight; + private final int riverFluidHeight; + private final int dimensionFluidHeight; private final boolean boreMantleActive; private final boolean caveHydrologyActive; private final boolean blockingRoutingPossible; @@ -101,12 +110,14 @@ public final class IrisRiverRuntime implements AutoCloseable { private final CNG bedNoise; private final CNG biomeNoise; private final CNG caveEntryNoise; + private final CNG deepPoolReachNoise; private final CNG tunnelFloorNoise; private final CNG tunnelRoofNoise; private final CNG tunnelWidthNoise; private final art.arcane.iris.engine.river.RiverNetwork network; private final RuntimeTerrainSampler terrainSampler; private final RiverTileCache tileCache; + private final Cache tunnelSampleCache; private final ConcurrentHashMap settingsCache; private final ConcurrentHashMap> biomePoolCache; @@ -115,7 +126,8 @@ public final class IrisRiverRuntime implements AutoCloseable { seed = context.seed(); configuration = context.configuration(); data = context.data(); - fluidHeight = context.fluidHeight(); + riverFluidHeight = context.riverFluidHeight(); + dimensionFluidHeight = context.dimensionFluidHeight(); boreMantleActive = context.boreMantleActive(); caveHydrologyActive = context.caveHydrologyActive(); blockingRoutingPossible = context.blockingRoutingPossible(); @@ -146,6 +158,11 @@ public final class IrisRiverRuntime implements AutoCloseable { bedNoise = noise(terrain.getBedRoughnessStyle(), BED_NOISE_SALT); biomeNoise = noise(configuration.getBiomes().getSelectionStyle(), BIOME_NOISE_SALT); caveEntryNoise = noise(caves.getEntry(), CAVE_ENTRY_NOISE_SALT); + IrisRiverDeepPools deepPools = caves.getDeepPools(); + deepPoolReachNoise = noise( + deepPools == null ? null : deepPools.getReach(), + DEEP_POOL_REACH_NOISE_SALT + ); tunnelFloorNoise = noise(terrain.getTunnelFloorStyle(), TUNNEL_FLOOR_NOISE_SALT); tunnelRoofNoise = noise(terrain.getTunnelRoofStyle(), TUNNEL_ROOF_NOISE_SALT); tunnelWidthNoise = noise(terrain.getTunnelWidthMultiplier(), TUNNEL_WIDTH_NOISE_SALT); @@ -158,12 +175,15 @@ public final class IrisRiverRuntime implements AutoCloseable { TILE_CACHE_SIZE, (tileX, tileZ) -> network.buildTile(tileX, tileZ, terrainSampler) ); + tunnelSampleCache = Caffeine.newBuilder() + .maximumSize(TUNNEL_SAMPLE_CHUNK_CACHE_SIZE) + .build(); } public IrisRiverSurfaceSample sample(double x, double z) { ResolvedRiverColumn column = resolveColumn(x, z); if (column == null) { - return IrisRiverSurfaceSample.none(naturalHeight.get(x, z), fluidHeight); + return IrisRiverSurfaceSample.none(naturalHeight.get(x, z), dimensionFluidHeight); } if (column.subterranean()) { return new IrisRiverSurfaceSample( @@ -229,9 +249,8 @@ public final class IrisRiverRuntime implements AutoCloseable { if (!column.subterranean()) { return null; } - if (isTunnelMouth(column, mouthBlend)) { - channelRadius += mouthBlend; - } + double mouthFactor = tunnelMouthFactor(column, mouthBlend); + channelRadius += mouthBlend * mouthFactor; if (column.river().distance() > channelRadius) { return null; } @@ -255,13 +274,20 @@ public final class IrisRiverRuntime implements AutoCloseable { ); int ceilingY = shapedTunnelCeilingY( waterHeadY, - caves.getDryHeadroom() * column.reach().roofScaleAt(column.river().alongReach()), + caves.getDryHeadroom() * column.reach().roofScaleAt(column.river().alongReach()) + + mouthBlend * mouthFactor, profile, roofOffset ); return new IrisRiverTunnelSample(column.river(), bedY, waterHeadY, ceilingY); } + public IrisRiverTunnelSample sampleTunnel(int x, int z) { + long chunkKey = ((long) (x >> 4) << 32) ^ ((z >> 4) & 0xFFFFFFFFL); + TunnelSampleChunk chunk = tunnelSampleCache.get(chunkKey, ignored -> new TunnelSampleChunk()); + return chunk.sample(this, x, z); + } + static int shapedTunnelBedY( int waterHeadY, double baseBedY, @@ -331,18 +357,44 @@ public final class IrisRiverRuntime implements AutoCloseable { ); } - private boolean isTunnelMouth(ResolvedRiverColumn column, double mouthBlend) { + private double tunnelMouthFactor(ResolvedRiverColumn column, double mouthBlend) { if (mouthBlend <= 0D) { - return false; + return 0D; } double length = column.reach().polyline().length(); if (length <= 0D) { - return false; + return 0D; } double offset = mouthBlend / length; double alongReach = column.river().alongReach(); - return !isCenterlineSubterranean(column.reach(), clamp01(alongReach - offset)) - || !isCenterlineSubterranean(column.reach(), clamp01(alongReach + offset)); + return Math.max( + tunnelMouthFactor(column.reach(), alongReach, clamp01(alongReach - offset), mouthBlend), + tunnelMouthFactor(column.reach(), alongReach, clamp01(alongReach + offset), mouthBlend) + ); + } + + private double tunnelMouthFactor( + RiverReach reach, + double subterraneanAlong, + double candidateOpenAlong, + double mouthBlend + ) { + if (candidateOpenAlong == subterraneanAlong + || isCenterlineSubterranean(reach, candidateOpenAlong)) { + return 0D; + } + double openAlong = candidateOpenAlong; + double solidAlong = subterraneanAlong; + for (int iteration = 0; iteration < 5; iteration++) { + double midpoint = (openAlong + solidAlong) * 0.5D; + if (isCenterlineSubterranean(reach, midpoint)) { + solidAlong = midpoint; + } else { + openAlong = midpoint; + } + } + double distance = StrictMath.abs(solidAlong - subterraneanAlong) * reach.polyline().length(); + return clamp01(1D - distance / mouthBlend); } private boolean isCenterlineSubterranean(RiverReach reach, double alongReach) { @@ -401,6 +453,35 @@ public final class IrisRiverRuntime implements AutoCloseable { return tileAt(centerX, centerZ).sampleFootprint(minimumX, minimumZ, maximumX, maximumZ); } + public boolean hasRiverFootprint( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + if (minimumX >= maximumX || minimumZ >= maximumZ) { + return false; + } + int minimumTileX = network.tileXForBlock(minimumX); + int minimumTileZ = network.tileZForBlock(minimumZ); + int maximumTileX = network.tileXForBlock(maximumX - 1); + int maximumTileZ = network.tileZForBlock(maximumZ - 1); + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + RiverSample sample = tileCache.get(tileX, tileZ).sampleFootprint( + minimumX, + minimumZ, + maximumX, + maximumZ + ); + if (sample.present()) { + return true; + } + } + } + return false; + } + public List candidateAnchors( int minimumX, int minimumZ, @@ -481,7 +562,9 @@ public final class IrisRiverRuntime implements AutoCloseable { } public int maximumTunnelHeadroom() { - return caves.getDryHeadroom() + (int) StrictMath.ceil(terrain.getTunnelRoofVariation()); + return caves.getDryHeadroom() + + (int) StrictMath.ceil(terrain.getTunnelRoofVariation()) + + (int) StrictMath.ceil(terrain.getTunnelMouthBlend()); } public boolean acceptsCaveAnchor(RiverAnchor anchor) { @@ -535,6 +618,52 @@ public final class IrisRiverRuntime implements AutoCloseable { return false; } + public boolean acceptsDeepPoolAnchor(RiverAnchor anchor) { + Objects.requireNonNull(anchor); + IrisRiverDeepPools deepPools = caves.getDeepPools(); + if (deepPools == null + || !deepPools.isEnabled() + || deepPools.getMaximumPerReach() <= 0 + || anchor.state() != RiverRouteState.WET + || !caveHydrologyActive) { + return false; + } + RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); + if (reach == null || reach.state() != RiverRouteState.WET) { + return false; + } + if (!deepPoolReachEligible(deepPools, reach)) { + return false; + } + TerminalCaveAnchor terminal = terminalCaveAnchor(reach); + if (terminal != null && anchor.stableId() == terminal.stableId()) { + return false; + } + double firstDistance = unit(art.arcane.iris.engine.river.RiverNetwork.mix( + reach.id().stableId() ^ anchor.samplingSalt() + )) * anchor.samplingSpacing(); + double anchorDistance = firstDistance + anchor.index() * anchor.samplingSpacing(); + if (anchorDistance >= reach.polyline().length()) { + return false; + } + int accepted = 0; + for (int index = 0; index <= anchor.index(); index++) { + long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( + reach.id().stableId() + ^ anchor.samplingSalt() + ^ (long) index * 0x9E3779B97F4A7C15L + ); + if (index == anchor.index()) { + return stableId == anchor.stableId() && accepted < deepPools.getMaximumPerReach(); + } + accepted++; + if (accepted >= deepPools.getMaximumPerReach()) { + return false; + } + } + return false; + } + private boolean isCaveAnchorSourceable(double x, double z) { IrisRiverSurfaceSample surface = sample(x, z); if (surface.river().present() @@ -603,6 +732,7 @@ public final class IrisRiverRuntime implements AutoCloseable { @Override public void close() { tileCache.close(); + tunnelSampleCache.invalidateAll(); settingsCache.clear(); biomePoolCache.clear(); } @@ -620,7 +750,7 @@ public final class IrisRiverRuntime implements AutoCloseable { .routingDeviationScaleCells(topology.getRoutingDeviationScaleCells()) .routingDeviationStrengthCells(topology.getRoutingDeviationStrengthCells()) .routingPlateauHeight(topology.getRoutingPlateauHeight()) - .hydraulicBaseHeight(fluidHeight) + .hydraulicBaseHeight(riverFluidHeight) .requireOcean(topology.isRequireOcean()) .sourceChance(chance(topology.getSource())) .reachChance(chance(topology.getContinuation())) @@ -633,6 +763,7 @@ public final class IrisRiverRuntime implements AutoCloseable { .channelWidth(mid(riverTerrain.getChannelWidth(), 12D)) .bankWidth(mid(riverTerrain.getBankWidth(), 8D)) .depth(mid(riverTerrain.getDepth(), 4D)) + .channelRadiusBonus(riverTerrain.getChannelRadiusBonus()) .maxChannelWidth(riverTerrain.getMaxChannelWidth()) .maxBankWidth(riverTerrain.getMaxBankWidth()) .maxDepth(riverTerrain.getMaxDepth()) @@ -683,6 +814,7 @@ public final class IrisRiverRuntime implements AutoCloseable { configured.getDepthMultiplier(), configured.getBodyWavelength(), configured.getBodyDetailWavelength(), + configured.getBodyDetailInfluence(), configured.getWidthVariation(), configured.getBankVariation(), configured.getDepthVariation(), @@ -757,6 +889,24 @@ public final class IrisRiverRuntime implements AutoCloseable { return unit(hash) < chance; } + private boolean deepPoolReachEligible( + IrisRiverDeepPools deepPools, + RiverReach reach + ) { + CenterlinePosition center = centerlinePosition(reach, 0.5D); + EffectiveRiverSettings settings = settingsAt(center.x(), center.z()); + double chance = clamp01(effectiveChance( + deepPools.getReach(), + deepPoolReachNoise, + (int) StrictMath.floor(center.x()), + (int) StrictMath.floor(center.z()) + ) * settings.caveEntryMultiplier()); + long hash = art.arcane.iris.engine.river.RiverNetwork.mix( + seed ^ reach.id().stableId() ^ DEEP_POOL_REACH_GATE_SALT + ); + return unit(hash) < chance; + } + private TerminalCaveAnchor terminalCaveAnchor(RiverReach reach) { if (!caveHydrologyActive || !reach.terminal() || reach.state() != RiverRouteState.WET) { return null; @@ -809,8 +959,11 @@ public final class IrisRiverRuntime implements AutoCloseable { } double waterSurface(RiverReach reach, double alongReach, boolean naturalOcean) { - if (reach == null || water.getMode() == IrisRiverWaterMode.SEA_LEVEL || naturalOcean) { - return fluidHeight; + if (naturalOcean) { + return dimensionFluidHeight; + } + if (reach == null || water.getMode() == IrisRiverWaterMode.FIXED) { + return riverFluidHeight; } return terracedWaterSurface( reach.from().hydraulicHeight(), @@ -883,10 +1036,10 @@ public final class IrisRiverRuntime implements AutoCloseable { private int nodeWaterHead(double naturalNodeHeight, int dropHeight) { int availableRise = Math.max(0, water.getMaximumPoolRise()); - int maximumHead = fluidHeight + availableRise; + int maximumHead = riverFluidHeight + availableRise; int naturalHead = (int) StrictMath.floor(naturalNodeHeight - 1D); - int clamped = Math.max(fluidHeight, Math.min(maximumHead, naturalHead)); - return fluidHeight + Math.floorDiv(clamped - fluidHeight, dropHeight) * dropHeight; + int clamped = Math.max(riverFluidHeight, Math.min(maximumHead, naturalHead)); + return riverFluidHeight + Math.floorDiv(clamped - riverFluidHeight, dropHeight) * dropHeight; } private static boolean isNaturalOcean(IrisBiome biome) { @@ -1011,6 +1164,25 @@ public final class IrisRiverRuntime implements AutoCloseable { return (hash >>> 11) * 0x1.0p-53; } + private static final class TunnelSampleChunk { + private final AtomicReferenceArray samples = new AtomicReferenceArray<>(256); + + private IrisRiverTunnelSample sample(IrisRiverRuntime runtime, int x, int z) { + int index = ((x & 15) << 4) | (z & 15); + Object cached = samples.get(index); + if (cached == null) { + IrisRiverTunnelSample computed = runtime.sampleTunnel((double) x, (double) z); + Object encoded = computed == null ? ABSENT_TUNNEL_SAMPLE : computed; + if (samples.compareAndSet(index, null, encoded)) { + cached = encoded; + } else { + cached = samples.get(index); + } + } + return cached == ABSENT_TUNNEL_SAMPLE ? null : (IrisRiverTunnelSample) cached; + } + } + private final class RuntimeTerrainSampler implements RiverTerrainSampler { private final IrisRiverTopology topology; @@ -1022,11 +1194,11 @@ public final class IrisRiverRuntime implements AutoCloseable { public RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { boolean oceanIntent = Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)); boolean naturalHeightRequired = topology.getTerrainHeightWeight() > 0D - || water.getMode() != IrisRiverWaterMode.SEA_LEVEL + || water.getMode() != IrisRiverWaterMode.FIXED || oceanIntent; double sampledNaturalHeight = naturalHeightRequired ? naturalHeight.get(blockX, blockZ) - : fluidHeight; + : dimensionFluidHeight; boolean sampledOcean = oceanIntent && isSubmergedOutlet(sampledNaturalHeight); IrisRegion sampledRegion = region.get(blockX, blockZ); IrisBiome sampledBiome = biomeRiverOverridesPossible ? naturalBiome.get(blockX, blockZ) : null; @@ -1068,7 +1240,7 @@ public final class IrisRiverRuntime implements AutoCloseable { } private boolean isSubmergedOutlet(double sampledNaturalHeight) { - return Math.round(sampledNaturalHeight) < Math.round(fluidHeight); + return Math.round(sampledNaturalHeight) < Math.round(dimensionFluidHeight); } @Override @@ -1150,8 +1322,8 @@ public final class IrisRiverRuntime implements AutoCloseable { } maximumIncision *= settings.maxIncisionMultiplier(); } - double head = configuration.getWater().getMode() == IrisRiverWaterMode.SEA_LEVEL - ? fluidHeight + double head = configuration.getWater().getMode() == IrisRiverWaterMode.FIXED + ? riverFluidHeight : terracedWaterSurface( context.from().hydraulicHeight(), context.to().hydraulicHeight(), diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java index 7d49a5780..27a1e69a5 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java @@ -12,7 +12,8 @@ public record IrisRiverRuntimeContext( long seed, IrisRiverNetwork configuration, IrisData data, - int fluidHeight, + int riverFluidHeight, + int dimensionFluidHeight, boolean boreMantleActive, boolean caveHydrologyActive, boolean blockingRoutingPossible, diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index 2201f5f3f..a6b81b666 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -52,6 +52,7 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.event.Listener; import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.bukkit.util.Vector; @@ -236,6 +237,11 @@ public final class BukkitPlatform implements IrisPlatform { return io.papermc.lib.PaperLib.teleportAsync(entity, destination); } + public static java.util.concurrent.CompletableFuture teleportAsync(Entity entity, Location destination, + PlayerTeleportEvent.TeleportCause cause) { + return io.papermc.lib.PaperLib.teleportAsync(entity, destination, cause); + } + public static boolean isPaperServer() { return io.papermc.lib.PaperLib.isPaper(); } diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java index 46a492a9c..bb67b965c 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java @@ -19,12 +19,55 @@ public class SlimJar { private static final ReentrantLock lock = new ReentrantLock(); private static final AtomicBoolean loaded = new AtomicBoolean(); + public static void loadBootstrap(Path downloadPath, BootstrapLogger logger) { + if (loaded.get()) { + return; + } + lock.lock(); + try { + if (loaded.get()) { + return; + } + ApplicationBuilder.appending("Iris") + .injectableFactory(InjectableFactory.selecting( + InjectableFactory.ERROR, + InjectableFactory.INJECTABLE, + InjectableFactory.WRAPPED, + InjectableFactory.UNSAFE)) + .downloadDirectoryPath(downloadPath) + .logger(new ProcessLogger() { + @Override + public void info(@NotNull String message, @Nullable Object... args) { + logger.info(message.formatted(args)); + } + + @Override + public void error(@NotNull String message, @Nullable Object... args) { + logger.error(message.formatted(args)); + } + + @Override + public void debug(@NotNull String message, @Nullable Object... args) { + logger.debug(message.formatted(args)); + } + }) + .build(); + loaded.set(true); + } finally { + lock.unlock(); + } + } + public static void load() { - if (loaded.get()) return; + if (loaded.get()) { + return; + } lock.lock(); try { - if (loaded.getAndSet(true)) return; + if (loaded.get()) { + return; + } VolmitPlugin plugin = BukkitPlatform.volmitPlugin(); Path downloadPath = plugin.getDataFolder("cache", "libraries").toPath(); debug(plugin, "Loading libraries..."); @@ -58,6 +101,7 @@ public class SlimJar { }) .build(); } + loaded.set(true); debug(plugin, "Libraries loaded successfully!"); } finally { lock.unlock(); @@ -69,4 +113,12 @@ public class SlimJar { plugin.getLogger().info("[DEBUG] " + message); } } + + public interface BootstrapLogger { + void info(String message); + + void error(String message); + + void debug(String message); + } } diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java index ec54690d7..f811bfc5d 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java @@ -303,6 +303,43 @@ public class J { return true; } + public static CompletableFuture runRegionFuture( + World world, + int chunkX, + int chunkZ, + Runnable runnable + ) { + if (world == null || runnable == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Region task world and runnable are required.")); + } + + if (isFolia()) { + CompletableFuture future = new CompletableFuture<>(); + if (isOwnedByCurrentRegion(world, chunkX, chunkZ)) { + settle(future, runnable); + return future; + } + if (!runRegionImmediate( + world, + chunkX, + chunkZ, + () -> settle(future, runnable))) { + future.completeExceptionally(new IllegalStateException( + "Failed to schedule region task for " + world.getName() + + "@" + chunkX + "," + chunkZ + ".")); + } + return future; + } + + if (isPrimaryThread()) { + CompletableFuture future = new CompletableFuture<>(); + settle(future, runnable); + return future; + } + return sfut(runnable); + } + public static boolean runGlobal(Runnable runnable) { if (runnable == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java index 3d9c5807f..d18492c5b 100644 --- a/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java @@ -1,6 +1,7 @@ package art.arcane.iris.util.project.matter.slices; import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.volmlib.util.data.palette.Palette; import art.arcane.volmlib.util.matter.Sliced; @@ -28,19 +29,21 @@ public final class RiverCaveHydrologyMatter extends RawMatter 1; - case FALLING_WATER -> 2; + case FALLING_FLUID -> 2; case DRY_AIR -> 3; case SEAL_GUARD -> 4; }; @@ -49,10 +52,25 @@ public final class RiverCaveHydrologyMatter extends RawMatter RiverCaveAction.WET_SOURCE; - case 2 -> RiverCaveAction.FALLING_WATER; + case 2 -> RiverCaveAction.FALLING_FLUID; case 3 -> RiverCaveAction.DRY_AIR; case 4 -> RiverCaveAction.SEAL_GUARD; default -> throw new IOException("Unknown river cave hydrology action code " + code); }; } + + private int fluidKindCode(RiverCaveFluidKind fluidKind) { + return switch (fluidKind) { + case RIVER -> 1; + case DEEP_POOL -> 2; + }; + } + + private RiverCaveFluidKind fluidKindFromCode(int code) throws IOException { + return switch (code) { + case 1 -> RiverCaveFluidKind.RIVER; + case 2 -> RiverCaveFluidKind.DEEP_POOL; + default -> throw new IOException("Unknown river cave fluid-kind code " + code); + }; + } } diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index 316bfad87..b9b8120fb 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -447,6 +447,15 @@ public class ServerConfiguratorDatapackFingerprintTest { assertFalse(ServerConfigurator.loadedRegistrySatisfies( loaded, Map.of("worldgen/biome/overworld:new", "biome-new"))); + assertFalse(ServerConfigurator.runtimeRequiresRegistryRestart( + loaded, + Map.of( + "dimension_type/iris:overworld", "dimension-a", + "worldgen/biome/overworld:forest", "biome-a"))); + assertTrue(ServerConfigurator.runtimeRequiresRegistryRestart( + loaded, + Map.of("dimension_type/iris:overworld", "dimension-b"))); + assertFalse(ServerConfigurator.runtimeRequiresRegistryRestart(loaded, Map.of())); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java new file mode 100644 index 000000000..ba2ab7d23 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KList; +import org.bukkit.Bukkit; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.File; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class ServerConfiguratorRegistryVerificationTest { + @Test + public void freshlyCompiledRegistryMissReportsOneRestartWarning() { + VerificationFixture fixture = missingRegistryFixture(); + + try (MockedStatic logging = mockStatic(IrisLogging.class); + MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(fixture.binding()); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + + assertTrue(ServerConfigurator.verifyDataPacksPost(Stream.of(fixture.data()))); + + logging.verify(() -> IrisLogging.debug("Checking Pack: packs/overworld")); + logging.verify(() -> IrisLogging.warn(ServerConfigurator.POST_COMPILE_RESTART_WARNING)); + logging.verifyNoMoreInteractions(); + } + } + + @Test + public void worldCreationRegistryMissRetainsFullOperationalError() { + VerificationFixture fixture = missingRegistryFixture(); + + try (MockedStatic logging = mockStatic(IrisLogging.class); + MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(fixture.binding()); + + assertFalse(ServerConfigurator.verifyDataPackInstalled(fixture.dimension())); + + logging.verify(() -> IrisLogging.warn( + "The Biome overworld:missing is not registered on the server.")); + logging.verify(() -> IrisLogging.warn( + "The Dimension Type for packs/overworld/dimensions/overworld.json is not registered on the server.")); + logging.verify(() -> IrisLogging.error( + "The Pack overworld is INCAPABLE of generating custom biomes")); + logging.verify(() -> IrisLogging.error( + "If not done automatically, restart your server before generating with this pack!")); + logging.verifyNoMoreInteractions(); + } + } + + @SuppressWarnings("unchecked") + private static VerificationFixture missingRegistryFixture() { + IrisBiomeCustom customBiome = mock(IrisBiomeCustom.class); + when(customBiome.getId()).thenReturn("missing"); + + IrisBiome biome = mock(IrisBiome.class); + when(biome.isCustom()).thenReturn(true); + when(biome.getCustomDerivitives()).thenReturn(new KList<>(customBiome)); + + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(new File("packs/overworld")); + + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getAllBiomes(any())).thenReturn(new KList<>(biome)); + when(dimension.getLoadKey()).thenReturn("overworld"); + when(dimension.getLoadFile()).thenReturn( + new File("packs/overworld/dimensions/overworld.json")); + when(dimension.getDimensionTypeKey()).thenReturn("iris:overworld"); + when(dimension.getLoader()).thenReturn(data); + + ResourceLoader loader = mock(ResourceLoader.class); + when(loader.getPossibleKeys()).thenReturn(new String[]{"overworld"}); + when(loader.loadAll(any(String[].class))).thenReturn(new KList<>(dimension)); + when(data.getDimensionLoader()).thenReturn(loader); + + INMSBinding binding = mock(INMSBinding.class); + when(binding.supportsDataPacks()).thenReturn(true); + when(binding.getCustomBiomeBaseFor("overworld:missing")).thenReturn(null); + when(binding.missingDimensionTypes("iris:overworld")).thenReturn(true); + return new VerificationFixture(data, dimension, binding); + } + + private record VerificationFixture( + IrisData data, + IrisDimension dimension, + INMSBinding binding + ) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java index 090c70182..443fc3593 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java @@ -11,6 +11,7 @@ import art.arcane.volmlib.util.localization.MessageValue; import art.arcane.volmlib.util.localization.PluralValue; import art.arcane.volmlib.util.localization.TextValue; import art.arcane.volmlib.util.localization.VolmitLocales; +import com.google.gson.JsonArray; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -174,6 +175,29 @@ public class IrisLanguageTest { } } + @Test + public void compactBukkitLocaleUsesSortedCatalogPositionsAndEnglishFallbacks() { + List messageIds = IrisLanguage.catalog().ids().stream() + .filter(id -> !id.startsWith("iris.modded.")) + .sorted() + .toList(); + String translatedId = IrisMessages.COMMAND_UNKNOWN.id(); + JsonArray values = new JsonArray(messageIds.size()); + for (int i = 0; i < messageIds.size(); i++) { + values.add(messageIds.get(i).equals(translatedId) ? "Unbekannter Iris-Befehl" : null); + } + JsonArray compact = new JsonArray(2); + compact.add("de_DE"); + compact.add(values); + + LocaleOverlay overlay = IrisLanguage.parseOverlay("test", "de_DE", compact.toString()); + + assertEquals(Set.of(translatedId), overlay.values().keySet()); + assertEquals( + "Unbekannter Iris-Befehl", + ((TextValue) overlay.value(translatedId)).template()); + } + @Test public void bundledServerResourcesExactlyMatchNonEnglishManifest() throws Exception { Set expected = VolmitLocales.nonEnglish().stream() diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index 594e61883..e34d6ca2e 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -651,6 +651,35 @@ public class PackDownloaderTest { assertEquals(0, PackDownloader.downloadLockCount()); } + @Test + public void invalidBuiltInRiverSchemaPreservesExistingTarget() throws Exception { + File packsFolder = temp.newFolder("invalid-river-packs"); + File target = writePack(packsFolder.toPath().resolve("overworld"), "overworld", "old"); + File extracted = writePack(temp.newFolder("invalid-river-source").toPath(), "overworld", "new"); + Files.writeString( + extracted.toPath().resolve("dimensions/overworld.json"), + "{\"name\":\"Overworld\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320},\"rivers\":{\"enabled\":true," + + "\"terrain\":{},\"water\":{\"mode\":\"SEA_LEVEL\"}}}", + StandardCharsets.UTF_8 + ); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "overworld", + ignored -> { + } + ); + + assertNull(result); + assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTrue(Files.isRegularFile(target.toPath().resolve("regions/local.json"))); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } + @Test public void forceOverwriteReplacesEnginelessLoadedPackData() throws Exception { // A registered loader with no engines is a stale catalog registration (startup diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java index dbdc74c03..4b26d4988 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java @@ -127,8 +127,9 @@ public class PackRiverValidatorTest { { "id": "trunk", "seed": 17, - "bodyWavelength": 31, + "bodyWavelength": 7, "bodyDetailWavelength": 16385, + "bodyDetailInfluence": 1.1, "widthVariation": -0.1, "bankVariation": 0.876, "depthVariation": -0.1, @@ -150,8 +151,9 @@ public class PackRiverValidatorTest { PackRiverValidator.Validation result = validate(pack); - assertContains(result.errors(), "rivers.terrain.worms[0].bodyWavelength must be at least 32"); + assertContains(result.errors(), "rivers.terrain.worms[0].bodyWavelength must be at least 8"); assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailWavelength must be at most 16384"); + assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailInfluence must be at most 1"); assertContains(result.errors(), "rivers.terrain.worms[0].widthVariation must be at least 0"); assertContains(result.errors(), "rivers.terrain.worms[0].bankVariation must be at most 0.875"); assertContains(result.errors(), "rivers.terrain.worms[0].depthVariation must be at least 0"); @@ -357,6 +359,140 @@ public class PackRiverValidatorTest { assertContains(result.errors(), "rivers.water.dropHeight must not exceed maximumPoolRise"); } + @Test + public void acceptsIndependentCaveLavaRiverHeightAndPalette() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 320}, + "fluidHeight": 63, + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, + "water": { + "mode": "FIXED", + "fluidHeight": -48, + "fluidPalette": { + "palette": [{"block": "minecraft:lava"}] + } + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void acceptsSparseBlobbyDeepLavaPoolsWithIndependentHeight() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, + "caves": { + "deepPools": { + "enabled": true, + "reach": { + "chance": 0.08, + "influence": 0.04, + "style": {"style": "IRIS", "zoom": 4096} + }, + "minimumSpacing": 768, + "maximumPerReach": 1, + "minimumFluidY": -224, + "maximumFluidY": -108, + "searchRadius": 20, + "searchAttempts": 12, + "horizontalRadius": 24, + "verticalRadius": 10, + "dryHeadroom": 5, + "shapeStyle": {"style": "IRIS", "zoom": 12}, + "shapeVariation": 0.6, + "warpStyle": {"style": "IRIS", "zoom": 24}, + "warpStrength": 8, + "maximumVolume": 65536, + "fluidPalette": { + "palette": [{"block": "minecraft:lava"}] + } + } + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void rejectsUnsafeDeepPoolEnvelopeShapeAndPalette() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 320}, + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, + "caves": { + "deepPools": { + "enabled": true, + "reach": {"chance": 0}, + "minimumFluidY": -90, + "maximumFluidY": -120, + "searchRadius": 120, + "horizontalRadius": 20, + "verticalRadius": 8, + "dryHeadroom": 8, + "maximumVolume": 64, + "fluidPalette": {"palette": []} + } + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "deepPools.minimumFluidY must not exceed maximumFluidY"); + assertContains(result.errors(), "deepPools.dryHeadroom must be smaller than verticalRadius"); + assertContains(result.errors(), "deepPools.searchRadius plus horizontalRadius must not exceed 128"); + assertContains(result.errors(), "deepPools.maximumVolume must be at least"); + assertContains(result.errors(), "deepPools.fluidPalette.palette must contain at least one fluid block"); + assertContains(result.errors(), "deepPools fluid range and chamber envelope must remain inside dimensionHeight"); + assertContains(result.warnings(), "deepPools is enabled but its reach gate cannot accept any pools"); + } + + @Test + public void rejectsRetiredWaterModeInvalidPaletteAndOutOfBoundsHeight() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 320}, + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, + "water": { + "mode": "SEA_LEVEL", + "fluidHeight": -80, + "fluidPalette": {"palette": []} + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.water.mode must be one of"); + assertContains(result.errors(), "rivers.water.fluidHeight must remain inside dimensionHeight"); + assertContains(result.errors(), "rivers.water.fluidPalette.palette must contain at least one fluid block"); + } + @Test public void rejectsPathologicalCombinedTopologyComplexity() throws Exception { File pack = pack(""" diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index bebc72762..1e5bbea4f 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -3,11 +3,17 @@ package art.arcane.iris.core.pregenerator.methods; import org.junit.Test; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +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.assertSame; +import static org.junit.Assert.assertTrue; public class AsyncPregenMethodConcurrencyCapTest { @Test @@ -77,4 +83,31 @@ public class AsyncPregenMethodConcurrencyCapTest { assertEquals(0, slowRequests.get()); assertEquals("generated", request.join()); } + + @Test + public void closeDrainWaitsPastWarningIntervalsUntilEveryPermitReturns() throws Exception { + Semaphore semaphore = new Semaphore(0); + AtomicInteger warnings = new AtomicInteger(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future drain = executor.submit(() -> AsyncPregenMethod.awaitDrain( + semaphore, + 2, + 10L, + TimeUnit.MILLISECONDS, + warnings::incrementAndGet + )); + + while (warnings.get() == 0) { + Thread.onSpinWait(); + } + semaphore.release(2); + + assertFalse(drain.get(1L, TimeUnit.SECONDS)); + assertTrue(warnings.get() > 0); + assertEquals(0, semaphore.availablePermits()); + } finally { + executor.shutdownNow(); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java index b4191e43c..14357f38b 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java @@ -51,6 +51,14 @@ public class IrisImageMapSchemaTest { properties.getJSONObject("curveExponent").getDouble("minimum"), 0D); assertEquals(IrisImageMap.MAXIMUM_COLOR_TOLERANCE, properties.getJSONObject("colorTolerance").getDouble("maximum"), 0D); + JSONObject origin = properties.getJSONObject("origin"); + JSONObject originProperties = mapSchema.getJSONObject("definitions") + .getJSONObject(origin.getString("$ref").substring("#/definitions/".length())) + .getJSONObject("properties"); + assertTrue(originProperties.getJSONObject("x").getString("description") + .contains("X coordinate in world blocks for origin, or source pixels for sourceOrigin")); + assertTrue(originProperties.getJSONObject("z").getString("description") + .contains("Z coordinate in world blocks for origin, or source image Y pixels for sourceOrigin")); } @SuppressWarnings("unchecked") diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java index 4c9bc5e11..4619e1632 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -4,6 +4,7 @@ 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.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisExpression; import art.arcane.iris.engine.object.IrisRiverNetwork; import art.arcane.iris.engine.object.IrisRiverOverride; @@ -11,6 +12,11 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.util.ArrayList; @@ -23,6 +29,29 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class IrisRiverSchemaTest { + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + if (previousPlatform != null) { + IrisPlatforms.unbind(); + } + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockTypeKeys()).thenReturn(List.of()); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + @Test public void riverNetworkSchemaExposesNestedNoiseLimitsModesAndBiomePools() { JSONObject schema = new SchemaBuilder(IrisRiverNetwork.class, schemaData()).construct(); @@ -36,6 +65,8 @@ public class IrisRiverSchemaTest { JSONObject worm = referencedProperties(definitions, worms.getJSONObject("items")); JSONObject water = referencedProperties(definitions, properties.getJSONObject("water")); JSONObject biomes = referencedProperties(definitions, properties.getJSONObject("biomes")); + JSONObject caves = referencedProperties(definitions, properties.getJSONObject("caves")); + JSONObject deepPools = referencedProperties(definitions, caves.getJSONObject("deepPools")); assertEquals("boolean", properties.getJSONObject("enabled").getString("type")); assertEquals(64, topology.getJSONObject("cellSize").getInt("minimum")); @@ -60,8 +91,9 @@ public class IrisRiverSchemaTest { assertEquals(0.125D, worm.getJSONObject("widthMultiplier").getDouble("minimum"), 0D); assertEquals(8D, worm.getJSONObject("bankMultiplier").getDouble("maximum"), 0D); assertEquals(8D, worm.getJSONObject("depthMultiplier").getDouble("maximum"), 0D); - assertEquals(32D, worm.getJSONObject("bodyWavelength").getDouble("minimum"), 0D); + assertEquals(8D, worm.getJSONObject("bodyWavelength").getDouble("minimum"), 0D); assertEquals(16384D, worm.getJSONObject("bodyDetailWavelength").getDouble("maximum"), 0D); + assertEquals(1D, worm.getJSONObject("bodyDetailInfluence").getDouble("maximum"), 0D); assertEquals(0.875D, worm.getJSONObject("widthVariation").getDouble("maximum"), 0D); assertEquals(0.875D, worm.getJSONObject("bankVariation").getDouble("maximum"), 0D); assertEquals(0.875D, worm.getJSONObject("depthVariation").getDouble("maximum"), 0D); @@ -72,12 +104,27 @@ public class IrisRiverSchemaTest { assertEquals(1D, worm.getJSONObject("childChance").getDouble("maximum"), 0D); assertEquals(1D, worm.getJSONObject("branchChildChance").getDouble("maximum"), 0D); assertEquals("array", worm.getJSONObject("children").getString("type")); - assertEquals(List.of("SEA_LEVEL", "TERRACED"), enumValues(definitions, water.getJSONObject("mode"))); + assertEquals(List.of("FIXED", "TERRACED"), enumValues(definitions, water.getJSONObject("mode"))); + assertEquals(-2048, water.getJSONObject("fluidHeight").getInt("minimum")); + assertEquals(2048, water.getJSONObject("fluidHeight").getInt("maximum")); + assertTrue(water.has("fluidPalette")); + assertEquals(64D, terrain.getJSONObject("channelRadiusBonus").getDouble("maximum"), 0D); assertEquals("array", biomes.getJSONObject("channel").getString("type")); assertEquals("#/definitions/erzbiomes", biomes.getJSONObject("channel").getJSONObject("items").getString("$ref")); assertTrue(properties.has("terrain")); assertTrue(properties.has("caves")); + assertEquals("boolean", deepPools.getJSONObject("enabled").getString("type")); + assertEquals(-2048, deepPools.getJSONObject("minimumFluidY").getInt("minimum")); + assertEquals(2048, deepPools.getJSONObject("maximumFluidY").getInt("maximum")); + assertEquals(128, deepPools.getJSONObject("horizontalRadius").getInt("maximum")); + assertEquals(64, deepPools.getJSONObject("verticalRadius").getInt("maximum")); + assertEquals(0.75D, deepPools.getJSONObject("shapeVariation").getDouble("maximum"), 0D); + assertEquals(64D, deepPools.getJSONObject("warpStrength").getDouble("maximum"), 0D); + assertTrue(deepPools.has("reach")); + assertTrue(deepPools.has("shapeStyle")); + assertTrue(deepPools.has("warpStyle")); + assertTrue(deepPools.has("fluidPalette")); } @Test @@ -98,14 +145,17 @@ public class IrisRiverSchemaTest { IrisData data = mock(IrisData.class); ResourceLoader biomeLoader = mock(ResourceLoader.class); ResourceLoader expressionLoader = mock(ResourceLoader.class); + ResourceLoader blockLoader = mock(ResourceLoader.class); KMap, ResourceLoader> loaders = new KMap<>(); loaders.put(IrisBiome.class, biomeLoader); loaders.put(IrisExpression.class, expressionLoader); + when(data.getBlockLoader()).thenReturn(blockLoader); when(data.getLoaders()).thenReturn(loaders); when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); when(biomeLoader.getPossibleKeys()).thenReturn(new String[]{"river/channel"}); when(biomeLoader.getFolderName()).thenReturn("biomes"); when(biomeLoader.getResourceTypeName()).thenReturn("Biome"); + when(blockLoader.getPossibleKeys()).thenReturn(new String[0]); when(expressionLoader.getPossibleKeys()).thenReturn(new String[0]); when(expressionLoader.getFolderName()).thenReturn("expressions"); when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java index 399e885dc..ece8ac6f5 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java @@ -69,10 +69,26 @@ public class StudioOpenCoordinatorOpenKindTest { null); assertEquals(StudioOpenCoordinator.StudioOpenKind.JIGSAW, request.openKind()); + assertTrue(request.requestedAtNanos() > 0L); } @Test - public void activeStudioTeleportIsSerializedAndBoundedBeforeNativeDelegation() throws Exception { + public void nativeTeleportWaitsForCompletionWithoutAnArrivalDeadline() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")) + .replace("\r\n", "\n"); + int methodStart = source.indexOf("private void executeOpen("); + int delegation = source.indexOf("WorldRuntimeControlService.get().teleportInMode(", methodStart); + int completion = source.indexOf("nativeTeleportFuture.get();", delegation); + + assertTrue(delegation >= 0); + assertTrue(completion > delegation); + assertFalse(source.substring(methodStart, completion).contains("orTimeout(")); + assertFalse(source.substring(methodStart, completion).contains("deadlineNanos")); + } + + @Test + public void activeStudioTeleportIsSerializedWithoutAnArrivalDeadline() throws Exception { String coordinator = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")) .replace("\r\n", "\n"); @@ -89,28 +105,22 @@ public class StudioOpenCoordinatorOpenKindTest { String serviceMethod = service.substring(serviceStart, serviceEnd); int transitionAdmission = serviceMethod.indexOf("studioTransitions.submit(() ->"); int projectCapture = serviceMethod.indexOf("IrisProject project = activeProject"); - int publicDeadline = serviceMethod.indexOf( - "transition.orTimeout(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS"); - int admissionClose = serviceMethod.indexOf( - "transition.whenComplete((ignored, failure) -> admission.set(false))"); - int nativeClaim = coordinatorMethod.indexOf( - "activeAdmission.compareAndSet(true, false)"); int nativeDelegation = coordinatorMethod.indexOf( "WorldRuntimeControlService.get().teleportInMode(player, entry, GameMode.SPECTATOR)"); assertTrue(transitionAdmission >= 0); assertTrue(projectCapture > transitionAdmission); - assertTrue(publicDeadline > projectCapture); - assertTrue(admissionClose > publicDeadline); - assertTrue(serviceMethod.contains("deadlineNanos")); + assertFalse(serviceMethod.contains("orTimeout(")); + assertFalse(serviceMethod.contains("deadlineNanos")); assertTrue(coordinatorMethod.contains( "WorldRuntimeControlService.get().resolveEntryAnchor(world, provider)")); assertTrue(coordinatorMethod.contains( "project.getActiveOpenKind() == StudioOpenKind.STANDARD")); assertFalse(coordinatorMethod.contains("requestChunkAsync(")); assertFalse(coordinatorMethod.contains("getHighestBlockYAt(")); - assertTrue(nativeClaim >= 0); - assertTrue(nativeDelegation > nativeClaim); + assertTrue(nativeDelegation >= 0); + assertFalse(coordinatorMethod.contains("orTimeout(")); + assertFalse(coordinatorMethod.contains("deadlineNanos")); } @Test @@ -161,6 +171,7 @@ public class StudioOpenCoordinatorOpenKindTest { "prepare_generator", "resolve_entry_anchor", "prepare_structure_rings", + "prepare_generation_caches", "teleport_standard_entry", "finalize_open")) { int current = source.indexOf("\"" + phase + "\"", previous + 1); @@ -174,11 +185,13 @@ public class StudioOpenCoordinatorOpenKindTest { public void structureStateCompletesBeforeImmediateNativeTeleport() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")).replace("\r\n", "\n"); - int completionCall = source.indexOf("endStudioEntryBootstrap(world, provider)"); - int teleport = source.indexOf( - "WorldRuntimeControlService.get().teleportInMode(", completionCall); + int executeOpen = source.indexOf("private void executeOpen("); + int completionCall = source.indexOf("endStudioEntryBootstrap(entryWorld, entryProvider)", executeOpen); + int completionAwait = source.indexOf("entryBootstrap.get(", completionCall); + int cacheAwait = source.indexOf("irisEngine.awaitGenerationCacheWarm()", completionAwait); + int teleportStart = source.indexOf("WorldRuntimeControlService.get().teleportInMode(", cacheAwait); int finalizeOpen = source.indexOf( - "updateStage(request, \"finalize_open\", 1.00D)", teleport); + "updateStage(request, \"finalize_open\", 1.00D)", teleportStart); int futureComplete = source.indexOf( "future.complete(new StudioOpenResult(world, entryLocation))", finalizeOpen); int methodStart = source.indexOf( @@ -191,14 +204,15 @@ public class StudioOpenCoordinatorOpenKindTest { int gateRelease = method.indexOf("bukkitGenerator::endStudioEntryBootstrap"); assertTrue(completionCall >= 0); - assertTrue(teleport > completionCall); - assertFalse(source.contains("preparedEntryChunks")); - assertTrue(finalizeOpen > teleport); + assertTrue(completionAwait > completionCall); + assertTrue(cacheAwait > completionAwait); + assertTrue(teleportStart > cacheAwait); + assertTrue(finalizeOpen > teleportStart); assertTrue(futureComplete > finalizeOpen); + assertFalse(source.contains("requestChunkAsync(")); assertTrue(scheduled >= 0); assertTrue(claim > scheduled); assertTrue(activation > claim); - assertTrue(gateRelease > activation); int ringCompletion = method.indexOf("thenCompose(nativeActivation -> nativeActivation)"); assertTrue(ringCompletion > activation); assertTrue(gateRelease > ringCompletion); 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 index b0875fdcf..0080f6173 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java @@ -77,6 +77,27 @@ public class WorldRuntimeControlServiceSafeEntryTest { assertEquals(63, result.getBlockY()); } + @Test + public void resolvesSafeCavityBelowDimensionRoof() { + World world = loadedWorld(0, 0); + Block netherrack = block(Material.NETHERRACK, false, false, FULL_BLOCK); + Block air = block(Material.AIR, false, true); + doReturn(300).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + if (y == 201 || y == 202) { + return air; + } + return netherrack; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 201D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + + assertNotNull(result); + assertEquals(201, result.getBlockY()); + } + @Test public void rejectsFluidHazardousAndCollisionBlockedCandidates() { World world = loadedWorld(0, 0); @@ -166,7 +187,7 @@ public class WorldRuntimeControlServiceSafeEntryTest { Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); assertNull(result); - assertEquals(-1, lowestReadY.get()); + assertEquals(-64, lowestReadY.get()); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java index 9e06c5b3a..3cf2cb34a 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTeleportTest.java @@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class WorldRuntimeControlServiceTeleportTest { @@ -89,6 +90,30 @@ public class WorldRuntimeControlServiceTeleportTest { } } + @Test + public void successfulModeTeleportTemporarilyReducesAndRestoresViewDistance() { + Player player = mock(Player.class); + Location destination = mock(Location.class); + CompletableFuture nativeTeleport = new CompletableFuture<>(); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getViewDistance()).thenReturn(12); + + try (MockedStatic scheduling = immediateEntityScheduling()) { + CompletableFuture result = WorldRuntimeControlService.scheduleTeleport( + player, + destination, + GameMode.SPECTATOR, + (target, location) -> nativeTeleport); + nativeTeleport.complete(true); + + assertTrue(result.join()); + InOrder viewDistances = inOrder(player); + viewDistances.verify(player).setViewDistance(2); + viewDistances.verify(player).setViewDistance(12); + verify(player).setGameMode(GameMode.SPECTATOR); + } + } + private static MockedStatic immediateEntityScheduling() { MockedStatic scheduling = mockStatic(J.class); scheduling.when(() -> J.runEntity(any(Player.class), any(Runnable.class))).thenAnswer(invocation -> { diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorInitialSpawnTest.java new file mode 100644 index 000000000..96643b551 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorInitialSpawnTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.core.tools; + +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisCreatorInitialSpawnTest { + @Test + public void worldCreationWaitsForInitialSpawnCompletion() throws Exception { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + CompletableFuture initialSpawn = new CompletableFuture<>(); + CountDownLatch waitStarted = new CountDownLatch(1); + when(generator.getInitialSpawnReady()).thenAnswer(invocation -> { + waitStarted.countDown(); + return initialSpawn; + }); + + CompletableFuture wait = CompletableFuture.runAsync(() -> { + try { + IrisCreator.awaitInitialSpawnPreparation(generator, "test-world"); + } catch (Throwable failure) { + throw new RuntimeException(failure); + } + }); + + if (!waitStarted.await(5L, TimeUnit.SECONDS)) { + fail("Initial spawn wait did not start."); + } + if (wait.isDone()) { + fail("Initial spawn wait completed before the chunk was ready."); + } + initialSpawn.complete(null); + wait.get(5L, TimeUnit.SECONDS); + } + + @Test + public void initialSpawnFailureFailsWorldCreation() throws Exception { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + IllegalStateException failure = new IllegalStateException("spawn failed"); + when(generator.getInitialSpawnReady()).thenReturn(CompletableFuture.failedFuture(failure)); + + try { + IrisCreator.awaitInitialSpawnPreparation(generator, "test-world"); + fail("Expected initial spawn failure."); + } catch (ExecutionException exception) { + assertSame(failure, exception.getCause()); + } + } + + @Test + public void missingInitialSpawnFutureFailsWorldCreation() { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + when(generator.getInitialSpawnReady()).thenReturn(null); + + NullPointerException failure = assertThrows( + NullPointerException.class, + () -> IrisCreator.awaitInitialSpawnPreparation(generator, "test-world")); + + assertEquals("Initial spawn preparation future", failure.getMessage()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorProgressContractTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorProgressContractTest.java index b39d07025..6c3167af0 100644 --- a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorProgressContractTest.java +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorProgressContractTest.java @@ -43,4 +43,52 @@ public class IrisCreatorProgressContractTest { assertFalse(source.contains("RuntimeProgressMessages.WORLD_CREATE_ACTION")); assertFalse(source.contains("RuntimeProgressMessages.WORLD_CREATE_CONSOLE")); } + + @Test + public void persistentCreateReadinessPrecedesSuccessRegistrationAndLeaseRelease() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.irisCreatorSource"))) + .replace("\r\n", "\n"); + + int acquireLease = source.indexOf("worldLease = coordinator.acquire("); + int createReserved = source.indexOf( + "createReserved(worldKey, resolvedDimension, creationReporter)", + acquireLease); + int reportSuccess = source.indexOf("creationReporter.succeed()", createReserved); + int releaseLease = source.indexOf("worldLease.close()", reportSuccess); + int createWorld = source.indexOf("INMS.get().createWorldAsync(wc, request)", createReserved); + int persistentGuard = source.indexOf("if (!studio && !benchmark) {", createWorld); + int awaitSpawn = source.indexOf("awaitInitialSpawnPreparation(access, name)", persistentGuard); + int creationDone = source.indexOf("done.set(true)", awaitSpawn); + int registerWorld = source.indexOf("BukkitWorldConfiguration.register(", creationDone); + int returnWorld = source.indexOf("return world;", registerWorld); + + assertTrue(acquireLease >= 0); + assertTrue(createReserved > acquireLease); + assertTrue(reportSuccess > createReserved); + assertTrue(releaseLease > reportSuccess); + assertTrue(createWorld > createReserved); + assertTrue(persistentGuard > createWorld); + assertTrue(awaitSpawn > persistentGuard); + assertTrue(creationDone > awaitSpawn); + assertTrue(registerWorld > creationDone); + assertTrue(returnWorld > registerWorld); + } + + @Test + public void spawnReadinessFailureReachesWorldCreationRollback() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.irisCreatorSource"))) + .replace("\r\n", "\n"); + + int awaitSpawn = source.indexOf("awaitInitialSpawnPreparation(access, name)"); + int createReservedFailure = source.indexOf("} catch (Throwable failure) {", awaitSpawn); + int rollback = source.indexOf( + "rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure)", + createReservedFailure); + int rethrow = source.indexOf("throw irisException", rollback); + + assertTrue(awaitSpawn >= 0); + assertTrue(createReservedFailure > awaitSpawn); + assertTrue(rollback > createReservedFailure); + assertTrue(rethrow > rollback); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java index 01b258191..677157c2f 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java @@ -4,7 +4,6 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.object.IrisRiverWaterMode; import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.river.RiverSample; import art.arcane.iris.engine.river.RiverSection; @@ -60,50 +59,19 @@ public class IrisComplexSurfaceBiomeTest { } @Test - public void naturalOceanHeightMaskMatchesResolvedLandAndSeaBiomes() { - double fluidHeight = 64D; - IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); - IrisBiome sea = new IrisBiome().setInferredType(InferredType.SEA); - IrisBiome shore = new IrisBiome().setInferredType(InferredType.SHORE); - IrisRegion region = mock(IrisRegion.class); - for (double shoreHeight : new double[]{0D, 3D}) { - doReturn(shoreHeight).when(region).getShoreHeight(0D, 0D); - double[] heights = new double[]{ - fluidHeight - 2D, - fluidHeight - 1D, - fluidHeight, - fluidHeight + shoreHeight, - fluidHeight + shoreHeight + 0.000001D - }; - for (IrisBiome base : List.of(land, sea)) { - for (double height : heights) { - IrisBiome resolved = IrisComplex.resolveSurfaceBiome( - height, - base, - region, - 0D, - 0D, - fluidHeight, - constant(land), - constant(sea), - constant(shore) - ); - Boolean ocean = IrisComplex.createNaturalOceanStream( - ProceduralStream.ofDouble((x, z) -> height), - constantType(InferredType.LAND), - null, - fluidHeight, - IrisRiverWaterMode.TERRACED - ).get(0D, 0D); - - assertEquals(resolved.getInferredType() == InferredType.SEA, ocean.booleanValue()); - } - } - } + public void naturalOceanMaskTracksContinentalIntent() { + assertTrue(IrisComplex.createNaturalOceanStream( + constantType(InferredType.SEA), + null + ).get(0D, 0D)); + assertFalse(IrisComplex.createNaturalOceanStream( + constantType(InferredType.LAND), + null + ).get(0D, 0D)); } @Test - public void naturalOceanHeightMaskSamplesOnlyNaturalHeight() { + public void naturalOceanMaskDoesNotSampleNaturalHeight() { AtomicInteger heightSamples = new AtomicInteger(); ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { heightSamples.incrementAndGet(); @@ -111,15 +79,12 @@ public class IrisComplexSurfaceBiomeTest { }); Boolean ocean = IrisComplex.createNaturalOceanStream( - height, constantType(InferredType.LAND), - null, - 64D, - IrisRiverWaterMode.TERRACED + null ).get(8D, -3D); - assertTrue(ocean); - assertEquals(1, heightSamples.get()); + assertFalse(ocean); + assertEquals(0, heightSamples.get()); } @Test @@ -131,31 +96,22 @@ public class IrisComplexSurfaceBiomeTest { }); assertTrue(IrisComplex.createNaturalOceanStream( - height, constantType(InferredType.LAND), - new IrisBiome().setInferredType(InferredType.SEA), - 64D, - IrisRiverWaterMode.SEA_LEVEL + new IrisBiome().setInferredType(InferredType.SEA) ).get(0D, 0D)); assertFalse(IrisComplex.createNaturalOceanStream( - height, constantType(InferredType.SEA), - new IrisBiome().setInferredType(InferredType.LAND), - 64D, - IrisRiverWaterMode.SEA_LEVEL + new IrisBiome().setInferredType(InferredType.LAND) ).get(0D, 0D)); assertFalse(IrisComplex.createNaturalOceanStream( - height, constantType(InferredType.SEA), - new IrisBiome().setInferredType(InferredType.SHORE), - 64D, - IrisRiverWaterMode.SEA_LEVEL + new IrisBiome().setInferredType(InferredType.SHORE) ).get(0D, 0D)); assertEquals(0, heightSamples.get()); } @Test - public void seaLevelOceanMaskUsesContinentalIntentWithoutSamplingHeight() { + public void fixedOceanMaskUsesContinentalIntentWithoutSamplingHeight() { AtomicInteger heightSamples = new AtomicInteger(); ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { heightSamples.incrementAndGet(); @@ -163,11 +119,8 @@ public class IrisComplexSurfaceBiomeTest { }); Boolean ocean = IrisComplex.createNaturalOceanStream( - height, constantType(InferredType.SEA), - null, - 64D, - IrisRiverWaterMode.SEA_LEVEL + null ).get(8D, -3D); assertTrue(ocean); diff --git a/core/src/test/java/art/arcane/iris/engine/WorldTeleportWarmupTest.java b/core/src/test/java/art/arcane/iris/engine/WorldTeleportWarmupTest.java new file mode 100644 index 000000000..58b388f2f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldTeleportWarmupTest.java @@ -0,0 +1,115 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldTeleportWarmupTest { + @Test + public void cancelledTeleportUsesNativeAsyncPathWithOriginalCause() { + World world = mock(World.class); + Player player = mock(Player.class); + PlayerTeleportEvent event = mock(PlayerTeleportEvent.class); + Location destination = new Location(world, 400.5D, 96D, 2.5D, 45F, 10F); + PlayerTeleportEvent.TeleportCause cause = PlayerTeleportEvent.TeleportCause.COMMAND; + CompletableFuture result = new CompletableFuture<>(); + AtomicReference capturedDestination = new AtomicReference<>(); + + when(event.getPlayer()).thenReturn(player); + when(event.getTo()).thenReturn(destination); + when(event.getCause()).thenReturn(cause); + + try (MockedStatic platform = mockStatic(BukkitPlatform.class)) { + platform.when(() -> BukkitPlatform.teleportAsync(same(player), any(Location.class), eq(cause))) + .thenAnswer(invocation -> { + capturedDestination.set(invocation.getArgument(1, Location.class)); + return result; + }); + + new WorldTeleportWarmup().teleportAsync(event); + + verify(event).setCancelled(true); + assertEquals(destination, capturedDestination.get()); + assertNotSame(destination, capturedDestination.get()); + } + } + + @Test + public void missingDestinationLeavesTeleportUntouched() { + PlayerTeleportEvent event = mock(PlayerTeleportEvent.class); + when(event.getTo()).thenReturn(null); + + try (MockedStatic platform = mockStatic(BukkitPlatform.class)) { + new WorldTeleportWarmup().teleportAsync(event); + + verify(event, never()).setCancelled(true); + platform.verifyNoInteractions(); + } + } + + @Test + public void falseNativeSettlementDoesNotThrowFromCompletion() { + World world = mock(World.class); + Player player = mock(Player.class); + PlayerTeleportEvent event = mock(PlayerTeleportEvent.class); + Location destination = new Location(world, 0.5D, 80D, 0.5D); + when(event.getPlayer()).thenReturn(player); + when(event.getTo()).thenReturn(destination); + when(event.getCause()).thenReturn(PlayerTeleportEvent.TeleportCause.COMMAND); + when(player.getName()).thenReturn("Player"); + + try (MockedStatic platform = mockStatic(BukkitPlatform.class)) { + platform.when(() -> BukkitPlatform.teleportAsync( + same(player), + any(Location.class), + eq(PlayerTeleportEvent.TeleportCause.COMMAND))) + .thenReturn(CompletableFuture.completedFuture(false)); + + new WorldTeleportWarmup().teleportAsync(event); + + verify(event).setCancelled(true); + } + } + + @Test + public void missingNativeFutureDoesNotThrowAfterCancellation() { + World world = mock(World.class); + Player player = mock(Player.class); + PlayerTeleportEvent event = mock(PlayerTeleportEvent.class); + Location destination = new Location(world, 0.5D, 80D, 0.5D); + when(event.getPlayer()).thenReturn(player); + when(event.getTo()).thenReturn(destination); + when(event.getCause()).thenReturn(PlayerTeleportEvent.TeleportCause.COMMAND); + when(player.getName()).thenReturn("Player"); + + try (MockedStatic platform = mockStatic(BukkitPlatform.class)) { + platform.when(() -> BukkitPlatform.teleportAsync( + same(player), + any(Location.class), + eq(PlayerTeleportEvent.TeleportCause.COMMAND))) + .thenReturn(null); + + new WorldTeleportWarmup().teleportAsync(event); + + verify(event).setCancelled(true); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java new file mode 100644 index 000000000..e9e593ee9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java @@ -0,0 +1,185 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class EngineAssignedWorldManagerTeleportTest { + @Test + public void unloadedIrisDestinationDelegatesToAsyncTeleport() { + TeleportHarness harness = new TeleportHarness(false, true); + + try (harness) { + harness.manager.on(harness.event); + + assertSame(harness.event, harness.manager.teleportEvent); + } + } + + @Test + public void loadedIrisDestinationContinuesWithoutInterception() { + TeleportHarness harness = new TeleportHarness(true, true); + + try (harness) { + harness.manager.on(harness.event); + + assertNull(harness.manager.teleportEvent); + } + } + + @Test + public void classicBukkitDestinationContinuesWithoutInterception() { + TeleportHarness harness = new TeleportHarness(false, false); + + try (harness) { + harness.manager.on(harness.event); + + assertNull(harness.manager.teleportEvent); + } + } + + @Test + public void pluginTeleportContinuesWithoutInterception() { + TeleportHarness harness = new TeleportHarness(false, true); + when(harness.event.getCause()).thenReturn(PlayerTeleportEvent.TeleportCause.PLUGIN); + + try (harness) { + harness.manager.on(harness.event); + + assertNull(harness.manager.teleportEvent); + } + } + + @Test + public void productionWorldCommandContinuesWithoutInterception() { + TeleportHarness harness = new TeleportHarness(false, true); + when(harness.engine.isStudio()).thenReturn(false); + + try (harness) { + harness.manager.on(harness.event); + + assertNull(harness.manager.teleportEvent); + } + } + + @Test + public void otherWorldDestinationContinuesWithoutInterception() { + TeleportHarness harness = new TeleportHarness(false, true); + World otherWorld = mock(World.class); + when(harness.event.getTo()).thenReturn(new Location(otherWorld, 400.5D, 96D, 2.5D)); + + try (harness) { + harness.manager.on(harness.event); + + assertNull(harness.manager.teleportEvent); + } + } + + private static final class TeleportHarness implements AutoCloseable { + private final Engine engine; + private final TestWorldManager manager; + private final PlayerTeleportEvent event; + private final MockedStatic platform; + private final MockedStatic binding; + + private TeleportHarness(boolean loaded, boolean paper) { + engine = mock(Engine.class); + EngineTarget target = mock(EngineTarget.class); + IrisWorld irisWorld = mock(IrisWorld.class); + World world = mock(World.class); + event = mock(PlayerTeleportEvent.class); + Location destination = new Location(world, 400.5D, 96D, 2.5D); + + when(engine.getTarget()).thenReturn(target); + when(engine.isStudio()).thenReturn(true); + when(target.getWorld()).thenReturn(irisWorld); + when(event.getTo()).thenReturn(destination); + when(event.getCause()).thenReturn(PlayerTeleportEvent.TeleportCause.COMMAND); + when(world.isChunkLoaded(25, 0)).thenReturn(loaded); + + platform = mockStatic(BukkitPlatform.class); + platform.when(BukkitPlatform::isPaperServer).thenReturn(paper); + binding = mockStatic(BukkitWorldBinding.class); + binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + manager = new TestWorldManager(engine); + } + + @Override + public void close() { + binding.close(); + platform.close(); + } + } + + private static final class TestWorldManager extends EngineAssignedWorldManager { + private PlayerTeleportEvent teleportEvent; + + private TestWorldManager(Engine engine) { + super(engine); + } + + @Override + protected boolean runManagerTask(String operation, Runnable task) { + task.run(); + return true; + } + + @Override + public int getEntityCount() { + return 0; + } + + @Override + public int getChunkCount() { + return 0; + } + + @Override + public double getEntitySaturation() { + return 0D; + } + + @Override + public void onTick() { + } + + @Override + public void onSave() { + } + + @Override + public void onBlockBreak(BlockBreakEvent event) { + } + + @Override + public void onBlockPlace(BlockPlaceEvent event) { + } + + @Override + public void onChunkLoad(Chunk chunk, boolean generated) { + } + + @Override + public void onChunkUnload(Chunk chunk) { + } + + @Override + public void teleportAsync(PlayerTeleportEvent event) { + teleportEvent = event; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java index b8cdc3bc3..013b440af 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java @@ -188,7 +188,7 @@ public class StructureCaveAnchorResolverTest { assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( true, fluid, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( - true, fluid, RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), 20, 8)); + true, fluid, RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), 20, 8)); assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid( diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterParallelismTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterParallelismTest.java new file mode 100644 index 000000000..cdcf0aea9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterParallelismTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.mantle; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class MantleWriterParallelismTest { + @Test + public void multicorePrefetchKeepsOneWorkerOnSingleProcessorSystems() { + assertEquals(1, MantleWriter.resolvePrefetchParallelism(false, true, 1)); + assertEquals(1, MantleWriter.resolvePrefetchParallelism(false, true, 0)); + } + + @Test + public void maintenanceAndSequentialPrefetchKeepTheirExistingLimits() { + assertEquals(1, MantleWriter.resolvePrefetchParallelism(true, true, 32)); + assertEquals(4, MantleWriter.resolvePrefetchParallelism(false, false, 1)); + assertEquals(8, MantleWriter.resolvePrefetchParallelism(false, true, 16)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java index 73bd5b8a7..e7482314e 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java @@ -54,16 +54,124 @@ public class MatterGeneratorCarvePassRadiusTest { assertEquals(objects.visited, carving.visited); } + @Test + @SuppressWarnings("unchecked") + public void chunkSpecificInputRadiusAvoidsUnusedPrerequisiteHalo() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + + RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 1); + RecordingComponent conditional = new RecordingComponent( + ReservedFlag.RIVER_HYDROLOGY, + 1, + 0, + 160, + 0 + ); + TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, List.of( + new MantlePass(List.of(carving), 11, 160), + new MantlePass(List.of(conditional), 0, 0) + )); + + generator.generateMatter(0, 0, false, mock(ChunkContext.class)); + + assertEquals(9, carving.visited.size()); + assertEquals(Set.of("0,0"), conditional.visited); + } + + @Test + @SuppressWarnings("unchecked") + public void lazyInputGenerationKeepsReadAccessWithoutEagerlyGeneratingTheHalo() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + + RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 0); + RecordingComponent conditional = new RecordingComponent( + ReservedFlag.RIVER_HYDROLOGY, + 1, + 0, + 0, + 160, + true, + 10 + ); + TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, List.of( + new MantlePass(List.of(carving), 10, 160), + new MantlePass(List.of(conditional), 0, 0) + )); + + generator.generateMatter(0, 0, false, mock(ChunkContext.class)); + + assertEquals(Set.of("0,0"), carving.visited); + assertEquals(Set.of("0,0"), conditional.visited); + assertTrue(conditional.accessSucceeded); + } + private static final class RecordingComponent implements MantleComponent { private final MantleFlag flag; private final int priority; private final int radius; + private final int inputRadius; + private final int chunkInputRadius; + private final boolean lazyInputGeneration; + private final int accessProbeOffset; private final Set visited = new LinkedHashSet<>(); + private boolean accessSucceeded; private RecordingComponent(MantleFlag flag, int priority, int radius) { + this(flag, priority, radius, 0, 0, false, 0); + } + + private RecordingComponent( + MantleFlag flag, + int priority, + int radius, + int inputRadius, + int chunkInputRadius + ) { + this(flag, priority, radius, inputRadius, chunkInputRadius, false, 0); + } + + private RecordingComponent( + MantleFlag flag, + int priority, + int radius, + int inputRadius, + int chunkInputRadius, + boolean lazyInputGeneration, + int accessProbeOffset + ) { this.flag = flag; this.priority = priority; this.radius = radius; + this.inputRadius = inputRadius; + this.chunkInputRadius = chunkInputRadius; + this.lazyInputGeneration = lazyInputGeneration; + this.accessProbeOffset = accessProbeOffset; } @Override @@ -76,6 +184,26 @@ public class MatterGeneratorCarvePassRadiusTest { return radius; } + @Override + public int getInputRadius() { + return inputRadius; + } + + @Override + public int getInputRadius( + int targetChunkX, + int targetChunkZ, + int invocationChunkRadius, + ChunkContext context + ) { + return chunkInputRadius; + } + + @Override + public boolean isInputGenerationLazy() { + return lazyInputGeneration; + } + @Override public EngineMantle getEngineMantle() { return null; @@ -102,6 +230,9 @@ public class MatterGeneratorCarvePassRadiusTest { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { visited.add(x + "," + z); + if (accessProbeOffset != 0) { + accessSucceeded = writer.acquireChunk(x + accessProbeOffset, z) != null; + } } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java new file mode 100644 index 000000000..15dedb50a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java @@ -0,0 +1,287 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MatterGeneratorConcurrencyTest { + private static IrisSettings previousSettings; + + @BeforeClass + public static void bindPlatform() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisPlatforms.unbind(); + PlatformBlockState defaultBlock = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(registries.block(anyString())).thenReturn(defaultBlock); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + IrisSettings.settings = previousSettings; + } + + @Test + public void multicoreChunksOverlapAndCompleteBeforeTheNextPass() { + GeneratorFixture fixture = new GeneratorFixture(); + CountDownLatch overlap = new CountDownLatch(2); + AtomicInteger completed = new AtomicInteger(); + AtomicBoolean barrierObserved = new AtomicBoolean(); + RecordingComponent concurrent = new RecordingComponent(ReservedFlag.OBJECT, 0, 16) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + overlap.countDown(); + await(overlap); + completed.incrementAndGet(); + } + }; + RecordingComponent barrier = new RecordingComponent(ReservedFlag.JIGSAW, 1, 0) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + assertEquals(9, completed.get()); + barrierObserved.set(true); + } + }; + TestMatterGenerator generator = fixture.generator(List.of( + new MantlePass(List.of(concurrent), 1, 0), + new MantlePass(List.of(barrier), 0, 0) + )); + + generator.generateMatter(0, 0, true, fixture.context); + + assertEquals(0, overlap.getCount()); + assertEquals(9, completed.get()); + assertTrue(barrierObserved.get()); + } + + @Test + public void multicoreComponentRunsOnTheDispatcher() { + GeneratorFixture fixture = new GeneratorFixture(); + Thread caller = Thread.currentThread(); + Set threads = ConcurrentHashMap.newKeySet(); + RecordingComponent ordinary = new RecordingComponent(ReservedFlag.OBJECT, 0, 16) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + threads.add(Thread.currentThread()); + } + }; + TestMatterGenerator generator = fixture.generator(List.of( + new MantlePass(List.of(ordinary), 1, 0) + )); + + generator.generateMatter(0, 0, true, fixture.context); + + assertFalse(threads.isEmpty()); + assertFalse(threads.contains(caller)); + } + + @Test + public void asyncComponentReceivesCallerContextAndCallerScopeIsRestored() { + GeneratorFixture fixture = new GeneratorFixture(); + AtomicReference observed = new AtomicReference<>(); + AtomicReference observedThread = new AtomicReference<>(); + RecordingComponent concurrent = new RecordingComponent(ReservedFlag.CARVED, 0, 0) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + observed.set(IrisContext.require()); + observedThread.set(Thread.currentThread()); + } + }; + TestMatterGenerator generator = fixture.generator(List.of( + new MantlePass(List.of(concurrent), 0, 0) + )); + + assertNull(IrisContext.get()); + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 91L, fixture.context)) { + IrisContext caller = IrisContext.require(); + Thread callerThread = Thread.currentThread(); + generator.generateMatter(0, 0, true, fixture.context); + + assertNotSame(callerThread, observedThread.get()); + assertSame(fixture.engine, observed.get().getEngine()); + assertSame(fixture.context, observed.get().getChunkContext()); + assertEquals(91L, observed.get().getGenerationSessionId()); + assertSame(caller, IrisContext.require()); + } + assertNull(IrisContext.get()); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + + private static class RecordingComponent implements MantleComponent { + private final MantleFlag flag; + private final int priority; + private final int radius; + + private RecordingComponent(MantleFlag flag, int priority, int radius) { + this.flag = flag; + this.priority = priority; + this.radius = radius; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public int getRadius() { + return radius; + } + + @Override + public EngineMantle getEngineMantle() { + return null; + } + + @Override + public MantleFlag getFlag() { + return flag; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public void setEnabled(boolean enabled) { + } + + @Override + public void hotload() { + } + + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + } + } + + private static final class GeneratorFixture { + private final Engine engine; + private final Mantle mantle; + private final ChunkContext context; + + @SuppressWarnings("unchecked") + private GeneratorFixture() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + EngineMantle engineMantle = mock(EngineMantle.class); + engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getMantle()).thenReturn(engineMantle); + mantle = mock(Mantle.class); + ConcurrentHashMap> chunks = new ConcurrentHashMap<>(); + when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(invocation -> { + int chunkX = invocation.getArgument(0); + int chunkZ = invocation.getArgument(1); + long key = (((long) chunkX) << 32) ^ (chunkZ & 0xffffffffL); + return chunks.computeIfAbsent(key, ignored -> chunk()); + }); + context = mock(ChunkContext.class); + when(context.getGenerationSessionId()).thenReturn(91L); + } + + private TestMatterGenerator generator(List passes) { + return new TestMatterGenerator(engine, mantle, passes); + } + + @SuppressWarnings("unchecked") + private static MantleChunk chunk() { + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.use()).thenReturn(chunk); + when(chunk.isFlagged(any())).thenReturn(false); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + return chunk; + } + } + + private static final class TestMatterGenerator implements MatterGenerator { + private final Engine engine; + private final Mantle mantle; + private final List passes; + + private TestMatterGenerator(Engine engine, Mantle mantle, List passes) { + this.engine = engine; + this.mantle = mantle; + this.passes = passes; + } + + @Override + public Engine getEngine() { + return engine; + } + + @Override + public Mantle getMantle() { + return mantle; + } + + @Override + public int getRadius() { + return passes.getFirst().passChunkRadius(); + } + + @Override + public int getRealRadius() { + return passes.getLast().passChunkRadius(); + } + + @Override + public List getComponents() { + return passes; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index e7a5fe3bb..86ea66868 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -44,6 +44,9 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisCaveCarver3DNearParityTest { + private static final int SURFACE_CEILING_FADE_DEPTH = 12; + private static final double SURFACE_CEILING_SOLID_EPSILON = 0.000001D; + private static Method sampleDensityMethod; private static Method aquiferCandidateMethod; private static Field engineField; @@ -167,6 +170,26 @@ public class IrisCaveCarver3DNearParityTest { assertTrue(minY(capture.carvedCells) >= 0); } + @Test + public void flatSurfaceDoesNotHardClipLargeCaveCeiling() { + assertFlatSurfaceCeiling(createFluidProfile() + .setVerticalRange(new IrisRange(20D, 72D)) + .setAllowSurfaceBreak(false) + .setSurfaceClearance(5) + .setAllowFluid(false) + .setAllowLava(false) + .setAdaptiveSampling(false) + .setSampleStep(1)); + assertFlatSurfaceCeiling(createFluidProfile() + .setVerticalRange(new IrisRange(20D, 72D)) + .setAllowSurfaceBreak(false) + .setSurfaceClearance(5) + .setAllowFluid(false) + .setAllowLava(false) + .setAdaptiveSampling(true) + .setSampleStep(1)); + } + @Test public void exactPathMatchesNaiveReferenceWithoutWarpOrModules() throws Exception { assertExactParity(false, false, false); @@ -444,6 +467,38 @@ public class IrisCaveCarver3DNearParityTest { assertTrue("expected carved cell delta below 3.5% but was " + differingRatio, differingRatio <= 0.035D); } + private void assertFlatSurfaceCeiling(IrisCaveProfile profile) { + Engine engine = createEngine(96, 72); + WriterCapture capture = createWriterCapture(96); + + new IrisCaveCarver3D(engine, profile).carve( + capture.writer, 0, 0, fullWeights(), 0D, 0D, null, filledHeights(72)); + + int[] ceilingYByColumn = new int[256]; + Arrays.fill(ceilingYByColumn, Integer.MIN_VALUE); + for (String cell : capture.carvedCells) { + int localX = coordinate(cell, 0); + int y = coordinate(cell, 1); + int localZ = coordinate(cell, 2); + int columnIndex = (localX << 4) | localZ; + ceilingYByColumn[columnIndex] = Math.max(ceilingYByColumn[columnIndex], y); + } + + Map ceilingPlaneCounts = new HashMap<>(); + int largestPlane = 0; + for (int ceilingY : ceilingYByColumn) { + assertTrue(ceilingY != Integer.MIN_VALUE); + assertTrue(ceilingY <= 67); + int planeCount = ceilingPlaneCounts.merge(ceilingY, 1, Integer::sum); + largestPlane = Math.max(largestPlane, planeCount); + } + + assertTrue("expected at least three ceiling levels but found " + ceilingPlaneCounts, + ceilingPlaneCounts.size() >= 3); + assertTrue("expected no dominant ceiling plane but found " + ceilingPlaneCounts, + largestPlane < 192); + } + private void assertExactParity(boolean warp, boolean modules, boolean adaptiveSampling) throws Exception { Engine engine = createEngine(96, 90); double[] columnWeights = fullWeights(); @@ -523,9 +578,11 @@ public class IrisCaveCarver3DNearParityTest { int[] columnTopY = new int[256]; int[] surfaceBreakFloorY = new int[256]; boolean[] surfaceBreakColumn = new boolean[256]; + boolean[] surfaceCeilingColumn = new boolean[256]; int[] fluidMaxY = new int[256]; double[] passThreshold = new double[256]; double[] verticalEdgeFade = computeVerticalEdgeFade(profile, minY, maxY); + double[] surfaceClosureThreshold = computeSurfaceClosureThresholds(profile, minY, maxY); MatterCavern[] matterByY = computeMatterByY(engine, profile, carveAir, carveLava, carveForcedAir, minY, maxY); int x0 = chunkX << 4; @@ -542,7 +599,8 @@ public class IrisCaveCarver3DNearParityTest { columnSurfaceY = engine.getHeight(x, z); } - int clearanceTopY = Math.min(maxY, Math.max(minY, columnSurfaceY - surfaceClearance)); + int unclampedClearanceTopY = columnSurfaceY - surfaceClearance; + int clearanceTopY = Math.min(maxY, Math.max(minY, unclampedClearanceTopY)); boolean breakColumn = allowSurfaceBreak && signed(surfaceBreakDensity.noiseFast2D(x, z)) >= surfaceBreakNoiseThreshold; int resolvedTopY = breakColumn ? Math.min(maxY, Math.max(minY, columnSurfaceY)) : clearanceTopY; columnTopY[columnIndex] = resolvedTopY; @@ -551,6 +609,7 @@ public class IrisCaveCarver3DNearParityTest { : Integer.MIN_VALUE; surfaceBreakFloorY[columnIndex] = Math.max(minY, columnSurfaceY - surfaceBreakDepth); surfaceBreakColumn[columnIndex] = breakColumn; + surfaceCeilingColumn[columnIndex] = !breakColumn && unclampedClearanceTopY <= maxY; double columnWeight = clampColumnWeight(resolvedWeights[columnIndex]); if (columnWeight <= 0D || resolvedTopY < minY) { passThreshold[columnIndex] = Double.NaN; @@ -584,6 +643,14 @@ public class IrisCaveCarver3DNearParityTest { localThreshold += surfaceBreakThresholdBoost; } localThreshold -= verticalEdgeFade[y - minY]; + localThreshold = applySurfaceCeilingFade( + localThreshold, + surfaceCeilingColumn[columnIndex], + topY, + y, + minY, + surfaceClosureThreshold + ); double density = (double) sampleDensityMethod.invoke(carver, x, y, z); if (density > localThreshold) { @@ -606,6 +673,64 @@ public class IrisCaveCarver3DNearParityTest { return carved; } + private double applySurfaceCeilingFade( + double threshold, + boolean surfaceCeilingColumn, + int columnTopY, + int y, + int minY, + double[] surfaceClosureThreshold + ) { + if (!surfaceCeilingColumn) { + return threshold; + } + + int ceilingDistance = columnTopY - y; + if (ceilingDistance < 0 || ceilingDistance >= SURFACE_CEILING_FADE_DEPTH) { + return threshold; + } + + double closureThreshold = surfaceClosureThreshold[y - minY]; + if (threshold <= closureThreshold) { + return threshold; + } + + double progress = ceilingDistance / (double) SURFACE_CEILING_FADE_DEPTH; + double smooth = progress * progress * (3D - (2D * progress)); + return closureThreshold + ((threshold - closureThreshold) * smooth); + } + + private double[] computeSurfaceClosureThresholds(IrisCaveProfile profile, int minY, int maxY) { + double normalization = Math.abs(profile.getBaseWeight()) + Math.abs(profile.getDetailWeight()); + for (IrisCaveFieldModule module : profile.getModules()) { + normalization += Math.abs(module.getWeight()); + } + if (normalization <= 0D) { + normalization = 1D; + } + + double[] thresholds = new double[Math.max(0, maxY - minY + 1)]; + double baseMinimum = -Math.abs(profile.getBaseWeight()) - Math.abs(profile.getDetailWeight()); + for (int y = minY; y <= maxY; y++) { + double minimumDensity = baseMinimum; + for (IrisCaveFieldModule module : profile.getModules()) { + IrisRange range = module.getVerticalRange(); + if (y < Math.floor(range.getMin()) || y > Math.ceil(range.getMax())) { + continue; + } + double rawMinimum = module.isInvert() + ? module.getThreshold() - 1D + : -1D - module.getThreshold(); + double rawMaximum = module.isInvert() + ? module.getThreshold() + 1D + : 1D - module.getThreshold(); + minimumDensity += Math.min(rawMinimum * module.getWeight(), rawMaximum * module.getWeight()); + } + thresholds[y - minY] = (minimumDensity / normalization) - SURFACE_CEILING_SOLID_EPSILON; + } + return thresholds; + } + private double[] computeVerticalEdgeFade(IrisCaveProfile profile, int minY, int maxY) { int size = Math.max(0, maxY - minY + 1); double[] verticalEdgeFade = new double[size]; diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java index cb8695dc3..938be8a38 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java @@ -55,7 +55,7 @@ public class MantleObjectComponentCaveAnchorTest { assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( true, water, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( - true, water, RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), 20, 8)); + true, water, RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), 20, 8)); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java index 8ba8cbea2..fdd1f4af3 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.river.cave.CavePosition; import art.arcane.iris.engine.river.cave.CaveVoxel; import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -10,8 +11,13 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.iris.spi.PlatformBlockState; import org.junit.Test; +import java.util.ArrayList; +import java.util.List; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -29,7 +35,10 @@ public class MantleRiverCaveVoxelViewTest { mantle, 128, (x, z) -> x < 0 ? 20 : 60, - (x, z) -> null + (x, z) -> null, + RiverCaveFluidKind.RIVER, + (chunkX, chunkZ) -> { + } ); CavePosition cliffAir = new CavePosition(-1, 21, 0); CavePosition terrain = new CavePosition(-1, 20, 0); @@ -40,6 +49,29 @@ public class MantleRiverCaveVoxelViewTest { assertFalse(view.isOpenToSurface(terrain)); } + @Test + @SuppressWarnings("unchecked") + public void carvingInputLoadsOnlyOncePerReadChunk() { + Mantle mantle = mock(Mantle.class); + when(mantle.getLoadedRegions()).thenReturn(new KMap>()); + List loaded = new ArrayList<>(); + MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (x, z) -> null, + RiverCaveFluidKind.RIVER, + (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) + ); + + view.voxelAt(new CavePosition(0, 20, 0)); + view.voxelAt(new CavePosition(15, 21, 15)); + view.riverHydrologyAt(new CavePosition(1, 22, 1)); + view.voxelAt(new CavePosition(16, 20, 0)); + + assertEquals(List.of("0,0", "1,0"), loaded); + } + @Test @SuppressWarnings("unchecked") public void publishedRiverActionsDoNotReplaceTheUnderlyingPlanningBaseline() { @@ -62,10 +94,89 @@ public class MantleRiverCaveVoxelViewTest { mantle, 128, (x, z) -> 60, - (x, z) -> null + (x, z) -> null, + RiverCaveFluidKind.RIVER, + (chunkX, chunkZ) -> { + } ); assertEquals(CaveVoxel.SOLID, view.voxelAt(position)); - assertEquals(RiverCaveAction.WET_SOURCE, view.riverActionAt(position)); + assertEquals(RiverCaveAction.WET_SOURCE, view.riverHydrologyAt(position).action()); + } + + @Test + @SuppressWarnings("unchecked") + public void configuredLavaRiverTreatsPublishedLavaAsCompatibleFluid() { + Mantle mantle = mock(Mantle.class); + TectonicPlate plate = mock(TectonicPlate.class); + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + MatterSlice cavernSlice = mock(MatterSlice.class); + PlatformBlockState lava = mock(PlatformBlockState.class); + KMap> regions = new KMap<>(); + regions.put(Mantle.key(0, 0), plate); + when(mantle.getLoadedRegions()).thenReturn(regions); + when(plate.get(0, 0)).thenReturn(chunk); + when(chunk.exists(1)).thenReturn(true); + when(chunk.get(1)).thenReturn(matter); + when(matter.hasSlice(MatterCavern.class)).thenReturn(true); + doReturn(cavernSlice).when(matter).getSlice(MatterCavern.class); + when(cavernSlice.get(0, 4, 0)).thenReturn(new MatterCavern(true, "", (byte) 2)); + when(lava.materialKey()).thenReturn("minecraft:lava"); + MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (x, z) -> lava, + RiverCaveFluidKind.RIVER, + (chunkX, chunkZ) -> { + } + ); + + assertEquals(CaveVoxel.COMPATIBLE_FLUID, view.voxelAt(new CavePosition(0, 20, 0))); + } + + @Test + @SuppressWarnings("unchecked") + public void planningRejectsHydrologyOwnedByTheOtherFluidKind() { + Mantle mantle = mock(Mantle.class); + TectonicPlate plate = mock(TectonicPlate.class); + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + MatterSlice hydrologySlice = mock(MatterSlice.class); + KMap> regions = new KMap<>(); + regions.put(Mantle.key(0, 0), plate); + CavePosition position = new CavePosition(0, 20, 0); + when(mantle.getLoadedRegions()).thenReturn(regions); + when(plate.get(0, 0)).thenReturn(chunk); + when(chunk.exists(1)).thenReturn(true); + when(chunk.get(1)).thenReturn(matter); + when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + doReturn(hydrologySlice).when(matter).getSlice(RiverCaveHydrology.class); + when(hydrologySlice.get(0, 4, 0)).thenReturn(RiverCaveHydrology.of( + RiverCaveAction.WET_SOURCE, + RiverCaveFluidKind.DEEP_POOL + )); + MantleRiverCaveVoxelView riverView = new MantleRiverCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (x, z) -> null, + RiverCaveFluidKind.RIVER, + (chunkX, chunkZ) -> { + } + ); + MantleRiverCaveVoxelView deepPoolView = new MantleRiverCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (x, z) -> null, + RiverCaveFluidKind.DEEP_POOL, + (chunkX, chunkZ) -> { + } + ); + + assertEquals(CaveVoxel.INCOMPATIBLE_FLUID, riverView.voxelAt(position)); + assertEquals(CaveVoxel.SOLID, deepPoolView.voxelAt(position)); } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java index de1ed2740..4846d71a4 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java @@ -1,24 +1,32 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisGeneratorStyle; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRiverCaveFallback; import art.arcane.iris.engine.object.IrisRiverCaveMode; import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverDeepPools; import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.engine.mantle.ComponentFlag; +import art.arcane.iris.engine.river.RiverAnchor; import art.arcane.iris.engine.river.RiverEdgeId; import art.arcane.iris.engine.river.RiverNodeId; import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.river.RiverSample; import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.RiverTopologyComplexity; import art.arcane.iris.engine.river.cave.CavePosition; import art.arcane.iris.engine.river.cave.CaveVoxel; import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; import art.arcane.iris.engine.river.cave.RiverCaveAction; import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.engine.river.cave.RiverCaveMode; import art.arcane.iris.engine.river.cave.RiverCavePlan; import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; @@ -26,6 +34,12 @@ import art.arcane.iris.engine.river.cave.RiverCaveRejection; import art.arcane.iris.engine.river.cave.RiverCaveSource; import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.context.ChunkContext; import org.junit.Test; import java.util.HashMap; @@ -41,7 +55,14 @@ import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class MantleRiverHydrologyComponentTest { private final RiverCaveContainmentPlanner planner = new RiverCaveContainmentPlanner(); @@ -70,6 +91,12 @@ public class MantleRiverHydrologyComponentTest { dimension.getRivers().getCaves().setMaximumPerReach(0); assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().setMode(IrisRiverCaveMode.SEALED); + dimension.getRivers().getCaves().getDeepPools().setEnabled(true); + assertTrue(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().getDeepPools().setMaximumPerReach(0); + assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().getDeepPools().setEnabled(false).setMaximumPerReach(1); dimension.getRivers().getCaves().setMaximumPerReach(1); dimension.setCarvingEnabled(false); assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); @@ -84,6 +111,69 @@ public class MantleRiverHydrologyComponentTest { assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); } + @Test + public void adaptiveInputRadiusRetainsOnlyRequiredHydrologyHalos() { + bindMockPlatform(); + try { + IrisDimension dimension = new IrisDimension(); + dimension.setCarvingEnabled(true); + dimension.getRivers().setEnabled(true); + IrisRiverCaves caves = dimension.getRivers().getCaves(); + caves.setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT).setMaximumPerReach(1); + IrisRiverDeepPools deepPools = caves.getDeepPools(); + deepPools.setEnabled(true).setMaximumPerReach(1); + + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + IrisComplex complex = mock(IrisComplex.class); + ChunkContext context = mock(ChunkContext.class); + IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); + RiverAnchor anchor = mock(RiverAnchor.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(context.getComplex()).thenReturn(complex); + when(complex.getRiverRuntime()).thenReturn(runtime); + when(runtime.caveSettings()).thenReturn(caves); + when(runtime.maximumChannelWidth()).thenReturn(12D); + when(runtime.maximumTunnelWidthMultiplier()).thenReturn(1D); + when(runtime.tunnelMouthBlend()).thenReturn(0D); + when(runtime.candidateAnchors( + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyDouble(), + anyLong() + )).thenReturn(List.of(anchor)); + + MantleRiverHydrologyComponent component = new MantleRiverHydrologyComponent(engineMantle); + assertEquals(0, component.getInputRadius(0, 0, 0, context)); + + when(runtime.hasRiverFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(true); + assertEquals( + RiverTopologyComplexity.tunnelHalo(12D, 1D, 0D), + component.getInputRadius(0, 0, 0, context) + ); + + when(runtime.hasRiverFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(false); + when(runtime.acceptsCaveAnchor(anchor)).thenReturn(true); + assertEquals( + MantleRiverHydrologyComponent.planningHalo(caves), + component.getInputRadius(0, 0, 0, context) + ); + + when(runtime.acceptsCaveAnchor(anchor)).thenReturn(false); + when(runtime.acceptsDeepPoolAnchor(anchor)).thenReturn(true); + assertEquals( + MantleRiverHydrologyComponent.deepPoolPlanningHalo(deepPools), + component.getInputRadius(0, 0, 0, context) + ); + } finally { + IrisPlatforms.unbind(); + } + } + @Test public void buriedChannelPlansWetCoreDryRoofAndSolidGuardShell() { TestVoxelView view = new TestVoxelView(); @@ -212,7 +302,7 @@ public class MantleRiverHydrologyComponentTest { } @Test - public void buriedChannelMayOpenOnlyIntoItsExactSurfaceRiverMouth() { + public void buriedChannelMouthMayFlareIntoTheWetSurfaceBank() { TestVoxelView view = new TestVoxelView(); view.set(new CavePosition(1, 11, 0), CaveVoxel.COMPATIBLE_FLUID); view.set(new CavePosition(1, 12, 0), CaveVoxel.COMPATIBLE_FLUID); @@ -225,7 +315,7 @@ public class MantleRiverHydrologyComponentTest { 14 ); IrisRiverSurfaceSample mouth = new IrisRiverSurfaceSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + riverSample(RiverRouteState.WET, RiverSection.BANK), 14D, 10D, 12D, @@ -411,7 +501,7 @@ public class MantleRiverHydrologyComponentTest { RiverCavePlan plan = planner.plan(view, source, settings); assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(entry)); + assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(entry)); assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(target)); assertFalse(plan.actions().containsKey(connectedPit)); } @@ -517,10 +607,107 @@ public class MantleRiverHydrologyComponentTest { caves.setMode(IrisRiverCaveMode.SEALED); assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + caves.getDeepPools() + .setEnabled(true) + .setSearchRadius(20) + .setHorizontalRadius(24); + assertEquals(180, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + + caves.getDeepPools().setEnabled(false); caves.setMode(IrisRiverCaveMode.GENERATE_GROTTO).setMaximumPerReach(0); assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); } + @Test + public void deepPoolSourceSearchUsesAbsoluteConfiguredHeightAndCaveFloor() { + IrisRiverDeepPools deepPools = new IrisRiverDeepPools() + .setMinimumFluidY(-180) + .setMaximumFluidY(-130) + .setSearchRadius(0) + .setSearchAttempts(1) + .setVerticalRadius(8) + .setDryHeadroom(4); + TestVoxelView view = new TestVoxelView(); + view.set(new CavePosition(12, 101, 20), CaveVoxel.CAVE_AIR); + RiverAnchor anchor = new RiverAnchor( + new RiverEdgeId(new RiverNodeId(1L, 1L), new RiverNodeId(2L, 2L)), + 0, + 91L, + 768D, + 73L, + 12D, + 20D, + 0.5D, + RiverRouteState.WET, + 1, + 1 + ); + + RiverCaveSource source = MantleRiverHydrologyComponent.deepPoolSourceFor( + view, + deepPools, + anchor, + -256, + 42L + ); + + assertNotNull(source); + assertEquals(new CavePosition(12, 100, 20), source.entry()); + assertEquals(new CavePosition(12, 96, 20), source.target()); + assertEquals(100, source.waterHeadY()); + assertEquals(-156, source.waterHeadY() - 256); + assertEquals(RiverCaveMode.DEEP_POOL, source.mode()); + } + + @Test + public void deepPoolProofRadiusContainsNoisyDiagonalLobesAndShell() { + IrisRiverDeepPools deepPools = new IrisRiverDeepPools() + .setHorizontalRadius(24) + .setVerticalRadius(10) + .setDryHeadroom(5); + + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.deepPoolPlannerSettings( + deepPools, + 42L, + null + ); + + assertEquals(35, settings.maxHorizontalRadius()); + assertEquals(16, settings.maxDepth()); + assertEquals(35, settings.maxClosedComponentHorizontalRadius()); + } + + @Test + public void managedScaleWarpedDeepPoolPassesAsOneContainedBlob() { + IrisRiverDeepPools deepPools = new IrisRiverDeepPools() + .setHorizontalRadius(24) + .setVerticalRadius(10) + .setDryHeadroom(5) + .setShapeVariation(0.6D) + .setWarpStrength(8D) + .setMaximumVolume(65536); + TestVoxelView view = new TestVoxelView(); + view.set(new CavePosition(0, 101, 0), CaveVoxel.CAVE_AIR); + RiverCaveSource source = new RiverCaveSource( + 92L, + new CavePosition(0, 100, 0), + new CavePosition(0, 95, 0), + 100, + RiverCaveMode.DEEP_POOL + ); + + RiverCavePlan plan = planner.plan( + view, + source, + MantleRiverHydrologyComponent.deepPoolPlannerSettings(deepPools, 42L, null) + ); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 100, 0))); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 101, 0))); + assertTrue(plan.actions().size() > 10000); + } + @Test public void absentRiverFootprintSkipsEveryTunnelColumnSample() { AtomicInteger footprintSamples = new AtomicInteger(); @@ -778,9 +965,19 @@ public class MantleRiverHydrologyComponentTest { return Map.copyOf(owned); } + private static void bindMockPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + private static final class TestVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { private final Map voxels = new HashMap<>(); - private final Map riverActions = new HashMap<>(); + private final Map riverActions = new HashMap<>(); private final Set open = new HashSet<>(); @Override @@ -799,7 +996,7 @@ public class MantleRiverHydrologyComponentTest { } @Override - public RiverCaveAction riverActionAt(CavePosition position) { + public RiverCaveHydrology riverHydrologyAt(CavePosition position) { return riverActions.get(position); } @@ -812,7 +1009,7 @@ public class MantleRiverHydrologyComponentTest { } private void publish(CavePosition position, RiverCaveAction action) { - riverActions.put(position, action); + riverActions.put(position, RiverCaveHydrology.of(action, RiverCaveFluidKind.RIVER)); voxels.put( position, action == RiverCaveAction.WET_SOURCE diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java index 4bfd6d00f..a30b29b7b 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.modifier; import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.matter.MatterCavern; @@ -24,7 +25,11 @@ public class IrisCarveModifierRiverHydrologyTest { baseline, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD))); MatterCavern wet = IrisCarveModifier.composeCavern( - baseline, new RiverCaveHydrology(RiverCaveAction.WET_SOURCE, "iris:flooded")); + baseline, new RiverCaveHydrology( + RiverCaveAction.WET_SOURCE, + "iris:flooded", + RiverCaveFluidKind.DEEP_POOL + )); MatterCavern dry = IrisCarveModifier.composeCavern( baseline, RiverCaveHydrology.of(RiverCaveAction.DRY_AIR)); @@ -47,7 +52,7 @@ public class IrisCarveModifierRiverHydrologyTest { assertSame(source, IrisCarveModifier.resolveHydrologyState( RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), current, source, air)); assertSame(falling, IrisCarveModifier.resolveHydrologyState( - RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), current, source, air)); + RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), current, source, air)); assertSame(air, IrisCarveModifier.resolveHydrologyState( RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), current, source, air)); } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsNearestBlockIndexTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsNearestBlockIndexTest.java new file mode 100644 index 000000000..02bf449e3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsNearestBlockIndexTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.VectorMap; +import art.arcane.iris.util.common.math.IrisBlockVector; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; +import static org.mockito.Mockito.when; + +public class IrisObjectTransformsNearestBlockIndexTest { + @Test + public void spatialLookupMatchesLinearIterationIncludingDistanceTies() { + VectorMap blocks = new VectorMap<>(); + Random random = new Random(812734L); + for (int i = 0; i < 120; i++) { + PlatformBlockState state = mock(PlatformBlockState.class, withSettings().stubOnly()); + blocks.put(new IrisBlockVector( + random.nextInt(25) - 12, + random.nextInt(25) - 12, + random.nextInt(25) - 12 + ), state); + } + + PlatformBlockState explicitAir = mock(PlatformBlockState.class, withSettings().stubOnly()); + when(explicitAir.isAir()).thenReturn(true); + blocks.put(new IrisBlockVector(0, 0, 0), explicitAir); + IrisObjectTransforms.NearestBlockIndex index = IrisObjectTransforms.NearestBlockIndex.create(blocks); + List> candidates = new ArrayList<>(); + for (Map.Entry entry : blocks) { + candidates.add(entry); + } + + for (int x = -10; x <= 10; x++) { + for (int y = -10; y <= 10; y++) { + for (int z = -10; z <= 10; z++) { + IrisBlockVector query = new IrisBlockVector(x, y, z); + PlatformBlockState direct = blocks.get(query); + PlatformBlockState actual = B.isAir(direct) ? index.nearest(x, y, z, direct) : direct; + assertSame("Mismatch at " + x + "," + y + "," + z, + nearestByLinearIteration(blocks, candidates, query), actual); + } + } + } + } + + @Test + public void emptyIndexRetainsMissingAndExplicitAirFallbacks() { + VectorMap blocks = new VectorMap<>(); + PlatformBlockState explicitAir = mock(PlatformBlockState.class, withSettings().stubOnly()); + when(explicitAir.isAir()).thenReturn(true); + blocks.put(new IrisBlockVector(1, 2, 3), explicitAir); + IrisObjectTransforms.NearestBlockIndex index = IrisObjectTransforms.NearestBlockIndex.create(blocks); + + assertSame(explicitAir, index.nearest(1, 2, 3, explicitAir)); + assertSame(null, index.nearest(4, 5, 6, null)); + } + + private static PlatformBlockState nearestByLinearIteration( + VectorMap blocks, + List> candidates, + IrisBlockVector query + ) { + PlatformBlockState result = blocks.get(query); + if (!B.isAir(result)) { + return result; + } + + double nearestDistance = Double.MAX_VALUE; + for (Map.Entry entry : candidates) { + PlatformBlockState state = entry.getValue(); + if (B.isAir(state)) { + continue; + } + + double distance = entry.getKey().distanceSquared(query); + if (distance < nearestDistance) { + nearestDistance = distance; + result = state; + } + } + return result; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java index 1432027ec..c6db9087d 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -24,7 +24,9 @@ public class IrisRiverConfigurationTest { assertNotNull(dimension.getRivers()); assertFalse(dimension.getRivers().isEnabled()); - assertEquals(IrisRiverWaterMode.SEA_LEVEL, dimension.getRivers().getWater().getMode()); + assertEquals(IrisRiverWaterMode.FIXED, dimension.getRivers().getWater().getMode()); + assertEquals(63, dimension.getRivers().getWater().getFluidHeight()); + assertEquals("water", dimension.getRivers().getWater().getFluidPalette().getPalette().getFirst().getBlock()); assertFalse(dimension.getRivers().getTopology().isRequireOcean()); assertEquals(512, dimension.getRivers().getTopology().getCellSize()); assertEquals(16, dimension.getRivers().getTopology().getMaxRouteReaches()); @@ -41,6 +43,12 @@ public class IrisRiverConfigurationTest { assertEquals(IrisRiverCaveFallback.SEALED, dimension.getRivers().getCaves().getFallback()); assertEquals(IrisRiverExistingFluidPolicy.REJECT, dimension.getRivers().getCaves().getExistingFluidPolicy()); + assertNotNull(dimension.getRivers().getCaves().getDeepPools()); + assertFalse(dimension.getRivers().getCaves().getDeepPools().isEnabled()); + assertEquals(-224, dimension.getRivers().getCaves().getDeepPools().getMinimumFluidY()); + assertEquals(-104, dimension.getRivers().getCaves().getDeepPools().getMaximumFluidY()); + assertEquals("lava", dimension.getRivers().getCaves().getDeepPools() + .getFluidPalette().getPalette().getFirst().getBlock()); assertTrue(dimension.getRivers().getBiomes().getAllBiomeIds().isEmpty()); assertNull(new IrisRegion().getRiverOverride()); assertNull(new IrisBiome().getRiverOverride()); @@ -62,6 +70,7 @@ public class IrisRiverConfigurationTest { "source": {"chance": 0.27, "influence": 0.4} }, "terrain": { + "channelRadiusBonus": 3, "maxChannelWidth": 9, "maxBankWidth": 2.5, "maxDepth": 8, @@ -80,7 +89,8 @@ public class IrisRiverConfigurationTest { "bankMultiplier": 1.2, "depthMultiplier": 0.8, "bodyWavelength": 704, - "bodyDetailWavelength": 88, + "bodyDetailWavelength": 18, + "bodyDetailInfluence": 0.82, "widthVariation": 0.75, "bankVariation": 0.65, "depthVariation": 0.55, @@ -89,7 +99,14 @@ public class IrisRiverConfigurationTest { ], "terminalMode": "SUPPRESS" }, - "water": {"mode": "TERRACED", "poolLength": 80}, + "water": { + "mode": "TERRACED", + "fluidHeight": -48, + "fluidPalette": { + "palette": [{"block": "minecraft:lava"}] + }, + "poolLength": 80 + }, "biomes": { "channel": ["river/channel"], "floodedCave": ["river/grotto"] @@ -97,7 +114,29 @@ public class IrisRiverConfigurationTest { "caves": { "mode": "FLOOD_CLOSED_COMPONENT", "maxFloodVolume": 2048, - "existingFluidPolicy": "ALLOW_SAME" + "existingFluidPolicy": "ALLOW_SAME", + "deepPools": { + "enabled": true, + "reach": { + "chance": 0.4, + "influence": 0.1 + }, + "minimumSpacing": 896, + "maximumPerReach": 2, + "minimumFluidY": -220, + "maximumFluidY": -112, + "searchRadius": 24, + "searchAttempts": 18, + "horizontalRadius": 28, + "verticalRadius": 11, + "dryHeadroom": 5, + "shapeVariation": 0.7, + "warpStrength": 9, + "maximumVolume": 65536, + "fluidPalette": { + "palette": [{"block": "minecraft:lava"}] + } + } } } } @@ -130,6 +169,7 @@ public class IrisRiverConfigurationTest { assertFalse(dimension.getRivers().getTopology().isRequireOcean()); assertEquals(0.27D, dimension.getRivers().getTopology().getSource().getChance(), 0D); assertEquals(9D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); + assertEquals(3D, dimension.getRivers().getTerrain().getChannelRadiusBonus(), 0D); assertEquals(2.5D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); assertEquals(8D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); assertEquals(36, dimension.getRivers().getTerrain().getMaxIncision()); @@ -146,7 +186,8 @@ public class IrisRiverConfigurationTest { assertEquals(1.2D, worm.getBankMultiplier(), 0D); assertEquals(0.8D, worm.getDepthMultiplier(), 0D); assertEquals(704D, worm.getBodyWavelength(), 0D); - assertEquals(88D, worm.getBodyDetailWavelength(), 0D); + assertEquals(18D, worm.getBodyDetailWavelength(), 0D); + assertEquals(0.82D, worm.getBodyDetailInfluence(), 0D); assertEquals(0.75D, worm.getWidthVariation(), 0D); assertEquals(0.65D, worm.getBankVariation(), 0D); assertEquals(0.55D, worm.getDepthVariation(), 0D); @@ -154,12 +195,32 @@ public class IrisRiverConfigurationTest { assertEquals(IrisRiverTerminalMode.SUPPRESS, dimension.getRivers().getTerrain().getTerminalMode()); assertEquals(IrisRiverWaterMode.TERRACED, dimension.getRivers().getWater().getMode()); + assertEquals(-48, dimension.getRivers().getWater().getFluidHeight()); + assertEquals("minecraft:lava", + dimension.getRivers().getWater().getFluidPalette().getPalette().getFirst().getBlock()); assertEquals(Set.of("river/channel", "river/grotto"), Set.copyOf(dimension.getRivers().getBiomes().getAllBiomeIds())); assertEquals(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, dimension.getRivers().getCaves().getMode()); assertEquals(IrisRiverExistingFluidPolicy.ALLOW_SAME, dimension.getRivers().getCaves().getExistingFluidPolicy()); + IrisRiverDeepPools deepPools = dimension.getRivers().getCaves().getDeepPools(); + assertTrue(deepPools.isEnabled()); + assertEquals(0.4D, deepPools.getReach().getChance(), 0D); + assertEquals(0.1D, deepPools.getReach().getInfluence(), 0D); + assertEquals(896, deepPools.getMinimumSpacing()); + assertEquals(2, deepPools.getMaximumPerReach()); + assertEquals(-220, deepPools.getMinimumFluidY()); + assertEquals(-112, deepPools.getMaximumFluidY()); + assertEquals(24, deepPools.getSearchRadius()); + assertEquals(18, deepPools.getSearchAttempts()); + assertEquals(28, deepPools.getHorizontalRadius()); + assertEquals(11, deepPools.getVerticalRadius()); + assertEquals(5, deepPools.getDryHeadroom()); + assertEquals(0.7D, deepPools.getShapeVariation(), 0D); + assertEquals(9D, deepPools.getWarpStrength(), 0D); + assertEquals(65536, deepPools.getMaximumVolume()); + assertEquals("minecraft:lava", deepPools.getFluidPalette().getPalette().getFirst().getBlock()); assertEquals(Boolean.FALSE, region.getRiverOverride().getAllowSources()); assertEquals(IrisRiverRoutingPolicy.AVOID, region.getRiverOverride().getRoutingPolicy()); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java new file mode 100644 index 000000000..f03b16ff6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java @@ -0,0 +1,189 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class BukkitChunkGeneratorInitialSpawnTest { + @Test + public void spawnReadinessWaitsForChunkAndRegionPlacement() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + World world = world("spawn-success"); + Chunk chunk = chunk(world); + CompletableFuture chunkFuture = new CompletableFuture<>(); + AtomicReference regionTask = new AtomicReference<>(); + when(world.getChunkAtAsync(0, 0, true)).thenReturn(chunkFuture); + when(world.getSpawnLocation()).thenReturn(new Location(world, 0.5D, 64D, 0.5D)); + when(world.getHighestBlockYAt(any(Location.class))).thenReturn(70); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.runRegionFuture( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class))) + .thenAnswer(invocation -> { + regionTask.set(invocation.getArgument(3, Runnable.class)); + return CompletableFuture.completedFuture(null); + }); + + invokeUpdateSpawnLocation(generator, world); + assertFalse(readiness.isDone()); + + chunkFuture.complete(chunk); + assertNotNull(regionTask.get()); + assertFalse(readiness.isDone()); + + regionTask.get().run(); + readiness.get(5L, TimeUnit.SECONDS); + } + } + + @Test + public void failedChunkRequestFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + World world = world("spawn-chunk-failure"); + IllegalStateException failure = new IllegalStateException("chunk failed"); + when(world.getChunkAtAsync(0, 0, true)).thenReturn(CompletableFuture.failedFuture(failure)); + + invokeUpdateSpawnLocation(generator, world); + + assertSame(failure, awaitFailure(readiness)); + } + + @Test + public void nullChunkFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + World world = world("spawn-null-chunk"); + when(world.getChunkAtAsync(0, 0, true)).thenReturn(CompletableFuture.completedFuture(null)); + + invokeUpdateSpawnLocation(generator, world); + + Throwable failure = awaitFailure(readiness); + assertTrue(failure.getMessage().contains("Initial spawn preparation failed")); + assertNotNull(failure.getCause()); + assertTrue(failure.getCause().getMessage().contains("completed without a chunk")); + } + + @Test + public void rejectedRegionScheduleFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + World world = world("spawn-schedule-failure"); + Chunk chunk = chunk(world); + IllegalStateException failure = new IllegalStateException("region rejected"); + when(world.getChunkAtAsync(0, 0, true)).thenReturn(CompletableFuture.completedFuture(chunk)); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.runRegionFuture( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class))) + .thenReturn(CompletableFuture.failedFuture(failure)); + + invokeUpdateSpawnLocation(generator, world); + } + + assertSame(failure, awaitFailure(readiness)); + } + + @Test + public void failedRegionPlacementFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + World world = world("spawn-placement-failure"); + Chunk chunk = chunk(world); + IllegalStateException failure = new IllegalStateException("spawn placement failed"); + when(world.getChunkAtAsync(0, 0, true)).thenReturn(CompletableFuture.completedFuture(chunk)); + when(world.getSpawnLocation()).thenThrow(failure); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.runRegionFuture( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class))) + .thenAnswer(invocation -> { + invocation.getArgument(3, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }); + + invokeUpdateSpawnLocation(generator, world); + } + + assertSame(failure, awaitFailure(readiness)); + } + + private static BukkitChunkGenerator generatorWithReadiness( + CompletableFuture readiness + ) throws ReflectiveOperationException { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + Field readinessField = BukkitChunkGenerator.class.getDeclaredField("initialSpawnReady"); + readinessField.setAccessible(true); + readinessField.set(generator, readiness); + return generator; + } + + private static World world(String name) { + World world = mock(World.class); + when(world.getName()).thenReturn(name); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + return world; + } + + private static Chunk chunk(World world) { + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(0); + when(chunk.getZ()).thenReturn(0); + return chunk; + } + + private static void invokeUpdateSpawnLocation( + BukkitChunkGenerator generator, + World world + ) throws ReflectiveOperationException { + Method updateSpawnLocation = BukkitChunkGenerator.class.getDeclaredMethod( + "updateSpawnLocation", + World.class); + updateSpawnLocation.setAccessible(true); + updateSpawnLocation.invoke(generator, world); + } + + private static Throwable awaitFailure(CompletableFuture readiness) throws Exception { + try { + readiness.get(5L, TimeUnit.SECONDS); + fail("Expected initial spawn readiness to fail."); + throw new AssertionError("unreachable"); + } catch (ExecutionException exception) { + return exception.getCause(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java index cc1dc0a01..4d22b6209 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java @@ -1018,6 +1018,117 @@ public class RiverNetworkTest { assertTrue(maximumRoof - minimumRoof > 0.1D); } + @Test + public void volatileBodyProfilesResolveSubTenBlockThicknessChanges() { + RiverWorm volatileBody = new RiverWorm( + "volatile-body", + 4815L, + 1D, + 1024D, + 128D, + 0.65D, + 0.2D, + 180D, + 48, + 1D, + 1D, + 1D, + 24D, + 8D, + 0.95D, + 0.875D, + 0.75D, + 0.75D, + 0.75D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + ); + RiverNetwork network = new RiverNetwork(options(4815L) + .cellSize(1700) + .tileCells(1) + .siteJitter(0D) + .requireOcean(false) + .channelWidth(14D) + .maxChannelWidth(38D) + .maximumReachRadius(64D) + .worms(List.of(volatileBody)) + .build()); + + RiverReach reach = network.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); + RiverBodyProfile profile = reach.bodyProfile(); + double maximumStationSpacing = 0D; + for (int index = 1; index < profile.size(); index++) { + maximumStationSpacing = StrictMath.max( + maximumStationSpacing, + reach.polyline().length() * (profile.position(index) - profile.position(index - 1)) + ); + } + boolean volatileChangeFound = false; + for (double distance = 0D; distance + 10D <= reach.polyline().length(); distance += 2D) { + double start = distance / reach.polyline().length(); + double end = (distance + 10D) / reach.polyline().length(); + if (StrictMath.abs(profile.width(start) - profile.width(end)) >= 2D) { + volatileChangeFound = true; + break; + } + } + + assertTrue(maximumStationSpacing <= 10D); + assertTrue(volatileChangeFound); + } + + @Test + public void channelRadiusBonusAddsThreeBlocksPerSideAfterShaping() { + RiverWorm constantBody = wormProfile( + "radius-bonus", + 6501L, + 8, + 1D, + 1D, + 1D, + 64D, + 16D, + 0D, + 0D, + 0D, + 0D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + ); + RiverNetwork baseline = new RiverNetwork(options(6501L) + .siteJitter(0D) + .requireOcean(false) + .channelWidth(10D) + .maxChannelWidth(38D) + .maximumReachRadius(64D) + .worms(List.of(constantBody)) + .build()); + RiverNetwork expanded = new RiverNetwork(options(6501L) + .siteJitter(0D) + .requireOcean(false) + .channelWidth(10D) + .channelRadiusBonus(3D) + .maxChannelWidth(38D) + .maximumReachRadius(64D) + .worms(List.of(constantBody)) + .build()); + + RiverReach baselineReach = baseline.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); + RiverReach expandedReach = expanded.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); + + assertEquals(baselineReach.id(), expandedReach.id()); + assertEquals(6D, expandedReach.bodyProfile().width(0.5D) + - baselineReach.bodyProfile().width(0.5D), 0.0000001D); + } + @Test public void foldedReachSamplingFindsFartherCoveringWidthEnvelope() { RiverNode from = node(0L, 0L, 0D, 0D); @@ -1119,17 +1230,17 @@ public class RiverNetworkTest { public void weightedWormProfilesSelectDistinctConfigurableVariants() { RiverWorm gentle = new RiverWorm( "gentle", 301L, 1D, 1024D, 256D, 0.2D, 0.05D, 10D, 8, 0.5D, 0.5D, 0.5D, - 512D, 128D, 0D, 0D, 0D, 0D, + 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, 4, 0.35D, 1D, 0D, 0D, List.of() ); RiverWorm winding = new RiverWorm( "winding", 302L, 1D, 512D, 128D, 0.55D, 0.15D, 20D, 16, 1D, 1D, 1D, - 512D, 128D, 0D, 0D, 0D, 0D, + 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, 4, 0.35D, 1D, 0D, 0D, List.of() ); RiverWorm restless = new RiverWorm( "restless", 303L, 1D, 192D, 48D, 0.9D, 0.35D, 30D, 32, 2D, 2D, 2D, - 512D, 128D, 0D, 0D, 0D, 0D, + 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, 4, 0.35D, 1D, 0D, 0D, List.of() ); RiverNetwork network = new RiverNetwork(options(64L) @@ -1547,6 +1658,7 @@ public class RiverNetworkTest { 1D, 512D, 128D, + 0.3D, 0D, 0D, 0D, @@ -1631,6 +1743,7 @@ public class RiverNetworkTest { depthMultiplier, bodyWavelength, bodyDetailWavelength, + 0.3D, widthVariation, bankVariation, depthVariation, diff --git a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java index f40a2d3a0..de2b39d7b 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java @@ -160,8 +160,8 @@ public class RiverCaveContainmentPlannerTest { RiverCavePlan rejected = planner.plan(view, source, rejectedSettings); assertTrue(accepted.accepted()); - assertEquals(RiverCaveAction.FALLING_WATER, accepted.actions().get(position(0, 11, 0))); - assertEquals(RiverCaveAction.FALLING_WATER, accepted.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 11, 0))); + assertEquals(RiverCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 12, 0))); assertEquals(RiverCaveRejection.DRY_HEADROOM_LIMIT, rejected.rejection()); } @@ -341,7 +341,7 @@ public class RiverCaveContainmentPlannerTest { RiverCavePlan plan = planner.plan(view, source, settings()); assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 8, 0))); assertTrue(plan.actions().containsValue(RiverCaveAction.SEAL_GUARD)); assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); @@ -383,7 +383,7 @@ public class RiverCaveContainmentPlannerTest { assertTrue(plan.rejection().toString(), plan.accepted()); for (CavePosition position : wetMouth) { - assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position)); + assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position)); } } @@ -401,6 +401,53 @@ public class RiverCaveContainmentPlannerTest { assertRejectedWithoutPublication(plan, RiverCaveRejection.GROTTO_SHELL_OPEN); } + @Test + public void deepPoolOpensOnlyAboveItsContainedFluidHead() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 11, 0), position(0, 13, 0)); + RiverCaveSource source = new RiverCaveSource( + 109L, + position(0, 10, 0), + position(0, 8, 0), + 10, + RiverCaveMode.DEEP_POOL + ); + + RiverCavePlan plan = planner.plan(view, source, detailedSettings( + 1, + 2, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.REJECT_EXISTING + )); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); + assertFalse(plan.actions().containsKey(position(0, 13, 0))); + } + + @Test + public void deepPoolRejectsCaveLeakAtOrBelowItsFluidHead() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(5, 8, 0)); + RiverCaveSource source = new RiverCaveSource( + 110L, + position(0, 10, 0), + position(0, 8, 0), + 10, + RiverCaveMode.DEEP_POOL + ); + + RiverCavePlan plan = planner.plan(view, source, detailedSettings( + 1, + 2, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.REJECT_EXISTING + )); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.GROTTO_SHELL_OPEN); + } + @Test public void combinedModeUsesClosedCaveOrGeneratedGrottoFromBaseline() { TestVoxelView caveView = new TestVoxelView(); @@ -426,8 +473,8 @@ public class RiverCaveContainmentPlannerTest { RiverCavePlan plan = planner.plan(view, source, settings()); assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position(0, 12, 0))); - assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position(0, 11, 0))); + assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 11, 0))); assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); assertFalse(plan.actions().containsValue(RiverCaveAction.DRY_AIR)); } diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java index 38d4f8c35..d1b7c8d96 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java @@ -138,6 +138,18 @@ public class IrisRiverRuntimeTest { } } + @Test + public void footprintPresenceQueryDistinguishesEmptyAndRoutedAreas() { + IrisRiverNetwork emptyConfiguration = configuration(false); + emptyConfiguration.getTopology().getSource().setChance(0D); + + try (IrisRiverRuntime empty = runtime(emptyConfiguration); + IrisRiverRuntime routed = runtime(configuration(false))) { + assertFalse(empty.hasRiverFootprint(-128, -128, 128, 128)); + assertTrue(routed.hasRiverFootprint(-128, -128, 128, 128)); + } + } + @Test public void settingsAtSkipsNaturalBiomeWhenBiomeOverridesAreUnreachable() { IrisRiverNetwork configuration = configuration(false); @@ -236,6 +248,7 @@ public class IrisRiverRuntimeTest { configuration, mock(IrisData.class), 63, + 63, false, true, true, @@ -290,6 +303,7 @@ public class IrisRiverRuntimeTest { configuration, mock(IrisData.class), 63, + 63, true, true, false, @@ -453,6 +467,37 @@ public class IrisRiverRuntimeTest { } } + @Test + public void deepPoolReachGateIsIndependentSparseAndReachLimited() { + IrisRiverNetwork configuration = configuration(false); + configuration.getCaves().getDeepPools() + .setEnabled(true) + .setMaximumPerReach(1); + configuration.getCaves().getDeepPools().getReach() + .setChance(1D) + .setInfluence(0D) + .setStyle(flat()); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + List anchors = runtime.candidateAnchors(0, 0, 256, 256, 16D, 882L); + Map acceptedPerReach = new HashMap<>(); + for (RiverAnchor anchor : anchors) { + if (runtime.acceptsDeepPoolAnchor(anchor)) { + acceptedPerReach.merge(anchor.reachId(), 1, Integer::sum); + } + } + + assertFalse(acceptedPerReach.isEmpty()); + for (int accepted : acceptedPerReach.values()) { + assertEquals(1, accepted); + } + configuration.getCaves().getDeepPools().setEnabled(false); + for (RiverAnchor anchor : anchors) { + assertFalse(runtime.acceptsDeepPoolAnchor(anchor)); + } + } + } + @Test public void finalPolylineSupercoverRejectsOneBlockedColumnMissedByWidthSpacing() { IrisRiverNetwork configuration = configuration(false); @@ -524,6 +569,7 @@ public class IrisRiverRuntimeTest { configuration, mock(IrisData.class), 63, + 63, false, true, false, @@ -693,15 +739,26 @@ public class IrisRiverRuntimeTest { } @Test - public void cliffTransitionClassifiesOpenAndSolidColumnsIndependently() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTerrain() + public void cliffTransitionCreatesAFlaredTunnelMouth() { + IrisRiverNetwork sealedConfiguration = configuration(false); + sealedConfiguration.getTerrain() .setMaxIncision(10) - .setTunnelMouthBlend(2D); + .setTunnelMouthBlend(0D); + IrisRiverNetwork mouthConfiguration = configuration(false); + mouthConfiguration.getTerrain() + .setMaxIncision(10) + .setTunnelMouthBlend(6D); ProceduralStream height = ProceduralStream.ofDouble((x, z) -> x < 0D ? 50D : 100D); - try (IrisRiverRuntime runtime = runtime( - configuration, + try (IrisRiverRuntime sealed = runtime( + sealedConfiguration, + height, + constantLandBiome(), + new IrisRegion(), + true, + false + ); IrisRiverRuntime mouth = runtime( + mouthConfiguration, height, constantLandBiome(), new IrisRegion(), @@ -710,14 +767,16 @@ public class IrisRiverRuntimeTest { )) { boolean transitionFound = false; for (int z = -2048; z <= 2048 && !transitionFound; z++) { - IrisRiverSurfaceSample open = runtime.sample(-1, z); - IrisRiverSurfaceSample solid = runtime.sample(0, z); - IrisRiverTunnelSample tunnel = runtime.sampleTunnel(0, z); + IrisRiverSurfaceSample open = mouth.sample(-1, z); + IrisRiverSurfaceSample solid = mouth.sample(0, z); + IrisRiverTunnelSample tunnel = mouth.sampleTunnel(0, z); + IrisRiverTunnelSample unflared = sealed.sampleTunnel(0, z); if (open.river().present() && solid.river().present() && open.river().reachId().equals(solid.river().reachId()) && open.surfaceFluid() - && tunnel != null) { + && tunnel != null + && (unflared == null || tunnel.ceilingY() > unflared.ceilingY())) { assertFalse(open.subterranean()); assertTrue(solid.subterranean()); transitionFound = true; @@ -814,6 +873,19 @@ public class IrisRiverRuntimeTest { } } + @Test + public void fixedRiverHeightIsIndependentFromNaturalOceanHeight() { + IrisRiverNetwork configuration = configuration(false); + configuration.getWater() + .setMode(IrisRiverWaterMode.FIXED) + .setFluidHeight(-48); + + try (IrisRiverRuntime runtime = runtimeWithFluidHeights(configuration, -48, 63)) { + assertEquals(-48D, runtime.waterSurface(null, 0D, false), 0D); + assertEquals(63D, runtime.waterSurface(null, 0D, true), 0D); + } + } + private static IrisRiverRuntime runtime(IrisRiverNetwork configuration) { IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); IrisRegion region = new IrisRegion(); @@ -897,6 +969,55 @@ public class IrisRiverRuntimeTest { boolean caveHydrologyActive, boolean blockingRoutingPossible, boolean biomeRiverOverridesPossible + ) { + return runtime( + configuration, + height, + biome, + region, + boreMantleActive, + caveHydrologyActive, + blockingRoutingPossible, + biomeRiverOverridesPossible, + 63, + 63 + ); + } + + private static IrisRiverRuntime runtimeWithFluidHeights( + IrisRiverNetwork configuration, + int riverFluidHeight, + int dimensionFluidHeight + ) { + IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); + return runtime( + configuration, + constantHeight(80D), + ProceduralStream.of( + (x, z) -> biome, + Interpolated.of(value -> 0D, value -> biome) + ), + new IrisRegion(), + false, + true, + true, + true, + riverFluidHeight, + dimensionFluidHeight + ); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome, + IrisRegion region, + boolean boreMantleActive, + boolean caveHydrologyActive, + boolean blockingRoutingPossible, + boolean biomeRiverOverridesPossible, + int riverFluidHeight, + int dimensionFluidHeight ) { ProceduralStream slope = ProceduralStream.ofDouble((x, z) -> 0.025D); ProceduralStream oceans = ProceduralStream.of( @@ -911,7 +1032,8 @@ public class IrisRiverRuntimeTest { 4829759234L, configuration, mock(IrisData.class), - 63, + riverFluidHeight, + dimensionFluidHeight, boreMantleActive, caveHydrologyActive, blockingRoutingPossible, diff --git a/core/src/test/java/art/arcane/iris/util/common/scheduling/JRegionFutureContractTest.java b/core/src/test/java/art/arcane/iris/util/common/scheduling/JRegionFutureContractTest.java new file mode 100644 index 000000000..470a3f737 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/scheduling/JRegionFutureContractTest.java @@ -0,0 +1,24 @@ +package art.arcane.iris.util.common.scheduling; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class JRegionFutureContractTest { + @Test + public void regionFutureSettlesEverySchedulerPath() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/util/common/scheduling/J.java")) + .replace("\r\n", "\n"); + int start = source.indexOf("public static CompletableFuture runRegionFuture("); + int end = source.indexOf("public static boolean runGlobal(", start); + String method = source.substring(start, end); + + assertTrue(method.contains("settle(future, runnable)")); + assertTrue(method.contains("future.completeExceptionally(")); + assertTrue(method.contains("return sfut(runnable);")); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java b/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java index 0260b69cf..c0008d65f 100644 --- a/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java +++ b/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.util.project.matter.slices; import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import org.junit.Test; @@ -18,14 +19,20 @@ public class RiverCaveHydrologyMatterTest { public void everyActionAndBiomeRoundTrips() throws IOException { RiverCaveHydrologyMatter matter = new RiverCaveHydrologyMatter(); for (RiverCaveAction action : RiverCaveAction.values()) { - RiverCaveHydrology expected = new RiverCaveHydrology(action, "iris:flooded_grotto"); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - matter.writeNode(expected, new DataOutputStream(bytes)); + for (RiverCaveFluidKind fluidKind : RiverCaveFluidKind.values()) { + RiverCaveHydrology expected = new RiverCaveHydrology( + action, + "iris:flooded_grotto", + fluidKind + ); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); - RiverCaveHydrology actual = matter.readNode( - new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + RiverCaveHydrology actual = matter.readNode( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); - assertEquals(expected, actual); + assertEquals(expected, actual); + } } } @@ -40,5 +47,7 @@ public class RiverCaveHydrologyMatterTest { new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())))); assertThrows(IOException.class, () -> matter.readNode( new DataInputStream(new ByteArrayInputStream(new byte[]{99})))); + assertThrows(IOException.class, () -> matter.readNode( + new DataInputStream(new ByteArrayInputStream(new byte[]{1, 99})))); } } diff --git a/probe/build.gradle b/probe/build.gradle index 665118904..c01c08d75 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -55,6 +55,8 @@ Provider probeWarmupChunks = providers.gradleProperty('probeWarmupChunks Provider probeMeasuredChunks = providers.gradleProperty('probeMeasuredChunks').orElse('1024') Provider probeCenterChunkX = providers.gradleProperty('probeCenterChunkX').orElse('2048') Provider probeCenterChunkZ = providers.gradleProperty('probeCenterChunkZ').orElse('2048') +Provider probeMulticore = providers.gradleProperty('probeMulticore').orElse('false') +Provider probeStudio = providers.gradleProperty('probeStudio').orElse('false') tasks.register('genProbe', JavaExec) { group = 'verification' @@ -76,7 +78,9 @@ tasks.register('genProbe', JavaExec) { probeWarmupChunks.get(), probeMeasuredChunks.get(), probeCenterChunkX.get(), - probeCenterChunkZ.get()) + probeCenterChunkZ.get(), + probeMulticore.get(), + probeStudio.get()) } } diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 292bca222..29226dc57 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -59,7 +59,6 @@ import java.util.stream.Stream; public final class GenerationProbe { private static final long SEED = 1337L; - private static final int SIGNATURE_SAMPLE_STEP = 8; private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); private static final class InertPreservation implements PreservationRegistry { @@ -127,7 +126,7 @@ public final class GenerationProbe { } record ProbeConfiguration(File packSource, String dimensionKey, int warmupChunks, int measuredChunks, - int centerChunkX, int centerChunkZ) { + int centerChunkX, int centerChunkZ, boolean multicore, boolean studio) { ProbeConfiguration { if (packSource == null) { throw new IllegalArgumentException("Pack folder is required."); @@ -144,8 +143,8 @@ public final class GenerationProbe { } static ProbeConfiguration parse(String[] args) { - if (args.length != 6) { - throw new IllegalArgumentException("Expected: "); + if (args.length != 8) { + throw new IllegalArgumentException("Expected: "); } return new ProbeConfiguration( new File(args[0]), @@ -153,7 +152,9 @@ public final class GenerationProbe { Integer.parseInt(args[2]), Integer.parseInt(args[3]), Integer.parseInt(args[4]), - Integer.parseInt(args[5])); + Integer.parseInt(args[5]), + Boolean.parseBoolean(args[6]), + Boolean.parseBoolean(args[7])); } } @@ -189,18 +190,21 @@ public final class GenerationProbe { TimingSummary measuredTimings, String signature) { } - record ProbeResult(String status, String dimensionKey, int warmupChunks, int measuredChunks, + record ProbeResult(String status, String dimensionKey, int warmupChunks, int measuredChunks, boolean multicore, + boolean studio, int successfulChunks, int failedChunks, long engineReadyNanos, long firstChunkNanos, TimingSummary measuredTimings, String signature) { String machineLine() { double measuredSeconds = measuredTimings.totalNanos() / 1_000_000_000D; double chunksPerSecond = measuredSeconds == 0D ? 0D : measuredChunks / measuredSeconds; return String.format(Locale.ROOT, - "IRIS_GENPROBE_RESULT version=1 status=%s dimension=%s warmup_chunks=%d measured_chunks=%d successful_chunks=%d failed_chunks=%d engine_ready_ms=%.3f first_chunk_ms=%.3f measured_median_ms=%.3f measured_p95_ms=%.3f measured_max_ms=%.3f measured_total_ms=%.3f measured_cps=%.3f signature=%s", + "IRIS_GENPROBE_RESULT version=1 status=%s dimension=%s warmup_chunks=%d measured_chunks=%d multicore=%s studio=%s successful_chunks=%d failed_chunks=%d engine_ready_ms=%.3f first_chunk_ms=%.3f measured_median_ms=%.3f measured_p95_ms=%.3f measured_max_ms=%.3f measured_total_ms=%.3f measured_cps=%.3f signature=%s", status, dimensionKey, warmupChunks, measuredChunks, + multicore, + studio, successfulChunks, failedChunks, nanosToMillis(engineReadyNanos), @@ -239,6 +243,8 @@ public final class GenerationProbe { configuration.dimensionKey(), configuration.warmupChunks(), configuration.measuredChunks(), + configuration.multicore(), + configuration.studio(), 0, configuration.warmupChunks() + configuration.measuredChunks(), 0L, @@ -285,6 +291,8 @@ public final class GenerationProbe { System.out.println("[genprobe] warmup chunks: " + configuration.warmupChunks()); System.out.println("[genprobe] measured chunks: " + configuration.measuredChunks()); System.out.println("[genprobe] center chunk: " + configuration.centerChunkX() + "," + configuration.centerChunkZ()); + System.out.println("[genprobe] multicore: " + configuration.multicore()); + System.out.println("[genprobe] studio: " + configuration.studio()); long engineStart = System.nanoTime(); data = IrisData.get(pack); @@ -302,7 +310,11 @@ public final class GenerationProbe { .maxHeight(dimension.getMaxHeight()) .build(); EngineTarget target = new EngineTarget(world, dimension, data); - engine = new IrisEngine(target, IrisEngine.InitializationMode.RUNTIME); + engine = new IrisEngine( + target, + configuration.studio() + ? IrisEngine.InitializationMode.STUDIO + : IrisEngine.InitializationMode.RUNTIME); long engineReadyNanos = System.nanoTime() - engineStart; List initNoise = settleAndDrain(); @@ -311,7 +323,6 @@ public final class GenerationProbe { + " seed=" + engine.getSeedManager().getSeed() + " minY=" + engine.getMinHeight() + " maxY=" + engine.getMaxHeight() + " timeMs=" + String.format(Locale.ROOT, "%.3f", nanosToMillis(engineReadyNanos))); - GenerationResult generation = generate(engine, configuration); String status = generation.failedChunks() == 0 ? "PASS" : "FAIL"; printGenerationFailures(generation); @@ -320,6 +331,8 @@ public final class GenerationProbe { configuration.dimensionKey(), configuration.warmupChunks(), configuration.measuredChunks(), + configuration.multicore(), + configuration.studio(), generation.successfulChunks(), generation.failedChunks(), engineReadyNanos, @@ -390,7 +403,12 @@ public final class GenerationProbe { Hunk biomes = Hunk.newArrayHunk(16, height, 16); long started = System.nanoTime(); try { - engine.generate(coordinate.x() << 4, coordinate.z() << 4, blocks, biomes, false); + engine.generate( + coordinate.x() << 4, + coordinate.z() << 4, + blocks, + biomes, + configuration.multicore()); } catch (Throwable e) { failures.add(e); } @@ -465,10 +483,9 @@ public final class GenerationProbe { private static void updateSignature(MessageDigest digest, ChunkCoordinate coordinate, Hunk blocks, Hunk biomes, int height) { updateDigest(digest, coordinate.x() + "," + coordinate.z()); - int verticalStep = Math.max(1, height / 16); - for (int x = 0; x < 16; x += SIGNATURE_SAMPLE_STEP) { - for (int z = 0; z < 16; z += SIGNATURE_SAMPLE_STEP) { - for (int y = 0; y < height; y += verticalStep) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { PlatformBlockState state = blocks.get(x, y, z); PlatformBiome biome = biomes.get(x, y, z); updateDigest(digest, state == null ? "minecraft:air" : state.key()); diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java new file mode 100644 index 000000000..4c141e7b4 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java @@ -0,0 +1,157 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.river.RiverBodyProfile; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverNode; +import art.arcane.iris.engine.river.RiverNodeId; +import art.arcane.iris.engine.river.RiverPolyline; +import art.arcane.iris.engine.river.RiverReach; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverTile; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public final class RiverTileProbe { + private static final int WARMUP_ROUNDS = 5; + private static final int MEASURED_ROUNDS = 25; + private static final int SAMPLES_PER_ROUND = 8_192; + + private RiverTileProbe() { + } + + public static void main(String[] args) { + RiverTile tile = createTile(); + long expectedSignature = sampleRound(tile); + for (int round = 0; round < WARMUP_ROUNDS; round++) { + requireSignature(expectedSignature, sampleRound(tile)); + } + + long[] timings = new long[MEASURED_ROUNDS]; + for (int round = 0; round < MEASURED_ROUNDS; round++) { + long start = System.nanoTime(); + long signature = sampleRound(tile); + timings[round] = System.nanoTime() - start; + requireSignature(expectedSignature, signature); + System.out.printf(Locale.ROOT, + "IRIS_RIVER_TILE_SAMPLE round=%d nanos=%d signature=%016x%n", + round, + timings[round], + signature); + } + long[] sorted = timings.clone(); + Arrays.sort(sorted); + System.out.printf(Locale.ROOT, + "IRIS_RIVER_TILE_RESULT version=1 rounds=%d samples_per_round=%d median_nanos=%d p95_nanos=%d signature=%016x%n", + MEASURED_ROUNDS, + SAMPLES_PER_ROUND, + sorted[MEASURED_ROUNDS / 2], + sorted[(int) StrictMath.ceil(MEASURED_ROUNDS * 0.95D) - 1], + expectedSignature); + } + + private static RiverTile createTile() { + List reaches = new ArrayList<>(64); + for (int reachIndex = 0; reachIndex < 64; reachIndex++) { + RiverNode from = node(0L, reachIndex, 0D, reachIndex * 32D); + RiverNode to = node(1L, reachIndex, 2_048D, reachIndex * 32D); + RiverBodyProfile profile = profile(reachIndex); + double[] x = new double[33]; + double[] z = new double[33]; + for (int point = 0; point < x.length; point++) { + x[point] = point * 64D; + z[point] = reachIndex * 32D + + StrictMath.sin(point * 0.625D + reachIndex * 0.25D) * 12D; + } + reaches.add(new RiverReach( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + RiverRouteState.WET, + 1 + reachIndex % 4, + 1 + reachIndex % 3, + profile.maximumWidth(), + profile.maximumBankWidth(), + profile.maximumDepth(), + profile, + false, + false, + new RiverPolyline(x, z) + )); + } + return new RiverTile(0, 0, 0, 0, 2_048, 2_048, reaches); + } + + private static RiverBodyProfile profile(int reachIndex) { + double[] positions = new double[17]; + double[] widths = new double[17]; + double[] bankWidths = new double[17]; + double[] depths = new double[17]; + double[] roofScales = new double[17]; + for (int index = 0; index < positions.length; index++) { + double position = index / 16D; + double body = StrictMath.sin(StrictMath.PI * position); + positions[index] = position; + widths[index] = 8D + reachIndex % 5 + body * 6D; + bankWidths[index] = 4D + reachIndex % 3 + body * 4D; + depths[index] = 3D + reachIndex % 2 + body * 2D; + roofScales[index] = 1D - body * 0.4D; + } + return new RiverBodyProfile(positions, widths, bankWidths, depths, roofScales); + } + + private static RiverNode node(long cellX, long cellZ, double x, double z) { + return new RiverNode( + new RiverNodeId(cellX, cellZ), + x, + z, + 64D, + 64D, + 64D, + 64D, + false, + true + ); + } + + private static long sampleRound(RiverTile tile) { + long signature = 0xCBF29CE484222325L; + for (int sampleIndex = 0; sampleIndex < SAMPLES_PER_ROUND; sampleIndex++) { + double x = Math.floorMod(sampleIndex * 1_229, 2_048) + 0.375D; + double z = Math.floorMod(sampleIndex * 811, 2_048) + 0.625D; + double additionalRadius = 16D + sampleIndex % 17; + RiverSample sample = tile.sampleExpanded(x, z, additionalRadius); + signature = mix(signature, sample.present() ? 1L : 0L); + if (!sample.present()) { + continue; + } + signature = mix(signature, sample.reachId().stableId()); + signature = mix(signature, Double.doubleToLongBits(sample.distance())); + signature = mix(signature, Double.doubleToLongBits(sample.alongReach())); + signature = mix(signature, Double.doubleToLongBits(sample.carveWeight())); + signature = mix(signature, Double.doubleToLongBits(sample.width())); + signature = mix(signature, Double.doubleToLongBits(sample.bankWidth())); + signature = mix(signature, Double.doubleToLongBits(sample.depth())); + signature = mix(signature, sample.section().ordinal()); + } + return signature; + } + + private static long mix(long hash, long value) { + return (hash ^ value) * 0x100000001B3L; + } + + private static void requireSignature(long expected, long actual) { + if (actual != expected) { + throw new IllegalStateException(String.format( + Locale.ROOT, + "River tile output changed: expected %016x but got %016x", + expected, + actual + )); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java index da55fcdce..4b7e37338 100644 --- a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java @@ -22,7 +22,9 @@ public final class GenerationProbeTest { "256", "1024", "2048", - "-2048" + "-2048", + "true", + "true" }); assertEquals(new File("/tmp/pack"), configuration.packSource()); @@ -31,6 +33,8 @@ public final class GenerationProbeTest { assertEquals(1024, configuration.measuredChunks()); assertEquals(2048, configuration.centerChunkX()); assertEquals(-2048, configuration.centerChunkZ()); + assertTrue(configuration.multicore()); + assertTrue(configuration.studio()); } @Test @@ -38,11 +42,11 @@ public final class GenerationProbeTest { assertThrows(IllegalArgumentException.class, () -> GenerationProbe.ProbeConfiguration.parse(new String[]{"/tmp/pack"})); assertThrows(IllegalArgumentException.class, - () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), " ", 1, 1, 0, 0)); + () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), " ", 1, 1, 0, 0, false, false)); assertThrows(IllegalArgumentException.class, - () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), "overworld", 0, 1, 0, 0)); + () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), "overworld", 0, 1, 0, 0, false, false)); assertThrows(IllegalArgumentException.class, - () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), "overworld", 1, 0, 0, 0)); + () -> new GenerationProbe.ProbeConfiguration(new File("/tmp/pack"), "overworld", 1, 0, 0, 0, false, false)); } @Test @@ -73,11 +77,11 @@ public final class GenerationProbeTest { GenerationProbe.TimingSummary timings = new GenerationProbe.TimingSummary( 10_000_000L, 20_000_000L, 30_000_000L, 40_000_000L); GenerationProbe.ProbeResult result = new GenerationProbe.ProbeResult( - "PASS", "underworld", 2, 4, 6, 0, + "PASS", "underworld", 2, 4, true, true, 6, 0, 5_000_000L, 6_000_000L, timings, "0123456789abcdef"); assertEquals( - "IRIS_GENPROBE_RESULT version=1 status=PASS dimension=underworld warmup_chunks=2 measured_chunks=4 successful_chunks=6 failed_chunks=0 engine_ready_ms=5.000 first_chunk_ms=6.000 measured_median_ms=10.000 measured_p95_ms=20.000 measured_max_ms=30.000 measured_total_ms=40.000 measured_cps=100.000 signature=0123456789abcdef", + "IRIS_GENPROBE_RESULT version=1 status=PASS dimension=underworld warmup_chunks=2 measured_chunks=4 multicore=true studio=true successful_chunks=6 failed_chunks=0 engine_ready_ms=5.000 first_chunk_ms=6.000 measured_median_ms=10.000 measured_p95_ms=20.000 measured_max_ms=30.000 measured_total_ms=40.000 measured_cps=100.000 signature=0123456789abcdef", result.machineLine()); }