diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index c5b26d645..5145c3153 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -200,6 +200,16 @@ public class IrisSettings { public int objectLoaderCacheSize = 4_096; public int tectonicPlateSize = -1; public int mantleCleanupDelay = 200; + public int heightBoundsInterpolationGrid = 4; + + public int getHeightBoundsInterpolationGrid() { + int grid = heightBoundsInterpolationGrid; + if (grid <= 1) { + return 1; + } + + return Math.min(16, Integer.highestOneBit(grid)); + } public int getTectonicPlateSize() { if (tectonicPlateSize > 0) diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index a07485b6f..645c01e87 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -109,6 +109,106 @@ public class CommandDeveloper implements DirectorExecutor { Iris.reportError(new Exception("This is a test")); } + @Director(description = "Hash generated block output of a fixed area for determinism/identity testing", origin = DirectorOrigin.BOTH) + public void genhash( + @Param(description = "The world to hash", contextual = true) + World world, + @Param(description = "Radius in chunks around the center", defaultValue = "4") + int radius, + @Param(description = "Center chunk X", defaultValue = "0") + int centerX, + @Param(description = "Center chunk Z", defaultValue = "0") + int centerZ) { + if (world == null) { + sender().sendMessage(C.RED + "World is null."); + return; + } + + VolmitSender sender = sender(); + sender.sendMessage(C.GREEN + "genhash started: " + ((radius * 2 + 1) * (radius * 2 + 1)) + " chunks..."); + J.a(() -> runGenhash(sender, world, radius, centerX, centerZ)); + } + + private void runGenhash(VolmitSender sender, World world, int radius, int centerX, int centerZ) { + long startMs = M.ms(); + int minY = world.getMinHeight(); + int maxY = world.getMaxHeight(); + long globalHash = 0L; + long solidBlocks = 0L; + Map histogram = new TreeMap<>(); + JsonObject chunkHashes = new JsonObject(); + + for (int rx = centerX - radius; rx <= centerX + radius; rx++) { + for (int rz = centerZ - radius; rz <= centerZ + radius; rz++) { + org.bukkit.ChunkSnapshot snapshot; + try { + org.bukkit.Chunk loaded = io.papermc.lib.PaperLib.getChunkAtAsync(world, rx, rz, true).get(); + snapshot = loaded.getChunkSnapshot(false, false, false); + } catch (Throwable e) { + Iris.reportError(e); + sender.sendMessage(C.RED + "genhash failed at chunk " + rx + "," + rz + ": " + e.getMessage()); + return; + } + long chunkHash = 0L; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int worldX = (rx << 4) + x; + int worldZ = (rz << 4) + z; + for (int y = minY; y < maxY; y++) { + org.bukkit.Material material = snapshot.getBlockType(x, y, z); + long positionSeed = ((long) worldX * 0x9E3779B97F4A7C15L) + ^ ((long) y * 0xC2B2AE3D27D4EB4FL) + ^ ((long) worldZ * 0x165667B19E3779F9L); + long blockHash = genHashMix(positionSeed ^ ((long) (material.ordinal() + 1) * 0xD6E8FEB86659FD93L)); + chunkHash ^= blockHash; + if (material != org.bukkit.Material.AIR + && material != org.bukkit.Material.CAVE_AIR + && material != org.bukkit.Material.VOID_AIR) { + histogram.merge(material.name(), 1L, Long::sum); + solidBlocks++; + } + } + } + } + globalHash ^= chunkHash; + chunkHashes.addProperty(rx + "," + rz, Long.toHexString(chunkHash)); + } + } + + int side = radius * 2 + 1; + JsonObject result = new JsonObject(); + result.addProperty("global", Long.toHexString(globalHash)); + result.addProperty("chunks", side * side); + result.addProperty("solidBlocks", solidBlocks); + result.addProperty("minY", minY); + result.addProperty("maxY", maxY); + JsonObject hist = new JsonObject(); + for (Map.Entry entry : histogram.entrySet()) { + hist.addProperty(entry.getKey(), entry.getValue()); + } + result.add("histogram", hist); + result.add("chunkHashes", chunkHashes); + + File out = new File(Iris.instance.getDataFolder(), "genhash.json"); + try { + Files.writeString(out.toPath(), result.toString()); + } catch (IOException e) { + Iris.reportError(e); + } + + sender.sendMessage(C.GREEN + "genhash global=" + C.GOLD + Long.toHexString(globalHash) + + C.GREEN + " chunks=" + (side * side) + " solid=" + solidBlocks + + " in " + Form.duration((long) (M.ms() - startMs), 1)); + Iris.info("genhash world=" + world.getName() + " global=" + Long.toHexString(globalHash) + + " chunks=" + (side * side) + " solidBlocks=" + solidBlocks + " -> " + out.getAbsolutePath()); + } + + private static long genHashMix(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + @Director(description = "QOL command to open an overworld studio world", sync = true) public void so() { sender().sendMessage(C.GREEN + "Opening studio for the \"Overworld\" pack (seed: 1337)"); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 6275b18ee..3ad852baf 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -205,7 +205,39 @@ public class IrisPregenerator { listener.onClose(); Mantle mantle = getMantle(); if (mantle != null) { + reclaimTectonicPlates(mantle); + } + } + + private void reclaimTectonicPlates(Mantle mantle) { + int loadedBefore = mantle.getLoadedRegionCount(); + if (loadedBefore <= 0) { + return; + } + + int previousLoaded = loadedBefore; + int stagnantRounds = 0; + for (int attempt = 0; attempt < 24 && stagnantRounds < 4; attempt++) { mantle.trim(0, 0); + mantle.unloadTectonicPlate(0); + int loaded = mantle.getLoadedRegionCount(); + if (loaded <= 0) { + break; + } + + if (loaded >= previousLoaded) { + stagnantRounds++; + } else { + stagnantRounds = 0; + } + + previousLoaded = loaded; + J.sleep(150); + } + + int loadedAfter = mantle.getLoadedRegionCount(); + if (loadedAfter < loadedBefore) { + Iris.info("Pregen reclaimed " + (loadedBefore - loadedAfter) + " tectonic plate(s) from the mantle"); } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 0b0eda8bb..407637bbb 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -368,11 +368,12 @@ public class AsyncPregenMethod implements PregeneratorMethod { } static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) { + int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); if (detectedWorkerPoolThreads > 0) { - return detectedWorkerPoolThreads; + return Math.max(detectedWorkerPoolThreads, provisionedWorkerThreads); } - return Math.max(1, Math.max(detectedCpuThreads, configuredWorldGenThreads)); + return Math.max(provisionedWorkerThreads, detectedCpuThreads); } static int computeFoliaRecommendedCap(int workerThreads) { diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index e4122e909..89896e740 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -716,10 +716,18 @@ public class SchemaBuilder { str.put("$ref", "#/definitions/" + key); if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); + JSONObject enumObj = new JSONObject(); JSONArray snl = new JSONArray(); data.getPossibleSnippets(snippet.value()).forEach(snl::put); - j.put("enum", snl); + enumObj.put("enum", snl); + JSONObject patternObj = new JSONObject(); + patternObj.put("type", "string"); + patternObj.put("pattern", "^snippet/" + snippet.value() + "/"); + JSONArray snippetAlt = new JSONArray(); + snippetAlt.put(enumObj); + snippetAlt.put(patternObj); + JSONObject j = new JSONObject(); + j.put("anyOf", snippetAlt); definitions.put(key, j); } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 29ef5902e..67a990c33 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -49,6 +49,8 @@ import java.util.*; public class IrisComplex implements DataProvider { private static final BlockData AIR = Material.AIR.createBlockData(); private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); + private static final int GRID_BOUNDS_CACHE_SIZE = 8192; + private static final ThreadLocal GRID_BOUNDS_CACHE = ThreadLocal.withInitial(GridBoundsCache::new); private RNG rng; private double fluidHeight; private IrisData data; @@ -316,15 +318,84 @@ public class IrisComplex implements DataProvider { return biome; } - private double interpolateGenerators(Engine engine, IrisInterpolator interpolator, Set generators, double x, double z, long seed) { + private double interpolateGenerators(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, Set generators, double x, double z, long seed) { if (generators.isEmpty()) { return 0; } + NoiseBounds sampledBounds = gridSampleBounds(engine, interpolator, interpolatorIndex, generators, x, z); + double hi = sampledBounds.max(); + double lo = sampledBounds.min(); + + double d = 0; + + for (IrisGenerator i : generators) { + d += M.lerp(lo, hi, i.getHeight(x, z, seed + 239945)); + } + + return d / generators.size(); + } + + private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, Set generators, double x, double z) { + int grid = IrisSettings.get().getPerformance().getHeightBoundsInterpolationGrid(); + if (grid <= 1) { + return sampleBoundsRaw(engine, interpolator, generators, x, z); + } + + int xi = (int) Math.floor(x); + int zi = (int) Math.floor(z); + int mask = grid - 1; + int gx = xi & ~mask; + int gz = zi & ~mask; + double fx = (x - gx) / grid; + double fz = (z - gz) / grid; + + GridBoundsCache cache = GRID_BOUNDS_CACHE.get(); + long b00 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz); + long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz); + long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid); + long b11 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz + grid); + + double lo = biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b01), boundsLow(b11), fx, fz); + double hi = biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b01), boundsHigh(b11), fx, fz); + return new NoiseBounds(lo, hi); + } + + private long cornerBounds(GridBoundsCache cache, Engine engine, IrisInterpolator interpolator, int interpolatorIndex, Set generators, int gx, int gz) { + int slot = cache.slot(gx, gz, interpolatorIndex); + if (cache.valid[slot] && cache.gx[slot] == gx && cache.gz[slot] == gz && cache.idx[slot] == interpolatorIndex) { + return cache.packed[slot]; + } + + NoiseBounds bounds = sampleBoundsRaw(engine, interpolator, generators, gx, gz); + long packed = (((long) Float.floatToRawIntBits((float) bounds.min())) << 32) | (Float.floatToRawIntBits((float) bounds.max()) & 0xFFFFFFFFL); + cache.gx[slot] = gx; + cache.gz[slot] = gz; + cache.idx[slot] = interpolatorIndex; + cache.packed[slot] = packed; + cache.valid[slot] = true; + return packed; + } + + private static double boundsLow(long packed) { + return Float.intBitsToFloat((int) (packed >>> 32)); + } + + private static double boundsHigh(long packed) { + return Float.intBitsToFloat((int) (packed & 0xFFFFFFFFL)); + } + + private static double biLerp(double v00, double v10, double v01, double v11, double fx, double fz) { + double a = v00 + ((v10 - v00) * fx); + double b = v01 + ((v11 - v01) * fx); + return a + ((b - a) * fz); + } + + private NoiseBounds sampleBoundsRaw(Engine engine, IrisInterpolator interpolator, Set generators, double x, double z) { CoordinateBiomeCache sampleCache = new CoordinateBiomeCache(64); IdentityHashMap cachedBounds = generatorBounds.get(interpolator); IdentityHashMap localBounds = new IdentityHashMap<>(8); - NoiseBounds sampledBounds = interpolator.interpolateBounds(x, z, (xx, zz) -> { + return interpolator.interpolateBounds(x, z, (xx, zz) -> { try { IrisBiome bx = sampleCache.get(xx, zz); if (bx == null) { @@ -342,24 +413,15 @@ public class IrisComplex implements DataProvider { return ZERO_NOISE_BOUNDS; }); - - double hi = sampledBounds.max(); - double lo = sampledBounds.min(); - - double d = 0; - - for (IrisGenerator i : generators) { - d += M.lerp(lo, hi, i.getHeight(x, z, seed + 239945)); - } - - return d / generators.size(); } private double getInterpolatedHeight(Engine engine, double x, double z, long seed) { double h = 0; + int interpolatorIndex = 0; for (Map.Entry> entry : generators.entrySet()) { - h += interpolateGenerators(engine, entry.getKey(), entry.getValue(), x, z, seed); + h += interpolateGenerators(engine, entry.getKey(), interpolatorIndex, entry.getValue(), x, z, seed); + interpolatorIndex++; } return h; @@ -499,6 +561,20 @@ public class IrisComplex implements DataProvider { } } + private static final class GridBoundsCache { + private final int[] gx = new int[GRID_BOUNDS_CACHE_SIZE]; + private final int[] gz = new int[GRID_BOUNDS_CACHE_SIZE]; + private final int[] idx = new int[GRID_BOUNDS_CACHE_SIZE]; + private final long[] packed = new long[GRID_BOUNDS_CACHE_SIZE]; + private final boolean[] valid = new boolean[GRID_BOUNDS_CACHE_SIZE]; + + private int slot(int cornerX, int cornerZ, int interpolatorIndex) { + long h = (cornerX * 0x9E3779B97F4A7C15L) ^ (cornerZ * 0xC2B2AE3D27D4EB4FL) ^ (interpolatorIndex * 0x165667B19E3779F9L); + h ^= (h >>> 32); + return (int) (h & (GRID_BOUNDS_CACHE_SIZE - 1)); + } + } + private static class CoordinateBiomeCache { private long[] xBits; private long[] zBits; 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 c8ee6ed38..be1447de0 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 @@ -19,6 +19,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.Iris; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.PlacedStructurePiece; @@ -37,15 +38,20 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.StructurePlacementRoute; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.data.BlockData; @ComponentFlag(ReservedFlag.JIGSAW) 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 BlockData CARVE_AIR = B.get("CAVE_AIR"); public IrisStructureComponent(EngineMantle engineMantle) { super(engineMantle, ReservedFlag.JIGSAW, 1); @@ -119,7 +125,9 @@ public class IrisStructureComponent extends IrisMantleComponent { return; } - if (placement.isBore()) { + if (placement.isOverbore()) { + overboreStructure(writer, pieces, placement.getOverboreRadius(), placement.getOverboreHeight(), placement.getOverboreFloor()); + } else if (placement.isBore()) { boreStructure(writer, pieces, placement.getBorePadding()); } @@ -143,6 +151,128 @@ public class IrisStructureComponent extends IrisMantleComponent { } private void boreStructure(MantleWriter writer, KList pieces, int padding) { + int[] bounds = computePieceBounds(pieces); + if (bounds == null) { + return; + } + int pad = Math.max(0, padding); + int minX = bounds[0] - pad; + int minY = bounds[1]; + int minZ = bounds[2] - pad; + int maxX = bounds[3] + pad; + int maxY = bounds[4] + pad; + int maxZ = bounds[5] + pad; + int worldMin = getEngineMantle().getEngine().getMinHeight() + 1; + int worldMax = getEngineMantle().getEngine().getMinHeight() + getEngineMantle().getEngine().getHeight() - 1; + minY = Math.max(minY, worldMin); + maxY = Math.min(maxY, worldMax); + if (maxX < minX || maxY < minY || maxZ < minZ) { + return; + } + long volume = (long) (maxX - minX + 1) * (long) (maxY - minY + 1) * (long) (maxZ - minZ + 1); + if (volume > MAX_BORE_VOLUME) { + Iris.warn("Skipping structure bore of " + volume + " blocks (cap " + MAX_BORE_VOLUME + "); use a smaller structure or larger spacing."); + return; + } + for (int bx = minX; bx <= maxX; bx++) { + for (int by = minY; by <= maxY; by++) { + for (int bz = minZ; bz <= maxZ; bz++) { + writer.set(bx, by, bz, CARVE_AIR); + } + } + } + } + + private void overboreStructure(MantleWriter writer, KList pieces, int radius, int ceiling, int floorDepth) { + int[] bounds = computePieceBounds(pieces); + if (bounds == null) { + return; + } + int r = Math.max(0, radius); + int boxMinX = bounds[0]; + int boxMinZ = bounds[2]; + int boxMaxX = bounds[3]; + int boxMaxZ = bounds[5]; + int worldMin = getEngineMantle().getEngine().getMinHeight() + 1; + int worldMax = getEngineMantle().getEngine().getMinHeight() + getEngineMantle().getEngine().getHeight() - 1; + int floorY = Math.max(worldMin, bounds[1] - Math.max(0, floorDepth)); + int apexY = Math.min(worldMax, bounds[4] + Math.max(0, ceiling)); + if (apexY < floorY) { + return; + } + int expMinX = boxMinX - r; + int expMaxX = boxMaxX + r; + int expMinZ = boxMinZ - r; + int expMaxZ = boxMaxZ + r; + long volume = (long) (expMaxX - expMinX + 1) * (long) (apexY - floorY + 1) * (long) (expMaxZ - expMinZ + 1); + if (volume > MAX_OVERBORE_VOLUME) { + Iris.warn("Skipping structure overbore of " + volume + " blocks (cap " + MAX_OVERBORE_VOLUME + "); reduce overboreRadius or use larger spacing."); + return; + } + int span = apexY - floorY; + double rr = r <= 0 ? 1.0 : (double) r; + + RNG noiseRng = new RNG(seed() + Cache.key(boxMinX, boxMinZ)); + CNG outlineNoise = CNG.signature(noiseRng); + CNG ceilNoise = CNG.signature(noiseRng.nextParallelRNG(0x51E10)); + CNG floorNoise = CNG.signature(noiseRng.nextParallelRNG(0xF1009)); + double outlineFreq = 0.045; + double ceilFreq = 0.055; + double floorFreq = 0.07; + int floorAmp = Math.min(5, Math.max(1, span / 8)); + double ceilLump = Math.min(4.0, span * 0.15); + + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Overbore carving cavern: box=[" + boxMinX + "," + floorY + "," + boxMinZ + " -> " + boxMaxX + "," + apexY + "," + boxMaxZ + "] radius=" + r + " volume=" + volume); + } + + BlockData air = CARVE_AIR; + for (int bx = expMinX; bx <= expMaxX; bx++) { + int dx = bx < boxMinX ? boxMinX - bx : bx > boxMaxX ? bx - boxMaxX : 0; + for (int bz = expMinZ; bz <= expMaxZ; bz++) { + int dz = bz < boxMinZ ? boxMinZ - bz : bz > boxMaxZ ? bz - boxMaxZ : 0; + int floorYcol = floorY + (int) Math.round(floorNoise.fitDouble(-1.0, 1.0, bx * floorFreq, bz * floorFreq) * floorAmp); + if (floorYcol < worldMin) { + floorYcol = worldMin; + } + int columnCeil; + if (dx == 0 && dz == 0) { + int bump = (int) Math.round(ceilNoise.fitDouble(0.0, 1.0, bx * ceilFreq, bz * ceilFreq) * ceilLump); + columnCeil = Math.min(worldMax, apexY - Math.max(0, bump)); + } else { + double dist = Math.sqrt((double) dx * dx + (double) dz * dz); + double outline = outlineNoise.fitDouble(-1.0, 1.0, bx * outlineFreq, bz * outlineFreq); + double rEff = rr * (0.80 + 0.35 * outline); + if (rEff < 1.0) { + rEff = 1.0; + } + if (dist > rEff) { + continue; + } + double t = dist / rEff; + double dome = Math.sqrt(Math.max(0.0, 1.0 - t * t)); + double lump = ceilNoise.fitDouble(-1.0, 1.0, bx * ceilFreq, bz * ceilFreq); + double mix = dome * (0.80 + 0.40 * lump); + if (mix < 0.0) { + mix = 0.0; + } + columnCeil = floorYcol + (int) Math.round(span * mix); + if (columnCeil <= floorYcol) { + continue; + } + columnCeil = Math.min(worldMax, columnCeil); + } + for (int by = floorYcol; by <= columnCeil; by++) { + writer.set(bx, by, bz, air); + } + } + } + } + + private int[] computePieceBounds(KList pieces) { + if (pieces == null || pieces.isEmpty()) { + return null; + } int minX = Integer.MAX_VALUE; int minY = Integer.MAX_VALUE; int minZ = Integer.MAX_VALUE; @@ -157,32 +287,7 @@ public class IrisStructureComponent extends IrisMantleComponent { maxY = Math.max(maxY, p.getMaxY()); maxZ = Math.max(maxZ, p.getMaxZ()); } - int pad = Math.max(0, padding); - minX -= pad; - minZ -= pad; - maxX += pad; - maxZ += pad; - maxY += pad; - int worldMin = getEngineMantle().getEngine().getMinHeight() + 1; - int worldMax = getEngineMantle().getEngine().getMinHeight() + getEngineMantle().getEngine().getHeight() - 1; - minY = Math.max(minY, worldMin); - maxY = Math.min(maxY, worldMax); - if (maxX < minX || maxY < minY || maxZ < minZ) { - return; - } - long volume = (long) (maxX - minX + 1) * (long) (maxY - minY + 1) * (long) (maxZ - minZ + 1); - if (volume > MAX_BORE_VOLUME) { - Iris.warn("Skipping structure bore of " + volume + " blocks (cap " + MAX_BORE_VOLUME + "); use a smaller structure or larger spacing."); - return; - } - org.bukkit.block.data.BlockData air = org.bukkit.Material.AIR.createBlockData(); - for (int bx = minX; bx <= maxX; bx++) { - for (int by = minY; by <= maxY; by++) { - for (int bz = minZ; bz <= maxZ; bz++) { - writer.set(bx, by, bz, air); - } - } - } + return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; } private void placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng) { @@ -200,29 +305,31 @@ public class IrisStructureComponent extends IrisMantleComponent { @Override protected int computeRadius() { IrisDimension dimension = getDimension(); - int maxChunks = 0; + int maxBlocks = 0; for (IrisRegion region : dimension.getAllRegions(this::getData)) { - maxChunks = Math.max(maxChunks, maxFrom(region.getStructures())); + maxBlocks = Math.max(maxBlocks, maxBlocksFrom(region.getStructures())); } for (IrisBiome biome : dimension.getAllBiomes(this::getData)) { - maxChunks = Math.max(maxChunks, maxFrom(biome.getStructures())); + maxBlocks = Math.max(maxBlocks, maxBlocksFrom(biome.getStructures())); } - maxChunks = Math.max(maxChunks, maxFrom(dimension.getStructures())); + maxBlocks = Math.max(maxBlocks, maxBlocksFrom(dimension.getStructures())); - return maxChunks * 16; + return maxBlocks; } - private int maxFrom(KList placements) { + private int maxBlocksFrom(KList placements) { int max = 0; for (IrisStructurePlacement placement : placements) { if (placement.getRoute() == StructurePlacementRoute.NATIVE_AT_POINT) { continue; } + int carvePadding = placement.isOverbore() ? Math.max(0, placement.getOverboreRadius()) + : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; for (String key : placement.getStructures()) { IrisStructure structure = art.arcane.iris.core.loader.IrisData.loadAnyStructure(key, getData()); if (structure != null) { - max = Math.max(max, structure.getMaxSizeChunks()); + max = Math.max(max, Math.max(1, structure.getMaxSizeChunks()) * 16 + carvePadding); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 6c6e25973..d329e0da5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -153,7 +153,6 @@ public class IrisBiome extends IrisRegistrant implements IRare { @ArrayType(type = IrisBiomePaletteLayer.class) @Desc("This defines the layers of materials in this biome. Each layer has a palette and min/max height and some other properties. Usually a grassy/sandy layer then a dirt layer then a stone layer. Iris will fill in the remaining blocks below your layers with stone.") private KList layers = new KList().qadd(new IrisBiomePaletteLayer()); - @Required @ArrayType(type = IrisBiomePaletteLayer.class) @Desc("This defines the layers of materials in this biome. Each layer has a palette and min/max height and some other properties. Usually a grassy/sandy layer then a dirt layer then a stone layer. Iris will fill in the remaining blocks below your layers with stone.") private KList caveCeilingLayers = new KList().qadd(new IrisBiomePaletteLayer()); @@ -174,7 +173,6 @@ public class IrisBiome extends IrisRegistrant implements IRare { private KList floatingChildBiomes = new KList<>(); @Desc("When true, every floating child entry is sampled independently and their solid masks are unioned, so multiple floating islands can stack, overlap, and collide instead of the picker choosing only one child per column.") private boolean mergeFloatingChildBiomes = false; - @Required @ArrayType(min = 1, type = IrisBiomeGeneratorLink.class) @Desc("Generators for this biome. Multiple generators with different interpolation sizes will mix with other biomes how you would expect. This defines your biome height relative to the fluid height. Use negative for oceans.") private KList generators = new KList().qadd(new IrisBiomeGeneratorLink()); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 1a35751bd..119a0862d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -44,6 +44,7 @@ import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.iris.util.common.scheduling.jobs.Job; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.util.project.stream.ProceduralStream; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -77,6 +78,7 @@ import java.util.stream.StreamSupport; public class IrisObject extends IrisRegistrant { protected static final Vector HALF = new Vector(0.5, 0.5, 0.5); protected static final BlockData AIR = B.get("CAVE_AIR"); + protected static final BlockData STONE = B.get("STONE"); protected static final BlockData VAIR = B.get("VOID_AIR"); protected static final BlockData VAIR_DEBUG = B.get("COBWEB"); protected static final BlockData[] SNOW_LAYERS = new BlockData[]{B.get("minecraft:snow[layers=1]"), B.get("minecraft:snow[layers=2]"), B.get("minecraft:snow[layers=3]"), B.get("minecraft:snow[layers=4]"), B.get("minecraft:snow[layers=5]"), B.get("minecraft:snow[layers=6]"), B.get("minecraft:snow[layers=7]"), B.get("minecraft:snow[layers=8]")}; @@ -695,6 +697,8 @@ public class IrisObject extends IrisRegistrant { boolean organicFloor = config.getMode() == ObjectPlaceMode.ORGANIC_STILT; boolean ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG; boolean organic = organicFloor || ceilingHang; + boolean vacuuming = config.getMode() == ObjectPlaceMode.VACUUM || config.getMode() == ObjectPlaceMode.VACUUM_HIGH + || config.getMode() == ObjectPlaceMode.VACUUM_FAST || config.getMode() == ObjectPlaceMode.VACUUM_ORGANIC; boolean stilting = (config.getMode().equals(ObjectPlaceMode.STILT) || config.getMode().equals(ObjectPlaceMode.FAST_STILT) || config.getMode() == ObjectPlaceMode.MIN_STILT || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT || config.getMode() == ObjectPlaceMode.CENTER_STILT || config.getMode() == ObjectPlaceMode.ERODE_STILT || organic); @@ -721,7 +725,7 @@ public class IrisObject extends IrisRegistrant { } } else if (yv < 0) { if (config.getMode().equals(ObjectPlaceMode.CENTER_HEIGHT) || config.getMode() == ObjectPlaceMode.CENTER_STILT - || organic) { + || organic || vacuuming) { y = (c != null ? c.getSurface() : placer.getHighest(x, z, getLoader(), config.isUnderwater())) + rty; if (!config.isForcePlace()) { if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { @@ -875,6 +879,19 @@ public class IrisObject extends IrisRegistrant { return -1; } + if (yv < 0 + && !config.isForcePlace() + && !config.isFromBottom() + && config.getMode() != ObjectPlaceMode.FLOATING + && !rawStructurePiece + && !stilting + && !vacuuming + && config.getCarvingSupport().supportsSurface() + && y > 0 + && lacksFootprintSupport(placer, config, x, y, z, spinx, spiny, spinz)) { + return -1; + } + if (yv < 0 && !config.getMode().equals(ObjectPlaceMode.FLOATING) && !rawStructurePiece) { if (!config.isForcePlace() && !config.isUnderwater() && !config.isOnwater() && placer.isUnderwater(x, z)) { return -1; @@ -928,6 +945,7 @@ public class IrisObject extends IrisRegistrant { int lowest = Integer.MAX_VALUE; int topLayer = Integer.MIN_VALUE; + int vacuumLowest = Integer.MAX_VALUE; y += yrand; readLock.lock(); @@ -1094,6 +1112,9 @@ public class IrisObject extends IrisRegistrant { if (tile != null) { placer.setTile(xx, yy, zz, tile); } + if (vacuuming && yy < vacuumLowest) { + vacuumLowest = yy; + } } } } catch (Throwable e) { @@ -1324,6 +1345,15 @@ public class IrisObject extends IrisRegistrant { readLock.unlock(); } + if (vacuuming && vacuumLowest != Integer.MAX_VALUE && placer.getEngine() != null) { + BlockVector rotDim = config.getRotation().rotate(new BlockVector(getW(), getH(), getD()), spinx, spiny, spinz).clone(); + int halfW = Math.max(0, Math.abs(rotDim.getBlockX()) / 2); + int halfD = Math.max(0, Math.abs(rotDim.getBlockZ()) / 2); + int centerX = x + config.getTranslate().getX(); + int centerZ = z + config.getTranslate().getZ(); + vacuumTerrain(placer, config, centerX, centerZ, halfW, halfD, vacuumLowest); + } + if (heightmap != null) { RNG rngx = rng.nextParallelRNG(3468854); @@ -1360,6 +1390,78 @@ public class IrisObject extends IrisRegistrant { + "(forcePlace=false, fromBottom=false, mode!=FLOATING). Skipping to protect bedrock."); } + private void vacuumTerrain(IObjectPlacer placer, IrisObjectPlacement config, int centerX, int centerZ, int halfW, int halfD, int baseY) { + ObjectPlaceMode mode = config.getMode(); + IrisVacuumSettings settings = config.getVacuumSettings(); + int radius; + int step; + switch (mode) { + case VACUUM_HIGH -> { + radius = 20; + step = 1; + } + case VACUUM_FAST -> { + radius = 8; + step = 2; + } + default -> { + radius = 12; + step = 1; + } + } + if (settings != null && settings.getRadius() > 0) { + radius = settings.getRadius(); + } + double falloff = settings != null ? Math.max(0.25, settings.getFalloff()) : 2.0; + int jitter = settings != null ? Math.max(0, settings.getOrganicJitter()) : 4; + boolean organicEdge = mode == ObjectPlaceMode.VACUUM_ORGANIC; + int meetY = baseY - 1; + + IrisComplex complex = placer.getEngine().getComplex(); + int worldMin = placer.getEngine().getMinHeight(); + int worldMax = worldMin + placer.getEngine().getHeight() - 1; + + for (int dx = -(halfW + radius); dx <= halfW + radius; dx += step) { + for (int dz = -(halfD + radius); dz <= halfD + radius; dz += step) { + int cx = centerX + dx; + int cz = centerZ + dz; + int outX = Math.max(0, Math.abs(dx) - halfW); + int outZ = Math.max(0, Math.abs(dz) - halfD); + double dist = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); + double effRadius = radius; + if (organicEdge && jitter > 0) { + long h = ((long) cx * 341873128712L) ^ ((long) cz * 132897987541L); + double n = ((Math.abs(h) % 1000) / 1000.0) - 0.5; + effRadius = Math.max(1.0, radius + (n * 2.0 * jitter)); + } + if (dist > effRadius) { + continue; + } + double t = 1.0 - (dist / effRadius); + if (t <= 0) { + continue; + } + double factor = Math.pow(t, falloff); + int origY = placer.getHighest(cx, cz, getLoader(), true); + int targetY = (int) Math.round(origY + ((meetY - origY) * factor)); + targetY = Math.max(worldMin + 1, Math.min(worldMax, targetY)); + if (targetY > origY) { + BlockData fill = complex != null ? complex.getRockStream().get(cx, cz) : null; + if (fill == null || B.isAir(fill)) { + fill = STONE; + } + for (int yy = origY + 1; yy <= targetY; yy++) { + placer.set(cx, yy, cz, fill); + } + } else if (targetY < origY) { + for (int yy = origY; yy > targetY; yy--) { + placer.set(cx, yy, cz, AIR); + } + } + } + } + } + private boolean shouldBailForCarvingAnchor(IObjectPlacer placer, IrisObjectPlacement placement, int x, int y, int z) { boolean carved = isCarvedAnchor(placer, x, y, z); CarvingMode carvingMode = placement.getCarvingSupport(); @@ -1370,6 +1472,28 @@ public class IrisObject extends IrisRegistrant { }; } + private boolean lacksFootprintSupport(IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z, int spinx, int spiny, int spinz) { + BlockVector rot = config.getRotation().rotate(new BlockVector(getW(), getH(), getD()), spinx, spiny, spinz).clone(); + int halfW = Math.max(0, Math.abs(rot.getBlockX()) / 2); + int halfD = Math.max(0, Math.abs(rot.getBlockZ()) / 2); + if (halfW == 0 && halfD == 0) { + return false; + } + int tx = config.getTranslate().getX(); + int tz = config.getTranslate().getZ(); + int cx = x + tx; + int cz = z + tz; + int[] sampleX = {cx, cx - halfW, cx + halfW, cx - halfW, cx + halfW}; + int[] sampleZ = {cz, cz - halfD, cz + halfD, cz + halfD, cz - halfD}; + for (int i = 0; i < sampleX.length; i++) { + int sh = placer.getHighest(sampleX[i], sampleZ[i], getLoader(), true); + if (isCarvedAnchor(placer, sampleX[i], sh, sampleZ[i])) { + return true; + } + } + return false; + } + private boolean isCarvedAnchor(IObjectPlacer placer, int x, int y, int z) { return placer.isCarved(x, y, z) || placer.isCarved(x, y - 1, z) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index 0be177637..f3660fba8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -85,6 +85,8 @@ public class IrisObjectPlacement { private IrisStyledRange densityStyle = null; @Desc("When stilting is enabled, this object will define various properties related to it.") private IrisStiltSettings stiltSettings; + @Desc("When a VACUUM place mode is used, this defines the deformation radius and falloff.") + private IrisVacuumSettings vacuumSettings; @MaxNumber(64) @MinNumber(0) @Desc("When bore is enabled, expand max-y of the cuboid it removes") @@ -178,6 +180,7 @@ public class IrisObjectPlacement { p.setBoreExtendMaxY(boreExtendMaxY); p.setBoreExtendMinY(boreExtendMinY); p.setStiltSettings(stiltSettings); + p.setVacuumSettings(vacuumSettings); p.setDensity(density); p.setDensityStyle(densityStyle); p.setChance(chance); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index ee6ccf223..3a85c87f8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -105,6 +105,24 @@ public class IrisStructurePlacement { @Desc("IRIS_PLACED only: extra blocks of air clearance added around the bored bounding box (horizontally and above) when bore=true. The floor is never bored below the structure so support is preserved.") private int borePadding = 0; + @Desc("IRIS_PLACED only: if true, massively carves the surrounding terrain into an open cavern around the structure instead of only clearing its tight bounding box. The structure's full interior is cleared and the surrounding terrain is excavated out to overboreRadius blocks, doming down to meet the ground at the edges so the structure is no longer buried in solid terrain. The mantle write window and the carve volume cap are expanded automatically to fit. Use for deep structures such as ancient cities so both their interior and surroundings are open and enterable. Takes precedence over bore when both are set.") + private boolean overbore = false; + + @MinNumber(0) + @MaxNumber(128) + @Desc("IRIS_PLACED only: when overbore=true, how many blocks of terrain to carve horizontally outward from the structure's bounding box. Larger values produce a wider open cavern around the structure. Larger values also widen the mantle write window for every chunk near the structure, so increase it deliberately.") + private int overboreRadius = 24; + + @MinNumber(0) + @MaxNumber(128) + @Desc("IRIS_PLACED only: when overbore=true, how many extra blocks of air to carve above the structure's roof at the cavern apex.") + private int overboreHeight = 8; + + @MinNumber(0) + @MaxNumber(64) + @Desc("IRIS_PLACED only: when overbore=true, how many blocks of terrain to carve below the structure's floor. 0 keeps the floor solid for support; small values recess the cavern floor around the structure.") + private int overboreFloor = 0; + @Desc("If false, this placement is skipped underwater.") private boolean underwater = false; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVacuumSettings.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVacuumSettings.java new file mode 100644 index 000000000..80ab1fd18 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVacuumSettings.java @@ -0,0 +1,31 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Snippet; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("vacuum-settings") +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("Defines how a VACUUM place mode bends terrain to meet an object's base.") +@Data +public class IrisVacuumSettings { + @MinNumber(0) + @MaxNumber(128) + @Desc("The horizontal radius (in blocks) the terrain deformation extends beyond the object footprint before it blends back to the natural surface. 0 = derive automatically from the place mode (VACUUM, VACUUM_HIGH larger, VACUUM_FAST smaller).") + private int radius = 0; + @MinNumber(0.25) + @MaxNumber(8) + @Desc("The falloff exponent controlling how the deformation eases out across the radius. 1 = linear (cone), 2 = parabolic (default, gentle bowl), higher = flatter near the object with a sharper drop at the edge.") + private double falloff = 2.0; + @MinNumber(0) + @MaxNumber(64) + @Desc("For VACUUM_ORGANIC: the maximum number of blocks the effective radius is randomly perturbed per column, giving the meeting edge an irregular organic outline instead of a clean circle.") + private int organicJitter = 4; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureControl.java index ca5582108..12c1436b7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureControl.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureControl.java @@ -20,6 +20,8 @@ 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; @@ -30,7 +32,7 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("Controls native vanilla & datapack structure generation for this dimension. The default mode is ALL_ON (everything generates).") +@Desc("Controls native vanilla & datapack structure generation for this dimension (set as the dimension's 'vanillaStructures' field). Default mode is ALL_ON (everything generates). Blacklist a few: mode=ALL_ON + list them in 'disabled'. Whitelist a few: mode=ALL_OFF + list them in 'enabled'. Key matching is exact OR prefix (an entry matches when the structure key equals it or starts with it), so 'minecraft:village' covers every village variant. Run '/iris structure list ' to dump every valid key. Only affects NEWLY generated chunks, and is separate from the IRIS_PLACED import system (imported structures are controlled by biome/region/dimension 'structures' placements, not here).") @Data public class IrisVanillaStructureControl { @Desc("Master toggle. ALL_ON generates every vanilla & datapack structure except those in 'disabled'. ALL_OFF (or CUSTOM) generates nothing except those in 'enabled'.") @@ -38,14 +40,19 @@ public class IrisVanillaStructureControl { @ArrayType(type = String.class, min = 1) @RegistryListVanillaStructure - @Desc("Structure keys to turn OFF while mode is ALL_ON, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village.") + @Desc("Structure keys to turn OFF while mode is ALL_ON, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village variant and 'minecraft:ruined_portal' disables every ruined portal. Ignored when mode is ALL_OFF.") private KList disabled = new KList<>(); @ArrayType(type = String.class, min = 1) @RegistryListVanillaStructure - @Desc("Structure keys to turn ON while mode is ALL_OFF (or CUSTOM), e.g. 'minecraft:village_plains'. A namespace:path prefix also matches.") + @Desc("Structure keys to turn ON while mode is ALL_OFF (or CUSTOM), e.g. 'minecraft:village_plains'. A namespace:path prefix also matches, so 'minecraft:village' enables every village variant. Ignored when mode is ALL_ON.") private KList enabled = new KList<>(); + @MinNumber(-512) + @MaxNumber(512) + @Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.") + private int undergroundYShift = 0; + public boolean active() { return mode == VanillaStructureMode.ALL_ON || !enabled.isEmpty(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/ObjectPlaceMode.java b/core/src/main/java/art/arcane/iris/engine/object/ObjectPlaceMode.java index 3632e2093..79fb9bc21 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/ObjectPlaceMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/ObjectPlaceMode.java @@ -74,6 +74,22 @@ public enum ObjectPlaceMode { CEILING_HANG, + @Desc("Anchors the object at the terrain surface (plus translate.y) like CENTER_HEIGHT, then bends the surrounding terrain to be flush with the object's base. Columns inside the object footprint are raised (or lowered) to meet the base, and the deformation falls off parabolically out to a radius so the terrain smoothly blends back to its natural height. Use this to seat a floating/translated object on the ground without a flat stilt disc, e.g. a cube translated up 10 on a mountain pulls the terrain up to meet it. Tune with vacuum-settings (radius, falloff).") + + VACUUM, + + @Desc("VACUUM with a larger radius and finer per-column sampling for the smoothest terrain blend. More expensive.") + + VACUUM_HIGH, + + @Desc("VACUUM with a smaller radius and coarser sampling for performance. Use when seating many objects.") + + VACUUM_FAST, + + @Desc("VACUUM whose falloff radius is perturbed by noise per column, so the terrain meets the object with an irregular organic edge rather than a clean parabolic bowl.") + + VACUUM_ORGANIC, + @Desc("Samples the height of the terrain at every x,z position of your object and pushes it down to the surface. It's pretty much like a melt function over the terrain.") PAINT, diff --git a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java b/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java index 8659df8a6..2121a684e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java @@ -22,12 +22,12 @@ import art.arcane.iris.engine.object.annotations.Desc; @Desc("Master toggle for vanilla & datapack structure generation in a dimension.") public enum VanillaStructureMode { - @Desc("All vanilla & datapack structures generate normally. This is the default. Per-set overrides may still disable individual sets.") + @Desc("All vanilla & datapack structures generate, except any keys listed in 'disabled'. This is the default. Use this to blacklist a few structures.") ALL_ON, - @Desc("All vanilla & datapack structures are suppressed. Per-set overrides may still enable individual sets.") + @Desc("No vanilla or datapack structures generate, except any keys listed in 'enabled'. Use this to whitelist a few structures.") ALL_OFF, - @Desc("Nothing generates unless explicitly enabled via a per-set override with enabled=true.") + @Desc("Same as ALL_OFF: nothing generates except keys listed in 'enabled'.") CUSTOM } diff --git a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java index 05ddf52e9..4bc6180f0 100644 --- a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java +++ b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java @@ -940,7 +940,41 @@ public class CNG { return applyPost(getNoise(x, z), x, z); } + private static final int COORD_CACHE_SIZE = 1 << 16; + private static final int COORD_CACHE_MASK = COORD_CACHE_SIZE - 1; + + private static final class CoordCache { + private final Object[] owner = new Object[COORD_CACHE_SIZE]; + private final long[] kx = new long[COORD_CACHE_SIZE]; + private final long[] kz = new long[COORD_CACHE_SIZE]; + private final double[] value = new double[COORD_CACHE_SIZE]; + } + + private static final ThreadLocal COORD_CACHE_2D = ThreadLocal.withInitial(CoordCache::new); + + private static int coordSlot(long a, long b, int salt) { + long h = (a * 0x9E3779B97F4A7C15L) ^ (b * 0xC2B2AE3D27D4EB4FL) ^ (salt * 0x165667B19E3779F9L); + h ^= (h >>> 32); + return (int) (h & COORD_CACHE_MASK); + } + public double noiseFastSigned2D(double x, double z) { + long kx = Double.doubleToRawLongBits(x); + long kz = Double.doubleToRawLongBits(z); + CoordCache cc = COORD_CACHE_2D.get(); + int slot = coordSlot(kx, kz, System.identityHashCode(this)); + if (cc.owner[slot] == this && cc.kx[slot] == kx && cc.kz[slot] == kz) { + return cc.value[slot]; + } + double computed = computeNoiseFastSigned2D(x, z); + cc.owner[slot] = this; + cc.kx[slot] = kx; + cc.kz[slot] = kz; + cc.value[slot] = computed; + return computed; + } + + private double computeNoiseFastSigned2D(double x, double z) { if (cache != null && isWholeCoordinate(x) && isWholeCoordinate(z)) { return (cache.get((int) x, (int) z) * 2D) - 1D; } @@ -964,12 +998,32 @@ public class CNG { return applyPost(getNoise(x, y, z), x, y, z); } - public double noiseFastSigned3D(double x, double y, double z) { - if (hasIdentitySignedFastPath()) { - return getSignedNoise(x, y, z); - } + private static final class CoordCache3D { + private final Object[] owner = new Object[COORD_CACHE_SIZE]; + private final long[] kx = new long[COORD_CACHE_SIZE]; + private final long[] ky = new long[COORD_CACHE_SIZE]; + private final long[] kz = new long[COORD_CACHE_SIZE]; + private final double[] value = new double[COORD_CACHE_SIZE]; + } - return (noiseFast3D(x, y, z) * 2D) - 1D; + private static final ThreadLocal COORD_CACHE_3D = ThreadLocal.withInitial(CoordCache3D::new); + + public double noiseFastSigned3D(double x, double y, double z) { + long kx = Double.doubleToRawLongBits(x); + long ky = Double.doubleToRawLongBits(y); + long kz = Double.doubleToRawLongBits(z); + CoordCache3D cc = COORD_CACHE_3D.get(); + int slot = coordSlot(kx ^ Long.rotateLeft(ky, 21), kz, System.identityHashCode(this)); + if (cc.owner[slot] == this && cc.kx[slot] == kx && cc.ky[slot] == ky && cc.kz[slot] == kz) { + return cc.value[slot]; + } + double computed = hasIdentitySignedFastPath() ? getSignedNoise(x, y, z) : (noiseFast3D(x, y, z) * 2D) - 1D; + cc.owner[slot] = this; + cc.kx[slot] = kx; + cc.ky[slot] = ky; + cc.kz[slot] = kz; + cc.value[slot] = computed; + return computed; } public CNG pow(double power) { diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index 1113b8403..64c1dde72 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -31,9 +31,11 @@ public class AsyncPregenMethodConcurrencyCapTest { } @Test - public void paperLikeConcurrencyUsesChunkWorkerPoolWhenAvailable() { - assertEquals(4, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32)); + public void paperLikeConcurrencyProvisionsForWorldGenThreadBump() { + assertEquals(32, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 32)); + assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, 16)); assertEquals(24, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 24)); + assertEquals(16, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(-1, 16, 8)); } @Test diff --git a/docs/reference/vanilla-structures.md b/docs/reference/vanilla-structures.md new file mode 100644 index 000000000..9904d7f7d --- /dev/null +++ b/docs/reference/vanilla-structures.md @@ -0,0 +1,70 @@ +# Controlling Vanilla & Datapack Structures + +How to turn vanilla (and datapack) structures on or off per dimension. + +## Where it lives + +Add a `vanillaStructures` block to a **dimension** file (`packs//dimensions/.json`). It is backed by `IrisVanillaStructureControl` and has three fields: + +| Field | Type | Meaning | +|-------|------|---------| +| `mode` | `ALL_ON` \| `ALL_OFF` \| `CUSTOM` | Master toggle. Default `ALL_ON`. | +| `disabled` | list of structure keys | Keys to turn **off** while `mode = ALL_ON`. Ignored otherwise. | +| `enabled` | list of structure keys | Keys to turn **on** while `mode = ALL_OFF` / `CUSTOM`. Ignored otherwise. | + +If you omit `vanillaStructures` entirely, the dimension behaves as `ALL_ON` with nothing disabled (every vanilla/datapack structure generates). + +## Blacklist a few (keep everything else) + +```json +"vanillaStructures": { + "mode": "ALL_ON", + "disabled": [ + "minecraft:stronghold", + "minecraft:mansion", + "minecraft:trial_chambers" + ] +} +``` + +Everything generates except those three. + +## Whitelist a few (turn everything else off) + +```json +"vanillaStructures": { + "mode": "ALL_OFF", + "enabled": [ + "minecraft:village_plains", + "minecraft:ancient_city" + ] +} +``` + +Only those generate. (`CUSTOM` behaves identically to `ALL_OFF`.) + +## Key matching: exact OR prefix + +An entry matches a structure when the structure key **equals** the entry **or starts with** it. This lets a partial key cover a whole family: + +| Entry | Matches | +|-------|---------| +| `minecraft:village_plains` | only the plains village | +| `minecraft:village` | every village variant (`village_plains`, `village_desert`, `village_savanna`, `village_snowy`, `village_taiga`) | +| `minecraft:ruined_portal` | every ruined-portal variant | +| `minecraft:` | every vanilla structure (rarely useful — prefer `mode: ALL_OFF`) | + +## Finding the keys + +Both `disabled` and `enabled` have editor autocomplete (the `@RegistryListVanillaStructure` schema hint). To dump the full live list (vanilla + any installed datapacks) into `packs//structures/structure-index.json`: + +``` +/iris structure list +``` + +## Notes & gotchas + +- **Only affects newly generated chunks.** Already-generated terrain keeps whatever structures it has — explore fresh chunks or regenerate to see changes. +- **Some structures are gated by biome.** Disabling is always honored, but *enabling* a structure (whitelist mode) still requires its biome to exist for it to actually place — e.g. an ocean monument needs deep ocean. +- **This is native vanilla control, separate from imported structures.** Structures you import (`/iris structure import-all`, `import-templates`) and place yourself via a biome/region/dimension `structures` placement (route `IRIS_PLACED`) are controlled by *that* placement, not by `vanillaStructures`. Disabling `minecraft:ancient_city` here does not affect an imported `minecraft_ancient_city` you placed via `IRIS_PLACED`. +- **`mode` only flips which list is read.** `disabled` is consulted only under `ALL_ON`; `enabled` only under `ALL_OFF`/`CUSTOM`. The unused list is ignored, so it's safe to keep both populated while you toggle `mode`. 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 2ede3548a..8f9c0bf93 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 @@ -46,7 +46,9 @@ import org.spigotmc.SpigotWorldConfig; import javax.annotation.Nullable; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -173,15 +175,18 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; IrisVanillaStructureControl control = vanillaControl(); + int undergroundShift = control.getUndergroundYShift(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.getOrDefault(step, List.of())) { Object id = registry.getKey(structure); if (control.shouldGenerate(id == null ? null : id.toString())) { random.setFeatureSeed(decoSeed, index, step); + int yShift = (undergroundShift != 0 && isUndergroundStep(structure.step())) ? undergroundShift : 0; + WorldGenLevel target = yShift == 0 ? world : yShiftedLevel(world, yShift); try { structureManager.startsForStructure(sectionPos, structure) - .forEach(start -> start.placeInChunk(world, structureManager, this, random, area, chunkPos)); + .forEach(start -> start.placeInChunk(target, structureManager, this, random, area, chunkPos)); } catch (Throwable e) { Iris.reportError(e); } @@ -191,6 +196,31 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } + private static boolean isUndergroundStep(GenerationStep.Decoration step) { + return step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES + || step == GenerationStep.Decoration.STRONGHOLDS; + } + + private WorldGenLevel yShiftedLevel(WorldGenLevel world, int yShift) { + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, + (proxy, method, args) -> { + 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()); + } + } + } + try { + return method.invoke(world, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + } + private BoundingBox writableArea(ChunkAccess chunk) { ChunkPos cp = chunk.getPos(); int i = cp.getMinBlockX(); 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 63cdde686..b22f47e18 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 @@ -46,7 +46,9 @@ import org.spigotmc.SpigotWorldConfig; import javax.annotation.Nullable; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -173,15 +175,18 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; IrisVanillaStructureControl control = vanillaControl(); + int undergroundShift = control.getUndergroundYShift(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.getOrDefault(step, List.of())) { Object id = registry.getKey(structure); if (control.shouldGenerate(id == null ? null : id.toString())) { random.setFeatureSeed(decoSeed, index, step); + int yShift = (undergroundShift != 0 && isUndergroundStep(structure.step())) ? undergroundShift : 0; + WorldGenLevel target = yShift == 0 ? world : yShiftedLevel(world, yShift); try { structureManager.startsForStructure(sectionPos, structure) - .forEach(start -> start.placeInChunk(world, structureManager, this, random, area, chunkPos)); + .forEach(start -> start.placeInChunk(target, structureManager, this, random, area, chunkPos)); } catch (Throwable e) { Iris.reportError(e); } @@ -191,6 +196,31 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } + private static boolean isUndergroundStep(GenerationStep.Decoration step) { + return step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES + || step == GenerationStep.Decoration.STRONGHOLDS; + } + + private WorldGenLevel yShiftedLevel(WorldGenLevel world, int yShift) { + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, + (proxy, method, args) -> { + 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()); + } + } + } + try { + return method.invoke(world, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + } + private BoundingBox writableArea(ChunkAccess chunk) { ChunkPos cp = chunk.getPos(); int i = cp.getMinBlockX();