diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance index d330ce7ae..1d12b25fe 100644 --- a/core/plugins/Iris/cache/instance +++ b/core/plugins/Iris/cache/instance @@ -1 +1 @@ --2003345818 \ No newline at end of file +203609563 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/Iris.java b/core/src/main/java/art/arcane/iris/Iris.java index c11e76b8a..b5eec567f 100644 --- a/core/src/main/java/art/arcane/iris/Iris.java +++ b/core/src/main/java/art/arcane/iris/Iris.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; +import art.arcane.iris.core.lifecycle.PaperLibBootstrap; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.runtime.WorldRuntimeControlService; @@ -545,6 +546,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private void enable() { + PaperLibBootstrap.install(); services = new KMap<>(); setupAudience(); Bindings.setupSentry(); @@ -575,7 +577,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { WorldRuntimeControlService.get(); if (J.isFolia()) { - checkForBukkitWorlds(s -> true); + J.s(() -> checkForBukkitWorlds(s -> true), 1); } J.s(() -> { @@ -641,6 +643,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { WorldCreator c = new WorldCreator(s) .generator(gen) .environment(dim.getEnvironment()); + Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s); + if (stagedSeed != null) { + c.seed(stagedSeed); + } INMS.get().createWorld(c); Iris.info(C.LIGHT_PURPLE + "Loaded " + s + "!"); } catch (Throwable e) { @@ -661,8 +667,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } }); if (!deferredStartupWorlds.isEmpty()) { - Iris.warn("World init delayed on Folia until server world-init phase for staged Iris worlds: %s", String.join(", ", deferredStartupWorlds)); - Iris.warn("Bukkit.createWorld is intentionally unavailable in this startup phase. Worlds remain staged in bukkit.yml."); + Iris.warn("Staged Iris worlds could not load on Folia: %s", String.join(", ", deferredStartupWorlds)); + Iris.warn("Bukkit.createWorld is unsupported on this server and the Iris runtime world backend is unavailable (%s).", WorldLifecycleService.get().capabilities().paperLikeResolution()); } } catch (Throwable e) { reportError("Failed while loading startup Iris worlds.", e); diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index cc03a1829..84f0b06dc 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -11,6 +11,7 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.io.IO; import art.arcane.iris.util.common.misc.ServerProperties; import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; @@ -101,6 +102,16 @@ public class IrisWorlds { } } + public static Long readBukkitWorldSeed(String world) { + YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); + ConfigurationSection worlds = bukkit.getConfigurationSection("worlds"); + if (worlds == null || !worlds.contains(world + ".seed")) { + return null; + } + + return worlds.getLong(world + ".seed"); + } + public static KMap readBukkitWorlds() { var bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); var worlds = bukkit.getConfigurationSection("worlds"); diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index 20279d0df..486cb5a37 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -219,7 +219,12 @@ public class CommandDeveloper implements DirectorExecutor { } @Director(description = "Test") - public void mantle(@Param(defaultValue = "false") boolean plate, @Param(defaultValue = "21474836474") String name) throws Throwable { + public void mantle( + @Param(name = "plate", description = "Dump the whole tectonic plate instead of a single section", defaultValue = "false") + boolean plate, + @Param(name = "name", description = "The dump file id under plugins/Iris/dump (pv..*)", defaultValue = "21474836474") + String name + ) throws Throwable { var base = Iris.instance.getDataFile("dump", "pv." + name + ".ttp.lz4b.bin"); var section = Iris.instance.getDataFile("dump", "pv." + name + ".section.bin"); @@ -256,7 +261,7 @@ public class CommandDeveloper implements DirectorExecutor { @Director(description = "test") public void mca ( - @Param(description = "String") String world) { + @Param(description = "The world folder to scan for .mca region files") String world) { try { File[] McaFiles = new File(world, "region").listFiles((dir, name) -> name.endsWith(".mca")); for (File mca : McaFiles) { diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java index e2e9765f2..06ad68aaf 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -115,11 +115,7 @@ public class CommandIris implements DirectorExecutor { @Param(description = "The seed to generate the world with", defaultValue = "1337") long seed, @Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", defaultValue = "false") - boolean main, - @Param(aliases = {"remove-others", "removeothers"}, description = "When main-world is true, remove other Iris worlds from bukkit.yml and queue deletion on startup", defaultValue = "false") - boolean removeOthers, - @Param(aliases = {"remove-worlds", "removeworlds"}, description = "Comma-separated world names to remove from Iris control and delete on next startup (main-world only)", defaultValue = "none") - String removeWorlds + boolean main ) { if (name.equalsIgnoreCase("iris")) { sender().sendMessage(C.RED + "You cannot use the world name \"iris\" for creating worlds as Iris uses this directory for studio worlds."); @@ -146,18 +142,12 @@ public class CommandIris implements DirectorExecutor { if (dimension == null) { sender().sendMessage(C.RED + "Could not find or download dimension \"" + resolvedType + "\"."); sender().sendMessage(C.YELLOW + "Try one of: overworld, vanilla, flat, theend"); - sender().sendMessage(C.YELLOW + "Or download manually: /iris download IrisDimensions/" + resolvedType); + sender().sendMessage(C.YELLOW + "Or download manually: /iris download " + resolvedType); return; } - if (!main && (removeOthers || hasExplicitCleanupWorlds(removeWorlds))) { - sender().sendMessage(C.YELLOW + "remove-others/remove-worlds only apply when main-world=true. Ignoring cleanup options."); - removeOthers = false; - removeWorlds = "none"; - } - if (J.isFolia()) { - if (stageFoliaWorldCreation(name, dimension, seed, main, removeOthers, removeWorlds)) { + if (stageFoliaWorldCreation(name, dimension, seed, main)) { sender().sendMessage(C.GREEN + "World staging completed. Restart the server to generate/load \"" + name + "\"."); } return; @@ -185,11 +175,6 @@ public class CommandIris implements DirectorExecutor { return; } - if (main && !applyMainWorldCleanup(name, removeOthers, removeWorlds)) { - worldCreation = false; - return; - } - worldCreation = false; sender().sendMessage(C.GREEN + "Successfully created your world!"); if (main) sender().sendMessage(C.GREEN + "Your world will automatically be set as the main world when the server restarts."); @@ -230,7 +215,7 @@ public class CommandIris implements DirectorExecutor { } } - private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main, boolean removeOthers, String removeWorlds) { + private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) { sender().sendMessage(C.YELLOW + "Runtime world creation is disabled on Folia."); sender().sendMessage(C.YELLOW + "Preparing world files and bukkit.yml for next startup..."); @@ -252,11 +237,6 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.RED + "World was staged, but failed to update server.properties main world."); return false; } - - if (!applyMainWorldCleanup(name, removeOthers, removeWorlds)) { - sender().sendMessage(C.RED + "World was staged, but failed to apply main-world cleanup options."); - return false; - } } sender().sendMessage(C.GREEN + "Staged Iris world \"" + name + "\" with generator Iris:" + dimension.getLoadKey() + " and seed " + seed + "."); @@ -295,126 +275,6 @@ public class CommandIris implements DirectorExecutor { } } - private boolean applyMainWorldCleanup(String mainWorld, boolean removeOthers, String removeWorlds) { - Set targets = resolveCleanupTargets(mainWorld, removeOthers, removeWorlds); - if (targets.isEmpty()) { - return true; - } - - sender().sendMessage(C.YELLOW + "Applying main-world cleanup for " + targets.size() + " world(s)."); - - YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); - ConfigurationSection worlds = yml.getConfigurationSection("worlds"); - - Set removedFromBukkit = new LinkedHashSet<>(); - Set notRemoved = new LinkedHashSet<>(); - for (String target : targets) { - String key = findWorldKeyIgnoreCase(worlds, target); - if (key == null) { - notRemoved.add(target); - continue; - } - - String generator = worlds.getString(key + ".generator"); - if (generator == null || !(generator.equalsIgnoreCase("iris") || generator.startsWith("Iris:"))) { - notRemoved.add(key); - continue; - } - - worlds.set(key, null); - removedFromBukkit.add(key); - } - - try { - if (worlds != null && worlds.getKeys(false).isEmpty()) { - yml.set("worlds", null); - } - - if (!removedFromBukkit.isEmpty()) { - yml.save(BUKKIT_YML); - } - } catch (IOException e) { - sender().sendMessage(C.RED + "Failed to update bukkit.yml while applying cleanup: " + e.getMessage()); - Iris.reportError(e); - return false; - } - - try { - int queued = Iris.queueWorldDeletionOnStartup(targets); - if (queued > 0) { - sender().sendMessage(C.GREEN + "Queued " + queued + " world folder(s) for deletion on next startup."); - } else { - sender().sendMessage(C.YELLOW + "Cleanup queue already contained the requested world folder(s)."); - } - } catch (IOException e) { - sender().sendMessage(C.RED + "Failed to queue startup world deletions: " + e.getMessage()); - Iris.reportError(e); - return false; - } - - if (!removedFromBukkit.isEmpty()) { - sender().sendMessage(C.GREEN + "Removed from Iris control in bukkit.yml: " + String.join(", ", removedFromBukkit)); - } - - if (!notRemoved.isEmpty()) { - sender().sendMessage(C.YELLOW + "Skipped from bukkit.yml removal (not found or non-Iris generator): " + String.join(", ", notRemoved)); - } - - return true; - } - - private Set resolveCleanupTargets(String mainWorld, boolean removeOthers, String removeWorlds) { - Set targets = new LinkedHashSet<>(); - if (removeOthers) { - IrisWorlds.readBukkitWorlds().keySet().stream() - .filter(world -> !world.equalsIgnoreCase(mainWorld)) - .forEach(targets::add); - } - - if (hasExplicitCleanupWorlds(removeWorlds)) { - for (String raw : removeWorlds.split("[,;\\s]+")) { - if (raw == null || raw.isBlank()) { - continue; - } - - if (raw.equalsIgnoreCase(mainWorld)) { - continue; - } - - targets.add(raw.trim()); - } - } - - return targets; - } - - private static boolean hasExplicitCleanupWorlds(String removeWorlds) { - if (removeWorlds == null) { - return false; - } - - String trimmed = removeWorlds.trim(); - return !trimmed.isEmpty() && !trimmed.equalsIgnoreCase("none"); - } - - private static String findWorldKeyIgnoreCase(ConfigurationSection worlds, String requested) { - if (worlds == null || requested == null) { - return null; - } - - if (worlds.contains(requested)) { - return requested; - } - - for (String key : worlds.getKeys(false)) { - if (key.equalsIgnoreCase(requested)) { - return key; - } - } - - return null; - } - @Director(description = "Teleport to another world", aliases = {"tp"}, sync = true) public void teleport( @Param(description = "World to teleport to") @@ -595,7 +455,7 @@ public class CommandIris implements DirectorExecutor { @Director(description = "Download a project.", aliases = "dl") public void download( - @Param(name = "pack", description = "The pack to download", defaultValue = "overworld", aliases = "project") + @Param(name = "pack", description = "The pack to download", aliases = "project") String pack, @Param(name = "branch", description = "The branch to download from", defaultValue = "stable") String branch, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLibBootstrap.java b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLibBootstrap.java new file mode 100644 index 000000000..3805ca4f4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLibBootstrap.java @@ -0,0 +1,72 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.Iris; +import io.papermc.lib.PaperLib; +import io.papermc.lib.environments.PaperEnvironment; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.event.player.PlayerTeleportEvent; + +public final class PaperLibBootstrap { + private PaperLibBootstrap() { + } + + public static void install() { + if (PaperLib.getEnvironment().getMinecraftVersion() > 0) { + return; + } + + String bukkitVersion = Bukkit.getBukkitVersion(); + if (!isModernVersionScheme(bukkitVersion)) { + return; + } + + boolean hasAsyncTeleport = hasMethod(Entity.class, "teleportAsync", Location.class, PlayerTeleportEvent.TeleportCause.class); + boolean hasAsyncChunks = hasMethod(World.class, "getChunkAtAsync", int.class, int.class, boolean.class); + if (!hasAsyncTeleport || !hasAsyncChunks) { + return; + } + + PaperLib.setCustomEnvironment(new ModernPaperEnvironment()); + Iris.info("PaperLib version detection failed for MC " + bukkitVersion + "; forced modern Paper environment"); + } + + static boolean isModernVersionScheme(String bukkitVersion) { + if (bukkitVersion == null || bukkitVersion.isEmpty()) { + return false; + } + + int end = 0; + while (end < bukkitVersion.length() && Character.isDigit(bukkitVersion.charAt(end))) { + end++; + } + + if (end == 0) { + return false; + } + + try { + return Integer.parseInt(bukkitVersion.substring(0, end)) > 1; + } catch (NumberFormatException e) { + return false; + } + } + + private static boolean hasMethod(Class owner, String name, Class... parameterTypes) { + try { + owner.getMethod(name, parameterTypes); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + private static final class ModernPaperEnvironment extends PaperEnvironment { + @Override + public boolean isVersion(int minor, int patch) { + return true; + } + } +} 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 b7681f6e8..1b96c9247 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 @@ -17,9 +17,15 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend { @Override public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) { - return request.studio() - && capabilities.serverFamily().isPaperLike() - && capabilities.hasPaperLikeRuntime(); + if (!capabilities.serverFamily().isPaperLike() || !capabilities.hasPaperLikeRuntime()) { + return false; + } + + if (request.studio()) { + return true; + } + + return capabilities.serverFamily() == ServerFamily.FOLIA || capabilities.regionizedRuntime(); } @Override @@ -50,6 +56,9 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend { 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()); + if (!WorldLifecycleSupport.hasExistingWorldData(request.worldName())) { + worldDataAndGenSettings = WorldLifecycleSupport.applySeedToWorldDataAndGenSettings(worldDataAndGenSettings, request.seed()); + } capabilities.createLevelMethod().invoke(capabilities.minecraftServer(), levelStem, worldLoadingInfoAndData, worldDataAndGenSettings); } else { legacyStorageAccess = WorldLifecycleSupport.createLegacyStorageAccess(capabilities, request.worldName()); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java index 87329200f..a09df22e4 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java @@ -14,6 +14,7 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.generator.ChunkGenerator; import java.io.File; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -21,6 +22,7 @@ import java.nio.file.Path; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -252,6 +254,63 @@ final class WorldLifecycleSupport { setModdedInfoMethod.invoke(worldData, modName, modified); } + static boolean hasExistingWorldData(String worldName) { + File worldFolder = new File(Bukkit.getWorldContainer(), worldName); + return new File(worldFolder, "level.dat").exists() + || new File(worldFolder, "region").exists() + || new File(worldFolder, "data").exists(); + } + + static Object applySeedToWorldDataAndGenSettings(Object worldDataAndGenSettings, long seed) throws ReflectiveOperationException { + Method genSettingsMethod = CapabilityResolution.resolveMethod(worldDataAndGenSettings.getClass(), "genSettings", method -> method.getParameterCount() == 0); + Method dataMethod = CapabilityResolution.resolveMethod(worldDataAndGenSettings.getClass(), "data", method -> method.getParameterCount() == 0); + if (genSettingsMethod == null || dataMethod == null) { + throw new IllegalStateException("WorldDataAndGenSettings does not expose data()/genSettings()."); + } + + Object genSettings = genSettingsMethod.invoke(worldDataAndGenSettings); + Method optionsMethod = CapabilityResolution.resolveMethod(genSettings.getClass(), "options", method -> method.getParameterCount() == 0); + Method dimensionsMethod = CapabilityResolution.resolveMethod(genSettings.getClass(), "dimensions", method -> method.getParameterCount() == 0); + if (optionsMethod == null || dimensionsMethod == null) { + throw new IllegalStateException("WorldGenSettings does not expose options()/dimensions()."); + } + + Object options = optionsMethod.invoke(genSettings); + Method seedMethod = CapabilityResolution.resolveMethod(options.getClass(), "seed", method -> method.getParameterCount() == 0 && long.class.equals(method.getReturnType())); + Method withSeedMethod = CapabilityResolution.resolveMethod(options.getClass(), "withSeed", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 1 && OptionalLong.class.equals(params[0]); + }); + if (seedMethod == null || withSeedMethod == null) { + throw new IllegalStateException("WorldOptions does not expose seed()/withSeed(OptionalLong)."); + } + + long currentSeed = (long) seedMethod.invoke(options); + if (currentSeed == seed) { + return worldDataAndGenSettings; + } + + Object newOptions = withSeedMethod.invoke(options, OptionalLong.of(seed)); + Object newGenSettings = construct(genSettings.getClass(), newOptions, dimensionsMethod.invoke(genSettings)); + return construct(worldDataAndGenSettings.getClass(), dataMethod.invoke(worldDataAndGenSettings), newGenSettings); + } + + private static Object construct(Class type, Object first, Object second) throws ReflectiveOperationException { + for (Constructor constructor : type.getDeclaredConstructors()) { + Class[] params = constructor.getParameterTypes(); + if (params.length != 2) { + continue; + } + + if ((first == null || params[0].isInstance(first)) && (second == null || params[1].isInstance(second))) { + constructor.setAccessible(true); + return constructor.newInstance(first, second); + } + } + + throw new IllegalStateException("No compatible two-argument constructor on " + type.getName()); + } + static Object createCurrentWorldDataAndSettings(CapabilitySnapshot capabilities, String worldName) throws ReflectiveOperationException { Object settings = read(capabilities.settingsField(), capabilities.minecraftServer()); Object worldLoaderContext = read(capabilities.worldLoaderContextField(), capabilities.minecraftServer()); diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 9bdac292e..e0cda301a 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.nms.container.BiomeColor; import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; @@ -167,6 +168,14 @@ public interface INMSBinding { MCAPaletteAccess createPalette(); + default boolean applyChunkBlocks(Chunk chunk, TerrainChunk data) { + return false; + } + + default boolean clearChunkBlocks(Chunk chunk) { + return false; + } + void injectBiomesFromMantle(Chunk e, Mantle mantle); ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException; diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java b/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java index baa5eef14..7d8feb1ba 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java @@ -19,14 +19,15 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.Iris; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.mantle.runtime.Mantle; import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; import org.bukkit.Material; import org.bukkit.World; -import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -118,20 +119,24 @@ public final class ChunkClearer { } } - int minHeight = world.getMinHeight(); - int maxHeight = world.getMaxHeight(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = minHeight; y < maxHeight; y++) { - Block live = chunk.getBlock(x, y, z); - if (live.getType() != Material.AIR) { - live.setType(Material.AIR, false); - } - } - } + if (!INMS.get().clearChunkBlocks(chunk)) { + clearBlocks(chunk, chunk.getChunkSnapshot(true, false, false), world.getMinHeight(), world.getMaxHeight()); } mantle.deleteChunk(chunkX, chunkZ); world.refreshChunk(chunkX, chunkZ); } + + static void clearBlocks(Chunk chunk, ChunkSnapshot snapshot, int minHeight, int maxHeight) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int top = Math.min(maxHeight - 1, snapshot.getHighestBlockYAt(x, z) + 1); + for (int y = minHeight; y <= top; y++) { + if (snapshot.getBlockType(x, y, z) != Material.AIR) { + chunk.getBlock(x, y, z).setType(Material.AIR, false); + } + } + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java index dbe6a39f3..b9482647b 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java @@ -19,20 +19,23 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.Iris; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.EngineMantle; +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.volmlib.util.mantle.runtime.Mantle; import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Biome; -import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; +import org.bukkit.generator.ChunkGenerator.ChunkData; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -102,37 +105,40 @@ public final class InPlaceChunkRegenerator { int chunkX = target[0]; int chunkZ = target[1]; - TerrainChunk buffer = TerrainChunk.create(world); - try { - engine.generate(chunkX << 4, chunkZ << 4, buffer, false); - } catch (Throwable e) { - Iris.reportError(e); - reporter.countApplied(false); - allApplied.countDown(); - continue; - } - inFlight.acquire(); - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - boolean ok = false; + MultiBurst.burst.lazy(() -> { + TerrainChunk buffer = TerrainChunk.create(world); try { - applyToLiveChunk(chunkX, chunkZ, buffer); - ok = true; + engine.generate(chunkX << 4, chunkZ << 4, buffer, false); } catch (Throwable e) { Iris.reportError(e); - } finally { - reporter.countApplied(ok); + reporter.countApplied(false); + inFlight.release(); + allApplied.countDown(); + return; + } + + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + boolean ok = false; + try { + applyToLiveChunk(chunkX, chunkZ, buffer); + ok = true; + } catch (Throwable e) { + Iris.reportError(e); + } finally { + reporter.countApplied(ok); + inFlight.release(); + allApplied.countDown(); + } + }); + + if (!scheduled) { + Iris.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); + reporter.countApplied(false); inFlight.release(); allApplied.countDown(); } }); - - if (!scheduled) { - Iris.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + "."); - reporter.countApplied(false); - inFlight.release(); - allApplied.countDown(); - } } allApplied.await(); @@ -148,20 +154,8 @@ public final class InPlaceChunkRegenerator { int minHeight = world.getMinHeight(); int maxHeight = world.getMaxHeight(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = minHeight; y < maxHeight; y++) { - BlockData data = buffer.getBlockData(x, y, z); - if (data == null) { - continue; - } - Block live = chunk.getBlock(x, y, z); - if (data.getMaterial() == Material.AIR && live.getType() == Material.AIR) { - continue; - } - live.setBlockData(data, false); - } - } + if (!INMS.get().applyChunkBlocks(chunk, buffer)) { + applyBlockDiffs(chunk, chunk.getChunkSnapshot(false, false, false), buffer.getChunkData(), minHeight, maxHeight); } int baseX = chunkX << 4; @@ -170,7 +164,7 @@ public final class InPlaceChunkRegenerator { for (int z = 0; z < 16; z += BIOME_STEP) { for (int y = minHeight; y < maxHeight; y += BIOME_STEP) { Biome biome = buffer.getBiome(x, y, z); - if (biome != null) { + if (biome != null && world.getBiome(baseX + x, y, baseZ + z) != biome) { world.setBiome(baseX + x, y, baseZ + z, biome); } } @@ -180,4 +174,27 @@ public final class InPlaceChunkRegenerator { engine.updateChunk(chunk); world.refreshChunk(chunkX, chunkZ); } + + static void applyBlockDiffs(Chunk chunk, ChunkSnapshot snapshot, ChunkData generated, int minHeight, int maxHeight) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minHeight; y < maxHeight; y++) { + Material target = generated.getType(x, y, z); + if (snapshot.getBlockType(x, y, z) != target) { + chunk.getBlock(x, y, z).setBlockData(generated.getBlockData(x, y, z), false); + continue; + } + + if (target == Material.AIR) { + continue; + } + + BlockData data = generated.getBlockData(x, y, z); + if (!data.equals(snapshot.getBlockData(x, y, z))) { + chunk.getBlock(x, y, z).setBlockData(data, false); + } + } + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index 9d391c1cd..055f1c246 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 @@ -113,7 +113,12 @@ public final class StudioOpenCoordinator { } updateStage(request, "apply_world_rules", 0.72D); - WorldRuntimeControlService.get().applyStudioWorldRules(world); + final World rulesWorld = world; + CompletableFuture rulesApplied = + J.sfut(() -> WorldRuntimeControlService.get().applyStudioWorldRules(rulesWorld)); + if (rulesApplied != null) { + rulesApplied.get(15L, TimeUnit.SECONDS); + } t = logStudioPhase("applyStudioWorldRules", t, openStart); updateStage(request, "prepare_generator", 0.78D); @@ -157,7 +162,12 @@ public final class StudioOpenCoordinator { throw new IllegalStateException("Player \"" + request.playerName() + "\" is not online."); } - Boolean teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(10L, TimeUnit.SECONDS); + Boolean teleported; + try { + teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(60L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Studio teleport timed out — destination region may still be generating."); + } if (!Boolean.TRUE.equals(teleported)) { throw new IllegalStateException("Studio teleport did not complete successfully."); } 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 925297582..2556187d1 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 @@ -80,6 +80,7 @@ public final class WorldRuntimeControlService { } Iris.linkMultiverseCore.removeFromConfig(world); + setIntGameRule(world, 0, "SPAWN_CHUNK_RADIUS", "spawnChunkRadius"); if (!IrisSettings.get().getStudio().isDisableTimeAndWeather()) { return true; } @@ -247,26 +248,30 @@ public final class WorldRuntimeControlService { CompletableFuture future = new CompletableFuture<>(); boolean scheduled = J.runEntity(player, () -> { - CompletableFuture teleportFuture = PaperLib.teleportAsync(player, location); - if (teleportFuture == null) { - future.complete(false); - return; + try { + CompletableFuture teleportFuture = PaperLib.teleportAsync(player, location); + if (teleportFuture == null) { + future.complete(false); + return; + } + + teleportFuture.whenComplete((success, throwable) -> { + if (throwable != null) { + future.completeExceptionally(throwable); + return; + } + + if (Boolean.TRUE.equals(success)) { + J.runEntity(player, () -> Iris.service(BoardSVC.class).updatePlayer(player)); + future.complete(true); + return; + } + + future.complete(false); + }); + } catch (Throwable t) { + future.completeExceptionally(t); } - - teleportFuture.whenComplete((success, throwable) -> { - if (throwable != null) { - future.completeExceptionally(throwable); - return; - } - - if (Boolean.TRUE.equals(success)) { - J.runEntity(player, () -> Iris.service(BoardSVC.class).updatePlayer(player)); - future.complete(true); - return; - } - - future.complete(false); - }); }); if (!scheduled) { return CompletableFuture.failedFuture(new IllegalStateException("Failed to schedule teleport for " + player.getName() + ".")); 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 79c1e5d12..147cea270 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 @@ -74,7 +74,7 @@ public class StudioSVC implements IrisService { Iris.info("Downloading Default Pack " + pack + " (latest on master)"); Iris.service(StudioSVC.class).downloadBranch(Iris.getSender(), "IrisDimensions/overworld", "master", false); } else { - Iris.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download"); + Iris.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack); } } }); @@ -216,7 +216,7 @@ public class StudioSVC implements IrisService { if (url == null) { sender.sendMessage("Pack '" + key + "' was not found in the pack listing."); - sender.sendMessage("Use /iris download to download manually."); + sender.sendMessage("Use /iris download branch= to download manually."); return; } @@ -267,7 +267,7 @@ public class StudioSVC implements IrisService { if (zip == null || !zip.exists()) { sender.sendMessage("Failed to find pack at " + url); sender.sendMessage("Make sure you specified the correct repo and branch!"); - sender.sendMessage("For example: /iris download IrisDimensions/overworld branch=stable"); + sender.sendMessage("For example: /iris download overworld branch=stable"); return; } sender.sendMessage("Unpacking " + repo); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/PaperLibBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/PaperLibBootstrapTest.java new file mode 100644 index 000000000..e9854f78f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/PaperLibBootstrapTest.java @@ -0,0 +1,29 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PaperLibBootstrapTest { + @Test + public void isModernVersionSchemeAcceptsMajorVersionAboveOne() { + assertTrue(PaperLibBootstrap.isModernVersionScheme("26.1.2-R0.1-SNAPSHOT")); + assertTrue(PaperLibBootstrap.isModernVersionScheme("26.1.2")); + assertTrue(PaperLibBootstrap.isModernVersionScheme("27.0.0-R0.1-SNAPSHOT")); + } + + @Test + public void isModernVersionSchemeRejectsLegacyScheme() { + assertFalse(PaperLibBootstrap.isModernVersionScheme("1.21.4-R0.1-SNAPSHOT")); + assertFalse(PaperLibBootstrap.isModernVersionScheme("1.13.2-R0.1-SNAPSHOT")); + } + + @Test + public void isModernVersionSchemeRejectsUnparsableInput() { + assertFalse(PaperLibBootstrap.isModernVersionScheme(null)); + assertFalse(PaperLibBootstrap.isModernVersionScheme("")); + assertFalse(PaperLibBootstrap.isModernVersionScheme("unknown")); + assertFalse(PaperLibBootstrap.isModernVersionScheme("-R0.1-SNAPSHOT")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSeedTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSeedTest.java new file mode 100644 index 000000000..03af9d3ef --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSeedTest.java @@ -0,0 +1,104 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Test; + +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class WorldLifecycleSeedTest { + @Test + public void appliesRequestedSeedToFreshWorldData() throws Exception { + TestWorldOptions options = new TestWorldOptions(111L, true, false); + Object dimensions = new Object(); + TestWorldGenSettings genSettings = new TestWorldGenSettings(options, dimensions); + Object data = new Object(); + TestWorldDataAndGenSettings input = new TestWorldDataAndGenSettings(data, genSettings); + + Object result = WorldLifecycleSupport.applySeedToWorldDataAndGenSettings(input, 69420L); + + assertTrue(result instanceof TestWorldDataAndGenSettings); + TestWorldDataAndGenSettings typed = (TestWorldDataAndGenSettings) result; + assertSame(data, typed.data()); + assertSame(dimensions, typed.genSettings().dimensions()); + assertEquals(69420L, typed.genSettings().options().seed()); + assertTrue(typed.genSettings().options().generateStructures()); + } + + @Test + public void returnsSameInstanceWhenSeedAlreadyMatches() throws Exception { + TestWorldOptions options = new TestWorldOptions(69420L, true, false); + TestWorldGenSettings genSettings = new TestWorldGenSettings(options, new Object()); + TestWorldDataAndGenSettings input = new TestWorldDataAndGenSettings(new Object(), genSettings); + + Object result = WorldLifecycleSupport.applySeedToWorldDataAndGenSettings(input, 69420L); + + assertSame(input, result); + } + + public static final class TestWorldDataAndGenSettings { + private final Object data; + private final TestWorldGenSettings genSettings; + + public TestWorldDataAndGenSettings(Object data, TestWorldGenSettings genSettings) { + this.data = data; + this.genSettings = genSettings; + } + + public Object data() { + return data; + } + + public TestWorldGenSettings genSettings() { + return genSettings; + } + } + + public static final class TestWorldGenSettings { + private final TestWorldOptions options; + private final Object dimensions; + + public TestWorldGenSettings(TestWorldOptions options, Object dimensions) { + this.options = options; + this.dimensions = dimensions; + } + + public TestWorldOptions options() { + return options; + } + + public Object dimensions() { + return dimensions; + } + } + + public static final class TestWorldOptions { + private final long seed; + private final boolean generateStructures; + private final boolean generateBonusChest; + + public TestWorldOptions(long seed, boolean generateStructures, boolean generateBonusChest) { + this.seed = seed; + this.generateStructures = generateStructures; + this.generateBonusChest = generateBonusChest; + } + + public long seed() { + return seed; + } + + public boolean generateStructures() { + return generateStructures; + } + + public boolean generateBonusChest() { + return generateBonusChest; + } + + public TestWorldOptions withSeed(OptionalLong newSeed) { + return new TestWorldOptions(newSeed.orElse(seed), generateStructures, generateBonusChest); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java index e9376d69c..c9da0f867 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSelectionTest.java @@ -54,6 +54,30 @@ public class WorldLifecycleSelectionTest { assertEquals("bukkit_public", service.selectCreateBackend(request).backendName()); } + @Test + public void persistentCreateSelectsPaperLikeBackendOnFolia() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.FOLIA, true, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("persistent", World.Environment.NORMAL, null, null, null, true, false, 1337L, false, false, WorldLifecycleCaller.CREATE); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void persistentCreateSelectsPaperLikeBackendOnCanvas() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.CANVAS, true, false, true)); + WorldLifecycleRequest request = new WorldLifecycleRequest("persistent", World.Environment.NORMAL, null, null, null, true, false, 1337L, false, false, WorldLifecycleCaller.CREATE); + + assertEquals("paper_like_runtime", service.selectCreateBackend(request).backendName()); + } + + @Test + public void persistentCreateFallsBackToBukkitBackendOnFoliaWithoutRuntime() { + WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.FOLIA, true, false, false)); + WorldLifecycleRequest request = new WorldLifecycleRequest("persistent", World.Environment.NORMAL, null, null, null, true, false, 1337L, false, false, WorldLifecycleCaller.CREATE); + + assertEquals("bukkit_public", service.selectCreateBackend(request).backendName()); + } + @Test public void unloadUsesRememberedBackendFamily() { WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, true)); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/ChunkClearerBlocksTest.java b/core/src/test/java/art/arcane/iris/core/runtime/ChunkClearerBlocksTest.java new file mode 100644 index 000000000..d173d8248 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/ChunkClearerBlocksTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.core.runtime; + +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.junit.Test; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ChunkClearerBlocksTest { + private static final int MIN_HEIGHT = 0; + private static final int MAX_HEIGHT = 16; + + @Test + public void clearBlocksOnlyIteratesUpToColumnHeightmap() { + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(3); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + ChunkClearer.clearBlocks(chunk, snapshot, MIN_HEIGHT, MAX_HEIGHT); + + verify(block, times(16 * 16 * 5)).setType(Material.AIR, false); + } + + @Test + public void clearBlocksSkipsAirPositions() { + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(2); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.AIR); + when(snapshot.getBlockType(4, 1, 9)).thenReturn(Material.STONE); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + ChunkClearer.clearBlocks(chunk, snapshot, MIN_HEIGHT, MAX_HEIGHT); + + verify(chunk, times(1)).getBlock(4, 1, 9); + verify(block, times(1)).setType(Material.AIR, false); + } + + @Test + public void clearBlocksHandlesEmptyColumns() { + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(MIN_HEIGHT - 1); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.AIR); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + ChunkClearer.clearBlocks(chunk, snapshot, MIN_HEIGHT, MAX_HEIGHT); + + verify(block, never()).setType(Material.AIR, false); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/InPlaceChunkRegeneratorDiffTest.java b/core/src/test/java/art/arcane/iris/core/runtime/InPlaceChunkRegeneratorDiffTest.java new file mode 100644 index 000000000..ee96518e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/InPlaceChunkRegeneratorDiffTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.core.runtime; + +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.generator.ChunkGenerator.ChunkData; +import org.junit.Test; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class InPlaceChunkRegeneratorDiffTest { + private static final int MIN_HEIGHT = 0; + private static final int MAX_HEIGHT = 8; + + @Test + public void applyBlockDiffsSkipsAllWritesWhenChunkMatchesBuffer() { + BlockData stone = mock(BlockData.class); + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE); + when(snapshot.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(stone); + + ChunkData generated = mock(ChunkData.class); + when(generated.getType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE); + when(generated.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(stone); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + InPlaceChunkRegenerator.applyBlockDiffs(chunk, snapshot, generated, MIN_HEIGHT, MAX_HEIGHT); + + verify(block, never()).setBlockData(eq(stone), eq(false)); + } + + @Test + public void applyBlockDiffsWritesOnlyMaterialChanges() { + BlockData stone = mock(BlockData.class); + BlockData dirt = mock(BlockData.class); + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE); + when(snapshot.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(stone); + + ChunkData generated = mock(ChunkData.class); + when(generated.getType(anyInt(), anyInt(), anyInt())).thenReturn(Material.STONE); + when(generated.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(stone); + when(generated.getType(3, 5, 7)).thenReturn(Material.DIRT); + when(generated.getBlockData(3, 5, 7)).thenReturn(dirt); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + InPlaceChunkRegenerator.applyBlockDiffs(chunk, snapshot, generated, MIN_HEIGHT, MAX_HEIGHT); + + verify(chunk, times(1)).getBlock(3, 5, 7); + verify(block, times(1)).setBlockData(dirt, false); + verify(block, never()).setBlockData(eq(stone), eq(false)); + } + + @Test + public void applyBlockDiffsWritesWhenMaterialMatchesButStateDiffers() { + BlockData liveStairs = mock(BlockData.class); + BlockData rotatedStairs = mock(BlockData.class); + ChunkSnapshot snapshot = mock(ChunkSnapshot.class); + when(snapshot.getBlockType(anyInt(), anyInt(), anyInt())).thenReturn(Material.OAK_STAIRS); + when(snapshot.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(liveStairs); + + ChunkData generated = mock(ChunkData.class); + when(generated.getType(anyInt(), anyInt(), anyInt())).thenReturn(Material.OAK_STAIRS); + when(generated.getBlockData(anyInt(), anyInt(), anyInt())).thenReturn(liveStairs); + when(generated.getBlockData(1, 2, 3)).thenReturn(rotatedStairs); + + Chunk chunk = mock(Chunk.class); + Block block = mock(Block.class); + when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(block); + + InPlaceChunkRegenerator.applyBlockDiffs(chunk, snapshot, generated, MIN_HEIGHT, MAX_HEIGHT); + + verify(block, times(1)).setBlockData(rotatedStairs, false); + } +} diff --git a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java index 7955d5b88..4b98994a5 100644 --- a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java +++ b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java @@ -8,6 +8,7 @@ import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.util.project.agent.Agent; @@ -40,6 +41,7 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.commands.data.BlockDataAccessor; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ThreadedLevelLightEngine; import net.minecraft.tags.TagKey; import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.entity.EntityType; @@ -54,11 +56,13 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.Property; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.chunk.ProtoChunk; import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.chunk.status.WorldGenContext; import net.minecraft.world.level.dimension.LevelStem; import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.WorldgenRandom; import net.minecraft.world.level.levelgen.XoroshiroRandomSource; import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; @@ -78,6 +82,7 @@ import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.CraftBlockState; import org.bukkit.craftbukkit.block.CraftBlockStates; import org.bukkit.craftbukkit.block.data.CraftBlockData; +import org.bukkit.craftbukkit.generator.CraftChunkData; import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.craftbukkit.util.CraftMagicNumbers; import org.bukkit.craftbukkit.util.CraftNamespacedKey; @@ -718,6 +723,100 @@ public class NMSBinding implements INMSBinding { }); } + @Override + public boolean applyChunkBlocks(Chunk bukkitChunk, TerrainChunk data) { + if (!(data.getChunkData() instanceof CraftChunkData chunkData)) { + return false; + } + + try { + ServerLevel level = ((CraftWorld) bukkitChunk.getWorld()).getHandle(); + LevelChunk chunk = level.getChunk(bukkitChunk.getX(), bukkitChunk.getZ()); + ChunkAccess source = chunkData.getHandle(); + removeBlockEntities(chunk); + + int minY = level.getMinY(); + int baseX = chunk.getPos().getMinBlockX(); + int baseZ = chunk.getPos().getMinBlockZ(); + for (int i = 0; i < chunk.getSectionsCount(); i++) { + LevelChunkSection target = chunk.getSection(i); + LevelChunkSection from = source.getSection(i); + if (from.hasOnlyAir() && target.hasOnlyAir()) { + continue; + } + + int sectionBaseY = minY + (i << 4); + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + BlockState state = from.getBlockState(x, y, z); + target.setBlockState(x, y, z, state, false); + if (state.hasBlockEntity() && state.getBlock() instanceof EntityBlock entityBlock) { + BlockPos pos = new BlockPos(baseX + x, sectionBaseY + y, baseZ + z); + BlockEntity entity = entityBlock.newBlockEntity(pos, state); + if (entity != null) { + chunk.setBlockEntity(entity); + } + } + } + } + } + } + + finishChunkRewrite(level, chunk); + return true; + } catch (Throwable e) { + Iris.reportError(e); + return false; + } + } + + @Override + public boolean clearChunkBlocks(Chunk bukkitChunk) { + try { + ServerLevel level = ((CraftWorld) bukkitChunk.getWorld()).getHandle(); + LevelChunk chunk = level.getChunk(bukkitChunk.getX(), bukkitChunk.getZ()); + removeBlockEntities(chunk); + + BlockState air = ((CraftBlockData) AIR).getState(); + for (int i = 0; i < chunk.getSectionsCount(); i++) { + LevelChunkSection section = chunk.getSection(i); + if (section.hasOnlyAir()) { + continue; + } + + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + section.setBlockState(x, y, z, air, false); + } + } + } + } + + finishChunkRewrite(level, chunk); + return true; + } catch (Throwable e) { + Iris.reportError(e); + return false; + } + } + + private void removeBlockEntities(LevelChunk chunk) { + for (BlockPos pos : new ArrayList<>(chunk.getBlockEntities().keySet())) { + chunk.removeBlockEntity(pos); + } + } + + private void finishChunkRewrite(ServerLevel level, LevelChunk chunk) { + Heightmap.primeHeightmaps(chunk, ChunkStatus.FULL.heightmapsAfter()); + chunk.markUnsaved(); + ThreadedLevelLightEngine lightEngine = (ThreadedLevelLightEngine) level.getChunkSource().getLightEngine(); + lightEngine.starlight$serverRelightChunks(List.of(chunk.getPos()), p -> { + }, c -> { + }); + } + public ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException { if (customNbt != null && !customNbt.isEmpty()) { net.minecraft.world.item.ItemStack s = CraftItemStack.asNMSCopy(itemStack);