From ce29b706186a1b0aac4a885768a3ab45d9f9b9da Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 23 Apr 2026 16:38:32 -0400 Subject: [PATCH] Content --- core/plugins/Iris/cache/instance | 2 +- .../arcane/iris/core/ServerConfigurator.java | 71 +++++++ .../lifecycle/PaperLikeRuntimeBackend.java | 4 +- .../core/runtime/StudioOpenCoordinator.java | 109 +---------- .../runtime/WorldRuntimeControlService.java | 92 ++-------- .../arcane/iris/core/tools/IrisCreator.java | 13 +- .../art/arcane/iris/engine/IrisEngine.java | 20 ++ .../mantle/components/IslandObjectPlacer.java | 97 +++++++--- .../MantleFloatingObjectComponent.java | 173 +++++++++++++++++- .../components/MantleObjectComponent.java | 19 +- .../engine/modifier/IrisDepositModifier.java | 58 +++++- .../IrisFloatingChildBiomeModifier.java | 6 + .../engine/object/FloatingIslandSample.java | 53 ++++++ .../object/FloatingObjectFootprint.java | 38 +++- .../arcane/iris/engine/object/IrisBiome.java | 3 + .../engine/object/IrisDepositVariant.java | 87 +++++++++ .../iris/engine/object/IrisDimension.java | 3 + .../object/IrisFloatingChildBiomes.java | 39 ++++ .../engine/object/IrisGeneratorStyle.java | 4 +- .../engine/object/IrisObjectRotation.java | 16 ++ .../arcane/iris/engine/object/IrisRegion.java | 3 + .../iris/engine/object/OverrideMode.java | 33 ++++ .../engine/platform/BukkitChunkGenerator.java | 4 +- ...erConfiguratorDatapackFingerprintTest.java | 74 ++++++++ ...enCoordinatorSpawnStuckRegressionTest.java | 30 +++ ...rldRuntimeControlServiceSafeEntryTest.java | 29 +-- ...ngObjectComponentInvertedCountersTest.java | 42 +++++ ...MantleObjectComponentCaveExposureTest.java | 33 ++++ .../FloatingIslandSampleBottomYTest.java | 75 ++++++++ .../IrisFloatingChildBiomesResolverTest.java | 112 ++++++++++++ .../object/IrisObjectRotationFlipTest.java | 41 +++++ .../IslandObjectPlacerAnchorFaceTest.java | 69 +++++++ 32 files changed, 1204 insertions(+), 248 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/OverrideMode.java create mode 100644 core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedCountersTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesResolverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectRotationFlipTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index 4b1356fce..f9dff3d09 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ --1935789196 \ No newline at end of file +1435163759 \ No newline at end of file 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 28225b763..a37f33188 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -42,7 +42,14 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicIntegerArray; @@ -172,6 +179,70 @@ public class ServerConfigurator { return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()); } + public static boolean installDataPacksIfChanged(boolean fullInstall) { + File packsDir = Iris.instance.getDataFolder("packs"); + String current = computePackFingerprint(packsDir); + File cacheFile = new File(Iris.instance.getDataFolder("cache"), "datapack-fingerprint"); + String cached = ""; + if (cacheFile.exists()) { + try { + cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim(); + } catch (IOException e) { + cached = ""; + } + } + if (!current.isEmpty() && current.equals(cached)) { + Iris.verbose("Data packs unchanged, skipping install."); + return false; + } + boolean result = installDataPacks(fullInstall); + try { + cacheFile.getParentFile().mkdirs(); + Files.writeString(cacheFile.toPath(), current, StandardCharsets.UTF_8); + } catch (IOException e) { + Iris.warn("Failed to write datapack fingerprint cache: " + e.getMessage()); + } + return result; + } + + public static String computePackFingerprint(File packsDir) { + if (packsDir == null || !packsDir.isDirectory()) { + return ""; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + List entries = new ArrayList<>(); + collectFingerprintEntries(packsDir, packsDir.getAbsolutePath(), entries); + Collections.sort(entries); + for (String entry : entries) { + digest.update(entry.getBytes(StandardCharsets.UTF_8)); + } + byte[] hash = digest.digest(); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static void collectFingerprintEntries(File dir, String rootPath, List entries) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isDirectory()) { + collectFingerprintEntries(file, rootPath, entries); + } else { + String relative = file.getAbsolutePath().substring(rootPath.length()); + entries.add(relative + "|" + file.length() + "|" + file.lastModified()); + } + } + } + private static boolean shouldDeferInstallUntilWorldsReady() { String forcedMainWorld = IrisSettings.get().getGeneral().forceMainWorld; if (forcedMainWorld != null && !forcedMainWorld.isBlank()) { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java index 75655d239..24cda371a 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java @@ -47,14 +47,14 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend { if (capabilities.paperLikeFlavor() == CapabilitySnapshot.PaperLikeFlavor.CURRENT_INFO_AND_DATA) { Object dimensionKey = WorldLifecycleSupport.createDimensionKey(stemKey); Object loadedWorldData = capabilities.paperWorldDataMethod().invoke(null, capabilities.minecraftServer(), dimensionKey, request.worldName()); - Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(request.environment(), stemKey, dimensionKey, true); + Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(request.environment(), stemKey, dimensionKey, !request.studio()); Object worldLoadingInfoAndData = capabilities.worldLoadingInfoAndDataConstructor().newInstance(worldLoadingInfo, loadedWorldData); Object worldDataAndGenSettings = WorldLifecycleSupport.createCurrentWorldDataAndSettings(capabilities, request.worldName()); capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfoAndData, worldDataAndGenSettings); } else { legacyStorageAccess = WorldLifecycleSupport.createLegacyStorageAccess(capabilities, request.worldName()); Object primaryLevelData = WorldLifecycleSupport.createLegacyPrimaryLevelData(capabilities, legacyStorageAccess, request.worldName()); - Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(0, request.worldName(), request.environment().name().toLowerCase(Locale.ROOT), stemKey, true); + Object worldLoadingInfo = capabilities.worldLoadingInfoConstructor().newInstance(0, request.worldName(), request.environment().name().toLowerCase(Locale.ROOT), stemKey, !request.studio()); capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfo, legacyStorageAccess, primaryLevelData); } 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 3eb09fa61..8fcbb80e2 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 @@ -118,14 +118,16 @@ public final class StudioOpenCoordinator { throw new IllegalStateException("Studio entry anchor could not be resolved."); } - long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(120L); - updateStage(request, "request_entry_chunk", 0.84D); - requestEntryChunk(world, entryAnchor, deadline); - - updateStage(request, "resolve_safe_entry", 0.90D); - Location safeEntry = resolveSafeEntry(world, entryAnchor, deadline); + updateStage(request, "resolve_safe_entry", 0.84D); + Location safeEntry; + try { + safeEntry = WorldRuntimeControlService.get().resolveSafeEntry(world, entryAnchor) + .get(5L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Studio entry point resolution timed out — region thread may be stalled."); + } if (safeEntry == null) { - throw new IllegalStateException("Studio safe entry resolution timed out."); + throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\"."); } if (request.playerName() != null && !request.playerName().isBlank()) { @@ -135,8 +137,7 @@ public final class StudioOpenCoordinator { throw new IllegalStateException("Player \"" + request.playerName() + "\" is not online."); } - long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); - Boolean teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(remaining, TimeUnit.MILLISECONDS); + Boolean teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(10L, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(teleported)) { throw new IllegalStateException("Studio teleport did not complete successfully."); } @@ -168,18 +169,6 @@ public final class StudioOpenCoordinator { } } - private void requestEntryChunk(World world, Location entryAnchor, long deadline) throws Exception { - int chunkX = entryAnchor.getBlockX() >> 4; - int chunkZ = entryAnchor.getBlockZ() >> 4; - long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); - waitForEntryChunk(world, chunkX, chunkZ, deadline, null).get(remaining, TimeUnit.MILLISECONDS); - } - - private Location resolveSafeEntry(World world, Location entryAnchor, long deadline) throws Exception { - long remaining = Math.max(1000L, deadline - System.currentTimeMillis()); - return waitForSafeEntry(world, entryAnchor, deadline, null).get(remaining, TimeUnit.MILLISECONDS); - } - private StudioCloseResult closeWorld( PlatformChunkGenerator provider, String worldName, @@ -361,58 +350,6 @@ public final class StudioOpenCoordinator { }; } - private CompletableFuture waitForEntryChunk(World world, int chunkX, int chunkZ, long deadline, Throwable lastFailure) { - long now = System.currentTimeMillis(); - if (now >= deadline) { - return CompletableFuture.failedFuture(timeoutFailure("Studio entry chunk request timed out.", lastFailure)); - } - - long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L); - CompletableFuture request = withAttemptTimeout( - WorldRuntimeControlService.get().requestChunkAsync(world, chunkX, chunkZ, true), - attemptTimeout, - "Studio entry chunk request attempt timed out." - ); - return request.handle((chunk, throwable) -> { - if (throwable == null && world.isChunkLoaded(chunkX, chunkZ)) { - return CompletableFuture.completedFuture(null); - } - - Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable); - if (System.currentTimeMillis() >= deadline) { - return CompletableFuture.failedFuture(timeoutFailure("Studio entry chunk request timed out.", nextFailure)); - } - - return delayFuture(1000L).thenCompose(ignored -> waitForEntryChunk(world, chunkX, chunkZ, deadline, nextFailure)); - }).thenCompose(next -> next); - } - - private CompletableFuture waitForSafeEntry(World world, Location entryAnchor, long deadline, Throwable lastFailure) { - long now = System.currentTimeMillis(); - if (now >= deadline) { - return CompletableFuture.failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", lastFailure)); - } - - long attemptTimeout = Math.min(Math.max(1000L, deadline - now), 3000L); - CompletableFuture resolve = withAttemptTimeout( - WorldRuntimeControlService.get().resolveSafeEntry(world, entryAnchor), - attemptTimeout, - "Studio safe-entry resolution attempt timed out." - ); - return resolve.handle((location, throwable) -> { - if (throwable == null && location != null) { - return CompletableFuture.completedFuture(location); - } - - Throwable nextFailure = throwable == null ? lastFailure : unwrapFailure(throwable); - if (System.currentTimeMillis() >= deadline) { - return CompletableFuture.failedFuture(timeoutFailure("Studio safe-entry resolution timed out.", nextFailure)); - } - - return delayFuture(250L).thenCompose(ignored -> waitForSafeEntry(world, entryAnchor, deadline, nextFailure)); - }).thenCompose(next -> next); - } - private CompletableFuture waitForWorldFamilyUnload(String worldName, long deadline) { if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) { return CompletableFuture.completedFuture(null); @@ -444,32 +381,6 @@ public final class StudioOpenCoordinator { }, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS)); } - private CompletableFuture withAttemptTimeout(CompletableFuture source, long timeoutMillis, String message) { - CompletableFuture future = new CompletableFuture<>(); - source.whenComplete((value, throwable) -> { - if (throwable != null) { - future.completeExceptionally(unwrapFailure(throwable)); - return; - } - - future.complete(value); - }); - delayFuture(timeoutMillis).whenComplete((ignored, throwable) -> { - if (!future.isDone()) { - future.completeExceptionally(new TimeoutException(message)); - } - }); - return future; - } - - private IllegalStateException timeoutFailure(String message, Throwable lastFailure) { - if (lastFailure == null) { - return new IllegalStateException(message); - } - - return new IllegalStateException(message, lastFailure); - } - private Throwable unwrapFailure(Throwable throwable) { Throwable cursor = throwable; while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { 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 9e0d5fc11..925297582 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 @@ -14,11 +14,9 @@ import io.papermc.lib.PaperLib; import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.GameRule; +import org.bukkit.HeightMap; import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.Tag; import org.bukkit.World; -import org.bukkit.block.Block; import org.bukkit.entity.Player; import org.bukkit.event.world.TimeSkipEvent; import org.bukkit.plugin.PluginManager; @@ -227,21 +225,19 @@ public final class WorldRuntimeControlService { int chunkX = source.getBlockX() >> 4; int chunkZ = source.getBlockZ() >> 4; - return requestChunkAsync(world, chunkX, chunkZ, true).thenCompose(chunk -> { - CompletableFuture future = new CompletableFuture<>(); - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - try { - future.complete(findTopSafeLocation(world, source)); - } catch (Throwable e) { - future.completeExceptionally(e); - } - }); - if (!scheduled) { - return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule safe-entry surface resolve for " + world.getName() + "@" + chunkX + "," + chunkZ + ".")); + CompletableFuture future = new CompletableFuture<>(); + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + future.complete(findTopSafeLocation(world, source)); + } catch (Throwable t) { + future.completeExceptionally(t); } - - return future; }); + if (!scheduled) { + future.completeExceptionally(new IllegalStateException( + "Failed to schedule safe-entry resolve for " + world.getName() + "@" + chunkX + "," + chunkZ + ".")); + } + return future; } public CompletableFuture teleport(Player player, Location location) { @@ -312,67 +308,15 @@ public final class WorldRuntimeControlService { int z = source.getBlockZ(); float yaw = source.getYaw(); float pitch = source.getPitch(); - - for (int y : buildSafeLocationScanOrder(world, source)) { - if (isSafeStandingLocation(world, x, y, z)) { - return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); - } - } - - return null; - } - - static int[] buildSafeLocationScanOrder(World world, Location source) { int minY = world.getMinHeight() + 1; int maxY = world.getMaxHeight() - 2; - int[] scanOrder = new int[maxY - minY + 1]; - int index = 0; - - int runtimeSurface = world.getHighestBlockYAt((int) source.getX(), (int) source.getZ()); - int startY = Math.min(maxY, runtimeSurface + 1); - - for (int y = startY; y >= minY; y--) { - scanOrder[index++] = y; + if (world.isChunkLoaded(x >> 4, z >> 4)) { + int raw = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); + int y = Math.max(minY, Math.min(maxY, raw + 1)); + return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); } - - for (int y = startY + 1; y <= maxY; y++) { - scanOrder[index++] = y; - } - - return scanOrder; - } - - private static boolean isSafeStandingLocation(World world, int x, int y, int z) { - if (y <= world.getMinHeight() || y >= world.getMaxHeight() - 1) { - return false; - } - - Block below = world.getBlockAt(x, y - 1, z); - Block feet = world.getBlockAt(x, y, z); - Block head = world.getBlockAt(x, y + 1, z); - Material belowType = below.getType(); - if (!belowType.isSolid()) { - return false; - } - if (Tag.LEAVES.isTagged(belowType)) { - return false; - } - if (belowType == Material.LAVA - || belowType == Material.MAGMA_BLOCK - || belowType == Material.FIRE - || belowType == Material.SOUL_FIRE - || belowType == Material.CAMPFIRE - || belowType == Material.SOUL_CAMPFIRE) { - return false; - } - if (feet.getType().isSolid() || head.getType().isSolid()) { - return false; - } - if (feet.isLiquid() || head.isLiquid()) { - return false; - } - - return true; + int y = Math.max(minY, Math.min(maxY, source.getBlockY())); + return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); } @SuppressWarnings("unchecked") 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 89d7c4899..605d76af4 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 @@ -40,7 +40,6 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.io.IO; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.FoliaScheduler; @@ -143,16 +142,6 @@ public class IrisCreator { } reportStudioProgress(0.02D, "resolve_dimension"); - - if (studio()) { - World existing = Bukkit.getWorld(name()); - if (existing == null) { - IO.delete(new File(Bukkit.getWorldContainer(), name())); - IO.delete(new File(Bukkit.getWorldContainer(), name() + "_nether")); - IO.delete(new File(Bukkit.getWorldContainer(), name() + "_the_end")); - } - } - reportStudioProgress(0.08D, "resolve_dimension"); IrisDimension d = IrisToolbelt.getDimension(dimension()); @@ -185,7 +174,7 @@ public class IrisCreator { if (!studio()) { IrisWorlds.get().put(name(), dimension()); } - ServerConfigurator.installDataPacks(!studio()); + ServerConfigurator.installDataPacksIfChanged(!studio()); reportStudioProgress(0.40D, "install_datapacks"); PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator(); 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 9b22b6337..f441d8076 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -129,23 +129,31 @@ public class IrisEngine implements Engine { wallClock = new AtomicRollingSequence(32); lastGPS = new AtomicLong(M.ms()); generated = new AtomicInteger(0); + long _t0 = M.ms(); mantle = new IrisEngineMantle(this); + Iris.info("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - _t0) + "ms"); context = new IrisContext(this); cleaning = new AtomicBoolean(false); modeFallbackLogged = new AtomicBoolean(false); if (studio) { + _t0 = M.ms(); getData().dump(); getData().clearLists(); getTarget().setDimension(getData().getDimensionLoader().load(getDimension().getLoadKey())); + Iris.info("[IrisEngine timing] dump+clearLists+reload=" + (M.ms() - _t0) + "ms"); } context.touch(); getData().setEngine(this); + _t0 = M.ms(); getData().loadPrefetch(this); + Iris.info("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); Iris.info("Initializing Engine: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " (" + target.getDimension().getDimensionHeight() + " height) Seed: " + getSeedManager().getSeed()); failing = false; closed = false; art = J.ar(this::tickRandomPlayer, 0); + _t0 = M.ms(); setupEngine(); + Iris.info("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); Iris.debug("Engine Initialized " + getCacheID()); } @@ -208,15 +216,27 @@ public class IrisEngine implements Engine { closing.set(false); Iris.debug("Setup Engine " + getCacheID()); cacheId = RNG.r.nextInt(); + long t0 = M.ms(); complex = ensureComplex(); + Iris.info("[IrisEngine timing] ensureComplex=" + (M.ms() - t0) + "ms"); + t0 = M.ms(); upperContext = buildUpperContext(); + Iris.info("[IrisEngine timing] buildUpperContext=" + (M.ms() - t0) + "ms"); + t0 = M.ms(); effects = new IrisEngineEffects(this); + Iris.info("[IrisEngine timing] IrisEngineEffects=" + (M.ms() - t0) + "ms"); hash32 = new CompletableFuture<>(); + t0 = M.ms(); mantle.hotload(); + Iris.info("[IrisEngine timing] mantle.hotload=" + (M.ms() - t0) + "ms"); + t0 = M.ms(); setupMode(); + Iris.info("[IrisEngine timing] setupMode=" + (M.ms() - t0) + "ms"); + t0 = M.ms(); IrisWorldManager manager = new IrisWorldManager(this); worldManager = manager; manager.startManager(); + Iris.info("[IrisEngine timing] IrisWorldManager=" + (M.ms() - t0) + "ms"); J.a(this::computeBiomeMaxes); J.a(() -> { File[] roots = getData().getLoaders() diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java index 5f271dfd3..a44066a74 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java @@ -31,33 +31,46 @@ import org.jetbrains.annotations.Nullable; public class IslandObjectPlacer implements IObjectPlacer { private static final int OVERHANG_RADIUS = 2; + public enum AnchorFace { TOP, BOTTOM } + private final MantleWriter wrapped; private final FloatingIslandSample[] samples; private final boolean[] overhangAllowed; private final int minX; private final int minZ; private final int chunkMaxIslandTopY; - private final int anchorTopY; + private final int chunkMinIslandBottomY; + private final int anchorY; + private final AnchorFace face; private int writesAttempted; private int writesDroppedBelow; private int writesDroppedOverhang; + private int writesDroppedAboveBottom; + private int writesDroppedBottomOverhang; public IslandObjectPlacer(MantleWriter wrapped, FloatingIslandSample[] samples, int minX, int minZ, int anchorTopY) { + this(wrapped, samples, minX, minZ, anchorTopY, AnchorFace.TOP); + } + + public IslandObjectPlacer(MantleWriter wrapped, FloatingIslandSample[] samples, int minX, int minZ, int anchorY, AnchorFace face) { this.wrapped = wrapped; this.samples = samples; this.minX = minX; this.minZ = minZ; - this.anchorTopY = anchorTopY; - int maxY = -1; + this.anchorY = anchorY; + this.face = face; + int maxTopY = -1; + int minBottomY = Integer.MAX_VALUE; for (FloatingIslandSample s : samples) { if (s != null) { int ty = s.topY(); - if (ty > maxY) { - maxY = ty; - } + if (ty > maxTopY) maxTopY = ty; + int by = s.bottomY(); + if (by >= 0 && by < minBottomY) minBottomY = by; } } - this.chunkMaxIslandTopY = maxY; + this.chunkMaxIslandTopY = maxTopY; + this.chunkMinIslandBottomY = (minBottomY == Integer.MAX_VALUE) ? -1 : minBottomY; this.overhangAllowed = buildOverhangMask(samples); } @@ -104,6 +117,14 @@ public class IslandObjectPlacer implements IObjectPlacer { return writesDroppedOverhang; } + public int getWritesDroppedAboveBottom() { + return writesDroppedAboveBottom; + } + + public int getWritesDroppedBottomOverhang() { + return writesDroppedBottomOverhang; + } + private boolean shouldSkipAirColumn(int x, int y, int z) { writesAttempted++; int xf = x - minX; @@ -111,21 +132,46 @@ public class IslandObjectPlacer implements IObjectPlacer { if (xf >= 0 && xf < 16 && zf >= 0 && zf < 16) { int idx = (zf << 4) | xf; if (samples[idx] != null) { + if (face == AnchorFace.TOP) { + return false; + } + if (y >= anchorY) { + writesDroppedAboveBottom++; + return true; + } return false; } - if (y <= anchorTopY) { - writesDroppedBelow++; - return true; - } - if (!overhangAllowed[idx]) { - writesDroppedOverhang++; - return true; + if (face == AnchorFace.TOP) { + if (y <= anchorY) { + writesDroppedBelow++; + return true; + } + if (!overhangAllowed[idx]) { + writesDroppedOverhang++; + return true; + } + } else { + if (y >= anchorY) { + writesDroppedBottomOverhang++; + return true; + } + if (!overhangAllowed[idx]) { + writesDroppedBottomOverhang++; + return true; + } } return false; } - if (y <= anchorTopY) { - writesDroppedBelow++; - return true; + if (face == AnchorFace.TOP) { + if (y <= anchorY) { + writesDroppedBelow++; + return true; + } + } else { + if (y >= anchorY) { + writesDroppedBottomOverhang++; + return true; + } } writesDroppedOverhang++; return true; @@ -143,19 +189,20 @@ public class IslandObjectPlacer implements IObjectPlacer { @Override public int getHighest(int x, int z, IrisData data) { FloatingIslandSample s = sampleAt(x, z); - if (s != null) { - return s.topY(); + if (face == AnchorFace.TOP) { + if (s != null) return s.topY(); + return chunkMaxIslandTopY; } - return chunkMaxIslandTopY; + if (s != null) { + int by = s.bottomY(); + return (by >= 0) ? by : chunkMinIslandBottomY; + } + return chunkMinIslandBottomY; } @Override public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { - FloatingIslandSample s = sampleAt(x, z); - if (s != null) { - return s.topY(); - } - return chunkMaxIslandTopY; + return getHighest(x, z, data); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index 965701b84..14b92ef48 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -57,6 +57,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { public static final AtomicLong writesAttemptedTotal = new AtomicLong(); public static final AtomicLong writesDroppedBelowTotal = new AtomicLong(); public static final AtomicLong writesDroppedOverhangTotal = new AtomicLong(); + public static final AtomicLong objectsInvertedAttempted = new AtomicLong(); + public static final AtomicLong objectsInvertedPlaced = new AtomicLong(); + public static final AtomicLong objectsInvertedSkippedNoFlat = new AtomicLong(); + public static final AtomicLong objectsInvertedFallbackNoInterior = new AtomicLong(); + public static final AtomicLong objectsInvertedSkippedShrink = new AtomicLong(); + public static final AtomicLong objectsInvertedSkippedNullObj = new AtomicLong(); + public static final AtomicLong writesDroppedAboveBottomTotal = new AtomicLong(); + public static final AtomicLong writesDroppedBottomOverhangTotal = new AtomicLong(); private static final int TERRAIN_MISMATCH_WARNING_CAP = 200; private static final AtomicLong heavyClipWarnings = new AtomicLong(); private static final int HEAVY_CLIP_WARNING_CAP = 30; @@ -83,6 +91,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { writesDroppedOverhangTotal.set(0); heavyClipWarnings.set(0); anchorYHisto.clear(); + objectsInvertedAttempted.set(0); + objectsInvertedPlaced.set(0); + objectsInvertedSkippedNoFlat.set(0); + objectsInvertedFallbackNoInterior.set(0); + objectsInvertedSkippedShrink.set(0); + objectsInvertedSkippedNullObj.set(0); + writesDroppedAboveBottomTotal.set(0); + writesDroppedBottomOverhangTotal.set(0); } private static void recordWriteStats(IrisObject obj, int wx, int wz, int pickTopY, IslandObjectPlacer islandPlacer) { @@ -107,6 +123,11 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } + private static void recordInvertedWriteStats(IslandObjectPlacer islandPlacer) { + writesDroppedAboveBottomTotal.addAndGet(islandPlacer.getWritesDroppedAboveBottom()); + writesDroppedBottomOverhangTotal.addAndGet(islandPlacer.getWritesDroppedBottomOverhang()); + } + private static void verifyTerrainBelowObject(IrisObject obj, int wx, int wz, int pickTopY, FloatingIslandSample sample) { if (terrainMismatchWarnings.get() >= TERRAIN_MISMATCH_WARNING_CAP) { return; @@ -189,12 +210,13 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } - KList surface = entry.isInheritObjects() && target != null ? target.getSurfaceObjects() : null; + KList surface = target != null ? entry.resolveTopObjects(target) : null; KList extras = entry.getExtraObjects(); boolean hasSurface = surface != null && !surface.isEmpty(); boolean hasExtras = extras != null && !extras.isEmpty(); + KList interior = null; if (hasSurface || hasExtras) { - KList interior = interiorColumns(samples, columns); + interior = interiorColumns(samples, columns); if (hasSurface) { for (IrisObjectPlacement placement : surface) { tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, columns, interior, minX, minZ, entry); @@ -206,6 +228,15 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } } + KList bottom = target != null ? entry.resolveBottomObjects(target) : null; + if (bottom != null && !bottom.isEmpty()) { + if (interior == null) { + interior = interiorColumns(samples, columns); + } + for (IrisObjectPlacement placement : bottom) { + tryPlaceInvertedChunk(writer, complex, chunkRng, data, placement, samples, columns, interior, minX, minZ, entry); + } + } } } @@ -352,6 +383,129 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } } + @ChunkCoordinates + private void tryPlaceInvertedChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { + if (placement == null || columns.isEmpty()) { + return; + } + int density = placement.getDensity(rng, minX, minZ, data); + double perAttempt = placement.getChance(); + + for (int i = 0; i < density; i++) { + objectsInvertedAttempted.incrementAndGet(); + if (!rng.chance(perAttempt + rng.d(-0.005, 0.005))) { + continue; + } + + IrisObject raw = placement.getObject(complex, rng); + if (raw == null) { + objectsInvertedSkippedNullObj.incrementAndGet(); + continue; + } + IrisObject obj0 = placement.getScale().get(rng, raw); + if (obj0 == null) { + objectsInvertedSkippedShrink.incrementAndGet(); + continue; + } + if (entry != null && entry.hasObjectShrink()) { + obj0 = entry.getShrinkScale().get(rng, obj0); + if (obj0 == null) { + objectsInvertedSkippedShrink.incrementAndGet(); + continue; + } + } + final IrisObject obj = obj0; + + FloatingObjectFootprint fp = FloatingObjectFootprint.compute(obj); + + KList pool = interior.isEmpty() ? columns : interior; + if (interior.isEmpty()) { + objectsInvertedFallbackNoInterior.incrementAndGet(); + } + + int pickedKey = pool.get(rng.i(0, pool.size() - 1)); + int pickedXf = pickedKey & 15; + int pickedZf = pickedKey >> 4; + FloatingIslandSample pickedSample = samples[(pickedZf << 4) | pickedXf]; + if (pickedSample == null) { + objectsInvertedSkippedNoFlat.incrementAndGet(); + continue; + } + int pickBottomY = pickedSample.bottomY(); + if (pickBottomY < 0) { + objectsInvertedSkippedNoFlat.incrementAndGet(); + continue; + } + + if (!isFootprintFlatBottom(fp, pickedXf, pickedZf, pickBottomY, samples, 2)) { + if (!isFootprintFlatBottom(fp, pickedXf, pickedZf, pickBottomY, samples, 4)) { + objectsInvertedSkippedNoFlat.incrementAndGet(); + continue; + } + } + + int wx = minX + pickedXf - fp.getTallestKxBottom(); + int wz = minZ + pickedZf - fp.getTallestKzBottom(); + + IrisObjectPlacement inverted = placement.toPlacement(obj.getLoadKey()); + inverted.setMode(translateStiltModeForFloating(inverted.getMode())); + inverted.setTranslate(new IrisObjectTranslate()); + inverted.setRotation(IrisObjectRotation.xFlip180()); + inverted.setForcePlace(true); + inverted.setBottom(false); + + int yv = pickBottomY - 1 + fp.getHighestSolidKeyY(); + + IslandObjectPlacer islandPlacer = new IslandObjectPlacer(writer, samples, minX, minZ, pickBottomY, IslandObjectPlacer.AnchorFace.BOTTOM); + int id = rng.i(0, Integer.MAX_VALUE); + + try { + obj.place(wx, yv, wz, islandPlacer, inverted, rng, (b, bd) -> { + String marker = placementMarker(obj, id); + if (marker != null) { + writer.setData(b.getX(), b.getY(), b.getZ(), marker); + } + }, null, data); + objectsInvertedPlaced.incrementAndGet(); + recordInvertedWriteStats(islandPlacer); + } catch (Throwable e) { + Iris.reportError(e); + } + } + } + + private static boolean isFootprintFlatBottom(FloatingObjectFootprint fp, int pickedXf, int pickedZf, int pickBottomY, FloatingIslandSample[] samples, int tolerance) { + int tallestKxBottom = fp.getTallestKxBottom(); + int tallestKzBottom = fp.getTallestKzBottom(); + int checked = 0; + boolean touchedChunkEdge = false; + long[] cells = fp.footprintXZ(); + for (int i = 0, n = cells.length; i < n; i++) { + long encoded = cells[i]; + int kx = (int) (encoded >> 32); + int kz = (int) (encoded & 0xFFFFFFFFL); + int colXf = pickedXf + (kx - tallestKxBottom); + int colZf = pickedZf + (kz - tallestKzBottom); + if (colXf < 0 || colXf >= 16 || colZf < 0 || colZf >= 16) { + touchedChunkEdge = true; + continue; + } + FloatingIslandSample s = samples[(colZf << 4) | colXf]; + if (s == null) { + return false; + } + int by = s.bottomY(); + if (by < 0 || Math.abs(by - pickBottomY) > tolerance) { + return false; + } + checked++; + } + if (checked >= MIN_FOOTPRINT_CELLS_CHECKED) { + return true; + } + return touchedChunkEdge; + } + private static boolean isFootprintFlat(FloatingObjectFootprint fp, int pickedXf, int pickedZf, int pickTopY, FloatingIslandSample[] samples, int tolerance) { int tallestKx = fp.getTallestKx(); int tallestKz = fp.getTallestKz(); @@ -449,14 +603,15 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { for (IrisFloatingChildBiomes entry : entries) { collectPlacementKeys(entry.getFloatingObjects(), objectKeys); collectPlacementKeys(entry.getExtraObjects(), objectKeys); - if (entry.isInheritObjects()) { - try { - IrisBiome target = entry.getRealBiome(biome, data); - if (target != null) { - collectPlacementKeys(target.getSurfaceObjects(), objectKeys); - } - } catch (Throwable ignored) { + collectPlacementKeys(entry.getTopObjectOverrides(), objectKeys); + collectPlacementKeys(entry.getBottomObjectOverrides(), objectKeys); + try { + IrisBiome target = entry.getRealBiome(biome, data); + if (target != null) { + collectPlacementKeys(entry.resolveTopObjects(target), objectKeys); + collectPlacementKeys(entry.resolveBottomObjects(target), objectKeys); } + } catch (Throwable ignored) { } } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 53336e3c5..cd0d22c63 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -392,8 +392,8 @@ public class MantleObjectComponent extends IrisMantleComponent { } int xx = rng.i(x, x + 15); int zz = rng.i(z, z + 15); - int surfaceObjectExclusionDepth = resolveSurfaceObjectExclusionDepth(surfaceObjectExclusionBaseDepth, v); - int surfaceObjectExclusionRadius = resolveSurfaceObjectExclusionRadius(v); + int surfaceObjectExclusionDepth = resolveSurfaceObjectExclusionDepth(surfaceObjectExclusionBaseDepth, v, objectPlacement); + int surfaceObjectExclusionRadius = resolveSurfaceObjectExclusionRadius(v, objectPlacement); boolean overCave = surfaceObjectExclusionDepth > 0 && hasSurfaceCarveExposure(writer, surfaceHeightLookup, xx, zz, surfaceObjectExclusionDepth, surfaceObjectExclusionRadius); int id = rng.i(0, Integer.MAX_VALUE); IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); @@ -1038,23 +1038,30 @@ public class MantleObjectComponent extends IrisMantleComponent { return Math.max(0, caveProfile.getSurfaceObjectExclusionDepth()); } - private int resolveSurfaceObjectExclusionDepth(int baseDepth, IrisObject object) { + private int resolveSurfaceObjectExclusionDepth(int baseDepth, IrisObject object, IrisObjectPlacement placement) { if (object == null) { return baseDepth; } - int horizontalReach = resolveSurfaceObjectExclusionRadius(object) + 2; + int horizontalReach = resolveSurfaceObjectExclusionRadius(object, placement) + 2; int verticalReach = Math.max(4, Math.min(16, Math.floorDiv(Math.max(1, object.getH()), 2))); return Math.max(baseDepth, Math.max(horizontalReach, verticalReach)); } - private int resolveSurfaceObjectExclusionRadius(IrisObject object) { + static int computeSurfaceExclusionRadius(int maxDimension, int absTranslateX, int absTranslateZ) { + return Math.max(1, Math.floorDiv(Math.max(1, maxDimension), 2) + absTranslateX + absTranslateZ + 1); + } + + private int resolveSurfaceObjectExclusionRadius(IrisObject object, IrisObjectPlacement placement) { if (object == null) { return 1; } int maxDimension = Math.max(object.getW(), object.getD()); - return Math.max(1, Math.min(8, Math.floorDiv(Math.max(1, maxDimension), 2))); + IrisObjectTranslate t = placement != null ? placement.getTranslate() : null; + int absX = t != null ? Math.abs(t.getX()) : 0; + int absZ = t != null ? Math.abs(t.getZ()) : 0; + return computeSurfaceExclusionRadius(maxDimension, absX, absZ); } private int resolveAnchorSearchAttempts(IrisCaveProfile caveProfile) { diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index cea8f2c88..8b06783e8 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -117,6 +117,8 @@ public class IrisDepositModifier extends EngineAssignedModifier { if (y > k.getMaxHeight() || y < k.getMinHeight() || y > height - 2) continue; + IrisDimension dimension = getDimension(); + for (BlockVector j : clump.getBlocks().keys()) { int nx = j.getBlockX() + x; int ny = j.getBlockY() + y; @@ -130,9 +132,63 @@ public class IrisDepositModifier extends EngineAssignedModifier { } if (chunk.get(nx, ny, nz, MatterCavern.class) == null) { - data.set(nx, ny, nz, B.toDeepSlateOre(data.get(nx, ny, nz), clump.getBlocks().get(j))); + BlockData ore = clump.getBlocks().get(j); + BlockData remapped = resolveDepositVariant(cx, cz, nx, ny, nz, ore, dimension, context); + BlockData finalBlock = remapped != null + ? remapped + : B.toDeepSlateOre(data.get(nx, ny, nz), ore); + data.set(nx, ny, nz, finalBlock); } } } } + + private BlockData resolveDepositVariant(int cx, int cz, int nx, int ny, int nz, BlockData ore, IrisDimension dimension, ChunkContext context) { + int worldX = (cx << 4) + nx; + int worldZ = (cz << 4) + nz; + + IrisBiome biome = getEngine().getBiome(worldX, ny, worldZ); + if (biome != null) { + BlockData match = matchDepositVariant(biome.getDepositVariants(), ore, ny); + if (match != null) { + return match; + } + } + + IrisRegion region = context.getRegion().get(nx, nz); + if (region != null) { + BlockData match = matchDepositVariant(region.getDepositVariants(), ore, ny); + if (match != null) { + return match; + } + } + + if (dimension != null) { + BlockData match = matchDepositVariant(dimension.getDepositVariants(), ore, ny); + if (match != null) { + return match; + } + } + + return null; + } + + private BlockData matchDepositVariant(java.util.List variants, BlockData ore, int y) { + if (variants == null || variants.isEmpty()) { + return null; + } + + for (IrisDepositVariant variant : variants) { + if (y < variant.getMinHeight() || y > variant.getMaxHeight()) { + continue; + } + + BlockData swapped = variant.remapOrNull(ore, getData()); + if (swapped != null) { + return swapped; + } + } + + return null; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java index e9cb864b9..4eac84f78 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java @@ -101,6 +101,12 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier" : topAnchorY.toString()) + " topFloors:" + (topFloors.length() == 0 ? " " : topFloors.toString())); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java index a2d57c31a..0d27e4cc1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java @@ -92,6 +92,7 @@ public final class FloatingIslandSample { public final int topIdx; public final int solidCount; public final boolean[] solidMask; + private transient int cachedBottomIdx = -2; private FloatingIslandSample(IrisFloatingChildBiomes entry, int islandBaseY, int thickness, int topIdx, int solidCount, boolean[] solidMask) { this.entry = entry; @@ -102,10 +103,27 @@ public final class FloatingIslandSample { this.solidMask = solidMask; } + static FloatingIslandSample constructForTest(int islandBaseY, int thickness, int topIdx, int solidCount, boolean[] solidMask) { + return new FloatingIslandSample(null, islandBaseY, thickness, topIdx, solidCount, solidMask); + } + public int topY() { return islandBaseY + topIdx; } + public int bottomY() { + if (cachedBottomIdx == -2) { + cachedBottomIdx = -1; + for (int i = 0; i < solidMask.length; i++) { + if (solidMask[i]) { + cachedBottomIdx = i; + break; + } + } + } + return cachedBottomIdx == -1 ? -1 : islandBaseY + cachedBottomIdx; + } + public static long columnSeed(long baseSeed, int wx, int wz) { return baseSeed ^ ((long) wx * 341873128712L) ^ ((long) wz * 132897987541L); } @@ -258,6 +276,11 @@ public final class FloatingIslandSample { } } + if (!useCarve) { + solidCount = solidifyUncarvedInterior(solidMask); + highestSolidIdx = highestSolidIndex(solidMask); + } + if (solidCount == 0 || highestSolidIdx < 0) { return reject(REJECT_NO_SOLID); } @@ -268,6 +291,36 @@ public final class FloatingIslandSample { return new FloatingIslandSample(entry, botY, thickness, topIdx, solidCount, solidMask); } + static int solidifyUncarvedInterior(boolean[] solidMask) { + int firstSolid = -1; + int lastSolid = -1; + for (int i = 0; i < solidMask.length; i++) { + if (!solidMask[i]) { + continue; + } + if (firstSolid < 0) { + firstSolid = i; + } + lastSolid = i; + } + if (firstSolid < 0) { + return 0; + } + for (int i = firstSolid; i <= lastSolid; i++) { + solidMask[i] = true; + } + return lastSolid - firstSolid + 1; + } + + private static int highestSolidIndex(boolean[] solidMask) { + for (int i = solidMask.length - 1; i >= 0; i--) { + if (solidMask[i]) { + return i; + } + } + return -1; + } + private static int computeTopHeight(IrisFloatingChildBiomes entry, IrisBiome target, Engine engine, long baseSeed, int wx, int wz, IrisData data) { int maxTopHeight = Math.max(0, entry.getMaxTopHeight()); if (maxTopHeight == 0) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java index c8697ae9d..0835165ee 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java @@ -35,20 +35,26 @@ public class FloatingObjectFootprint { private static final boolean DIAGNOSTIC_LOG = Boolean.parseBoolean(System.getProperty("iris.floating.footprintLog", "true")); private final int lowestSolidKeyY; + private final int highestSolidKeyY; private final int centerX; private final int centerY; private final int centerZ; private final int tallestKx; private final int tallestKz; + private final int tallestKxBottom; + private final int tallestKzBottom; private final long[] footprintXZ; - private FloatingObjectFootprint(int lowestSolidKeyY, int centerX, int centerY, int centerZ, int tallestKx, int tallestKz, long[] footprintXZ) { + private FloatingObjectFootprint(int lowestSolidKeyY, int highestSolidKeyY, int centerX, int centerY, int centerZ, int tallestKx, int tallestKz, int tallestKxBottom, int tallestKzBottom, long[] footprintXZ) { this.lowestSolidKeyY = lowestSolidKeyY; + this.highestSolidKeyY = highestSolidKeyY; this.centerX = centerX; this.centerY = centerY; this.centerZ = centerZ; this.tallestKx = tallestKx; this.tallestKz = tallestKz; + this.tallestKxBottom = tallestKxBottom; + this.tallestKzBottom = tallestKzBottom; this.footprintXZ = footprintXZ; } @@ -63,6 +69,10 @@ public class FloatingObjectFootprint { int cz = obj.getCenter().getBlockZ(); Map columnStats = new HashMap<>(); + int[] globalHighestY = {Integer.MIN_VALUE}; + int[] globalHighestKx = {0}; + int[] globalHighestKz = {0}; + obj.getBlocks().forEach((BlockVector key, BlockData bd) -> { if (!B.isSolid(bd)) { return; @@ -81,6 +91,11 @@ public class FloatingObjectFootprint { } stats[1]++; } + if (ky > globalHighestY[0]) { + globalHighestY[0] = ky; + globalHighestKx[0] = kx; + globalHighestKz[0] = kz; + } }); long[] footprintArray = new long[columnStats.size()]; @@ -90,15 +105,16 @@ public class FloatingObjectFootprint { } long tallestPacked = resolveTallestColumn(columnStats); - int lowestSolidKeyY = columnStats.isEmpty() - ? cy - : columnStats.get(tallestPacked)[0]; + int lowestSolidKeyY = columnStats.isEmpty() ? cy : columnStats.get(tallestPacked)[0]; + int highestSolidKeyY = columnStats.isEmpty() ? cy : globalHighestY[0]; int tallestKx = columnStats.isEmpty() ? 0 : (int) (tallestPacked >> 32); int tallestKz = columnStats.isEmpty() ? 0 : (int) (tallestPacked & 0xFFFFFFFFL); + int tallestKxBottom = columnStats.isEmpty() ? 0 : globalHighestKx[0]; + int tallestKzBottom = columnStats.isEmpty() ? 0 : globalHighestKz[0]; if (DIAGNOSTIC_LOG) { logFootprintDiagnostic(cacheKey, obj, cx, cy, cz, lowestSolidKeyY, tallestKx, tallestKz, columnStats); } - return new FloatingObjectFootprint(lowestSolidKeyY, cx, cy, cz, tallestKx, tallestKz, footprintArray); + return new FloatingObjectFootprint(lowestSolidKeyY, highestSolidKeyY, cx, cy, cz, tallestKx, tallestKz, tallestKxBottom, tallestKzBottom, footprintArray); } private static void logFootprintDiagnostic(String cacheKey, IrisObject obj, int cx, int cy, int cz, int anchorY, int tallestKx, int tallestKz, Map columnStats) { @@ -232,6 +248,18 @@ public class FloatingObjectFootprint { return tallestKz; } + public int getHighestSolidKeyY() { + return highestSolidKeyY; + } + + public int getTallestKxBottom() { + return tallestKxBottom; + } + + public int getTallestKzBottom() { + return tallestKzBottom; + } + public long[] footprintXZ() { return footprintXZ; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 308db9ab7..f43e56002 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -176,6 +176,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisDepositGenerator.class) @Desc("Define biome deposit generators that add onto the existing regional and global deposit generators") private KList deposits = new KList<>(); + @ArrayType(min = 1, type = IrisDepositVariant.class) + @Desc("Deposit ore remap rules scoped to this biome. Each entry declares a vertical band and a source->replacement block id map. Applied before regional and dimension rules; first matching biome rule wins.") + private KList depositVariants = new KList<>(); private transient InferredType inferredType; @Desc("Collection of ores to be generated") @ArrayType(type = IrisOreGenerator.class, min = 1) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java new file mode 100644 index 000000000..a956392ea --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java @@ -0,0 +1,87 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.AtomicCache; +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 art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KMap; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; + +@Snippet("deposit-variant") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("Remaps ore block ids to alternate block ids within a vertical band. Ores declared at dimension, region, and biome scope can be rewritten at placement time (for example, iron_ore -> deepslate_iron_ore inside a deep carving band, or yourmod:iron -> yourmod:moon_iron inside a lunar biome).") +@Data +public class IrisDepositVariant { + private final transient AtomicCache> resolved = new AtomicCache<>(); + + @Required + @MinNumber(-2048) + @MaxNumber(8192) + @Desc("Inclusive minimum world Y this variant applies at.") + private int minHeight = 0; + + @Required + @MinNumber(-2048) + @MaxNumber(8192) + @Desc("Inclusive maximum world Y this variant applies at.") + private int maxHeight = 0; + + @Required + @Desc("Source block id (for example `minecraft:iron_ore`) -> replacement block id (for example `minecraft:deepslate_iron_ore`). Any block id the data loader resolves is accepted, including external/mod blocks. Source match is by material only, so block properties on the source key are ignored.") + private KMap remap = new KMap<>(); + + public BlockData remapOrNull(BlockData ore, IrisData rdata) { + if (ore == null || remap == null || remap.isEmpty()) { + return null; + } + + KMap map = resolved.aquire(() -> buildResolved(rdata)); + return map.get(ore.getMaterial()); + } + + private KMap buildResolved(IrisData rdata) { + KMap out = new KMap<>(); + + for (java.util.Map.Entry entry : remap.entrySet()) { + BlockData source = B.getOrNull(entry.getKey(), false); + BlockData target = B.getOrNull(entry.getValue(), true); + + if (source == null || target == null) { + continue; + } + + out.put(source.getMaterial(), target); + } + + return out; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index b5bc9cdc2..08b2d9eb3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -236,6 +236,9 @@ public class IrisDimension extends IrisRegistrant { @ArrayType(min = 1, type = IrisDepositGenerator.class) @Desc("Define global deposit generators") private KList deposits = new KList<>(); + @ArrayType(min = 1, type = IrisDepositVariant.class) + @Desc("Dimension-wide deposit ore remap rules. Each entry declares a vertical band and a source->replacement block id map. Applied after biome and region rules; first matching dimension rule wins.") + private KList depositVariants = new KList<>(); @ArrayType(min = 1, type = IrisShapedGeneratorStyle.class) @Desc("Overlay additional noise on top of the interoplated terrain.") private KList overlayNoise = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java index 1ba6fa957..de04af8b3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java @@ -211,6 +211,45 @@ public class IrisFloatingChildBiomes implements IRare { @Desc("Visualization color for this floating child in Iris Studio.") private String color = null; + @Desc("Controls how topObjectOverrides are combined with the inherited surface objects from the target biome. INHERIT_ONLY (default) = behaves identically to before this field was added. MERGE = appends overrides after inherited objects. REPLACE = uses only overrides, ignoring all inherited objects.") + private OverrideMode topObjectMode = OverrideMode.INHERIT_ONLY; + + @Desc("Controls how bottomObjectOverrides are combined. INHERIT_ONLY (default) = no bottom objects placed (there is no inherited bottom set). MERGE = same as REPLACE for bottom (no inherited source). REPLACE = uses bottomObjectOverrides list only.") + private OverrideMode bottomObjectMode = OverrideMode.INHERIT_ONLY; + + @ArrayType(min = 1, type = IrisObjectPlacement.class) + @Desc("Object placements that override or supplement the inherited surface objects on the island TOP. Behaviour depends on topObjectMode. INHERIT_ONLY = this list is ignored. MERGE = appended after inherited. REPLACE = used instead of inherited.") + private KList topObjectOverrides = new KList<>(); + + @ArrayType(min = 1, type = IrisObjectPlacement.class) + @Desc("Object placements anchored to the island BOTTOM face. Each entry is auto-inverted 180 degrees around the X axis and placed flush against the lowest solid face of the island, so objects appear to hang upside-down from the underside. WARNING: directional blocks (stairs, doors, slabs) will not render correctly when flipped — use non-directional content (logs, leaves, stone, mycelium, ice, glass) for bottom placements.") + private KList bottomObjectOverrides = new KList<>(); + + public KList resolveTopObjects(IrisBiome target) { + KList surfaceObjects = (inheritObjects && target != null) ? target.getSurfaceObjects() : new KList<>(); + return resolveTopObjectsFromSurface(surfaceObjects); + } + + KList resolveTopObjectsFromSurface(KList surfaceObjects) { + return switch (topObjectMode) { + case REPLACE -> new KList<>(topObjectOverrides); + case MERGE -> { + KList merged = new KList<>(); + merged.addAll(surfaceObjects); + merged.addAll(topObjectOverrides); + yield merged; + } + case INHERIT_ONLY -> surfaceObjects; + }; + } + + public KList resolveBottomObjects(IrisBiome target) { + return switch (bottomObjectMode) { + case INHERIT_ONLY -> new KList<>(); + case MERGE, REPLACE -> bottomObjectOverrides; + }; + } + public boolean hasObjectShrink() { return objectShrinkFactor > 0 && objectShrinkFactor < 1.0; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java index adb6a0f45..15d53a94a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java @@ -171,7 +171,7 @@ public class IrisGeneratorStyle { } if (cng == null) { - cng = style.create(rng).bake(); + cng = (style != null ? style : NoiseStyle.FLAT).create(rng).bake(); } cng = cng.scale(1D / zoom).pow(exponent).bake(); @@ -205,7 +205,7 @@ public class IrisGeneratorStyle { @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean isFlat() { - return style.equals(NoiseStyle.FLAT); + return style == null || style.equals(NoiseStyle.FLAT); } public double getMaxFractureDistance() { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java index f33691526..05bd55982 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java @@ -59,6 +59,22 @@ public class IrisObjectRotation { @Desc("The z axis rotation") private IrisAxisRotationClamp zAxis = new IrisAxisRotationClamp(); + public static IrisObjectRotation xFlip180() { + IrisObjectRotation rt = new IrisObjectRotation(); + IrisAxisRotationClamp rtx = new IrisAxisRotationClamp(); + IrisAxisRotationClamp rty = new IrisAxisRotationClamp(); + IrisAxisRotationClamp rtz = new IrisAxisRotationClamp(); + rt.setEnabled(true); + rt.setXAxis(rtx); + rt.setYAxis(rty); + rt.setZAxis(rtz); + rtx.setEnabled(true); + rtx.minMax(180); + rty.setEnabled(false); + rtz.setEnabled(false); + return rt; + } + public static IrisObjectRotation of(double x, double y, double z) { IrisObjectRotation rt = new IrisObjectRotation(); IrisAxisRotationClamp rtx = new IrisAxisRotationClamp(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index faa8dbe17..6d51cc6b5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -141,6 +141,9 @@ public class IrisRegion extends IrisRegistrant implements IRare { @ArrayType(min = 1, type = IrisDepositGenerator.class) @Desc("Define regional deposit generators that add onto the global deposit generators") private KList deposits = new KList<>(); + @ArrayType(min = 1, type = IrisDepositVariant.class) + @Desc("Deposit ore remap rules scoped to this region. Each entry declares a vertical band and a source->replacement block id map. Applied after biome rules but before dimension rules; first matching region rule wins.") + private KList depositVariants = new KList<>(); @Desc("The style of rivers") private IrisGeneratorStyle riverStyle = NoiseStyle.VASCULAR_THIN.style().zoomed(7.77); @Desc("The style of lakes") diff --git a/core/src/main/java/art/arcane/iris/engine/object/OverrideMode.java b/core/src/main/java/art/arcane/iris/engine/object/OverrideMode.java new file mode 100644 index 000000000..feb23578d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/OverrideMode.java @@ -0,0 +1,33 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls how an override list is combined with the inherited object set from the target biome.") +public enum OverrideMode { + @Desc("Ignore the override list entirely. Use only the inherited objects from the target biome (subject to inheritObjects).") + INHERIT_ONLY, + + @Desc("Append override list entries after the inherited objects from the target biome. Both sets are placed.") + MERGE, + + @Desc("Use only the override list. The inherited objects from the target biome are discarded regardless of inheritObjects.") + REPLACE +} 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 b71794a4f..f6c993a43 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 @@ -149,7 +149,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun try { INMS.get().inject(world.getSeed(), engine, world); Iris.info("Injected Iris Biome Source into " + world.getName()); - J.s(() -> updateSpawnLocation(world), 1); + if (!studio) { + J.s(() -> updateSpawnLocation(world), 1); + } } catch (Throwable e) { Iris.reportError(e); Iris.error("Failed to inject biome source into " + world.getName()); diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java new file mode 100644 index 000000000..8714d4ea6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.core; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +public class ServerConfiguratorDatapackFingerprintTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private Method fingerprintMethod() throws Exception { + try { + return ServerConfigurator.class.getMethod("computePackFingerprint", File.class); + } catch (NoSuchMethodException e) { + fail("ServerConfigurator.computePackFingerprint(File) does not exist yet — implement it in Task 2"); + throw e; + } + } + + @Test + public void computePackFingerprintReturnsSameHashForUnchangedFiles() throws Exception { + Method method = fingerprintMethod(); + File packsDir = tmp.newFolder("packs"); + File dimFile = new File(packsDir, "testpack/dimensions/overworld.json"); + dimFile.getParentFile().mkdirs(); + dimFile.createNewFile(); + + String fp1 = (String) method.invoke(null, packsDir); + String fp2 = (String) method.invoke(null, packsDir); + + assertNotNull("Fingerprint must not be null", fp1); + assertEquals("Same unchanged files must produce identical fingerprint", fp1, fp2); + } + + @Test + public void computePackFingerprintChangesWhenFileIsModified() throws Exception { + Method method = fingerprintMethod(); + File packsDir = tmp.newFolder("packs"); + File dimFile = new File(packsDir, "testpack/dimensions/overworld.json"); + dimFile.getParentFile().mkdirs(); + dimFile.createNewFile(); + + String fp1 = (String) method.invoke(null, packsDir); + dimFile.setLastModified(dimFile.lastModified() + 2000L); + String fp2 = (String) method.invoke(null, packsDir); + + assertNotEquals("A modified file must produce a different fingerprint", fp1, fp2); + } + + @Test + public void computePackFingerprintChangesWhenFileIsAdded() throws Exception { + Method method = fingerprintMethod(); + File packsDir = tmp.newFolder("packs"); + File dimDir = new File(packsDir, "testpack/dimensions"); + dimDir.mkdirs(); + File dimFile = new File(dimDir, "overworld.json"); + dimFile.createNewFile(); + + String fp1 = (String) method.invoke(null, packsDir); + File extraFile = new File(dimDir, "nether.json"); + extraFile.createNewFile(); + String fp2 = (String) method.invoke(null, packsDir); + + assertNotEquals("Adding a file must produce a different fingerprint", fp1, fp2); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java new file mode 100644 index 000000000..bde4e18c2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.assertFalse; + +public class StudioOpenCoordinatorSpawnStuckRegressionTest { + @Test + public void waitForSafeEntryRetryLoopIsRemoved() { + boolean found = Arrays.stream(StudioOpenCoordinator.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals("waitForSafeEntry")); + assertFalse("waitForSafeEntry retry loop must be removed — it burns up to 120s on ocean columns", found); + } + + @Test + public void requestEntryChunkRedundantLoopIsRemoved() { + boolean found = Arrays.stream(StudioOpenCoordinator.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals("requestEntryChunk")); + assertFalse("requestEntryChunk must be removed — createLevel already loads (0,0)", found); + } + + @Test + public void waitForEntryChunkRedundantLoopIsRemoved() { + boolean found = Arrays.stream(StudioOpenCoordinator.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals("waitForEntryChunk")); + assertFalse("waitForEntryChunk retry loop must be removed", found); + } +} 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 95b14ce41..2ef9b8d5a 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 @@ -1,11 +1,16 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.block.Block; import org.junit.Test; +import org.mockito.Mockito; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -41,19 +46,21 @@ public class WorldRuntimeControlServiceSafeEntryTest { } @Test - public void scansFromRuntimeSurfaceBeforeFallingThroughRemainingWorldHeight() { + public void resolvesSafeEntryImmediatelyWhenColumnIsAllWater() { World world = mock(World.class); + Block stub = mock(Block.class, Mockito.RETURNS_DEEP_STUBS); + doReturn(-64).when(world).getMinHeight(); + doReturn(320).when(world).getMaxHeight(); + doReturn(true).when(world).isChunkLoaded(0, 0); + doReturn(62).when(world).getHighestBlockYAt(0, 0); + doReturn(62).when(world).getHighestBlockYAt(0, 0, HeightMap.MOTION_BLOCKING_NO_LEAVES); + doReturn(stub).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); - doReturn(0).when(world).getMinHeight(); - doReturn(256).when(world).getMaxHeight(); - doReturn(179).when(world).getHighestBlockYAt(0, 0); + Location source = new Location(world, 0.5D, 62D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); - int[] scanOrder = WorldRuntimeControlService.buildSafeLocationScanOrder(world, new Location(world, 0.5D, 96D, 0.5D)); - - assertEquals(180, scanOrder[0]); - assertEquals(179, scanOrder[1]); - assertEquals(1, scanOrder[179]); - assertEquals(181, scanOrder[180]); - assertEquals(254, scanOrder[scanOrder.length - 1]); + assertNotNull("Safe entry must resolve to a non-null location even for water-only columns", result); + assertEquals(63, result.getBlockY()); } + } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedCountersTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedCountersTest.java new file mode 100644 index 000000000..9c8569c52 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedCountersTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class MantleFloatingObjectComponentInvertedCountersTest { + + @Test + public void resetObjectCounters_resetsAllInvertedCountersToZero() { + MantleFloatingObjectComponent.objectsInvertedAttempted.set(5); + MantleFloatingObjectComponent.objectsInvertedPlaced.set(3); + MantleFloatingObjectComponent.objectsInvertedSkippedNoFlat.set(2); + MantleFloatingObjectComponent.objectsInvertedFallbackNoInterior.set(1); + MantleFloatingObjectComponent.objectsInvertedSkippedShrink.set(4); + MantleFloatingObjectComponent.objectsInvertedSkippedNullObj.set(7); + MantleFloatingObjectComponent.writesDroppedAboveBottomTotal.set(11); + MantleFloatingObjectComponent.writesDroppedBottomOverhangTotal.set(9); + + MantleFloatingObjectComponent.resetObjectCounters(); + + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedAttempted.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedPlaced.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedSkippedNoFlat.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedFallbackNoInterior.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedSkippedShrink.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsInvertedSkippedNullObj.get()); + assertEquals(0, MantleFloatingObjectComponent.writesDroppedAboveBottomTotal.get()); + assertEquals(0, MantleFloatingObjectComponent.writesDroppedBottomOverhangTotal.get()); + } + + @Test + public void resetObjectCounters_alsoResetsExistingCounters_noRegression() { + MantleFloatingObjectComponent.objectsAttempted.set(99); + MantleFloatingObjectComponent.objectsPlaced.set(88); + + MantleFloatingObjectComponent.resetObjectCounters(); + + assertEquals(0, MantleFloatingObjectComponent.objectsAttempted.get()); + assertEquals(0, MantleFloatingObjectComponent.objectsPlaced.get()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java new file mode 100644 index 000000000..5399e7758 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class MantleObjectComponentCaveExposureTest { + + @Test + public void test_resolveSurfaceObjectExclusionRadius_largeObject_coversFullFootprint() { + int radius = MantleObjectComponent.computeSurfaceExclusionRadius(24, 0, 0); + assertTrue("Expected radius >= 12 for a 24x24 object but got " + radius, radius >= 12); + } + + @Test + public void test_resolveSurfaceObjectExclusionRadius_withTranslateOffset_includesOffset() { + int radius = MantleObjectComponent.computeSurfaceExclusionRadius(16, 5, 3); + int expected = 8 + 5 + 3 + 1; + assertTrue("Expected radius >= " + expected + " for 16x16 object with translate offsets 5+3 but got " + radius, radius >= expected); + } + + @Test + public void test_resolveSurfaceObjectExclusionRadius_smallObject_atLeastOne() { + int radius = MantleObjectComponent.computeSurfaceExclusionRadius(2, 0, 0); + assertTrue("Expected radius >= 1 for a 2x2 object but got " + radius, radius >= 1); + } + + @Test + public void test_resolveSurfaceObjectExclusionRadius_nullLike_returnsOne() { + int radius = MantleObjectComponent.computeSurfaceExclusionRadius(0, 0, 0); + assertTrue("Expected radius >= 1 for zero-dimension object but got " + radius, radius >= 1); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java new file mode 100644 index 000000000..df7a5de7d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class FloatingIslandSampleBottomYTest { + private FloatingIslandSample buildSample(int islandBaseY, boolean[] solidMask) { + int topIdx = 0; + int solidCount = 0; + for (int i = solidMask.length - 1; i >= 0; i--) { + if (solidMask[i]) { + topIdx = i; + break; + } + } + for (boolean b : solidMask) { + if (b) solidCount++; + } + return FloatingIslandSample.constructForTest(islandBaseY, solidMask.length, topIdx, solidCount, solidMask); + } + + @Test + public void bottomY_firstMaskTrue_returnsIslandBaseY() { + boolean[] mask = {true, true, false}; + FloatingIslandSample sample = buildSample(100, mask); + assertEquals(100, sample.bottomY()); + } + + @Test + public void bottomY_lowestSolidAtOffset_returnsIslandBaseYPlusOffset() { + boolean[] mask = {false, false, true, true}; + FloatingIslandSample sample = buildSample(50, mask); + assertEquals(52, sample.bottomY()); + } + + @Test + public void bottomY_allFalseMask_returnsNegativeOne() { + boolean[] mask = {false, false, false}; + FloatingIslandSample sample = buildSample(100, mask); + assertEquals(-1, sample.bottomY()); + } + + @Test + public void bottomY_isCached_sameSampleReturnsSameValue() { + boolean[] mask = {false, true, true}; + FloatingIslandSample sample = buildSample(200, mask); + int first = sample.bottomY(); + int second = sample.bottomY(); + assertEquals(first, second); + } + + @Test + public void solidifyUncarvedInterior_fillsGapsBetweenSolids() { + boolean[] mask = {false, true, false, false, true, false}; + int count = FloatingIslandSample.solidifyUncarvedInterior(mask); + assertEquals(4, count); + assertEquals(false, mask[0]); + assertEquals(true, mask[1]); + assertEquals(true, mask[2]); + assertEquals(true, mask[3]); + assertEquals(true, mask[4]); + assertEquals(false, mask[5]); + } + + @Test + public void solidifyUncarvedInterior_emptyMaskStaysEmpty() { + boolean[] mask = {false, false, false}; + int count = FloatingIslandSample.solidifyUncarvedInterior(mask); + assertEquals(0, count); + assertEquals(false, mask[0]); + assertEquals(false, mask[1]); + assertEquals(false, mask[2]); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesResolverTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesResolverTest.java new file mode 100644 index 000000000..84f7da8bb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisFloatingChildBiomesResolverTest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.engine.object; + +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisFloatingChildBiomesResolverTest { + @Test + public void resolveTopObjects_inheritOnly_withInheritTrue_returnsSurfaceObjects() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + KList surface = new KList<>(); + surface.add(placement); + + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setInheritObjects(true); + entry.setTopObjectMode(OverrideMode.INHERIT_ONLY); + + KList result = entry.resolveTopObjectsFromSurface(surface); + + assertEquals(surface, result); + } + + @Test + public void resolveTopObjects_replace_returnsTopOverridesIgnoringSurface() { + IrisObjectPlacement surfacePlacement = new IrisObjectPlacement(); + IrisObjectPlacement overridePlacement = new IrisObjectPlacement(); + KList surface = new KList<>(); + surface.add(surfacePlacement); + KList overrides = new KList<>(); + overrides.add(overridePlacement); + + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setInheritObjects(true); + entry.setTopObjectMode(OverrideMode.REPLACE); + entry.setTopObjectOverrides(overrides); + + KList result = entry.resolveTopObjectsFromSurface(surface); + + assertEquals(1, result.size()); + assertEquals(overridePlacement, result.get(0)); + } + + @Test + public void resolveTopObjects_merge_returnsCombinedSurfacePlusOverrides() { + IrisObjectPlacement surfacePlacement = new IrisObjectPlacement(); + IrisObjectPlacement overridePlacement = new IrisObjectPlacement(); + KList surface = new KList<>(); + surface.add(surfacePlacement); + KList overrides = new KList<>(); + overrides.add(overridePlacement); + + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setInheritObjects(true); + entry.setTopObjectMode(OverrideMode.MERGE); + entry.setTopObjectOverrides(overrides); + + KList result = entry.resolveTopObjectsFromSurface(surface); + + assertEquals(2, result.size()); + assertTrue(result.contains(surfacePlacement)); + assertTrue(result.contains(overridePlacement)); + } + + @Test + public void resolveTopObjects_inheritOnly_emptySurface_returnsEmpty() { + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setTopObjectMode(OverrideMode.INHERIT_ONLY); + + KList result = entry.resolveTopObjectsFromSurface(new KList<>()); + + assertTrue(result.isEmpty()); + } + + @Test + public void resolveBottomObjects_inheritOnly_returnsEmptyList() { + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setBottomObjectMode(OverrideMode.INHERIT_ONLY); + + KList result = entry.resolveBottomObjects(null); + + assertTrue(result.isEmpty()); + } + + @Test + public void resolveBottomObjects_replace_returnsBottomOverrides() { + IrisObjectPlacement overridePlacement = new IrisObjectPlacement(); + KList overrides = new KList<>(); + overrides.add(overridePlacement); + + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setBottomObjectMode(OverrideMode.REPLACE); + entry.setBottomObjectOverrides(overrides); + + KList result = entry.resolveBottomObjects(null); + + assertEquals(1, result.size()); + assertEquals(overridePlacement, result.get(0)); + } + + @Test + public void resolveTopObjects_inheritTrue_nullTargetProduces_emptySurface() { + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + entry.setInheritObjects(true); + entry.setTopObjectMode(OverrideMode.INHERIT_ONLY); + + KList result = entry.resolveTopObjects(null); + + assertTrue(result.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectRotationFlipTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectRotationFlipTest.java new file mode 100644 index 000000000..46d29cb6a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectRotationFlipTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.engine.object; + +import org.bukkit.util.BlockVector; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisObjectRotationFlipTest { + @Test + public void xFlip180_canRotateX_returnsTrue() { + IrisObjectRotation rot = IrisObjectRotation.xFlip180(); + assertTrue(rot.canRotateX()); + } + + @Test + public void xFlip180_canRotateY_returnsFalse() { + IrisObjectRotation rot = IrisObjectRotation.xFlip180(); + assertTrue(!rot.canRotateY()); + } + + @Test + public void xFlip180_rotateVector_negatesYandZ() { + IrisObjectRotation rot = IrisObjectRotation.xFlip180(); + BlockVector v = new BlockVector(1, 2, 3); + BlockVector result = rot.rotate(v, 0, 0, 0); + assertEquals(1, result.getBlockX()); + assertEquals(-2, result.getBlockY()); + assertEquals(-3, result.getBlockZ()); + } + + @Test + public void xFlip180_rotateNegativeVector_negatesYandZ() { + IrisObjectRotation rot = IrisObjectRotation.xFlip180(); + BlockVector v = new BlockVector(-3, -5, -7); + BlockVector result = rot.rotate(v, 0, 0, 0); + assertEquals(-3, result.getBlockX()); + assertEquals(5, result.getBlockY()); + assertEquals(7, result.getBlockZ()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java new file mode 100644 index 000000000..c9f3bfc78 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java @@ -0,0 +1,69 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.mantle.components.IslandObjectPlacer; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IslandObjectPlacerAnchorFaceTest { + + private FloatingIslandSample sampleWithBottomAt(int baseY, int bottomOffset) { + boolean[] mask = new boolean[10]; + mask[bottomOffset] = true; + mask[9] = true; + return FloatingIslandSample.constructForTest(baseY, 10, 9, 2, mask); + } + + @Test + public void bottomFace_getHighest_inFootprint_returnsSampleBottomY() { + FloatingIslandSample[] samples = new FloatingIslandSample[256]; + samples[0] = sampleWithBottomAt(100, 0); // bottomY = 100 + + IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + + int result = placer.getHighest(0, 0, null); + assertEquals(100, result); + } + + @Test + public void bottomFace_getHighest_offFootprint_returnsChunkMinBottomY() { + FloatingIslandSample[] samples = new FloatingIslandSample[256]; + samples[0] = sampleWithBottomAt(100, 0); // bottomY = 100, only sample + + IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + + // No sample at (15, 15) → falls back to chunkMinIslandBottomY = 100 + int result = placer.getHighest(15, 15, null); + assertEquals(100, result); + } + + @Test + public void bottomFace_set_aboveAnchor_dropsWrite_andIncrementsDroppedAboveBottom() { + FloatingIslandSample[] samples = new FloatingIslandSample[256]; + samples[0] = sampleWithBottomAt(100, 0); // bottomY = 100 + + IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + + // y=101 >= anchorBottomY=100 → in-footprint but above/at anchor → dropped + placer.set(0, 101, 0, null); + + assertEquals(1, placer.getWritesAttempted()); + assertEquals(1, placer.getWritesDroppedAboveBottom()); + } + + @Test + public void topFace_existingConstructor_dropsBelowAnchor_noRegression() { + FloatingIslandSample[] samples = new FloatingIslandSample[256]; + samples[0] = sampleWithBottomAt(100, 0); + // No sample at x=1, z=0 (idx=1) + + // Existing single-face constructor defaults to TOP + IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 105); + + // Off-footprint column, y=104 <= anchorTopY=105 → dropped below + placer.set(1, 104, 0, null); + + assertEquals(1, placer.getWritesAttempted()); + assertEquals(1, placer.getWritesDroppedBelow()); + } +}