From 639786537b3898d9390c3b1045690c4f3b5e3b85 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 1 Sep 2026 23:08:47 -0400 Subject: [PATCH] River bed padding and feature search rings - surface.bed: falling blocks under and beside river water are replaced by a padding palette unless allowed - Ring order helper for bounded feature searches --- .../core/pack/PackHydrologyValidator.java | 37 ++++++++++++ .../actuator/IrisTerrainNormalActuator.java | 13 ++++- .../runtime/HydrologyFeatureSearch.java | 57 +++++++++++++++++++ .../engine/modifier/IrisCarveModifier.java | 11 +--- .../engine/object/IrisProceduralBlocks.java | 14 +++++ .../object/IrisSurfaceRiverBedConfig.java | 25 ++++++++ .../engine/object/IrisSurfaceRiverConfig.java | 2 + .../core/pack/PackHydrologyValidatorTest.java | 43 ++++++++++++++ .../runtime/HydrologyFeatureSearchTest.java | 47 +++++++++++++++ 9 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearch.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index 91f48d797..a26d479a0 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -324,6 +324,7 @@ final class PackHydrologyValidator { JSONObject banks = nestedObject(surface, "banks", path, errors); JSONObject flow = nestedObject(surface, "flow", path, errors); JSONObject mouths = nestedObject(surface, "mouths", path, errors); + JSONObject bed = nestedObject(surface, "bed", path, errors); SourceBudget budget = sources == null ? SourceBudget.defaults() @@ -340,6 +341,9 @@ final class PackHydrologyValidator { if (mouths != null) { validateMouths(path + ".mouths", mouths, errors); } + if (bed != null) { + validateSurfaceBed(path + ".bed", bed, errors); + } boolean surfaceEnabled = booleanValue(surface, "enabled", true); if (surfaceEnabled && budget.maximumExpectedSources() == 0) { @@ -382,6 +386,39 @@ final class PackHydrologyValidator { PackJsonFieldChecks.validateOptionalIntegerRange(path, flow, "waterfallMinimumDrop", 2, 32, errors); } + private static void validateSurfaceBed(String path, JSONObject bed, List errors) { + PackJsonFieldChecks.validateOptionalBoolean(path, bed, "allowGravityBlocks", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, bed, "padding", 0, 8, errors); + if (!bed.has("paddingPalette")) { + return; + } + Object rawPalette = bed.opt("paddingPalette"); + if (!(rawPalette instanceof JSONObject palette)) { + errors.add(path + ".paddingPalette must be an object."); + return; + } + Object rawEntries = palette.opt("palette"); + if (!(rawEntries instanceof JSONArray entries) || entries.length() == 0) { + errors.add(path + ".paddingPalette.palette must contain at least one solid block."); + return; + } + for (int index = 0; index < entries.length(); index++) { + String entryPath = path + ".paddingPalette.palette[" + index + "]"; + JSONObject entry = entries.optJSONObject(index); + if (entry == null) { + errors.add(entryPath + " must be an object."); + continue; + } + Object rawBlock = entry.opt("block"); + if (!(rawBlock instanceof String block) || block.isBlank()) { + errors.add(entryPath + ".block must name a solid block."); + } else if (!definitelyNotFluid(block)) { + errors.add(entryPath + ".block must not be a fluid."); + } + PackJsonFieldChecks.validateOptionalIntegerRange(entryPath, entry, "weight", 1, 64, errors); + } + } + private static void validateMouths(String path, JSONObject mouths, List errors) { PackJsonFieldChecks.validateOptionalDoubleRange(path, mouths, "flareRatio", 1D, 4D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 32, errors); 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 eb249afe3..6e62ce062 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 @@ -26,6 +26,8 @@ import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.IrisSurfaceRiverBedConfig; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisOreGenerator; import art.arcane.iris.engine.object.IrisOreGeneratorBounds; @@ -105,6 +107,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator ring(int originTileX, int originTileZ, int radius) { + if (radius < 0) { + throw new IllegalArgumentException("radius must not be negative."); + } + if (radius == 0) { + return List.of(new HydrologyTileKey(originTileX, originTileZ)); + } + ArrayList keys = new ArrayList<>(radius * 8); + for (int tileZ = originTileZ - radius; tileZ <= originTileZ + radius; tileZ++) { + boolean edgeRow = tileZ == originTileZ - radius || tileZ == originTileZ + radius; + for (int tileX = originTileX - radius; tileX <= originTileX + radius; tileX++) { + boolean edgeColumn = tileX == originTileX - radius || tileX == originTileX + radius; + if (edgeRow || edgeColumn) { + keys.add(new HydrologyTileKey(tileX, tileZ)); + } + } + } + return List.copyOf(keys); + } + + /** Number of rings needed so every tile that can publish within {@code maximumDistance} is visited. */ + static int ringLimit(int maximumDistance, int tileSize, int publicationRadius) { + if (maximumDistance < 0 || tileSize <= 0 || publicationRadius < 0) { + throw new IllegalArgumentException("Hydrology feature search bounds are invalid."); + } + long reach = (long) maximumDistance + publicationRadius; + return Math.toIntExact((reach + tileSize - 1) / tileSize); + } + + /** + * Smallest block distance a feature published by a ring-{@code radius} tile can have from the + * origin: the ring starts {@code radius - 1} tiles away and a tile's footprint reaches + * {@code publicationRadius} beyond its bounds. + */ + static long lowerBound(int radius, int tileSize, int publicationRadius) { + if (radius <= 0) { + return 0L; + } + return Math.max(0L, (long) (radius - 1) * tileSize - publicationRadius); + } +} 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 9ab217c0b..814cd6333 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 @@ -988,16 +988,7 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java index 845d57556..158aba60b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java @@ -74,6 +74,20 @@ public final class IrisProceduralBlocks { return key.indexOf(',', valueStart) >= 0 || key.indexOf(']', valueStart) >= 0; } + /** Blocks that fall when unsupported: sand, gravel and concrete powder. */ + public static boolean isGravityAffected(PlatformBlockState state) { + if (state == null) { + return false; + } + String key = materialKey(state); + return key.equals("minecraft:sand") + || key.equals("minecraft:red_sand") + || key.equals("minecraft:gravel") + || key.equals("minecraft:suspicious_sand") + || key.equals("minecraft:suspicious_gravel") + || key.endsWith("_concrete_powder"); + } + public static String materialKey(PlatformBlockState state) { String memoized = state.materialKey(); if (memoized != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java new file mode 100644 index 000000000..2a6ccb6d1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java @@ -0,0 +1,25 @@ +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 lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls the material under and beside surface river water.") +@Data +public class IrisSurfaceRiverBedConfig { + @Desc("Allow sand, gravel and other falling blocks in the bed, shore and eroded banks. When false they are replaced by the padding palette.") + private boolean allowGravityBlocks = false; + + @MinNumber(0) + @MaxNumber(8) + @Desc("Blocks below the bed that are also kept free of falling blocks, so nothing under the water can collapse.") + private int padding = 2; + + @Desc("Blocks used in place of falling blocks in the bed, padding, shore and banks.") + private IrisMaterialPalette paddingPalette = new IrisMaterialPalette().qclear().qadd("clay").qadd("dirt"); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java index 6c667000b..e97d4e91d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java @@ -21,6 +21,8 @@ public class IrisSurfaceRiverConfig { @Desc("Lip, shore band and eroded valley around the channel.") private IrisSurfaceRiverBankConfig banks = new IrisSurfaceRiverBankConfig(); + @Desc("Material under and beside the water: falling-block replacement and padding.") + private IrisSurfaceRiverBedConfig bed = new IrisSurfaceRiverBedConfig(); @Desc("Cascade and waterfall thresholds.") private IrisSurfaceRiverFlowConfig flow = new IrisSurfaceRiverFlowConfig(); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java index 121c06330..75cede05c 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java @@ -113,6 +113,49 @@ public class PackHydrologyValidatorTest { assertTrue(result.errors().toString(), result.errors().isEmpty()); } + @Test + public void bedRulesRejectBadPaddingAndFluidPaddingBlocks() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}, + "surface": { + "bed": { + "allowGravityBlocks": false, + "padding": 9, + "paddingPalette": {"palette": [{"block": "minecraft:water"}]} + } + } + } + """, "")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "padding must be at most 8"); + assertContains(result.errors(), "paddingPalette.palette[0].block must not be a fluid"); + } + + @Test + public void bedRulesAcceptSolidPaddingBlocks() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}, + "surface": { + "bed": { + "allowGravityBlocks": true, + "padding": 0, + "paddingPalette": {"palette": [{"block": "minecraft:clay"}, {"block": "minecraft:dirt", "weight": 2}]} + } + } + } + """, "")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().stream().noneMatch((String error) -> error.contains(".bed"))); + } + @Test public void rejectsMouthLevelingDistanceBeyondTheRouteLength() throws Exception { File pack = pack(riversDimension(""" diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java new file mode 100644 index 000000000..e21c2329d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyFeatureSearchTest { + @Test + public void ringZeroIsTheOriginTile() { + assertEquals(List.of(new HydrologyTileKey(3, -2)), HydrologyFeatureSearch.ring(3, -2, 0)); + } + + @Test + public void ringsCoverEveryTileAtExactlyTheirChebyshevDistance() { + HashSet seen = new HashSet<>(); + for (int radius = 0; radius <= 3; radius++) { + List ring = HydrologyFeatureSearch.ring(1, 1, radius); + assertEquals(radius == 0 ? 1 : radius * 8, ring.size()); + for (HydrologyTileKey key : ring) { + int distance = Math.max(Math.abs(key.tileX() - 1), Math.abs(key.tileZ() - 1)); + assertEquals(radius, distance); + assertTrue(seen.add(key)); + } + } + assertEquals(49, seen.size()); + } + + @Test + public void ringLimitCoversTheRequestedDistancePlusPublication() { + assertEquals(9, HydrologyFeatureSearch.ringLimit(8192, 1024, 358)); + assertEquals(1, HydrologyFeatureSearch.ringLimit(0, 1024, 358)); + assertEquals(0, HydrologyFeatureSearch.ringLimit(0, 1024, 0)); + } + + @Test + public void lowerBoundGrowsWithTheRingAndNeverGoesNegative() { + assertEquals(0L, HydrologyFeatureSearch.lowerBound(0, 1024, 358)); + assertEquals(0L, HydrologyFeatureSearch.lowerBound(1, 1024, 358)); + assertEquals(666L, HydrologyFeatureSearch.lowerBound(2, 1024, 358)); + assertEquals(1690L, HydrologyFeatureSearch.lowerBound(3, 1024, 358)); + } +}