From e12c2659d05c6ae2c79c301990b98d05087d10f7 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:51:46 +0000 Subject: [PATCH 1/2] Fix //regen -b sampling biomes at block coordinates ChunkAccess#getNoiseBiome takes biome (quart) coordinates, as the adapter's own getBiome already does. The previous call passed block coordinates, so biome regeneration read from the wrong place. --- .../bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java | 2 +- .../bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java | 2 +- .../bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java | 2 +- .../bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java | 2 +- .../bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java | 2 +- .../worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java | 2 +- .../worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java index fdce051ad0..bd32644752 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java @@ -902,7 +902,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java index 07b10f53b5..0c196dcffb 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java @@ -876,7 +876,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java index 1dd1595d30..31638ed64c 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java @@ -874,7 +874,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java index dc75fb35e0..44a69df94a 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java @@ -903,7 +903,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java index c9959eef6f..109f3d2416 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java @@ -901,7 +901,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java index ac79eb3e3a..7b384946b5 100644 --- a/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java @@ -905,7 +905,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); diff --git a/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java index 9d9f0f66d4..2c231d5940 100644 --- a/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java @@ -910,7 +910,7 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } extent.setBlock(vec, state.toBaseBlock()); if (options.shouldRegenBiomes()) { - Biome origBiome = chunk.getNoiseBiome(vec.x(), vec.y(), vec.z()).value(); + Biome origBiome = chunk.getNoiseBiome(vec.x() >> 2, vec.y() >> 2, vec.z() >> 2).value(); BiomeType adaptedBiome = adapt(serverWorld, origBiome); if (adaptedBiome != null) { extent.setBiome(vec, adaptedBiome); From 6c532048943b2ef11c267c157bc4cbdd8baa1fd0 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:02:14 +0000 Subject: [PATCH 2/2] Support //regen on Folia Folia cannot wait for chunk generation on a region thread, so the Bukkit adapter generates the terrain into a detached snapshot in a temporary world, as the synchronous path does, but waits on chunk futures instead of managedBlock. The selection's chunks are then loaded with World#getChunkAtAsync and held with plugin tickets, the snapshot is applied on the region thread owning them, and the command completes on the player's scheduler. Core gains a single World#regenerateAsync default method that falls back to the synchronous regenerate(); //regen creates its own edit session so both paths share one completion handler. The adapters implement regenerateAsync from the existing temporary world setup and copy loop, driven by a small AsyncRegeneration helper, and load the temporary world through the Bukkit API rather than chunk system internals. Temporary worlds live on the global region thread, one at a time. Limitations: the selection must end up in one region when the result is applied, concurrent regenerations are refused with a message, inventory mode is not supported, seed overrides remain unsupported on 26.x, and there is no timeout. --- .../impl/v1_21_11/PaperweightAdapter.java | 90 +++++- .../impl/v1_21_4/PaperweightAdapter.java | 90 +++++- .../impl/v1_21_5/PaperweightAdapter.java | 90 +++++- .../impl/v1_21_6/PaperweightAdapter.java | 90 +++++- .../impl/v1_21_9/PaperweightAdapter.java | 90 +++++- .../impl/v26_1/PaperweightAdapter.java | 98 +++++-- .../impl/v26_2/PaperweightAdapter.java | 98 +++++-- .../sk89q/worldedit/bukkit/BukkitWorld.java | 14 + .../bukkit/adapter/AsyncRegeneration.java | 125 +++++++++ .../bukkit/adapter/BukkitImplAdapter.java | 19 ++ .../bukkit/folia/FoliaRegeneration.java | 258 ++++++++++++++++++ .../worldedit/command/RegionCommands.java | 77 ++++-- .../java/com/sk89q/worldedit/world/World.java | 23 ++ .../src/main/resources/lang/strings.json | 4 + 14 files changed, 1047 insertions(+), 119 deletions(-) create mode 100644 worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/AsyncRegeneration.java create mode 100644 worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/folia/FoliaRegeneration.java diff --git a/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java index bd32644752..ec18d42141 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.11/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_11/PaperweightAdapter.java @@ -34,6 +34,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -152,6 +153,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -180,6 +182,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -198,6 +201,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -775,13 +779,40 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey)) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PrimaryLevelData levelProperties = (PrimaryLevelData) originalWorld.getServer() .getWorldData().overworldData(); @@ -830,20 +861,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio gen, bukkitWorld.getBiomeProvider() ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -873,6 +898,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1309,4 +1339,38 @@ public void tick() { } } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java index 0c196dcffb..66a14d6e2b 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.4/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_4/PaperweightAdapter.java @@ -34,6 +34,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -150,6 +151,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -178,6 +180,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -196,6 +199,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -755,13 +759,40 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey)) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PrimaryLevelData levelProperties = (PrimaryLevelData) originalWorld.getServer() .getWorldData().overworldData(); @@ -811,20 +842,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio gen, bukkitWorld.getBiomeProvider() ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -854,6 +879,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1275,4 +1305,38 @@ public void stop() { } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java index 31638ed64c..3ad28b7683 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.5/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_5/PaperweightAdapter.java @@ -34,6 +34,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -150,6 +151,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -178,6 +180,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -196,6 +199,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -753,13 +757,40 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey)) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PrimaryLevelData levelProperties = (PrimaryLevelData) originalWorld.getServer() .getWorldData().overworldData(); @@ -809,20 +840,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio gen, bukkitWorld.getBiomeProvider() ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -852,6 +877,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1300,4 +1330,38 @@ public void stop() { } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java index 44a69df94a..53bfdc0245 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.6/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_6/PaperweightAdapter.java @@ -34,6 +34,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -153,6 +154,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -181,6 +183,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -199,6 +202,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -775,13 +779,40 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey)) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PrimaryLevelData levelProperties = (PrimaryLevelData) originalWorld.getServer() .getWorldData().overworldData(); @@ -831,20 +862,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio gen, bukkitWorld.getBiomeProvider() ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -874,6 +899,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1329,4 +1359,38 @@ public void stop() { } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java index 109f3d2416..230cf39029 100644 --- a/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-1.21.9/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v1_21_9/PaperweightAdapter.java @@ -34,6 +34,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -152,6 +153,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -180,6 +182,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -198,6 +201,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -774,13 +778,40 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey)) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld", worldDimKey); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PrimaryLevelData levelProperties = (PrimaryLevelData) originalWorld.getServer() .getWorldData().overworldData(); @@ -829,20 +860,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio gen, bukkitWorld.getBiomeProvider() ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -872,6 +897,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1308,4 +1338,38 @@ public void tick() { } } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java index 7b384946b5..45c247b2fe 100644 --- a/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-26.1/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_1/PaperweightAdapter.java @@ -33,6 +33,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -152,6 +153,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -181,6 +183,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -199,6 +202,7 @@ import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -782,10 +786,6 @@ public boolean canPlaceAt(World world, BlockVector3 position, BlockState blockSt @Override public boolean regenerate(World bukkitWorld, Region region, Extent extent, RegenOptions options) { - if (options.getSeed().isPresent()) { - throw new UnsupportedOperationException("26.1+ worldgen does not support overriding the seed for regen"); - } - try { doRegen(bukkitWorld, region, extent, options); } catch (Exception e) { @@ -796,13 +796,44 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { + if (options.getSeed().isPresent()) { + throw new UnsupportedOperationException("26.1+ worldgen does not support overriding the seed for regen"); + } + Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld")) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld"); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PaperWorldLoader.LoadedWorldData loadedWorldData = new PaperWorldLoader.LoadedWorldData( @@ -833,20 +864,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio new SavedDataStorage(session.getDimensionPath(originalWorld.dimension()), originalWorld.getServer().getFixerUpper(), originalWorld.registryAccess()), loadedWorldData ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -876,6 +901,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1312,4 +1342,38 @@ public void tick() { } } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java b/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java index 2c231d5940..4d96018b5d 100644 --- a/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java +++ b/worldedit-bukkit/adapters/adapter-26.2/src/main/java/com/sk89q/worldedit/bukkit/adapter/impl/v26_2/PaperweightAdapter.java @@ -33,6 +33,7 @@ import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.bukkit.adapter.AsyncRegeneration; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.extension.platform.Watchdog; @@ -152,6 +153,7 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.block.data.BlockData; +import org.bukkit.craftbukkit.CraftChunk; import org.bukkit.craftbukkit.CraftServer; import org.bukkit.craftbukkit.CraftWorld; import org.bukkit.craftbukkit.block.data.CraftBlockData; @@ -181,6 +183,7 @@ import org.spigotmc.SpigotConfig; import org.spigotmc.WatchdogThread; +import java.io.IOException; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -200,6 +203,7 @@ import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -787,10 +791,6 @@ public boolean canPlaceAt(World world, BlockVector3 position, BlockState blockSt @Override public boolean regenerate(World bukkitWorld, Region region, Extent extent, RegenOptions options) { - if (options.getSeed().isPresent()) { - throw new UnsupportedOperationException("26.1+ worldgen does not support overriding the seed for regen"); - } - try { doRegen(bukkitWorld, region, extent, options); } catch (Exception e) { @@ -801,13 +801,44 @@ public boolean regenerate(World bukkitWorld, Region region, Extent extent, Regen } private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptions options) throws Exception { + try (TemporaryWorld temporaryWorld = createTemporaryWorld(bukkitWorld, options)) { + regenForWorld(region, extent, temporaryWorld.level, options); + } + } + + @Override + public CompletionStage regenerateAsync(World bukkitWorld, Region region, Extent extent, RegenOptions options) { + TemporaryWorld temporaryWorld; + try { + temporaryWorld = createTemporaryWorld(bukkitWorld, options); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + ServerLevel freshWorld = temporaryWorld.level; + // Folia disables the vanilla chunk futures, so the temporary world is loaded through the Bukkit API. + World freshBukkitWorld = freshWorld.getWorld(); + return AsyncRegeneration.run( + region, + chunk -> freshBukkitWorld.getChunkAtAsync(chunk.x(), chunk.z(), true) + .thenApply(loaded -> loaded == null ? null : ((CraftChunk) loaded).getHandle(ChunkStatus.FULL)), + chunks -> copyChunks(region, extent, freshWorld, options, chunks), + temporaryWorld + ); + } + + private TemporaryWorld createTemporaryWorld(World bukkitWorld, RegenOptions options) throws Exception { + if (options.getSeed().isPresent()) { + throw new UnsupportedOperationException("26.1+ worldgen does not support overriding the seed for regen"); + } + Environment env = bukkitWorld.getEnvironment(); ChunkGenerator gen = bukkitWorld.getGenerator(); Path tempDir = Files.createTempDirectory("WorldEditWorldGen"); LevelStorageSource levelStorage = LevelStorageSource.createDefault(tempDir); ResourceKey worldDimKey = getWorldDimKey(env); - try (LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld")) { + LevelStorageSource.LevelStorageAccess session = levelStorage.createAccess("worldeditregentempworld"); + try { ServerLevel originalWorld = ((CraftWorld) bukkitWorld).getHandle(); PaperWorldLoader.LoadedWorldData loadedWorldData = new PaperWorldLoader.LoadedWorldData( @@ -838,20 +869,14 @@ private void doRegen(World bukkitWorld, Region region, Extent extent, RegenOptio new SavedDataStorage(session.getDimensionPath(originalWorld.dimension()), originalWorld.getServer().getFixerUpper(), originalWorld.registryAccess()), loadedWorldData ); + return new TemporaryWorld(freshWorld, session, tempDir); + } catch (Exception e) { try { - regenForWorld(region, extent, freshWorld, options); - } finally { - freshWorld.getChunkSource().close(false); - } - } finally { - try { - @SuppressWarnings("unchecked") - Map map = (Map) serverWorldsField.get(Bukkit.getServer()); - map.remove("worldeditregentempworld"); - } catch (IllegalAccessException ignored) { - // It's fine if we couldn't remove it + new TemporaryWorld(null, session, tempDir).close(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } - SafeFiles.tryHardToDeleteDir(tempDir); + throw e; } } @@ -881,6 +906,11 @@ private void regenForWorld(Region region, Extent extent, ServerLevel serverWorld } return chunkLoadings.stream().allMatch(CompletableFuture::isDone); }); + copyChunks(region, extent, serverWorld, options, chunkLoadings); + } + + private void copyChunks(Region region, Extent extent, ServerLevel serverWorld, RegenOptions options, + List> chunkLoadings) throws WorldEditException { Map chunks = new HashMap<>(); for (CompletableFuture future : chunkLoadings) { @Nullable @@ -1317,4 +1347,38 @@ public void tick() { } } } + + /** + * A world that exists only to generate chunks in; closing it releases everything it uses. + */ + private final class TemporaryWorld implements AutoCloseable { + @Nullable + private final ServerLevel level; + private final LevelStorageSource.LevelStorageAccess session; + private final Path tempDir; + + private TemporaryWorld(@Nullable ServerLevel level, LevelStorageSource.LevelStorageAccess session, Path tempDir) { + this.level = level; + this.session = session; + this.tempDir = tempDir; + } + + @Override + public void close() throws IOException { + try (session) { + if (level != null) { + level.getChunkSource().close(false); + } + } finally { + try { + @SuppressWarnings("unchecked") + Map map = (Map) serverWorldsField.get(Bukkit.getServer()); + map.remove("worldeditregentempworld"); + } catch (IllegalAccessException ignored) { + // It's fine if we couldn't remove it + } + SafeFiles.tryHardToDeleteDir(tempDir); + } + } + } } diff --git a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/BukkitWorld.java b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/BukkitWorld.java index e4a9088516..77a814d8c1 100644 --- a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/BukkitWorld.java +++ b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/BukkitWorld.java @@ -28,7 +28,9 @@ import com.sk89q.worldedit.blocks.BaseItemStack; import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; import com.sk89q.worldedit.bukkit.adapter.UnsupportedVersionEditException; +import com.sk89q.worldedit.bukkit.folia.FoliaRegeneration; import com.sk89q.worldedit.entity.BaseEntity; +import com.sk89q.worldedit.extension.platform.Actor; import com.sk89q.worldedit.extent.Extent; import com.sk89q.worldedit.function.mask.Mask; import com.sk89q.worldedit.internal.util.LogManagerCompat; @@ -74,6 +76,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; import javax.annotation.Nullable; @@ -215,6 +218,17 @@ public boolean regenerate(Region region, Extent extent, RegenOptions options) { } } + @Override + public CompletionStage regenerateAsync(Region region, Extent extent, RegenOptions options, + @Nullable Actor actor) { + WorldEditPlugin plugin = WorldEditPlugin.getInstance(); + BukkitImplAdapter adapter = plugin.getBukkitImplAdapter(); + if (!plugin.isFolia() || adapter == null) { + return super.regenerateAsync(region, extent, options, actor); + } + return FoliaRegeneration.regenerate(plugin, adapter, this, region, extent, options, actor); + } + @Override public boolean clearContainerBlockContents(BlockVector3 pt) { checkNotNull(pt); diff --git a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/AsyncRegeneration.java b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/AsyncRegeneration.java new file mode 100644 index 0000000000..1f05ff0389 --- /dev/null +++ b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/AsyncRegeneration.java @@ -0,0 +1,125 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * 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 com.sk89q.worldedit.bukkit.adapter; + +import com.sk89q.worldedit.WorldEditException; +import com.sk89q.worldedit.bukkit.WorldEditPlugin; +import com.sk89q.worldedit.math.BlockVector2; +import com.sk89q.worldedit.regions.Region; +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import javax.annotation.Nullable; + +/** + * Drives {@link BukkitImplAdapter#regenerateAsync} for adapters that generate in a temporary world. + * + *

Chunk futures complete on the temporary world's own region threads. Once all of them are done + * the result is copied, then the temporary world is released on the global region thread: a tick + * thread, as closing a world requires, that is never inside one of that world's own chunk tasks.

+ */ +public final class AsyncRegeneration { + + private AsyncRegeneration() { + } + + /** + * Copies generated chunks into the output extent. + * + * @param the adapter's chunk type + */ + @FunctionalInterface + public interface ChunkCopier { + + /** + * Copy the generated chunks into the output extent. + * + * @param chunks the chunk futures, all of which are complete + * @throws WorldEditException if writing to the extent fails + */ + void copy(List> chunks) throws WorldEditException; + } + + /** + * Generate every chunk of the region, copy the result and release the temporary world. + * + * @param region the region to generate + * @param loadChunk schedules generation of one chunk; the future completes with null on failure + * @param copier copies the generated chunks into the output extent + * @param temporaryWorld released on the global region thread once copying is done + * @param the adapter's chunk type + * @return a future that completes after the temporary world has been released + */ + public static CompletableFuture run(Region region, + Function> loadChunk, + ChunkCopier copier, + AutoCloseable temporaryWorld) { + List> chunks = new ArrayList<>(); + for (BlockVector2 chunk : region.getChunks()) { + try { + chunks.add(loadChunk.apply(chunk)); + } catch (RuntimeException e) { + // Still wait for the chunks already scheduled before releasing the world. + chunks.add(CompletableFuture.failedFuture(e)); + break; + } + } + return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)) + .handle((_, error) -> { + if (error != null) { + return error; + } + try { + copier.copy(chunks); + return null; + } catch (Throwable t) { + return t; + } + }) + .thenCompose(failure -> release(temporaryWorld, failure)); + } + + private static CompletableFuture release(AutoCloseable temporaryWorld, @Nullable Throwable failure) { + CompletableFuture result = new CompletableFuture<>(); + // Closing a world requires a tick thread; the global one is also never inside this world's chunk tasks. + Bukkit.getGlobalRegionScheduler().execute(JavaPlugin.getPlugin(WorldEditPlugin.class), () -> { + Throwable finalFailure = failure; + try { + temporaryWorld.close(); + } catch (Exception e) { + if (finalFailure == null) { + finalFailure = e; + } else { + finalFailure.addSuppressed(e); + } + } + if (finalFailure == null) { + result.complete(null); + } else { + result.completeExceptionally(finalFailure); + } + }); + return result; + } +} diff --git a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/BukkitImplAdapter.java b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/BukkitImplAdapter.java index caa8ad3a26..2af5b84cf0 100644 --- a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/BukkitImplAdapter.java +++ b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/adapter/BukkitImplAdapter.java @@ -58,6 +58,7 @@ import java.util.Map; import java.util.OptionalInt; import java.util.Set; +import java.util.concurrent.CompletionStage; import javax.annotation.Nullable; /** @@ -281,6 +282,24 @@ default boolean regenerate(World world, Region region, Extent extent, RegenOptio throw new UnsupportedOperationException("This adapter does not support regeneration."); } + /** + * Regenerate a region into the given extent without blocking the calling thread. + * + *

Used on Folia, where chunk generation cannot be awaited on a region thread. The extent is + * written on an arbitrary tick thread once every chunk has generated, so it must not depend on + * a particular thread. Temporary generation resources are released before the returned future + * completes.

+ * + * @param world the world to regen in + * @param region the region to regen + * @param extent the extent to use for setting blocks + * @param options the regeneration options + * @return a future that completes once the extent has been written + */ + default CompletionStage regenerateAsync(World world, Region region, Extent extent, RegenOptions options) { + throw new UnsupportedOperationException("This adapter does not support asynchronous regeneration."); + } + /** * Clears the contents of a Clearable block. * diff --git a/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/folia/FoliaRegeneration.java b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/folia/FoliaRegeneration.java new file mode 100644 index 0000000000..64c52d0d9a --- /dev/null +++ b/worldedit-bukkit/src/main/java/com/sk89q/worldedit/bukkit/folia/FoliaRegeneration.java @@ -0,0 +1,258 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * 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 com.sk89q.worldedit.bukkit.folia; + +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEditException; +import com.sk89q.worldedit.bukkit.BukkitPlayer; +import com.sk89q.worldedit.bukkit.BukkitWorld; +import com.sk89q.worldedit.bukkit.WorldEditPlugin; +import com.sk89q.worldedit.bukkit.adapter.BukkitImplAdapter; +import com.sk89q.worldedit.extension.platform.Actor; +import com.sk89q.worldedit.extent.Extent; +import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard; +import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.function.operation.Operations; +import com.sk89q.worldedit.math.BlockVector2; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.regions.Region; +import com.sk89q.worldedit.regions.RegionOperationException; +import com.sk89q.worldedit.session.request.Request; +import com.sk89q.worldedit.util.formatting.text.TranslatableComponent; +import com.sk89q.worldedit.world.RegenOptions; +import org.bukkit.Bukkit; +import org.bukkit.World; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nullable; + +/** + * Runs {@code //regen} on Folia, where terrain generation cannot be awaited on a region thread. + * + *

The adapter generates the terrain into a detached snapshot. The chunks of the selection are then + * loaded and held with plugin tickets, the snapshot is written to the extent on the thread owning them, + * and the returned future completes on the actor's own scheduler. A clipboard is written directly by + * the adapter, since it never touches the world.

+ * + *

Temporary worlds are created and released on the global region thread, which also serialises + * the changes they make to the server's world map, and only one of them exists at a time. Applying a + * finished result may overlap with the next generation.

+ */ +public final class FoliaRegeneration { + + /** + * Freshly loaded chunks can take a few ticks to merge into the region applying the result. + */ + private static final int OWNERSHIP_ATTEMPTS = 40; + + /** + * Whether a temporary world currently exists. Only changed on the global region thread. + */ + private static volatile boolean generating; + + private FoliaRegeneration() { + } + + /** + * Regenerate the region without blocking the calling region thread. + * + * @param plugin the plugin to schedule with + * @param adapter the adapter generating the terrain + * @param world the world to regenerate in + * @param region the region to regenerate + * @param extent the extent to write the result to + * @param options the regeneration options + * @param actor the actor that requested the regeneration, or null for an API call without one + * @return a future completing with {@code true} on the actor's scheduler, or exceptionally on failure + */ + public static CompletionStage regenerate(WorldEditPlugin plugin, BukkitImplAdapter adapter, + BukkitWorld world, Region region, Extent extent, + RegenOptions options, @Nullable Actor actor) { + CompletableFuture result = new CompletableFuture<>(); + if (extent instanceof EditSession editSession && editSession.getBlockBag() != null) { + // The block bag reads the player's inventory, which may be in another region by then. + result.completeExceptionally( + new RegionOperationException(TranslatableComponent.of("worldedit.regen.inventory-unsupported"))); + return result; + } + boolean direct = extent instanceof Clipboard; + BlockArrayClipboard snapshot = direct ? null : new BlockArrayClipboard(region); + Extent generated = direct ? extent : snapshot; + Bukkit.getGlobalRegionScheduler().execute(plugin, () -> { + if (generating) { + complete(plugin, actor, result, + new RegionOperationException(TranslatableComponent.of("worldedit.regen.busy"))); + return; + } + generating = true; + CompletionStage generation; + try { + generation = adapter.regenerateAsync(world.getWorld(), region, generated, options); + } catch (Exception e) { + generation = CompletableFuture.failedFuture(e); + } + generation.whenComplete((_, error) -> { + // The adapter completes this on the global region thread, after releasing its temporary world. + generating = false; + if (error != null || direct) { + complete(plugin, actor, result, error); + return; + } + Job job = new Job(plugin, world, region, snapshot, extent, options, actor, result, + ConcurrentHashMap.newKeySet()); + loadChunks(job).whenComplete((_, loadError) -> { + if (loadError != null) { + finish(job, loadError); + } else { + applyWhenOwned(job, 0); + } + }); + }); + }); + return result; + } + + private static CompletionStage loadChunks(Job job) { + World world = job.world().getWorld(); + List> loads = new ArrayList<>(); + for (BlockVector2 chunk : job.region().getChunks()) { + loads.add(world.getChunkAtAsync(chunk.x(), chunk.z()).thenCompose(_ -> holdChunk(job, chunk))); + } + return CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)); + } + + private static CompletableFuture holdChunk(Job job, BlockVector2 chunk) { + World world = job.world().getWorld(); + CompletableFuture held = new CompletableFuture<>(); + // A plugin ticket loads the chunk synchronously if needed, so it is added on the thread owning the chunk. + Bukkit.getRegionScheduler().execute(job.plugin(), world, chunk.x(), chunk.z(), () -> { + try { + if (world.addPluginChunkTicket(chunk.x(), chunk.z(), job.plugin())) { + job.tickets().add(chunk); + } + held.complete(null); + } catch (Throwable t) { + held.completeExceptionally(t); + } + }); + return held; + } + + private static void applyWhenOwned(Job job, int attempt) { + World world = job.world().getWorld(); + BlockVector3 anchor = job.region().getMinimumPoint(); + Bukkit.getRegionScheduler().runDelayed(job.plugin(), world, anchor.x() >> 4, anchor.z() >> 4, _ -> { + if (!isOwned(world, job.region())) { + if (attempt < OWNERSHIP_ATTEMPTS) { + applyWhenOwned(job, attempt + 1); + } else { + finish(job, new RegionOperationException(TranslatableComponent.of("worldedit.regen.folia-region"))); + } + return; + } + Throwable failure = null; + try { + apply(job); + } catch (Exception e) { + failure = e; + } + finish(job, failure); + }, 1); + } + + private static boolean isOwned(World world, Region region) { + for (BlockVector2 chunk : region.getChunks()) { + if (!Bukkit.isOwnedByCurrentRegion(world, chunk.x(), chunk.z())) { + return false; + } + } + return true; + } + + private static void apply(Job job) throws WorldEditException { + Extent extent = job.extent(); + try { + // Masks parsed without an extent, such as the global mask, read it from the current request. + Request.runWithRequest(() -> { + Request.request().setWorld(job.world()); + if (extent instanceof EditSession editSession) { + Request.request().setEditSession(editSession); + } + try { + for (BlockVector3 position : job.region()) { + extent.setBlock(position, job.snapshot().getFullBlock(position)); + if (job.options().shouldRegenBiomes()) { + extent.setBiome(position, job.snapshot().getBiome(position)); + } + } + } catch (WorldEditException e) { + throw new CompletionException(e); + } finally { + // Flush here, as the thread the command returns to may not write to this region. + Operations.completeBlindly(extent.commit()); + } + }); + } catch (CompletionException e) { + if (e.getCause() instanceof WorldEditException cause) { + throw cause; + } + throw e; + } + } + + private static void finish(Job job, @Nullable Throwable failure) { + World world = job.world().getWorld(); + for (BlockVector2 chunk : job.tickets()) { + world.removePluginChunkTicket(chunk.x(), chunk.z(), job.plugin()); + } + complete(job.plugin(), job.actor(), job.result(), failure); + } + + private static void complete(WorldEditPlugin plugin, @Nullable Actor actor, CompletableFuture result, + @Nullable Throwable failure) { + Runnable completion = () -> { + if (failure == null) { + result.complete(true); + } else { + result.completeExceptionally(failure); + } + }; + if (actor instanceof BukkitPlayer player) { + // If the player is already gone, complete right here so that history is still recorded. + if (!player.getPlayer().getScheduler().execute(plugin, completion, completion, 1)) { + completion.run(); + } + } else { + // Other actors and API callers continue on the thread that finished the job. + completion.run(); + } + } + + private record Job(WorldEditPlugin plugin, BukkitWorld world, Region region, BlockArrayClipboard snapshot, + Extent extent, RegenOptions options, @Nullable Actor actor, + CompletableFuture result, Set tickets) { + } +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/command/RegionCommands.java b/worldedit-core/src/main/java/com/sk89q/worldedit/command/RegionCommands.java index e258169d62..2690bb2c69 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/command/RegionCommands.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/command/RegionCommands.java @@ -52,6 +52,7 @@ import com.sk89q.worldedit.internal.annotation.Offset; import com.sk89q.worldedit.internal.annotation.Selection; import com.sk89q.worldedit.internal.expression.ExpressionException; +import com.sk89q.worldedit.internal.util.LogManagerCompat; import com.sk89q.worldedit.internal.util.TransformUtil; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.math.Vector3; @@ -74,6 +75,7 @@ import com.sk89q.worldedit.world.RegenOptions; import com.sk89q.worldedit.world.World; import com.sk89q.worldedit.world.generation.TreeType; +import org.apache.logging.log4j.Logger; import org.enginehub.piston.annotation.Command; import org.enginehub.piston.annotation.CommandContainer; import org.enginehub.piston.annotation.param.Arg; @@ -82,6 +84,9 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import static com.sk89q.worldedit.command.util.Logging.LogMode.ALL; import static com.sk89q.worldedit.command.util.Logging.LogMode.ORIENTATION_REGION; @@ -97,6 +102,8 @@ @CommandContainer(superTypes = CommandPermissionsConditionGenerator.Registration.class) public class RegionCommands { + private static final Logger LOGGER = LogManagerCompat.getLogger(); + /** * Create a new instance. */ @@ -450,39 +457,69 @@ public int stack(Actor actor, World world, EditSession editSession, LocalSession ) @CommandPermissions("worldedit.regen") @Logging(REGION) - void regenerate(Actor actor, World world, LocalSession session, EditSession editSession, - @Selection Region region, + void regenerate(Actor actor, World world, LocalSession session, + @Selection Region selection, @Arg(desc = "The seed to regenerate with, otherwise uses world seed", def = "") Long seed, @Switch(name = 'b', desc = "Regenerate biomes as well") boolean regenBiomes, @Switch(name = 'c', desc = "Regenerate to the clipboard") boolean toClipboard) throws WorldEditException { + // Regeneration may complete after this command has returned, so it gets its own edit session. + Region region = selection.clone(); + RegenOptions options = RegenOptions.builder() + .seed(seed) + .regenBiomes(regenBiomes) + .build(); + BlockArrayClipboard clipboard = toClipboard ? new BlockArrayClipboard(region) : null; + EditSession editSession = toClipboard ? null : session.createEditSession(actor); + if (clipboard != null) { + clipboard.setOrigin(session.getPlacementPosition(actor)); + } else { + editSession.enableStandardMode(); + } + Extent outputExtent = clipboard != null ? clipboard : editSession; Mask mask = session.getMask(); - boolean success; + CompletionStage result; try { session.setMask(null); - RegenOptions options = RegenOptions.builder() - .seed(seed) - .regenBiomes(regenBiomes) - .build(); - Extent outputExtent = editSession; - BlockArrayClipboard clipboard = null; - if (toClipboard) { - clipboard = new BlockArrayClipboard(region); - clipboard.setOrigin(session.getPlacementPosition(actor)); - outputExtent = clipboard; - } - success = world.regenerate(region, outputExtent, options); - if (success && toClipboard) { - session.setClipboard(new ClipboardHolder(clipboard)); - } + result = world.regenerateAsync(region, outputExtent, options, actor); + } catch (RuntimeException e) { + result = CompletableFuture.failedFuture(e); } finally { session.setMask(mask); } - if (success) { - actor.printInfo(TranslatableComponent.of("worldedit.regen.regenerated")); + if (!result.toCompletableFuture().isDone()) { + actor.printInfo(TranslatableComponent.of("worldedit.regen.generating")); + } + result.whenComplete((success, failure) -> { + if (editSession != null) { + session.remember(editSession); + editSession.close(); + WorldEdit.getInstance().flushBlockBag(actor, editSession); + } + if (failure != null) { + reportRegenerationFailure(actor, failure); + } else if (success) { + if (clipboard != null) { + session.setClipboard(new ClipboardHolder(clipboard)); + } + actor.printInfo(TranslatableComponent.of("worldedit.regen.regenerated")); + } else { + actor.printError(TranslatableComponent.of("worldedit.regen.failed")); + } + }); + } + + private static void reportRegenerationFailure(Actor actor, Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + if (cause instanceof WorldEditException exception && exception.getRichMessage() != null) { + actor.printError(exception.getRichMessage()); } else { + LOGGER.warn("Regeneration failed.", cause); actor.printError(TranslatableComponent.of("worldedit.regen.failed")); } } diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/world/World.java b/worldedit-core/src/main/java/com/sk89q/worldedit/world/World.java index 7a1c3a742d..8de50d36c9 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/world/World.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/world/World.java @@ -24,6 +24,7 @@ import com.sk89q.worldedit.WorldEditException; import com.sk89q.worldedit.blocks.BaseItem; import com.sk89q.worldedit.blocks.BaseItemStack; +import com.sk89q.worldedit.extension.platform.Actor; import com.sk89q.worldedit.extension.platform.Platform; import com.sk89q.worldedit.extent.Extent; import com.sk89q.worldedit.function.mask.Mask; @@ -48,6 +49,8 @@ import java.nio.file.Path; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import javax.annotation.Nullable; /** @@ -267,6 +270,26 @@ default boolean regenerate(Region region, Extent extent, RegenOptions options) { + ") does not implement the general Extent variant of this method"); } + /** + * Regenerate the given region into the given extent, possibly without blocking the calling thread. + * + *

The default implementation simply calls {@link #regenerate(Region, Extent, RegenOptions)}. + * Platforms that cannot generate terrain on the calling thread override it instead. An override + * must write and flush the extent on a thread that is allowed to edit the region, loading the + * region's chunks if necessary, and must complete the returned future on the thread that the + * actor's commands run on, or on the writing thread when the actor has no thread of its own.

+ * + * @param region the region + * @param extent the {@link Extent} to write the regenerated blocks and biomes to + * @param options the regeneration options + * @param actor the actor that requested the regeneration, or null for an API call without one + * @return a future completing with whether regeneration was successful + */ + default CompletionStage regenerateAsync(Region region, Extent extent, RegenOptions options, + @Nullable Actor actor) { + return CompletableFuture.completedFuture(regenerate(region, extent, options)); + } + /** * Generate a tree at the given position. * diff --git a/worldedit-core/src/main/resources/lang/strings.json b/worldedit-core/src/main/resources/lang/strings.json index 6ff2131bd0..d4ea9a766f 100644 --- a/worldedit-core/src/main/resources/lang/strings.json +++ b/worldedit-core/src/main/resources/lang/strings.json @@ -227,6 +227,10 @@ "worldedit.replace.replaced": "{0} blocks have been replaced.", "worldedit.stack.changed": "{0} blocks changed. Undo with //undo", "worldedit.stack.intersecting-region": "Stack offset must not collide with the region when using block units", + "worldedit.regen.folia-region": "On Folia, the whole selection must end up in a single region. Try again, or use a smaller selection.", + "worldedit.regen.inventory-unsupported": "Regeneration on Folia does not support inventory usage. Disable inventory usage or regenerate to the clipboard with -c.", + "worldedit.regen.busy": "Another regeneration is still generating terrain. Try again in a moment.", + "worldedit.regen.generating": "Generating terrain...", "worldedit.regen.regenerated": "Region regenerated.", "worldedit.regen.failed": "Unable to regenerate chunks. Check console for details.", "worldedit.walls.changed": "{0} blocks have been changed.",