diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 0538cdf0f..1ee48d146 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -73,8 +73,6 @@ import org.zeroturnaround.zip.ZipUtil; import java.awt.Desktop; import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -997,7 +995,7 @@ public final class ModdedStudioCommands { for (String key : Stream.of(dm.getExpressionLoader().getPossibleKeys()).sorted().toList()) { hashes.append(copyJson(folder, "expressions", key, dm.getExpressionLoader().findFile(key))); } - hashes.append(copyStyleSnippets(packFolder, folder)); + hashes.append(PackExportClosure.copySnippets(packFolder, folder)); for (String key : regionKeys) { hashes.append(copyJson(folder, "regions", key, dm.getRegionLoader().findFile(key))); } @@ -1060,25 +1058,6 @@ public final class ModdedStudioCommands { } } - private static String copyStyleSnippets(File packFolder, File folder) throws IOException { - Path sourceRoot = new File(packFolder, "snippet/style").toPath(); - if (!Files.isDirectory(sourceRoot)) { - return ""; - } - StringBuilder hashes = new StringBuilder(); - try (Stream files = Files.walk(sourceRoot)) { - for (Path source : files.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted() - .toList()) { - String relative = sourceRoot.relativize(source).toString().replace(File.separatorChar, '/'); - String key = relative.substring(0, relative.length() - ".json".length()); - hashes.append(copyJson(folder, "snippet/style", key, source.toFile())); - } - } - return hashes.toString(); - } - private static int regions(CommandSourceStack source, int radius) { ServerPlayer player = source.getPlayer(); if (player == null) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java index bbc186876..da07db772 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java @@ -24,9 +24,16 @@ import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.volmlib.util.collection.KSet; +import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONObject; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayDeque; import java.util.function.Function; +import java.util.stream.Stream; /** * Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the @@ -38,6 +45,26 @@ public final class PackExportClosure { private PackExportClosure() { } + public static String copySnippets(File packFolder, File targetFolder) throws IOException { + Path sourceRoot = new File(packFolder, "snippet").toPath(); + if (!Files.isDirectory(sourceRoot)) { + return ""; + } + Path targetRoot = new File(targetFolder, "snippet").toPath(); + StringBuilder hashes = new StringBuilder(); + try (Stream files = Files.walk(sourceRoot)) { + for (Path source : files.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList()) { + String json = new JSONObject(IO.readAll(source.toFile())).toString(0); + IO.writeAll(targetRoot.resolve(sourceRoot.relativize(source)).toFile(), json); + hashes.append(IO.hash(json)); + } + } + return hashes.toString(); + } + public static KSet collectDimensionKeys(IrisDimension dimension) { return collectDimensionKeys(dimension, key -> dimension == null || dimension.getLoader() == null ? null diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackTerrain3DValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackTerrain3DValidator.java new file mode 100644 index 000000000..f3795126f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackTerrain3DValidator.java @@ -0,0 +1,236 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisTerrain3D; +import art.arcane.iris.engine.object.NoiseStyle; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class PackTerrain3DValidator { + private static final Gson GSON = new Gson(); + private static final Set PROFILE_FIELDS = Set.of("$schema", "enabled", "seed", "amplitude", + "horizontalScale", "verticalScale", "densityStyle", "crackDepth", "crackWidth", "crackScale", + "crackStyle", "minimumSlope", "slopeFade", "fluidClearance", "fluidFade"); + private static final Set STYLE_FIELDS = Set.of("$schema", "style", "cellularFrequency", + "cellularZoom", "zoom", "expression", "imageMap", "multiplier", "fracture", "exponent", "cacheSize"); + + private PackTerrain3DValidator() { + } + + static List validate(File packFolder) { + List errors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return errors; + } + File biomeFolder = new File(packFolder, "biomes"); + for (File biomeFile : files(biomeFolder)) { + JsonObject biome = readObject(biomeFile, "Biome '" + PackValidationIo.deriveKey(biomeFolder, biomeFile) + + "'", errors); + if (biome != null && biome.has("terrain3D") && !biome.get("terrain3D").isJsonNull()) { + validateProfile(packFolder, biome.get("terrain3D"), + "Biome '" + PackValidationIo.deriveKey(biomeFolder, biomeFile) + "' terrain3D", errors); + } + } + File snippetFolder = new File(packFolder, "snippet/terrain-3d"); + for (File snippetFile : files(snippetFolder)) { + String location = "Terrain3D snippet '" + PackValidationIo.deriveKey(snippetFolder, snippetFile) + "'"; + JsonObject profile = readObject(snippetFile, location, errors); + if (profile != null) { + validateProfile(packFolder, profile, location, errors); + } + } + return errors; + } + + private static List files(File folder) { + if (!folder.isDirectory()) { + return List.of(); + } + List files = PackValidationIo.listJsonRecursive(folder); + files.sort(Comparator.comparing(File::getPath)); + return files; + } + + private static void validateProfile(File packFolder, JsonElement raw, String location, List errors) { + JsonObject object = resolveObject(packFolder, raw, "terrain-3d", location, errors); + if (object == null) { + return; + } + JsonObject normalized = object.deepCopy(); + int initialErrors = errors.size(); + for (Map.Entry entry : object.entrySet()) { + String field = entry.getKey(); + JsonElement value = entry.getValue(); + String path = location + "." + field; + if (!PROFILE_FIELDS.contains(field)) { + errors.add(path + " is not a terrain3D field."); + continue; + } + switch (field) { + case "$schema" -> requireString(value, path, false, errors); + case "enabled" -> { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + errors.add(path + " must be a JSON boolean."); + } + } + case "seed" -> requireInteger(value, path, errors); + case "densityStyle", "crackStyle" -> { + JsonObject style = validateStyle(packFolder, value, path, 0, errors); + if (style != null) { + normalized.add(field, style); + } + } + default -> requireNumber(value, path, errors); + } + } + if (initialErrors != errors.size()) { + return; + } + try { + GSON.fromJson(normalized, IrisTerrain3D.class).validate(); + } catch (JsonParseException | IllegalArgumentException invalid) { + errors.add(location + ": " + invalid.getMessage()); + } + } + + private static JsonObject validateStyle(File packFolder, JsonElement raw, String location, + int depth, List errors) { + if (depth > 32) { + errors.add(location + " exceeds the maximum style nesting depth of 32."); + return null; + } + JsonObject object = resolveObject(packFolder, raw, "style", location, errors); + if (object == null) { + return null; + } + JsonObject normalized = object.deepCopy(); + for (Map.Entry entry : object.entrySet()) { + String field = entry.getKey(); + JsonElement value = entry.getValue(); + String path = location + "." + field; + if (!STYLE_FIELDS.contains(field)) { + errors.add(path + " is not a generator style field."); + continue; + } + switch (field) { + case "$schema" -> requireString(value, path, false, errors); + case "expression", "imageMap" -> requireString(value, path, true, errors); + case "style" -> { + if (requireString(value, path, false, errors)) { + try { + NoiseStyle.valueOf(value.getAsString()); + } catch (IllegalArgumentException invalid) { + errors.add(path + " is not a known noise style."); + } + } + } + case "fracture" -> { + if (!value.isJsonNull()) { + JsonObject fracture = validateStyle(packFolder, value, path, depth + 1, errors); + if (fracture != null) { + normalized.add(field, fracture); + } + } + } + case "cacheSize" -> { + if (requireInteger(value, path, errors)) { + requireRange(value, path, 0D, 8192D, errors); + } + } + case "exponent" -> requireRange(value, path, 0.01562D, 64D, errors); + case "cellularFrequency" -> requireRange(value, path, 0D, Double.MAX_VALUE, errors); + default -> requireRange(value, path, 0.00001D, Double.MAX_VALUE, errors); + } + } + return normalized; + } + + private static JsonObject resolveObject(File packFolder, JsonElement raw, String snippetType, + String location, List errors) { + if (raw.isJsonObject()) { + return raw.getAsJsonObject(); + } + if (!raw.isJsonPrimitive() || !raw.getAsJsonPrimitive().isString() + || !raw.getAsString().startsWith("snippet/")) { + errors.add(location + " must be an object or snippet reference."); + return null; + } + String reference = raw.getAsString(); + String prefix = "snippet/" + snippetType + "/"; + String resolved = reference.startsWith(prefix) ? reference : prefix + reference.substring(8); + Path root = packFolder.toPath().toAbsolutePath().normalize(); + Path path = root.resolve(resolved + ".json").normalize(); + if (!path.startsWith(root.resolve(prefix)) || !Files.isRegularFile(path)) { + errors.add(location + " references an unavailable " + snippetType + " snippet '" + reference + "'."); + return null; + } + return readObject(path.toFile(), location + " (" + reference + ")", errors); + } + + private static JsonObject readObject(File file, String location, List errors) { + try { + JsonElement parsed = JsonParser.parseString(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + if (parsed.isJsonObject()) { + return parsed.getAsJsonObject(); + } + errors.add(location + " must contain a JSON object."); + } catch (IOException | JsonParseException invalid) { + errors.add(location + " contains unreadable JSON: " + invalid.getMessage()); + } + return null; + } + + private static boolean requireString(JsonElement value, String path, boolean nullable, List errors) { + if (nullable && value.isJsonNull()) { + return true; + } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + errors.add(path + " must be a JSON string" + (nullable ? " or null." : ".")); + return false; + } + return true; + } + + private static boolean requireNumber(JsonElement value, String path, List errors) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber() + || !Double.isFinite(value.getAsDouble())) { + errors.add(path + " must be a finite JSON number."); + return false; + } + return true; + } + + private static boolean requireInteger(JsonElement value, String path, List errors) { + if (!requireNumber(value, path, errors)) { + return false; + } + try { + value.getAsBigDecimal().longValueExact(); + return true; + } catch (ArithmeticException invalid) { + errors.add(path + " must be an integer in the signed 64-bit range."); + return false; + } + } + + private static void requireRange(JsonElement value, String path, double minimum, + double maximum, List errors) { + if (requireNumber(value, path, errors) + && (value.getAsDouble() < minimum || value.getAsDouble() > maximum)) { + errors.add(path + " must be between " + minimum + " and " + maximum + "."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 5cd39ebd5..9e1bc82bf 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -129,6 +129,7 @@ public final class PackValidator { addDistinct(blockingErrors, hydrologyValidation.errors()); addDistinct(warnings, hydrologyValidation.warnings()); blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder)); + addDistinct(blockingErrors, PackTerrain3DValidator.validate(packFolder)); PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder); addDistinct(blockingErrors, lootIssues.errors()); addDistinct(warnings, lootIssues.warnings()); diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java b/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java index 2e93c8c05..f453922a5 100644 --- a/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java +++ b/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java @@ -30,6 +30,7 @@ public final class DimensionStackContext { private final List layersBottomToTop; private final List seamOffsetSamplersBottomToTop; private final Cache layoutCache; + private final boolean volumetric; private DimensionStackContext(ContextState state) { engine = state.engine(); @@ -41,6 +42,7 @@ public final class DimensionStackContext { layersTopToBottom = List.copyOf(reversed); seamOffsetSamplersBottomToTop = List.copyOf(state.seamOffsetSamplersBottomToTop()); layoutCache = Caffeine.newBuilder().maximumSize(LAYOUT_CACHE_SIZE).build(); + volumetric = layersBottomToTop.stream().anyMatch(DimensionTerrainContext::hasTerrain3D); } public static DimensionStackContext create(Engine engine, IrisDimensionStack stack) { @@ -191,7 +193,8 @@ public final class DimensionStackContext { column.fluidBlock(), column.surfaceBlock(), normalTerrainHeight, - fluidHeight + fluidHeight, + column.terrainColumn() )); } @@ -244,6 +247,11 @@ public final class DimensionStackContext { } private long sampleTopHeights(int x, int z) { + if (volumetric) { + DimensionStackLayout layout = getLayout(x, z); + return ((long) layout.clippedStackTerrainTopY() << 32) + | (layout.clippedStackTopY() & 0xFFFFFFFFL); + } boolean naturalFallback = usesTemporaryNaturalFallback(x, z); int baseY = 0; int renderedTerrainTopY = Integer.MIN_VALUE; diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java b/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java index 06706788c..2838995f1 100644 --- a/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java +++ b/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java @@ -2,6 +2,7 @@ package art.arcane.iris.engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.spi.PlatformBlockState; import java.util.ArrayList; @@ -15,6 +16,7 @@ public final class DimensionStackLayout { private final int stackTopY; private final int clippedStackTerrainTopY; private final int clippedStackTopY; + private final boolean volumetric; private DimensionStackLayout(LayoutState state) { layersBottomToTop = List.copyOf(state.layersBottomToTop()); @@ -25,6 +27,7 @@ public final class DimensionStackLayout { stackTopY = state.stackTopY(); clippedStackTerrainTopY = state.clippedStackTerrainTopY(); clippedStackTopY = state.clippedStackTopY(); + volumetric = layersBottomToTop.stream().anyMatch(layer -> layer.terrainColumn() != null); } static DimensionStackLayout create( @@ -80,6 +83,15 @@ public final class DimensionStackLayout { int renderMinY = Math.max(0, baseY); int renderMaxY = Math.min(outputMaxY, contentTopY); boolean visible = renderMinY <= renderMaxY; + int clippedSurfaceY = clip(surfaceY, outputMaxY); + if (input.terrainColumn() != null) { + int maximumSourceY = Math.clamp((long) renderMaxY - baseY, + Integer.MIN_VALUE, Integer.MAX_VALUE); + int sourceY = input.terrainColumn().highestSolidY(maximumSourceY); + int visibleY = saturatedAdd(baseY, sourceY); + clippedSurfaceY = sourceY >= 0 && visibleY >= renderMinY && visible + ? visibleY : -1; + } Layer layer = new Layer( input.terrainContext(), input.biome(), @@ -97,9 +109,10 @@ public final class DimensionStackLayout { seamOffsetBelow, renderMinY, renderMaxY, - clip(surfaceY, outputMaxY), + clippedSurfaceY, clip(fluidY, outputMaxY), - visible + visible, + input.terrainColumn() ); layers.add(layer); stackTerrainTopY = Math.max(stackTerrainTopY, surfaceY); @@ -122,6 +135,14 @@ public final class DimensionStackLayout { } } + boolean volumetric = false; + for (Layer layer : layers) { + volumetric |= layer.terrainColumn() != null; + } + if (volumetric) { + renderedTerrainTopY = highestVisibleY(layers, outputMaxY, false); + renderedContentTopY = highestVisibleY(layers, outputMaxY, true); + } return new DimensionStackLayout(new LayoutState( layers, stackTerrainTopY, @@ -182,6 +203,33 @@ public final class DimensionStackLayout { return layer.clippedSurfaceY(); } + private static int highestVisibleY(List layers, int maximumY, boolean includeFluid) { + int highest = -1; + for (int sourceIndex = 0; sourceIndex < layers.size(); sourceIndex++) { + Layer source = layers.get(sourceIndex); + int candidate = includeFluid ? source.highestContentY(maximumY) : source.highestSolidY(maximumY); + int index = sourceIndex + 1; + while (candidate > highest && index < layers.size()) { + Layer overlay = layers.get(index); + Layer lower = layers.get(index - 1); + int nextMaximum = candidate; + if (overlay.containsRenderedY(candidate)) { + nextMaximum = overlay.renderMinY() - 1; + } else if ((long) candidate > lower.contentTopY() && (long) candidate < overlay.localBaseY()) { + nextMaximum = lower.contentTopY(); + } + if (nextMaximum < candidate) { + candidate = includeFluid ? source.highestContentY(nextMaximum) : source.highestSolidY(nextMaximum); + index = sourceIndex + 1; + } else { + index++; + } + } + highest = Math.max(highest, candidate); + } + return highest; + } + public List layersBottomToTop() { return layersBottomToTop; } @@ -207,6 +255,10 @@ public final class DimensionStackLayout { } public Layer topTerrainLayer() { + if (volumetric) { + Layer owner = layerAt(clippedStackTerrainTopY); + return isSolid(clippedStackTerrainTopY) ? owner : null; + } Layer highest = null; int highestSurfaceY = Integer.MIN_VALUE; for (int layerIndex = 0; layerIndex < layersBottomToTop.size(); layerIndex++) { @@ -278,6 +330,20 @@ public final class DimensionStackLayout { return owner; } + public boolean isSolid(int y) { + for (int index = layersBottomToTop.size() - 1; index > 0; index--) { + Layer layer = layersBottomToTop.get(index); + if (layer.containsRenderedY(y)) { + return layer.isSolid(y); + } + Layer lower = layersBottomToTop.get(index - 1); + if ((long) y > lower.contentTopY() && (long) y < layer.localBaseY()) { + return false; + } + } + return layersBottomToTop.get(0).isSolid(y); + } + public boolean containsUpperLayerY(int y) { for (int layerIndex = 1; layerIndex < layersBottomToTop.size(); layerIndex++) { if (layersBottomToTop.get(layerIndex).containsRenderedY(y)) { @@ -307,7 +373,8 @@ public final class DimensionStackLayout { PlatformBlockState fluidBlock, PlatformBlockState surfaceBlock, int normalTerrainHeight, - int fluidHeight + int fluidHeight, + Terrain3DColumn terrainColumn ) { } @@ -330,12 +397,51 @@ public final class DimensionStackLayout { int renderMaxY, int clippedSurfaceY, int clippedFluidY, - boolean visible + boolean visible, + Terrain3DColumn terrainColumn ) { public boolean containsRenderedY(int y) { return visible && y >= renderMinY && y <= renderMaxY; } + public boolean isSolid(int y) { + if (!containsRenderedY(y)) { + return false; + } + int sourceY = y - localBaseY; + return terrainColumn == null ? sourceY <= normalTerrainHeight : terrainColumn.isSolid(sourceY); + } + + public int surfaceAt(int y) { + if (!isSolid(y)) { + return -1; + } + return terrainColumn == null ? surfaceY + : saturatedAdd(localBaseY, terrainColumn.surfaceY(y - localBaseY)); + } + + public int highestSolidY(int maximumY) { + if (!visible || maximumY < renderMinY) { + return -1; + } + int maximumSourceY = Math.clamp((long) Math.min(maximumY, renderMaxY) - localBaseY, + Integer.MIN_VALUE, Integer.MAX_VALUE); + int sourceY = terrainColumn == null ? Math.min(normalTerrainHeight, maximumSourceY) + : terrainColumn.highestSolidY(maximumSourceY); + int worldY = saturatedAdd(localBaseY, sourceY); + return sourceY >= 0 && worldY >= renderMinY ? worldY : -1; + } + + public int highestContentY(int maximumY) { + if (visible && fluidHeight > normalTerrainHeight) { + int fluidTop = Math.min(maximumY, Math.min(renderMaxY, fluidY)); + if (fluidTop >= renderMinY && fluidTop > surfaceY) { + return fluidTop; + } + } + return highestSolidY(maximumY); + } + } private record LayoutState( diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java index 72f79f9ce..488ead5ea 100644 --- a/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java +++ b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java @@ -11,6 +11,8 @@ import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.engine.terrain.Terrain3DRuntime; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.DataProvider; @@ -48,6 +50,8 @@ public final class DimensionTerrainContext implements DataProvider { private final IrisImageMapRuntime imageMapRuntime; private final boolean selfReferencing; private final SelfFallback selfFallback; + private final Terrain3DRuntime terrain3D; + private final boolean naturalSelf; private DimensionTerrainContext(ContextState state) { engine = state.engine(); @@ -64,6 +68,8 @@ public final class DimensionTerrainContext implements DataProvider { imageMapRuntime = state.imageMapRuntime(); selfReferencing = state.selfReferencing(); selfFallback = state.selfFallback(); + terrain3D = state.terrain3D(); + naturalSelf = state.naturalSelf(); } public static DimensionTerrainContext forStack(Engine engine, IrisDimension dimension) { @@ -130,7 +136,9 @@ public final class DimensionTerrainContext implements DataProvider { fluidBlockSampler, complex.getImageMapRuntime(), true, - selfFallback + selfFallback, + null, + natural )); } @@ -175,7 +183,7 @@ public final class DimensionTerrainContext implements DataProvider { focusRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( biome, dataProvider, allBiomes, generators)); } else { - for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + for (IrisRegion region : terrainRegions(dimension.getAllRegions(dataProvider), imageMapRuntime)) { preparedRegions.add(region); region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( biome, dataProvider, allBiomes, generators)); @@ -351,9 +359,6 @@ public final class DimensionTerrainContext implements DataProvider { return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z); }, Interpolated.DOUBLE).cache2DDouble( cachePrefix + "ImageMappedHeightStream", engine, cacheSize); - ProceduralStream slopeStream = heightStream.slope(3) - .cache2DDouble(cachePrefix + "SlopeStream", engine, cacheSize); - ProceduralStream finalBiomeStream = focusBiome == null ? heightStream.convertAware2D((height, x, z) -> { IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z); @@ -381,6 +386,24 @@ public final class DimensionTerrainContext implements DataProvider { Interpolated.of(value -> 0D, value -> focusBiome) ).cache2D(cachePrefix + "FinalBiomeStreamFocus", engine, cacheSize); + boolean terrain3DEnabled = false; + for (IrisBiome biome : allBiomes) { + if (biome.getTerrain3D() != null) { + biome.getTerrain3D().validate(); + terrain3DEnabled |= biome.getTerrain3D().isEnabled(); + } + } + Terrain3DRuntime terrain3D = !terrain3DEnabled ? null : new Terrain3DRuntime( + new Terrain3DRuntime.Sources(heightStream::getDouble, finalBiomeStream::get), + new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain() ^ seedOffset, + localHeight, fluidHeight, dimensionData, true, Math.max(4096, cacheSize)) + ); + ProceduralStream shapedHeightStream = terrain3D == null ? heightStream + : ProceduralStream.ofDouble((x, z) -> shapedHeight(terrain3D, x, z)) + .cache2DDouble(cachePrefix + "Terrain3DHeightStream", engine, cacheSize); + ProceduralStream slopeStream = shapedHeightStream.slope(3) + .cache2DDouble(cachePrefix + "SlopeStream", engine, cacheSize); + ProceduralStream rockStream = dimension.getRockPalette() .getLayerGenerator(rng.nextParallelRNG(45), dimensionData).stream() .select(dimension.getRockPalette().getBlockData(dimensionData)); @@ -399,7 +422,7 @@ public final class DimensionTerrainContext implements DataProvider { dimension, dimensionData, localHeight, - heightStream, + shapedHeightStream, slopeStream, ProceduralStream.ofDouble((x, z) -> fluidHeight), finalBiomeStream, @@ -408,7 +431,9 @@ public final class DimensionTerrainContext implements DataProvider { fluidBlockSampler, imageMapRuntime, false, - null + null, + terrain3D, + false )); } @@ -427,6 +452,22 @@ public final class DimensionTerrainContext implements DataProvider { }); } + static KList terrainRegions(Iterable declared, IrisImageMapRuntime imageMaps) { + KList regions = new KList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (IrisRegion region : declared) { + if (!region.isCompatExcluded() && seen.add(region)) { + regions.add(region); + } + } + for (IrisRegion region : imageMaps.getMappedRegions()) { + if (!region.isCompatExcluded() && seen.add(region)) { + regions.add(region); + } + } + return regions; + } + static IrisRegion mappedRegion( IrisImageMapRuntime imageMapRuntime, IrisRegion proceduralRegion, @@ -629,6 +670,32 @@ public final class DimensionTerrainContext implements DataProvider { return getNormalTerrainHeight(x, z, usesNaturalFallback(x, z)); } + public boolean hasTerrain3D() { + return selfReferencing ? engine.getComplex().hasTerrain3D() : terrain3D != null; + } + + public Terrain3DColumn terrainColumn(int x, int z) { + return terrainColumn(x, z, usesNaturalFallback(x, z)); + } + + Terrain3DColumn terrainColumn(int x, int z, boolean naturalFallback) { + if (selfReferencing) { + return naturalSelf || naturalFallback + ? engine.getComplex().naturalTerrainColumn(x, z) + : engine.getComplex().terrainColumn(x, z); + } + if (terrain3D == null) { + return null; + } + Terrain3DColumn column = terrain3D.column(x, z); + return column.shaped() ? column : null; + } + + private static double shapedHeight(Terrain3DRuntime runtime, double x, double z) { + Terrain3DColumn column = runtime.column((int) Math.floor(x), (int) Math.floor(z)); + return column.shaped() ? column.topY() : column.baseHeight(); + } + double getNormalTerrainHeight(double x, double z, boolean naturalFallback) { if (naturalFallback && selfFallback != null) { return selfFallback.heightStream().getDouble(x, z); @@ -651,6 +718,25 @@ public final class DimensionTerrainContext implements DataProvider { return slopeStream; } + public ProceduralStream getSurfaceSlopeStream(int sourceY) { + return !hasTerrain3D() ? slopeStream : ProceduralStream.ofDouble((x, z) -> + surfaceSlope((int) Math.floor(x), sourceY, (int) Math.floor(z))); + } + + double surfaceSlope(int x, int sourceY, int z) { + Terrain3DColumn column = terrainColumn(x, z); + if (column == null || sourceY >= column.topY() || column.surfaceY(sourceY) != sourceY) { + return slopeStream.getDouble(x, z); + } + return IrisComplex.calculateNaturalSlope(sourceY, + nearbySurfaceHeight(x + 3, sourceY, z), nearbySurfaceHeight(x, sourceY, z + 3)); + } + + private double nearbySurfaceHeight(int x, int sourceY, int z) { + Terrain3DColumn column = terrainColumn(x, z); + return column == null ? getNormalTerrainHeight(x, z) : column.nearestSurfaceY(sourceY); + } + public IrisBiome getBiome(double x, double z) { return getBiome(x, z, usesNaturalFallback(x, z)); } @@ -710,7 +796,8 @@ public final class DimensionTerrainContext implements DataProvider { getRegion(x, z), getRockBlock(x, z), getFluidBlock(x, z, naturalFallback), - getSurfaceBlock(x, z) + getSurfaceBlock(x, z), + terrainColumn((int) Math.floor(x), (int) Math.floor(z), naturalFallback) ); } @@ -739,7 +826,9 @@ public final class DimensionTerrainContext implements DataProvider { FluidBlockSampler fluidBlockSampler, IrisImageMapRuntime imageMapRuntime, boolean selfReferencing, - SelfFallback selfFallback + SelfFallback selfFallback, + Terrain3DRuntime terrain3D, + boolean naturalSelf ) { } @@ -758,7 +847,8 @@ public final class DimensionTerrainContext implements DataProvider { IrisRegion region, PlatformBlockState rockBlock, PlatformBlockState fluidBlock, - PlatformBlockState surfaceBlock + PlatformBlockState surfaceBlock, + Terrain3DColumn terrainColumn ) { } } 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 8bb5db43b..59138aee8 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -53,6 +53,8 @@ import art.arcane.iris.engine.object.IrisHydrology; import art.arcane.iris.engine.object.IrisRiverHydrology; import art.arcane.iris.engine.object.IrisRiverProfile; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.engine.terrain.Terrain3DRuntime; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -94,8 +96,8 @@ import java.nio.file.Path; import java.util.function.BiFunction; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"}) -@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"}) +@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); @@ -149,6 +151,11 @@ public class IrisComplex implements DataProvider { private ProceduralStream trueBiomeStream; private ProceduralStream trueBiomeDerivativeStream; private ProceduralStream naturalHeightStream; + private ProceduralStream baseTerrainHeightStream; + @Getter(AccessLevel.NONE) + private final transient Engine terrainEngine; + @Getter(AccessLevel.NONE) + private transient Terrain3DRuntime terrain3D; private ProceduralStream unblendedNaturalHeightStream; private final ResolvedTerrainProvider resolvedTerrain; private ProceduralStream placementHeightStream; @@ -199,6 +206,7 @@ public class IrisComplex implements DataProvider { } IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) { + terrainEngine = engine; this.transitionGenerationPlan = transitionGenerationPlan; this.resolvedTerrain = new ResolvedTerrainProvider(engine); int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize()); @@ -324,10 +332,27 @@ public class IrisComplex implements DataProvider { return mapped == null ? biome : mapped; }) .cache2D("imageMappedBaseBiomeStream", engine, cacheSize); - unblendedNaturalHeightStream = ProceduralStream.of( + baseTerrainHeightStream = ProceduralStream.of( (x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z), Interpolated.DOUBLE - ).cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize); + ).cache2DDouble("baseTerrainHeightStream", engine, cacheSize); + boolean terrain3DEnabled = false; + for (IrisBiome biome : generatorBiomes) { + if (biome.getTerrain3D() != null) { + biome.getTerrain3D().validate(); + terrain3DEnabled |= biome.getTerrain3D().isEnabled(); + } + } + terrain3D = !terrain3DEnabled ? null : new Terrain3DRuntime( + new Terrain3DRuntime.Sources( + (x, z) -> baseTerrainHeightStream.getDouble(x, z), + this::sampleTerrain3DBiome), + new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain(), engine.getHeight(), + fluidHeight, data, terrain3DEnabled, Math.max(4_096, cacheSize))); + unblendedNaturalHeightStream = terrain3DEnabled + ? ProceduralStream.ofDouble(this::sampleTerrain3DHeight) + .cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize) + : baseTerrainHeightStream; naturalHeightStream = unblendedNaturalHeightStream; naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) @@ -491,7 +516,95 @@ public class IrisComplex implements DataProvider { } private double sampleNaturalTerrainHeight(Engine engine, double x, double z) { - return sampleUnblendedNaturalTerrainHeight(engine, x, z); + return terrain3D != null && terrain3D.active() + ? sampleTerrain3DHeight(x, z) + : sampleUnblendedNaturalTerrainHeight(engine, x, z); + } + + private double sampleTerrain3DHeight(double x, double z) { + Terrain3DColumn column = terrain3D.column(blockCoordinate(x), blockCoordinate(z)); + return column.shaped() ? column.topY() : column.baseHeight(); + } + + private IrisBiome sampleTerrain3DBiome(int x, int z) { + if (focusBiome != null) { + return focusBiome; + } + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(baseTerrainHeightStream.getDouble(x, z), baseBiomeStream.get(x, z), + regionStream.get(x, z), (double) x, (double) z, fluidHeight) + : mapped; + } + + public Terrain3DColumn naturalTerrainColumn(int x, int z) { + if (terrain3D == null || !terrain3D.active()) { + return null; + } + Terrain3DColumn column = terrain3D.column(x, z); + return column.shaped() ? column : null; + } + + public boolean hasTerrain3D() { + return terrain3D != null && terrain3D.active(); + } + + public Terrain3DColumn terrainColumn(int x, int z, HydrologyColumnSample hydrology) { + if (hydrology != null) { + HydrologyColumnLayer layer = hydrology.primarySurfaceLayer().orElse(null); + if (layer != null && layer.terrainOwned()) { + return null; + } + } + return naturalTerrainColumn(x, z); + } + + public Terrain3DColumn terrainColumn(int x, int z) { + if (terrain3D == null || !terrain3D.active()) { + return null; + } + if (terrainEngine != null && terrainEngine.answersFromNaturalTerrain(x, z)) { + return naturalTerrainColumn(x, z); + } + return terrainColumn(x, z, sampleHydrologyColumn(x, z)); + } + + public boolean isTerrain3DOpening(int x, int y, int z) { + Terrain3DColumn column = terrainColumn(x, z); + return column != null && y >= column.minY() && y < column.topY() && !column.isSolid(y) + && (terrainEngine == null || !terrainEngine.isAdditionalTerrainOwned(x, y, z)); + } + + public boolean isNaturalTerrainSolid(int x, int y, int z) { + Terrain3DColumn column = naturalTerrainColumn(x, z); + return column == null ? y <= naturalTrueHeight(x, z) : column.isSolid(y); + } + + public boolean isTerrain3DSurface(int x, int y, int z) { + Terrain3DColumn column = terrainColumn(x, z); + if (column == null || y < column.minY()) { + return false; + } + if (!column.isSolid(y)) { + return true; + } + int surface = column.surfaceY(y); + return surface >= y && surface - y < 2; + } + + public double terrainSurfaceSlope(int x, int surfaceY, int z) { + Terrain3DColumn column = terrainColumn(x, z); + if (column == null || surfaceY >= column.topY() || column.surfaceY(surfaceY) != surfaceY) { + return getSlopeStream().getDouble(x, z); + } + return calculateNaturalSlope(surfaceY, + nearbyTerrainSurfaceHeight(x + 3, surfaceY, z), + nearbyTerrainSurfaceHeight(x, surfaceY, z + 3)); + } + + private double nearbyTerrainSurfaceHeight(int x, int surfaceY, int z) { + Terrain3DColumn column = terrainColumn(x, z); + return column == null ? getPlacementHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY); } private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) { @@ -1735,6 +1848,9 @@ public class IrisComplex implements DataProvider { } public void close() { + if (terrain3D != null) { + terrain3D.clear(); + } if (hydrologyRuntime != null) { hydrologyRuntime.close(); } diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index 6790131b7..dc278ef3c 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -24,6 +24,7 @@ import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.project.stream.ProceduralStream; @@ -93,6 +94,26 @@ public class UpperDimensionContext implements DataProvider { } public int getEffectiveSurfaceY(int x, int z) { + int surfaceY = effectiveEnvelopeY(x, z); + Terrain3DColumn column = terrainContext.terrainColumn(x, z); + return clipSurfaceY(column, ceilingLayout.height(), surfaceY); + } + + public Column sampleColumn(int x, int z) { + Terrain3DColumn column = terrainContext.terrainColumn(x, z); + return new Column(ceilingLayout.height(), + clipSurfaceY(column, ceilingLayout.height(), effectiveEnvelopeY(x, z)), column); + } + + private static int clipSurfaceY(Terrain3DColumn column, int height, int surfaceY) { + if (column == null || surfaceY >= height) { + return surfaceY; + } + int top = column.highestSolidY(height - 1 - surfaceY); + return top <= 0 ? height : height - 1 - top; + } + + private int effectiveEnvelopeY(int x, int z) { double depth = Math.max( 0D, Math.min(ceilingLayout.height() - 1D, terrainContext.getNormalTerrainHeight(x, z)) @@ -127,6 +148,10 @@ public class UpperDimensionContext implements DataProvider { return terrainContext.getSurfaceBlock(x, z); } + public ProceduralStream getSurfaceSlopeStream(int sourceY) { + return terrainContext.getSurfaceSlopeStream(sourceY); + } + public IrisDimension getDimension() { return terrainContext.getDimension(); } @@ -140,6 +165,24 @@ public class UpperDimensionContext implements DataProvider { return terrainContext.isSelfReferencing(); } + public record Column(int height, int surfaceY, Terrain3DColumn terrain) { + public boolean ownsY(int y) { + return y >= surfaceY && y < height; + } + + public boolean isSolid(int y) { + return ownsY(y) && (terrain == null || terrain.isSolid(height - 1 - y)); + } + + public int faceY(int y) { + if (!isSolid(y)) { + return -1; + } + return terrain == null ? surfaceY + : Math.max(surfaceY, height - 1 - terrain.surfaceY(height - 1 - y)); + } + } + record CeilingLayout(int height, int minimumGap) { CeilingLayout { if (height <= 0 || minimumGap < 0) { diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index 441967091..13463f6f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -228,6 +229,11 @@ public class IrisDecorantActuator extends EngineAssignedActuator 0; k--) { @@ -256,6 +262,24 @@ public class IrisDecorantActuator extends EngineAssignedActuator output, Terrain3DColumn column, + IrisBiome biome, int localX, int localZ, int worldX, int worldZ) { + for (int span = 0; span + 1 < column.spanCount(); span++) { + int floorY = column.floor(span); + int ceilingY = column.ceiling(span + 1); + int headroom = ceilingY - floorY - 1; + if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) { + continue; + } + getSurfaceDecorator().decorate(localX, localZ, worldX, worldZ, + output, biome, floorY, headroom); + if (PREDICATE_SOLID.test(output.getRaw(localX, ceilingY, localZ))) { + getCeilingDecorator().decorate(localX, localZ, worldX, worldZ, + output, biome, ceilingY - 1, headroom); + } + } + } + enum ShorelineDecorationMode { NONE, LEGACY, diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java index 80078b705..91e2db071 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java @@ -180,7 +180,7 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator surfaceBlocks = biome == null + KList surfaceBlocks = biome == null || layer.terrainColumn() != null ? null : biome.generateLayersWithSlope( dimension, @@ -192,6 +192,7 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator seaBlocks = biome == null || fluidDepth == 0 ? null : biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data); @@ -215,13 +216,26 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator layer.normalTerrainHeight()) { continue; } - int depth = layer.surfaceY() - y; + if (!layer.isSolid(y)) { + writeBlock(output, metadata, localX, y, localZ, AIR.get()); + continue; + } + int depth = layer.surfaceAt(y) - y; + int sourceSurfaceY = sourceY + depth; + if (sourceSurfaceY != paletteSurfaceY) { + paletteSurfaceY = sourceSurfaceY; + surfaceBlocks = null; + } if (depth == 0 && layer.surfaceBlock() != null) { writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock()); - } else if (surfaceBlocks != null && surfaceBlocks.hasIndex(depth)) { - writeBlock(output, metadata, localX, y, localZ, surfaceBlocks.get(depth)); } else { - writeBlock(output, metadata, localX, y, localZ, layer.rockBlock()); + if (surfaceBlocks == null && biome != null) { + surfaceBlocks = biome.generateLayersWithSlope(dimension, worldX, worldZ, rng, + sourceSurfaceY, sourceSurfaceY, data, terrainContext.getSurfaceSlopeStream(sourceSurfaceY)); + } + writeBlock(output, metadata, localX, y, localZ, + surfaceBlocks != null && surfaceBlocks.hasIndex(depth) + ? surfaceBlocks.get(depth) : layer.rockBlock()); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index 6d31bff52..7b01fe83e 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -34,6 +34,7 @@ import art.arcane.iris.engine.object.IrisOreGeneratorBounds; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRiverMaterialConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.util.common.data.BoundBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.context.ChunkedDataCache; @@ -47,6 +48,7 @@ import lombok.Getter; public class IrisTerrainNormalActuator extends EngineAssignedActuator { private static final BoundBlockState BEDROCK = BoundBlockState.of("BEDROCK"); + private static final BoundBlockState AIR = BoundBlockState.of("AIR"); @Getter private final RNG rng; @Getter @@ -132,6 +134,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator blocks = null; + KList ceilingBlocks = null; KList fblocks = null; for (int i = topY; i >= 0; i--) { @@ -166,6 +173,20 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= 0 && i < terrainColumn.ceiling(terrainSpan)) { + terrainSpan--; + blocks = null; + ceilingBlocks = null; + } + if (terrainSpan < 0 || i > terrainColumn.floor(terrainSpan)) { + h.setRaw(xf, i, zf, AIR.get()); + continue; + } + layerSurfaceY = terrainColumn.floor(terrainSpan); + layerCeilingY = terrainColumn.ceiling(terrainSpan); + } + PlatformBlockState ore = null; if (hasSurfaceOres) { if (biomeSurfaceOreBounds.contains(i)) { @@ -198,13 +219,25 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator 0 && depth >= 2 && i - layerCeilingY < 2) { + if (ceilingBlocks == null) { + ceilingBlocks = biome.generateCeilingLayers(dimension, realX, realZ, localRng, + 2, layerCeilingY, data, complex); + } + int ceilingDepth = i - layerCeilingY; + h.setRaw(xf, i, zf, ceilingBlocks.hasIndex(ceilingDepth) + ? ceilingBlocks.get(ceilingDepth) : rock); + continue; } if (blocks.hasIndex(depth + cut)) { @@ -241,29 +274,41 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator upperBlocks = upperBiome != null - ? upperBiome.generateLayers(upperContext.getDimension(), - realX, realZ, localRng, upperThickness, upperThickness, - upperContext.getData(), complex) - : null; + KList upperBlocks = null; + int paletteSourceY = -1; for (int y = chunkHeight - 1; y >= upperSurfaceY; y--) { if (y == chunkHeight - 1 && bedrockEnabled) { h.setRaw(xf, y, zf, BEDROCK.get()); continue; } - if (y == upperSurfaceY && upperMappedSurface != null) { + if (!upperColumn.isSolid(y)) { + h.setRaw(xf, y, zf, AIR.get()); + continue; + } + int faceY = upperColumn.faceY(y); + int sourceSurfaceY = upperColumn.height() - 1 - faceY; + if (sourceSurfaceY != paletteSourceY) { + paletteSourceY = sourceSurfaceY; + upperBlocks = null; + } + if (y == faceY && upperMappedSurface != null) { h.setRaw(xf, y, zf, upperMappedSurface); continue; } - int depthFromFace = y - upperSurfaceY; + int depthFromFace = y - faceY; + if (upperBlocks == null && upperBiome != null) { + upperBlocks = upperBiome.generateLayersWithSlope(upperContext.getDimension(), + realX, realZ, localRng, sourceSurfaceY, sourceSurfaceY, + upperContext.getData(), upperContext.getSurfaceSlopeStream(sourceSurfaceY)); + } if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) { h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace)); } else { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java index f7284c2c2..b129e80ce 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java @@ -43,11 +43,14 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { this.partRNG = new RNG(DecoratorCore.partSeed(getSeed(), IrisDecorationPart.NONE)); } - protected boolean isSlopeValid(IrisDecorator decorator, int realX, int realZ) { + protected boolean isSlopeValid(IrisDecorator decorator, int realX, int height, int realZ) { if (decorator.isForcePlace() || decorator.getSlopeCondition().isDefault()) { return true; } - return decorator.getSlopeCondition().isValid(getComplex().getSlopeStream().get(realX, realZ)); + double slope = getComplex().hasTerrain3D() + ? getComplex().terrainSurfaceSlope(realX, height, realZ) + : getComplex().getSlopeStream().getDouble(realX, realZ); + return decorator.getSlopeCondition().isValid(slope); } @BlockCoordinates @@ -73,7 +76,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); - if (decorator == null || !isSlopeValid(decorator, realX, realZ)) { + if (decorator == null || !isSlopeValid(decorator, realX, height, realZ)) { return; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index d845120b6..767747d16 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.terrain.Terrain3DColumn; + import art.arcane.iris.core.IrisSettings; @@ -118,6 +120,34 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return null; } + default boolean isAdditionalTerrainOwned(int x, int y, int z) { + DimensionStackContext stack = getDimensionStackContext(); + if (stack != null && stack.getLayout(x, z).isHostFeatureProtectedY(y)) { + return true; + } + UpperDimensionContext upper = getUpperContext(); + return upper != null && y >= upper.getEffectiveSurfaceY(x, z) && y < getHeight(); + } + + default boolean isTerrainSurfaceSolid(int x, int y, int z) { + DimensionStackContext stack = getDimensionStackContext(); + if (stack != null) { + DimensionStackLayout layout = stack.getLayout(x, z); + if (layout.isHostFeatureProtectedY(y)) { + return layout.isSolid(y); + } + } + UpperDimensionContext upper = getUpperContext(); + if (upper != null) { + UpperDimensionContext.Column column = upper.sampleColumn(x, z); + if (column.ownsY(y)) { + return column.isSolid(y); + } + } + Terrain3DColumn column = getComplex().terrainColumn(x, z); + return column == null || column.isSolid(y); + } + EngineMode getMode(); EnginePlatformHooks getPlatformHooks(); @@ -615,12 +645,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, if (y > bottomLayer.renderMaxY()) { return bottomLayer.biome(); } - if (y <= bottomLayer.clippedSurfaceY() - 2) { + if (y <= bottomLayer.clippedSurfaceY() - 2 && !getComplex().isTerrain3DSurface(x, y, z)) { return getCaveBiome(x, y, z); } return bottomLayer.biome(); } - if (y <= getHeight(x, z) - 2) { + if (y <= getHeight(x, z) - 2 && !getComplex().isTerrain3DSurface(x, y, z)) { return getCaveBiome(x, y, z); } @@ -639,12 +669,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, if (y > bottomLayer.renderMaxY()) { return bottomLayer.biome(); } - if (y <= bottomLayer.clippedSurfaceY() - 2) { + if (y <= bottomLayer.clippedSurfaceY() - 2 && !getComplex().isTerrain3DSurface(x, y, z)) { return getCaveOrMantleBiome(x, y, z); } return bottomLayer.biome(); } - if (y <= getHeight(x, z) - 2) { + if (y <= getHeight(x, z) - 2 && !getComplex().isTerrain3DSurface(x, y, z)) { return getCaveOrMantleBiome(x, y, z); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java index 9cf46b7b1..f581fdb74 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java @@ -6,8 +6,9 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; public interface HydrologyCaveVoxelViewFactory { CaveVoxelView create(PlannedSurface plannedSurface); - @FunctionalInterface interface PlannedSurface { int resolve(int x, int z, int naturalHeight); + + boolean ownsTerrain(int x, int z); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java index bb66f0b63..e7e204304 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -2039,9 +2039,7 @@ final class HydrologyFootprintCompiler { if (materializedSurface == null) { return surfaceRaster; } - return (int x, int z, int naturalHeight) -> materializedSurface.sample(x, z) - .map(HydrologyColumnSample::terrainHeight) - .orElse(naturalHeight); + return new MaterializedSurface(materializedSurface); } HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { @@ -2100,6 +2098,19 @@ final class HydrologyFootprintCompiler { } } + private record MaterializedSurface(RiverFootprint footprint) implements HydrologyCaveVoxelViewFactory.PlannedSurface { + @Override + public int resolve(int x, int z, int naturalHeight) { + return footprint.sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight); + } + + @Override + public boolean ownsTerrain(int x, int z) { + HydrologyColumnSample sample = footprint.sample(x, z).orElse(null); + return sample != null && sample.primarySurfaceLayer().isPresent(); + } + } + private final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface { private final Long2ObjectOpenHashMap surfaceColumns; private final Long2IntOpenHashMap validationNaturalHeights; @@ -2140,6 +2151,12 @@ final class HydrologyFootprintCompiler { return naturalHeight; } + @Override + public boolean ownsTerrain(int x, int z) { + HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z)); + return sample != null && sample.primarySurfaceLayer().isPresent(); + } + private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z)); if (sample != null && sample.naturalHeight() != naturalHeight) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java index 0595fca3c..6fa954a87 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java @@ -25,24 +25,57 @@ public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxel observations.put(key, column); } for (Observation observation : column) { - if (observation.naturalHeight() == naturalHeight) { + if (observation instanceof HeightObservation height && height.naturalHeight() == naturalHeight) { return resolvedHeight; } } - column.add(new Observation(x, z, naturalHeight, resolvedHeight)); + column.add(new HeightObservation(x, z, naturalHeight, resolvedHeight)); return resolvedHeight; } + @Override + public boolean ownsTerrain(int x, int z) { + boolean owned = delegate.ownsTerrain(x, z); + long key = RiverFootprint.pack(x, z); + ArrayList column = observations.get(key); + if (column == null) { + column = new ArrayList<>(1); + observations.put(key, column); + } + for (Observation observation : column) { + if (observation instanceof OwnershipObservation) { + return owned; + } + } + column.add(new OwnershipObservation(x, z, owned)); + return owned; + } + public List observationsAt(int x, int z) { ArrayList column = observations.get(RiverFootprint.pack(x, z)); return column == null ? List.of() : List.copyOf(column); } - public record Observation( + public sealed interface Observation permits HeightObservation, OwnershipObservation { + boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface); + } + + public record HeightObservation( int x, int z, int naturalHeight, int resolvedHeight - ) { + ) implements Observation { + @Override + public boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface) { + return surface.resolve(x, z, naturalHeight) == resolvedHeight; + } + } + + public record OwnershipObservation(int x, int z, boolean terrainOwned) implements Observation { + @Override + public boolean matches(HydrologyCaveVoxelViewFactory.PlannedSurface surface) { + return surface.ownsTerrain(x, z) == terrainOwned; + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java index e1d067d21..5c2671251 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java @@ -508,12 +508,7 @@ public final class HydrologyCaveContainmentPlanner { private boolean matches(HydrologyObservedPlannedSurface plannedSurface) { for (HydrologyObservedPlannedSurface.Observation observation : observations) { - int resolvedHeight = plannedSurface.resolve( - observation.x(), - observation.z(), - observation.naturalHeight() - ); - if (resolvedHeight != observation.resolvedHeight()) { + if (!observation.matches(plannedSurface)) { return false; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index b34ef7db1..ff5aa8fe5 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -144,7 +144,8 @@ public interface EngineMantle extends MatterGenerator { if (hydrology != null) { return hydrology.carves(); } - return getMantle().get(x, h, z, MatterCavern.class) != null; + return getMantle().get(x, h, z, MatterCavern.class) != null + || getComplex().isTerrain3DOpening(x, h, z); } default PlatformBlockState get(int x, int y, int z) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 8c8c5b7b4..d422f4cce 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -29,6 +29,7 @@ import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.history.BoundaryColumnGeometry; @@ -499,7 +500,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (hydrology != null) { return hydrology.carves(); } - return getPrerequisiteDataIfPresent(x, y, z, MatterCavern.class) != null; + return getPrerequisiteDataIfPresent(x, y, z, MatterCavern.class) != null + || engineMantle.getComplex().isTerrain3DOpening(x, y, z); } public byte[] getPrerequisiteCarvedColumn(int x, int z, int height) { @@ -513,6 +515,17 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return carvedColumn; } + Terrain3DColumn terrainColumn = engineMantle.getComplex().terrainColumn(x, z); + if (terrainColumn != null) { + int maximumY = Math.min(cappedHeight, terrainColumn.topY()); + for (int y = Math.max(0, terrainColumn.minY()); y < maximumY; y++) { + if (!terrainColumn.isSolid(y) + && !engineMantle.getEngine().isAdditionalTerrainOwned(x, y, z)) { + carvedColumn[y] = 1; + } + } + } + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); if (chunk == null) { return carvedColumn; @@ -885,7 +898,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (hydrology != null) { return hydrology.carves(); } - return getDataIfPresent(x, y, z, MatterCavern.class) != null; + return getDataIfPresent(x, y, z, MatterCavern.class) != null + || engineMantle.getComplex().isTerrain3DOpening(x, y, z); } private byte[] resolvedCarvedColumn(TerrainBoundarySignature column, int height) { @@ -933,6 +947,16 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return carvedColumn; } + Terrain3DColumn terrainColumn = engineMantle.getComplex().terrainColumn(x, z); + if (terrainColumn != null) { + int maximumY = Math.min(cappedHeight, terrainColumn.topY()); + for (int y = terrainColumn.minY(); y < maximumY; y++) { + if (!terrainColumn.isSolid(y) && !engineMantle.getEngine().isAdditionalTerrainOwned(x, y, z)) { + carvedColumn[y] = 1; + } + } + } + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); if (chunk == null) { return carvedColumn; @@ -980,8 +1004,11 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @Override public boolean isSurfaceSolid(int x, int y, int z) { Optional resolved = resolvedColumn(x, z); - return resolved.isEmpty() || resolved.get().geometry() - .voxelAt(y + engineMantle.getEngine().getMinHeight()).phase() == BoundaryColumnGeometry.Phase.SOLID; + if (resolved.isPresent()) { + return resolved.get().geometry() + .voxelAt(y + engineMantle.getEngine().getMinHeight()).phase() == BoundaryColumnGeometry.Phase.SOLID; + } + return engineMantle.getEngine().isTerrainSurfaceSolid(x, y, z); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java index 8432fa289..8985f7dea 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java @@ -98,12 +98,16 @@ public final class IslandObjectPlacer implements IObjectPlacer { @Override public boolean isCarved(int x, int y, int z) { + FloatingIslandSample sample = samples.sample(x, z); + if (sample != null && y >= sample.islandBaseY && y < sample.islandBaseY + sample.solidMask.length) { + return !sample.solidMask[y - sample.islandBaseY]; + } return wrapped.isCarved(x, y, z); } @Override public boolean isSurfaceSolid(int x, int y, int z) { - return wrapped.isSurfaceSolid(x, y, z); + return samples.sample(x, z) == null ? wrapped.isSurfaceSolid(x, y, z) : isSolid(x, y, z); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java index c40bfb92b..6c8f46896 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java @@ -15,13 +15,13 @@ import art.arcane.iris.engine.mantle.TerrainMatterView; import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.volmlib.util.function.Function2; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; import java.util.Objects; @@ -33,11 +33,12 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { private final Mantle mantle; private final int worldHeight; - private final Function2 surfaceHeight; + private final ColumnSource terrainColumns; + private final SolidSource naturalSolid; private final BiConsumer chunkLoader; private final LongOpenHashSet loadedChunks; private final Long2IntOpenHashMap openFloorCache; - private final Long2IntOpenHashMap surfaceHeightCache; + private final Long2ObjectOpenHashMap terrainCache; public MantleHydrologyCaveVoxelView( Engine engine, @@ -71,8 +72,10 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { this( engineMantle.getMantle(), engineMantle.getMantle().getWorldHeight(), - (x, z) -> plannedSurfaceHeight(complex, footprint, x, z), - (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ) + new TerrainSources( + (x, z) -> terrainColumn(complex, footprint.sample(x, z).orElse(null), x, z), + complex::isNaturalTerrainSolid, + (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ)) ); } @@ -84,33 +87,32 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { this( engineMantle.getMantle(), engineMantle.getMantle().getWorldHeight(), - (x, z) -> plannedSurface.resolve( + new TerrainSources((x, z) -> new TerrainColumn(plannedSurface.resolve( x, z, (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) - ), - (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ) + ), plannedSurface.ownsTerrain(x, z)), complex::isNaturalTerrainSolid, + (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ)) ); } MantleHydrologyCaveVoxelView( Mantle mantle, int worldHeight, - Function2 surfaceHeight, - BiConsumer chunkLoader + TerrainSources sources ) { this.mantle = Objects.requireNonNull(mantle); if (worldHeight < 3) { throw new IllegalArgumentException("worldHeight must be at least three"); } this.worldHeight = worldHeight; - this.surfaceHeight = Objects.requireNonNull(surfaceHeight); - this.chunkLoader = Objects.requireNonNull(chunkLoader); + this.terrainColumns = Objects.requireNonNull(sources.columns()); + this.naturalSolid = Objects.requireNonNull(sources.naturalSolid()); + this.chunkLoader = Objects.requireNonNull(sources.chunkLoader()); this.loadedChunks = new LongOpenHashSet(); this.openFloorCache = new Long2IntOpenHashMap(); this.openFloorCache.defaultReturnValue(CACHE_MISS); - this.surfaceHeightCache = new Long2IntOpenHashMap(); - this.surfaceHeightCache.defaultReturnValue(CACHE_MISS); + this.terrainCache = new Long2ObjectOpenHashMap<>(); } @Override @@ -140,7 +142,9 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { ? CaveVoxel.LAVA : CaveVoxel.INCOMPATIBLE_FLUID; } - return position.y() > surfaceY(position.x(), position.z()) + TerrainColumn terrain = column(position.x(), position.z()); + return position.y() > terrain.surfaceHeight() + || !terrain.terrainOwned() && !naturalSolid.test(position.x(), position.y(), position.z()) ? CaveVoxel.CAVE_AIR : CaveVoxel.SOLID; } @@ -187,14 +191,21 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { } private int surfaceY(int x, int z) { + return column(x, z).surfaceHeight(); + } + + private TerrainColumn column(int x, int z) { long key = RiverFootprint.pack(x, z); - int cached = surfaceHeightCache.get(key); - if (cached != CACHE_MISS) { + TerrainColumn cached = terrainCache.get(key); + if (cached != null) { return cached; } - int resolved = Math.max(1, Math.min(worldHeight - 2, surfaceHeight.apply(x, z))); - surfaceHeightCache.put(key, resolved); - return resolved; + TerrainColumn sampled = Objects.requireNonNull(terrainColumns.sample(x, z)); + int height = Math.clamp(sampled.surfaceHeight(), 1, worldHeight - 2); + TerrainColumn bounded = height == sampled.surfaceHeight() ? sampled + : new TerrainColumn(height, sampled.terrainOwned()); + terrainCache.put(key, bounded); + return bounded; } private T dataIfPresent(CavePosition position, Class type) { @@ -207,16 +218,16 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { return TerrainMatterView.get(mantle, position.x(), position.y(), position.z(), type); } - private static int plannedSurfaceHeight( + static TerrainColumn terrainColumn( IrisComplex complex, - RiverFootprint footprint, + HydrologyColumnSample sample, int x, int z ) { - HydrologyColumnSample sample = footprint.sample(x, z).orElse(null); - return sample == null + int surfaceHeight = sample == null ? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) : sample.terrainHeight(); + return new TerrainColumn(surfaceHeight, sample != null && sample.primarySurfaceLayer().isPresent()); } static void generateCarvingInput( @@ -263,4 +274,21 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { static boolean requiresCarvingInput(Mantle mantle, int chunkX, int chunkZ) { return !mantle.hasFlag(chunkX, chunkZ, ReservedFlag.CARVED); } + + record TerrainSources(ColumnSource columns, + SolidSource naturalSolid, BiConsumer chunkLoader) { + } + + record TerrainColumn(int surfaceHeight, boolean terrainOwned) { + } + + @FunctionalInterface + interface ColumnSource { + TerrainColumn sample(int x, int z); + } + + @FunctionalInterface + interface SolidSource { + boolean test(int x, int y, int z); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java index 6b2fa8065..e1c9e0c2f 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java @@ -96,18 +96,14 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { CaveVoxelView caveView = new MantleHydrologyCaveVoxelView( writer.getMantle(), writer.getMantle().getWorldHeight(), - (x, z) -> { - HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z); - return sample == null - ? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) - : sample.terrainHeight(); - }, + new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> MantleHydrologyCaveVoxelView.terrainColumn( + complex, complex.sampleHydrologyColumn(x, z), x, z), complex::isNaturalTerrainSolid, (inputChunkX, inputChunkZ) -> MantleHydrologyCaveVoxelView.generateCarvingInput( getEngineMantle(), complex, inputChunkX, inputChunkZ - ) + )) ); Publication publication = compilePublication( chunkX, diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 27b987419..d5d264dd1 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.DimensionStackContext; import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.engine.actuator.IrisDecorantActuator; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedModifier; @@ -97,12 +98,16 @@ public class IrisCarveModifier extends EngineAssignedModifier= column.minY() && y < column.topY() && !column.isSolid(y)) { + return; + } + } if (hydrology != null && hydrology.action() == HydrologyCaveAction.SEAL_GUARD) { PlatformBlockState normalized = resolveHydrologyState(hydrology, current, null, AIR); if (normalized != current) { @@ -419,6 +431,7 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, Map customBiomeCache) { + if (getComplex().isTerrain3DOpening(worldX, y, worldZ)) { + return getComplex().getTrueBiomeStream().get(worldX, worldZ); + } if (cavern != null && !cavern.getCustomBiome().isEmpty()) { return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome()); } 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 94dcbef30..7d512df6b 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 @@ -207,6 +207,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { @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()); + + @Desc("Optional volumetric surface terrain profile. Shapes projecting ledges, undercuts, arches, and fissures around this biome's height generators.") + private IrisTerrain3D terrain3D = null; @ArrayType(min = 1, type = IrisDepositGenerator.class) @Desc("Define biome deposit generators that add onto the existing regional and global deposit generators") private KList deposits = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java index b8c9abcef..3fcb23a3d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java @@ -68,6 +68,8 @@ final class IrisBiomeLayerGenerator { } KList heightGenerators = getLayerHeightGenerators(biome, random, rdata); + double surfaceSlope = 0D; + boolean sampledSlope = false; for (int i = 0; i < layerCount; i++) { IrisBiomePaletteLayer layer = layers.get(i); @@ -78,7 +80,11 @@ final class IrisBiomeLayerGenerator { IrisSlopeClip sc = layer.getSlopeCondition(); if (!sc.isDefault()) { - if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) { + if (!sampledSlope) { + surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz); + sampledSlope = true; + } + if (!sc.isValid(surfaceSlope)) { d = 0; } } @@ -171,6 +177,8 @@ final class IrisBiomeLayerGenerator { 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); @@ -181,7 +189,11 @@ final class IrisBiomeLayerGenerator { IrisSlopeClip sc = layer.getSlopeCondition(); if (!sc.isDefault()) { - if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) { + if (!sampledSlope) { + surfaceSlope = resolveSurfaceSlope(complex, slopeStream, wx, height, wz); + sampledSlope = true; + } + if (!sc.isValid(surfaceSlope)) { d = 0; } } @@ -206,11 +218,19 @@ final class IrisBiomeLayerGenerator { return real; } - private static ProceduralStream resolveSlopeStream( + private static double resolveSurfaceSlope( IrisComplex complex, - ProceduralStream slopeStream + ProceduralStream slopeStream, + double x, + int height, + double z ) { - return slopeStream == null ? complex.getSlopeStream() : slopeStream; + if (slopeStream != null) { + return slopeStream.getDouble(x, z); + } + return complex.hasTerrain3D() + ? complex.terrainSurfaceSlope((int) Math.floor(x), height, (int) Math.floor(z)) + : complex.getSlopeStream().getDouble(x, z); } static KList generateSeaLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepth, IrisData rdata) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 9bdf2163e..0da44881b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -219,7 +219,7 @@ public class IrisDimension extends IrisRegistrant { @MaxNumber(256) @Desc("Minimum air gap in blocks between the lower terrain surface and the upper terrain surface.") private int upperDimensionGap = 32; - @Desc("When true, cave carving will cut through the upper dimension terrain. When false, the upper terrain is a solid untouched mass.") + @Desc("When true, cave carving cuts through the upper dimension terrain. When false, cave carving leaves the upper terrain unchanged, including its biome terrain3D openings.") private boolean upperDimensionCarving = false; @Desc("When true, objects from the mantle (structures, trees, etc.) can be placed in the upper dimension terrain zone. When false, the upper terrain is protected from object placement.") private boolean upperDimensionObjects = false; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTerrain3D.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTerrain3D.java new file mode 100644 index 000000000..db0c1775c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTerrain3D.java @@ -0,0 +1,118 @@ +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.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Snippet("terrain-3d") +@Accessors(chain = true) +@NoArgsConstructor +@Data +@Desc("Volumetric surface terrain that adds projecting rock and removes undercuts and fissures around the biome's height generators.") +public class IrisTerrain3D { + @Desc("Enable this biome's volumetric terrain profile.") + private boolean enabled = true; + + @Desc("Seed offset for the surface density and fissure fields.") + private long seed = 0; + + @MinNumber(0) + @MaxNumber(128) + @Desc("Maximum positive or negative surface density displacement in blocks.") + private double amplitude = 32; + + @MinNumber(8) + @MaxNumber(4096) + @Desc("Horizontal feature size in blocks for a density style with its default zoom.") + private double horizontalScale = 96; + + @MinNumber(8) + @MaxNumber(4096) + @Desc("Vertical feature size in blocks. Shorter scales relative to amplitude permit overlapping ledges and overhangs.") + private double verticalScale = 24; + + @Desc("Signed three-dimensional noise that displaces terrain density. Style zoom multiplies the configured feature sizes.") + private IrisGeneratorStyle densityStyle = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + + @MinNumber(0) + @MaxNumber(128) + @Desc("Maximum additional fissure depth in blocks. Zero disables fissures.") + private double crackDepth = 0; + + @MinNumber(0.25) + @MaxNumber(64) + @Desc("Approximate fissure half-width in blocks around the fissure field's zero crossings.") + private double crackWidth = 4; + + @MinNumber(8) + @MaxNumber(4096) + @Desc("Horizontal fissure feature size in blocks. Fissures use four times this vertical scale.") + private double crackScale = 96; + + @Desc("Noise whose zero crossings define tall narrow fissures.") + private IrisGeneratorStyle crackStyle = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + + @MinNumber(0) + @MaxNumber(16) + @Desc("Minimum original terrain slope, measured as rise divided by horizontal distance, before shaping starts. Zero disables slope gating.") + private double minimumSlope = 0.15; + + @MinNumber(0.001) + @MaxNumber(16) + @Desc("Slope interval above minimumSlope over which shaping reaches full strength.") + private double slopeFade = 0.35; + + @MinNumber(0) + @MaxNumber(128) + @Desc("Height above the dimension fluid level below which terrain remains solid and unchanged.") + private double fluidClearance = 8; + + @MinNumber(1) + @MaxNumber(128) + @Desc("Vertical distance over which shaping grows from zero above the fluid clearance.") + private double fluidFade = 24; + + public void validate() { + requireRange("amplitude", amplitude, 0D, 128D); + requireRange("horizontalScale", horizontalScale, 8D, 4096D); + requireRange("verticalScale", verticalScale, 8D, 4096D); + requireRange("crackDepth", crackDepth, 0D, 128D); + requireRange("crackWidth", crackWidth, 0.25D, 64D); + requireRange("crackScale", crackScale, 8D, 4096D); + requireRange("minimumSlope", minimumSlope, 0D, 16D); + requireRange("slopeFade", slopeFade, 0.001D, 16D); + requireRange("fluidClearance", fluidClearance, 0D, 128D); + requireRange("fluidFade", fluidFade, 1D, 128D); + if (densityStyle == null || crackStyle == null) { + throw new IllegalArgumentException("terrain3D densityStyle and crackStyle must be style objects or snippets"); + } + validateStyle(densityStyle, "densityStyle", 0); + validateStyle(crackStyle, "crackStyle", 0); + } + + private static void validateStyle(IrisGeneratorStyle style, String path, int depth) { + if (depth > 32 || style.getStyle() == null) { + throw new IllegalArgumentException("terrain3D." + path + " must use a known style with at most 32 nested fractures"); + } + requireRange(path + ".zoom", style.getZoom(), 0.00001D, Double.MAX_VALUE); + requireRange(path + ".cellularFrequency", style.getCellularFrequency(), 0D, Double.MAX_VALUE); + requireRange(path + ".cellularZoom", style.getCellularZoom(), 0.00001D, Double.MAX_VALUE); + requireRange(path + ".multiplier", style.getMultiplier(), 0.00001D, Double.MAX_VALUE); + requireRange(path + ".exponent", style.getExponent(), 0.01562D, 64D); + requireRange(path + ".cacheSize", style.getCacheSize(), 0D, 8192D); + if (style.getFracture() != null) { + validateStyle(style.getFracture(), path + ".fracture", depth + 1); + } + } + + private static void requireRange(String name, double value, double minimum, double maximum) { + if (!Double.isFinite(value) || value < minimum || value > maximum) { + throw new IllegalArgumentException("terrain3D." + name + " must be finite and between " + + minimum + " and " + maximum); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DColumn.java b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DColumn.java new file mode 100644 index 000000000..80693c47d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DColumn.java @@ -0,0 +1,104 @@ +package art.arcane.iris.engine.terrain; + +import java.util.Arrays; + +public final class Terrain3DColumn { + private final double baseHeight; + private final int minY; + private final boolean shaped; + private final int[] boundaries; + + Terrain3DColumn(double baseHeight, int minY, boolean shaped, int[] boundaries) { + this.baseHeight = baseHeight; + this.minY = minY; + this.shaped = shaped; + this.boundaries = boundaries.clone(); + } + + public static Terrain3DColumn unshaped(double baseHeight, int height) { + int top = Math.clamp(Math.round(baseHeight), 0, height - 1); + return new Terrain3DColumn(baseHeight, top + 1, false, new int[]{0, top}); + } + + public double baseHeight() { + return baseHeight; + } + + public int topY() { + return boundaries[boundaries.length - 1]; + } + + public int minY() { + return minY; + } + + public boolean shaped() { + return shaped; + } + + public int spanCount() { + return boundaries.length / 2; + } + + public int floor(int span) { + return boundaries[span * 2 + 1]; + } + + public int ceiling(int span) { + return boundaries[span * 2]; + } + + public boolean isSolid(int y) { + return surfaceY(y) >= 0; + } + + public int highestSolidY(int maximumY) { + for (int index = boundaries.length - 2; index >= 0; index -= 2) { + if (maximumY >= boundaries[index]) { + return Math.min(maximumY, boundaries[index + 1]); + } + } + return -1; + } + + public int surfaceY(int y) { + for (int index = 0; index < boundaries.length; index += 2) { + if (y < boundaries[index]) { + return -1; + } + if (y <= boundaries[index + 1]) { + return boundaries[index + 1]; + } + } + return -1; + } + + public int nearestSurfaceY(int y) { + int nearest = boundaries[1]; + long distance = Math.abs((long) nearest - y); + for (int index = 3; index < boundaries.length; index += 2) { + long candidateDistance = Math.abs((long) boundaries[index] - y); + if (candidateDistance < distance) { + nearest = boundaries[index]; + distance = candidateDistance; + } + } + return nearest; + } + + @Override + public boolean equals(Object value) { + return this == value || value instanceof Terrain3DColumn other + && Double.compare(baseHeight, other.baseHeight) == 0 + && minY == other.minY && shaped == other.shaped + && Arrays.equals(boundaries, other.boundaries); + } + + @Override + public int hashCode() { + int result = Double.hashCode(baseHeight); + result = 31 * result + minY; + result = 31 * result + Boolean.hashCode(shaped); + return 31 * result + Arrays.hashCode(boundaries); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java new file mode 100644 index 000000000..b2924a5d8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java @@ -0,0 +1,177 @@ +package art.arcane.iris.engine.terrain; + +import it.unimi.dsi.fastutil.HashCommon; + +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicIntegerArray; + +final class Terrain3DFragmentFilter { + private static final int MAXIMUM_FRAGMENT_VOLUME = 512; + private static final int VISITED_CAPACITY = 2048; + private static final int UNKNOWN = 0; + private static final int KEEP = 1; + private static final int REMOVE = 2; + + private final ColumnSource source; + private final ThreadLocal traversals = ThreadLocal.withInitial(Traversal::new); + + Terrain3DFragmentFilter(ColumnSource source) { + this.source = Objects.requireNonNull(source, "Raw density columns"); + } + + Terrain3DColumn column(int x, int z) { + DensityColumn density = source.column(x, z); + Terrain3DColumn resolved = density.resolved; + if (resolved != null) { + return resolved; + } + Terrain3DColumn raw = density.raw; + int removed = 0; + for (int span = 1; span < raw.spanCount(); span++) { + if (density.state(span) == UNKNOWN) { + traversals.get().classify(x, z, density, span); + } + if (density.state(span) == REMOVE) { + removed++; + } + } + resolved = removed == 0 ? raw : retainSpans(density, removed); + density.resolved = resolved; + return resolved; + } + + void clear() { + traversals.remove(); + } + + private Terrain3DColumn retainSpans(DensityColumn density, int removed) { + Terrain3DColumn raw = density.raw; + int[] boundaries = new int[(raw.spanCount() - removed) * 2]; + int index = 0; + for (int span = 0; span < raw.spanCount(); span++) { + if (density.state(span) != REMOVE) { + boundaries[index++] = raw.ceiling(span); + boundaries[index++] = raw.floor(span); + } + } + return new Terrain3DColumn(raw.baseHeight(), raw.minY(), raw.shaped(), boundaries); + } + + @FunctionalInterface + interface ColumnSource { + DensityColumn column(int x, int z); + } + + static final class DensityColumn { + private final Terrain3DColumn raw; + private final AtomicIntegerArray states; + private volatile Terrain3DColumn resolved; + + DensityColumn(Terrain3DColumn raw) { + this.raw = Objects.requireNonNull(raw, "Raw density column"); + states = raw.spanCount() == 1 ? null : new AtomicIntegerArray(raw.spanCount() - 1); + resolved = states == null ? raw : null; + } + + private int state(int span) { + return span == 0 ? KEEP : states.get(span - 1); + } + } + + private final class Traversal { + private final int[] xs = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; + private final int[] zs = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; + private final int[] spans = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; + private final DensityColumn[] columns = new DensityColumn[MAXIMUM_FRAGMENT_VOLUME + 1]; + private final int[] visited = new int[VISITED_CAPACITY]; + private final int[] visitedSlots = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; + private int count; + private int volume; + private int decision; + + private void classify(int x, int z, DensityColumn density, int span) { + try { + visit(x, z, density, span); + for (int cursor = 0; cursor < count && decision == UNKNOWN; cursor++) { + Terrain3DColumn raw = columns[cursor].raw; + int bottom = raw.ceiling(spans[cursor]); + int top = raw.floor(spans[cursor]); + inspect((long) xs[cursor] - 1, zs[cursor], bottom, top); + inspect((long) xs[cursor] + 1, zs[cursor], bottom, top); + inspect(xs[cursor], (long) zs[cursor] - 1, bottom, top); + inspect(xs[cursor], (long) zs[cursor] + 1, bottom, top); + } + int result = decision == UNKNOWN ? REMOVE : decision; + for (int index = 0; index < count; index++) { + columns[index].states.set(spans[index] - 1, result); + } + } finally { + for (int index = 0; index < count; index++) { + visited[visitedSlots[index]] = 0; + } + Arrays.fill(columns, 0, count, null); + count = 0; + volume = 0; + decision = UNKNOWN; + } + } + + private void inspect(long x, long z, int bottom, int top) { + if (decision != UNKNOWN) { + return; + } + if (x < Integer.MIN_VALUE || x > Integer.MAX_VALUE + || z < Integer.MIN_VALUE || z > Integer.MAX_VALUE) { + decision = KEEP; + return; + } + DensityColumn density = source.column((int) x, (int) z); + Terrain3DColumn raw = density.raw; + for (int span = 0; span < raw.spanCount(); span++) { + if (raw.ceiling(span) > top || decision != UNKNOWN) { + return; + } + if (raw.floor(span) >= bottom) { + visit((int) x, (int) z, density, span); + } + } + } + + private void visit(int x, int z, DensityColumn density, int span) { + int state = density.state(span); + if (state != UNKNOWN) { + decision = state; + return; + } + int slot = visitedSlot(x, z, span); + if (visited[slot] != 0) { + return; + } + visited[slot] = count + 1; + visitedSlots[count] = slot; + xs[count] = x; + zs[count] = z; + spans[count] = span; + columns[count++] = density; + volume += density.raw.floor(span) - density.raw.ceiling(span) + 1; + if (volume > MAXIMUM_FRAGMENT_VOLUME) { + decision = KEEP; + } + } + + private int visitedSlot(int x, int z, int span) { + long coordinate = (long) x << 32 ^ z & 0xffffffffL; + int slot = (int) HashCommon.mix(coordinate ^ span * 0x9e3779b97f4a7c15L) + & (VISITED_CAPACITY - 1); + while (visited[slot] != 0) { + int index = visited[slot] - 1; + if (xs[index] == x && zs[index] == z && spans[index] == span) { + return slot; + } + slot = (slot + 1) & (VISITED_CAPACITY - 1); + } + return slot; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java new file mode 100644 index 000000000..07570e362 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java @@ -0,0 +1,438 @@ +package art.arcane.iris.engine.terrain; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisTerrain3D; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; + +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReferenceArray; + +public final class Terrain3DRuntime { + private static final int STEP = 4; + private static final int CACHE_STRIPES = 16; + private static final long DENSITY_SALT = 0x536E4A11C924B3D7L; + private static final long CRACK_SALT = 0x7839A16DC4052EFBL; + private static final NodeSample EMPTY_SAMPLE = new NodeSample(0D, 0D, 0D); + private static final CompiledProfile DISABLED = new CompiledProfile(null, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, null, null); + + private final Sources sources; + private final Options options; + private final NoiseFactory noiseFactory; + private final BoundedCache columns; + private final BoundedCache anchors; + private final Terrain3DFragmentFilter fragments; + private final Map profiles = new IdentityHashMap<>(); + private final ThreadLocal localColumn = ThreadLocal.withInitial(ColumnMemo::new); + private volatile Object cacheGeneration = new Object(); + + public Terrain3DRuntime(Sources sources, Options options) { + this(sources, options, (style, seed) -> { + CNG noise = style.createNoCache(new RNG(seed), options.data()); + return noise::noiseFastSigned3D; + }); + } + + Terrain3DRuntime(Sources sources, Options options, NoiseFactory noiseFactory) { + this.sources = Objects.requireNonNull(sources, "Terrain sources"); + this.options = Objects.requireNonNull(options, "Terrain options"); + this.noiseFactory = Objects.requireNonNull(noiseFactory, "Terrain noise factory"); + columns = new BoundedCache<>(options.maximumColumns()); + anchors = new BoundedCache<>(Math.max(CACHE_STRIPES, options.maximumColumns() / 4)); + fragments = new Terrain3DFragmentFilter(this::rawColumn); + } + + public boolean active() { + return options.enabled(); + } + + public Terrain3DColumn column(int x, int z) { + if (!active()) { + return Terrain3DColumn.unshaped(baseHeight(x, z), options.height()); + } + long key = pack(x, z); + Object generation = cacheGeneration; + ColumnMemo memo = localColumn.get(); + if (memo.generation == generation && memo.key == key) { + return memo.column; + } + Terrain3DColumn column = fragments.column(x, z); + memo.key = key; + memo.column = column; + memo.generation = generation; + return column; + } + + public void clear() { + columns.clear(); + anchors.clear(); + fragments.clear(); + synchronized (profiles) { + profiles.clear(); + } + cacheGeneration = new Object(); + localColumn.remove(); + } + + private Terrain3DFragmentFilter.DensityColumn rawColumn(int x, int z) { + long key = pack(x, z); + Terrain3DFragmentFilter.DensityColumn cached = columns.get(key); + return cached == null ? columns.putIfAbsent(key, + new Terrain3DFragmentFilter.DensityColumn(createColumn(x, z))) : cached; + } + + private Terrain3DColumn createColumn(int x, int z) { + double baseHeight = baseHeight(x, z); + int gridX = Math.floorDiv(x, STEP) * STEP; + int gridZ = Math.floorDiv(z, STEP) * STEP; + Anchor northWest = anchor(gridX, gridZ); + Anchor northEast = anchor(gridX + STEP, gridZ); + Anchor southWest = anchor(gridX, gridZ + STEP); + Anchor southEast = anchor(gridX + STEP, gridZ + STEP); + double dx = (x - gridX) / (double) STEP; + double dz = (z - gridZ) / (double) STEP; + double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(), + southWest.amplitude(), southEast.amplitude(), dx, dz); + double crackDepth = interpolate(northWest.crackDepth(), northEast.crackDepth(), + southWest.crackDepth(), southEast.crackDepth(), dx, dz); + if (amplitude + crackDepth <= 0D) { + return Terrain3DColumn.unshaped(baseHeight, options.height()); + } + double crackWidth = interpolate(northWest.profile.crackWidth, northEast.profile.crackWidth, + southWest.profile.crackWidth, southEast.profile.crackWidth, dx, dz); + int minimum = Math.max(Math.max(1, (int) Math.floor(options.fluidHeight()) + 1), + (int) Math.floor(baseHeight - amplitude - crackDepth)); + int maximum = Math.min(options.height() - 1, (int) Math.ceil(baseHeight + amplitude)); + if (minimum > maximum) { + return Terrain3DColumn.unshaped(baseHeight, options.height()); + } + int[] boundaries = new int[16]; + int count = 1; + boundaries[0] = 0; + boolean previousSolid = true; + for (int gridY = Math.floorDiv(minimum, STEP) * STEP; gridY <= maximum; gridY += STEP) { + NodeSample lowerNW = northWest.sample(gridY); + NodeSample lowerNE = northEast.sample(gridY); + NodeSample lowerSW = southWest.sample(gridY); + NodeSample lowerSE = southEast.sample(gridY); + NodeSample upperNW = northWest.sample(gridY + STEP); + NodeSample upperNE = northEast.sample(gridY + STEP); + NodeSample upperSW = southWest.sample(gridY + STEP); + NodeSample upperSE = southEast.sample(gridY + STEP); + double lowerDisplacement = interpolate(lowerNW.displacement, lowerNE.displacement, + lowerSW.displacement, lowerSE.displacement, dx, dz); + double upperDisplacement = interpolate(upperNW.displacement, upperNE.displacement, + upperSW.displacement, upperSE.displacement, dx, dz); + double lowerCrack = interpolate(lowerNW.crackDistance, lowerNE.crackDistance, + lowerSW.crackDistance, lowerSE.crackDistance, dx, dz); + double upperCrack = interpolate(upperNW.crackDistance, upperNE.crackDistance, + upperSW.crackDistance, upperSE.crackDistance, dx, dz); + double lowerDepth = interpolate(lowerNW.crackDepth, lowerNE.crackDepth, + lowerSW.crackDepth, lowerSE.crackDepth, dx, dz); + double upperDepth = interpolate(upperNW.crackDepth, upperNE.crackDepth, + upperSW.crackDepth, upperSE.crackDepth, dx, dz); + int lastY = Math.min(maximum, gridY + STEP - 1); + for (int y = Math.max(minimum, gridY); y <= lastY; y++) { + double dy = (y - gridY) / (double) STEP; + double displacement = lerp(lowerDisplacement, upperDisplacement, dy); + double fissure = 0D; + if (crackDepth > 0D && crackWidth > 0D) { + double distance = Math.abs(lerp(lowerCrack, upperCrack, dy)); + double ridge = Math.max(0D, 1D - distance / crackWidth); + fissure = lerp(lowerDepth, upperDepth, dy) * ridge * ridge; + } + boolean solid = baseHeight + 0.5D - y + displacement - fissure >= 0D; + if (solid != previousSolid) { + if (count == boundaries.length) { + boundaries = Arrays.copyOf(boundaries, count * 2); + } + boundaries[count++] = solid ? y : y - 1; + previousSolid = solid; + } + } + } + if (previousSolid) { + if (count == boundaries.length) { + boundaries = Arrays.copyOf(boundaries, count + 1); + } + boundaries[count++] = maximum; + } + return new Terrain3DColumn(baseHeight, minimum, true, Arrays.copyOf(boundaries, count)); + } + + private Anchor anchor(int x, int z) { + long key = pack(x, z); + Anchor cached = anchors.get(key); + return cached == null ? anchors.putIfAbsent(key, createAnchor(x, z)) : cached; + } + + private Anchor createAnchor(int x, int z) { + IrisBiome biome = sources.biomes().sample(x, z); + CompiledProfile profile = profile(biome == null ? null : biome.getTerrain3D()); + if (profile == DISABLED) { + return new Anchor(x, z, profile, 0D); + } + double height = baseHeight(x, z); + double elevationStrength = smooth((height - options.fluidHeight() - profile.fluidClearance) + / profile.fluidFade); + if (elevationStrength <= 0D) { + return new Anchor(x, z, profile, 0D); + } + double slopeStrength = 1D; + if (profile.minimumSlope > 0D) { + double east = (baseHeight(x + STEP, z) - height) / STEP; + double south = (baseHeight(x, z + STEP) - height) / STEP; + double slope = StrictMath.sqrt(east * east + south * south); + slopeStrength = smooth((slope - profile.minimumSlope) / profile.slopeFade); + } + return new Anchor(x, z, profile, elevationStrength * slopeStrength); + } + + private CompiledProfile profile(IrisTerrain3D config) { + if (config == null || !config.isEnabled()) { + return DISABLED; + } + synchronized (profiles) { + CompiledProfile cached = profiles.get(config); + if (cached != null) { + return cached; + } + } + CompiledProfile compiled = compile(config); + synchronized (profiles) { + CompiledProfile existing = profiles.get(config); + if (existing != null) { + return existing; + } + profiles.put(config, compiled); + return compiled; + } + } + + private CompiledProfile compile(IrisTerrain3D config) { + config.validate(); + double amplitude = config.getAmplitude(); + double crackDepth = config.getCrackDepth(); + double horizontalScale = config.getHorizontalScale(); + double verticalScale = config.getVerticalScale(); + double crackScale = config.getCrackScale(); + double crackWidth = config.getCrackWidth(); + double minimumSlope = config.getMinimumSlope(); + double slopeFade = config.getSlopeFade(); + double fluidClearance = config.getFluidClearance(); + double fluidFade = config.getFluidFade(); + if (amplitude + crackDepth == 0D) { + return DISABLED; + } + long seed = options.seed() ^ config.getSeed(); + NoiseSource density = amplitude == 0D ? null : noiseFactory.create( + Objects.requireNonNull(config.getDensityStyle(), "terrain3D.densityStyle"), seed ^ DENSITY_SALT); + NoiseSource cracks = crackDepth == 0D ? null : noiseFactory.create( + Objects.requireNonNull(config.getCrackStyle(), "terrain3D.crackStyle"), seed ^ CRACK_SALT); + return new CompiledProfile(config, amplitude, crackDepth, crackWidth, crackScale, + 64D / horizontalScale, 64D / verticalScale, 64D / crackScale, + minimumSlope, slopeFade, fluidClearance, fluidFade, density, cracks); + } + + private double baseHeight(int x, int z) { + double height = sources.heights().sample(x, z); + if (!Double.isFinite(height)) { + throw new IllegalStateException("Nonfinite terrain height at " + x + "," + z); + } + return height; + } + + private static double signed(NoiseSource source, double x, double y, double z) { + double sampled = source.sample(x, y, z); + if (!Double.isFinite(sampled)) { + throw new IllegalStateException("Nonfinite terrain3D density at " + x + "," + y + "," + z); + } + return Math.clamp(sampled, -1D, 1D); + } + + private static double interpolate(double northWest, double northEast, double southWest, + double southEast, double x, double z) { + return lerp(lerp(northWest, northEast, x), lerp(southWest, southEast, x), z); + } + + private static double lerp(double lower, double upper, double fraction) { + return lower + (upper - lower) * fraction; + } + + private static double smooth(double value) { + double bounded = Math.clamp(value, 0D, 1D); + return bounded * bounded * (3D - 2D * bounded); + } + + private static long pack(int x, int z) { + return (long) x << 32 ^ z & 0xffffffffL; + } + + public record Sources(BaseHeightSource heights, BiomeSource biomes) { + public Sources { + Objects.requireNonNull(heights, "Base terrain height source"); + Objects.requireNonNull(biomes, "Base terrain biome source"); + } + } + + public record Options(long seed, int height, double fluidHeight, IrisData data, + boolean enabled, int maximumColumns) { + public Options { + if (height < 2 || height > 4096 || !Double.isFinite(fluidHeight) + || maximumColumns < CACHE_STRIPES) { + throw new IllegalArgumentException("Invalid volumetric terrain runtime options"); + } + } + } + + @FunctionalInterface + public interface BaseHeightSource { + double sample(int x, int z); + } + + @FunctionalInterface + public interface BiomeSource { + IrisBiome sample(int x, int z); + } + + @FunctionalInterface + interface NoiseFactory { + NoiseSource create(IrisGeneratorStyle style, long seed); + } + + @FunctionalInterface + interface NoiseSource { + double sample(double x, double y, double z); + } + + private static final class ColumnMemo { + private Object generation; + private long key; + private Terrain3DColumn column; + } + + private final class Anchor { + private final int x; + private final int z; + private final CompiledProfile profile; + private final double strength; + private final AtomicReferenceArray samples; + + private Anchor(int x, int z, CompiledProfile profile, double strength) { + this.x = x; + this.z = z; + this.profile = profile; + this.strength = strength; + samples = strength == 0D ? null + : new AtomicReferenceArray<>(Math.ceilDiv(options.height(), STEP) + 1); + } + + private double amplitude() { + return profile.amplitude * strength; + } + + private double crackDepth() { + return profile.crackDepth * strength; + } + + private NodeSample sample(int y) { + if (samples == null) { + return EMPTY_SAMPLE; + } + int index = y / STEP; + NodeSample cached = samples.get(index); + if (cached != null) { + return cached; + } + double fade = strength * smooth((y - options.fluidHeight() - profile.fluidClearance) + / profile.fluidFade); + double displacement = profile.density == null || fade == 0D ? 0D + : profile.amplitude * fade * signed(profile.density, + x * profile.horizontalFrequency, y * profile.verticalFrequency, z * profile.horizontalFrequency); + double crackDistance = profile.cracks == null || fade == 0D ? 0D + : profile.crackScale * 0.5D * signed(profile.cracks, + x * profile.crackFrequency, y * profile.crackFrequency * 0.25D, z * profile.crackFrequency); + NodeSample computed = new NodeSample(displacement, crackDistance, profile.crackDepth * fade); + return samples.compareAndSet(index, null, computed) ? computed : samples.get(index); + } + } + + private record NodeSample(double displacement, double crackDistance, double crackDepth) { + } + + private record CompiledProfile(IrisTerrain3D config, double amplitude, double crackDepth, + double crackWidth, double crackScale, double horizontalFrequency, + double verticalFrequency, double crackFrequency, + double minimumSlope, double slopeFade, double fluidClearance, + double fluidFade, NoiseSource density, NoiseSource cracks) { + } + + private static final class BoundedCache { + private final CacheStripe[] stripes; + + @SuppressWarnings("unchecked") + private BoundedCache(int maximumSize) { + stripes = (CacheStripe[]) new CacheStripe[CACHE_STRIPES]; + for (int index = 0; index < stripes.length; index++) { + stripes[index] = new CacheStripe<>(Math.ceilDiv(maximumSize, CACHE_STRIPES)); + } + } + + private T get(long key) { + return stripe(key).get(key); + } + + private T putIfAbsent(long key, T value) { + return stripe(key).putIfAbsent(key, value); + } + + private void clear() { + for (CacheStripe stripe : stripes) { + stripe.clear(); + } + } + + private CacheStripe stripe(long key) { + long mixed = key ^ (key >>> 33); + mixed *= 0xff51afd7ed558ccdL; + mixed ^= mixed >>> 33; + return stripes[(int) mixed & (CACHE_STRIPES - 1)]; + } + } + + private static final class CacheStripe { + private final int maximumSize; + private final Long2ObjectLinkedOpenHashMap entries = new Long2ObjectLinkedOpenHashMap<>(16); + + private CacheStripe(int maximumSize) { + this.maximumSize = maximumSize; + } + + private synchronized T get(long key) { + return entries.getAndMoveToLast(key); + } + + private synchronized T putIfAbsent(long key, T value) { + T existing = entries.getAndMoveToLast(key); + if (existing != null) { + return existing; + } + entries.putAndMoveToLast(key, value); + if (entries.size() > maximumSize) { + entries.removeFirst(); + } + return value; + } + + private synchronized void clear() { + entries.clear(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java index c3c3aed2c..2b8b111e1 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java @@ -1,12 +1,16 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionStack; import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.volmlib.util.collection.KList; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import java.nio.file.Files; import java.nio.file.Path; @@ -14,9 +18,57 @@ import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class PackExportClosureTest { + @Rule + public final TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void exportedSnippetsLoadTerrainAndNestedStylesFromTheTargetPack() throws Exception { + Path source = temporary.newFolder("source").toPath(); + Path target = temporary.newFolder("target").toPath(); + Path terrain = source.resolve("snippet/terrain-3d/cliffs/main.json"); + Path density = source.resolve("snippet/style/cliffs/density.json"); + Path fracture = source.resolve("snippet/style/cliffs/fracture.json"); + Path decorator = source.resolve("snippet/decorator/ledge.json"); + for (Path file : List.of(terrain, density, fracture, decorator)) { + Files.createDirectories(file.getParent()); + } + Files.writeString(terrain, "{\"amplitude\":57,\"densityStyle\":\"snippet/style/cliffs/density\"}"); + Files.writeString(density, "{\"style\":\"SIMPLEX\",\"zoom\":1.5,\"fracture\":\"snippet/style/cliffs/fracture\"}"); + Files.writeString(fracture, "{\"style\":\"PERLIN\",\"zoom\":2}"); + Files.writeString(decorator, "{\"chance\":0.2}"); + Files.writeString(source.resolve("snippet/notes.txt"), "not pack data"); + + String hash = PackExportClosure.copySnippets(source.toFile(), target.toFile()); + + assertFalse(hash.isEmpty()); + assertEquals(hash, PackExportClosure.copySnippets(source.toFile(), target.toFile())); + assertTrue(Files.isRegularFile(target.resolve("snippet/decorator/ledge.json"))); + assertFalse(Files.exists(target.resolve("snippet/notes.txt"))); + IrisData data = IrisData.openDatapackCompiler(target.toFile()); + try { + IrisBiome biome = data.getGson().fromJson( + "{\"terrain3D\":\"snippet/terrain-3d/cliffs/main\"}", IrisBiome.class); + assertEquals(57D, biome.getTerrain3D().getAmplitude(), 0D); + assertEquals(1.5D, biome.getTerrain3D().getDensityStyle().getZoom(), 0D); + assertEquals(2D, biome.getTerrain3D().getDensityStyle().getFracture().getZoom(), 0D); + } finally { + data.close(); + } + } + + @Test + public void packsWithoutSnippetsDoNotCreateAnExportFolder() throws Exception { + Path source = temporary.newFolder("without-snippets").toPath(); + Path target = temporary.getRoot().toPath().resolve("absent-export"); + + assertEquals("", PackExportClosure.copySnippets(source.toFile(), target.toFile())); + assertFalse(Files.exists(target)); + } + private static IrisObjectPlacement placement(String objectKey, String... markerKeys) { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setPlace(new KList<>(objectKey)); @@ -139,8 +191,8 @@ public class PackExportClosureTest { assertTrue("modded packager must export expression-backed stack blend styles", modded.contains("dm.getExpressionLoader().getPossibleKeys()") && modded.contains("\"expressions\"")); - assertTrue("modded packager must retain referenced style snippets", - modded.contains("copyStyleSnippets(packFolder, folder)")); + assertTrue("modded packager must retain all snippet types and nested references", + modded.contains("PackExportClosure.copySnippets(packFolder, folder)")); int moddedValidation = modded.indexOf("PackValidator.validateForPackaging(packFolder)"); assertTrue("modded packager must validate before mutating package state", moddedValidation >= 0 && moddedValidation < modded.indexOf("IO.delete(folder)")); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackTerrain3DValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackTerrain3DValidatorTest.java new file mode 100644 index 000000000..1d9cd3790 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackTerrain3DValidatorTest.java @@ -0,0 +1,134 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackTerrain3DValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsInlineProfilesAndReferencedProfileAndStyleSnippets() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/mountains.json", """ + {"terrain3D":{"amplitude":64,"verticalScale":16,"densityStyle":{"style":"SIMPLEX"}}} + """); + write(pack, "biomes/cliffs.json", """ + {"terrain3D":"snippet/terrain-3d/cliffs"} + """); + write(pack, "snippet/terrain-3d/cliffs.json", """ + {"$schema":"../../schema.json","crackDepth":32,"densityStyle":"snippet/style/terrain"} + """); + write(pack, "snippet/style/terrain.json", """ + {"style":"SIMPLEX","fracture":"snippet/style/warp"} + """); + write(pack, "snippet/style/warp.json", """ + {"style":"SIMPLEX","zoom":2,"multiplier":12} + """); + + assertTrue(PackTerrain3DValidator.validate(pack).toString(), PackTerrain3DValidator.validate(pack).isEmpty()); + } + + @Test + public void acceptsOmissionAndNullWithoutEnablingProfiles() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/plain.json", "{}"); + write(pack, "biomes/ocean.json", "{\"terrain3D\":null}"); + write(pack, "biomes/disabled.json", "{\"terrain3D\":{\"enabled\":false}}"); + + assertTrue(PackTerrain3DValidator.validate(pack).isEmpty()); + } + + @Test + public void rejectsUnknownFieldsAndNonBooleanEnableFlags() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/unknown.json", "{\"terrain3D\":{\"amplitdue\":64}}"); + write(pack, "biomes/boolean.json", "{\"terrain3D\":{\"enabled\":\"true\"}}"); + write(pack, "biomes/array.json", "{\"terrain3D\":[]}"); + write(pack, "biomes/numeric.json", "{\"terrain3D\":{\"amplitude\":\"64\"}}"); + + List errors = PackTerrain3DValidator.validate(pack); + assertTrue(errors.stream().anyMatch(error -> error.contains("amplitdue is not a terrain3D field"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("enabled must be a JSON boolean"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("terrain3D must be an object or snippet reference"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("amplitude must be a finite JSON number"))); + } + + @Test + public void appliesModelBoundsToInlineAndDisabledSnippetProfiles() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/amplitude.json", "{\"terrain3D\":{\"amplitude\":129}}"); + write(pack, "snippet/terrain-3d/disabled.json", "{\"enabled\":false,\"verticalScale\":0}"); + write(pack, "biomes/nonfinite.json", "{\"terrain3D\":{\"crackDepth\":1e999}}"); + write(pack, "biomes/seed.json", "{\"terrain3D\":{\"seed\":1.5}}"); + write(pack, "biomes/huge-seed.json", "{\"terrain3D\":{\"seed\":9223372036854775808}}"); + + List errors = PackTerrain3DValidator.validate(pack); + assertTrue(errors.stream().anyMatch(error -> error.contains("amplitude must be finite and between"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("verticalScale must be finite and between"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("crackDepth must be a finite JSON number"))); + assertTrue(errors.stream().filter(error -> error.contains("seed must be an integer")).count() == 2); + } + + @Test + public void rejectsUnavailableAndUnreadableCurrentSnippets() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/missing.json", "{\"terrain3D\":\"snippet/terrain-3d/missing\"}"); + write(pack, "biomes/style.json", "{\"terrain3D\":{\"densityStyle\":\"snippet/style/missing\"}}"); + write(pack, "snippet/terrain-3d/broken.json", "{\"amplitude\":"); + write(pack, "biomes/path.json", "{\"terrain3D\":\"snippet/terrain-3d/../../outside\"}"); + + List errors = PackTerrain3DValidator.validate(pack); + assertTrue(errors.stream().anyMatch(error -> error.contains("unavailable terrain-3d snippet"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("unavailable style snippet"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("contains unreadable JSON"))); + } + + @Test + public void rejectsMalformedNestedStylesAndCyclicStyleReferences() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "biomes/shape.json", "{\"terrain3D\":{\"densityStyle\":true}}"); + write(pack, "biomes/style.json", "{\"terrain3D\":{\"densityStyle\":{\"style\":\"UNKNOWN\"}}}"); + write(pack, "biomes/nested.json", """ + {"terrain3D":{"densityStyle":{"fracture":{"style":"SIMPLEX","zoom":0,"wrong":1}}}} + """); + write(pack, "biomes/cycle.json", "{\"terrain3D\":{\"densityStyle\":\"snippet/style/cycle\"}}"); + write(pack, "snippet/style/cycle.json", "{\"fracture\":\"snippet/style/cycle\"}"); + + List errors = PackTerrain3DValidator.validate(pack); + assertTrue(errors.stream().anyMatch(error -> error.contains("densityStyle must be an object or snippet reference"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("is not a known noise style"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("fracture.zoom must be between"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("wrong is not a generator style field"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("maximum style nesting depth"))); + } + + @Test + public void invalidTerrainProfileBlocksFullPackValidation() throws Exception { + File pack = temporaryFolder.newFolder(); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"terrain3D\":{\"amplitude\":129}}"); + + PackValidationResult result = PackValidator.validate(pack); + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().stream() + .anyMatch(error -> error.contains("terrain3D.amplitude must be finite and between"))); + } + + private static void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java index b5e295861..a23f62fe9 100644 --- a/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.history.SavedBiomeUnavailableException; @@ -107,6 +108,7 @@ public class BoardSVCSavedBiomeTest { when(natural.getName()).thenReturn("Natural cave"); when(flooded.getName()).thenReturn("Flooded cave"); when(fixture.engine.getHeight(8, 8)).thenReturn(200); + when(fixture.engine.getComplex()).thenReturn(mock(IrisComplex.class)); when(fixture.engine.getCaveBiome(8, 160, 8)).thenReturn(natural); when(fixture.engine.getCaveOrMantleBiome(8, 160, 8)).thenReturn(flooded); when(fixture.engine.getDimension()).thenReturn(fixture.environment.dimension()); diff --git a/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java b/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java index 3f8d630ee..c4919c997 100644 --- a/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java +++ b/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java @@ -331,7 +331,8 @@ public class DimensionStackLayoutTest { null, null, terrainHeight, - fluidHeight + fluidHeight, + null ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/DimensionTerrain3DCompositionTest.java b/core/src/test/java/art/arcane/iris/engine/DimensionTerrain3DCompositionTest.java new file mode 100644 index 000000000..336bc5e33 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/DimensionTerrain3DCompositionTest.java @@ -0,0 +1,365 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.actuator.IrisDimensionStackActuator; +import art.arcane.iris.engine.actuator.IrisTerrainNormalActuator; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisBiomePaletteLayer; +import art.arcane.iris.engine.object.IrisSlopeClip; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.ChunkedDataCache; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DimensionTerrain3DCompositionTest { + private final Map states = new HashMap<>(); + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void actualStackPassClearsGapsAndPaintsEveryExposedFloor() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 3, 7, 9); + DimensionStackLayout layout = DimensionStackLayout.create(32, 0, + List.of(input(10, 0, null), input(9, 0, terrain)), new int[]{0}); + Hunk blocks = render(layout, 32); + assertSame(state("surface"), blocks.getRaw(0, 14, 0)); + assertSame(state("rock"), blocks.getRaw(0, 13, 0)); + assertTrue(blocks.getRaw(0, 15, 0).isAir()); + assertTrue(blocks.getRaw(0, 17, 0).isAir()); + assertSame(state("surface"), blocks.getRaw(0, 20, 0)); + for (int y = 11; y <= 20; y++) { + assertEquals("y=" + y, layout.isSolid(y), !blocks.getRaw(0, y, 0).isAir()); + } + } + + @Test + public void clippedWorldCeilingFindsTheActualFloorBelowAnUndercut() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 3, 7, 9); + DimensionStackLayout layout = DimensionStackLayout.create(17, 0, + List.of(input(10, 0, null), input(9, 0, terrain)), new int[]{0}); + assertEquals(14, layout.clippedStackTerrainTopY()); + assertEquals(14, layout.clippedStackTopY()); + assertEquals(14, layout.layersBottomToTop().get(1).clippedSurfaceY()); + assertSame(layout.layersBottomToTop().get(1), layout.topTerrainLayer()); + assertTrue(render(layout, 17).getRaw(0, 16, 0).isAir()); + } + + @Test + public void laterLayerAirOverwritesLowerTerrainInAnOverlappingSeam() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 2, 8, 10); + DimensionStackLayout layout = DimensionStackLayout.create(32, 0, + List.of(input(20, 0, null), input(10, 0, terrain)), new int[]{-11}); + Hunk blocks = render(layout, 32); + assertFalse(layout.isSolid(15)); + assertTrue(blocks.getRaw(0, 15, 0).isAir()); + assertTrue(layout.isHostFeatureProtectedY(15)); + assertTrue(layout.isSolid(18)); + } + + @Test + public void referencedStackSelectsEachLedgePaletteFromItsSourceSlope() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21); + IrisBiome biome = paletteBiome(false, new IrisSlopeClip(0D, 1D), "grass"); + DimensionStackLayout.LayerInput upper = materialInput(terrain, biome, 5D); + DimensionStackLayout layout = DimensionStackLayout.create(64, 0, + List.of(input(10, 0, null), upper), new int[]{0}); + Hunk blocks = render(layout, 64); + assertSame(state("grass"), blocks.getRaw(0, 16, 0)); + assertSame(state("rock"), blocks.getRaw(0, 32, 0)); + } + + @Test + public void referencedStackLockedPalettePhaseUsesEachSourceFloorHeight() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21); + IrisBiome biome = paletteBiome(true, new IrisSlopeClip(), "a", "b", "c"); + DimensionStackLayout layout = DimensionStackLayout.create(64, 0, + List.of(input(10, 0, null), materialInput(terrain, biome, 0D)), new int[]{0}); + Hunk blocks = render(layout, 64); + assertSame(state("a"), blocks.getRaw(0, 16, 0)); + assertSame(state("c"), blocks.getRaw(0, 15, 0)); + assertSame(state("c"), blocks.getRaw(0, 32, 0)); + } + + @Test + public void mirroredUpperPalettesUseSourceSlopesAndLockedLayerPhases() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 21); + UpperDimensionContext upper = materialUpper(terrain, + paletteBiome(false, new IrisSlopeClip(0D, 1D), "grass"), 5D); + Hunk slopeBlocks = renderUpper(upper); + assertSame(state("grass"), slopeBlocks.getRaw(0, 58, 0)); + assertSame(state("rock"), slopeBlocks.getRaw(0, 42, 0)); + + upper = materialUpper(terrain, paletteBiome(true, new IrisSlopeClip(), "a", "b", "c"), 0D); + Hunk lockedBlocks = renderUpper(upper); + assertSame(state("a"), lockedBlocks.getRaw(0, 58, 0)); + assertSame(state("c"), lockedBlocks.getRaw(0, 59, 0)); + assertSame(state("c"), lockedBlocks.getRaw(0, 42, 0)); + } + + @Test + @SuppressWarnings("unchecked") + public void actualUpperPassMirrorsGapsAndPaintsEveryInvertedFace() { + PlatformBlockState upperRock = state("upper-rock"); + PlatformBlockState upperSurface = state("upper-surface"); + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(15, 0, 5, 10, 20); + UpperDimensionContext.Column upperColumn = new UpperDimensionContext.Column(64, 43, terrain); + UpperDimensionContext upper = mock(UpperDimensionContext.class); + when(upper.sampleColumn(0, 0)).thenReturn(upperColumn); + when(upper.getRockBlock(0, 0)).thenReturn(upperRock); + when(upper.getSurfaceBlock(0, 0)).thenReturn(upperSurface); + Hunk blocks = renderUpper(upper); + + assertSame(state("upper-surface"), blocks.getRaw(0, 43, 0)); + assertSame(state("upper-rock"), blocks.getRaw(0, 44, 0)); + assertTrue(blocks.getRaw(0, 54, 0).isAir()); + assertTrue(blocks.getRaw(0, 57, 0).isAir()); + assertSame(state("upper-surface"), blocks.getRaw(0, 58, 0)); + for (int y = 43; y < 64; y++) { + assertEquals("y=" + y, upperColumn.isSolid(y), !blocks.getRaw(0, y, 0).isAir()); + } + } + + @Test + public void clippedOverlappingStacksMatchIndependentVoxelComposition() { + Random random = new Random(718223L); + Terrain3DColumn first = Terrain3DColumnFixtures.spans(12, 0, 4, 10, 14, 21, 24); + Terrain3DColumn second = Terrain3DColumnFixtures.spans(9, 0, 2, 7, 10, 14, 18); + for (int iteration = 0; iteration < 150; iteration++) { + int height = 8 + random.nextInt(56); + DimensionStackLayout layout = DimensionStackLayout.create(height, random.nextInt(10), + List.of(input(24, 0, first), input(18, 22, second), input(24, 0, first)), + new int[]{random.nextInt(71) - 50, random.nextInt(71) - 50}); + boolean[] solid = new boolean[height]; + boolean[] content = new boolean[height]; + List layers = layout.layersBottomToTop(); + for (int index = 0; index < layers.size(); index++) { + DimensionStackLayout.Layer layer = layers.get(index); + for (int y = 0; y < height; y++) { + if (index > 0 && y > layers.get(index - 1).contentTopY() && y < layer.localBaseY()) { + solid[y] = false; + content[y] = false; + } + if (y >= layer.renderMinY() && y <= layer.renderMaxY()) { + int sourceY = y - layer.localBaseY(); + solid[y] = layer.terrainColumn().isSolid(sourceY); + content[y] = solid[y] + || sourceY > layer.normalTerrainHeight() && sourceY <= layer.fluidHeight(); + } + } + } + int highestSolid = 0; + int highestContent = 0; + for (int y = 0; y < height; y++) { + assertEquals("iteration=" + iteration + ",y=" + y, solid[y], layout.isSolid(y)); + if (solid[y]) { + highestSolid = y; + } + if (content[y]) { + highestContent = y; + } + } + assertEquals("iteration=" + iteration, highestSolid, layout.clippedStackTerrainTopY()); + assertEquals("iteration=" + iteration, highestContent, layout.clippedStackTopY()); + } + } + + @Test + public void composedSupportRespectsUpperAndStackOwnershipAboveRootTerrain() { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + IrisComplex complex = mock(IrisComplex.class); + doReturn(complex).when(engine).getComplex(); + doReturn(64).when(engine).getHeight(); + when(complex.terrainColumn(0, 0)).thenReturn(Terrain3DColumnFixtures.spans(12, 0, 4, 15, 20)); + UpperDimensionContext upper = mock(UpperDimensionContext.class); + UpperDimensionContext.Column upperColumn = new UpperDimensionContext.Column(64, 40, + Terrain3DColumnFixtures.spans(12, 0, 5, 10, 23)); + doReturn(upper).when(engine).getUpperContext(); + when(upper.sampleColumn(0, 0)).thenReturn(upperColumn); + when(upper.getEffectiveSurfaceY(0, 0)).thenReturn(40); + assertTrue(engine.isTerrainSurfaceSolid(0, 50, 0)); + assertFalse(engine.isTerrainSurfaceSolid(0, 56, 0)); + assertFalse(engine.isTerrainSurfaceSolid(0, 30, 0)); + + DimensionStackLayout layout = DimensionStackLayout.create(64, 0, + List.of(input(20, 0, null), input(30, 0, + Terrain3DColumnFixtures.spans(10, 0, 3, 28, 30))), new int[]{0}); + DimensionStackContext stack = mock(DimensionStackContext.class); + doReturn(stack).when(engine).getDimensionStackContext(); + when(stack.getLayout(0, 0)).thenReturn(layout); + assertFalse(engine.isTerrainSurfaceSolid(0, 45, 0)); + assertTrue(engine.isTerrainSurfaceSolid(0, 50, 0)); + } + + @Test + public void stackedHostKeepsOutdoorLedgesInTheSurfaceBiome() { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + IrisComplex complex = mock(IrisComplex.class); + doReturn(complex).when(engine).getComplex(); + DimensionStackContext stack = mock(DimensionStackContext.class); + doReturn(stack).when(engine).getDimensionStackContext(); + DimensionStackLayout.LayerInput bottom = input(24, 0, + Terrain3DColumnFixtures.spans(12, 0, 4, 20, 24)); + DimensionStackLayout layout = DimensionStackLayout.create(64, 10, + List.of(bottom, input(9, 0, null)), new int[]{0}); + when(stack.getLayout(0, 0)).thenReturn(layout); + when(complex.isTerrain3DSurface(0, 4, 0)).thenReturn(true); + IrisBiome cave = new IrisBiome(); + doReturn(cave).when(engine).getCaveBiome(0, 2, 0); + doReturn(cave).when(engine).getCaveOrMantleBiome(0, 2, 0); + assertSame(bottom.biome(), engine.getBiome(0, 4, 0)); + assertSame(bottom.biome(), engine.getBiomeOrMantle(0, 4, 0)); + assertSame(cave, engine.getBiome(0, 2, 0)); + assertSame(cave, engine.getBiomeOrMantle(0, 2, 0)); + } + + @SuppressWarnings("unchecked") + private Hunk renderUpper(UpperDimensionContext upper) { + PlatformBlockState rootRock = state("root"); + Engine engine = mock(Engine.class); + IrisDimension dimension = new IrisDimension().setBedrock(false).setHideOresForHiddenOre(true); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(engine.getUpperContext()).thenReturn(upper); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 8D)); + when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class)); + IrisBiome biome = mock(IrisBiome.class); + when(biome.generateLayers(any(), anyDouble(), anyDouble(), any(), anyInt(), anyInt(), any(), any())) + .thenReturn(new KList<>()); + ChunkedDataCache biomes = mock(ChunkedDataCache.class); + when(biomes.get(0, 0)).thenReturn(biome); + ChunkedDataCache regions = mock(ChunkedDataCache.class); + ChunkedDataCache rocks = mock(ChunkedDataCache.class); + when(rocks.get(0, 0)).thenReturn(rootRock); + ChunkContext context = mock(ChunkContext.class); + when(context.getBiome()).thenReturn(biomes); + when(context.getRegion()).thenReturn(regions); + when(context.getRock()).thenReturn(rocks); + when(context.getRoundedHeight(0, 0)).thenReturn(8); + Hunk blocks = Hunk.newArrayHunk(1, 64, 1); + new IrisTerrainNormalActuator(engine).terrainSliver(0, 0, 0, blocks, context); + return blocks; + } + + private Hunk render(DimensionStackLayout layout, int height) { + Engine engine = mock(Engine.class); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(engine.getDimensionStackContext()).thenReturn(mock(DimensionStackContext.class)); + ChunkContext context = mock(ChunkContext.class); + when(context.isSpeculativeTerrain()).thenReturn(true); + when(context.getDimensionStackLayout(0, 0)).thenReturn(layout); + Hunk output = Hunk.newArrayHunk(1, height, 1); + for (int y = 0; y < height; y++) { + output.setRaw(0, y, 0, state("root")); + } + new IrisDimensionStackActuator(engine).onActuate(0, 0, output, false, context); + return output; + } + + private DimensionStackLayout.LayerInput input(int height, int fluidHeight, Terrain3DColumn terrain) { + DimensionTerrainContext context = mock(DimensionTerrainContext.class); + when(context.getDimension()).thenReturn(new IrisDimension().setBedrock(false)); + return new DimensionStackLayout.LayerInput(context, null, null, state("rock"), state("water"), + state("surface"), height, fluidHeight, terrain); + } + + private DimensionStackLayout.LayerInput materialInput(Terrain3DColumn terrain, IrisBiome biome, double capSlope) { + DimensionStackLayout.LayerInput input = input(terrain.topY(), 0, terrain); + when(input.terrainContext().getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> capSlope)); + when(input.terrainContext().getSurfaceSlopeStream(anyInt())).thenAnswer(invocation -> + ProceduralStream.ofDouble((x, z) -> (int) invocation.getArgument(0) == terrain.topY() ? capSlope : 0D)); + return new DimensionStackLayout.LayerInput(input.terrainContext(), biome, null, + input.rockBlock(), input.fluidBlock(), null, terrain.topY(), 0, terrain); + } + + private UpperDimensionContext materialUpper(Terrain3DColumn terrain, IrisBiome biome, double capSlope) { + PlatformBlockState rock = state("rock"); + UpperDimensionContext upper = mock(UpperDimensionContext.class); + when(upper.sampleColumn(0, 0)).thenReturn(new UpperDimensionContext.Column(64, 63 - terrain.topY(), terrain)); + when(upper.getRockBlock(0, 0)).thenReturn(rock); + when(upper.getUpperBiome(0, 0)).thenReturn(biome); + when(upper.getDimension()).thenReturn(new IrisDimension().setBedrock(false)); + when(upper.getSurfaceSlopeStream(anyInt())).thenAnswer(invocation -> + ProceduralStream.ofDouble((x, z) -> (int) invocation.getArgument(0) == terrain.topY() ? capSlope : 0D)); + return upper; + } + + private IrisBiome paletteBiome(boolean locked, IrisSlopeClip slope, String... keys) { + KList layers = new KList<>(); + for (String key : keys) { + PlatformBlockState block = state(key); + IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class); + CNG height = mock(CNG.class); + when(layer.getZoom()).thenReturn(1D); + when(layer.getMinHeight()).thenReturn(1); + when(layer.getMaxHeight()).thenReturn(1); + when(layer.getSlopeCondition()).thenReturn(slope); + when(layer.getHeightGenerator(any(), any())).thenReturn(height); + when(height.fit(anyInt(), anyInt(), anyDouble(), anyDouble())).thenReturn(1); + when(layer.get(any(), anyInt(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(block); + layers.add(layer); + } + return new IrisBiome().setLockLayers(locked).setLayers(layers); + } + + private PlatformBlockState state(String key) { + return states.computeIfAbsent(key, value -> { + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.key()).thenReturn(value); + when(block.isAir()).thenReturn(value.equalsIgnoreCase("air") || value.equals("minecraft:air")); + return block; + }); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java b/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java index 05b286848..3b5cd3487 100644 --- a/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java +++ b/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java @@ -7,14 +7,64 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures; +import art.arcane.volmlib.util.collection.KList; + +import java.util.List; +import java.util.Set; +import java.util.Collections; +import java.util.IdentityHashMap; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class DimensionTerrainContextFallbackTest { + @Test + public void regionMapOnlyTargetsJoinPreparedRegionsWithoutDuplicateIdentities() { + IrisRegion declared = new IrisRegion(); + IrisRegion mapped = new IrisRegion(); + declared.setLoadKey("declared"); + mapped.setLoadKey("mapped"); + IrisRegion excluded = mock(IrisRegion.class); + when(excluded.isCompatExcluded()).thenReturn(true); + IrisImageMapRuntime imageMaps = mock(IrisImageMapRuntime.class); + Set mappedRegions = Collections.newSetFromMap(new IdentityHashMap<>()); + mappedRegions.add(declared); + mappedRegions.add(mapped); + mappedRegions.add(excluded); + when(imageMaps.getMappedRegions()).thenReturn(mappedRegions); + KList regions = DimensionTerrainContext.terrainRegions(List.of(declared), imageMaps); + + assertEquals(2, regions.size()); + assertSame(declared, regions.get(0)); + assertTrue(regions.stream().anyMatch(region -> region == mapped)); + } + + @Test + public void sourceLedgeSlopeUsesNeighboringSourceSpansRatherThanTheHighestCap() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(128); + when(dimension.getLoadKey()).thenReturn("root"); + when(complex.hasTerrain3D()).thenReturn(true); + when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 40D)); + when(complex.terrainColumn(-17, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 5, 20, 40)); + when(complex.terrainColumn(-14, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 8, 20, 60)); + when(complex.terrainColumn(-17, 11)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 9, 20, 80)); + DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension); + + assertEquals(5D, context.getSurfaceSlopeStream(5).getDouble(-17, 8), 0D); + assertEquals(40D, context.getSurfaceSlopeStream(40).getDouble(-17, 8), 0D); + } + @Test @SuppressWarnings("unchecked") public void selfSamplerUsesNaturalValuesOnlyForNonblockingColumns() { @@ -70,4 +120,26 @@ public class DimensionTerrainContextFallbackTest { assertSame(resolvedBiomeValue, context.getBiome(12D, -8D)); assertSame(resolvedFluid, context.getFluidBlock(12D, -8D)); } + + @Test + public void selfStackReusesResolvedColumnsAndNaturalFallbackWithoutShapingAgain() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(128); + when(dimension.getLoadKey()).thenReturn("root"); + Terrain3DColumn natural = Terrain3DColumnFixtures.spans(30, 0, 15, 25, 40); + Terrain3DColumn resolved = Terrain3DColumnFixtures.spans(30, 0, 12, 28, 35); + when(complex.naturalTerrainColumn(-17, 8)).thenReturn(natural); + when(complex.terrainColumn(-17, 8)).thenReturn(resolved); + DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension); + + assertSame(resolved, context.terrainColumn(-17, 8)); + when(engine.answersFromNaturalTerrain(-17, 8)).thenReturn(true); + assertSame(natural, context.terrainColumn(-17, 8)); + when(engine.answersFromNaturalTerrain(-17, 8)).thenReturn(false); + assertSame(resolved, context.terrainColumn(-17, 8)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java b/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java index bc5b4ffee..2eedf9e8a 100644 --- a/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java @@ -4,11 +4,15 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -16,6 +20,43 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class UpperDimensionTransitionTest { + @Test + public void mirroredDensityPreservesEverySolidSpanAndFace() { + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110); + UpperDimensionContext.Column column = new UpperDimensionContext.Column(128, 17, terrain); + assertTrue(column.isSolid(17)); + assertTrue(column.isSolid(27)); + assertFalse(column.isSolid(28)); + assertFalse(column.isSolid(66)); + assertTrue(column.isSolid(67)); + assertEquals(67, column.faceY(70)); + assertEquals(97, column.faceY(110)); + assertFalse(column.isSolid(128)); + } + + @Test + public void upperGapClipsToTheNextRealMirroredFace() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("main"); + when(dimension.getUpperDimensionGap()).thenReturn(28); + IrisComplex complex = mock(IrisComplex.class); + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110); + when(complex.naturalTerrainColumn(-17, 8)).thenReturn(terrain); + when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 110D)); + when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(128); + UpperDimensionContext context = UpperDimensionContext.create(engine, dimension); + + assertEquals(97, context.getEffectiveSurfaceY(-17, 8)); + UpperDimensionContext.Column column = context.sampleColumn(-17, 8); + assertFalse(column.isSolid(92)); + assertTrue(column.isSolid(97)); + assertEquals(97, column.faceY(110)); + verify(complex, never()).terrainColumn(anyInt(), anyInt()); + } @Test public void currentCeilingInputRespectsCurrentLowerTerrainGap() { IrisDimension dimension = mock(IrisDimension.class); diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisSurfaceDecoratorSlopeTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisSurfaceDecoratorSlopeTest.java new file mode 100644 index 000000000..4e1cb3351 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisSurfaceDecoratorSlopeTest.java @@ -0,0 +1,43 @@ +package art.arcane.iris.engine.decorator; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.engine.object.IrisSlopeClip; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisSurfaceDecoratorSlopeTest { + @Test + public void lowerLedgeUsesItsOwnSlopeForVegetation() { + IrisComplex complex = mock(IrisComplex.class); + IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class, CALLS_REAL_METHODS); + doReturn(complex).when(surface).getComplex(); + IrisDecorator vegetation = new IrisDecorator().setSlopeCondition(new IrisSlopeClip(0D, 2.6D)); + when(complex.hasTerrain3D()).thenReturn(true); + when(complex.terrainSurfaceSlope(12, 32, -8)).thenReturn(0D); + when(complex.terrainSurfaceSlope(12, 96, -8)).thenReturn(5D); + + assertTrue(surface.isSlopeValid(vegetation, 12, 32, -8)); + assertFalse(surface.isSlopeValid(vegetation, 12, 96, -8)); + } + + @Test + public void disabledTerrainKeepsItsExistingSlopeGate() { + IrisComplex complex = mock(IrisComplex.class); + IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class, CALLS_REAL_METHODS); + doReturn(complex).when(surface).getComplex(); + IrisDecorator vegetation = new IrisDecorator().setSlopeCondition(new IrisSlopeClip(0D, 2.6D)); + when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 5D)); + + assertFalse(surface.isSlopeValid(vegetation, 12, 32, -8)); + vegetation.setForcePlace(true); + assertTrue(surface.isSlopeValid(vegetation, 12, 32, -8)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java index a55287fd7..619466e65 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -1255,6 +1255,10 @@ public class HydrologyFootprintCompilerTest { ).compile(courses); for (HydrologyColumnSample sample : full.columns().values()) { + assertEquals(sample.primarySurfaceLayer().isPresent(), + validation.plannedSurface().ownsTerrain(sample.x(), sample.z())); + assertEquals(sample.primarySurfaceLayer().isPresent(), + validation.withMaterializedSurface(full).plannedSurface().ownsTerrain(sample.x(), sample.z())); assertEquals( "planned surface at " + sample.x() + "," + sample.z(), sample.terrainHeight(), @@ -1294,6 +1298,8 @@ public class HydrologyFootprintCompilerTest { } assertEquals(91, validation.plannedSurface().resolve(80, 80, 91)); assertEquals(92, validation.plannedSurface().resolve(80, 80, 92)); + assertFalse(validation.plannedSurface().ownsTerrain(80, 80)); + assertFalse(validation.withMaterializedSurface(full).plannedSurface().ownsTerrain(80, 80)); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java index 063087527..1df05a68a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -146,10 +146,18 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { } private void assertMaterializedPreconditions(HydrologyTile tile, HydrologyTerrainSampler terrain) { - HydrologyCaveVoxelViewFactory.PlannedSurface surface = (int x, int z, int naturalHeight) -> - tile.footprint().sample(x, z) - .map(HydrologyColumnSample::terrainHeight) - .orElse(naturalHeight); + HydrologyCaveVoxelViewFactory.PlannedSurface surface = new HydrologyCaveVoxelViewFactory.PlannedSurface() { + @Override + public int resolve(int x, int z, int naturalHeight) { + return tile.footprint().sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight); + } + + @Override + public boolean ownsTerrain(int x, int z) { + HydrologyColumnSample sample = tile.footprint().sample(x, z).orElse(null); + return sample != null && sample.primarySurfaceLayer().isPresent(); + } + }; CaveVoxelView view = new PlannedSurfaceCaveView(terrain, surface); for (HydrologyCavePlan plan : tile.cavePlans()) { for (Map.Entry entry diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java index ff3f8e895..9aa090b97 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java @@ -788,9 +788,9 @@ public class HydrologyCaveContainmentPlannerTest { ); HydrologyCaveContainmentPlanner.ValidationCache cache = new HydrologyCaveContainmentPlanner.ValidationCache(); - HydrologyObservedPlannedSurface initialSurface = observedSurface(32); - HydrologyObservedPlannedSurface matchingSurface = observedSurface(32); - HydrologyObservedPlannedSurface changedSurface = observedSurface(9); + HydrologyObservedPlannedSurface initialSurface = observedSurface(32, true); + HydrologyObservedPlannedSurface matchingSurface = observedSurface(32, true); + HydrologyObservedPlannedSurface changedSurface = observedSurface(9, true); HydrologyCavePlan initial = planner.validateAll( plannedSurfaceView(initialSurface), @@ -822,6 +822,31 @@ public class HydrologyCaveContainmentPlannerTest { assertEquals(2L, cache.misses()); } + @Test + public void plannedSurfaceCacheInvalidatesEqualHeightWhenTerrainOwnershipChanges() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate candidate = candidate( + 204L, 10, "water", Map.of(center, HydrologyCaveAction.WET_SOURCE), settings()); + HydrologyCaveContainmentPlanner.ValidationCache cache = + new HydrologyCaveContainmentPlanner.ValidationCache(); + HydrologyObservedPlannedSurface initialSurface = observedSurface(32, true); + HydrologyObservedPlannedSurface matchingSurface = observedSurface(32, true); + HydrologyObservedPlannedSurface changedSurface = observedSurface(32, false); + + HydrologyCavePlan initial = planner.validateAll(plannedSurfaceView(initialSurface), + List.of(candidate), cache, initialSurface).plans().getFirst(); + HydrologyCavePlan cached = planner.validateAll(plannedSurfaceView(matchingSurface), + List.of(candidate), cache, matchingSurface).plans().getFirst(); + HydrologyCavePlan changed = planner.validateAll(plannedSurfaceView(changedSurface), + List.of(candidate), cache, changedSurface).plans().getFirst(); + + assertTrue(initial.accepted()); + assertEquals(initial, cached); + assertEquals(HydrologyCaveRejection.OPEN_SURFACE, changed.rejection()); + assertEquals(1L, cache.hits()); + assertEquals(2L, cache.misses()); + } + @Test public void validationCacheEvictsItsPreviousWorkingSetWithoutResettingStatistics() { CavePosition center = position(0, 10, 0); @@ -1228,9 +1253,18 @@ public class HydrologyCaveContainmentPlannerTest { return new CavePosition(x, y, z); } - private HydrologyObservedPlannedSurface observedSurface(int resolvedHeight) { - HydrologyCaveVoxelViewFactory.PlannedSurface surface = - (int x, int z, int naturalHeight) -> resolvedHeight; + private HydrologyObservedPlannedSurface observedSurface(int resolvedHeight, boolean terrainOwned) { + HydrologyCaveVoxelViewFactory.PlannedSurface surface = new HydrologyCaveVoxelViewFactory.PlannedSurface() { + @Override + public int resolve(int x, int z, int naturalHeight) { + return resolvedHeight; + } + + @Override + public boolean ownsTerrain(int x, int z) { + return terrainOwned; + } + }; return new HydrologyObservedPlannedSurface(surface); } @@ -1253,7 +1287,8 @@ public class HydrologyCaveContainmentPlannerTest { @Override public boolean isAboveTerrainSurface(CavePosition position) { - return position.y() > surface.resolve(position.x(), position.z(), 32); + return !surface.ownsTerrain(position.x(), position.z()) + || position.y() > surface.resolve(position.x(), position.z(), 32); } }; } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java index 246aea5b6..a72acdf14 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java @@ -60,6 +60,7 @@ public class MantleWriterOverlayTest { IrisPlatforms.bind(platform); engineMantle = mock(EngineMantle.class); + when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class)); Engine engine = mock(Engine.class); dimension = mock(IrisDimension.class); mantle = mock(Mantle.class); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java index 19203cf4b..5592ce147 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -63,6 +64,7 @@ public class MantleWriterPreObjectJournalTest { IrisMatterSupport.ensureRegistered(); EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class)); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); matter = new IrisMatter(16, 16, 16); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java index 1c35de24f..1b359efc2 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java @@ -1,8 +1,17 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.RiverFootprint; import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; @@ -12,15 +21,19 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,8 +78,9 @@ public class MantleHydrologyCaveVoxelViewTest { MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView( mantle, 128, - (x, z) -> 60, - (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) + new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(60, false), + (x, y, z) -> y <= 60, + (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ)) ); assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 20, 0))); @@ -100,12 +114,106 @@ public class MantleHydrologyCaveVoxelViewTest { MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView( mantle, 128, - (x, z) -> 60, - (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) + new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(60, false), + (x, y, z) -> y <= 60, + (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ)) ); assertTrue(view.isOpenToSurface(new CavePosition(4, 20, 4))); assertFalse(view.isOpenToSurface(new CavePosition(5, 20, 4))); assertEquals(List.of("0,0"), loaded); } + + @Test + @SuppressWarnings("unchecked") + public void terrainUndercutsRemainOpenWithoutCaveMatter() { + Mantle mantle = mock(Mantle.class); + doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(mantle, 128, + new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(80, false), + (x, y, z) -> y <= 40 || y >= 65 && y <= 80, + (chunkX, chunkZ) -> { })); + + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 40, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 41, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 64, 0))); + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 65, 0))); + assertFalse(view.isOpenToSurface(new CavePosition(0, 50, 0))); + assertTrue(view.isOpenToSurface(new CavePosition(0, 81, 0))); + } + + @Test + @SuppressWarnings("unchecked") + public void footprintOwnedBedsFillNaturalGapsEvenWhenTheirHeightIsUnchanged() { + Mantle mantle = mock(Mantle.class); + Engine engine = configuredEngine(mantle); + IrisComplex complex = undercutTerrain(); + HydrologyColumnSample sample = mock(HydrologyColumnSample.class); + when(sample.x()).thenReturn(0); + when(sample.z()).thenReturn(0); + when(sample.terrainHeight()).thenReturn(80); + when(sample.primarySurfaceLayer()).thenReturn(Optional.of(mock(HydrologyColumnLayer.class))); + RiverFootprint footprint = new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), sample)); + MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(engine, complex, footprint); + + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 50, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(1, 50, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 81, 0))); + assertFalse(view.isOpenToSurface(new CavePosition(0, 50, 0))); + verify(complex, never()).isNaturalTerrainSolid(0, 50, 0); + verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble()); + } + + @Test + @SuppressWarnings("unchecked") + public void plannedOwnershipPreservesOwnedBedsAndUnownedUndercutsWithoutHydrologyRecursion() { + Mantle mantle = mock(Mantle.class); + Engine engine = configuredEngine(mantle); + IrisComplex complex = undercutTerrain(); + HydrologyCaveVoxelViewFactory.PlannedSurface planned = new HydrologyCaveVoxelViewFactory.PlannedSurface() { + @Override + public int resolve(int x, int z, int naturalHeight) { + return x == 0 ? 55 : naturalHeight; + } + + @Override + public boolean ownsTerrain(int x, int z) { + return x == 0; + } + }; + doReturn(new MatterCavern(true, "", (byte) 0)).when(mantle).get(0, 20, 0, MatterCavern.class); + MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(engine, complex, planned); + + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 50, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(1, 50, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 20, 0))); + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(0, 55, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 56, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 80, 0))); + verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble()); + } + + private static Engine configuredEngine(Mantle mantle) { + doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + when(mantle.getWorldHeight()).thenReturn(128); + when(mantle.hasFlag(anyInt(), anyInt(), eq(ReservedFlag.CARVED))).thenReturn(true); + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + MantleComponent carving = mock(MantleComponent.class); + when(carving.isEnabled()).thenReturn(true); + when(engineMantle.getRegisteredComponents()).thenReturn(Map.of(ReservedFlag.CARVED, carving)); + return engine; + } + + private static IrisComplex undercutTerrain() { + IrisComplex complex = mock(IrisComplex.class); + when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); + when(complex.isNaturalTerrainSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int y = invocation.getArgument(1); + return y <= 40 || y >= 65 && y <= 80; + }); + return complex; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java index ab13868ba..2297e9af7 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java @@ -1,18 +1,35 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.BoundaryColumnGeometry; +import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.terrain.Terrain3DColumnFixtures; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.util.project.matter.IrisMatterSupport; import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; import org.mockito.InOrder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -22,6 +39,7 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -106,6 +124,90 @@ public class ObjectDestinationTransactionTest { assertArrayEquals(new byte[]{0, 0}, transaction.getCarvedColumn(0, 0, 2)); } + @Test + public void densityOpeningsReachTransactionsBeforeMantleCarving() { + try (TerrainFixture fixture = new TerrainFixture()) { + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0); + + assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0}, + transaction.getCarvedColumn(0, 0, 16)); + assertFalse(transaction.isCarved(0, 4, 0)); + assertTrue(transaction.isCarved(0, 5, 0)); + assertTrue(transaction.isCarved(0, 9, 0)); + assertFalse(transaction.isCarved(0, 10, 0)); + assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1}, transaction.getCarvedColumn(0, 0, 6)); + assertArrayEquals(new byte[0], transaction.getCarvedColumn(0, 0, -1)); + } + } + + @Test + public void densityMaskPreservesPrerequisiteJournalAndHydrologyPrecedence() { + try (TerrainFixture fixture = new TerrainFixture()) { + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + fixture.matter.slice(MatterCavern.class).set(0, 3, 0, cavern); + fixture.writer.withComponentPriority(2, () -> { + fixture.writer.clearData(0, 3, 0, MatterCavern.class); + fixture.writer.setData(0, 4, 0, cavern); + }); + fixture.matter.slice(HydrologyCaveCell.class) + .set(0, 6, 0, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + fixture.matter.slice(HydrologyCaveCell.class) + .set(0, 12, 0, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0); + + assertArrayEquals(new byte[]{0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0}, + transaction.getCarvedColumn(0, 0, 16)); + assertTrue(transaction.isCarved(0, 3, 0)); + assertFalse(transaction.isCarved(0, 4, 0)); + assertFalse(transaction.isCarved(0, 6, 0)); + assertTrue(transaction.isCarved(0, 12, 0)); + + transaction.setData(0, 5, 0, mock(PlatformBlockState.class)); + + assertFalse(transaction.isCarved(0, 5, 0)); + assertEquals(0, transaction.getCarvedColumn(0, 0, 16)[5]); + assertTrue(fixture.writer.isPrerequisiteCarved(0, 5, 0)); + } + } + + @Test + public void additionalTerrainOwnershipExcludesRootDensityOpenings() { + try (TerrainFixture fixture = new TerrainFixture()) { + when(fixture.engine.isAdditionalTerrainOwned(0, 6, 0)).thenReturn(true); + when(fixture.complex.isTerrain3DOpening(0, 6, 0)).thenReturn(false); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0); + + assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0}, + transaction.getCarvedColumn(0, 0, 16)); + assertFalse(transaction.isCarved(0, 6, 0)); + assertTrue(transaction.isCarved(0, 7, 0)); + } + } + + @Test + public void historicalGeometryOwnsTransactionalCarvingOverCurrentDensity() { + try (TerrainFixture fixture = new TerrainFixture()) { + BoundaryColumnGeometry.Voxel solid = new BoundaryColumnGeometry.Voxel( + "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); + BoundaryColumnGeometry.Voxel air = new BoundaryColumnGeometry.Voxel( + "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + List voxels = new ArrayList<>(Collections.nCopies(16, solid)); + voxels.set(2, air); + TerrainBoundarySignature historical = mock(TerrainBoundarySignature.class); + when(historical.geometry()).thenReturn(BoundaryColumnGeometry.fromVoxels(-64, voxels)); + when(fixture.engine.getMinHeight()).thenReturn(-64); + when(fixture.complex.resolvedTerrainColumn(0, 0)).thenReturn(Optional.of(historical)); + fixture.matter.slice(HydrologyCaveCell.class) + .set(0, 2, 0, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0); + + assertArrayEquals(new byte[]{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + transaction.getCarvedColumn(0, 0, 16)); + assertTrue(transaction.isCarved(0, 2, 0)); + assertFalse(transaction.isCarved(0, 5, 0)); + } + } + @Test public void failedCommitRestoresEveryTouchedDestinationCell() { MantleWriter writer = writer(); @@ -239,6 +341,59 @@ public class ObjectDestinationTransactionTest { return writer; } + private static final class TerrainFixture implements AutoCloseable { + private final IrisPlatform previousPlatform; + private final IrisComplex complex = mock(IrisComplex.class); + private final Engine engine = mock(Engine.class); + private final Matter matter; + private final MantleWriter writer; + + @SuppressWarnings("unchecked") + private TerrainFixture() { + BukkitTestServer.install(); + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(air.isAir()).thenReturn(true); + when(registries.air()).thenReturn(air); + when(registries.block(anyString())).thenReturn(air); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + IrisMatterSupport.ensureRegistered(); + + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + matter = new IrisMatter(16, 16, 16); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getComplex()).thenReturn(complex); + when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); + when(mantle.getWorldHeight()).thenReturn(16); + when(mantle.getChunk(0, 0)).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + when(chunk.exists(0)).thenReturn(true); + when(chunk.getOrCreate(0)).thenReturn(matter); + when(chunk.get(0)).thenReturn(matter); + when(complex.terrainColumn(0, 0)).thenReturn(Terrain3DColumnFixtures.spans(8, 0, 4, 10, 11)); + for (int y = 5; y <= 9; y++) { + when(complex.isTerrain3DOpening(0, y, 0)).thenReturn(true); + } + writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); + } + + @Override + public void close() { + writer.close(); + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + } + private record Marker(String value) { } } diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java index 74b0e8221..daccd5200 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.modifier; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.volmlib.util.math.BlockPosition; @@ -127,6 +128,7 @@ public class IrisCarveModifierBiomeBlendTest { private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException { IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); doReturn(engine).when(modifier).getEngine(); + doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); Field field = IrisCarveModifier.class.getDeclaredField("rng"); field.setAccessible(true); field.set(modifier, rng); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java index 28e9180db..07519662a 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java @@ -3,12 +3,14 @@ package art.arcane.iris.engine.modifier; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; @@ -48,6 +50,7 @@ public class IrisCarveModifierBoundarySupportTest { IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); doReturn(engine).when(modifier).getEngine(); + doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); Field rng = IrisCarveModifier.class.getDeclaredField("rng"); rng.setAccessible(true); rng.set(modifier, new RNG(71L)); @@ -81,6 +84,25 @@ public class IrisCarveModifierBoundarySupportTest { assertSame(resolvedCeiling, ceiling); } + @Test + @SuppressWarnings("unchecked") + public void terrainOpeningKeepsItsSurfaceBiomeOverCaveMatter() { + IrisBiome surface = new IrisBiome(); + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream biomes = mock(ProceduralStream.class); + doReturn(true).when(complex).isTerrain3DOpening(40, 42, 44); + doReturn(biomes).when(complex).getTrueBiomeStream(); + doReturn(surface).when(biomes).get(40, 44); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(complex).when(modifier).getComplex(); + + IrisBiome biome = modifier.resolveCaveBoundaryBiome( + new MatterCavern(true, "carving/deep", (byte) 0), 40, 42, 44, + new IrisDimensionCarvingResolver.State(), new Long2ObjectOpenHashMap<>(), new HashMap<>()); + + assertSame(surface, biome); + } + @Test @SuppressWarnings("unchecked") public void gravityFloorLayerRequiresSolidSupportBelowItsTarget() { diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java index 7a4ff1eba..d3a493ec1 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.modifier; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.actuator.IrisDecorantActuator; import art.arcane.iris.engine.decorator.IrisCeilingDecorator; import art.arcane.iris.engine.decorator.IrisSurfaceDecorator; @@ -176,6 +177,7 @@ public class IrisNaturalCaveScanTest { Mantle mantle = mock(Mantle.class); chunk = mock(MantleChunk.class); doReturn(engine).when(modifier).getEngine(); + doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); doReturn(32).when(engine).getHeight(); doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); doReturn(engineMantle).when(engine).getMantle(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java index ed445edb6..5c377eaba 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java @@ -28,6 +28,45 @@ public final class IrisBiomeLayerSlopeSourceTest { assertExplicitSlopeStreamControlsSurfacePaletteSelection(true); } + @Test + public void lowerLedgeSlopeControlsSurfacePaletteSelection() { + assertLowerLedgeSlopeControlsSurfacePaletteSelection(false); + } + + @Test + public void lowerLedgeSlopeControlsLockedSurfacePaletteSelection() { + assertLowerLedgeSlopeControlsSurfacePaletteSelection(true); + } + + private static void assertLowerLedgeSlopeControlsSurfacePaletteSelection(boolean lockLayers) { + IrisData data = mock(IrisData.class); + IrisComplex complex = mock(IrisComplex.class); + IrisBiomePaletteLayer grass = mock(IrisBiomePaletteLayer.class); + CNG heightGenerator = mock(CNG.class); + PlatformBlockState block = mock(PlatformBlockState.class); + RNG rng = new RNG(19L); + IrisBiome biome = new IrisBiome().setLockLayers(lockLayers).setLayers(new KList<>(grass)); + when(complex.hasTerrain3D()).thenReturn(true); + when(complex.terrainSurfaceSlope(12, 32, -8)).thenReturn(0D); + when(complex.terrainSurfaceSlope(12, 96, -8)).thenReturn(5D); + when(grass.getZoom()).thenReturn(1D); + when(grass.getMinHeight()).thenReturn(1); + when(grass.getMaxHeight()).thenReturn(1); + when(grass.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 2.6D)); + when(grass.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator); + when(heightGenerator.fit(1, 1, 12D, -8D)).thenReturn(1); + when(grass.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(block); + + KList ledge = biome.generateLayers( + new IrisDimension(), 12D, -8D, rng, 1, 32, data, complex); + KList cap = biome.generateLayers( + new IrisDimension(), 12D, -8D, rng, 1, 96, data, complex); + + assertEquals(1, ledge.size()); + assertSame(block, ledge.get(0)); + assertTrue(cap.isEmpty()); + } + @SuppressWarnings("unchecked") private static void assertExplicitSlopeStreamControlsSurfacePaletteSelection(boolean lockLayers) { IrisData data = mock(IrisData.class); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java index e4fad0b83..67a6ddf11 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java @@ -1,12 +1,32 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.mantle.components.IslandObjectPlacer; +import art.arcane.iris.engine.mantle.MantleWriter; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class IslandObjectPlacerAnchorFaceTest { + @Test + public void islandMaskOwnsSurfaceSupportAboveRootTerrainAndInsideRootOpenings() { + FloatingIslandSample sample = sampleWithBottomAt(100, 0); + MantleWriter writer = mock(MantleWriter.class); + when(writer.isSurfaceSolid(0, 109, 0)).thenReturn(false); + when(writer.isCarved(0, 109, 0)).thenReturn(true); + IslandObjectPlacer placer = IslandObjectPlacer.top(writer, (x, z) -> sample, null, 109); + + assertTrue(placer.isSurfaceSolid(0, 109, 0)); + assertFalse(placer.isCarved(0, 109, 0)); + assertFalse(placer.isSurfaceSolid(0, 108, 0)); + assertTrue(placer.isCarved(0, 108, 0)); + assertFalse(placer.isSurfaceSolid(0, 110, 0)); + } + private FloatingIslandSample sampleWithBottomAt(int baseY, int bottomOffset) { boolean[] mask = new boolean[10]; mask[bottomOffset] = true; diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DColumnFixtures.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DColumnFixtures.java new file mode 100644 index 000000000..dd9d0259f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DColumnFixtures.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.terrain; + +public final class Terrain3DColumnFixtures { + private Terrain3DColumnFixtures() { + } + + public static Terrain3DColumn spans(double baseHeight, int... boundaries) { + return new Terrain3DColumn(baseHeight, boundaries[1] + 1, true, boundaries); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java new file mode 100644 index 000000000..5da8fefac --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java @@ -0,0 +1,280 @@ +package art.arcane.iris.engine.terrain; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Terrain3DFragmentFilterTest { + @Test + public void removesAnEntire512BlockIslandAcrossChunkBoundaries() { + Set blocks = new HashSet<>(); + for (int x = 12; x <= 19; x++) { + for (int z = -20; z <= -13; z++) { + for (int y = 24; y <= 31; y++) { + blocks.add(new Voxel(x, y, z)); + } + } + } + Fixture fixture = new Fixture(blocks, true); + + for (Voxel block : blocks) { + Terrain3DColumn column = fixture.filter.column(block.x(), block.z()); + assertFalse(column.isSolid(block.y())); + assertEquals(8, column.topY()); + assertEquals(1, column.spanCount()); + assertTrue(column.isSolid(8)); + } + } + + @Test + public void preservesThinShelvesConnectedSidewaysAroundACorner() { + Set blocks = new HashSet<>(); + for (int x = 0; x <= 15; x++) { + blocks.add(new Voxel(x, 24, 0)); + } + for (int z = 1; z <= 15; z++) { + blocks.add(new Voxel(15, 24, z)); + } + for (int y = 9; y < 24; y++) { + blocks.add(new Voxel(15, y, 15)); + } + Fixture fixture = new Fixture(blocks, true); + + for (Voxel block : blocks) { + assertTrue(fixture.filter.column(block.x(), block.z()).isSolid(block.y())); + } + assertFalse(fixture.filter.column(0, 0).isSolid(23)); + assertEquals(24, fixture.filter.column(0, 0).surfaceY(24)); + } + + @Test + public void aDiagonalContactDoesNotKeepAnOtherwiseDetachedBlock() { + Set blocks = new HashSet<>(); + for (int y = 9; y <= 24; y++) { + blocks.add(new Voxel(0, y, 0)); + } + blocks.add(new Voxel(1, 24, 1)); + Fixture fixture = new Fixture(blocks, true); + + assertTrue(fixture.filter.column(0, 0).isSolid(24)); + assertFalse(fixture.filter.column(1, 1).isSolid(24)); + } + + @Test + public void preservesAll513BlocksAfterAPartialComponentClassification() { + Set blocks = new HashSet<>(); + for (int x = 0; x < 513; x++) { + blocks.add(new Voxel(x, 24, 0)); + } + Fixture fixture = new Fixture(blocks, true); + + assertTrue(fixture.filter.column(0, 0).isSolid(24)); + assertTrue(fixture.lookups.get() <= 1 + 4 * 512); + for (int x = 512; x >= 0; x--) { + assertTrue(fixture.filter.column(x, 0).isSolid(24)); + } + } + + @Test + public void aLargeVerticalIslandNeedsNoNeighbourSampling() { + Set blocks = new HashSet<>(); + for (int y = 24; y < 537; y++) { + blocks.add(new Voxel(0, y, 0)); + } + Fixture fixture = new Fixture(blocks, true); + + assertEquals(536, fixture.filter.column(0, 0).topY()); + assertEquals(1, fixture.lookups.get()); + } + + @Test + public void completeCacheEvictionAndConcurrentQueriesPreserveDecisions() throws Exception { + Set blocks = new HashSet<>(); + for (int x = -18; x <= -15; x++) { + for (int z = 14; z <= 17; z++) { + for (int y = 24; y <= 27; y++) { + blocks.add(new Voxel(x, y, z)); + } + } + } + for (int x = 14; x <= 526; x++) { + blocks.add(new Voxel(x, 24, 16)); + } + Fixture evicted = new Fixture(blocks, false); + Fixture cached = new Fixture(blocks, true); + int[] coordinates = {-18, -17, -16, -15, 14, 15, 16, 526}; + try (ExecutorService executor = Executors.newFixedThreadPool(4)) { + List> results = new ArrayList<>(); + for (int x : coordinates) { + results.add(executor.submit(() -> evicted.filter.column(x, 16))); + } + for (int index = results.size() - 1; index >= 0; index--) { + assertEquals(cached.filter.column(coordinates[index], 16), + results.get(index).get(10, TimeUnit.SECONDS)); + } + } + } + + @Test + public void sharedConcurrentQueriesPublishWholeRemovedAndKeptComponents() throws Exception { + Set blocks = new HashSet<>(); + for (int x = 12; x <= 19; x++) { + for (int z = -20; z <= -13; z++) { + for (int y = 24; y <= 31; y++) { + blocks.add(new Voxel(x, y, z)); + } + } + } + for (int x = 32; x <= 544; x++) { + blocks.add(new Voxel(x, 24, 16)); + } + Fixture fixture = new Fixture(blocks, true); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(8)) { + List> removed = new ArrayList<>(); + List> kept = new ArrayList<>(); + for (int index = 0; index < 64; index++) { + int removedX = 12 + index % 8; + int removedZ = -20 + index / 8; + int keptX = 32 + index * 8; + removed.add(executor.submit(() -> { + assertTrue(start.await(10, TimeUnit.SECONDS)); + return fixture.filter.column(removedX, removedZ); + })); + kept.add(executor.submit(() -> { + assertTrue(start.await(10, TimeUnit.SECONDS)); + return fixture.filter.column(keptX, 16); + })); + } + start.countDown(); + for (int index = 63; index >= 0; index--) { + assertEquals(8, removed.get(index).get(10, TimeUnit.SECONDS).topY()); + assertTrue(kept.get(index).get(10, TimeUnit.SECONDS).isSolid(24)); + } + } + } + + @Test + public void spanFilteringMatchesAnIndependentVoxelComponentOracle() { + Random random = new Random(74921); + for (int sample = 0; sample < 18; sample++) { + Set blocks = new HashSet<>(); + double probability = 0.12D + sample % 6 * 0.14D; + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + for (int y = 9; y <= 20; y++) { + if (random.nextDouble() < probability) { + blocks.add(new Voxel(x, y, z)); + } + } + } + } + Set retained = retainedBlocks(blocks); + Fixture fixture = new Fixture(blocks, true); + for (int x = 3; x >= -3; x--) { + for (int z = -3; z <= 3; z++) { + Terrain3DColumn column = fixture.filter.column(x, z); + for (int y = 9; y <= 20; y++) { + Voxel block = new Voxel(x, y, z); + assertEquals("Sample " + sample + " at " + block, + retained.contains(block), column.isSolid(y)); + } + } + } + } + } + + private static Set retainedBlocks(Set blocks) { + Set remaining = new HashSet<>(blocks); + Set retained = new HashSet<>(); + while (!remaining.isEmpty()) { + Voxel start = remaining.iterator().next(); + remaining.remove(start); + Set component = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(start); + boolean grounded = false; + while (!pending.isEmpty()) { + Voxel block = pending.removeFirst(); + component.add(block); + grounded |= block.y() == 9; + for (Voxel neighbour : List.of(new Voxel(block.x() - 1, block.y(), block.z()), + new Voxel(block.x() + 1, block.y(), block.z()), + new Voxel(block.x(), block.y() - 1, block.z()), + new Voxel(block.x(), block.y() + 1, block.z()), + new Voxel(block.x(), block.y(), block.z() - 1), + new Voxel(block.x(), block.y(), block.z() + 1))) { + if (remaining.remove(neighbour)) { + pending.addLast(neighbour); + } + } + } + if (grounded || component.size() > 512) { + retained.addAll(component); + } + } + return retained; + } + + private record Voxel(int x, int y, int z) { + } + + private static final class Fixture { + private final Set blocks; + private final boolean cache; + private final int maximumY; + private final Map columns = new ConcurrentHashMap<>(); + private final AtomicInteger lookups = new AtomicInteger(); + private final Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter(this::column); + + private Fixture(Set blocks, boolean cache) { + this.blocks = Set.copyOf(blocks); + this.cache = cache; + int highest = 9; + for (Voxel block : blocks) { + highest = Math.max(highest, block.y()); + } + maximumY = highest + 1; + } + + private Terrain3DFragmentFilter.DensityColumn column(int x, int z) { + lookups.incrementAndGet(); + long key = (long) x << 32 ^ z & 0xffffffffL; + return cache ? columns.computeIfAbsent(key, ignored -> createColumn(x, z)) : createColumn(x, z); + } + + private Terrain3DFragmentFilter.DensityColumn createColumn(int x, int z) { + int[] boundaries = new int[maximumY * 2]; + boundaries[0] = 0; + int count = 1; + boolean previousSolid = true; + for (int y = 9; y <= maximumY; y++) { + boolean solid = blocks.contains(new Voxel(x, y, z)); + if (solid != previousSolid) { + boundaries[count++] = solid ? y : y - 1; + previousSolid = solid; + } + } + return new Terrain3DFragmentFilter.DensityColumn( + new Terrain3DColumn(8, 9, true, Arrays.copyOf(boundaries, count))); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java new file mode 100644 index 000000000..55c95931d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java @@ -0,0 +1,325 @@ +package art.arcane.iris.engine.terrain; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisTerrain3D; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +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; + +public class Terrain3DRuntimeTest { + @Test + public void omittedProfilesPreserveExactHeightsWithoutCreatingNoise() { + AtomicInteger compilations = new AtomicInteger(); + IrisBiome biome = new IrisBiome(); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 100.25D + x * 0.125D, (x, z) -> biome), + options(true), + (style, seed) -> { + compilations.incrementAndGet(); + return (x, y, z) -> 1D; + }); + + for (int x = -20; x < 20; x++) { + Terrain3DColumn column = runtime.column(x, 3); + assertEquals(100.25D + x * 0.125D, column.baseHeight(), 0D); + assertEquals(Math.round(column.baseHeight()), column.topY()); + assertEquals(1, column.spanCount()); + assertFalse(column.shaped()); + } + assertEquals(0, compilations.get()); + } + + @Test + public void inactiveRuntimeDoesNotResolveBiomesOrNoise() { + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 91.5D, (x, z) -> { + throw new AssertionError("Unexpected biome resolution"); + }), options(false), (style, seed) -> { + throw new AssertionError("Unexpected noise compilation"); + }); + + assertFalse(runtime.active()); + assertEquals(92, runtime.column(0, 0).topY()); + } + + @Test + public void unchangedColumnsRetainTheOriginalHeightOutsideVoxelBounds() { + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 300.25D, (x, z) -> null), + options(false), (style, seed) -> (x, y, z) -> 0D); + + assertEquals(300.25D, runtime.column(0, 0).baseHeight(), 0D); + assertEquals(255, runtime.column(0, 0).topY()); + assertFalse(runtime.column(0, 0).isSolid(256)); + } + + @Test + public void densityAddsRockAndLeavesAirBelowProjectingLedges() { + Terrain3DRuntime runtime = runtime(profile(), (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D)); + Terrain3DColumn column = runtime.column(0, 0); + + assertTrue(column.shaped()); + assertTrue(column.topY() > Math.round(column.baseHeight())); + assertTrue(column.spanCount() > 1); + assertTrue(column.isSolid(104)); + assertFalse(column.isSolid(84)); + assertTrue(column.isSolid(60)); + assertEquals(-1, column.surfaceY(84)); + for (int span = 0; span < column.spanCount(); span++) { + assertEquals(column.floor(span), column.surfaceY(column.ceiling(span))); + assertFalse(column.isSolid(column.floor(span) + 1)); + } + } + + @Test + public void isolatedDensityPeakIsRemovedFromEveryColumnQuery() { + Terrain3DRuntime runtime = runtime(profile(), + (x, y, z) -> x == 16D && y == 112D && z == -16D ? 1D : -1D); + + for (int x = 14; x <= 18; x++) { + for (int z = -18; z <= -14; z++) { + Terrain3DColumn column = runtime.column(x, z); + assertEquals(64, column.topY()); + assertEquals(1, column.spanCount()); + assertFalse(column.isSolid(112)); + assertEquals(-1, column.surfaceY(112)); + assertEquals(64, column.highestSolidY(112)); + assertEquals(64, column.nearestSurfaceY(112)); + assertTrue(column.isSolid(64)); + } + } + } + + @Test + public void latticeMatchesTheSameFieldAcrossPositiveAndNegativeChunkBoundaries() { + Terrain3DRuntime.NoiseSource noise = (x, y, z) -> (StrictMath.sin(x * StrictMath.PI / 23D) + + StrictMath.sin(y * StrictMath.PI / 16D) + + StrictMath.cos(z * StrictMath.PI / 29D)) / 3D; + Terrain3DRuntime forward = runtime(profile(), noise); + Terrain3DRuntime reverse = runtime(profile(), noise); + + for (int x = 20; x >= -20; x--) { + reverse.column(x, -17); + } + for (int x = -20; x <= 20; x++) { + Terrain3DColumn column = forward.column(x, -17); + assertEquals(column, reverse.column(x, -17)); + for (int y = 1; y < 160; y++) { + double density = 96.5D - y + 32D * interpolated(noise, x, y, -17); + assertEquals("at " + x + "," + y + ",-17", density >= 0D, column.isSolid(y)); + } + } + } + + @Test + public void profileContributionsFadeIntoAnOmittedNeighbour() { + IrisBiome shaped = new IrisBiome().setTerrain3D(profile()); + IrisBiome unchanged = new IrisBiome(); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> x < 16 ? shaped : unchanged), + options(true), (style, seed) -> (x, y, z) -> 1D); + + assertEquals(128, runtime.column(12, 0).topY()); + assertEquals(120, runtime.column(13, 0).topY()); + assertEquals(112, runtime.column(14, 0).topY()); + assertEquals(104, runtime.column(15, 0).topY()); + assertEquals(96, runtime.column(16, 0).topY()); + assertFalse(runtime.column(16, 0).shaped()); + } + + @Test + public void signedFissureInterpolationPreservesNarrowCracksBetweenLatticeNodes() { + IrisTerrain3D config = profile().setAmplitude(0).setCrackDepth(24).setCrackWidth(1) + .setCrackScale(64); + Terrain3DRuntime runtime = runtime(config, (x, y, z) -> (x - 2D) / 32D); + + assertEquals(72, runtime.column(2, 0).topY()); + assertEquals(96, runtime.column(0, 0).topY()); + assertEquals(96, runtime.column(4, 0).topY()); + } + + @Test + public void waterAndSlopeGatesKeepProtectedTerrainUnchanged() { + IrisBiome biome = new IrisBiome().setTerrain3D(profile().setMinimumSlope(0.25D)); + Terrain3DRuntime flat = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), + options(true), (style, seed) -> (x, y, z) -> -1D); + assertFalse(flat.column(0, 0).shaped()); + + biome.setTerrain3D(profile().setAmplitude(128).setCrackDepth(128).setFluidClearance(8)); + Terrain3DRuntime water = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 40D, (x, z) -> biome), + new Terrain3DRuntime.Options(12, 256, 32, null, true, 4096), + (style, seed) -> (x, y, z) -> -1D); + assertFalse(water.column(0, 0).shaped()); + assertEquals(40, water.column(0, 0).topY()); + } + + @Test + public void columnCacheReusesGeometryAndAnchorNoise() { + AtomicInteger samples = new AtomicInteger(); + Terrain3DRuntime runtime = runtime(profile(), (x, y, z) -> { + samples.incrementAndGet(); + return StrictMath.sin(y * StrictMath.PI / 16D); + }); + Terrain3DColumn first = runtime.column(0, 0); + int initialSamples = samples.get(); + assertSame(first, runtime.column(0, 0)); + assertEquals(initialSamples, samples.get()); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + runtime.column(x, z); + } + } + assertTrue(samples.get() <= 49 * 18); + int samplesBeforeClear = samples.get(); + runtime.clear(); + assertEquals(first, runtime.column(0, 0)); + assertTrue(samples.get() > samplesBeforeClear); + } + + @Test + public void concurrentAndEvictedColumnsRemainReproducible() throws Exception { + Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin((x + y + z) * StrictMath.PI / 16D); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), + new Terrain3DRuntime.Options(12, 256, 0, null, true, 16), + (style, seed) -> noise); + Terrain3DRuntime reference = runtime(profile(), noise); + try (ExecutorService executor = Executors.newFixedThreadPool(8)) { + List> results = new ArrayList<>(); + for (int index = 0; index < 128; index++) { + int x = index % 32 - 16; + results.add(executor.submit(() -> runtime.column(x, 0))); + } + for (int index = 0; index < results.size(); index++) { + assertEquals(reference.column(index % 32 - 16, 0), results.get(index).get(10, TimeUnit.SECONDS)); + } + } + } + + @Test + public void repeatedColumnsKeepCoordinateAndRuntimeGeometrySeparate() { + Terrain3DRuntime first = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D + x * 0.25D + z * 0.5D, (x, z) -> null), + options(true), (style, seed) -> (x, y, z) -> 0D); + Terrain3DRuntime second = runtime(profile(), (x, y, z) -> 1D); + + assertEquals(96D, first.column(0, 0).baseHeight(), 0D); + assertEquals(128, second.column(0, 0).topY()); + assertEquals(96, first.column(0, 0).topY()); + assertEquals(95.75D, first.column(-1, 0).baseHeight(), 0D); + assertEquals(95.5D, first.column(0, -1).baseHeight(), 0D); + assertEquals(95.5D, first.column(0, -1).baseHeight(), 0D); + assertEquals(96D, first.column(0, 0).baseHeight(), 0D); + assertEquals(128, second.column(0, 0).topY()); + } + + @Test + public void clearingRuntimeRefreshesTheLastColumnOnItsCallingThread() { + AtomicInteger height = new AtomicInteger(96); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> height.get(), (x, z) -> biome), + options(true), (style, seed) -> (x, y, z) -> 0D); + + assertEquals(96, runtime.column(0, 0).topY()); + height.set(120); + runtime.clear(); + + assertEquals(120, runtime.column(0, 0).topY()); + assertTrue(runtime.column(0, 0).isSolid(110)); + } + + @Test + public void clearingRuntimeRefreshesLastColumnsOnOtherThreads() throws Exception { + AtomicInteger height = new AtomicInteger(96); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> height.get(), (x, z) -> biome), + options(true), (style, seed) -> (x, y, z) -> 0D); + CountDownLatch sampled = new CountDownLatch(2); + CountDownLatch cleared = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> results = new ArrayList<>(); + for (int index = 0; index < 2; index++) { + results.add(executor.submit(() -> { + Terrain3DColumn before = runtime.column(0, 0); + sampled.countDown(); + assertTrue(cleared.await(10, TimeUnit.SECONDS)); + assertEquals(96, before.topY()); + return runtime.column(0, 0); + })); + } + try { + assertTrue(sampled.await(10, TimeUnit.SECONDS)); + height.set(120); + runtime.clear(); + } finally { + cleared.countDown(); + } + for (Future result : results) { + assertEquals(120, result.get(10, TimeUnit.SECONDS).topY()); + } + } + } + + @Test + public void invalidCurrentFieldsAndNonfiniteNoiseFailBeforeGeometryEscapes() { + assertThrows(IllegalArgumentException.class, + () -> runtime(profile().setAmplitude(129), (x, y, z) -> 0D).column(0, 0)); + assertThrows(IllegalArgumentException.class, + () -> runtime(profile().setVerticalScale(Double.NaN), (x, y, z) -> 0D).column(0, 0)); + assertThrows(IllegalStateException.class, + () -> runtime(profile(), (x, y, z) -> Double.NaN).column(0, 0)); + } + + private static Terrain3DRuntime runtime(IrisTerrain3D profile, Terrain3DRuntime.NoiseSource noise) { + IrisBiome biome = new IrisBiome().setTerrain3D(profile); + return new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), + options(true), (style, seed) -> noise); + } + + private static Terrain3DRuntime.Options options(boolean enabled) { + return new Terrain3DRuntime.Options(12, 256, 0, null, enabled, 4096); + } + + private static IrisTerrain3D profile() { + return new IrisTerrain3D().setAmplitude(32).setHorizontalScale(64).setVerticalScale(64) + .setMinimumSlope(0).setFluidClearance(0).setFluidFade(1); + } + + private static double interpolated(Terrain3DRuntime.NoiseSource noise, int x, int y, int z) { + int startX = Math.floorDiv(x, 4) * 4; + int startY = Math.floorDiv(y, 4) * 4; + int startZ = Math.floorDiv(z, 4) * 4; + double density = 0D; + for (int dx = 0; dx <= 1; dx++) { + for (int dy = 0; dy <= 1; dy++) { + for (int dz = 0; dz <= 1; dz++) { + double weightX = dx == 0 ? 1D - (x - startX) / 4D : (x - startX) / 4D; + double weightY = dy == 0 ? 1D - (y - startY) / 4D : (y - startY) / 4D; + double weightZ = dz == 0 ? 1D - (z - startZ) / 4D : (z - startZ) / 4D; + density += weightX * weightY * weightZ + * noise.sample(startX + dx * 4, startY + dy * 4, startZ + dz * 4); + } + } + } + return density; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DSurfaceSlopeTest.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DSurfaceSlopeTest.java new file mode 100644 index 000000000..07bc7e2d1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DSurfaceSlopeTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.engine.terrain; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Terrain3DSurfaceSlopeTest { + @Test + public void flatLowerLedgeIgnoresTheVaryingUpperCap() { + IrisComplex complex = complex(); + when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120)); + when(complex.terrainColumn(3, 0)).thenReturn(column(60, 150, 200)); + when(complex.terrainColumn(0, 3)).thenReturn(column(60, 90, 100)); + + assertEquals(0D, complex.terrainSurfaceSlope(0, 60, 0), 0D); + } + + @Test + public void lowerLedgeUsesTheExistingThreeBlockSlopeMetric() { + IrisComplex complex = complex(); + when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120)); + when(complex.terrainColumn(3, 0)).thenReturn(column(63, 100, 120)); + when(complex.terrainColumn(0, 3)).thenReturn(column(64, 100, 120)); + + assertEquals(5D, complex.terrainSurfaceSlope(0, 60, 0), 0D); + } + + @Test + public void neighboringContinuousRiverTerrainUsesItsPlacementHeight() { + IrisComplex complex = complex(); + when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120)); + when(complex.getPlacementHeightStream()).thenReturn( + ProceduralStream.ofDouble((x, z) -> x == 3D ? 63D : 64D)); + + assertEquals(5D, complex.terrainSurfaceSlope(0, 60, 0), 0D); + } + + @Test + public void topSurfaceAndCaveInteriorsKeepTheExistingSlope() { + IrisComplex complex = complex(); + when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120)); + when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 7.25D)); + + assertEquals(7.25D, complex.terrainSurfaceSlope(0, 120, 0), 0D); + assertEquals(7.25D, complex.terrainSurfaceSlope(0, 50, 0), 0D); + verify(complex, never()).terrainColumn(3, 0); + verify(complex, never()).terrainColumn(0, 3); + } + + @Test + public void unshapedTerrainKeepsTheExistingSlope() { + IrisComplex complex = complex(); + when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 3.5D)); + + assertEquals(3.5D, complex.terrainSurfaceSlope(0, 60, 0), 0D); + verify(complex, never()).terrainColumn(3, 0); + verify(complex, never()).terrainColumn(0, 3); + } + + @Test + public void nearestSurfaceUsesTheLowerFloorOnAnEqualDistance() { + Terrain3DColumn column = column(60, 80, 100); + + assertEquals(60, column.nearestSurfaceY(80)); + assertEquals(100, column.nearestSurfaceY(81)); + assertEquals(60, column.nearestSurfaceY(Integer.MIN_VALUE)); + assertEquals(100, column.nearestSurfaceY(Integer.MAX_VALUE)); + } + + private static IrisComplex complex() { + IrisComplex complex = mock(IrisComplex.class); + doCallRealMethod().when(complex).terrainSurfaceSlope(anyInt(), anyInt(), anyInt()); + return complex; + } + + private static Terrain3DColumn column(int floor, int ceiling, int top) { + return new Terrain3DColumn(floor, floor, true, new int[]{0, floor, ceiling, top}); + } +} diff --git a/probe/build.gradle b/probe/build.gradle index 502539bc4..97bedefbe 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -13,6 +13,10 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') .orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT') .get() +configurations { + studioProbeApi +} + dependencies { implementation(project(':core')) compileOnly(libs.spigot) @@ -36,6 +40,9 @@ dependencies { runtimeOnly(libs.oshi) runtimeOnly(libs.byteBuddy.core) runtimeOnly(libs.byteBuddy.agent) + studioProbeApi(libs.spigot) { + transitive = false + } } tasks.named('run', JavaExec).configure { @@ -84,6 +91,37 @@ Provider probeJfr = providers.gradleProperty('probeJfr') Provider probeTileX = providers.gradleProperty('probeTileX') Provider probeTileZ = providers.gradleProperty('probeTileZ') Provider probeOutput = providers.gradleProperty('probeOutput') +Provider probeStrictGeometry = providers.gradleProperty('probeStrictGeometry').orElse('false') + +tasks.register('terrain3DProbe', JavaExec) { + group = 'verification' + description = 'Generates real pack chunks, verifies volumetric terrain spans and required biome overhang coverage, and exports actual block cross-sections.' + mainClass = 'art.arcane.iris.probe.Terrain3DProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + dependsOn(':core:compileJava') + doFirst { + Map> required = [ + probePack: probePack, + probeDimension: probeDimension, + probeSeed: probeSeed, + probeMinimumChunkX: probeMinimumChunkX, + probeMaximumChunkX: probeMaximumChunkX, + probeMinimumChunkZ: probeMinimumChunkZ, + probeMaximumChunkZ: probeMaximumChunkZ, + probeRequiredCoverage: probeRequiredCoverage, + probeOutput: probeOutput + ] + required.each { String name, Provider value -> + if (!value.isPresent() || value.get().isBlank()) { + throw new GradleException("terrain3DProbe requires -P${name}=") + } + } + args(probePack.get(), probeDimension.get(), probeSeed.get(), probeMinimumChunkX.get(), + probeMaximumChunkX.get(), probeMinimumChunkZ.get(), probeMaximumChunkZ.get(), + probeRequiredCoverage.get(), probeOutput.get(), probeStrictGeometry.get(), probeStudio.get()) + } +} tasks.register('genProbe', JavaExec) { group = 'verification' @@ -221,8 +259,9 @@ tasks.register('riverTransectProbe', JavaExec) { } } -['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe'].each { String taskName -> +['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe', 'terrain3DProbe'].each { String taskName -> tasks.named(taskName, JavaExec).configure { JavaExec task -> + classpath += configurations.studioProbeApi doFirst { if (probeJfr.isPresent()) { String requestedPath = probeJfr.get() diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java index 60109b857..9aeb0311d 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; import art.arcane.iris.engine.hydrology.DrainageEdge; import art.arcane.iris.engine.hydrology.DrainageNode; import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; @@ -3844,9 +3845,18 @@ public final class HydrologyPackProbe { CaveVoxelView view = new MantleHydrologyCaveVoxelView( engine, engine.getComplex(), - (int x, int z, int naturalHeight) -> runtime.sample(x, z) - .map(HydrologyColumnSample::terrainHeight) - .orElse(naturalHeight) + new HydrologyCaveVoxelViewFactory.PlannedSurface() { + @Override + public int resolve(int x, int z, int naturalHeight) { + return runtime.sample(x, z).map(HydrologyColumnSample::terrainHeight).orElse(naturalHeight); + } + + @Override + public boolean ownsTerrain(int x, int z) { + HydrologyColumnSample sample = runtime.sample(x, z).orElse(null); + return sample != null && sample.primarySurfaceLayer().isPresent(); + } + } ); int minimumX = chunkX << 4; int minimumZ = chunkZ << 4; diff --git a/probe/src/main/java/art/arcane/iris/probe/Terrain3DProbe.java b/probe/src/main/java/art/arcane/iris/probe/Terrain3DProbe.java new file mode 100644 index 000000000..762a45923 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/Terrain3DProbe.java @@ -0,0 +1,793 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.actuator.IrisTerrainNormalActuator; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.terrain.Terrain3DColumn; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.hunk.Hunk; +import com.google.gson.GsonBuilder; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +public final class Terrain3DProbe { + private static final String PREFIX = "[terrain3d]"; + private static final int MAXIMUM_CHUNKS_PER_AXIS = 64; + private static final int MINIMUM_GAP_HEIGHT = 2; + + private Terrain3DProbe() { + } + + record Configuration(File pack, String dimension, long seed, int minimumChunkX, int maximumChunkX, + int minimumChunkZ, int maximumChunkZ, Set requiredBiomes, File output, + boolean strictGeometry, boolean studio) { + Configuration { + if (pack == null || !pack.isAbsolute() || dimension == null || dimension.isBlank() + || dimension.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("An absolute pack path and nonblank dimension key are required."); + } + validateAxis(minimumChunkX, maximumChunkX); + validateAxis(minimumChunkZ, maximumChunkZ); + if (output == null || !output.isAbsolute()) { + throw new IllegalArgumentException("The output directory must be absolute."); + } + requiredBiomes = Set.copyOf(requiredBiomes); + } + + static Configuration parse(String[] args) { + if (args.length != 11) { + throw new IllegalArgumentException("Expected: " + + " "); + } + LinkedHashSet required = new LinkedHashSet<>(); + if (!args[7].equals("-")) { + for (String value : args[7].split(",", -1)) { + String key = value.trim(); + if (key.isBlank() || key.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Required biome keys must be comma-separated nonblank load keys."); + } + required.add(key); + } + } + return new Configuration(new File(args[0]), args[1], Long.parseLong(args[2]), + Integer.parseInt(args[3]), Integer.parseInt(args[4]), Integer.parseInt(args[5]), + Integer.parseInt(args[6]), required, new File(args[8]), + RealPackProbeSupport.parseBoolean(args[9], "strictGeometry"), + RealPackProbeSupport.parseBoolean(args[10], "studio")); + } + + int chunkCount() { + return (maximumChunkX - minimumChunkX + 1) * (maximumChunkZ - minimumChunkZ + 1); + } + + private static void validateAxis(int minimum, int maximum) { + long width = (long) maximum - minimum + 1; + if (width < 1 || width > MAXIMUM_CHUNKS_PER_AXIS + || minimum < Integer.MIN_VALUE / 16 || maximum > Integer.MAX_VALUE / 16 - 1) { + throw new IllegalArgumentException("Chunk axes must be ordered, at most 64 chunks wide, and fit block coordinates."); + } + } + } + + record ColumnEvidence(int additiveBlocks, int retainedAdditiveBlocks, int coveredGaps, int retainedCoveredGaps, + int carvedBlocks, int retainedCarvedBlocks, int geometryMismatches, + int finalGeometryDifferences, int generatedTop, int maximumGapHeight) { + } + + record ColumnMorphology(int solidSpans, int coveredAirBlocks, long[] gapHeightBins, + long[] upperSolidThicknessBins) { + } + + static final class BiomeEvidence { + long sampledColumns; + long shapedColumns; + long additiveBlocks; + long retainedAdditiveBlocks; + long coveredGaps; + long retainedCoveredGaps; + long carvedBlocks; + long retainedCarvedBlocks; + long geometryMismatches; + long finalGeometryDifferences; + long heightMismatches; + long columnsWithCoveredGaps; + long columnsWithMultipleSolidSpans; + long additionalSolidSpans; + long coveredAirBlocks; + final long[] gapHeightBins = new long[5]; + final long[] upperSolidThicknessBins = new long[5]; + int maximumGapHeight; + final List examples = new ArrayList<>(); + + void add(ColumnEvidence evidence, ColumnMorphology morphology, boolean heightMismatch, int x, int z, int minHeight) { + shapedColumns++; + additiveBlocks += evidence.additiveBlocks(); + retainedAdditiveBlocks += evidence.retainedAdditiveBlocks(); + coveredGaps += evidence.coveredGaps(); + retainedCoveredGaps += evidence.retainedCoveredGaps(); + carvedBlocks += evidence.carvedBlocks(); + retainedCarvedBlocks += evidence.retainedCarvedBlocks(); + geometryMismatches += evidence.geometryMismatches(); + finalGeometryDifferences += evidence.finalGeometryDifferences(); + heightMismatches += heightMismatch ? 1 : 0; + columnsWithCoveredGaps += morphology.coveredAirBlocks() > 0 ? 1 : 0; + columnsWithMultipleSolidSpans += morphology.solidSpans() > 1 ? 1 : 0; + additionalSolidSpans += Math.max(0, morphology.solidSpans() - 1); + coveredAirBlocks += morphology.coveredAirBlocks(); + for (int index = 0; index < gapHeightBins.length; index++) { + gapHeightBins[index] += morphology.gapHeightBins()[index]; + upperSolidThicknessBins[index] += morphology.upperSolidThicknessBins()[index]; + } + maximumGapHeight = Math.max(maximumGapHeight, evidence.maximumGapHeight()); + if (evidence.retainedCoveredGaps() > 0 && examples.size() < 8) { + examples.add(x + "," + (evidence.generatedTop() + minHeight) + "," + z); + } + } + + boolean coversOverhangs() { + return retainedCoveredGaps > 0 && retainedAdditiveBlocks > 0; + } + } + + record Result(String status, Configuration configuration, int generatedChunks, int worldMinimumY, + Map biomes, TopologyEvidence topology, + List failures, List sections) { + } + + record TopologyEvidence(long groundedComponents, long censoredComponents, long detachedComponents, + long detachedBlocks, long retainedDetachedBlocks, long[] detachedSizeBins, + long fullyRetainedSmallComponents, List examples) { + } + + public static void main(String[] args) { + int exitCode = 2; + try { + Configuration configuration = Configuration.parse(args); + Result result = run(configuration); + System.out.println("IRIS_TERRAIN3D_RESULT version=1 status=" + result.status() + + " dimension=" + configuration.dimension() + " seed=" + configuration.seed() + + " chunks=" + result.generatedChunks() + " biomes=" + result.biomes().size() + + " failures=" + result.failures().size() + " studio=" + configuration.studio() + + " strict_geometry=" + configuration.strictGeometry() + + " output=" + configuration.output().getAbsolutePath()); + exitCode = result.failures().isEmpty() ? 0 : 1; + } catch (Throwable failure) { + System.out.println(PREFIX + " FAIL: " + failure.getMessage()); + failure.printStackTrace(System.out); + } + System.exit(exitCode); + } + + static ColumnEvidence inspectColumn(int baseTop, boolean[] predicted, boolean[] terrain, + boolean[] generated, boolean[] retainedTerrain) { + int height = predicted.length; + if (height < 1 || terrain.length != height || generated.length != height || retainedTerrain.length != height) { + throw new IllegalArgumentException("All column buffers must have the same positive height."); + } + int additive = 0; + int retainedAdditive = 0; + int carved = 0; + int retainedCarved = 0; + int mismatches = 0; + int finalDifferences = 0; + int top = -1; + for (int y = 0; y < height; y++) { + if (terrain[y]) { + top = y; + } + if (predicted[y] != terrain[y]) { + mismatches++; + } + if (predicted[y] != generated[y]) { + finalDifferences++; + } + if (y > baseTop && predicted[y] && terrain[y]) { + additive++; + retainedAdditive += retainedTerrain[y] ? 1 : 0; + } + if (y <= baseTop && !predicted[y] && !terrain[y]) { + carved++; + retainedCarved += !generated[y] ? 1 : 0; + } + } + int gaps = 0; + int retainedGaps = 0; + int maximumGap = 0; + for (int bottom = 1; bottom < height - 1; bottom++) { + if (predicted[bottom] || !predicted[bottom - 1] || terrain[bottom]) { + continue; + } + int roof = bottom + 1; + while (roof < height && !predicted[roof]) { + roof++; + } + int gapHeight = roof - bottom; + if (roof >= height || gapHeight < MINIMUM_GAP_HEIGHT || !terrain[bottom - 1] || !terrain[roof]) { + continue; + } + boolean rawAir = true; + boolean finalAir = true; + for (int y = bottom; y < roof; y++) { + rawAir &= !terrain[y]; + finalAir &= !generated[y]; + } + if (rawAir) { + gaps++; + maximumGap = Math.max(maximumGap, gapHeight); + if (finalAir && retainedTerrain[bottom - 1] && retainedTerrain[roof]) { + retainedGaps++; + } + } + bottom = roof; + } + return new ColumnEvidence(additive, retainedAdditive, gaps, retainedGaps, carved, retainedCarved, + mismatches, finalDifferences, top, maximumGap); + } + + static ColumnMorphology inspectMorphology(boolean[] terrain) { + int spans = 0; + int previousTop = -1; + int coveredAir = 0; + long[] gaps = new long[5]; + long[] thickness = new long[5]; + for (int bottom = 0; bottom < terrain.length; bottom++) { + if (!terrain[bottom]) { + continue; + } + int top = bottom; + while (top + 1 < terrain.length && terrain[top + 1]) { + top++; + } + if (previousTop >= 0) { + int gap = bottom - previousTop - 1; + coveredAir += gap; + gaps[morphologyBin(gap)]++; + thickness[morphologyBin(top - bottom + 1)]++; + } + spans++; + previousTop = top; + bottom = top; + } + return new ColumnMorphology(spans, coveredAir, gaps, thickness); + } + + private static int morphologyBin(int size) { + return size <= 1 ? 0 : size <= 3 ? 1 : size <= 7 ? 2 : size <= 15 ? 3 : 4; + } + + static List coverageFailures(Map biomes, Set required, boolean strict) { + ArrayList failures = new ArrayList<>(); + long shapedColumns = 0; + for (Map.Entry entry : biomes.entrySet()) { + BiomeEvidence evidence = entry.getValue(); + shapedColumns += evidence.shapedColumns; + if (evidence.geometryMismatches > 0 || evidence.heightMismatches > 0) { + failures.add(entry.getKey() + ": terrain output differs from shape/query: blocks=" + + evidence.geometryMismatches + ", heights=" + evidence.heightMismatches); + } + if (strict && evidence.finalGeometryDifferences > 0) { + failures.add(entry.getKey() + ": final chunk geometry differs at " + + evidence.finalGeometryDifferences + " blocks in strict mode"); + } + } + if (shapedColumns == 0) { + failures.add("No shaped terrain columns were generated in the requested rectangle."); + } + for (String key : required) { + BiomeEvidence evidence = biomes.get(key); + if (evidence == null || !evidence.coversOverhangs()) { + failures.add(key + ": required retained additive terrain and covered air gaps were not both generated"); + } + } + return List.copyOf(failures); + } + + private static Result run(Configuration configuration) throws Exception { + Files.createDirectories(configuration.output().toPath()); + TreeMap evidence = new TreeMap<>(); + ArrayList sections = new ArrayList<>(); + int generatedChunks = 0; + int minHeight; + TopologyEvidence topology; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + configuration.pack(), configuration.dimension(), PREFIX); + RealPackProbeSupport.EngineSession session = workspace.openEngine( + configuration.seed(), configuration.studio(), "terrain3d")) { + Engine engine = session.engine(); + minHeight = engine.getMinHeight(); + TerrainTopology terrainTopology = new TerrainTopology( + (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16, + (configuration.maximumChunkZ() - configuration.minimumChunkZ() + 1) * 16); + IrisTerrainNormalActuator actuator = new IrisTerrainNormalActuator(engine); + for (int chunkZ = configuration.minimumChunkZ(); chunkZ <= configuration.maximumChunkZ(); chunkZ++) { + SectionRenderer renderer = new SectionRenderer(configuration, + new SectionBounds(chunkZ, engine.getHeight(), minHeight)); + for (int chunkX = configuration.minimumChunkX(); chunkX <= configuration.maximumChunkX(); chunkX++) { + inspectChunk(engine, actuator, chunkX, chunkZ, evidence, renderer, terrainTopology); + generatedChunks++; + if (generatedChunks % 16 == 0 || generatedChunks == configuration.chunkCount()) { + System.out.println(PREFIX + " generated " + generatedChunks + "/" + configuration.chunkCount()); + } + } + sections.addAll(renderer.write()); + } + topology = terrainTopology.inspect(); + failReported("generation", RealPackProbeSupport.settleAndDrain()); + } + List failures = coverageFailures(evidence, configuration.requiredBiomes(), configuration.strictGeometry()); + Result result = new Result(failures.isEmpty() ? "PASS" : "FAIL", configuration, generatedChunks, + minHeight, evidence, topology, failures, sections); + Files.writeString(configuration.output().toPath().resolve("terrain3d-summary.json"), + new GsonBuilder().setPrettyPrinting().create().toJson(summary(result)) + "\n", StandardCharsets.UTF_8); + for (Map.Entry entry : evidence.entrySet()) { + BiomeEvidence value = entry.getValue(); + System.out.println(PREFIX + " biome=" + entry.getKey() + " shaped=" + value.shapedColumns + + " additive=" + value.retainedAdditiveBlocks + " covered_gaps=" + value.retainedCoveredGaps + + " max_gap=" + value.maximumGapHeight + " geometry_errors=" + value.geometryMismatches); + } + for (String failure : failures) { + System.out.println(PREFIX + " FAIL: " + failure); + } + return result; + } + + private static Map summary(Result result) { + Configuration configuration = result.configuration(); + Map inputs = Map.ofEntries( + Map.entry("pack", configuration.pack().getAbsolutePath()), + Map.entry("dimension", configuration.dimension()), + Map.entry("seed", configuration.seed()), + Map.entry("minimumChunkX", configuration.minimumChunkX()), + Map.entry("maximumChunkX", configuration.maximumChunkX()), + Map.entry("minimumChunkZ", configuration.minimumChunkZ()), + Map.entry("maximumChunkZ", configuration.maximumChunkZ()), + Map.entry("requiredBiomes", configuration.requiredBiomes()), + Map.entry("strictGeometry", configuration.strictGeometry()), + Map.entry("studio", configuration.studio())); + return Map.of("status", result.status(), "configuration", inputs, "generatedChunks", result.generatedChunks(), + "worldMinimumY", result.worldMinimumY(), "biomes", result.biomes(), "failures", result.failures(), + "sections", result.sections(), "topology", result.topology(), "metricDefinitions", Map.of( + "morphologyBinInclusiveRanges", List.of("1", "2–3", "4–7", "8–15", "16+"), + "detachedSizeBinInclusiveRanges", List.of("1–8", "9–64", "65–512", "513+"), + "normalization", "Divide per-biome counts by sampledColumns, including unshaped columns.", + "topologyScope", "Actual normal-terrain solid output; excludes additional-layer ownership and placed features. " + + "Components touching rectangle edges, world ceiling, or excluded ownership are censored. " + + "Examples use local block X,Y,Z relative to the rectangle and world minimum Y.")); + } + + private static void inspectChunk(Engine engine, IrisTerrainNormalActuator actuator, int chunkX, int chunkZ, + Map biomes, SectionRenderer renderer, + TerrainTopology topology) throws Exception { + failReported("before chunk " + chunkX + "," + chunkZ, RealPackProbeSupport.drainReported()); + RealPackProbeSupport.GeneratedChunk generated = RealPackProbeSupport.generateChunk(engine, chunkX, chunkZ); + int height = generated.height(); + Hunk terrain = Hunk.newArrayHunk(16, height, 16); + IrisComplex complex = engine.getComplex(); + int blockX = chunkX << 4; + int blockZ = chunkZ << 4; + actuator.actuate(blockX, blockZ, terrain, false, new ChunkContext(blockX, blockZ, complex)); + failReported("chunk " + chunkX + "," + chunkZ, RealPackProbeSupport.drainReported()); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int worldX = blockX + x; + int worldZ = blockZ + z; + IrisBiome biome = complex.getTrueBiomeStream().get(worldX, worldZ); + String key = biome == null ? "" : biome.getLoadKey(); + BiomeEvidence aggregate = biomes.computeIfAbsent(key, ignored -> new BiomeEvidence()); + aggregate.sampledColumns++; + Terrain3DColumn column = complex.terrainColumn(worldX, worldZ); + boolean[] raw = new boolean[height]; + boolean[] retained = new boolean[height]; + BitSet excluded = new BitSet(height); + boolean additionalTerrain = engine.getUpperContext() != null || engine.getDimensionStackContext() != null; + int rootTop = (int) Math.round(complex.getRawHeightStream().getDouble(worldX, worldZ)); + for (int y = 0; y < height; y++) { + boolean owned = additionalTerrain && engine.isAdditionalTerrainOwned(worldX, y, worldZ); + excluded.set(y, owned); + PlatformBlockState rawBlock = terrain.get(x, y, z); + PlatformBlockState finalBlock = generated.blocks().get(x, y, z); + raw[y] = y <= rootTop && !owned && solid(rawBlock); + retained[y] = raw[y] && finalBlock != null && rawBlock.matches(finalBlock); + } + topology.column(worldX - (renderer.configuration.minimumChunkX() << 4), + worldZ - (renderer.configuration.minimumChunkZ() << 4), + new TopologyColumn(raw, retained, excluded, column != null)); + ColumnEvidence observation = column == null ? null + : inspectShapedColumn(column, terrain, generated.blocks(), x, z); + if (observation != null) { + int queryTop = (int) Math.round(complex.getRawHeightStream().getDouble(worldX, worldZ)); + aggregate.add(observation, inspectMorphology(raw), + queryTop != column.topY() || queryTop != observation.generatedTop(), + worldX, worldZ, engine.getMinHeight()); + } + renderer.column(worldX, z, terrain, generated.blocks(), x, column, + (int) Math.round(complex.getBaseTerrainHeightStream().getDouble(worldX, worldZ)), observation); + } + } + } + + private static ColumnEvidence inspectShapedColumn(Terrain3DColumn column, Hunk terrain, + Hunk generated, int x, int z) { + int height = terrain.getHeight(); + boolean[] predicted = new boolean[height]; + boolean[] raw = new boolean[height]; + boolean[] complete = new boolean[height]; + boolean[] retained = new boolean[height]; + for (int y = 0; y < height; y++) { + PlatformBlockState rawBlock = terrain.get(x, y, z); + PlatformBlockState finalBlock = generated.get(x, y, z); + predicted[y] = column.isSolid(y); + raw[y] = solid(rawBlock); + complete[y] = finalBlock != null && !finalBlock.isAir(); + retained[y] = raw[y] && finalBlock != null && rawBlock.matches(finalBlock); + } + return inspectColumn((int) Math.round(column.baseHeight()), predicted, raw, complete, retained); + } + + private static boolean solid(PlatformBlockState block) { + return block != null && !block.isAir() && !block.isFluid(); + } + + private static void failReported(String phase, List reports) throws Exception { + if (reports.isEmpty()) { + return; + } + RealPackProbeSupport.printReports(PREFIX, phase, reports); + IllegalStateException failure = new IllegalStateException("Engine reported failures during " + phase); + for (Throwable report : reports) { + failure.addSuppressed(report); + } + throw failure; + } + + record TopologyColumn(boolean[] solid, boolean[] retained, BitSet excluded, boolean shaped) { + TopologyColumn { + if (solid.length == 0 || solid.length != retained.length || excluded == null) { + throw new IllegalArgumentException("Topology columns need matching nonempty buffers and an ownership mask."); + } + } + } + + static final class TerrainTopology { + private final int width; + private final int depth; + private final List> columns; + private final List excluded; + + TerrainTopology(int width, int depth) { + if (width < 1 || depth < 1) { + throw new IllegalArgumentException("Topology dimensions must be positive."); + } + this.width = width; + this.depth = depth; + this.columns = new ArrayList<>(width * depth); + this.excluded = new ArrayList<>(width * depth); + for (int index = 0; index < width * depth; index++) { + columns.add(null); + excluded.add(null); + } + } + + void column(int x, int z, TopologyColumn column) { + if (x < 0 || z < 0 || x >= width || z >= depth || columns.get(z * width + x) != null) { + throw new IllegalArgumentException("Each topology column must be supplied exactly once within the rectangle."); + } + ArrayList spans = new ArrayList<>(); + for (int bottom = 0; bottom < column.solid().length; bottom++) { + if (!column.solid()[bottom] || column.excluded().get(bottom)) { + continue; + } + int top = bottom; + long retained = column.retained()[bottom] ? 1 : 0; + while (top + 1 < column.solid().length && column.solid()[top + 1] && !column.excluded().get(top + 1)) { + top++; + retained += column.retained()[top] ? 1 : 0; + } + boolean censored = x == 0 || x == width - 1 || z == 0 || z == depth - 1 + || top == column.solid().length - 1 || column.excluded().get(top + 1) + || bottom > 0 && column.excluded().get(bottom - 1); + spans.add(new SolidSpan(new SpanBounds(x, z, bottom, top), + new SpanObservation(column.shaped(), censored, retained))); + bottom = top; + } + columns.set(z * width + x, spans); + excluded.set(z * width + x, (BitSet) column.excluded().clone()); + } + + TopologyEvidence inspect() { + if (columns.contains(null)) { + throw new IllegalStateException("All columns must be sampled before topology analysis."); + } + for (int z = 0; z < depth; z++) { + for (int x = 0; x < width; x++) { + int index = z * width + x; + if (x > 0) { + connect(index, index - 1); + } + if (z > 0) { + connect(index, index - width); + } + } + } + long grounded = 0; + long censored = 0; + long detached = 0; + long blocks = 0; + long retained = 0; + long smallRetained = 0; + long[] bins = new long[4]; + ArrayList examples = new ArrayList<>(); + for (List column : columns) { + for (SolidSpan span : column) { + if (span.parent != span || !span.shaped) { + continue; + } + if (span.grounded) { + grounded++; + } else if (span.censored) { + censored++; + } else { + detached++; + blocks += span.blocks; + retained += span.retained; + bins[span.blocks <= 8 ? 0 : span.blocks <= 64 ? 1 : span.blocks <= 512 ? 2 : 3]++; + smallRetained += span.blocks <= 64 && span.retained == span.blocks ? 1 : 0; + if (examples.size() < 16) { + examples.add(span.bounds.x() + "," + span.bounds.bottom() + "," + span.bounds.z() + + " blocks=" + span.blocks + " retained=" + span.retained); + } + } + } + } + return new TopologyEvidence(grounded, censored, detached, blocks, retained, bins, + smallRetained, List.copyOf(examples)); + } + + private void connect(int firstIndex, int secondIndex) { + List first = columns.get(firstIndex); + List second = columns.get(secondIndex); + censor(first, excluded.get(secondIndex)); + censor(second, excluded.get(firstIndex)); + int left = 0; + int right = 0; + while (left < first.size() && right < second.size()) { + SolidSpan a = first.get(left); + SolidSpan b = second.get(right); + if (a.bounds.bottom() <= b.bounds.top() && b.bounds.bottom() <= a.bounds.top()) { + a.union(b); + } + if (a.bounds.top() < b.bounds.top()) { + left++; + } else { + right++; + } + } + } + + private static void censor(List spans, BitSet ownership) { + for (SolidSpan span : spans) { + int owned = ownership.nextSetBit(span.bounds.bottom()); + if (owned >= 0 && owned <= span.bounds.top()) { + span.root().censored = true; + } + } + } + } + + private record SpanBounds(int x, int z, int bottom, int top) { + } + + private record SpanObservation(boolean shaped, boolean censored, long retained) { + } + + private static final class SolidSpan { + private final SpanBounds bounds; + private SolidSpan parent = this; + private long blocks; + private long retained; + private boolean shaped; + private boolean grounded; + private boolean censored; + + private SolidSpan(SpanBounds bounds, SpanObservation observation) { + this.bounds = bounds; + this.blocks = bounds.top() - bounds.bottom() + 1L; + this.retained = observation.retained(); + this.shaped = observation.shaped(); + this.grounded = bounds.bottom() == 0; + this.censored = observation.censored(); + } + + private SolidSpan root() { + SolidSpan root = this; + while (root.parent != root) { + root = root.parent; + } + SolidSpan current = this; + while (current.parent != root) { + SolidSpan next = current.parent; + current.parent = root; + current = next; + } + return root; + } + + private void union(SolidSpan other) { + SolidSpan first = root(); + SolidSpan second = other.root(); + if (first == second) { + return; + } + if (first.blocks < second.blocks) { + SolidSpan swap = first; + first = second; + second = swap; + } + second.parent = first; + first.blocks += second.blocks; + first.retained += second.retained; + first.shaped |= second.shaped; + first.grounded |= second.grounded; + first.censored |= second.censored; + } + } + + private record SectionBounds(int chunkZ, int height, int minimumY) { + } + + private static final class SectionRenderer { + private static final int LEFT = 48; + private static final int HEADER = 58; + private final Configuration configuration; + private final int chunkZ; + private final int height; + private final int minimumY; + private final BufferedImage[] images = new BufferedImage[16]; + private final long[] scores = new long[16]; + private final int[] lowestShape = new int[16]; + private final int[] highestShape = new int[16]; + + private SectionRenderer(Configuration configuration, SectionBounds bounds) { + this.configuration = configuration; + this.chunkZ = bounds.chunkZ(); + this.height = bounds.height(); + this.minimumY = bounds.minimumY(); + int width = Math.max(720, (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16 + LEFT + 16); + for (int z = 0; z < 16; z++) { + BufferedImage image = new BufferedImage(width, height * 2 + HEADER * 2 + 28, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(new Color(0xf1f4f7)); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.dispose(); + images[z] = image; + lowestShape[z] = height; + } + } + + private void column(int worldX, int localZ, Hunk terrain, Hunk generated, + int localX, Terrain3DColumn shape, int baseHeight, ColumnEvidence observation) { + BufferedImage image = images[localZ]; + int pixelX = LEFT + worldX - (configuration.minimumChunkX() << 4); + for (int y = 0; y < height; y++) { + image.setRGB(pixelX, HEADER + height - y - 1, color(terrain.get(localX, y, localZ), shape, baseHeight, y)); + image.setRGB(pixelX, HEADER * 2 + height * 2 - y - 1, + color(generated.get(localX, y, localZ), shape, baseHeight, y)); + } + if (observation != null) { + scores[localZ] += observation.retainedCoveredGaps() * 1_000L + observation.retainedAdditiveBlocks(); + lowestShape[localZ] = Math.min(lowestShape[localZ], shape.minY()); + highestShape[localZ] = Math.max(highestShape[localZ], shape.topY()); + } + } + + private int color(PlatformBlockState block, Terrain3DColumn shape, int baseHeight, int y) { + if (block == null || block.isAir()) { + return shape != null && y <= shape.topY() && !shape.isSolid(y) ? 0xd8b8ce : 0xeaf1f7; + } + if (block.isFluid()) { + return block.isWater() ? 0x328bbc : 0xe3762b; + } + if (shape != null && y > baseHeight && shape.isSolid(y)) { + return 0xcf9b43; + } + String key = block.key(); + if (key.contains("grass") || key.contains("moss") || block.isTreeBlock()) { + return 0x497b50; + } + if (key.contains("snow") || key.contains("ice")) { + return 0xc5d8de; + } + return 0x58616c; + } + + private List write() throws Exception { + int selected = 8; + for (int z = 0; z < scores.length; z++) { + if (scores[z] > scores[selected]) { + selected = z; + } + } + return List.of(writeSection(selected, "section-z-"), writeSection(8, "fixed-section-z-")); + } + + private String writeSection(int selected, String prefix) throws Exception { + int worldZ = (chunkZ << 4) + selected; + BufferedImage image = images[selected]; + Graphics2D graphics = image.createGraphics(); + graphics.setColor(new Color(0x222a34)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + graphics.drawString(configuration.dimension() + " | seed " + configuration.seed() + " | Z " + worldZ + + " | X " + (configuration.minimumChunkX() << 4) + ".." + ((configuration.maximumChunkX() << 4) + 15), LEFT, 19); + graphics.drawString("Terrain actuator output | gold: added terrain | mauve: shaped air | green: vegetation", LEFT, 39); + graphics.drawString("Complete generated chunk output (all pack features enabled)", LEFT, height + HEADER + 34); + graphics.drawString("Block geometry only; colors identify categories, not Minecraft rendering.", LEFT, image.getHeight() - 8); + for (int y = 0; y < height; y += 64) { + String label = Integer.toString(y + minimumY); + graphics.drawString(label, 4, HEADER + height - y - 1); + graphics.drawString(label, 4, HEADER * 2 + height * 2 - y - 1); + } + graphics.dispose(); + File file = new File(configuration.output(), prefix + worldZ + "-full.png"); + if (!ImageIO.write(image, "PNG", file)) { + throw new IllegalStateException("No PNG writer is available."); + } + return writeFocused(image, selected, prefix); + } + + private String writeFocused(BufferedImage source, int selected, String prefix) throws Exception { + int worldZ = (chunkZ << 4) + selected; + int minimum = lowestShape[selected] == height ? 0 : Math.max(0, lowestShape[selected] - 16); + int maximum = lowestShape[selected] == height ? height - 1 : Math.min(height - 1, highestShape[selected] + 16); + int columns = (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) * 16; + int scale = Math.min(6, Math.max(1, 640 / columns)); + int panelHeight = (maximum - minimum + 1) * scale; + int panelWidth = columns * scale; + int focusedHeader = 80; + BufferedImage focused = new BufferedImage(Math.max(720, panelWidth * 2 + LEFT * 2 + 24), + panelHeight + focusedHeader + 28, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = focused.createGraphics(); + graphics.setColor(new Color(0xf1f4f7)); + graphics.fillRect(0, 0, focused.getWidth(), focused.getHeight()); + graphics.setColor(new Color(0x222a34)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + graphics.drawString(configuration.dimension() + " | seed " + configuration.seed() + " | Z " + worldZ + + " | X " + (configuration.minimumChunkX() << 4) + ".." + ((configuration.maximumChunkX() << 4) + 15), LEFT, 19); + graphics.drawString("Gold: additions | mauve: shaped air | scale " + scale + " px/block", LEFT, 39); + graphics.drawString("Terrain actuator output", LEFT, 64); + graphics.drawString("Complete generation", LEFT * 2 + panelWidth, 64); + for (int panel = 0; panel < 2; panel++) { + int destinationLeft = LEFT + panel * (LEFT + panelWidth); + int sourceTop = HEADER + panel * (HEADER + height) + height - maximum - 1; + graphics.drawImage(source, destinationLeft, focusedHeader, destinationLeft + panelWidth, focusedHeader + panelHeight, + LEFT, sourceTop, LEFT + columns, sourceTop + maximum - minimum + 1, null); + for (int y = minimum; y <= maximum; y += 16) { + graphics.drawString(Integer.toString(y + minimumY), destinationLeft - 44, + focusedHeader + (maximum - y) * scale + 10); + } + } + graphics.drawString("Actual block cross-section; colors do not represent Minecraft rendering.", LEFT, focused.getHeight() - 8); + graphics.dispose(); + File output = new File(configuration.output(), prefix + worldZ + ".png"); + if (!ImageIO.write(focused, "PNG", output)) { + throw new IllegalStateException("No PNG writer is available."); + } + return output.getAbsolutePath(); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/Terrain3DProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/Terrain3DProbeTest.java new file mode 100644 index 000000000..518dd9b98 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/Terrain3DProbeTest.java @@ -0,0 +1,222 @@ +package art.arcane.iris.probe; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.BitSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class Terrain3DProbeTest { + @Test + public void recognizesAddedRockAboveAnAirGapWithFloorAndRoof() { + boolean[] column = column(12, 0, 3, 7, 9); + Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn(6, column, column, column, column); + + assertEquals(3, evidence.additiveBlocks()); + assertEquals(3, evidence.retainedAdditiveBlocks()); + assertEquals(3, evidence.carvedBlocks()); + assertEquals(3, evidence.retainedCarvedBlocks()); + assertEquals(1, evidence.retainedCoveredGaps()); + assertEquals(3, evidence.maximumGapHeight()); + assertEquals(9, evidence.generatedTop()); + assertEquals(0, evidence.geometryMismatches()); + } + + @Test + public void doesNotCountCavesOrFloatingObjectsAsVolumetricTerrain() { + boolean[] predicted = column(16, 0, 9); + boolean[] generated = column(16, 0, 3, 7, 9, 13, 14); + Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn( + 9, predicted, predicted, generated, generated); + + assertEquals(0, evidence.coveredGaps()); + assertEquals(0, evidence.retainedCoveredGaps()); + assertEquals(0, evidence.additiveBlocks()); + assertEquals(0, evidence.retainedAdditiveBlocks()); + assertEquals(5, evidence.finalGeometryDifferences()); + } + + @Test + public void rejectsCoveredGapProofWhenLaterGenerationFillsAirOrRemovesTheRoof() { + boolean[] predicted = column(12, 0, 3, 7, 9); + boolean[] filled = predicted.clone(); + filled[5] = true; + Terrain3DProbe.ColumnEvidence obstructed = Terrain3DProbe.inspectColumn(6, predicted, predicted, filled, predicted); + boolean[] roofRemoved = predicted.clone(); + roofRemoved[7] = false; + Terrain3DProbe.ColumnEvidence open = Terrain3DProbe.inspectColumn(6, predicted, predicted, roofRemoved, roofRemoved); + + assertEquals(1, obstructed.coveredGaps()); + assertEquals(0, obstructed.retainedCoveredGaps()); + assertEquals(0, open.retainedCoveredGaps()); + } + + @Test + public void detectsOneBlockTerrainErrorsAndIgnoresSingleBlockCracksForOverhangCoverage() { + boolean[] predicted = column(12, 0, 3, 5, 9); + boolean[] actual = predicted.clone(); + actual[10] = true; + Terrain3DProbe.ColumnEvidence evidence = Terrain3DProbe.inspectColumn(6, predicted, actual, actual, actual); + + assertEquals(0, evidence.coveredGaps()); + assertEquals(1, evidence.geometryMismatches()); + assertEquals(10, evidence.generatedTop()); + } + + @Test + public void requiresPositiveBiomeCoverageAndDoesNotPassAnEmptyScan() { + assertFalse(Terrain3DProbe.coverageFailures(Map.of(), Set.of(), false).isEmpty()); + Terrain3DProbe.BiomeEvidence biome = new Terrain3DProbe.BiomeEvidence(); + biome.shapedColumns = 200; + biome.retainedAdditiveBlocks = 4; + Map evidence = Map.of("highlands", biome); + + assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty()); + biome.retainedCoveredGaps = 1; + assertTrue(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty()); + assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("missing"), false).isEmpty()); + biome.finalGeometryDifferences = 1; + assertTrue(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), false).isEmpty()); + assertFalse(Terrain3DProbe.coverageFailures(evidence, Set.of("highlands"), true).isEmpty()); + } + + @Test + public void rejectsUnboundedOrMalformedProbeInputs() { + String[] inputs = {"/tmp/pack", "overworld", "1337", "-1", "1", "-1", "1", "highlands", "/tmp/output", "false", "true"}; + Terrain3DProbe.Configuration configuration = Terrain3DProbe.Configuration.parse(inputs); + assertEquals(9, configuration.chunkCount()); + assertTrue(configuration.studio()); + inputs[4] = "64"; + assertThrows(IllegalArgumentException.class, () -> Terrain3DProbe.Configuration.parse(inputs)); + inputs[4] = "1"; + inputs[7] = "highlands,"; + assertThrows(IllegalArgumentException.class, () -> Terrain3DProbe.Configuration.parse(inputs)); + } + + @Test + public void recordsTinyPerforationsAndThinRoofsWithoutCallingThemLargeOverhangs() { + Terrain3DProbe.ColumnMorphology morphology = Terrain3DProbe.inspectMorphology(column(16, 0, 4, 6, 6, 10, 13)); + + assertEquals(3, morphology.solidSpans()); + assertEquals(4, morphology.coveredAirBlocks()); + assertArrayEquals(new long[]{1, 1, 0, 0, 0}, morphology.gapHeightBins()); + assertArrayEquals(new long[]{1, 0, 1, 0, 0}, morphology.upperSolidThicknessBins()); + } + + @Test + public void countsOnlyFaceConnectedDetachedTerrainAndReportsRetention() { + boolean[][][] voxels = terrainVolume(5, 5); + voxels[1][1][7] = true; + voxels[2][2][8] = true; + Terrain3DProbe.TopologyEvidence evidence = inspectVolume(voxels, new VolumeOptions(false, false, true)); + + assertEquals(2, evidence.detachedComponents()); + assertEquals(2, evidence.detachedBlocks()); + assertEquals(1, evidence.retainedDetachedBlocks()); + assertEquals(1, evidence.fullyRetainedSmallComponents()); + assertArrayEquals(new long[]{2, 0, 0, 0}, evidence.detachedSizeBins()); + } + + @Test + public void followsGroundSupportAcrossAChunkBoundary() { + boolean[][][] voxels = terrainVolume(20, 5); + for (int x = 14; x <= 17; x++) { + voxels[x][2][8] = true; + } + Arrays.fill(voxels[16][2], 0, 9, true); + Terrain3DProbe.TopologyEvidence evidence = inspectVolume(voxels, new VolumeOptions(false, false, false)); + + assertEquals(1, evidence.groundedComponents()); + assertEquals(0, evidence.detachedComponents()); + } + + @Test + public void distinguishesThe64BlockBoundaryWithoutCullingAnything() { + boolean[][][] voxels = terrainVolume(12, 12); + for (int x = 1; x <= 8; x++) { + for (int z = 1; z <= 8; z++) { + voxels[x][z][7] = true; + } + } + Terrain3DProbe.TopologyEvidence small = inspectVolume(voxels, new VolumeOptions(false, false, false)); + voxels[9][8][7] = true; + Terrain3DProbe.TopologyEvidence large = inspectVolume(voxels, new VolumeOptions(false, false, false)); + + assertArrayEquals(new long[]{0, 1, 0, 0}, small.detachedSizeBins()); + assertEquals(1, small.fullyRetainedSmallComponents()); + assertArrayEquals(new long[]{0, 0, 1, 0}, large.detachedSizeBins()); + assertEquals(0, large.fullyRetainedSmallComponents()); + } + + @Test + public void censorsTruncatedComponentsAndExcludesUnshapedAuthoredTerrain() { + boolean[][][] edge = terrainVolume(5, 5); + edge[0][2][7] = true; + Terrain3DProbe.TopologyEvidence boundary = inspectVolume(edge, new VolumeOptions(false, false, false)); + boolean[][][] owned = terrainVolume(5, 5); + owned[2][2][7] = true; + Terrain3DProbe.TopologyEvidence ownership = inspectVolume(owned, new VolumeOptions(true, false, false)); + Terrain3DProbe.TopologyEvidence unshaped = inspectVolume(owned, new VolumeOptions(false, true, false)); + + assertEquals(1, boundary.censoredComponents()); + assertEquals(0, boundary.detachedComponents()); + assertEquals(1, ownership.censoredComponents()); + assertEquals(0, ownership.detachedComponents()); + assertEquals(0, unshaped.detachedComponents()); + } + + @Test + public void rejectsPartialTopologyEvidence() { + Terrain3DProbe.TerrainTopology topology = new Terrain3DProbe.TerrainTopology(3, 3); + topology.column(0, 0, new Terrain3DProbe.TopologyColumn(column(16, 0, 1), column(16, 0, 1), new BitSet(), true)); + + assertThrows(IllegalStateException.class, topology::inspect); + } + + private static boolean[][][] terrainVolume(int width, int depth) { + boolean[][][] result = new boolean[width][depth][16]; + for (int x = 0; x < width; x++) { + for (int z = 0; z < depth; z++) { + result[x][z][0] = true; + } + } + return result; + } + + private static Terrain3DProbe.TopologyEvidence inspectVolume(boolean[][][] voxels, VolumeOptions options) { + Terrain3DProbe.TerrainTopology topology = new Terrain3DProbe.TerrainTopology(voxels.length, voxels[0].length); + for (int x = 0; x < voxels.length; x++) { + for (int z = 0; z < voxels[x].length; z++) { + BitSet excluded = new BitSet(); + if (options.excludedNeighbor() && x == 3 && z == 2) { + excluded.set(7); + } + boolean[] retained = voxels[x][z].clone(); + if (options.removeOneBlock() && x == 1 && z == 1) { + retained[7] = false; + } + topology.column(x, z, new Terrain3DProbe.TopologyColumn(voxels[x][z], retained, excluded, + !options.unshaped())); + } + } + return topology.inspect(); + } + + private record VolumeOptions(boolean excludedNeighbor, boolean unshaped, boolean removeOneBlock) { + } + + private static boolean[] column(int height, int... boundaries) { + boolean[] result = new boolean[height]; + for (int index = 0; index < boundaries.length; index += 2) { + Arrays.fill(result, boundaries[index], boundaries[index + 1] + 1, true); + } + return result; + } +} diff --git a/probe/src/test/resources/terrain3d-pack/biomes/ledges.json b/probe/src/test/resources/terrain3d-pack/biomes/ledges.json new file mode 100644 index 000000000..b416e4301 --- /dev/null +++ b/probe/src/test/resources/terrain3d-pack/biomes/ledges.json @@ -0,0 +1,17 @@ +{ + "name": "Ledges", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [{"palette": [{"block": "minecraft:stone"}]}], + "generators": [{"generator": "flat", "min": 128, "max": 128}], + "terrain3D": { + "amplitude": 64, + "horizontalScale": 96, + "verticalScale": 16, + "minimumSlope": 0, + "slopeFade": 1, + "fluidClearance": 0, + "fluidFade": 1, + "crackDepth": 0 + } +} diff --git a/probe/src/test/resources/terrain3d-pack/dimensions/terrain3d.json b/probe/src/test/resources/terrain3d-pack/dimensions/terrain3d.json new file mode 100644 index 000000000..988437b6c --- /dev/null +++ b/probe/src/test/resources/terrain3d-pack/dimensions/terrain3d.json @@ -0,0 +1,11 @@ +{ + "name": "Volumetric terrain fixture", + "version": 1, + "mode": {"type": "OVERWORLD"}, + "regions": ["ledges"], + "environment": "NORMAL", + "dimensionHeight": {"min": 0, "max": 256}, + "logicalHeight": 256, + "fluidHeight": 0, + "caveProfile": {"enabled": false} +} diff --git a/probe/src/test/resources/terrain3d-pack/generators/flat.json b/probe/src/test/resources/terrain3d-pack/generators/flat.json new file mode 100644 index 000000000..eda4f4611 --- /dev/null +++ b/probe/src/test/resources/terrain3d-pack/generators/flat.json @@ -0,0 +1,5 @@ +{ + "seed": 310, + "interpolator": {"function": "NONE", "horizontalScale": 1}, + "composite": [{"seed": 310, "style": {"style": "FLAT"}}] +} diff --git a/probe/src/test/resources/terrain3d-pack/regions/ledges.json b/probe/src/test/resources/terrain3d-pack/regions/ledges.json new file mode 100644 index 000000000..f1cd3fceb --- /dev/null +++ b/probe/src/test/resources/terrain3d-pack/regions/ledges.json @@ -0,0 +1,6 @@ +{ + "name": "Ledges", + "landBiomes": ["ledges"], + "seaBiomes": ["ledges"], + "shoreBiomes": ["ledges"] +}