diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandStructure.java b/core/src/main/java/art/arcane/iris/core/commands/CommandStructure.java index 8ec197613..628affd98 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandStructure.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandStructure.java @@ -20,6 +20,7 @@ package art.arcane.iris.core.commands; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.structure.BulkStructureImporter; +import art.arcane.iris.core.structure.StructureCaptureImporter; import art.arcane.iris.core.structure.StructureImporter; import art.arcane.iris.core.structure.StructureIndexService; import art.arcane.iris.engine.framework.Engine; @@ -86,12 +87,27 @@ public class CommandStructure implements DirectorExecutor { BulkStructureImporter.Report jigsaws = BulkStructureImporter.importAllVanilla(data, StructureImporter.Mode.OVERWRITE, true, sender()); BulkStructureImporter.Report templates = BulkStructureImporter.importAllTemplates(data, StructureImporter.Mode.OVERWRITE, sender()); BulkStructureImporter.Report groups = BulkStructureImporter.importTemplateGroups(data, StructureImporter.Mode.OVERWRITE, sender()); - int imported = jigsaws.imported() + templates.imported() + groups.imported(); - int failed = jigsaws.failed() + templates.failed() + groups.failed(); + StructureCaptureImporter.Report captured = StructureCaptureImporter.importAllStructures(data, StructureImporter.Mode.OVERWRITE, sender()); + int imported = jigsaws.imported() + templates.imported() + groups.imported() + captured.imported(); + int failed = jigsaws.failed() + templates.failed() + groups.failed() + captured.failed(); sender().sendMessage(C.GREEN + "Import complete: " + C.WHITE + imported + C.GREEN + " structures/objects written, " + C.WHITE + failed + C.GREEN + " failed."); sender().sendMessage(C.GRAY + "Reference them from a biome/region/dimension 'structures' list, or run /iris structure list " + dimension.getLoadKey() + " to refresh the index. Regenerate chunks for changes to take effect."); } + @Director(name = "capture", description = "Capture code-generated structures that have no NBT template (swamp huts, igloos, etc.) into editable Iris objects by generating each one in a throwaway scratch world and reading back its blocks. Skips structures that already import as a structure, structures wider/taller than the capture cap (strongholds, mansions, monuments stay vanilla), and anything that will not generate in a flat overworld. Each captured structure becomes a single-piece Iris structure you can place from a biome/region/dimension 'structures' list. Runs automatically as the last pass of /iris structure import.", aliases = {"cap"}, origin = DirectorOrigin.BOTH) + public void capture( + @Param(description = "The dimension whose pack to capture into", aliases = "dim") + IrisDimension dimension + ) { + IrisData data = dimension.getLoader(); + if (data == null) { + sender().sendMessage(C.RED + "Could not resolve the pack for dimension " + dimension.getLoadKey()); + return; + } + StructureCaptureImporter.Report report = StructureCaptureImporter.importAllStructures(data, StructureImporter.Mode.OVERWRITE, sender()); + sender().sendMessage(C.GRAY + "Captured " + report.imported() + " structures. Place them from a 'structures' list and regenerate chunks. Delete a structures/*.json to re-capture it."); + } + @Director(description = "Locate every vanilla/datapack/iris structure to verify which are locatable in this world. Heavy synchronous search per structure - keep the radius modest. 'Not found' can mean rarer than the radius, a different dimension (nether/end structures never appear in the overworld), or a structure that generates but cannot be located; generation itself happens during chunk decoration, independent of locate.", aliases = {"locateall"}, origin = DirectorOrigin.BOTH, sync = true) public void verify( @Param(description = "The dimension to verify", aliases = "dim") diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 26698011a..625198a84 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -25,6 +25,9 @@ import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.service.StudioSVC; +import art.arcane.iris.core.structure.BulkStructureImporter; +import art.arcane.iris.core.structure.FeatureImporter; +import art.arcane.iris.core.structure.StructureImporter; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.*; @@ -105,6 +108,44 @@ public class CommandStudio implements DirectorExecutor { Iris.service(StudioSVC.class).open(sender(), seed, dimension.getLoadKey()); } + @Director(description = "Import vanilla trees, mushrooms & objects (and structures/jigsaw) from the server into a pack's objects/vanilla folder", aliases = {"importv", "iv"}, origin = DirectorOrigin.BOTH) + public void importvanilla( + @Param(description = "The dimension pack to import vanilla content into", aliases = {"pack", "dim"}, customHandler = DimensionHandler.class) + IrisDimension dimension, + @Param(description = "How many variants to capture per tree/object feature", defaultValue = "3") + int variants, + @Param(description = "Also import vanilla & datapack structures/jigsaw into the pack", defaultValue = "true") + boolean structures + ) { + if (dimension == null) { + sender().sendMessage(C.RED + "Provide a dimension pack: /iris std importvanilla pack="); + return; + } + IrisData data = dimension.getLoader(); + if (data == null) { + sender().sendMessage(C.RED + "Could not resolve the pack for dimension " + dimension.getLoadKey()); + return; + } + + VolmitSender sender = sender(); + sender.sendMessage(C.GREEN + "Importing vanilla content into " + C.WHITE + dimension.getLoadKey() + C.GREEN + "..."); + + FeatureImporter.Report features = FeatureImporter.importAllObjectFeatures(data, variants, sender); + int imported = features.imported(); + int failed = features.failed(); + + if (structures) { + BulkStructureImporter.Report jigsaws = BulkStructureImporter.importAllVanilla(data, StructureImporter.Mode.OVERWRITE, true, sender); + BulkStructureImporter.Report templates = BulkStructureImporter.importAllTemplates(data, StructureImporter.Mode.OVERWRITE, sender); + BulkStructureImporter.Report groups = BulkStructureImporter.importTemplateGroups(data, StructureImporter.Mode.OVERWRITE, sender); + imported += jigsaws.imported() + templates.imported() + groups.imported(); + failed += jigsaws.failed() + templates.failed() + groups.failed(); + } + + sender.sendMessage(C.GREEN + "importvanilla complete: " + C.WHITE + imported + C.GREEN + " objects/structures written, " + C.WHITE + failed + C.GREEN + " failed."); + sender.sendMessage(C.GRAY + "Trees/objects are under objects/vanilla/...; reference them from biome object placements."); + } + @Director(description = "Open VSCode for a dimension", aliases = {"vsc"}) public void vscode( @Param(defaultValue = "default", description = "The dimension to open VSCode for", aliases = "dim", customHandler = DimensionHandler.class) 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 78ec058a8..8eee0ee3f 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 @@ -109,6 +109,22 @@ public interface INMSBinding { return new KList<>(); } + default KList getObjectFeatureKeys() { + return new KList<>(); + } + + default boolean placeFeature(World world, int x, int y, int z, String featureKey, long seed) { + throw new UnsupportedOperationException("The active NMS binding does not support feature placement."); + } + + default int[] placeStructure(World world, int chunkX, int chunkZ, String structureKey, long seed, int maxSpan) { + throw new UnsupportedOperationException("The active NMS binding does not support structure placement."); + } + + default boolean supportsStructureCapture() { + return false; + } + boolean isBukkit(); int getBiomeId(Biome biome); diff --git a/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java new file mode 100644 index 000000000..c92e960e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java @@ -0,0 +1,341 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.tools.PlausibilizeMode; +import art.arcane.iris.core.tools.TreePlausibilizer; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.io.IO; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.WorldType; +import org.bukkit.block.Block; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +public final class FeatureImporter { + public record Report(int total, int imported, int skipped, int failed) { + } + + private static final String SCRATCH_WORLD_NAME = "iris_vanilla_import"; + private static final int CAPTURE_RADIUS = 16; + private static final int CAPTURE_HEIGHT = 40; + private static final int CELL_STRIDE = 48; + private static final int CELL_COLUMNS = 16; + private static final int PLACE_ATTEMPTS = 6; + private static final long REGION_TIMEOUT_SECONDS = 30L; + + private FeatureImporter() { + } + + public static Report importAllObjectFeatures(IrisData data, int variants, VolmitSender sender) { + int wantVariants = Math.max(1, variants); + + List rows = parseRows(INMS.get().getObjectFeatureKeys()); + int total = rows.size(); + if (total == 0) { + sender.sendMessage(C.YELLOW + "No vanilla tree/object features are exposed by the active NMS binding (importing structures only)."); + return new Report(0, 0, 0, 0); + } + + sender.sendMessage(C.GREEN + "Importing " + C.WHITE + total + C.GREEN + " vanilla tree/object features (" + C.WHITE + wantVariants + C.GREEN + " variants each) into a scratch world..."); + + World scratch = createScratchWorld(sender); + if (scratch == null) { + return new Report(total, 0, 0, total); + } + + int imported = 0; + int skipped = 0; + int failed = 0; + int cellIndex = 0; + + try { + for (Row row : rows) { + try { + int written = 0; + Set hashes = new HashSet<>(); + for (int v = 0; v < wantVariants; v++) { + CaptureResult capture = captureOne(scratch, row.key(), v, cellIndex++); + if (capture == null || !capture.placed() || capture.object() == null) { + continue; + } + IrisObject object = capture.object(); + if (object.getBlocks().isEmpty()) { + continue; + } + object.shrinkwrap(); + if (row.group().equals("trees") || row.group().equals("fallen_trees")) { + try { + TreePlausibilizer.apply(object, PlausibilizeMode.NORMALIZE, TreePlausibilizer.DEFAULT_SHELL_RADIUS); + } catch (Throwable e) { + Iris.reportError(e); + } + } + long hash = hashOf(object); + if (!hashes.add(hash)) { + continue; + } + write(objectFile(data, row.group(), row.safeName(), written), object); + written++; + } + + if (written > 0) { + imported++; + sender.sendMessage(C.GRAY + "[obj] " + row.key() + " -> objects/vanilla/" + row.group() + "/" + row.safeName() + " (" + written + ")"); + } else { + skipped++; + sender.sendMessage(C.YELLOW + "[skip] " + row.key() + ": feature placed nothing after retries."); + } + } catch (Throwable e) { + failed++; + sender.sendMessage(C.RED + "[fail] " + row.key() + ": " + e.getMessage()); + Iris.reportError(e); + } + + int processed = imported + skipped + failed; + if (processed % 25 == 0) { + sender.sendMessage(C.GRAY + "..." + processed + "/" + total + " (" + imported + " imported, " + skipped + " skipped, " + failed + " failed)"); + } + } + } finally { + destroyScratchWorld(scratch, sender); + } + + sender.sendMessage(C.GREEN + "Feature import complete: " + C.WHITE + imported + C.GREEN + " features written, " + C.WHITE + skipped + C.GREEN + " skipped, " + C.WHITE + failed + C.GREEN + " failed (" + C.WHITE + total + C.GREEN + " total)."); + return new Report(total, imported, skipped, failed); + } + + private static List parseRows(KList raw) { + List rows = new ArrayList<>(); + if (raw == null) { + return rows; + } + for (String entry : raw) { + if (entry == null) { + continue; + } + int pipe = entry.indexOf('|'); + if (pipe <= 0 || pipe >= entry.length() - 1) { + continue; + } + String group = entry.substring(0, pipe); + String key = entry.substring(pipe + 1); + if (key.isBlank()) { + continue; + } + rows.add(new Row(group, key, BulkStructureImporter.templateNameFor(key))); + } + rows.sort(Comparator.comparing(r -> r.group() + "/" + r.safeName())); + return rows; + } + + private static CaptureResult captureOne(World world, String featureKey, int variant, int cellIndex) { + int originX = (cellIndex % CELL_COLUMNS) * CELL_STRIDE; + int originZ = (cellIndex / CELL_COLUMNS) * CELL_STRIDE; + int centerX = originX + CELL_STRIDE / 2; + int centerZ = originZ + CELL_STRIDE / 2; + int anchorChunkX = centerX >> 4; + int anchorChunkZ = centerZ >> 4; + + AtomicReference objectRef = new AtomicReference<>(); + AtomicBoolean placedRef = new AtomicBoolean(false); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + boolean scheduled = J.runRegion(world, anchorChunkX, anchorChunkZ, () -> { + try { + int chunkMinX = (centerX - CAPTURE_RADIUS) >> 4; + int chunkMaxX = (centerX + CAPTURE_RADIUS) >> 4; + int chunkMinZ = (centerZ - CAPTURE_RADIUS) >> 4; + int chunkMaxZ = (centerZ + CAPTURE_RADIUS) >> 4; + for (int cx = chunkMinX; cx <= chunkMaxX; cx++) { + for (int cz = chunkMinZ; cz <= chunkMaxZ; cz++) { + world.getChunkAt(cx, cz); + } + } + + int baseY = world.getHighestBlockYAt(centerX, centerZ) + 1; + + boolean placed = false; + for (int attempt = 0; attempt < PLACE_ATTEMPTS; attempt++) { + if (INMS.get().placeFeature(world, centerX, baseY, centerZ, featureKey, featureSeed(featureKey, variant, attempt))) { + placed = true; + break; + } + } + placedRef.set(placed); + + if (placed) { + int width = CAPTURE_RADIUS * 2 + 1; + int depth = CAPTURE_RADIUS * 2 + 1; + int minX = centerX - CAPTURE_RADIUS; + int minZ = centerZ - CAPTURE_RADIUS; + IrisObject object = new IrisObject(width, CAPTURE_HEIGHT, depth); + for (int dx = 0; dx < width; dx++) { + for (int dy = 0; dy < CAPTURE_HEIGHT; dy++) { + for (int dz = 0; dz < depth; dz++) { + Block block = world.getBlockAt(minX + dx, baseY + dy, minZ + dz); + if (block.getType() == Material.AIR) { + continue; + } + object.setUnsigned(dx, dy, dz, block, false); + } + } + } + objectRef.set(object); + } + + for (int cx = chunkMinX; cx <= chunkMaxX; cx++) { + for (int cz = chunkMinZ; cz <= chunkMaxZ; cz++) { + try { + world.unloadChunk(cx, cz, false); + } catch (Throwable ignored) { + } + } + } + } catch (Throwable e) { + errorRef.set(e); + } finally { + latch.countDown(); + } + }); + + if (!scheduled) { + return null; + } + + try { + if (!latch.await(REGION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + + if (errorRef.get() != null) { + Iris.reportError(errorRef.get()); + return null; + } + return new CaptureResult(placedRef.get(), objectRef.get()); + } + + private static long featureSeed(String key, int variant, int attempt) { + long h = key.hashCode() & 0xFFFFFFFFL; + h = h * 0x9E3779B97F4A7C15L + variant * 0x100000001B3L + attempt * 31L + 0x5DEECE66DL; + return h; + } + + private static long hashOf(IrisObject object) { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + object.write(out); + byte[] bytes = out.toByteArray(); + long h = 1125899906842597L; + for (byte b : bytes) { + h = 31 * h + b; + } + return h; + } catch (Throwable e) { + return object.getW() * 73856093L + object.getH() * 19349663L + object.getD(); + } + } + + private static File objectFile(IrisData data, String group, String safeName, int writtenIndex) { + String suffix = writtenIndex == 0 ? "" : "_" + (writtenIndex + 1); + return new File(data.getDataFolder(), "objects/vanilla/" + group + "/" + safeName + suffix + ".iob"); + } + + private static void write(File file, IrisObject object) throws IOException { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + object.write(file); + } + + static World createScratchWorld(VolmitSender sender) { + try { + World existing = Bukkit.getWorld(SCRATCH_WORLD_NAME); + if (existing != null) { + return existing; + } + WorldCreator creator = new WorldCreator(SCRATCH_WORLD_NAME) + .environment(World.Environment.NORMAL) + .type(WorldType.FLAT) + .generateStructures(false); + return J.sfut(() -> INMS.get().createWorldAsync(creator)) + .thenCompose(Function.identity()) + .get(); + } catch (Throwable e) { + Iris.reportError(e); + sender.sendMessage(C.RED + "Could not create the scratch world for feature import (" + e.getMessage() + "); skipping the tree/object pass."); + return null; + } + } + + static void destroyScratchWorld(World world, VolmitSender sender) { + if (world == null) { + return; + } + File folder = world.getWorldFolder(); + try { + J.sfut(() -> { + Bukkit.unloadWorld(world, false); + return Boolean.TRUE; + }).get(); + } catch (Throwable e) { + Iris.reportError(e); + } + try { + if (folder != null && folder.exists()) { + IO.delete(folder); + } + } catch (Throwable e) { + Iris.reportError(e); + } + } + + private record Row(String group, String key, String safeName) { + } + + private record CaptureResult(boolean placed, IrisObject object) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java new file mode 100644 index 000000000..3d96611b7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java @@ -0,0 +1,208 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; + +import java.io.File; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public final class StructureCaptureImporter { + public record Report(int total, int imported, int skipped, int failed) { + } + + private static final int MAX_SPAN = 48; + private static final int CELL_STRIDE = 128; + private static final int CELL_COLUMNS = 8; + private static final long REGION_TIMEOUT_SECONDS = 30L; + + private StructureCaptureImporter() { + } + + public static Report importAllStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { + if (!INMS.get().supportsStructureCapture()) { + sender.sendMessage(C.YELLOW + "Structure capture is not supported by the active NMS binding; skipping the capture pass."); + return new Report(0, 0, 0, 0); + } + + KList keys = INMS.get().getStructureKeys(); + if (keys == null || keys.isEmpty()) { + return new Report(0, 0, 0, 0); + } + + KList targets = new KList<>(); + for (String key : keys) { + if (key == null || key.isEmpty()) { + continue; + } + String name = StructureImporter.deriveName(key); + File structureFile = new File(data.getDataFolder(), "structures/" + name + ".json"); + if (structureFile.exists()) { + continue; + } + targets.add(key); + } + + int total = targets.size(); + if (total == 0) { + sender.sendMessage(C.GRAY + "No code-generated structures left to capture (everything is already imported as a structure)."); + return new Report(0, 0, 0, 0); + } + + sender.sendMessage(C.GREEN + "Capturing " + C.WHITE + total + C.GREEN + " code-generated structures (no NBT template) into a scratch world (skipping any wider/taller than " + MAX_SPAN + " blocks)..."); + + World scratch = FeatureImporter.createScratchWorld(sender); + if (scratch == null) { + return new Report(total, 0, 0, total); + } + + int imported = 0; + int skipped = 0; + int failed = 0; + int cellIndex = 0; + + try { + for (String key : targets) { + String name = StructureImporter.deriveName(key); + try { + IrisObject object = captureOne(scratch, key, cellIndex++); + if (object == null || object.getBlocks().isEmpty()) { + skipped++; + sender.sendMessage(C.YELLOW + "[skip] " + key + ": did not place a capturable structure here (too large, wrong dimension, or no valid placement in a flat world). Stays vanilla-generated."); + continue; + } + object.shrinkwrap(); + int span = Math.max(object.getW(), object.getD()); + File objectFile = new File(data.getDataFolder(), "objects/" + name + ".iob"); + objectFile.getParentFile().mkdirs(); + object.write(objectFile); + StructureImporter.writeSinglePieceStructure(data, name, key, span, "CENTER_HEIGHT"); + imported++; + sender.sendMessage(C.GRAY + "[capture] " + key + " -> objects/" + name + ".iob (" + object.getW() + "x" + object.getH() + "x" + object.getD() + ")"); + } catch (Throwable e) { + failed++; + sender.sendMessage(C.RED + "[fail] " + key + ": " + e.getMessage()); + Iris.reportError(e); + } + + int processed = imported + skipped + failed; + if (processed % 10 == 0) { + sender.sendMessage(C.GRAY + "..." + processed + "/" + total + " (" + imported + " captured, " + skipped + " skipped, " + failed + " failed)"); + } + } + } finally { + FeatureImporter.destroyScratchWorld(scratch, sender); + } + + sender.sendMessage(C.GREEN + "Structure capture complete: " + C.WHITE + imported + C.GREEN + " captured, " + C.WHITE + skipped + C.GREEN + " skipped, " + C.WHITE + failed + C.GREEN + " failed (" + C.WHITE + total + C.GREEN + " total)."); + return new Report(total, imported, skipped, failed); + } + + private static IrisObject captureOne(World world, String key, int cellIndex) { + int originX = (cellIndex % CELL_COLUMNS) * CELL_STRIDE; + int originZ = (cellIndex / CELL_COLUMNS) * CELL_STRIDE; + int anchorChunkX = (originX + CELL_STRIDE / 2) >> 4; + int anchorChunkZ = (originZ + CELL_STRIDE / 2) >> 4; + long seed = key.hashCode() * 0x9E3779B97F4A7C15L + cellIndex * 0x100000001B3L + 0x5DEECE66DL; + + AtomicReference objectRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + boolean scheduled = J.runRegion(world, anchorChunkX, anchorChunkZ, () -> { + try { + int[] box = INMS.get().placeStructure(world, anchorChunkX, anchorChunkZ, key, seed, MAX_SPAN); + if (box == null) { + return; + } + int width = box[3] - box[0] + 1; + int height = box[4] - box[1] + 1; + int depth = box[5] - box[2] + 1; + if (width <= 0 || height <= 0 || depth <= 0) { + return; + } + IrisObject object = new IrisObject(width, height, depth); + boolean any = false; + for (int dx = 0; dx < width; dx++) { + for (int dy = 0; dy < height; dy++) { + for (int dz = 0; dz < depth; dz++) { + Block block = world.getBlockAt(box[0] + dx, box[1] + dy, box[2] + dz); + if (block.getType() == Material.AIR) { + continue; + } + object.setUnsigned(dx, dy, dz, block, false); + any = true; + } + } + } + if (any) { + objectRef.set(object); + } + + int minCX = box[0] >> 4; + int maxCX = box[3] >> 4; + int minCZ = box[2] >> 4; + int maxCZ = box[5] >> 4; + for (int cx = minCX; cx <= maxCX; cx++) { + for (int cz = minCZ; cz <= maxCZ; cz++) { + try { + world.unloadChunk(cx, cz, false); + } catch (Throwable ignored) { + } + } + } + } catch (Throwable e) { + errorRef.set(e); + } finally { + latch.countDown(); + } + }); + + if (!scheduled) { + return null; + } + + try { + if (!latch.await(REGION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + + if (errorRef.get() != null) { + Iris.reportError(errorRef.get()); + return null; + } + return objectRef.get(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java index da87a40dc..03ee1d0ea 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java @@ -299,6 +299,16 @@ public final class StructureImporter { return pool; } + public static void writeSinglePieceStructure(IrisData data, String name, String vanillaSource, int maxSpan, String placeMode) throws Exception { + writeJson(new File(data.getDataFolder(), "jigsaw-pieces/" + name + ".json"), pieceJson(name)); + writeJson(new File(data.getDataFolder(), "jigsaw-pools/" + name + ".json"), poolJson(name)); + Map structure = structureJson(name, vanillaSource, maxSpan); + if (placeMode != null && !placeMode.isEmpty()) { + structure.put("placeMode", placeMode); + } + writeJson(new File(data.getDataFolder(), "structures/" + name + ".json"), structure); + } + private static Map structureJson(String name, String source, int maxSpan) { Map root = new LinkedHashMap<>(); root.put("startPool", name); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index 77aa748da..480e97197 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -49,7 +49,7 @@ import art.arcane.volmlib.util.math.RNG; public class IrisStructureComponent extends IrisMantleComponent { private static final long MAX_BORE_VOLUME = 6_000_000L; private static final long MAX_OVERBORE_VOLUME = 48_000_000L; - private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 0); + private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 3); public IrisStructureComponent(EngineMantle engineMantle) { super(engineMantle, ReservedFlag.JIGSAW, 3); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java index d3d32b2df..1e77a87d4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java @@ -56,6 +56,10 @@ public class IrisImportedStructureControl { @Desc("When true (the default), ingested datapacks generate normally: a datapack that redefines a VANILLA structure key (e.g. an ancient-city datapack overriding 'minecraft:ancient_city') REPLACES the vanilla placement, structure definition, jigsaw pools and pieces exactly as the datapack intends, and the datapack's OWN new structures generate too. When false, datapack structures do NOT generate at all - Iris strips the vanilla-key overrides from the installed datapack copy so the original vanilla structures generate untouched, and holds every datapack-namespaced structure out of natural generation so it never appears over or beside the vanilla one. Datapacks stay installed and importable either way, so when false the only way to get a datapack structure is to run '/iris structure import' and place it manually from a biome/region/dimension 'structures' list. Resolved globally across every loaded pack: if ANY dimension sets this false, vanilla-key overrides are stripped for every installed datapack.") private boolean datapackOverrides = true; + @ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1) + @Desc("Per-structure transforms applied to the vanilla & datapack structures that still generate natively. Each entry translates the matched structure by (xShift, yShift, zShift). Use this to relocate structures you do not own through the Iris 'structures' placement system - e.g. push 'minecraft:stronghold' down 64 blocks. Multiple matching entries stack, and any underground shift here adds on top of 'undergroundYShift'. A structure suppressed by an Iris placement is unaffected (Iris already controls its position).") + private KList adjustments = new KList<>(); + public boolean active() { return mode == VanillaStructureMode.ALL_ON || !enabled.isEmpty(); } @@ -70,6 +74,20 @@ public class IrisImportedStructureControl { return matches(enabled, key); } + public int[] resolveOffset(String key, boolean undergroundStep) { + int x = 0; + int y = undergroundStep ? undergroundYShift : 0; + int z = 0; + for (IrisVanillaStructureAdjustment adjustment : adjustments) { + if (adjustment != null && adjustment.matches(key)) { + x += adjustment.getXShift(); + y += adjustment.getYShift(); + z += adjustment.getZShift(); + } + } + return new int[]{x, y, z}; + } + private static boolean isDatapackKey(String key) { return key != null && key.contains(":") && !key.startsWith("minecraft:"); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java new file mode 100644 index 000000000..641b026c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java @@ -0,0 +1,72 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; +import art.arcane.volmlib.util.collection.KList; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A per-structure transform applied to vanilla & datapack structures that still generate natively (those NOT suppressed by an Iris 'structures' placement). Every block the structure writes is translated by (xShift, yShift, zShift). Use it to relocate a structure you do not control through the Iris placement system - e.g. push 'minecraft:stronghold' down 64 blocks when your terrain sits lower than vanilla's. Listed under the dimension's importedStructures 'adjustments'.") +@Data +public class IrisVanillaStructureAdjustment { + @ArrayType(type = String.class, min = 1) + @RegistryListVanillaStructure + @Desc("Structure keys this adjustment applies to, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' adjusts every village variant and 'minecraft:ruined_portal' adjusts every ruined portal. Empty matches nothing.") + private KList match = new KList<>(); + + @MinNumber(-512) + @MaxNumber(512) + @Desc("Vertical block offset. Negative pushes the structure down, positive lifts it. Applied to every block the structure places.") + private int yShift = 0; + + @MinNumber(-512) + @MaxNumber(512) + @Desc("East/west block offset (positive = +X). Keep small; large horizontal shifts move the structure far from the position vanilla chose for it.") + private int xShift = 0; + + @MinNumber(-512) + @MaxNumber(512) + @Desc("North/south block offset (positive = +Z). Keep small; large horizontal shifts move the structure far from the position vanilla chose for it.") + private int zShift = 0; + + public boolean matches(String key) { + if (key == null) { + return false; + } + for (String entry : match) { + if (entry == null || entry.isEmpty()) { + continue; + } + if (key.equals(entry) || key.startsWith(entry)) { + return true; + } + } + return false; + } +} diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java index b8d5ce5ca..2b582e504 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java @@ -235,7 +235,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; IrisImportedStructureControl control = importedControl(); - int undergroundShift = control.getUndergroundYShift(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.getOrDefault(step, List.of())) { @@ -243,11 +242,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator { String structureId = id == null ? null : id.toString(); if (control.shouldGenerate(structureId) && !IrisStructureLocator.suppressesVanilla(engine, structureId)) { random.setFeatureSeed(decoSeed, index, step); - int yShift = (undergroundShift != 0 && isUndergroundStep(structure.step())) ? undergroundShift : 0; - WorldGenLevel target = yShift == 0 ? world : yShiftedLevel(world, yShift); + int[] offset = control.resolveOffset(structureId, isUndergroundStep(structure.step())); + boolean shifted = offset[0] != 0 || offset[1] != 0 || offset[2] != 0; + WorldGenLevel target = shifted ? shiftedLevel(world, offset[0], offset[1], offset[2]) : world; + BoundingBox placeArea = shifted + ? new BoundingBox(area.minX() - offset[0], area.minY(), area.minZ() - offset[2], area.maxX() - offset[0], area.maxY(), area.maxZ() - offset[2]) + : area; try { structureManager.startsForStructure(sectionPos, structure) - .forEach(start -> start.placeInChunk(target, structureManager, this, random, area, chunkPos)); + .forEach(start -> start.placeInChunk(target, structureManager, this, random, placeArea, chunkPos)); } catch (Throwable e) { Iris.reportError(e); } @@ -262,7 +265,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { || step == GenerationStep.Decoration.STRONGHOLDS; } - private WorldGenLevel yShiftedLevel(WorldGenLevel world, int yShift) { + private WorldGenLevel shiftedLevel(WorldGenLevel world, int dx, int dy, int dz) { return (WorldGenLevel) Proxy.newProxyInstance( WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, @@ -270,7 +273,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (args != null) { for (int i = 0; i < args.length; i++) { if (args[i] instanceof BlockPos bp) { - args[i] = new BlockPos(bp.getX(), bp.getY() + yShift, bp.getZ()); + args[i] = new BlockPos(bp.getX() + dx, bp.getY() + dy, bp.getZ() + dz); } } } diff --git a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/IrisChunkGenerator.java b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/IrisChunkGenerator.java index cb432d0c7..59532c319 100644 --- a/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/IrisChunkGenerator.java +++ b/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/IrisChunkGenerator.java @@ -235,7 +235,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; IrisImportedStructureControl control = importedControl(); - int undergroundShift = control.getUndergroundYShift(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.getOrDefault(step, List.of())) { @@ -243,11 +242,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator { String structureId = id == null ? null : id.toString(); if (control.shouldGenerate(structureId) && !IrisStructureLocator.suppressesVanilla(engine, structureId)) { random.setFeatureSeed(decoSeed, index, step); - int yShift = (undergroundShift != 0 && isUndergroundStep(structure.step())) ? undergroundShift : 0; - WorldGenLevel target = yShift == 0 ? world : yShiftedLevel(world, yShift); + int[] offset = control.resolveOffset(structureId, isUndergroundStep(structure.step())); + boolean shifted = offset[0] != 0 || offset[1] != 0 || offset[2] != 0; + WorldGenLevel target = shifted ? shiftedLevel(world, offset[0], offset[1], offset[2]) : world; + BoundingBox placeArea = shifted + ? new BoundingBox(area.minX() - offset[0], area.minY(), area.minZ() - offset[2], area.maxX() - offset[0], area.maxY(), area.maxZ() - offset[2]) + : area; try { structureManager.startsForStructure(sectionPos, structure) - .forEach(start -> start.placeInChunk(target, structureManager, this, random, area, chunkPos)); + .forEach(start -> start.placeInChunk(target, structureManager, this, random, placeArea, chunkPos)); } catch (Throwable e) { Iris.reportError(e); } @@ -262,7 +265,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { || step == GenerationStep.Decoration.STRONGHOLDS; } - private WorldGenLevel yShiftedLevel(WorldGenLevel world, int yShift) { + private WorldGenLevel shiftedLevel(WorldGenLevel world, int dx, int dy, int dz) { return (WorldGenLevel) Proxy.newProxyInstance( WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, @@ -270,7 +273,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (args != null) { for (int i = 0; i < args.length; i++) { if (args[i] instanceof BlockPos bp) { - args[i] = new BlockPos(bp.getX(), bp.getY() + yShift, bp.getZ()); + args[i] = new BlockPos(bp.getX() + dx, bp.getY() + dy, bp.getZ() + dz); } } } 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 ce3934b25..f83c350df 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 @@ -59,6 +59,13 @@ 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.WorldgenRandom; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; +import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.FallenTreeFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.TreeFeature; import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; import net.minecraft.world.level.storage.LevelStorageSource; @@ -453,6 +460,129 @@ public class NMSBinding implements INMSBinding { return keys; } + @Override + public KList getObjectFeatureKeys() { + KList keys = new KList<>(); + try { + Registry> reg = registry().lookupOrThrow(Registries.CONFIGURED_FEATURE); + for (Identifier id : reg.keySet()) { + ConfiguredFeature cf = reg.getValue(id); + if (cf == null) { + continue; + } + String group = classifyFeature(cf.feature()); + if (group == null) { + continue; + } + keys.add(group + "|" + id); + } + } catch (Throwable e) { + Iris.reportError(e); + } + return keys; + } + + private static String classifyFeature(Feature feature) { + if (feature instanceof TreeFeature) { + return "trees"; + } + if (feature instanceof FallenTreeFeature) { + return "fallen_trees"; + } + if (feature instanceof AbstractHugeMushroomFeature) { + return "mushrooms"; + } + return null; + } + + @Override + public boolean placeFeature(World world, int x, int y, int z, String featureKey, long seed) { + try { + ServerLevel level = ((CraftWorld) world).getHandle(); + net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator(); + Registry> reg = registry().lookupOrThrow(Registries.CONFIGURED_FEATURE); + ConfiguredFeature cf = reg.getValue(Identifier.parse(featureKey)); + if (cf == null) { + return false; + } + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(seed)); + return cf.place(level, generator, random, new BlockPos(x, y, z)); + } catch (Throwable e) { + Iris.reportError(e); + return false; + } + } + + @Override + public int[] placeStructure(World world, int chunkX, int chunkZ, String structureKey, long seed, int maxSpan) { + try { + ServerLevel level = ((CraftWorld) world).getHandle(); + net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator(); + Registry reg = registry().lookupOrThrow(Registries.STRUCTURE); + net.minecraft.world.level.levelgen.structure.Structure structure = reg.getValue(Identifier.parse(structureKey)); + if (structure == null) { + return null; + } + net.minecraft.core.Holder holder = reg.wrapAsHolder(structure); + net.minecraft.world.level.levelgen.RandomState randomState = level.getChunkSource().randomState(); + net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager templateManager = level.getStructureManager(); + net.minecraft.world.level.StructureManager structureManager = level.structureManager(); + net.minecraft.world.level.biome.BiomeSource biomeSource = generator.getBiomeSource(); + net.minecraft.world.level.ChunkPos chunkPos = new net.minecraft.world.level.ChunkPos(chunkX, chunkZ); + + net.minecraft.world.level.levelgen.structure.StructureStart start = structure.generate( + holder, + level.dimension(), + level.registryAccess(), + generator, + biomeSource, + randomState, + templateManager, + seed, + chunkPos, + 0, + level, + biome -> true); + + if (start == null || !start.isValid()) { + return null; + } + + net.minecraft.world.level.levelgen.structure.BoundingBox box = start.getBoundingBox(); + int spanX = box.maxX() - box.minX() + 1; + int spanY = box.maxY() - box.minY() + 1; + int spanZ = box.maxZ() - box.minZ() + 1; + if (maxSpan > 0 && (spanX > maxSpan || spanZ > maxSpan || spanY > maxSpan)) { + return null; + } + + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(seed)); + int minCX = box.minX() >> 4; + int maxCX = box.maxX() >> 4; + int minCZ = box.minZ() >> 4; + int maxCZ = box.maxZ() >> 4; + for (int cx = minCX; cx <= maxCX; cx++) { + for (int cz = minCZ; cz <= maxCZ; cz++) { + level.getChunk(cx, cz); + net.minecraft.world.level.ChunkPos cp = new net.minecraft.world.level.ChunkPos(cx, cz); + net.minecraft.world.level.levelgen.structure.BoundingBox chunkBox = new net.minecraft.world.level.levelgen.structure.BoundingBox( + cp.getMinBlockX(), box.minY(), cp.getMinBlockZ(), + cp.getMaxBlockX(), box.maxY(), cp.getMaxBlockZ()); + start.placeInChunk(level, structureManager, generator, random, chunkBox, cp); + } + } + return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()}; + } catch (Throwable e) { + Iris.reportError(e); + return null; + } + } + + @Override + public boolean supportsStructureCapture() { + return true; + } + @Override public boolean isBukkit() { return true;