diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index 53f68c1b0..ca6a26420 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -78,10 +78,6 @@ final class IrisBiomeLayerGenerator { for (int i = 0; i < layerCount; i++) { IrisBiomePaletteLayer layer = layers.get(i); - double zoom = layer.getZoom(); - CNG hgen = heightGenerators.get(i); - int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); - IrisSlopeClip sc = layer.getSlopeCondition(); if (!sc.isDefault()) { @@ -90,10 +86,13 @@ final class IrisBiomeLayerGenerator { sampledSlope = true; } if (!sc.isValid(surfaceSlope)) { - d = 0; + continue; } } + double zoom = layer.getZoom(); + CNG hgen = heightGenerators.get(i); + int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); if (d <= 0) { continue; } @@ -171,56 +170,78 @@ final class IrisBiomeLayerGenerator { private static KList generateLockedLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepthf, int height, IrisData rdata, IrisComplex complex, ProceduralStream slopeStream) { KList data = new KList<>(); - KList real = new KList<>(); int maxDepth = Math.min(maxDepthf, biome.getLockLayersMax()); - if (maxDepth <= 0) { + KList layers = biome.getLayers(); + int layerCount = layers.size(); + if (maxDepth <= 0 || layerCount == 0) { return data; } - KList layers = biome.getLayers(); - int layerCount = layers.size(); - - if (layerCount > 0) { - KList heightGenerators = getLayerHeightGenerators(biome, random, rdata); - double surfaceSlope = 0D; - boolean sampledSlope = false; - - for (int i = 0; i < layerCount; i++) { - IrisBiomePaletteLayer layer = layers.get(i); - double zoom = layer.getZoom(); - CNG hgen = heightGenerators.get(i); - int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); - - IrisSlopeClip sc = layer.getSlopeCondition(); - - if (!sc.isDefault()) { - if (!sampledSlope) { - surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz); - sampledSlope = true; - } - if (!sc.isValid(surfaceSlope)) { - d = 0; - } + KList heightGenerators = getLayerHeightGenerators(biome, random, rdata); + int[] boundaries = new int[layerCount + 1]; + double surfaceSlope = 0D; + boolean sampledSlope = false; + for (int i = 0; i < layerCount; i++) { + IrisBiomePaletteLayer layer = layers.get(i); + IrisSlopeClip sc = layer.getSlopeCondition(); + boundaries[i + 1] = boundaries[i]; + if (!sc.isDefault()) { + if (!sampledSlope) { + surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz); + sampledSlope = true; } - - if (d <= 0) { + if (!sc.isValid(surfaceSlope)) { continue; } + } - appendLayer(data, layer, i, d, wx, wz, random, Integer.MAX_VALUE, rdata); + double zoom = layer.getZoom(); + CNG hgen = heightGenerators.get(i); + int thickness = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); + boundaries[i + 1] += Math.max(0, thickness); + } + + int cycleLength = boundaries[layerCount]; + if (cycleLength == 0) { + return data; + } + + int sampledDepth = Math.min(maxDepth, cycleLength); + for (int depth = 0; depth < sampledDepth; depth++) { + int position = Math.floorMod(512L - height - depth, cycleLength); + int layerIndex = findLayer(boundaries, position); + IrisBiomePaletteLayer layer = layers.get(layerIndex); + int offset = position - boundaries[layerIndex]; + double zoom = layer.getZoom(); + try { + data.add(layer.get(random, layerIndex + offset, + (wx + offset) / zoom, offset, (wz - offset) / zoom, rdata)); + } catch (Throwable error) { + IrisLogging.reportError(error); } } - if (data.isEmpty()) { - return real; + if (sampledDepth == cycleLength && !data.isEmpty()) { + int resolvedCycleLength = data.size(); + for (int depth = resolvedCycleLength; depth < maxDepth; depth++) { + data.add(data.get(depth % resolvedCycleLength)); + } } + return data; + } - for (int i = 0; i < maxDepth; i++) { - long offset = 512L - height - i; - real.add(data.get(Math.floorMod(offset, data.size()))); + private static int findLayer(int[] boundaries, int position) { + int low = 0; + int high = boundaries.length - 1; + while (low < high) { + int middle = (low + high) >>> 1; + if (boundaries[middle + 1] <= position) { + low = middle + 1; + } else { + high = middle; + } } - - return real; + return low; } private static double resolveSurfaceSlope( diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisStiltSettings.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisStiltSettings.java index 46dcdbd61..20dd22628 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisStiltSettings.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisStiltSettings.java @@ -3,14 +3,21 @@ package art.arcane.iris.generation.decoration; import art.arcane.iris.generation.terrain.IrisMaterialPalette; import art.arcane.volmlib.util.documentation.Description; +import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.iris.pack.schema.annotation.MaxNumber; import art.arcane.iris.pack.schema.annotation.MinNumber; +import art.arcane.iris.pack.schema.annotation.RegistryListBlockType; import art.arcane.iris.pack.schema.annotation.Snippet; +import art.arcane.volmlib.util.collection.KList; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + @Snippet("stilt-settings") @Accessors(chain = true) @NoArgsConstructor @@ -32,6 +39,10 @@ public class IrisStiltSettings { private int overStilt; @Description("If defined, stilting will be done using this block palette rather than the last layer of the object.") private IrisMaterialPalette palette; + @ArrayType(min = 1, type = String.class) + @RegistryListBlockType + @Description("Block materials that must never extend into stilts. Matches all block states of each material, before and after palette replacement or object edits. Original object blocks remain in place. Containers and blocks with tile entities are always excluded.") + private KList exclude = new KList<>(); @MinNumber(1) @MaxNumber(256) @Description("For ORGANIC_STILT / CEILING_HANG: the maximum number of blocks to scan toward the cave floor (or ceiling) looking for solid ground before giving up.") @@ -45,4 +56,16 @@ public class IrisStiltSettings { @Description("For ORGANIC_STILT / CEILING_HANG: in the deepest fraction of each stilt column, blocks are randomly skipped to break the tip up. Higher = more broken and scratchy. 0 disables.") private double organicScratch = 0.55; + public Set excludedMaterials() { + if (exclude == null || exclude.isEmpty()) { + return Set.of(); + } + Set materials = new HashSet<>(exclude.size()); + for (String block : exclude) { + String material = block.trim().toLowerCase(Locale.ROOT); + materials.add(material.indexOf(':') < 0 ? "minecraft:" + material : material); + } + return materials; + } + } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index 4431feabe..4cc193d43 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -18,7 +18,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executor; @@ -890,8 +890,11 @@ public final class HydrologyTileCache implements AutoCloseable { private static T awaitPlan(CompletableFuture future) { try { - return future.join(); - } catch (CompletionException failure) { + return future.get(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancellationException("Hydrology plan wait interrupted."); + } catch (ExecutionException failure) { Throwable cause = failure.getCause(); if (cause instanceof RuntimeException runtimeException) { throw runtimeException; diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java index 9562718e3..67d8fc918 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java @@ -20,6 +20,8 @@ package art.arcane.iris.generation.noise; import art.arcane.iris.pack.loading.IrisRegistrant; import art.arcane.iris.generation.cache.AtomicCache; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.volmlib.util.documentation.Description; import art.arcane.iris.pack.schema.annotation.MaxNumber; @@ -29,10 +31,14 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.interpolation.IrisInterpolation; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.noise.CellGenerator; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import lombok.Getter; +import lombok.Setter; import lombok.experimental.Accessors; import java.util.List; @@ -44,13 +50,24 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = false) public class IrisGenerator extends IrisRegistrant { + private static final int SURFACE_GRID = 6; + private static final int SURFACE_CACHE_STRIPES = 16; + private static final int SURFACE_CACHE_STRIPE_SIZE = 1024; + private final transient AtomicCache cellGen = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile SurfaceCache surfaceSamples; @MinNumber(0.001) @Description("The zoom or frequency.") private double zoom = 1; @MinNumber(0) @Description("The opacity, essentially a multiplier on the output.") private double opacity = 1; + @MinNumber(0) + @MaxNumber(1) + @Description("Fine terrain detail retained around bilinear interpolation of the original noise on a 6-block grid. 1 preserves the original noise; 0.5 halves the sub-grid residual. Original grid heights, feature sizes and height ranges stay unchanged.") + private double surfaceDetail = 1; @Description("Multiply the compsites instead of adding them") private boolean multiplicitive = false; @MinNumber(0.001) @@ -165,6 +182,37 @@ public class IrisGenerator extends IrisRegistrant { } public double getHeight(double rx, double ry, double rz, long superSeed, boolean no3d) { + if (!Double.isFinite(surfaceDetail) || surfaceDetail < 0D || surfaceDetail > 1D) { + throw new IllegalArgumentException("Generator surfaceDetail must be finite and between 0 and 1"); + } + if (surfaceDetail == 1D || composite.isEmpty()) { + return sampleHeight(rx, rz, superSeed); + } + IrisData data = getLoader(); + Engine engine = data == null ? null : data.getEngine(); + SurfaceCacheStripe[] stripes = surfaceCache(data, engine).stripes(); + double lowerX = Math.floor(rx / SURFACE_GRID) * SURFACE_GRID; + double lowerZ = Math.floor(rz / SURFACE_GRID) * SURFACE_GRID; + double dx = (rx - lowerX) / SURFACE_GRID; + double dz = (rz - lowerZ) / SURFACE_GRID; + double northWest = cachedSurfaceHeight(lowerX, lowerZ, superSeed, stripes); + if (dx == 0D && dz == 0D) { + return northWest; + } + double north = dx == 0D ? northWest : IrisInterpolation.lerp(northWest, + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ, superSeed, stripes), dx); + double smooth = north; + if (dz != 0D) { + double southWest = cachedSurfaceHeight(lowerX, lowerZ + SURFACE_GRID, superSeed, stripes); + double south = dx == 0D ? southWest : IrisInterpolation.lerp(southWest, + cachedSurfaceHeight(lowerX + SURFACE_GRID, lowerZ + SURFACE_GRID, superSeed, stripes), dx); + smooth = IrisInterpolation.lerp(north, south, dz); + } + return surfaceDetail == 0D ? smooth + : smooth + (sampleHeight(rx, rz, superSeed) - smooth) * surfaceDetail; + } + + private double sampleHeight(double rx, double rz, long superSeed) { if (composite.isEmpty()) { return 0; } @@ -205,6 +253,47 @@ public class IrisGenerator extends IrisRegistrant { return v; } + private double cachedSurfaceHeight(double x, double z, long superSeed, SurfaceCacheStripe[] stripes) { + if (x != (int) x || z != (int) z) { + return sampleHeight(x, z, superSeed); + } + long key = ((long) (int) x << 32) | ((int) z & 0xffffffffL); + long mixed = key ^ (key >>> 33); + mixed *= 0xff51afd7ed558ccdL; + mixed ^= mixed >>> 33; + SurfaceCacheStripe stripe = stripes[(int) mixed & (SURFACE_CACHE_STRIPES - 1)]; + SurfaceSample cached = stripe.get(key, superSeed); + if (cached != null) { + return cached.height(); + } + double height = sampleHeight(x, z, superSeed); + stripe.put(key, new SurfaceSample(superSeed, height)); + return height; + } + + private SurfaceCache surfaceCache(IrisData data, Engine engine) { + SurfaceCache cached = surfaceSamples; + if (cached != null && cached.data() == data && cached.engine() == engine) { + return cached; + } + synchronized (this) { + cached = surfaceSamples; + if (cached == null || cached.data() != data || cached.engine() != engine) { + cached = new SurfaceCache(data, engine, createSurfaceCache()); + surfaceSamples = cached; + } + return cached; + } + } + + private static SurfaceCacheStripe[] createSurfaceCache() { + SurfaceCacheStripe[] stripes = new SurfaceCacheStripe[SURFACE_CACHE_STRIPES]; + for (int i = 0; i < stripes.length; i++) { + stripes[i] = new SurfaceCacheStripe(); + } + return stripes; + } + public double cell(double rx, double rz, double v, double superSeed) { getCellGenerator(getSeed() + 46222).setShuffle(getCellFractureShuffle()); double fractureX = rx / getCellFractureZoom(); @@ -231,6 +320,7 @@ public class IrisGenerator extends IrisRegistrant { public IrisGenerator rescale(double scale) { zoom /= scale; + surfaceSamples = null; return this; } @@ -253,4 +343,26 @@ public class IrisGenerator extends IrisRegistrant { public String getTypeName() { return "Generator"; } + + private record SurfaceCache(IrisData data, Engine engine, SurfaceCacheStripe[] stripes) { + } + + private record SurfaceSample(long seed, double height) { + } + + private static final class SurfaceCacheStripe { + private final Long2ObjectLinkedOpenHashMap entries = new Long2ObjectLinkedOpenHashMap<>(16); + + private synchronized SurfaceSample get(long key, long seed) { + SurfaceSample sample = entries.getAndMoveToLast(key); + return sample != null && sample.seed() == seed ? sample : null; + } + + private synchronized void put(long key, SurfaceSample sample) { + entries.putAndMoveToLast(key, sample); + if (entries.size() > SURFACE_CACHE_STRIPE_SIZE) { + entries.removeFirst(); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java index cfaa011da..49944165a 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java @@ -173,6 +173,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { @Override public void start() { + getData().prepareBlockDropRules(); super.start(); if (!looper.isAlive()) { looper.start(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java index 3b313f8d1..9cf289a40 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldBlockDropRouter.java @@ -21,12 +21,14 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.world.tree.BlockDropRouter; import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.world.loot.IrisBlockDrops; import art.arcane.iris.world.entity.IrisMarker; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.platform.generation.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.collection.KList; @@ -139,6 +141,9 @@ final class WorldBlockDropRouter { } private List resolveDropProviders(BlockBreakEvent event) { + if (!mayHaveBlockDropRules(BukkitBlockState.of(event.getBlock().getBlockData()).materialKey())) { + return List.of(); + } IrisBiome biome = EngineBukkitOps.getBiome(manager.getEngine(), event.getBlock().getLocation()); List providers = filterDrops(biome.getBlockDrops(), event, manager.getData()); if (providers.stream().noneMatch(IrisBlockDrops::isSkipParents)) { @@ -148,4 +153,15 @@ final class WorldBlockDropRouter { } return providers; } + + private boolean mayHaveBlockDropRules(String material) { + if (manager.getData().hasBlockDropRules(material)) { + return true; + } + if (manager.getEngine() instanceof IrisEngine engine) { + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElse(null); + return router != null && router.biomes().mayHaveBlockDropRules(material); + } + return false; + } } diff --git a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java index 47981f4cf..6ddd3aabc 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java @@ -37,6 +37,9 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.generation.cache.AtomicCache; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.iris.world.loot.IrisBlockDrops; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.graph.StructureGraphCatalog; import art.arcane.iris.world.history.GenerationPackFingerprint; @@ -102,6 +105,7 @@ import java.util.Optional; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Set; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; @Data @@ -157,6 +161,9 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { @EqualsAndHashCode.Exclude private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver; private transient volatile Engine soleEngine; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile Set blockDropMaterials; private IrisData(File dataFolder) { this(dataFolder, false); @@ -651,6 +658,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public synchronized void hotloaded() { + blockDropMaterials = null; StructureGraphCatalog.invalidate(this); IrisObjectScale.invalidate(this); contentGate = null; @@ -730,6 +738,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void dump() { + blockDropMaterials = null; StructureGraphCatalog.invalidate(this); IrisObjectScale.invalidate(this); for (ResourceLoader i : loaders.values()) { @@ -771,12 +780,43 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void clearLists() { + blockDropMaterials = null; for (ResourceLoader i : loaders.values()) { i.clearList(); } possibleSnippets.clear(); } + public boolean hasBlockDropRules(String material) { + Set materials = blockDropMaterials; + return materials == null || materials.contains(material); + } + + public void prepareBlockDropRules() { + Set materials = new HashSet<>(); + for (IrisDimension dimension : dimensionLoader.loadAll(dimensionLoader.getPossibleKeys())) { + collectBlockDropMaterials(materials, dimension.getBlockDrops()); + } + for (IrisRegion region : regionLoader.loadAll(regionLoader.getPossibleKeys())) { + collectBlockDropMaterials(materials, region.getBlockDrops()); + } + for (IrisBiome biome : biomeLoader.loadAll(biomeLoader.getPossibleKeys())) { + collectBlockDropMaterials(materials, biome.getBlockDrops()); + } + blockDropMaterials = Set.copyOf(materials); + } + + private void collectBlockDropMaterials(Set materials, KList rules) { + for (IrisBlockDrops rule : rules) { + for (IrisBlockData block : rule.getBlocks()) { + PlatformBlockState state = block.getBlockData(this); + if (state != null) { + materials.add(IrisProceduralBlocks.materialKey(state.placementBaseState())); + } + } + } + } + private void invalidateLoader(ResourceLoader loader) { if (loader == null) { return; diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java index fd95a780f..de75c6bb4 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java @@ -50,6 +50,7 @@ import art.arcane.volmlib.util.matter.MatterMarker; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; import java.util.stream.StreamSupport; @@ -143,6 +144,9 @@ final class IrisObjectPlacementRunner { config.getMode() == ObjectPlaceMode.MIN_STILT || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT || config.getMode() == ObjectPlaceMode.CENTER_STILT || config.getMode() == ObjectPlaceMode.ERODE_STILT || organic); boolean eroding = config.getMode() == ObjectPlaceMode.ERODE_STILT; + IrisStiltSettings stiltSettings = config.getStiltSettings(); + Set excludedStiltMaterials = stilting && stiltSettings != null + ? stiltSettings.excludedMaterials() : Set.of(); KMap heightmap = config.getSnow() > 0 ? new KMap<>() : null; int spinx = rng.imax() / 1000; int spiny = rng.imax() / 1000; @@ -539,7 +543,7 @@ final class IrisObjectPlacementRunner { i.add(translateOffset); } - if (stilting && IrisObjectShaping.shouldStilt(data)) { + if (stilting && IrisObjectShaping.isStiltLayerBlock(data)) { if (i.getBlockY() < lowest) { lowest = i.getBlockY(); } @@ -654,7 +658,7 @@ final class IrisObjectPlacementRunner { self.readLock.lock(); try { VectorMap blocks = self.blocks; - IrisStiltSettings settings = config.getStiltSettings(); + IrisStiltSettings settings = stiltSettings; double erodeCentroidX = 0; double erodeCentroidZ = 0; @@ -670,7 +674,7 @@ final class IrisObjectPlacementRunner { } if (rot.getBlockY() == lowest) { PlatformBlockState bd = centroidCursor.value(); - if (bd != null && IrisObjectShaping.shouldStilt(bd)) { + if (bd != null && IrisObjectShaping.shouldStilt(bd, excludedStiltMaterials)) { erodeCentroidX += rot.getX(); erodeCentroidZ += rot.getZ(); centroidCount++; @@ -690,7 +694,7 @@ final class IrisObjectPlacementRunner { } if (rot.getBlockY() == lowest) { PlatformBlockState bd = spreadCursor.value(); - if (bd != null && IrisObjectShaping.shouldStilt(bd)) { + if (bd != null && IrisObjectShaping.shouldStilt(bd, excludedStiltMaterials)) { double dx = rot.getX() - erodeCentroidX; double dz = rot.getZ() - erodeCentroidZ; double dist = Math.sqrt(dx * dx + dz * dz); @@ -719,7 +723,7 @@ final class IrisObjectPlacementRunner { sourceData = IrisObject.States.air(); } - if (!IrisObjectShaping.shouldStilt(sourceData)) { + if (!IrisObjectShaping.shouldStilt(sourceData, excludedStiltMaterials)) { continue; } @@ -768,7 +772,7 @@ final class IrisObjectPlacementRunner { } } - if (d == null || !d.isOccluding()) + if (d == null || !IrisObjectShaping.shouldStilt(d, excludedStiltMaterials)) continue; xx = x + (int) Math.round(i.getX()); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java index eba0a0054..fbacab75e 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java @@ -29,6 +29,8 @@ import art.arcane.volmlib.util.math.Vector3i; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import java.util.Set; + /** * Volume shaping for {@link IrisObject}: smart boring, shrinkwrapping, compaction and the shared block * classification helpers used by placement. @@ -244,17 +246,23 @@ final class IrisObjectShaping { self.floatingFootprint.reset(); } - static boolean shouldStilt(PlatformBlockState state) { - if (!state.isOccluding()) { + static boolean isStiltLayerBlock(PlatformBlockState state) { + String material = materialKey(state); + if (!state.isOccluding() && !material.equals("minecraft:ice") && !material.equals("minecraft:glass") + && !material.equals("minecraft:tinted_glass") && !material.endsWith("_stained_glass")) { return false; } - String material = materialKey(state); if (material.endsWith("_stairs") || material.endsWith("_slab")) { return false; } return !material.equals("minecraft:dirt_path"); } + static boolean shouldStilt(PlatformBlockState state, Set excludedMaterials) { + return isStiltLayerBlock(state) && !state.isStorage() && !state.hasTileEntity() + && !excludedMaterials.contains(materialKey(state)); + } + static String materialKey(PlatformBlockState state) { return IrisProceduralBlocks.materialKey(state); } diff --git a/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java index 499c8d88f..899aa5e5a 100644 --- a/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java @@ -94,7 +94,7 @@ public final class IrisRenderer { RenderType currentType, BooleanSupplier cancelled ) { - return render(sx, sz, size, resolution, currentType, cancelled, false); + return render(sx, sz, size, resolution, currentType, cancelled, false, false); } public BufferedImage renderStudio( @@ -105,7 +105,41 @@ public final class IrisRenderer { RenderType currentType, BooleanSupplier cancelled ) { - return render(sx, sz, size, resolution, currentType, cancelled, true); + return render(sx, sz, size, resolution, currentType, cancelled, true, true); + } + + public BufferedImage renderStudioBase( + double sx, + double sz, + double size, + int resolution, + RenderType currentType, + BooleanSupplier cancelled + ) { + return render(sx, sz, size, resolution, currentType, cancelled, true, false); + } + + public BufferedImage refineStudioBiome( + double sx, + double sz, + double size, + BufferedImage base, + BooleanSupplier cancelled + ) { + Objects.requireNonNull(base, "base"); + Objects.requireNonNull(cancelled, "cancelled"); + checkCancelled(cancelled); + if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D + || base.getWidth() != base.getHeight()) { + throw new IllegalArgumentException("Vision biome refinement requires finite coordinates and a square base image"); + } + int resolution = base.getWidth(); + BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB); + base.copyData(image.getRaster()); + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + renderRiverAtlas(pixels, resolution, sx, sz, size / resolution, renderer.getComplex(), cancelled, true); + checkCancelled(cancelled); + return image; } private BufferedImage render( @@ -115,7 +149,8 @@ public final class IrisRenderer { int resolution, RenderType currentType, BooleanSupplier cancelled, - boolean studio + boolean studio, + boolean riverOverlay ) { if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) { throw new IllegalArgumentException("Vision render coordinates and size must be finite"); @@ -142,7 +177,7 @@ public final class IrisRenderer { } if (studio && adaptiveStudioType(currentType)) { renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled); - if (currentType == RenderType.BIOME) { + if (currentType == RenderType.BIOME && riverOverlay) { renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, true); } return image; diff --git a/core/src/main/java/art/arcane/iris/studio/view/VisionRenderController.java b/core/src/main/java/art/arcane/iris/studio/view/VisionRenderController.java index e466885a6..baa143eac 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/VisionRenderController.java +++ b/core/src/main/java/art/arcane/iris/studio/view/VisionRenderController.java @@ -136,7 +136,7 @@ final class VisionRenderController implements AutoCloseable { List tiles = visibleTiles(spec); Frame frame = new Frame(viewSequence.incrementAndGet(), spec, tiles); for (VisibleTile tile : tiles) { - tile.setImage(cache.get(frame.key(tile))); + tile.setResult(cache.get(frame.key(tile))); } CancellationToken token = new CancellationToken(); WorkState work = new WorkState(frame, token); @@ -165,7 +165,7 @@ final class VisionRenderController implements AutoCloseable { } int ready = 0; for (VisibleTile tile : frame.tiles()) { - if (tile.image() != null) { + if (tile.complete()) { ready++; } } @@ -275,14 +275,19 @@ final class VisionRenderController implements AutoCloseable { if (!isCurrent(work)) { return; } - int admissionLimit = work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount; + int admissionLimit = work.refining() || work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount; while (work.inFlight() < admissionLimit) { VisibleTile tile = work.nextMissing(); if (tile == null) { + if (work.inFlight() == 0 && work.beginRefinement()) { + admissionLimit = 1; + continue; + } return; } + boolean refining = work.refining(); work.incrementInFlight(); - TrackedRenderTask task = new TrackedRenderTask(() -> render(work, tile), activeRenderTasks); + TrackedRenderTask task = new TrackedRenderTask(() -> render(work, tile, refining), activeRenderTasks); activeRenderTasks.add(task); try { renderExecutor.execute(task); @@ -295,7 +300,7 @@ final class VisionRenderController implements AutoCloseable { } } - private void render(WorkState work, VisibleTile tile) { + private void render(WorkState work, VisibleTile tile, boolean refining) { Frame frame = work.frame(); CancellationToken token = work.token(); try { @@ -303,19 +308,26 @@ final class VisionRenderController implements AutoCloseable { return; } double tileSpan = TILE_PIXELS * frame.spec().blocksPerPixel(); - BufferedImage image = frame.spec().renderer().renderStudio( - tile.tileX() * tileSpan, - tile.tileZ() * tileSpan, - tileSpan, - TILE_PIXELS, - frame.spec().type(), - () -> !isCurrent(frame, token) - ); + double startX = tile.tileX() * tileSpan; + double startZ = tile.tileZ() * tileSpan; + IrisRenderer renderer = frame.spec().renderer(); + BufferedImage image; + if (refining) { + image = renderer.refineStudioBiome(startX, startZ, tileSpan, tile.image(), + () -> !isCurrent(frame, token)); + } else if (frame.spec().type() == RenderType.BIOME) { + image = renderer.renderStudioBase(startX, startZ, tileSpan, TILE_PIXELS, frame.spec().type(), + () -> !isCurrent(frame, token)); + } else { + image = renderer.renderStudio(startX, startZ, tileSpan, TILE_PIXELS, frame.spec().type(), + () -> !isCurrent(frame, token)); + } if (!isCurrent(frame, token)) { return; } - cache.put(frame.key(tile), image); - tile.setImage(image); + TileResult result = new TileResult(image, refining || frame.spec().type() != RenderType.BIOME); + cache.put(frame.key(tile), result); + tile.setResult(result); publish(frame, token); } catch (CancellationException ignored) { } catch (Throwable error) { @@ -446,7 +458,7 @@ final class VisionRenderController implements AutoCloseable { private final int screenX; private final int screenY; private final double distanceSquared; - private volatile BufferedImage image; + private volatile TileResult result; private VisibleTile(long tileX, long tileZ, int screenX, int screenY, double distanceSquared) { this.tileX = tileX; @@ -477,11 +489,17 @@ final class VisionRenderController implements AutoCloseable { } BufferedImage image() { - return image; + TileResult current = result; + return current == null ? null : current.image(); } - void setImage(BufferedImage image) { - this.image = image; + boolean complete() { + TileResult current = result; + return current != null && current.complete(); + } + + void setResult(TileResult result) { + this.result = result; } } @@ -548,6 +566,7 @@ final class VisionRenderController implements AutoCloseable { private final CancellationToken token; private int index; private int inFlight; + private boolean refining; private WorkState(Frame frame, CancellationToken token) { this.frame = frame; @@ -565,13 +584,26 @@ final class VisionRenderController implements AutoCloseable { VisibleTile nextMissing() { while (index < frame.tiles().size()) { VisibleTile tile = frame.tiles().get(index++); - if (tile.image() == null) { + if (refining ? tile.image() != null && !tile.complete() : tile.image() == null) { return tile; } } return null; } + boolean refining() { + return refining; + } + + boolean beginRefinement() { + if (refining || frame.spec().type() != RenderType.BIOME) { + return false; + } + refining = true; + index = 0; + return true; + } + int inFlight() { return inFlight; } @@ -595,14 +627,15 @@ final class VisionRenderController implements AutoCloseable { this.entries = new LinkedHashMap<>(128, 0.75F, true); } - synchronized BufferedImage get(TileKey key) { + synchronized TileResult get(TileKey key) { CacheEntry entry = entries.get(key); - return entry == null ? null : entry.image(); + return entry == null ? null : entry.result(); } - synchronized void put(TileKey key, BufferedImage image) { + synchronized void put(TileKey key, TileResult result) { + BufferedImage image = result.image(); long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES; - CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes)); + CacheEntry previous = entries.put(key, new CacheEntry(result, imageBytes)); if (previous != null) { bytes -= previous.bytes(); } @@ -621,6 +654,12 @@ final class VisionRenderController implements AutoCloseable { } } - private record CacheEntry(BufferedImage image, long bytes) { + private record TileResult(BufferedImage image, boolean complete) { + private TileResult { + Objects.requireNonNull(image, "image"); + } + } + + private record CacheEntry(TileResult result, long bytes) { } } diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java index 81b0b88c3..6275e7905 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java @@ -112,6 +112,24 @@ public final class SavedBiomeRuntime implements AutoCloseable { } } + public boolean mayHaveBlockDropRules(String material) { + if (engine.getData().hasBlockDropRules(material)) { + return true; + } + GenerationManifest manifest = history.manifest(); + String activeEpoch = manifest.activeEpoch().epochId(); + for (GenerationEpoch epoch : manifest.epochs()) { + if (epoch.epochId().equals(activeEpoch)) { + continue; + } + Definitions source = definitions.get(epoch.epochId()); + if (source == null || source.data().hasBlockDropRules(material)) { + return true; + } + } + return false; + } + public Optional resolveCaveBase(int blockX, int blockZ) { return resolve(blockX, 0, blockZ, QueryKind.CAVE_BASE); } @@ -449,6 +467,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { } Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data), resolveFocusRegions(data, dimension)); + data.prepareBlockDropRules(); definitions.put(epochId, loaded); return loaded; } catch (Throwable failure) { diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java index f7e4cad31..fe4ad4dcd 100644 --- a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java @@ -24,6 +24,8 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(Parameterized.class) @@ -70,6 +72,7 @@ public final class IrisBiomeLayerSlopeSourceTest { assertEquals(1, ledge.size()); assertSame(block, ledge.get(0)); assertTrue(cap.isEmpty()); + verify(heightGenerator, times(1)).fit(1, 1, 12D, -8D); } @Test @@ -109,5 +112,6 @@ public final class IrisBiomeLayerSlopeSourceTest { assertTrue(hostLayers.isEmpty()); assertEquals(1, sourceLayers.size()); assertSame(surfaceBlock, sourceLayers.get(0)); + verify(heightGenerator, times(1)).fit(1, 1, 12D, -8D); } } diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java index 5345fb509..86f0aaa7c 100644 --- a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java @@ -1,14 +1,30 @@ package art.arcane.iris.generation.biome; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.terrain.IrisSlopeClip; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.stream.ProceduralStream; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; public class IrisBiomeLockedLayerTest { @Test @@ -32,4 +48,133 @@ public class IrisBiomeLockedLayerTest { } } } + + @Test + public void sampledBandsMatchExpandedVariableMixedPalettes() { + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + PlatformBlockState third = mock(PlatformBlockState.class); + long[] seeds = {1L, 37L, -291L, Long.MAX_VALUE}; + int[] heights = {Integer.MIN_VALUE, -1000, -1, 0, 509, 512, 513, 1000, Integer.MAX_VALUE}; + int[] depths = {0, 1, 7, 39}; + double[] coordinates = {-71.5D, -1D, 0D, 13.25D, 127D}; + for (long seed : seeds) { + RNG rng = new RNG(seed); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(31).setLayers(new KList<>( + palette(0, 0, 1D, first), + palette(2, 5, 0.6D, first, second, second), + palette(0, 3, 3D, third, first), + palette(0, 0, 1D, second), + palette(1, 4, 1.5D, second, third, first, third), + palette(0, 0, 1D, third))); + for (double x : coordinates) { + for (int height : heights) { + for (int depth : depths) { + assertEquals(expandedBands(biome, x, -x, rng, depth, height, 0D), + biome.generateLockedLayers(x, -x, rng, depth, height, null, null)); + } + } + } + } + } + + @Test + public void nullEntriesAndRepeatedCyclesMatchExpandedBands() { + PlatformBlockState block = mock(PlatformBlockState.class); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(30).setLayers(new KList<>( + palette(1, 1, 1D, block), palette(2, 2, 1D, (PlatformBlockState) null))); + RNG rng = new RNG(37L); + assertEquals(expandedBands(biome, 3D, -11D, rng, 30, 1000, 0D), + biome.generateLockedLayers(3D, -11D, rng, 30, 1000, null, null)); + } + + @Test + public void zeroThicknessAndEmptyPalettesProduceNoBands() { + IrisBiome biome = new IrisBiome().setLockLayers(true).setLayers(new KList<>()); + RNG rng = new RNG(37L); + assertTrue(biome.generateLockedLayers(0D, 0D, rng, 7, 64, null, null).isEmpty()); + biome.setLayers(new KList<>(palette(0, 0, 1D, mock(PlatformBlockState.class)))); + assertTrue(biome.generateLockedLayers(0D, 0D, rng, 7, 64, null, null).isEmpty()); + assertTrue(biome.generateLockedLayers(0D, 0D, rng, -1, 64, null, null).isEmpty()); + } + + @Test + public void resolvesOnlyRequestedPositionsOfLongBands() { + IrisBiomePaletteLayer layer = spy(palette(1000, 1000, 0.6D, + mock(PlatformBlockState.class), mock(PlatformBlockState.class))); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLayers(new KList<>(layer)); + RNG rng = new RNG(37L); + assertEquals(7, biome.generateLockedLayers(13D, -27D, rng, 7, 64, null, null).size()); + verify(layer, times(7)).get(same(rng), anyInt(), anyDouble(), anyDouble(), anyDouble(), isNull()); + } + + @Test + public void repeatedCyclesResolveEachPositionOnce() { + IrisBiomePaletteLayer layer = spy(palette(3, 3, 0.6D, + mock(PlatformBlockState.class), mock(PlatformBlockState.class))); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(100).setLayers(new KList<>(layer)); + RNG rng = new RNG(37L); + assertEquals(100, biome.generateLockedLayers(13D, -27D, rng, 100, 64, null, null).size()); + verify(layer, times(3)).get(same(rng), anyInt(), anyDouble(), anyDouble(), anyDouble(), isNull()); + } + + @Test + public void slopeFilteredBandsMatchExpandedCycle() { + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(30).setLayers(new KList<>( + palette(2, 4, 0.6D, mock(PlatformBlockState.class)), + palette(3, 8, 1D, mock(PlatformBlockState.class)).setSlopeCondition(new IrisSlopeClip(3D, 255D)), + palette(1, 5, 2D, mock(PlatformBlockState.class)).setSlopeCondition(new IrisSlopeClip(0D, 3D)))); + RNG rng = new RNG(37L); + double[] slopes = {0D, 3D, 8D}; + for (double slope : slopes) { + ProceduralStream slopeStream = ProceduralStream.ofDouble((x, z) -> slope); + assertEquals(expandedBands(biome, 13D, -27D, rng, 30, 64, slope), + biome.generateLayersWithSlope(null, 13D, -27D, rng, 30, 64, null, slopeStream)); + } + } + + @Test + public void sampledPaletteFailuresAreReported() { + IrisBiomePaletteLayer layer = spy(palette(1, 1, 1D, mock(PlatformBlockState.class))); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLayers(new KList<>(layer)); + RNG rng = new RNG(37L); + IllegalStateException failure = new IllegalStateException("Palette unavailable"); + doThrow(failure).when(layer).get(same(rng), anyInt(), anyDouble(), anyDouble(), anyDouble(), isNull()); + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertTrue(biome.generateLockedLayers(0D, 0D, rng, 7, 64, null, null).isEmpty()); + logging.verify(() -> IrisLogging.reportError(failure)); + } + } + + private static IrisBiomePaletteLayer palette(int minimum, int maximum, double zoom, PlatformBlockState... blocks) { + IrisBiomePaletteLayer layer = new IrisBiomePaletteLayer().setMinHeight(minimum).setMaxHeight(maximum) + .setZoom(zoom).setStyle(NoiseStyle.NOWHERE.style()); + layer.getBlockData().aquire(() -> new KList<>(blocks)); + return layer; + } + + private static KList expandedBands(IrisBiome biome, double x, double z, RNG rng, + int maxDepth, int height, double slope) { + KList expanded = new KList<>(); + KList result = new KList<>(); + KList heights = biome.getLayerHeightGenerators(rng, null); + for (int index = 0; index < biome.getLayers().size(); index++) { + IrisBiomePaletteLayer layer = biome.getLayers().get(index); + double zoom = layer.getZoom(); + int thickness = heights.get(index).fit(layer.getMinHeight(), layer.getMaxHeight(), x / zoom, z / zoom); + if (!layer.getSlopeCondition().isValid(slope)) { + continue; + } + for (int offset = 0; offset < thickness; offset++) { + expanded.add(layer.get(rng, index + offset, (x + offset) / zoom, offset, (z - offset) / zoom, null)); + } + } + if (!expanded.isEmpty()) { + for (int depth = 0; depth < Math.min(maxDepth, biome.getLockLayersMax()); depth++) { + result.add(expanded.get(Math.floorMod(512L - height - depth, expanded.size()))); + } + } + return result; + } + } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index 42a43a06c..1be175ae2 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -50,6 +50,57 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Test + public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch waiterExited = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + AtomicReference waiterThread = new AtomicReference<>(); + when(planner.plan(key)).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return tile; + }); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + try { + Future waiter = callers.submit(() -> { + waiterThread.set(Thread.currentThread()); + try { + assertThrows(CancellationException.class, () -> cache.get(key)); + interrupted.set(Thread.currentThread().isInterrupted()); + } finally { + waiterExited.countDown(); + } + }); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + Future other = callers.submit(() -> cache.get(key)); + + waiterThread.get().interrupt(); + assertTrue(waiterExited.await(2L, TimeUnit.SECONDS)); + waiter.get(2L, TimeUnit.SECONDS); + assertTrue(interrupted.get()); + assertFalse(other.isDone()); + + release.countDown(); + assertSame(tile, other.get(5L, TimeUnit.SECONDS)); + assertSame(tile, cache.get(key)); + verify(planner, times(1)).plan(key); + } finally { + release.countDown(); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + @Test public void closeDrainsBackgroundPlanningAndRejectsNewDemand() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java new file mode 100644 index 000000000..3a6b3ea2a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java @@ -0,0 +1,194 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.ToDoubleFunction; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisGeneratorSurfaceDetailTest { + @Test + public void defaultUsesTheOriginalSampleOnce() { + AtomicInteger samples = new AtomicInteger(); + IrisGenerator generator = generator((seed, x, z) -> { + samples.incrementAndGet(); + return x * 0.01D + z * 0.02D; + }); + + assertEquals(0.07D, generator.getHeight(1, 3, 27L), 1e-12D); + assertEquals(1, samples.get()); + } + + @Test + public void halvesSubgridDetailAndPreservesLatticeHeights() { + IrisGenerator generator = generator((seed, x, z) -> + (int) x % 6 == 0 && (int) z % 6 == 0 ? 0.2D : 0.8D); + generator.setSurfaceDetail(0.5D); + + for (int z = -12; z <= 12; z++) { + for (int x = -12; x <= 12; x++) { + double expected = x % 6 == 0 && z % 6 == 0 ? 0.2D : 0.5D; + assertEquals(expected, generator.getHeight(x, z, 27L), 1e-12D); + } + } + } + + @Test + public void preservesPlanarSlopesAtNegativeAndFractionalCoordinates() { + IrisGenerator generator = generator((seed, x, z) -> 0.5D + x * 0.01D + z * 0.02D); + generator.setSurfaceDetail(0.5D); + + for (double z = -4.25D; z < 4D; z += 0.5D) { + for (double x = -4.75D; x < 4D; x += 0.5D) { + assertEquals(0.5D + x * 0.01D + z * 0.02D, + generator.getHeight(x, z, 27L), 1e-12D); + } + } + } + + @Test + public void reusesLatticeSamplesDuringDenseGeneration() { + AtomicInteger samples = new AtomicInteger(); + IrisGenerator generator = generator((seed, x, z) -> { + samples.incrementAndGet(); + return 0.5D; + }); + generator.setSurfaceDetail(0.5D); + + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(0.5D, generator.getHeight(x, z, 27L), 0D); + } + } + + assertEquals(263, samples.get()); + assertTrue(samples.get() < 256 * 1.1D); + } + + @Test + public void fourBlockPreviewSamplingReusesCoarseCorners() { + AtomicInteger samples = new AtomicInteger(); + IrisGenerator generator = generator((seed, x, z) -> { + samples.incrementAndGet(); + return 0.5D; + }); + generator.setSurfaceDetail(0.5D); + + for (int z = -16; z < 16; z += 4) { + for (int x = -16; x < 16; x += 4) { + generator.getHeight(x, z, 27L); + } + } + + assertEquals(91, samples.get()); + } + + @Test + public void cachedLatticeHeightsRemainIsolatedBySeed() { + IrisGenerator generator = generator((seed, x, z) -> seed * 0.000001D); + generator.setSurfaceDetail(0.5D); + double first = generator.getHeight(1, 1, 27L); + double second = generator.getHeight(1, 1, 28L); + + assertEquals(0.000001D, second - first, 1e-12D); + assertEquals(first, generator.getHeight(1, 1, 27L), 0D); + } + + @Test + public void cachedLatticeHeightsRemainIsolatedByEngine() { + Engine firstEngine = mock(Engine.class); + Engine secondEngine = mock(Engine.class); + AtomicReference activeEngine = new AtomicReference<>(firstEngine); + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> activeEngine.get()); + IrisGenerator generator = new IrisGenerator().setSurfaceDetail(0.5D).setComposite(new KList<>( + new ContextNoise(loader -> loader.getEngine() == firstEngine ? 0.2D : 0.8D))); + generator.setLoader(data); + + assertEquals(0.2D, generator.getHeight(0, 0, 27L), 1e-12D); + assertEquals(0.2D, generator.getHeight(1, 1, 27L), 1e-12D); + activeEngine.set(secondEngine); + assertEquals(0.8D, generator.getHeight(0, 0, 27L), 1e-12D); + assertEquals(0.8D, generator.getHeight(1, 1, 27L), 1e-12D); + activeEngine.set(firstEngine); + assertEquals(0.2D, generator.getHeight(1, 1, 27L), 1e-12D); + } + + @Test + public void cachedLatticeHeightsRemainIsolatedByLoader() { + Engine engine = mock(Engine.class); + IrisData firstData = mock(IrisData.class); + IrisData secondData = mock(IrisData.class); + when(firstData.getEngine()).thenReturn(engine); + when(secondData.getEngine()).thenReturn(engine); + IrisGenerator generator = new IrisGenerator().setSurfaceDetail(0.5D).setComposite(new KList<>( + new ContextNoise(loader -> loader == firstData ? 0.2D : 0.8D))); + generator.setLoader(firstData); + + assertEquals(0.2D, generator.getHeight(0, 0, 27L), 1e-12D); + assertEquals(0.2D, generator.getHeight(1, 1, 27L), 1e-12D); + generator.setLoader(secondData); + assertEquals(0.8D, generator.getHeight(0, 0, 27L), 1e-12D); + assertEquals(0.8D, generator.getHeight(1, 1, 27L), 1e-12D); + generator.setLoader(firstData); + assertEquals(0.2D, generator.getHeight(1, 1, 27L), 1e-12D); + } + + @Test + public void rescalingClearsCachedLatticeHeights() { + IrisGenerator generator = generator((seed, x, z) -> x * 0.01D + z * 0.02D); + generator.setSurfaceDetail(0.5D); + assertEquals(0.06D, generator.getHeight(2, 2, 27L), 1e-12D); + + generator.rescale(2D); + + assertEquals(0.12D, generator.getHeight(2, 2, 27L), 1e-12D); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNonfiniteDetail() { + generator((seed, x, z) -> 0D).setSurfaceDetail(Double.NaN).getHeight(1, 1, 27L); + } + + private static IrisGenerator generator(Sampler sampler) { + return new IrisGenerator().setComposite(new KList<>(new SampledNoise(sampler))); + } + + private interface Sampler { + double sample(long seed, double x, double z); + } + + private static final class SampledNoise extends IrisNoiseGenerator { + private final Sampler sampler; + + private SampledNoise(Sampler sampler) { + this.sampler = sampler; + } + + @Override + public double getNoise(long superSeed, double x, double z, IrisData data) { + return sampler.sample(superSeed, x, z); + } + } + + private static final class ContextNoise extends IrisNoiseGenerator { + private final ToDoubleFunction sampler; + + private ContextNoise(ToDoubleFunction sampler) { + this.sampler = sampler; + } + + @Override + public double getNoise(long superSeed, double x, double z, IrisData data) { + return sampler.applyAsDouble(data); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java index 7d4666cd3..4494bfde5 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldBlockDropRouterTest.java @@ -12,6 +12,7 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.collection.KList; import org.bukkit.Location; +import org.bukkit.GameMode; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; @@ -41,6 +42,30 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class WorldBlockDropRouterTest { + @Test + public void packWithoutDropRulesAllowsTheFirstCreativeAndSurvivalBreakWhileBiomesAreCold() { + for (GameMode mode : new GameMode[] {GameMode.CREATIVE, GameMode.SURVIVAL}) { + try (Fixture fixture = new Fixture()) { + when(fixture.data.hasBlockDropRules("minecraft:stone")).thenReturn(false); + when(fixture.data.hasBlockDropRules("minecraft:grass_block")).thenReturn(true); + when(fixture.player.getGameMode()).thenReturn(mode); + fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) + .thenThrow(new SavedBiomeUnavailableException("Biome loading", true)); + BlockBreakEvent event = fixture.event(); + + fixture.router.onBlockBreak(event); + + assertFalse(mode.name(), event.isCancelled()); + assertTrue(event.isDropItems()); + fixture.queries.verifyNoInteractions(); + verify(fixture.drops, never()).fillDrops(anyBoolean(), any()); + assertEquals(1, fixture.delayed.size()); + fixture.delayed.remove().run(); + assertEquals(1, fixture.markers.size()); + } + } + } + @Test public void coldBiomeCancelsBeforeChangingDropsOrSchedulingWork() { try (Fixture fixture = new Fixture()) { @@ -206,6 +231,7 @@ public class WorldBlockDropRouterTest { when(manager.getEngine()).thenReturn(engine); when(manager.getTarget()).thenReturn(target); when(manager.getData()).thenReturn(data); + when(data.hasBlockDropRules(anyString())).thenReturn(true); when(target.getWorld()).thenReturn(irisWorld); when(engine.getWorld()).thenReturn(irisWorld); when(irisWorld.minHeight()).thenReturn(-64); @@ -217,6 +243,7 @@ public class WorldBlockDropRouterTest { when(block.getZ()).thenReturn(32); when(block.getLocation()).thenReturn(location); when(block.getBlockData()).thenReturn(blockData); + when(blockData.getAsString()).thenReturn("minecraft:stone"); when(biome.getBlockDrops()).thenReturn(new KList<>(drops)); when(region.getBlockDrops()).thenReturn(new KList<>()); when(dimension.getBlockDrops()).thenReturn(new KList<>()); diff --git a/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java index 1c657e87d..45aa32eb6 100644 --- a/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java +++ b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java @@ -3,6 +3,9 @@ package art.arcane.iris.pack.loading; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.runtime.PreservationRegistry; import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.block.B; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.pack.validation.ContentGate; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.testsupport.PlatformLeakGuard; import org.junit.After; @@ -11,6 +14,7 @@ import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.File; import java.nio.file.Files; @@ -25,6 +29,8 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public class IrisDataAuthoringCacheTest { @ClassRule @@ -105,4 +111,61 @@ public class IrisDataAuthoringCacheTest { assertSame(keys, entry.getKey().getPossibleKeys()); } } + + @Test + public void dropRulePresenceIncludesEachScopeAndIsResetByPackReload() throws Exception { + for (String folder : List.of("dimensions", "regions", "biomes")) { + Path root = temporary.newFolder(folder + "-drops").toPath(); + Files.createDirectories(root.resolve(folder)); + Path resource = root.resolve(folder + "/sample.json"); + Files.writeString(resource, "{}"); + source = IrisData.openRuntime(root.toFile()); + try (MockedStatic blocks = mockStatic(B.class)) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.placementBaseState()).thenReturn(state); + when(state.materialKey()).thenReturn("minecraft:stone"); + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(state); + source.setContentGate(mock(ContentGate.class)); + assertTrue(source.hasBlockDropRules("minecraft:stone")); + source.prepareBlockDropRules(); + assertFalse(source.hasBlockDropRules("minecraft:stone")); + Files.writeString(resource, "{\"blockDrops\":[{\"blocks\":[{\"block\":\"minecraft:stone\"}]}]}"); + source.hotloaded(); + source.setContentGate(mock(ContentGate.class)); + assertTrue(source.hasBlockDropRules("minecraft:stone")); + source.prepareBlockDropRules(); + assertTrue(source.hasBlockDropRules("minecraft:stone")); + assertFalse(source.hasBlockDropRules("minecraft:grass_block")); + } finally { + source.close(); + source = null; + } + } + } + + @Test + public void customDropCandidatesUseTheResolvedCarrierAndKeepExactStateRulesEligible() throws Exception { + Path root = temporary.newFolder("custom-drops").toPath(); + Files.createDirectories(root.resolve("biomes")); + Files.writeString(root.resolve("biomes/sample.json"), """ + {"blockDrops":[{"exactBlocks":true,"blocks":[{"block":"rocks:ruby_ore"}]}]} + """); + source = IrisData.openRuntime(root.toFile()); + source.setContentGate(mock(ContentGate.class)); + PlatformBlockState custom = mock(PlatformBlockState.class); + PlatformBlockState carrier = mock(PlatformBlockState.class); + when(custom.materialKey()).thenReturn("rocks:ruby_ore"); + when(custom.placementBaseState()).thenReturn(carrier); + when(carrier.materialKey()).thenReturn("minecraft:note_block"); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("rocks:ruby_ore", false)).thenReturn(custom); + + source.prepareBlockDropRules(); + + assertTrue(source.hasBlockDropRules("minecraft:note_block")); + assertFalse(source.hasBlockDropRules("minecraft:stone")); + assertFalse(source.hasBlockDropRules("rocks:ruby_ore")); + } + } } diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 7aca65265..fa4774533 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "da993d49349b961b46f86a4966b8d2ab228a6b005c2a4ee279018e03e1139783"; + private static final String EXPECTED_SCHEMA_DIGEST = "545cbfcdaa4b4c8dd03fca96f6b2617c37bbe9bf73d2011679d2fabcf3f2a6be"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java index f5e0b1992..2e611481d 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java @@ -22,6 +22,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisRegistrant; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.noise.IrisExpression; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.structure.jigsaw.IrisJigsawPiece; @@ -124,6 +125,17 @@ public class SchemaBuilderParityTest { } } + @Test + public void stiltExclusionsExposeRegisteredMaterialKeys() { + IrisData data = structureSchemaData(); + when(data.getBlockLoader().getPossibleKeys()).thenReturn(new String[] {"minecraft:barrel", "minecraft:calcite"}); + JSONObject schema = new SchemaBuilder(IrisStiltSettings.class, data).construct(); + JSONObject exclude = rootProperties(schema).getJSONObject("exclude"); + + assertEquals("array", exclude.getString("type")); + assertTrue(exclude.getJSONObject("items").getString("$ref").endsWith("enum-block-type")); + } + @Test public void registryDependentEnumsMatchLegacyTransforms() { JSONObject definitions = new SchemaBuilder(RegistryModel.class, (IrisData) null).construct().getJSONObject("definitions"); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java index 2a4be9e60..8e0ced642 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java @@ -1,6 +1,11 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.IrisStiltSettings; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.decoration.formation.FormationGenerator; +import art.arcane.iris.generation.decoration.formation.IrisFormation; +import art.arcane.iris.generation.decoration.formation.IrisFormationForm; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.noise.NoiseStyle; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -415,6 +420,194 @@ public class IrisObjectPlacementRunnerRegressionTest { assertTrue(placer.writes().isEmpty()); } + @Test + public void everyStiltModePreservesTheBarrelAndItsTileWithoutRepeatingIt() { + PlatformBlockState barrel = state("minecraft:barrel[facing=up,open=false]", true); + when(barrel.materialKey()).thenReturn("minecraft:barrel"); + when(barrel.isStorage()).thenReturn(true); + when(barrel.hasTileEntity()).thenReturn(true); + TileData tile = new TileData("minecraft:barrel", new KMap<>()); + for (ObjectPlaceMode mode : List.of(ObjectPlaceMode.STILT, ObjectPlaceMode.FAST_STILT, + ObjectPlaceMode.MIN_STILT, ObjectPlaceMode.FAST_MIN_STILT, ObjectPlaceMode.CENTER_STILT, + ObjectPlaceMode.ERODE_STILT, ObjectPlaceMode.ORGANIC_STILT, ObjectPlaceMode.CEILING_HANG)) { + RecordingPlacer placer = new RecordingPlacer(null); + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + placer.set(x, ANCHOR_Y, z, solid); + placer.set(x, ANCHOR_Y + 20, z, solid); + } + } + placer.writes().clear(); + IrisObject object = boxObject(5, 1, 5); + object.setUnsigned(3, 0, 2, barrel); + object.setUnsignedTile(3, 0, 2, tile); + IrisObjectPlacement placement = placement().setMode(mode) + .setRotation(IrisObjectRotation.of(0, 0, 0)) + .setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + object.place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertEquals(mode.name(), 1, placer.writesOf(barrel).size()); + assertSame(mode.name(), barrel, placer.get(1, ANCHOR_Y + 10, 0)); + assertSame(mode.name(), tile, placer.getData(1, ANCHOR_Y + 10, 0, TileData.class)); + assertTrue(mode.name(), placer.writesOf(solid).stream() + .anyMatch(write -> write.y() != ANCHOR_Y + 10)); + } + } + + @Test + public void excludedSourceMaterialKeepsItsBlockAndDoesNotUseTheStiltPalette() { + PlatformBlockState calcite = state("minecraft:calcite", true); + PlatformBlockState cobblestone = state("minecraft:cobblestone", true); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(cobblestone); + IrisObject object = lineObject(3); + object.setUnsigned(2, 0, 0, calcite); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.CENTER_STILT) + .setRotation(IrisObjectRotation.of(0, 0, 0)) + .setStiltSettings(new IrisStiltSettings().setExclude(new KList<>("CALCITE")).setPalette(palette)); + + object.place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertEquals(1, placer.writesOf(calcite).size()); + assertSame(calcite, placer.get(1, ANCHOR_Y + 10, 0)); + assertNull(placer.get(1, ANCHOR_Y + 9, 0)); + assertSame(cobblestone, placer.get(0, ANCHOR_Y + 9, 0)); + } + + @Test + public void translucentFullBlocksGrowFoundationsWhileCrystalModelsRemainUnextended() { + PlatformBlockState ice = state("minecraft:ice", true); + when(ice.isOccluding()).thenReturn(false); + PlatformBlockState cluster = state("minecraft:amethyst_cluster", true); + when(cluster.isOccluding()).thenReturn(false); + IrisObject object = new IrisObject(3, 1, 1); + object.setUnsigned(0, 0, 0, ice); + object.setUnsigned(2, 0, 0, cluster); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(solid); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.CENTER_STILT) + .setRotation(IrisObjectRotation.of(0, 0, 0)) + .setStiltSettings(new IrisStiltSettings().setPalette(palette)); + + object.place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertSame(solid, placer.get(-1, ANCHOR_Y + 9, 0)); + assertEquals(1, placer.writesOf(cluster).size()); + assertNull(placer.get(1, ANCHOR_Y + 9, 0)); + } + + @Test + public void generatedGlacialOverhangGrowsPackedIceRootsToTerrain() { + PlatformBlockState ice = state("minecraft:ice", true); + when(ice.isOccluding()).thenReturn(false); + PlatformBlockState packedIce = state("minecraft:packed_ice", true); + IrisMaterialPalette body = mock(IrisMaterialPalette.class); + when(body.getPalette()).thenReturn(new KList<>(new IrisBlockData("minecraft:ice"))); + when(body.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(ice); + IrisMaterialPalette roots = mock(IrisMaterialPalette.class); + when(roots.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(packedIce); + IrisFormation formation = new IrisFormation().setForm(IrisFormationForm.OVERHANG) + .setHeightMin(16).setHeightMax(25).setBaseWidthMin(5).setBaseWidthMax(8) + .setOverhangReach(13).setOverhangDrop(6).setRoughness(0.3).setJitter(0.08) + .setBlockPalette(body); + IrisObject object = FormationGenerator.generate(formation, 16, new RNG(5055004L + 16L), data); + RecordingPlacer placer = new RecordingPlacer(null); + for (int x = -32; x <= 32; x++) { + for (int z = -32; z <= 32; z++) { + placer.set(x, ANCHOR_Y, z, solid); + } + } + placer.writes().clear(); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.ORGANIC_STILT) + .setRotation(IrisObjectRotation.of(0, 0, 0)) + .setStiltSettings(new IrisStiltSettings().setOrganicMaxScan(96).setOrganicJitter(0) + .setOrganicScratch(0).setPalette(roots)); + + object.place(0, ANCHOR_Y + object.getCenter().getBlockY() + 12, 0, placer, placement, + new RNG(2L), data); + + List rootWrites = placer.writesOf(packedIce); + assertFalse(rootWrites.isEmpty()); + assertTrue(rootWrites.stream().anyMatch(write -> write.y() == ANCHOR_Y + 1)); + for (BlockWrite root : rootWrites) { + for (int y = ANCHOR_Y + 1; y < root.y(); y++) { + assertSame(packedIce, placer.get(root.x(), y, root.z())); + } + } + assertFalse(placer.writesOf(ice).isEmpty()); + } + + @Test + public void excludedLowestBlockDoesNotMoveTheFoundationUpIntoTheOriginalObject() { + PlatformBlockState machine = state("test:machine", true); + when(machine.hasTileEntity()).thenReturn(true); + IrisObject object = new IrisObject(1, 3, 1); + object.setUnsigned(0, 0, 0, machine); + object.setUnsigned(0, 1, 0, solid); + RecordingPlacer placer = new RecordingPlacer(null); + + object.place(0, ANCHOR_Y + 10, 0, placer, placement().setMode(ObjectPlaceMode.CENTER_STILT), + new RNG(2L), data); + + assertEquals(2, placer.writes().size()); + assertSame(machine, placer.get(0, ANCHOR_Y + 9, 0)); + } + + @Test + public void stiltPaletteCannotIntroduceExcludedMaterialsOrContainers() { + PlatformBlockState calcite = state("minecraft:calcite", true); + PlatformBlockState barrel = state("minecraft:barrel", true); + when(barrel.isStorage()).thenReturn(true); + for (PlatformBlockState replacement : List.of(calcite, barrel)) { + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(replacement); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.CENTER_STILT) + .setStiltSettings(new IrisStiltSettings().setExclude(new KList<>("minecraft:calcite")) + .setPalette(palette)); + + lineObject(3).place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertEquals(replacement.key(), 3, placer.writes().size()); + assertTrue(placer.writesOf(replacement).isEmpty()); + } + } + + @Test + public void objectEditsCannotIntroduceExcludedMaterialsOrTileEntitiesIntoStilts() { + PlatformBlockState calcite = state("minecraft:calcite", true); + PlatformBlockState machine = state("test:machine", true); + when(machine.hasTileEntity()).thenReturn(true); + for (PlatformBlockState replacement : List.of(calcite, machine)) { + IrisObjectReplace edit = mock(IrisObjectReplace.class); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(edit.getChance()).thenReturn(1F); + when(edit.getFind(data)).thenReturn(new KList<>(solid)); + when(edit.getReplace(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(replacement); + when(edit.getReplace()).thenReturn(palette); + when(palette.getTile(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(Optional.empty()); + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.CENTER_STILT) + .setStiltSettings(new IrisStiltSettings().setExclude(new KList<>("minecraft:calcite"))); + placement.getEdit().add(edit); + + lineObject(3).place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertEquals(replacement.key(), 3, placer.writesOf(replacement).size()); + assertTrue(placer.writesOf(replacement).stream().allMatch(write -> write.y() == ANCHOR_Y + 10)); + } + } + private IrisObjectPlacement placement() { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); diff --git a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererPerformanceContractTest.java b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererPerformanceContractTest.java index ae3bdba61..180d6da02 100644 --- a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererPerformanceContractTest.java +++ b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererPerformanceContractTest.java @@ -139,6 +139,27 @@ public class IrisRendererPerformanceContractTest { assertTrue(samples.get() < 128); } + @Test + public void studioBiomeBaseDoesNotEnterHydrologyPlanning() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisBiome biome = mock(IrisBiome.class); + @SuppressWarnings("unchecked") + ProceduralStream base = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getBaseBiomeStream()).thenReturn(base); + when(base.get(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble())) + .thenReturn(biome); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + + BufferedImage image = new IrisRenderer(engine).renderStudioBase( + 0D, 0D, 256D, 64, RenderType.BIOME, () -> false); + + assertEquals(Color.GREEN.getRGB(), image.getRGB(0, 0)); + assertEquals(Color.GREEN.getRGB(), image.getRGB(63, 63)); + verify(complex, never()).getHydrologyRuntime(); + } + @Test public void planarStudioHeightPagesInterpolateWithoutPerPixelEngineSampling() { Engine engine = mock(Engine.class); diff --git a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java index 75ab912c9..e970f3c24 100644 --- a/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java +++ b/core/src/test/java/art/arcane/iris/studio/render/IrisRendererRiverTest.java @@ -134,6 +134,32 @@ public class IrisRendererRiverTest { verify(runtime).sampleRenderFootprint(12D, -7D, 20D, 1D); } + @Test + public void biomeRefinementCopiesTheBaseAndRetainsTheFullRiverOverlay() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + IrisBiome biome = mock(IrisBiome.class); + @SuppressWarnings("unchecked") + ProceduralStream base = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(complex.getBaseBiomeStream()).thenReturn(base); + when(base.get(12D, -7D)).thenReturn(biome); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + when(runtime.sampleRenderFootprint(12D, -7D, 20D, 1D)) + .thenReturn(sample(HydrologyFeatureType.SURFACE_POOL, false)); + IrisRenderer renderer = new IrisRenderer(engine); + BufferedImage preview = renderer.renderStudioBase(12D, -7D, 8D, 1, RenderType.BIOME, () -> false); + + BufferedImage refined = renderer.refineStudioBiome(12D, -7D, 8D, preview, () -> false); + BufferedImage complete = renderer.renderStudio(12D, -7D, 8D, 1, RenderType.BIOME, () -> false); + + assertEquals(Color.GREEN.getRGB(), preview.getRGB(0, 0)); + assertNotEquals(preview.getRGB(0, 0), refined.getRGB(0, 0)); + assertEquals(complete.getRGB(0, 0), refined.getRGB(0, 0)); + } + @Test public void riverVisionShowsAProjectedCandidateWithoutTreatingItAsAccepted() { Engine engine = mock(Engine.class); diff --git a/core/src/test/java/art/arcane/iris/studio/view/VisionRenderControllerTest.java b/core/src/test/java/art/arcane/iris/studio/view/VisionRenderControllerTest.java index f5a6a5487..f2f5b11e9 100644 --- a/core/src/test/java/art/arcane/iris/studio/view/VisionRenderControllerTest.java +++ b/core/src/test/java/art/arcane/iris/studio/view/VisionRenderControllerTest.java @@ -11,6 +11,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CancellationException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; @@ -68,7 +69,7 @@ public class VisionRenderControllerTest { public void everyPublishedPageIsFinalResolutionAndIdenticalRequestsAreCacheHits() throws Exception { IrisRenderer renderer = mock(IrisRenderer.class); AtomicInteger calls = new AtomicInteger(); - when(renderer.renderStudio( + when(renderer.renderStudioBase( anyDouble(), anyDouble(), anyDouble(), @@ -84,6 +85,7 @@ public class VisionRenderControllerTest { BufferedImage.TYPE_INT_RGB ); }); + passThroughRefinement(renderer); VisionRenderController controller = controller(2); VisionRenderController.RenderSpec spec = spec(renderer, RenderType.BIOME, 1L, 0D, 0D, 4D, 128, 128); try { @@ -153,6 +155,12 @@ public class VisionRenderControllerTest { latestStarted.countDown(); return new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); }); + when(renderer.renderStudioBase(anyDouble(), anyDouble(), anyDouble(), anyInt(), + any(RenderType.class), any(BooleanSupplier.class))).thenAnswer(invocation -> { + latestStarted.countDown(); + return new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + }); + passThroughRefinement(renderer); VisionRenderController controller = controller(2); try { controller.request(spec(renderer, RenderType.REGION, 1L, 0D, 0D, 4D, 64, 64)); @@ -166,6 +174,103 @@ public class VisionRenderControllerTest { } } + @Test + public void completeBaseIsVisibleBeforeBlockedHydrologyRefinementStarts() throws Exception { + IrisRenderer renderer = exactRenderer(); + CountDownLatch overlayStarted = new CountDownLatch(1); + CountDownLatch releaseOverlay = new CountDownLatch(1); + AtomicInteger baseCalls = new AtomicInteger(); + AtomicInteger overlays = new AtomicInteger(); + when(renderer.renderStudioBase(anyDouble(), anyDouble(), anyDouble(), anyInt(), + any(RenderType.class), any(BooleanSupplier.class))).thenAnswer(invocation -> { + baseCalls.incrementAndGet(); + return new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + }); + when(renderer.refineStudioBiome(anyDouble(), anyDouble(), anyDouble(), any(BufferedImage.class), + any(BooleanSupplier.class))).thenAnswer(invocation -> { + overlays.incrementAndGet(); + overlayStarted.countDown(); + assertTrue(releaseOverlay.await(2L, TimeUnit.SECONDS)); + return invocation.getArgument(3); + }); + VisionRenderController controller = controller(3); + try { + VisionRenderController.RenderSpec spec = spec(renderer, RenderType.BIOME, 1L, 0D, 0D, 4D, 192, 128); + VisionRenderController.Frame frame = controller.request(spec); + assertTrue(overlayStarted.await(2L, TimeUnit.SECONDS)); + assertEquals(frame.tiles().size(), baseCalls.get()); + assertEquals(1, overlays.get()); + assertEquals(0, controller.progress(frame).ready()); + for (VisionRenderController.VisibleTile tile : frame.tiles()) { + assertEquals(64, controller.image(frame, tile).getWidth()); + } + + releaseOverlay.countDown(); + Await.until("every biome overlay to complete", Duration.ofSeconds(2L), + () -> controller.progress(frame).ready() == frame.tiles().size()); + assertEquals(frame.tiles().size(), overlays.get()); + + VisionRenderController.Frame cached = controller.request(spec); + assertEquals(frame.tiles().size(), controller.progress(cached).ready()); + assertEquals(frame.tiles().size(), baseCalls.get()); + assertEquals(frame.tiles().size(), overlays.get()); + } finally { + releaseOverlay.countDown(); + controller.close(); + } + } + + @Test + public void cancelledOverlayKeepsCachedBaseWithoutMarkingItComplete() throws Exception { + IrisRenderer renderer = exactRenderer(); + CountDownLatch staleStarted = new CountDownLatch(1); + CountDownLatch latestStarted = new CountDownLatch(1); + CountDownLatch releaseLatest = new CountDownLatch(1); + AtomicInteger baseCalls = new AtomicInteger(); + AtomicInteger overlayCalls = new AtomicInteger(); + when(renderer.renderStudioBase(anyDouble(), anyDouble(), anyDouble(), anyInt(), + any(RenderType.class), any(BooleanSupplier.class))).thenAnswer(invocation -> { + baseCalls.incrementAndGet(); + return new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + }); + when(renderer.refineStudioBiome(anyDouble(), anyDouble(), anyDouble(), any(BufferedImage.class), + any(BooleanSupplier.class))).thenAnswer(invocation -> { + if (overlayCalls.getAndIncrement() == 0) { + staleStarted.countDown(); + BooleanSupplier cancelled = invocation.getArgument(4); + while (!cancelled.getAsBoolean()) { + Thread.onSpinWait(); + } + throw new CancellationException(); + } + latestStarted.countDown(); + assertTrue(releaseLatest.await(2L, TimeUnit.SECONDS)); + return invocation.getArgument(3); + }); + VisionRenderController controller = controller(2); + try { + VisionRenderController.RenderSpec spec = spec(renderer, RenderType.BIOME, 1L, 0D, 0D, 4D, 128, 128); + VisionRenderController.Frame stale = controller.request(spec); + assertTrue(staleStarted.await(2L, TimeUnit.SECONDS)); + + VisionRenderController.Frame latest = controller.request(spec); + assertTrue(latestStarted.await(2L, TimeUnit.SECONDS)); + assertEquals(stale.tiles().size(), baseCalls.get()); + assertEquals(0, controller.progress(latest).ready()); + for (VisionRenderController.VisibleTile tile : latest.tiles()) { + assertEquals(64, controller.image(latest, tile).getWidth()); + } + + releaseLatest.countDown(); + Await.until("the replacement frame to finish its overlays", Duration.ofSeconds(2L), + () -> controller.progress(latest).ready() == latest.tiles().size()); + assertEquals(0, controller.progress(stale).ready()); + } finally { + releaseLatest.countDown(); + controller.close(); + } + } + private static VisionRenderController controller(int workers) { return new VisionRenderController( () -> { @@ -188,9 +293,18 @@ public class VisionRenderControllerTest { invocation.getArgument(3), BufferedImage.TYPE_INT_RGB )); + when(renderer.renderStudioBase(anyDouble(), anyDouble(), anyDouble(), anyInt(), + any(RenderType.class), any(BooleanSupplier.class))).thenAnswer(invocation -> new BufferedImage( + invocation.getArgument(3), invocation.getArgument(3), BufferedImage.TYPE_INT_RGB)); + passThroughRefinement(renderer); return renderer; } + private static void passThroughRefinement(IrisRenderer renderer) { + when(renderer.refineStudioBiome(anyDouble(), anyDouble(), anyDouble(), any(BufferedImage.class), + any(BooleanSupplier.class))).thenAnswer(invocation -> invocation.getArgument(3)); + } + private static Set keys(VisionRenderController.Frame frame) { Set keys = new HashSet<>(); for (VisionRenderController.VisibleTile tile : frame.tiles()) { diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java index 77df500eb..71ea2242e 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeRuntimeTest.java @@ -37,6 +37,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantReadWriteLock; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -45,6 +46,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; public class SavedBiomeRuntimeTest { @ClassRule @@ -102,6 +104,39 @@ public class SavedBiomeRuntimeTest { } } + @Test + public void packWithoutDropRulesDoesNotNeedAChunkQueryForTheOnlyEpoch() throws Exception { + IrisData current = mock(IrisData.class); + when(engine.getData()).thenReturn(current); + GenerationManifest manifest = history.manifest(); + GenerationEpoch active = manifest.epoch("a".repeat(64)).orElseThrow(); + when(manifest.activeEpoch()).thenReturn(active); + when(manifest.epochs()).thenReturn(List.of(active)); + + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + assertFalse(runtime.mayHaveBlockDropRules("minecraft:stone")); + verifyNoInteractions(store); + when(current.hasBlockDropRules("minecraft:stone")).thenReturn(true); + assertTrue(runtime.mayHaveBlockDropRules("minecraft:stone")); + } + } + + @Test + public void unloadedHistoricalPackRetainsDropChecksWhenTheCurrentPackHasNoRules() throws Exception { + when(engine.getData()).thenReturn(mock(IrisData.class)); + GenerationManifest manifest = history.manifest(); + GenerationEpoch previous = manifest.epoch("a".repeat(64)).orElseThrow(); + GenerationEpoch active = mock(GenerationEpoch.class); + when(active.epochId()).thenReturn("b".repeat(64)); + when(manifest.activeEpoch()).thenReturn(active); + when(manifest.epochs()).thenReturn(List.of(previous, active)); + + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + assertTrue(runtime.mayHaveBlockDropRules("minecraft:stone")); + verifyNoInteractions(store); + } + } + @Test public void chunkReadinessDoesNotRequireEveryCellToHaveAResolvedIdentity() throws Exception { when(store.get(0, 0)).thenReturn(Optional.of(unresolvedChunk(1)));