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
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-01 23:08:47 -04:00
1 parent 1ad3a25cb6
commit 639786537b
9 files changed
+238 -11

No files matched your search

@@ -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<String> 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<String> errors) {
PackJsonFieldChecks.validateOptionalDoubleRange(path, mouths, "flareRatio", 1D, 4D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 32, errors);
@@ -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<PlatformBl
IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds();
boolean exposeCutStrata = dimension.getHydrology() != null
&& dimension.getHydrology().getRivers().getSurface().getBanks().isExposeCutStrata();
IrisSurfaceRiverBedConfig riverBed = dimension.getHydrology() == null
? null
: dimension.getHydrology().getRivers().getSurface().getBed();
boolean padRiverBed = riverBed != null && !riverBed.isAllowGravityBlocks();
for (int zf = 0; zf < chunkDepth; zf++) {
int realZ = zf + z;
@@ -136,6 +142,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
&& !hydrologyTerrain.channel()
? Math.max(0, hydrology.naturalHeight() - he)
: 0;
boolean riverOwned = padRiverBed && hydrologyTerrain != null && hydrologyTerrain.terrainOwned();
PlatformBlockState fluid = hydrologyFluid == null
? complex.resolveSurfaceFluid(realX, realZ)
: complex.resolveHydrologyFluid(hydrologyFluid.profileKey(), realX, realZ);
@@ -203,7 +210,11 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
}
if (blocks.hasIndex(depth + cut)) {
h.setRaw(xf, i, zf, blocks.get(depth + cut));
PlatformBlockState layerBlock = blocks.get(depth + cut);
if (riverOwned && depth <= riverBed.getPadding() && IrisProceduralBlocks.isGravityAffected(layerBlock)) {
layerBlock = riverBed.getPaddingPalette().get(localRng, realX, i, realZ, data);
}
h.setRaw(xf, i, zf, layerBlock);
continue;
}
@@ -0,0 +1,57 @@
package art.arcane.iris.engine.hydrology.runtime;
import art.arcane.iris.engine.hydrology.HydrologyTileKey;
import java.util.ArrayList;
import java.util.List;
/**
* Ring order for feature searches: tiles are visited outward from the origin tile so a search can
* stop as soon as no unvisited tile could hold a nearer feature, instead of planning the whole box.
*/
final class HydrologyFeatureSearch {
private HydrologyFeatureSearch() {
}
/** Tiles at exactly Chebyshev distance {@code radius} from the origin tile, in row-major order. */
static List<HydrologyTileKey> 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<HydrologyTileKey> 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);
}
}
@@ -988,16 +988,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
}
static boolean isGravityAffected(PlatformBlockState state) {
if (state == null) {
return false;
}
String key = IrisProceduralBlocks.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");
return IrisProceduralBlocks.isGravityAffected(state);
}
private IrisBiome resolveCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) {
@@ -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) {
@@ -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");
}
@@ -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();
@@ -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("""
@@ -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<HydrologyTileKey> seen = new HashSet<>();
for (int radius = 0; radius <= 3; radius++) {
List<HydrologyTileKey> 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));
}
}