Merge terrain-first hydrology into master

Surface rivers v2: valley-first eroded channels, spring pools, standing
pools, tributaries, bed padding, ring-ordered feature search, schema v2
hydrology configuration and the transect probe.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 03:00:10 -04:00
330 changed files with 60150 additions and 606 deletions
@@ -62,6 +62,7 @@ public class CustomBiomeSource extends BiomeSource {
private final ConcurrentHashMap<Long, Holder<Biome>> noiseBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> structureBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> surfaceStructureBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>();
private volatile KMap<String, Holder<Biome>> customBiomes;
private volatile Map<Biome, Holder<Biome>> vanillaSpawnBiomes;
private volatile IrisDimension cacheDimension;
@@ -331,8 +332,18 @@ public class CustomBiomeSource extends BiomeSource {
if (quartStep == 1) {
return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler);
}
return super.findBiomeHorizontal(
x, y, z, searchRadius, quartStep, allowed, random, false, sampler);
GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_structure_ring_biome");
if (lease == null) {
throw new IllegalStateException("Iris structure ring biome lookup was rejected during an engine transition");
}
try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
if (!isRuntimeAvailable()) {
throw new IllegalStateException("Iris structure ring biome lookup has no active engine runtime");
}
ensureCachesCurrent();
return findNaturalSurfaceBiomeHorizontal(
x, y, z, searchRadius, quartStep, allowed, random);
}
}
static int horizontalBiomeSearchQuartStep(int blockY, int searchRadius) {
@@ -390,6 +401,60 @@ public class CustomBiomeSource extends BiomeSource {
return resolvedSurfaceHolder;
}
private Pair<BlockPos, Holder<Biome>> findNaturalSurfaceBiomeHorizontal(
int x,
int y,
int z,
int searchRadius,
int quartStep,
Predicate<Holder<Biome>> allowed,
RandomSource random
) {
int centerQuartX = QuartPos.fromBlock(x);
int centerQuartZ = QuartPos.fromBlock(z);
int quartRadius = QuartPos.fromBlock(searchRadius);
Pair<BlockPos, Holder<Biome>> selected = null;
int matches = 0;
for (int radius = 0; radius <= quartRadius; radius += quartStep) {
for (int offsetZ = -radius; offsetZ <= radius; offsetZ += quartStep) {
for (int offsetX = -radius; offsetX <= radius; offsetX += quartStep) {
int quartX = centerQuartX + offsetX;
int quartZ = centerQuartZ + offsetZ;
Holder<Biome> holder = getNaturalSurfaceStructureBiomeHolder(quartX, quartZ);
if (!allowed.test(holder)) {
continue;
}
if (selected == null || random.nextInt(matches + 1) == 0) {
selected = Pair.of(new BlockPos(
QuartPos.toBlock(quartX),
y,
QuartPos.toBlock(quartZ)), holder);
}
matches++;
}
}
}
return selected;
}
private Holder<Biome> getNaturalSurfaceStructureBiomeHolder(int x, int z) {
long columnKey = packColumnKey(x, z);
Holder<Biome> cachedHolder = naturalSurfaceStructureBiomeCache.get(columnKey);
if (cachedHolder != null) {
return cachedHolder;
}
Holder<Biome> resolvedHolder = resolveNaturalSurfaceStructureBiomeHolder(x, z);
Holder<Biome> existingHolder = naturalSurfaceStructureBiomeCache.putIfAbsent(
columnKey, resolvedHolder);
if (existingHolder != null) {
return existingHolder;
}
if (naturalSurfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) {
naturalSurfaceStructureBiomeCache.clear();
}
return resolvedHolder;
}
private boolean isGuaranteedSurfaceBiome(int quartY) {
if (engine == null || engine.isClosed() || engine.getComplex() == null) {
return false;
@@ -417,6 +482,23 @@ public class CustomBiomeSource extends BiomeSource {
return holder;
}
private Holder<Biome> resolveNaturalSurfaceStructureBiomeHolder(int x, int z) {
int blockX = x << 2;
int blockZ = z << 2;
IrisBiome irisBiome = engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ);
if (irisBiome == null) {
throw new IllegalStateException("Iris returned no natural structure biome at block "
+ blockX + "," + blockZ);
}
Holder<Biome> holder = resolveBiomeHolder(biomeRegistry, irisBiome.getStructureDerivativeKey());
if (holder == null) {
throw new IllegalStateException("Iris natural structure biome derivative '"
+ irisBiome.getStructureDerivativeKey() + "' is not registered at block "
+ blockX + "," + blockZ);
}
return holder;
}
public Holder<Biome> getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_visible_biome");
if (lease == null) {
@@ -506,6 +588,7 @@ public class CustomBiomeSource extends BiomeSource {
noiseBiomeCache.clear();
structureBiomeCache.clear();
surfaceStructureBiomeCache.clear();
naturalSurfaceStructureBiomeCache.clear();
customBiomes = refreshedCustomBiomes;
vanillaSpawnBiomes = refreshedSpawnBiomes;
cacheDimension = dimension;
@@ -45,9 +45,36 @@ public class CustomBiomeSourceStructureContractTest {
assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_Y = 0"));
assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_RADIUS = 112"));
assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_QUART_STEP = 4"));
assertTrue(source.contains("x, y, z, searchRadius, quartStep, allowed, random, false, sampler"));
assertTrue(source.contains(
"return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler)"));
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_ring_biome\")"));
assertTrue(source.contains("findNaturalSurfaceBiomeHorizontal("));
assertTrue(source.contains("radius += quartStep"));
assertTrue(source.contains("offsetZ += quartStep"));
assertTrue(source.contains("offsetX += quartStep"));
assertTrue(source.contains("random.nextInt(matches + 1) == 0"));
}
@Test
public void onlyConcentricRingSuitabilityUsesTheNaturalTerrainStream() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource")));
int realResolutionStart = source.indexOf("private Holder<Biome> resolveSurfaceStructureBiomeHolder(");
int naturalResolutionStart = source.indexOf(
"private Holder<Biome> resolveNaturalSurfaceStructureBiomeHolder(");
int resolutionEnd = source.indexOf("public Holder<Biome> getVisibleNoiseBiome(", naturalResolutionStart);
assertTrue(realResolutionStart >= 0);
assertTrue(naturalResolutionStart > realResolutionStart);
assertTrue(resolutionEnd > naturalResolutionStart);
String realResolution = source.substring(realResolutionStart, naturalResolutionStart);
String naturalResolution = source.substring(naturalResolutionStart, resolutionEnd);
assertTrue(realResolution.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)"));
assertFalse(realResolution.contains("getNaturalTrueBiomeStream()"));
assertTrue(naturalResolution.contains(
"engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ)"));
assertFalse(naturalResolution.contains("getTrueBiomeStream()"));
assertFalse(source.contains("studioBootstrapSurfaceStructureBiomeCache"));
}
@Test
@@ -2,6 +2,11 @@ package art.arcane.iris.api.terrain;
public enum IrisColumnField {
SURFACE_HEIGHT,
NATURAL_HEIGHT,
SURFACE_KIND,
BIOME_KEY
BIOME_KEY,
RIVER_STATE,
RIVER_DISTANCE,
RIVER_FLOW,
RIVER_WATER_SURFACE_Y
}
@@ -0,0 +1,64 @@
package art.arcane.iris.api.terrain;
import java.util.Objects;
public record IrisColumnSample(
int blockX,
int blockZ,
int surfaceHeight,
int naturalHeight,
IrisSurfaceKind surfaceKind,
String biomeKey,
IrisRiverState riverState,
double riverDistance,
int riverFlow,
int riverWaterSurfaceY
) {
public static final int UNAVAILABLE_HEIGHT = Integer.MIN_VALUE;
public static final double UNAVAILABLE_RIVER_DISTANCE = Double.NaN;
public static final int UNAVAILABLE_RIVER_FLOW = -1;
public IrisColumnSample {
Objects.requireNonNull(surfaceKind, "surfaceKind");
Objects.requireNonNull(riverState, "riverState");
biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey;
if (!Double.isNaN(riverDistance) && (!Double.isFinite(riverDistance) || riverDistance < 0D)) {
throw new IllegalArgumentException("riverDistance must be non-negative, finite, or unavailable");
}
if (riverFlow < UNAVAILABLE_RIVER_FLOW) {
throw new IllegalArgumentException("riverFlow must be non-negative or unavailable");
}
}
public boolean hasSurfaceHeight() {
return surfaceHeight != UNAVAILABLE_HEIGHT;
}
public boolean hasNaturalHeight() {
return naturalHeight != UNAVAILABLE_HEIGHT;
}
public boolean hasSurfaceKind() {
return surfaceKind != IrisSurfaceKind.UNKNOWN;
}
public boolean hasBiomeKey() {
return biomeKey != null;
}
public boolean hasRiverState() {
return riverState != IrisRiverState.NONE;
}
public boolean hasRiverDistance() {
return !Double.isNaN(riverDistance);
}
public boolean hasRiverFlow() {
return riverFlow != UNAVAILABLE_RIVER_FLOW;
}
public boolean hasRiverWaterSurfaceY() {
return riverWaterSurfaceY != UNAVAILABLE_HEIGHT;
}
}
@@ -2,5 +2,5 @@ package art.arcane.iris.api.terrain;
@FunctionalInterface
public interface IrisColumnSink {
void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey);
void accept(IrisColumnSample sample);
}
@@ -0,0 +1,7 @@
package art.arcane.iris.api.terrain;
public enum IrisRiverState {
NONE,
WET,
DRY
}
@@ -4,6 +4,9 @@ public enum IrisSurfaceKind {
UNKNOWN,
LAND,
SHORE,
RIVER,
RIVER_SHORE,
DRY_CHANNEL,
OCEAN,
VOID
}
@@ -28,6 +28,9 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisRegion;
@@ -37,7 +40,9 @@ import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.director.specialhandlers.HydrologyTypeHandler;
import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler;
import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler;
import art.arcane.iris.util.common.director.specialhandlers.StructureHandler;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
@@ -68,7 +73,7 @@ import java.util.Set;
public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome")
public void biome(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look")
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported", defaultValue = "true")
boolean teleport
@@ -116,6 +121,75 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
}
@Director(description = "Find an accepted hydrology feature")
public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
String type,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
Engine activeEngine = engine();
VolmitSender commandSender = sender();
Player target = player();
if (activeEngine == null || commandSender == null || target == null) {
if (commandSender != null) {
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
}
return;
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
if (runtime == null) {
commandSender.sendMessage(C.YELLOW + "Hydrology is not active in this world.");
return;
}
HydrologyFeatureQuery query;
try {
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
Location origin = target.getLocation();
int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
J.a(() -> {
try {
HydrologyFeatureRef feature = runtime.nearestFeature(
query.types(),
query.profileKey(),
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance,
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
int worldY = feature.y() + activeEngine.getDimension().getMinHeight();
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + worldY + ", " + feature.z() + ".");
return;
}
Location destination = new Location(
target.getWorld(), feature.x(), worldY, feature.z());
prepareStructureTeleport(
target, target.getWorld(), commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
}
});
}
@Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)", descriptionKey = "iris.director.commandfind.director.find_structure_vanilla_key_like_minecraft_village_plains_minecraft_stronghold_imported_iris", sync = true)
public void structure(
@Param(description = "The structure to look for (e.g. minecraft:village_plains, minecraft:stronghold, minecraft_ancient_city)", descriptionKey = "iris.director.commandfind.param.structure_look_e_g_minecraft_village_plains_minecraft_stronghold_minecraft_ancient_city", customHandler = StructureHandler.class)
@@ -25,7 +25,10 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.director.handlers.DimensionHandler;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@@ -90,6 +93,18 @@ public class CommandPack implements DirectorExecutor {
persistValidationCache(packsRoot);
}
@Director(description = "Package a validated dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package")
public void pkg(
@Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false")
boolean obfuscate,
@Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true")
boolean minify
) {
Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify);
}
@Director(description = "Preview or apply unused-resource cleanup", descriptionKey = "iris.director.commandpack.director.preview_apply_unused_resource_cleanup", aliases = {"c"})
public void cleanup(
@Param(description = "The pack folder name to clean", descriptionKey = "iris.director.commandpack.param.pack_folder_name_clean")
@@ -420,18 +420,6 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
@Director(description = "Package a dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package")
public void pkg(
@Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false")
boolean obfuscate,
@Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true")
boolean minify
) {
Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify);
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -318,7 +318,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT ->
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
@@ -2,7 +2,9 @@ package art.arcane.iris.core.service;
import art.arcane.iris.api.terrain.IrisColumnField;
import art.arcane.iris.api.terrain.IrisColumnQuery;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.api.terrain.IrisColumnSink;
import art.arcane.iris.api.terrain.IrisRiverState;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.api.terrain.IrisWorldInfo;
@@ -13,6 +15,8 @@ import art.arcane.iris.core.service.terrain.IrisSampleLimits;
import art.arcane.iris.core.service.terrain.IrisSurfaceClassifier;
import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
@@ -101,13 +105,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
int fluid = engine.getDimension().getFluidHeight();
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -224,26 +230,75 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
EnumSet<IrisColumnField> fields = query.fields();
boolean wantHeight = fields.contains(IrisColumnField.SURFACE_HEIGHT);
boolean wantNaturalHeight = fields.contains(IrisColumnField.NATURAL_HEIGHT);
boolean wantKind = fields.contains(IrisColumnField.SURFACE_KIND);
boolean wantBiome = fields.contains(IrisColumnField.BIOME_KEY);
boolean wantRiverState = fields.contains(IrisColumnField.RIVER_STATE);
boolean wantRiverDistance = fields.contains(IrisColumnField.RIVER_DISTANCE);
boolean wantRiverFlow = fields.contains(IrisColumnField.RIVER_FLOW);
boolean wantRiverWaterSurface = fields.contains(IrisColumnField.RIVER_WATER_SURFACE_Y);
boolean wantRiver = wantKind || wantRiverState || wantRiverDistance || wantRiverFlow
|| wantRiverWaterSurface;
try {
int minHeight = engine.getMinHeight();
int fluid = engine.getDimension().getFluidHeight();
long visited = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> {
if (engine.isClosed()) {
return false;
}
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null;
IrisSurfaceKind kind = wantKind
? IrisSurfaceClassifier.classify(surface, fluid, biome == null ? null : biome.getInferredType())
? IrisSurfaceClassifier.classify(
surface,
fluid,
biome == null ? null : biome.getInferredType(),
hydrology
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
sink.accept(blockX, blockZ, wantHeight ? surface + minHeight : -1, kind, biomeKey);
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
blockX,
blockZ,
wantHeight ? surface + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT,
natural,
kind,
biomeKey,
riverState,
riverDistance,
riverFlow,
riverWaterSurfaceY
));
return true;
});
return visited == query.columnCount();
@@ -253,6 +308,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
}
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
}
private static Optional<String> key(IrisBiome biome) {
String loadKey = biome == null ? null : biome.getLoadKey();
return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey);
@@ -1,6 +1,8 @@
package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.InferredType;
public final class IrisSurfaceClassifier {
@@ -22,4 +24,30 @@ public final class IrisSurfaceClassifier {
return inferredType == InferredType.SHORE ? IrisSurfaceKind.SHORE : IrisSurfaceKind.LAND;
}
public static IrisSurfaceKind classify(
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
HydrologyColumnSample hydrology
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
}
}
@@ -0,0 +1,145 @@
package art.arcane.iris.api.terrain;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class IrisColumnSampleTest {
@Test
public void unavailableFieldsHaveUnambiguousSentinels() {
IrisColumnSample sample = sample(
IrisColumnSample.UNAVAILABLE_HEIGHT,
IrisColumnSample.UNAVAILABLE_HEIGHT,
IrisSurfaceKind.UNKNOWN,
null,
IrisRiverState.NONE,
IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE,
IrisColumnSample.UNAVAILABLE_RIVER_FLOW,
IrisColumnSample.UNAVAILABLE_HEIGHT
);
assertFalse(sample.hasSurfaceHeight());
assertFalse(sample.hasNaturalHeight());
assertFalse(sample.hasSurfaceKind());
assertFalse(sample.hasBiomeKey());
assertFalse(sample.hasRiverState());
assertFalse(sample.hasRiverDistance());
assertFalse(sample.hasRiverFlow());
assertFalse(sample.hasRiverWaterSurfaceY());
}
@Test
public void negativeWorldHeightsRemainAvailableValues() {
IrisColumnSample sample = sample(
-1,
-64,
IrisSurfaceKind.DRY_CHANNEL,
"test:river",
IrisRiverState.DRY,
0D,
0,
-1
);
assertTrue(sample.hasSurfaceHeight());
assertTrue(sample.hasNaturalHeight());
assertTrue(sample.hasSurfaceKind());
assertTrue(sample.hasBiomeKey());
assertTrue(sample.hasRiverState());
assertTrue(sample.hasRiverDistance());
assertTrue(sample.hasRiverFlow());
assertTrue(sample.hasRiverWaterSurfaceY());
}
@Test
public void blankBiomeKeysNormalizeToUnavailable() {
IrisColumnSample sample = sample(
64,
65,
IrisSurfaceKind.LAND,
" ",
IrisRiverState.NONE,
IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE,
IrisColumnSample.UNAVAILABLE_RIVER_FLOW,
IrisColumnSample.UNAVAILABLE_HEIGHT
);
assertNull(sample.biomeKey());
assertFalse(sample.hasBiomeKey());
}
@Test
public void theSinkReceivesTheTypedSample() {
IrisColumnSample sample = sample(
64,
65,
IrisSurfaceKind.RIVER,
"test:river",
IrisRiverState.WET,
0.5D,
3,
67
);
AtomicReference<IrisColumnSample> received = new AtomicReference<>();
IrisColumnSink sink = received::set;
sink.accept(sample);
assertSame(sample, received.get());
}
@Test
public void invalidHydrologyValuesAreRejected() {
assertInvalid(Double.POSITIVE_INFINITY, 1);
assertInvalid(-0.1D, 1);
assertInvalid(0D, -2);
}
private static void assertInvalid(double distance, int flow) {
try {
sample(
64,
65,
IrisSurfaceKind.RIVER,
"test:river",
IrisRiverState.WET,
distance,
flow,
67
);
fail("Expected invalid hydrology values to be rejected");
} catch (IllegalArgumentException expected) {
assertTrue(expected.getMessage().startsWith("river"));
}
}
private static IrisColumnSample sample(
int surfaceHeight,
int naturalHeight,
IrisSurfaceKind surfaceKind,
String biomeKey,
IrisRiverState riverState,
double riverDistance,
int riverFlow,
int riverWaterSurfaceY
) {
return new IrisColumnSample(
12,
-7,
surfaceHeight,
naturalHeight,
surfaceKind,
biomeKey,
riverState,
riverDistance,
riverFlow,
riverWaterSurfaceY
);
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
public class CommandFindBiomeContractTest {
@Test
public void biomeLookupUsesReachableBiomeHandler() throws Exception {
Method method = CommandFind.class.getMethod("biome", IrisBiome.class, boolean.class);
Param parameter = method.getParameters()[0].getAnnotation(Param.class);
assertEquals(ReachableBiomeHandler.class, parameter.customHandler());
}
}
@@ -1,8 +1,10 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IObjectPlacer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.project.stream.ProceduralStream;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
@@ -13,22 +15,31 @@ import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsFluidHeightFromNegativeMinY() {
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7);
}
}
@@ -1,10 +1,12 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.List;
import static org.junit.Assert.assertEquals;
@@ -52,8 +54,23 @@ public class CommandPackOptionalPackContractTest {
assertFalse(CommandPack.wantsAllPacks("overworld"));
}
private static java.lang.reflect.Method findMethod(String name) {
for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) {
@Test
public void packageCommandIsOwnedByPackNamespace() throws Exception {
Method method = CommandPack.class.getDeclaredMethod(
"pkg",
IrisDimension.class,
boolean.class,
boolean.class
);
DirectorNodeDescriptor descriptor = DirectorAnnotationCompatibility.fromMethod(method).orElseThrow();
assertTrue(descriptor.getAliases().contains("package"));
for (Method studioMethod : CommandStudio.class.getDeclaredMethods()) {
assertFalse("Studio must not retain the package command", studioMethod.getName().equals("pkg"));
}
}
private static Method findMethod(String name) {
for (Method method : CommandPack.class.getDeclaredMethods()) {
if (method.getName().equals(name)) {
return method;
}
@@ -2,8 +2,13 @@ package art.arcane.iris.core.service;
import art.arcane.iris.api.terrain.IrisColumnField;
import art.arcane.iris.api.terrain.IrisColumnQuery;
import art.arcane.iris.api.terrain.IrisRiverState;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.World;
import org.junit.Test;
@@ -12,6 +17,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger;
@@ -82,9 +88,7 @@ public class IrisTerrainSVCTest {
IrisTerrainSVC service = new IrisTerrainSVC();
AtomicInteger sinkCalls = new AtomicInteger();
boolean answered = service.sampleColumns(null, SMALL,
(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey)
-> sinkCalls.incrementAndGet());
boolean answered = service.sampleColumns(null, SMALL, sample -> sinkCalls.incrementAndGet());
assertFalse(answered);
assertEquals(0, sinkCalls.get());
@@ -97,4 +101,48 @@ public class IrisTerrainSVCTest {
assertFalse(service.sampleColumns(null, null, null));
assertFalse(service.sampleColumns(null, SMALL, null));
}
@Test
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
}
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
true,
false,
false,
connectedFluid,
false,
false,
true,
connectedFluid,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
@@ -1,9 +1,15 @@
package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.InferredType;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -53,9 +59,108 @@ public class IrisSurfaceClassifierTest {
IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null);
for (InferredType inferredType : InferredType.values()) {
assertEquals("surface=" + surface + " type=" + inferredType,
expected, IrisSurfaceClassifier.classify(surface, FLUID, inferredType));
assertEquals(
"surface=" + surface + " type=" + inferredType,
expected,
IrisSurfaceClassifier.classify(surface, FLUID, inferredType)
);
}
}
}
@Test
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
}
@Test
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
}
@Test
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
null
));
}
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
+3
View File
@@ -115,6 +115,9 @@ dependencies {
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
// registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris:
// fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets
// IrisFabricBootstrap register the CHUNK_GENERATOR codec in
+3
View File
@@ -132,6 +132,9 @@ dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
compileOnly('org.slf4j:slf4j-api:2.0.17')
compileOnly(libs.spigot) {
transitive = false
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -41,7 +42,7 @@ public final class MainWorldService {
"region",
"entities",
"poi",
"mantle",
IrisEngineMantle.STORAGE_FOLDER_NAME,
"dimensions/minecraft/overworld",
"dimensions/minecraft/the_nether",
"dimensions/minecraft/the_end",
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.Level;
@@ -33,7 +34,12 @@ import java.util.List;
import java.util.stream.Stream;
public final class ModdedDimensionStorage {
private static final List<String> CHUNK_DATA_FOLDERS = List.of("region", "entities", "poi", "mantle");
private static final List<String> CHUNK_DATA_FOLDERS = List.of(
"region",
"entities",
"poi",
IrisEngineMantle.STORAGE_FOLDER_NAME
);
private ModdedDimensionStorage() {
}
@@ -25,6 +25,8 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -62,6 +64,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<CommandSourceStack> OBJECT_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<CommandSourceStack> STRUCTURE_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<CommandSourceStack> POI_TYPES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<CommandSourceStack> HYDROLOGY_TYPES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<CommandSourceStack> PACK_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<CommandSourceStack> DIMENSION_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -79,7 +82,7 @@ final class ModdedCommandSuggestions {
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel());
if (engine != null) {
return SharedSuggestionProvider.suggest(engine.getData().getBiomeLoader().getPossibleKeys(), builder);
return SharedSuggestionProvider.suggest(reachableBiomeKeys(engine), builder);
}
} catch (Throwable e) {
warnTabFailure("biome keys", context.getSource(), e);
@@ -87,6 +90,46 @@ final class ModdedCommandSuggestions {
return builder.buildFuture();
}
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<CommandSourceStack> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return SharedSuggestionProvider.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return SharedSuggestionProvider.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(engine.getAllBiomes());
}
static Set<String> reachableBiomeKeys(Iterable<IrisBiome> biomes) {
Set<String> keys = new TreeSet<>();
for (IrisBiome biome : biomes) {
if (biome != null && biome.getLoadKey() != null && !biome.getLoadKey().isBlank()) {
keys.add(biome.getLoadKey());
}
}
return keys;
}
static boolean isReachableBiome(Engine engine, String biomeKey) {
return isReachableBiome(engine.getAllBiomes(), biomeKey);
}
static boolean isReachableBiome(Iterable<IrisBiome> biomes, String biomeKey) {
return biomeKey != null && reachableBiomeKeys(biomes).contains(biomeKey.trim());
}
private static CompletableFuture<Suggestions> suggestRegionKeys(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
ModdedCommandFeedback.tab(context.getSource());
try {
@@ -227,6 +227,9 @@ final class ModdedCommandTree {
.then(Commands.literal("object")
.then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS)
.executes((CommandContext<CommandSourceStack> context) -> ModdedLocateCommands.gotoObject(context.getSource(), StringArgumentType.getString(context, "key")))))
.then(Commands.literal("river")
.then(Commands.argument("type", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.HYDROLOGY_TYPES)
.executes((CommandContext<CommandSourceStack> context) -> ModdedLocateCommands.gotoRiver(context.getSource(), StringArgumentType.getString(context, "type")))))
.then(Commands.literal("unregistered")
.executes((CommandContext<CommandSourceStack> context) ->
ModdedUnregisteredStructures.print(context.getSource())))
@@ -29,12 +29,16 @@ import art.arcane.iris.engine.framework.Locator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.framework.WrongEngineBroException;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.math.Position2;
import com.mojang.datafixers.util.Pair;
@@ -87,6 +91,10 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key)));
return 0;
}
if (!ModdedCommandSuggestions.isReachableBiome(engine, biome.getLoadKey())) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("value", biome.getLoadKey())));
return 0;
}
locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey());
return 1;
}
@@ -137,6 +145,67 @@ final class ModdedLocateCommands {
return 1;
}
static int gotoRiver(CommandSourceStack source, String type) {
ServerPlayer player = source.getPlayer();
if (player == null) {
IrisModdedCommands.fail(source, "This command can only be used by a player.");
return 0;
}
ServerLevel level = source.getLevel();
Engine engine = IrisModdedCommands.engineFor(level);
if (engine == null) {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
if (runtime == null) {
IrisModdedCommands.fail(source, "Hydrology is not active in this dimension.");
return 0;
}
HydrologyFeatureQuery query;
try {
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
int originX = player.blockPosition().getX();
int originZ = player.blockPosition().getZ();
int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
MinecraftServer server = source.getServer();
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
MultiBurst.burst.completeValueAsync(() -> runtime.nearestFeature(
query.types(), query.profileKey(), originX, originZ, maximumDistance,
(int visited) -> server.execute(() -> IrisModdedCommands.ok(source,
"Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null))
.whenComplete((HydrologyFeatureRef feature, Throwable error) -> server.execute(() -> {
if (error != null) {
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
return;
}
if (feature == null) {
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
int worldY = feature.y() + engine.getDimension().getMinHeight() + 2;
teleportToStructure(
source,
level,
player,
feature.x(),
worldY,
feature.z(),
type + " hydrology feature"
);
}));
return 1;
}
static int gotoStructure(CommandSourceStack source, String keyRaw) {
ServerLevel level = source.getLevel();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -341,8 +341,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}
markCompleted();
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (TimeoutException | ExecutionException e) {
@@ -406,8 +405,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
onSuccess();
markCompleted();
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
} finally {
markFinished();
semaphore.release();
@@ -494,9 +492,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}
}
private void cleanupMantleChunk(int x, int z) {
private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) {
try {
engine.getMantle().forceCleanupChunk(x, z);
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable e) {
ModdedIrisLog.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString());
}
@@ -126,7 +126,7 @@ public final class ModdedVisionOverlay implements GuiOverlay {
}
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT ->
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
@@ -301,7 +301,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
private void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
PrecisionStopwatch stopwatch = PrecisionStopwatch.start();
int minHeight = engine.getWorld().minHeight();
int baseX = chunkX << 4;
@@ -187,6 +187,17 @@ public class ModdedGenerationLeaseContractTest {
assertTrue(logFailure.contains("cause.toString()"));
}
@Test
public void pregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException {
String source = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
String cleanup = method(source, "private void cleanupMantleChunksCoveredBy(");
assertTrue(source.contains("cleanupMantleChunksCoveredBy(x, z, listener);"));
assertFalse(source.contains("cleanupMantleChunk(x, z);"));
assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)"));
assertFalse(cleanup.contains("forceCleanupChunk"));
}
@Test
public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException {
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
@@ -0,0 +1,30 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisModdedBiomeCommandTest {
@Test
public void biomeKeysAreSortedAndLimitedToReachableBiomes() {
KList<IrisBiome> reachable = new KList<>();
IrisBiome river = new IrisBiome();
river.setLoadKey("river");
IrisBiome natural = new IrisBiome();
natural.setLoadKey("natural");
reachable.add(river);
reachable.add(natural);
assertEquals(
List.of("natural", "river"),
List.copyOf(ModdedCommandSuggestions.reachableBiomeKeys(reachable))
);
assertTrue(ModdedCommandSuggestions.isReachableBiome(reachable, "river"));
assertFalse(ModdedCommandSuggestions.isReachableBiome(reachable, "unused"));
}
}
@@ -1,11 +1,27 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineMetrics;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.util.project.matter.TileWrapper;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FlowingFluid;
import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
@@ -15,8 +31,42 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest {
@Test
public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
ServerLevel level = mock(ServerLevel.class);
when(level.getMinY()).thenReturn(-64);
when(level.getMaxY()).thenReturn(320);
BlockPos position = new BlockPos(15, -59, 14);
BlockState falling = Blocks.WATER.defaultBlockState().setValue(LiquidBlock.LEVEL, 8);
when(level.getBlockState(position)).thenReturn(falling);
UpdateRecordingMantleChunk chunk = new UpdateRecordingMantleChunk();
new ModdedChunkUpdateService().runUpdatePass(engine, level, 0, 0, chunk);
assertEquals(8, falling.getValue(LiquidBlock.LEVEL).intValue());
if (falling.getFluidState().hasProperty(FlowingFluid.FALLING)) {
assertTrue(falling.getFluidState().getValue(FlowingFluid.FALLING));
}
InOrder order = inOrder(level);
order.verify(level).setBlock(eq(position), eq(Blocks.AIR.defaultBlockState()), anyInt());
order.verify(level).setBlock(position, falling, Block.UPDATE_ALL);
assertTrue(chunk.deleted());
}
@Test
public void scansWhenPlayersArePresent() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
@@ -84,6 +134,35 @@ public class ModdedChunkUpdateServiceTest {
}
}
private static final class UpdateRecordingMantleChunk extends MantleChunk<Matter> {
private boolean deleted;
private UpdateRecordingMantleChunk() {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
}
@Override
public <T> void iterate(
Class<T> type,
Consumer4<Integer, Integer, Integer, T> iterator
) {
if (type == MatterUpdate.class) {
iterator.accept(-1, 5, -2, type.cast(UpdateMatter.ON));
}
}
@Override
public void deleteSlices(Class<?> type) {
if (type == MatterUpdate.class) {
deleted = true;
}
}
private boolean deleted() {
return deleted;
}
}
@SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
+3
View File
@@ -106,6 +106,9 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2')
testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2')
compileOnly('org.slf4j:slf4j-api:2.0.17')
@@ -23,6 +23,8 @@ import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.render.IrisRenderer;
import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
@@ -69,6 +71,7 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@@ -484,11 +487,52 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
}
private void renderLegend(Graphics2D canvas) {
if (currentType == RenderType.HEIGHT) {
if (currentType == RenderType.RIVER) {
renderRiverLegend(canvas);
} else if (currentType == RenderType.HEIGHT) {
renderHeightLegend(canvas);
}
}
private void renderRiverLegend(Graphics2D canvas) {
HydrologyFeatureType[] types = HydrologyFeatureType.values();
HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values();
int lineHeight = 18;
int width = 188;
int height = (types.length + candidateKinds.length + 1) * lineHeight + CARD_PADDING * 2;
int x = getWidth() - width - CARD_PADDING;
int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING;
drawCardBackground(canvas, x, y, width, height);
canvas.setFont(BODY_FONT);
int headwaterY = y + CARD_PADDING;
canvas.setColor(new Color(IrisRenderer.headwaterColor()));
canvas.fillRoundRect(x + CARD_PADDING, headwaterY + 2, 12, 12, 4, 4);
canvas.setColor(new Color(IrisRenderer.headwaterDirectionColor()));
canvas.drawLine(x + CARD_PADDING + 3, headwaterY + 8, x + CARD_PADDING + 9, headwaterY + 8);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 5);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 11);
canvas.setColor(TEXT_SECONDARY);
canvas.drawString("headwater / source flow", x + CARD_PADDING + 20, headwaterY + 13);
for (int index = 0; index < types.length; index++) {
HydrologyFeatureType type = types[index];
int rowY = y + CARD_PADDING + (index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyFeatureColor(type)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY);
String label = type.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
}
for (int index = 0; index < candidateKinds.length; index++) {
HydrologyCandidateKind kind = candidateKinds[index];
int rowY = y + CARD_PADDING + (types.length + index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyDiagnosticColor(kind)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY);
String label = "projected " + kind.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
}
}
private void renderHeightLegend(Graphics2D canvas) {
int width = 244;
int height = 54;
@@ -1037,6 +1081,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
case BIOME_SEA -> DesktopUiMessages.VISION_MODE_BIOME_SEA;
case REGION -> DesktopUiMessages.VISION_MODE_REGION;
case CAVE_LAND -> DesktopUiMessages.VISION_MODE_CAVE_LAND;
case RIVER -> DesktopUiMessages.VISION_MODE_RIVER;
case HEIGHT -> DesktopUiMessages.VISION_MODE_HEIGHT;
case OBJECT_LOAD -> DesktopUiMessages.VISION_MODE_OBJECT_LOAD;
case DECORATOR_LOAD -> DesktopUiMessages.VISION_MODE_DECORATOR_LOAD;
@@ -275,7 +275,8 @@ final class VisionRenderController implements AutoCloseable {
if (!isCurrent(work)) {
return;
}
while (work.inFlight() < renderWorkerCount) {
int admissionLimit = work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount;
while (work.inFlight() < admissionLimit) {
VisibleTile tile = work.nextMissing();
if (tile == null) {
return;
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -253,7 +254,9 @@ public final class BukkitWorldConfiguration {
if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
return true;
}
for (String directory : new String[]{"region", "entities", "poi", "mantle"}) {
for (String directory : new String[]{
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
}) {
if (hasEntries(worldRoot.resolve(directory))) {
return true;
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
@@ -37,7 +38,9 @@ import java.util.stream.Stream;
public final class HuskWorldQuarantine {
private static final String IRIS_NAMESPACE = "iris";
private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+");
private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"};
private static final String[] CHUNK_DATA_DIRECTORIES = {
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
};
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
private static final String APPLE_DOUBLE_PREFIX = "._";
private static final DateTimeFormatter STAMP = DateTimeFormatter
@@ -52,6 +52,7 @@ public final class DesktopUiMessages {
public static final TextKey VISION_MODE_BIOME_SEA = TextKey.of("iris.desktop.vision.mode.biome_sea", "Biome sea");
public static final TextKey VISION_MODE_REGION = TextKey.of("iris.desktop.vision.mode.region", "Region");
public static final TextKey VISION_MODE_CAVE_LAND = TextKey.of("iris.desktop.vision.mode.cave_land", "Cave land");
public static final TextKey VISION_MODE_RIVER = TextKey.of("iris.desktop.vision.mode.river", "River network");
public static final TextKey VISION_MODE_HEIGHT = TextKey.of("iris.desktop.vision.mode.height", "Height");
public static final TextKey VISION_MODE_OBJECT_LOAD = TextKey.of("iris.desktop.vision.mode.object_load", "Object load");
public static final TextKey VISION_MODE_DECORATOR_LOAD = TextKey.of("iris.desktop.vision.mode.decorator_load", "Decorator load");
@@ -171,7 +172,7 @@ public final class DesktopUiMessages {
VISION_HELP_RESET_ZOOM, VISION_HELP_CYCLE_MODE, VISION_HELP_FPS,
VISION_HELP_GRID, VISION_HELP_BIOME, VISION_HELP_TELEPORT, VISION_HELP_EDITOR, VISION_OPENED,
VISION_TELEPORTING, VISION_MODE_BIOME, VISION_MODE_BIOME_LAND, VISION_MODE_BIOME_SEA,
VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD,
VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_RIVER, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD,
VISION_MODE_DECORATOR_LOAD, VISION_MODE_CONTINENT, VISION_MODE_LAYER_LOAD, NOISE_TITLE,
NOISE_TITLE_GENERATOR, NOISE_SEARCH, NOISE_STATUS, NOISE_CATEGORY_CUSTOM,
NOISE_CATEGORY_PACK_GENERATORS, NOISE_CATEGORY_SIMPLEX, NOISE_CATEGORY_PERLIN,
File diff suppressed because it is too large Load Diff
@@ -84,6 +84,10 @@ public final class PackValidator {
packFolder, dimensionFiles, validateLiveRegistries);
addDistinct(blockingErrors, imageMaps.errors());
addDistinct(warnings, imageMaps.warnings());
PackHydrologyValidator.Validation hydrologyValidation =
PackHydrologyValidator.validate(packFolder, dimensionFiles);
addDistinct(blockingErrors, hydrologyValidation.errors());
addDistinct(warnings, hydrologyValidation.warnings());
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors());
@@ -481,8 +481,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
listener.onChunkFailed(x, z);
} else {
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
onChunkCompleted(x, z, chunk);
success = true;
}
@@ -692,11 +691,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
}
}
private void cleanupMantleChunk(int x, int z) {
private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) {
Engine engine = resolveMetricsEngine();
if (engine != null) {
try {
engine.getMantle().forceCleanupChunk(x, z);
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable ignored) {
}
}
@@ -92,6 +92,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true)
public class IrisCreator {
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private static final long INITIAL_SPAWN_TIMEOUT_SECONDS = 600L;
private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L;
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
@@ -619,7 +620,7 @@ public class IrisCreator {
generator.getInitialSpawnReady(),
"Initial spawn preparation future");
try {
initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
initialSpawnReady.get(INITIAL_SPAWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException failure) {
throw new TimeoutException("Initial spawn preparation timed out for world \""
+ worldName + "\".");
@@ -22,14 +22,27 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.framework.Engine;
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.hydrology.runtime.IrisHydrologyNaturalSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntimeContext;
import art.arcane.iris.engine.image.IrisImageMapRuntime;
import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisInterpolator;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
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.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisLogging;
@@ -58,6 +71,7 @@ import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -66,8 +80,8 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiFunction;
@Data
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "imageMapRuntime"})
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
@@ -109,14 +123,20 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisBiome> shoreBiomeStream;
private ProceduralStream<IrisBiome> baseBiomeStream;
private ProceduralStream<UUID> baseBiomeIDStream;
private ProceduralStream<IrisBiome> naturalTrueBiomeStream;
private ProceduralStream<IrisBiome> trueBiomeStream;
private ProceduralStream<PlatformBiome> trueBiomeDerivativeStream;
private ProceduralStream<Double> naturalHeightStream;
private ProceduralStream<Double> heightStream;
private ProceduralStream<Integer> roundedHeighteightStream;
private ProceduralStream<Double> maxHeightStream;
private ProceduralStream<Double> overlayStream;
private ProceduralStream<Double> heightFluidStream;
private ProceduralStream<Double> slopeStream;
private ProceduralStream<Double> riverDistanceStream;
private ProceduralStream<Double> riverFlowStream;
private ProceduralStream<Double> riverCarveWeightStream;
private ProceduralStream<Double> riverWaterSurfaceStream;
private ProceduralStream<Integer> topSurfaceStream;
private ProceduralStream<IrisDecorator> terrainSurfaceDecoration;
private ProceduralStream<IrisDecorator> terrainCeilingDecoration;
@@ -127,10 +147,12 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisDecorator> shoreSurfaceDecoration;
private ProceduralStream<PlatformBlockState> rockStream;
private ProceduralStream<PlatformBlockState> fluidStream;
private Map<String, ProceduralStream<PlatformBlockState>> hydrologyFluidStreams;
private IrisBiome focusBiome;
private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes;
private IrisHydrologyRuntime hydrologyRuntime;
private transient IrisImageMapRuntime imageMapRuntime;
// Copy-on-write: reads happen per column on every burst thread; the synchronizedMap
// monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map
@@ -166,7 +188,7 @@ public class IrisComplex implements DataProvider {
//@builder
if (focusRegion != null) {
prepareInferredBiomes(focusRegion, preparedRegions);
focusRegion.getAllBiomes(this).forEach(this::registerGenerators);
focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators);
} else {
engine.getDimension().getRegions().forEach(regionKey -> {
IrisRegion region = data.getRegionLoader().load(regionKey);
@@ -174,11 +196,11 @@ public class IrisComplex implements DataProvider {
return;
}
prepareInferredBiomes(region, preparedRegions);
region.getAllBiomes(this).forEach(this::registerGenerators);
region.getNaturalBiomes(this).forEach(this::registerGenerators);
});
for (IrisRegion region : imageMapRuntime.getMappedRegions()) {
prepareInferredBiomes(region, preparedRegions);
region.getAllBiomes(this).forEach(this::registerGenerators);
region.getNaturalBiomes(this).forEach(this::registerGenerators);
}
imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators);
}
@@ -211,6 +233,7 @@ public class IrisComplex implements DataProvider {
.select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data));
hydrologyFluidStreams = createHydrologyFluidStreams(engine.getDimension().getHydrology());
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
@@ -263,28 +286,59 @@ public class IrisComplex implements DataProvider {
return mapped == null ? biome : mapped;
})
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
heightStream = ProceduralStream.of((x, z) -> {
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize);
slopeStream = heightStream.slope(3)
.cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
naturalHeightStream = ProceduralStream.of(
(x, z) -> sampleNaturalTerrainHeight(engine, x, z),
Interpolated.DOUBLE
).cache2DDouble("naturalHeightStream", engine, cacheSize);
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
.convertAware2D((h, x, z) -> {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
return mapped == null
? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight)
: mapped;
})
.cache2D("trueBiomeStream", engine, cacheSize);
.cache2D("naturalTrueBiomeStream", engine, cacheSize);
IrisHydrology configuredHydrology = engine.getDimension().getHydrology();
if (hydrologyActive(configuredHydrology)) {
hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(
engine.getSeedManager().getBodies(),
engine.getHeight(),
engine.getDimension(),
data,
(x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight),
(x, z) -> naturalHeightStream.getDouble(x, z),
this::sampleNaturalOcean,
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint)
));
}
heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z))
.cache2DDouble("heightStream", engine, cacheSize);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round();
slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z))
.cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize);
riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance)
.cache2DDouble("riverDistanceStream", engine, cacheSize);
riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow)
.cache2DDouble("riverFlowStream", engine, cacheSize);
riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight)
.cache2DDouble("riverCarveWeightStream", engine, cacheSize);
riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface)
.cache2DDouble("riverWaterSurfaceStream", engine, cacheSize);
heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max(
heightStream.get(x, z),
riverWaterSurfaceStream.get(x, z)
))
.cache2DDouble("heightFluidStream", engine, cacheSize);
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height);
terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize);
@@ -310,6 +364,320 @@ public class IrisComplex implements DataProvider {
//@done
}
static InferredType resolveNaturalInferredType(
ProceduralStream<InferredType> bridgeStream,
IrisBiome focusBiome,
double x,
double z
) {
if (focusBiome != null) {
return focusBiome.getInferredType();
}
return bridgeStream.get(x, z);
}
private IrisHydrologyNaturalSample sampleHydrologyNatural(
Engine engine,
int x,
int z,
double naturalHeight
) {
InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z);
IrisRegion region = regionStream.get(x, z);
IrisBiome biome = focusBiome == null
? sampleNaturalBiome(inferredType, region, naturalHeight, x, z)
: focusBiome;
return new IrisHydrologyNaturalSample(
naturalHeight,
inferredType == InferredType.SEA,
biome,
region
);
}
private boolean sampleNaturalOcean(int x, int z) {
return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA;
}
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}
static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) {
double deltaX = easternHeight - naturalHeight;
double deltaZ = southernHeight - naturalHeight;
return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
private IrisBiome sampleNaturalBiome(
InferredType inferredType,
IrisRegion region,
double naturalHeight,
double x,
double z
) {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null) {
return mapped;
}
IrisBiome baseBiome = implode(sampleInferredBiome(region, inferredType, x, z), x, z);
IrisBiome resolved = resolveNaturalSurfaceBiome(naturalHeight, baseBiome, region, x, z);
return resolved == baseBiome ? baseBiome : implode(resolved, x, z);
}
private IrisBiome resolveNaturalSurfaceBiome(
double height,
IrisBiome biome,
IrisRegion region,
double x,
double z
) {
if (biome == null || region == null) {
return biome;
}
double shoreHeight = region.getShoreHeight(x, z);
if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
if (height > fluidHeight + shoreHeight && !biome.isLand()) {
return sampleInferredBiome(region, InferredType.LAND, x, z);
}
if (height < fluidHeight && !biome.isAquatic()) {
return sampleInferredBiome(region, InferredType.SEA, x, z);
}
if (height == fluidHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
return biome;
}
private IrisBiome sampleInferredBiome(
IrisRegion region,
InferredType inferredType,
double x,
double z
) {
return createInferredBiomeStream(region, inferredType).get(x, z);
}
private ProceduralStream<IrisBiome> createInferredBiomeStream(
IrisRegion region,
InferredType inferredType
) {
return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType);
}
private ProceduralStream<IrisBiome> compileInferredBiomeStream(
Engine engine,
IrisRegion region,
InferredType inferredType,
IrisBiome emptyBiome
) {
return switch (inferredType) {
case CAVE -> engine.getDimension().getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> engine.getDimension().getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> engine.getDimension().getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> engine.getDimension().getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
};
}
private static boolean hydrologyActive(IrisHydrology hydrology) {
if (hydrology == null) {
return false;
}
IrisRiverHydrology rivers = hydrology.getRivers();
if (rivers != null && rivers.isEnabled()) {
return true;
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid != null
&& deepFluid.getDensity() > 0D
&& (deepFluid.isContainedPools() || deepFluid.isShortChannels())) {
return true;
}
}
return false;
}
private Map<String, ProceduralStream<PlatformBlockState>> createHydrologyFluidStreams(IrisHydrology hydrology) {
if (hydrology == null) {
return Map.of();
}
LinkedHashMap<String, ProceduralStream<PlatformBlockState>> streams = new LinkedHashMap<>();
IrisRiverHydrology rivers = Objects.requireNonNull(hydrology.getRivers(), "hydrology.rivers");
int streamIndex = 0;
for (IrisRiverProfile profile : rivers.getProfiles()) {
if (profile == null) {
throw new IllegalArgumentException("hydrology.rivers.profiles cannot contain null entries");
}
String profileKey = requireHydrologyKey(profile.getId(), "hydrology.rivers.profiles[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
profile.getFluidPalette(),
rng.nextParallelRNG(7900 + streamIndex++),
"hydrology river profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid == null) {
throw new IllegalArgumentException("hydrology.deepFluids cannot contain null entries");
}
String profileKey = requireHydrologyKey(deepFluid.getId(), "hydrology.deepFluids[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
deepFluid.getFluidPalette(),
rng.nextParallelRNG(8900 + streamIndex++),
"hydrology deep-fluid profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisSurfacePoolConfig pool : hydrology.getSurfacePools()) {
if (pool == null) {
throw new IllegalArgumentException("hydrology.surfacePools cannot contain null entries");
}
String profileKey = requireHydrologyKey(pool.getId(), "hydrology.surfacePools[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
pool.getFluidPalette(),
rng.nextParallelRNG(9900 + streamIndex++),
"hydrology surface pool " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
return Collections.unmodifiableMap(streams);
}
private static String requireHydrologyKey(String key, String path) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException(path + " must not be blank");
}
return key.trim();
}
private ProceduralStream<PlatformBlockState> configuredFluidStream(
IrisMaterialPalette palette,
RNG fluidRng,
String configurationName
) {
Objects.requireNonNull(palette, configurationName + " fluidPalette must be configured");
KList<PlatformBlockState> blocks = palette.getBlockData(data);
if (blocks.isEmpty()) {
throw new IllegalArgumentException(
configurationName + " fluidPalette must resolve at least one fluid block");
}
for (PlatformBlockState block : blocks) {
if (block == null || !block.isFluid()) {
throw new IllegalArgumentException(
configurationName + " fluidPalette may contain only fluid blocks");
}
}
return palette.getLayerGenerator(fluidRng, data).stream().select(blocks);
}
public PlatformBlockState resolveHydrologyFluid(String profileKey, double x, double z) {
String key = Objects.requireNonNull(profileKey, "profileKey").trim();
ProceduralStream<PlatformBlockState> stream = hydrologyFluidStreams.get(key);
if (stream == null) {
throw new IllegalArgumentException("Unknown hydrology fluid profile: " + key);
}
return stream.get(x, z);
}
public PlatformBlockState resolveSurfaceFluid(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer != null) {
return resolveHydrologyFluid(layer.profileKey(), x, z);
}
return fluidStream.get(x, z);
}
private double resolveHydrologyTerrainHeight(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? naturalHeightStream.get(x, z) : sample.terrainHeight();
}
private double resolveHydrologyDistance(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return Double.MAX_VALUE;
}
if (layer.channel()) {
return 0D;
}
return layer.shore() ? 1D : 2D;
}
private double resolveHydrologyFlow(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer == null) {
return 0D;
}
return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D;
}
private double resolveHydrologyCarveWeight(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return 0D;
}
if (layer.channel()) {
return 1D;
}
return layer.shore() ? 0.75D : 0.5D;
}
private double resolveHydrologyFluidSurface(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
return layer == null ? fluidHeight : layer.fluidHeadY();
}
private HydrologyColumnSample hydrologySample(double x, double z) {
return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null);
}
private HydrologyColumnLayer surfaceLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceLayer().orElse(null);
}
private HydrologyColumnLayer surfaceFluidLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceFluidLayer().orElse(null);
}
public boolean hasHydrologySurfaceFluid(int x, int z) {
return surfaceFluidLayer(x, z) != null;
}
public boolean hasHydrologyChannelOrShore(int x, int z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
return layer != null && (layer.channel() || layer.shore());
}
public ProceduralStream<IrisBiome> getBiomeStream(InferredType type) {
switch (type) {
case CAVE:
@@ -370,6 +738,47 @@ public class IrisComplex implements DataProvider {
return null;
}
private IrisBiome resolveHydrologySurfaceBiome(double terrainHeight, double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
HydrologyColumnLayer layer = sample == null ? null : sample.primarySurfaceLayer().orElse(null);
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null && layer == null) {
return mapped;
}
if (layer != null) {
String biomeKey = hydrologySurfaceBiomeKey(sample);
IrisBiome biome = data.getBiomeLoader().load(biomeKey);
if (biome == null) {
throw new IllegalStateException("Hydrology biome does not exist: " + biomeKey);
}
if (layer.biomeKey() == null) {
return implode(biome.withInferredType(InferredType.LAND), x, z);
}
InferredType inferredType = layer.shore()
? InferredType.SHORE
: layer.connectedFluid() ? InferredType.SEA : InferredType.LAND;
return implode(biome.withInferredType(inferredType), x, z);
}
return fixBiomeType(
terrainHeight,
baseBiomeStream.get(x, z),
regionStream.get(x, z),
x,
z,
fluidHeight
);
}
static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null);
if (layer == null) {
return sample.parentBiomeKey();
}
String biomeKey = layer.biomeKey();
return biomeKey == null ? sample.parentBiomeKey() : biomeKey;
}
private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) {
IrisBiome resolved = resolveSurfaceBiome(
height,
@@ -528,6 +937,38 @@ public class IrisComplex implements DataProvider {
return h;
}
private NoiseBounds naturalHeightBounds(
Engine engine,
KList<IrisShapedGeneratorStyle> overlayNoise,
double x,
double z
) {
double minimum = fluidHeight;
double maximum = fluidHeight;
for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) {
NoiseBounds bounds = gridSampleBounds(
engine,
frozenInterpolators[interpolatorIndex],
interpolatorIndex,
frozenGenerators[interpolatorIndex],
x,
z
);
minimum += Math.min(bounds.min(), bounds.max());
maximum += Math.max(bounds.min(), bounds.max());
}
for (IrisShapedGeneratorStyle style : overlayNoise) {
minimum += Math.min(style.getMin(), style.getMax());
maximum += Math.max(style.getMin(), style.getMax());
}
minimum = imageMapRuntime.sampleTerrainHeight(x, z, minimum);
maximum = imageMapRuntime.sampleTerrainHeight(x, z, maximum);
return new NoiseBounds(
Math.max(0D, Math.min(engine.getHeight(), Math.min(minimum, maximum))),
Math.max(0D, Math.min(engine.getHeight(), Math.max(minimum, maximum)))
);
}
private double getHeight(Engine engine, double x, double z, long seed) {
return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0);
}
@@ -540,46 +981,6 @@ public class IrisComplex implements DataProvider {
preparedRegions.add(region);
}
private ProceduralStream<IrisBiome> createInferredBiomeStream(
IrisRegion region,
InferredType inferredType
) {
return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType);
}
private ProceduralStream<IrisBiome> compileInferredBiomeStream(
Engine engine,
IrisRegion region,
InferredType inferredType,
IrisBiome emptyBiome
) {
return switch (inferredType) {
case CAVE -> engine.getDimension().getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> engine.getDimension().getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> engine.getDimension().getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> engine.getDimension().getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
};
}
static Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> compileInferredBiomeStreams(
Iterable<IrisRegion> regions,
BiFunction<IrisRegion, InferredType, ProceduralStream<IrisBiome>> compiler
@@ -1072,6 +1473,8 @@ public class IrisComplex implements DataProvider {
}
public void close() {
if (hydrologyRuntime != null) {
hydrologyRuntime.close();
}
}
}
@@ -29,7 +29,9 @@ import art.arcane.iris.engine.mantle.MantlePass;
import art.arcane.iris.engine.mantle.components.MantleCarvingComponent;
import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleHydrologyComponent;
import art.arcane.iris.engine.mantle.components.IrisStructureComponent;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
@@ -72,6 +74,7 @@ import java.util.function.Supplier;
@EqualsAndHashCode(exclude = "engine")
@ToString(exclude = "engine")
public class IrisEngineMantle implements EngineMantle {
public static final String STORAGE_FOLDER_NAME = "mantle-hydrology";
private final Engine engine;
private final Mantle<Matter> mantle;
@Getter(AccessLevel.NONE)
@@ -86,6 +89,7 @@ public class IrisEngineMantle implements EngineMantle {
this.mantle = createMantle(engine);
components = new KMap<>();
registerComponent(new MantleCarvingComponent(this));
registerComponent(new MantleHydrologyComponent(this));
object = new MantleObjectComponent(this);
registerComponent(object);
registerComponent(new MantleFloatingObjectComponent(this));
@@ -121,7 +125,7 @@ public class IrisEngineMantle implements EngineMantle {
List<MantleComponent> pass = passes.get(i);
int passBlockRadius = pass.stream()
.filter(MantleComponent::isEnabled)
.mapToInt(MantleComponent::getRadius)
.mapToInt(MantleComponent::getOutputRadius)
.max()
.orElse(0);
int passInputRadius = pass.stream()
@@ -174,10 +178,18 @@ public class IrisEngineMantle implements EngineMantle {
if (!getDimension().isCarvingEnabled()) {
disabled.addIfMissing(ReservedFlag.CARVED);
}
if (disabled.contains(ReservedFlag.CARVED)
|| !isHydrologyEnabled(getDimension())) {
disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY);
}
return Set.copyOf(disabled);
});
}
static boolean isHydrologyEnabled(IrisDimension dimension) {
return MantleHydrologyComponent.isEnabledFor(dimension);
}
@Override
public MantleObjectComponent getObjectComponent() {
return object;
@@ -185,7 +197,7 @@ public class IrisEngineMantle implements EngineMantle {
private static Mantle<Matter> createMantle(Engine engine) {
IrisMatterSupport.ensureRegistered();
File dataFolder = new File(engine.getWorld().worldFolder(), "mantle");
File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME);
int worldHeight = engine.getTarget().getHeight();
MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData);
MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name()));
@@ -66,8 +66,8 @@ public class UpperDimensionContext implements DataProvider {
engine.getDimension(),
engine.getData(),
chunkHeight,
complex.getHeightStream(),
complex.getTrueBiomeStream(),
complex.getNaturalHeightStream(),
complex.getNaturalTrueBiomeStream(),
complex.getRegionStream(),
complex.getRockStream(),
complex.getImageMapRuntime(),
@@ -99,12 +99,12 @@ public class UpperDimensionContext implements DataProvider {
upperDim.getRegions().forEach(regionKey -> {
IrisRegion region = upperData.getRegionLoader().load(regionKey);
if (region != null) {
region.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
biome, dataProvider, allBiomes, generators));
}
});
for (IrisRegion mappedRegion : imageMapRuntime.getMappedRegions()) {
mappedRegion.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
mappedRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators(
biome, dataProvider, allBiomes, generators));
}
for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) {
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.actuator;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
final class HydrologyFluidLayerSelector {
private HydrologyFluidLayerSelector() {
}
static PlatformBlockState select(
KList<PlatformBlockState> seaLayers,
int depth,
PlatformBlockState fluid,
boolean hydrologyOwned
) {
if (!hydrologyOwned && seaLayers != null && seaLayers.hasIndex(depth)) {
return seaLayers.get(depth);
}
return fluid;
}
}
@@ -26,6 +26,9 @@ import art.arcane.iris.engine.decorator.IrisSurfaceDecorator;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.engine.framework.EngineDecorator;
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.util.common.data.B;
import art.arcane.iris.util.project.context.ChunkContext;
@@ -50,7 +53,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
@Getter
private final EngineDecorator seaFloorDecorator;
@Getter
private final EngineDecorator shoreLineDecorator;
private final IrisShoreLineDecorator shoreLineDecorator;
private final boolean shouldRay;
public IrisDecorantActuator(Engine engine) {
@@ -64,6 +67,84 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
seaFloorDecorator = new IrisSeaFloorDecorator(getEngine());
}
static ShorelineDecorationMode shorelineDecorationMode(
HydrologyColumnSample sample,
int height,
int fluidHeight
) {
HydrologyColumnLayer surfaceLayer = sample == null
? null
: sample.primarySurfaceLayer().orElse(null);
if (surfaceLayer != null) {
return surfaceLayer.shore()
? ShorelineDecorationMode.ACCEPTED
: ShorelineDecorationMode.NONE;
}
return height == fluidHeight
? ShorelineDecorationMode.LEGACY
: ShorelineDecorationMode.NONE;
}
static boolean hasConnectedSurfaceWater(
Hunk<PlatformBlockState> output,
int x,
int z,
int height,
int fluidHeight
) {
return height < fluidHeight
&& height + 1 < output.getHeight()
&& IrisSurfaceDecorator.hasConnectedWater(output, x, height + 1, z);
}
static boolean hasConnectedWaterColumn(
Hunk<PlatformBlockState> output,
int x,
int z,
int lowerY,
int upperY
) {
int boundedLowerY = Math.max(0, lowerY);
int boundedUpperY = Math.min(output.getHeight() - 1, upperY);
if (boundedLowerY > boundedUpperY) {
return false;
}
for (int y = boundedLowerY; y <= boundedUpperY; y++) {
if (!IrisSurfaceDecorator.hasConnectedWater(output, x, y, z)) {
return false;
}
}
return true;
}
static void restoreUnsupportedAquaticPlacement(
Hunk<PlatformBlockState> output,
int x,
int y,
int z,
PlatformBlockState original
) {
PlatformBlockState placed = output.get(x, y, z);
if (placed != original && IrisSurfaceDecorator.isAquaticPlacement(placed)) {
output.set(x, y, z, original);
}
}
static boolean isFallingWaterfallThroat(HydrologyColumnSample sample) {
if (sample == null) {
return false;
}
for (HydrologyColumnLayer layer : sample.layers()) {
if ((layer.feature().type() == HydrologyFeatureType.WATERFALL
|| layer.feature().type() == HydrologyFeatureType.SINKHOLE)
&& layer.channel()
&& layer.fallingFluid()) {
return true;
}
}
return false;
}
@BlockCoordinates
@Override
public void onActuate(int x, int z, Hunk<PlatformBlockState> output, boolean multicore, ChunkContext context) {
@@ -72,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
}
PrecisionStopwatch p = PrecisionStopwatch.start();
for (int i = 0; i < output.getWidth(); i++) {
int height;
int realX = Math.round(x + i);
@@ -86,23 +166,57 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
height = context.getRoundedHeight(i, j);
biome = context.getBiome().get(i, j);
cave = shouldRay ? context.getCave().get(i, j) : null;
HydrologyColumnSample hydrology = getComplex().getHydrologyRuntime() == null
? null
: getComplex().getHydrologyRuntime().sample(realX, realZ).orElse(null);
if (isFallingWaterfallThroat(hydrology)) {
continue;
}
int surfaceFluidHeight = (int) Math.round(
getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
continue;
}
if (height < getDimension().getFluidHeight() && PREDICATE_SOLID.test(output.get(i, height, j))
&& height + 1 < output.getHeight() && B.isWater(output.get(i, height + 1, j))) {
if (PREDICATE_SOLID.test(output.get(i, height, j))
&& hasConnectedSurfaceWater(output, i, j, height, surfaceFluidHeight)) {
int seaSurfaceY = surfaceFluidHeight + 1;
PlatformBlockState seaSurfaceOriginal = seaSurfaceY < output.getHeight()
? output.get(i, seaSurfaceY, j)
: null;
getSeaSurfaceDecorator().decorate(i, j,
realX, Math.round(i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1),
output, biome, getDimension().getFluidHeight(), getEngine().getHeight());
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1,
getDimension().getFluidHeight() + 1);
output, biome, surfaceFluidHeight, getEngine().getHeight());
if (seaSurfaceY < output.getHeight()) {
restoreUnsupportedAquaticPlacement(
output, i, seaSurfaceY, j, seaSurfaceOriginal);
}
if (hasConnectedWaterColumn(output, i, j, height + 1, surfaceFluidHeight)) {
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1,
surfaceFluidHeight + 1);
}
}
if (height == getDimension().getFluidHeight()) {
ShorelineDecorationMode shorelineMode = shorelineDecorationMode(
hydrology,
height,
surfaceFluidHeight
);
if (shorelineMode == ShorelineDecorationMode.ACCEPTED) {
getShoreLineDecorator().decorateAcceptedShore(
i,
j,
realX,
realZ,
output,
biome,
height,
getEngine().getHeight()
);
} else if (shorelineMode == ShorelineDecorationMode.LEGACY) {
getShoreLineDecorator().decorate(i, j,
realX, Math.round(x + i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1),
@@ -140,4 +254,10 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
private boolean shouldRayDecorate() {
return false; // TODO CAVES
}
enum ShorelineDecorationMode {
NONE,
LEGACY,
ACCEPTED
}
}
@@ -23,7 +23,11 @@ import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex;
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;
@@ -88,13 +92,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
IrisData data = getData();
IrisComplex complex = getComplex();
RNG localRng = rng;
int fluidHeight = dimension.getFluidHeight();
int clampedFluidHeight = Math.min(chunkHeight, fluidHeight);
boolean bedrockEnabled = dimension.isBedrock();
boolean hideOres = dimension.isHideOresForHiddenOre();
ChunkedDataCache<IrisBiome> biomeCache = context.getBiome();
ChunkedDataCache<IrisRegion> regionCache = context.getRegion();
ChunkedDataCache<PlatformBlockState> fluidCache = context.getFluid();
ChunkedDataCache<PlatformBlockState> rockCache = context.getRock();
int realX = xf + x;
UpperDimensionContext upperContext = getEngine().getUpperContext();
@@ -104,19 +105,47 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
KList<IrisOreGenerator> dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators();
IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds();
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;
IrisBiome biome = biomeCache.get(xf, zf);
IrisRegion region = regionCache.get(xf, zf);
int he = Math.min(chunkHeight, context.getRoundedHeight(xf, zf));
int hf = Math.max(clampedFluidHeight, he);
int surfaceFluidHeight = Math.min(
chunkHeight,
(int) Math.round(complex.getRiverWaterSurfaceStream().get(realX, realZ))
);
int hf = Math.max(surfaceFluidHeight, he);
if (hf < 0) {
continue;
}
int topY = Math.min(hf, chunkHeight - 1);
PlatformBlockState fluid = fluidCache.get(xf, zf);
HydrologyColumnSample hydrology = complex.getHydrologyRuntime() == null
? null
: complex.getHydrologyRuntime().sample(realX, realZ).orElse(null);
HydrologyColumnLayer hydrologyFluid = hydrology == null
? null
: hydrology.primarySurfaceFluidLayer().orElse(null);
HydrologyColumnLayer hydrologyTerrain = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
int cut = exposeCutStrata
&& hydrologyTerrain != null
&& hydrologyTerrain.terrainOwned()
&& !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);
PlatformBlockState rock = rockCache.get(xf, zf);
PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
@@ -158,15 +187,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (i > he && i <= hf) {
int fdepth = hf - i;
if (fblocks == null) {
if (hydrologyFluid == null && fblocks == null) {
fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data);
}
if (fblocks.hasIndex(fdepth)) {
h.setRaw(xf, i, zf, fblocks.get(fdepth));
} else {
h.setRaw(xf, i, zf, fluid);
}
h.setRaw(xf, i, zf, HydrologyFluidLayerSelector.select(
fblocks,
fdepth,
fluid,
hydrologyFluid != null
));
continue;
}
@@ -177,11 +206,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
continue;
}
if (blocks == null) {
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he, he, data, complex);
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he + cut, he + cut, data, complex);
}
if (blocks.hasIndex(depth)) {
h.setRaw(xf, i, zf, blocks.get(depth));
if (blocks.hasIndex(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;
}
@@ -40,19 +40,46 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
@Override
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
if (height != getDimension().getFluidHeight()) {
double localFluidHeight = getComplex().getRiverWaterSurfaceStream().get(realX, realZ);
if (height != Math.round(localFluidHeight)) {
return;
}
double complexFluidHeight = getComplex().getFluidHeight();
ProceduralStream<Double> heightStream = getComplex().getHeightStream();
if (Math.round(heightStream.get(realX1, realZ)) >= complexFluidHeight
&& Math.round(heightStream.get(realX_1, realZ)) >= complexFluidHeight
&& Math.round(heightStream.get(realX, realZ1)) >= complexFluidHeight
&& Math.round(heightStream.get(realX, realZ_1)) >= complexFluidHeight) {
ProceduralStream<Double> fluidStream = getComplex().getRiverWaterSurfaceStream();
if (Math.round(heightStream.get(realX1, realZ)) >= Math.round(fluidStream.get(realX1, realZ))
&& Math.round(heightStream.get(realX_1, realZ)) >= Math.round(fluidStream.get(realX_1, realZ))
&& Math.round(heightStream.get(realX, realZ1)) >= Math.round(fluidStream.get(realX, realZ1))
&& Math.round(heightStream.get(realX, realZ_1)) >= Math.round(fluidStream.get(realX, realZ_1))) {
return;
}
place(x, z, realX, realZ, data, biome, height, max);
}
public void decorateAcceptedShore(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
place(x, z, realX, realZ, data, biome, height, max);
}
private void place(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -70,6 +97,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return;
}
IrisSurfaceDecorator.AquaticPlacementSnapshot aquaticSnapshot = IrisSurfaceDecorator.captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (!decorator.isStacking()) {
int targetY = height + 1;
if (targetY >= data.getHeight()
@@ -79,6 +108,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -99,6 +129,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -122,5 +153,6 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
}
data.set(x, targetY, z, block);
}
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
}
@@ -18,11 +18,13 @@
package art.arcane.iris.engine.decorator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.volmlib.util.math.RNG;
@@ -58,12 +60,15 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
@BlockCoordinates
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
int fluidHeight = getDimension().getFluidHeight();
int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
if (inferredType == InferredType.SHORE && height < fluidHeight) {
return;
}
boolean underwater = isUnderwater(inferredType, height, fluidHeight);
if (underwater && !hasFluidAbove(data, x, height, z)) {
return;
}
boolean caveSkipFluid = skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -72,6 +77,8 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
return;
}
AquaticPlacementSnapshot aquaticSnapshot = captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (decorator.isStacking()) {
DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get();
opts.reset();
@@ -79,11 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
opts.fluidHeight = fluidHeight;
opts.caveSkipFluid = caveSkipFluid;
DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
return;
}
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
@@ -93,4 +102,148 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
static boolean skipsFluid(InferredType inferredType) {
return inferredType == InferredType.CAVE;
}
static boolean hasFluidAbove(Hunk<PlatformBlockState> data, int x, int height, int z) {
return height + 1 < data.getHeight() && data.get(x, height + 1, z).isFluid();
}
public static boolean isAquaticPlacement(PlatformBlockState state) {
if (state == null) {
return false;
}
if (state.isWaterLogged()) {
return true;
}
String material = IrisProceduralBlocks.materialKey(state);
if (material.equals("minecraft:seagrass")
|| material.equals("minecraft:tall_seagrass")
|| material.equals("minecraft:kelp")
|| material.equals("minecraft:kelp_plant")
|| material.equals("minecraft:sea_pickle")) {
return true;
}
return material.startsWith("minecraft:")
&& material.contains("_coral")
&& !material.contains(":dead_");
}
public static boolean hasConnectedWater(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (y < 0 || y >= data.getHeight() || !isWater(data.get(x, y, z))) {
return false;
}
return isWaterNeighbor(data, x - 1, y, z)
|| isWaterNeighbor(data, x + 1, y, z)
|| isWaterNeighbor(data, x, y, z - 1)
|| isWaterNeighbor(data, x, y, z + 1);
}
static AquaticPlacementSnapshot captureAquaticPlacement(
IrisDecorator decorator,
IrisData irisData,
Hunk<PlatformBlockState> data,
int x,
int z,
int height,
int max
) {
if (!mayPlaceAquatic(decorator, irisData)) {
return AquaticPlacementSnapshot.EMPTY;
}
int highestOffset = decorator.isStacking()
? maximumStackHeight(decorator, max)
: 2;
int lowerY = Math.max(0, height);
int upperY = Math.min(data.getHeight() - 1, height + highestOffset);
if (lowerY > upperY) {
return AquaticPlacementSnapshot.EMPTY;
}
PlatformBlockState[] originals = new PlatformBlockState[upperY - lowerY + 1];
boolean[] connectedWater = new boolean[originals.length];
for (int i = 0; i < originals.length; i++) {
int y = lowerY + i;
originals[i] = data.get(x, y, z);
connectedWater[i] = hasConnectedWater(data, x, y, z);
}
return new AquaticPlacementSnapshot(lowerY, originals, connectedWater);
}
private static boolean mayPlaceAquatic(IrisDecorator decorator, IrisData irisData) {
PlatformBlockState[] palette = decorator.getBlockDataArray(irisData);
if (palette != null) {
for (PlatformBlockState state : palette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
PlatformBlockState[] topPalette = decorator.getBlockDataTopsArray(irisData);
if (topPalette != null) {
for (PlatformBlockState state : topPalette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
return decorator.getForceBlock() != null
&& isAquaticPlacement(decorator.getForceBlock().getBlockData(irisData));
}
private static int maximumStackHeight(IrisDecorator decorator, int max) {
if (decorator.isScaleStack()) {
return Math.max(1, decorator.getAbsoluteMaxStack());
}
return Math.max(1, Math.min(Math.max(1, max), decorator.getStackMax()));
}
private static boolean isWaterNeighbor(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (x < 0 || x >= data.getWidth() || z < 0 || z >= data.getDepth()) {
return false;
}
return isWater(data.get(x, y, z));
}
private static boolean isWater(PlatformBlockState state) {
return state != null && state.isWater();
}
static final class AquaticPlacementSnapshot {
private static final AquaticPlacementSnapshot EMPTY = new AquaticPlacementSnapshot(
0, new PlatformBlockState[0], new boolean[0]);
private final int lowerY;
private final PlatformBlockState[] originals;
private final boolean[] connectedWater;
private AquaticPlacementSnapshot(
int lowerY,
PlatformBlockState[] originals,
boolean[] connectedWater
) {
this.lowerY = lowerY;
this.originals = originals;
this.connectedWater = connectedWater;
}
void restoreIfUnsupported(Hunk<PlatformBlockState> data, int x, int z) {
if (!containsUnsupportedAquatic(data, x, z)) {
return;
}
for (int i = 0; i < originals.length; i++) {
data.set(x, lowerY + i, z, originals[i]);
}
}
private boolean containsUnsupportedAquatic(Hunk<PlatformBlockState> data, int x, int z) {
for (int i = 0; i < originals.length; i++) {
PlatformBlockState placed = data.get(x, lowerY + i, z);
if (placed != originals[i] && isAquaticPlacement(placed) && !connectedWater[i]) {
return true;
}
}
return false;
}
}
}
@@ -42,6 +42,8 @@ import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState;
@@ -244,6 +246,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates
default IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(
getMantle().getMantle(), x, y, z);
if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) {
IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey());
if (biome != null) {
return biome;
}
}
MatterCavern m = getMantle().getMantle().get(x, y, z, MatterCavern.class);
if (m != null && m.getCustomBiome() != null && !m.getCustomBiome().isEmpty()) {
@@ -445,6 +455,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
default PlacedObject getObjectPlacement(int x, int y, int z, MantleChunk<Matter> chunk) {
String objectAt = chunk.get(x & 15, y, z & 15, String.class);
return resolveObjectPlacementMarker(x, z, objectAt);
}
default PlacedObject resolveObjectPlacementMarker(int x, int z, @Nullable String objectAt) {
if (objectAt == null || objectAt.isEmpty()) {
return null;
}
@@ -552,7 +566,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return;
}
if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) {
getMantle().cleanupChunk(x, z);
getMantle().cleanupChunksCoveredBy(
x,
z,
false,
EngineMantle.ChunkCleanupCallback.NONE
);
}
}
}
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.mantle.MantleComponent;
import art.arcane.volmlib.util.collection.KList;
public interface EnginePlatformHooks {
@@ -70,4 +71,8 @@ public interface EnginePlatformHooks {
default boolean shouldBypassMantleStages(Engine engine) {
return false;
}
default boolean shouldGenerateMantleComponent(Engine engine, MantleComponent component) {
return true;
}
}
@@ -328,7 +328,11 @@ public final class HintedLocator<T> implements Locator<T> {
}
if (!surfaceRegions.isEmpty()) {
return SearchPlan.of(surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), BIOME_STRIDE_CHUNKS, null);
return SearchPlan.of(
surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts),
BIOME_STRIDE_CHUNKS,
null
);
}
if (!caveRegions.isEmpty()) {
@@ -341,6 +345,11 @@ public final class HintedLocator<T> implements Locator<T> {
return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact);
}
for (IrisBiome reachableBiome : engine.getDimension().getReachableBiomes(engine)) {
if (reachableBiome != null && biomeKey.equals(reachableBiome.getLoadKey())) {
return SearchPlan.unpruned();
}
}
return SearchPlan.impossible();
}
@@ -576,6 +585,36 @@ public final class HintedLocator<T> implements Locator<T> {
return false;
}
private KList<IrisBiome> closure(String rootKey) {
KList<IrisBiome> biomes = new KList<>();
if (rootKey == null) {
return biomes;
}
KSet<String> visited = new KSet<>();
ArrayDeque<String> queue = new ArrayDeque<>();
queue.add(rootKey);
while (!queue.isEmpty()) {
String key = queue.poll();
if (!visited.add(key)) {
continue;
}
IrisBiome biome = load(key);
if (biome == null) {
continue;
}
biomes.add(biome);
for (String child : biome.getChildren()) {
if (child != null && !visited.contains(child)) {
queue.add(child);
}
}
}
return biomes;
}
private boolean closurePlacesObject(String rootKey, String objectKey) {
if (rootKey == null || objectKey == null) {
return false;
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.container.BlockPos;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisRegion;
@@ -53,10 +54,22 @@ public interface Locator<T> {
}
static Locator<IrisBiome> surfaceBiome(String loadKey) {
Locator<IrisBiome> exact = (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey);
Locator<IrisBiome> exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey);
return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey));
}
static boolean chunkContainsSurfaceBiome(Engine engine, Position2 chunk, String loadKey) {
int minimumX = chunk.getX() << 4;
int minimumZ = chunk.getZ() << 4;
IrisBiome center = engine.getSurfaceBiome(minimumX + 8, minimumZ + 8);
if (center != null && loadKey.equals(center.getLoadKey())) {
return true;
}
IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime();
return hydrology != null
&& hydrology.hasAcceptedSurfaceBiomeInChunk(loadKey, chunk.getX(), chunk.getZ());
}
static Locator<art.arcane.iris.engine.object.IrisStructure> structure(String key) {
return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ());
}
@@ -157,7 +157,10 @@ public final class NativeStructurePlacementPlanner {
}
static boolean isSubmerged(Engine engine, int blockX, int blockZ) {
return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight();
int localFluidHeight = engine.getComplex() == null
? engine.getDimension().getFluidHeight()
: (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
return engine.getHeight(blockX, blockZ, true) < localFluidHeight;
}
private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) {
@@ -3,6 +3,8 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisStructureAnchorMode;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern;
@@ -286,10 +288,12 @@ public final class StructureCaveAnchorResolver {
int mantleY,
int blockZ
) {
HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ);
return acceptsAnchorFluid(
placement.isUnderwater(),
cavern,
hydrology,
mantleY,
engine.getDimension().getCaveLavaHeight());
}
@@ -300,6 +304,19 @@ public final class StructureCaveAnchorResolver {
int mantleY,
int defaultLavaHeight
) {
return acceptsAnchorFluid(underwater, cavern, null, mantleY, defaultLavaHeight);
}
static boolean acceptsAnchorFluid(
boolean underwater,
MatterCavern cavern,
HydrologyCaveCell hydrology,
int mantleY,
int defaultLavaHeight
) {
if (hydrology != null && hydrology.protectsPlacement()) {
return false;
}
if (cavern == null || !cavern.isCavern()) {
return false;
}
@@ -313,7 +330,15 @@ public final class StructureCaveAnchorResolver {
}
private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) {
return engine.getMantle().getMantle().get(blockX, mantleY, blockZ, MatterCavern.class);
MatterCavern baseline = engine.getMantle().getMantle()
.get(blockX, mantleY, blockZ, MatterCavern.class);
HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
return hydrology == null ? baseline : hydrology.asCavern();
}
private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) {
return HydrologyCaveStorage.getIfPresent(
engine.getMantle().getMantle(), blockX, mantleY, blockZ);
}
static int toMantleY(int worldY, int worldMinHeight) {
@@ -21,7 +21,6 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.bukkit.Bukkit;
@@ -112,7 +111,7 @@ public class WorldObjectPlacer implements IObjectPlacer {
@Override
public boolean isCarved(int x, int y, int z) {
return mantle.getMantle().get(x, y, z, MatterCavern.class) != null;
return mantle.isCarved(x, y, z);
}
@Override
@@ -20,6 +20,13 @@ package art.arcane.iris.engine.framework.render;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyRenderSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
import art.arcane.iris.engine.object.IrisDimension;
@@ -29,9 +36,13 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.BooleanSupplier;
@@ -40,6 +51,24 @@ public final class IrisRenderer {
private static final int BLUE = new Color(45, 91, 156).getRGB();
private static final int YELLOW = new Color(211, 164, 67).getRGB();
private static final int GREEN = new Color(78, 137, 83).getRGB();
private static final int HEADWATER = new Color(99, 184, 230).getRGB();
private static final int HEADWATER_DIRECTION = new Color(13, 38, 55).getRGB();
private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB();
private static final int RIVER_RIFFLE = new Color(96, 166, 214).getRGB();
private static final int RIVER_CASCADE = new Color(135, 204, 232).getRGB();
private static final int SUBTERRANEAN_CHANNEL = new Color(102, 86, 196).getRGB();
private static final int RIDGE_TUNNEL = new Color(120, 99, 174).getRGB();
private static final int COASTAL_GROTTO = new Color(51, 174, 174).getRGB();
private static final int INLAND_GROTTO = new Color(116, 76, 150).getRGB();
private static final int RIVER_SINKHOLE = new Color(176, 146, 232).getRGB();
private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB();
private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB();
private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB();
private static final int STANDING_POOL = new Color(232, 120, 40).getRGB();
private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB();
private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB();
private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB();
private static final int NO_RIVER = new Color(28, 31, 38).getRGB();
private static final int DEEP_WATER = new Color(20, 48, 92).getRGB();
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
private static final int LOWLAND = new Color(78, 128, 76).getRGB();
@@ -105,9 +134,17 @@ public final class IrisRenderer {
renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled);
return image;
}
PixelShader shader = shader(currentType, studio);
PixelShader shader = shader(currentType, step, studio);
if (studio && currentType == RenderType.RIVER) {
Arrays.fill(pixels, NO_RIVER);
renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, false);
return image;
}
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
if (currentType == RenderType.BIOME) {
renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, true);
}
return image;
}
int groupSize = sampleGroup(step, resolution);
@@ -133,6 +170,57 @@ public final class IrisRenderer {
return image;
}
public static int waterfallColor() {
return RIVER_WATERFALL;
}
public static int surfaceWaterColor() {
return RIVER_CHANNEL;
}
public static int subterraneanColor() {
return SUBTERRANEAN_CHANNEL;
}
public static int confirmedDeepLavaColor() {
return CONFIRMED_DEEP_LAVA;
}
public static int hydrologyFeatureColor(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
return switch (type) {
case SURFACE_POOL -> RIVER_CHANNEL;
case RIFFLE -> RIVER_RIFFLE;
case CASCADE -> RIVER_CASCADE;
case WATERFALL -> RIVER_WATERFALL;
case SINKHOLE -> RIVER_SINKHOLE;
case RIDGE_BORE -> RIDGE_TUNNEL;
case UNDERGROUND_POOL, UNDERGROUND_DROP -> SUBTERRANEAN_CHANNEL;
case COASTAL_GROTTO -> COASTAL_GROTTO;
case INLAND_GROTTO -> INLAND_GROTTO;
case MOUTH -> RIVER_MOUTH;
case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA;
case STANDING_POOL -> STANDING_POOL;
};
}
public static int headwaterColor() {
return HEADWATER;
}
public static int headwaterDirectionColor() {
return HEADWATER_DIRECTION;
}
public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) {
Objects.requireNonNull(kind, "kind");
return switch (kind) {
case SOURCE -> PROJECTED_SOURCE;
case OUTLET -> PROJECTED_OUTLET;
case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID;
};
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {
double boundedMaximum = Math.max(1D, maximumHeight);
double boundedHeight = clamp(height, 0D, boundedMaximum);
@@ -160,7 +248,7 @@ public final class IrisRenderer {
return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep)));
}
private PixelShader shader(RenderType currentType, boolean studio) {
private PixelShader shader(RenderType currentType, double step, boolean studio) {
IrisComplex complex = renderer.getComplex();
return switch (currentType) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader(
@@ -169,7 +257,8 @@ public final class IrisRenderer {
case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType);
case REGION -> regionShader(complex, currentType);
case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType);
case HEIGHT -> heightShader(complex.getHeightStream());
case HEIGHT -> heightShader(studio ? complex.getNaturalHeightStream() : complex.getHeightStream());
case RIVER -> (double x, double z) -> riverColor(complex, x, z, step);
case CONTINENT -> studio
? continentShader(complex.getBaseBiomeStream())
: this::continentColor;
@@ -180,7 +269,7 @@ public final class IrisRenderer {
return switch (type) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND,
CONTINENT -> true;
case HEIGHT -> false;
case HEIGHT, RIVER -> false;
};
}
@@ -224,7 +313,7 @@ public final class IrisRenderer {
startX,
startZ,
step,
complex.getHeightStream(),
complex.getNaturalHeightStream(),
engine.getHeight(),
fluidHeight,
cancelled
@@ -237,6 +326,212 @@ public final class IrisRenderer {
}
}
private static void renderRiverAtlas(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
IrisComplex complex,
BooleanSupplier cancelled,
boolean composite
) {
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return;
}
Map<Long, HydrologyFeatureRef> headwaters = new LinkedHashMap<>();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
renderRiverBlock(
pixels,
resolution,
startX,
startZ,
step,
runtime,
cancelled,
composite,
!composite,
headwaters,
pixelX,
pixelZ,
Math.min(blockPixels, resolution - pixelX),
height
);
}
}
renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters);
}
private static void renderRiverBlock(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
IrisHydrologyRuntime runtime,
BooleanSupplier cancelled,
boolean composite,
boolean diagnostics,
Map<Long, HydrologyFeatureRef> headwaters,
int pixelX,
int pixelZ,
int width,
int height
) {
checkCancelled(cancelled);
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step
);
HydrologyDiagnosticRenderSample diagnosticSample = diagnostics
? runtime.sampleDiagnosticFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step
)
: null;
collectHeadwaters(sample, headwaters);
if (!sample.present() && (diagnosticSample == null || !diagnosticSample.present())) {
return;
}
if (width == 1 && height == 1) {
int index = pixelZ * resolution + pixelX;
int color = sample.present()
? hydrologyColor(sample)
: hydrologyDiagnosticColor(diagnosticSample);
pixels[index] = composite
? blend(pixels[index], color, 0.72D)
: color;
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight;
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ, rightWidth, topHeight);
}
if (bottomHeight > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
}
}
}
private static void collectHeadwaters(
HydrologyRenderSample sample,
Map<Long, HydrologyFeatureRef> headwaters
) {
for (HydrologyFeatureRef feature : sample.features()) {
if (!feature.source()) {
continue;
}
HydrologyFeatureRef existing = headwaters.putIfAbsent(feature.id(), feature);
if (existing != null && !existing.equals(feature)) {
throw new IllegalStateException("Hydrology headwater feature id collision in renderer footprint.");
}
}
}
private static void renderHeadwaterDirections(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
Map<Long, HydrologyFeatureRef> headwaters
) {
if (resolution < 5 || headwaters.isEmpty()) {
return;
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(headwaters.values());
ordered.sort(Comparator.comparingLong(HydrologyFeatureRef::id));
for (HydrologyFeatureRef feature : ordered) {
int pixelX = worldToPixel(feature.x(), startX, step, resolution);
int pixelZ = worldToPixel(feature.z(), startZ, step, resolution);
drawHeadwaterDirection(
pixels,
resolution,
pixelX,
pixelZ,
feature.flowDeltaX(),
feature.flowDeltaZ()
);
}
}
private static int worldToPixel(int coordinate, double start, double step, int resolution) {
int pixel = (int) StrictMath.floor((coordinate + 0.5D - start) / step);
int markerRadius = 2;
return Math.max(markerRadius, Math.min(resolution - markerRadius - 1, pixel));
}
private static void drawHeadwaterDirection(
int[] pixels,
int resolution,
int centerX,
int centerZ,
int flowX,
int flowZ
) {
int markerRadius = Math.min(2, (resolution - 1) / 2);
for (int deltaZ = -markerRadius; deltaZ <= markerRadius; deltaZ++) {
for (int deltaX = -markerRadius; deltaX <= markerRadius; deltaX++) {
setPixel(pixels, resolution, centerX + deltaX, centerZ + deltaZ, HEADWATER);
}
}
if (flowX == 0 && flowZ == 0) {
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
return;
}
setPixel(pixels, resolution, centerX - flowX * 2, centerZ - flowZ * 2, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX - flowX, centerZ - flowZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX + flowX, centerZ + flowZ, HEADWATER_DIRECTION);
int tipX = centerX + flowX * 2;
int tipZ = centerZ + flowZ * 2;
setPixel(pixels, resolution, tipX, tipZ, HEADWATER_DIRECTION);
setPixel(
pixels,
resolution,
tipX - flowX - flowZ,
tipZ - flowZ + flowX,
HEADWATER_DIRECTION
);
setPixel(
pixels,
resolution,
tipX - flowX + flowZ,
tipZ - flowZ - flowX,
HEADWATER_DIRECTION
);
}
private static void setPixel(int[] pixels, int resolution, int x, int z, int color) {
if (x < 0 || x >= resolution || z < 0 || z >= resolution) {
return;
}
pixels[z * resolution + x] = color;
}
private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
@@ -271,6 +566,50 @@ public final class IrisRenderer {
return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight);
}
private int riverColor(IrisComplex complex, double x, double z, double step) {
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return NO_RIVER;
}
double endX = x + step;
double endZ = z + step;
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
StrictMath.max(z, endZ)
);
if (sample.present()) {
return hydrologyColor(sample);
}
return hydrologyDiagnosticColor(runtime.sampleDiagnosticFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
StrictMath.max(z, endZ)
));
}
static int hydrologyColor(HydrologyRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyFeatureRef feature = sample.primaryFeature().orElse(null);
if (feature == null) {
return NO_RIVER;
}
if (feature.source()) {
return HEADWATER;
}
return hydrologyFeatureColor(feature.type());
}
static int hydrologyDiagnosticColor(HydrologyDiagnosticRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyDiagnosticCandidate candidate = sample.candidates().isEmpty()
? null
: sample.candidates().getFirst();
return candidate == null ? NO_RIVER : hydrologyDiagnosticColor(candidate.kind());
}
private int continentColor(double x, double z) {
IrisBiome biome = renderer.getBiome(
(int) Math.round(x),
@@ -19,5 +19,5 @@
package art.arcane.iris.engine.framework.render;
public enum RenderType {
BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD
BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, RIVER, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD
}
@@ -0,0 +1,36 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<HydrologyPoint> centerline
) {
public DrainageEdge {
if (upstreamNodeId == downstreamNodeId) {
throw new IllegalArgumentException("A drainage edge cannot point to the same node.");
}
if (!Double.isFinite(cost) || cost <= 0D) {
throw new IllegalArgumentException("Drainage edge cost must be finite and positive.");
}
if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) {
throw new IllegalArgumentException("Contributing source counts cannot be negative.");
}
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.size() < 2) {
throw new IllegalArgumentException("A drainage edge requires at least two centerline points.");
}
}
public int totalContributingSources() {
return contributingSurfaceSources + contributingUndergroundSources;
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -0,0 +1,66 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID,
POOL
}
@@ -0,0 +1,24 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
SOURCE_SPACING,
OUTLET_LIMIT,
OUTLET_LEVEL,
CAVE_CONTAINMENT,
VOLUME_LIMIT,
SURFACE_HEAD_RANGE,
SURFACE_HEAD_DISTRIBUTION,
SURFACE_EXPOSURE,
SURFACE_DROP_UNSUPPORTED,
SURFACE_CORRIDOR_UNSUPPORTED,
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED,
POLICY_EXCLUDED;
}
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@FunctionalInterface
public interface HydrologyCaveVoxelViewFactory {
CaveVoxelView create(PlannedSurface plannedSurface);
@FunctionalInterface
interface PlannedSurface {
int resolve(int x, int z, int naturalHeight);
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyColumnLayer(
HydrologyFeatureRef feature,
int bedY,
int fluidHeadY,
int ceilingY,
boolean channel,
boolean shore,
boolean grading,
boolean connectedFluid,
boolean fallingFluid,
boolean receivingPool,
boolean terrainOwned,
boolean fluidOwned,
boolean oceanApron,
String profileKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey
) {
public HydrologyColumnLayer {
Objects.requireNonNull(feature, "feature");
profileKey = requireKey(profileKey, "profileKey");
surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey");
mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey");
shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey");
bankBiomeKey = requireKey(bankBiomeKey, "bankBiomeKey");
floodedCaveBiomeKey = requireKey(floodedCaveBiomeKey, "floodedCaveBiomeKey");
if (channel && bedY > fluidHeadY) {
throw new IllegalArgumentException("A channel bed cannot be above its fluid head.");
}
if (ceilingY < fluidHeadY) {
throw new IllegalArgumentException("A hydrology carve ceiling cannot be below its fluid head.");
}
if (!feature.type().isUnderground() && !feature.type().isDeepFluid() && ceilingY != fluidHeadY) {
throw new IllegalArgumentException("Surface hydrology ceilings must equal the fluid head.");
}
if (fluidOwned && !channel) {
throw new IllegalArgumentException("Owned fluid requires a channel footprint.");
}
if (fallingFluid && !connectedFluid) {
throw new IllegalArgumentException("Falling fluid must be connected.");
}
if (receivingPool && !connectedFluid) {
throw new IllegalArgumentException("A receiving pool must be connected.");
}
if (oceanApron && (terrainOwned || fluidOwned || grading || shore)) {
throw new IllegalArgumentException("An ocean apron cannot own river writes or grading.");
}
}
public String biomeKey() {
if (grading && !channel && !shore) {
return bankBiomeKey;
}
if (shore) {
return shoreBiomeKey;
}
if (feature.type() == HydrologyFeatureType.MOUTH || feature.type() == HydrologyFeatureType.COASTAL_GROTTO) {
return mouthBiomeKey;
}
if (feature.type().isUnderground() || feature.type().isDeepFluid()) {
return floodedCaveBiomeKey;
}
return surfaceBiomeKey;
}
/** Every owned channel water column is published so steps between stations receive fluid updates. */
public boolean publishesSurfaceFluid() {
return feature.type().isSurface()
&& !oceanApron
&& channel
&& fluidOwned
&& (terrainOwned || fallingFluid)
&& fluidHeadY > bedY;
}
private static String requireKey(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank.");
}
return value.trim();
}
}
@@ -0,0 +1,193 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record HydrologyColumnSample(
int x,
int z,
int naturalHeight,
int seaLevel,
boolean ocean,
String parentBiomeKey,
List<HydrologyColumnLayer> layers
) {
private static final Comparator<HydrologyColumnLayer> LAYER_ORDER = Comparator
.comparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
private static final Comparator<HydrologyColumnLayer> SURFACE_LAYER_ORDER = Comparator
.comparingInt(HydrologyColumnSample::surfaceRolePriority)
.thenComparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingInt(HydrologyColumnLayer::bedY)
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
public HydrologyColumnSample {
if (parentBiomeKey == null || parentBiomeKey.isBlank()) {
throw new IllegalArgumentException("parentBiomeKey must not be blank.");
}
parentBiomeKey = parentBiomeKey.trim();
Objects.requireNonNull(layers, "layers");
if (layers.size() < 2) {
layers = List.copyOf(layers);
} else {
ArrayList<HydrologyColumnLayer> ordered = new ArrayList<>(layers);
ordered.sort(LAYER_ORDER);
layers = List.copyOf(ordered);
}
if (ocean) {
for (HydrologyColumnLayer layer : layers) {
if (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore()) {
throw new IllegalArgumentException("Ocean columns cannot contain river-owned writes or grading.");
}
if (layer.fluidHeadY() > seaLevel) {
throw new IllegalArgumentException("Ocean column fluid cannot be elevated above sea level.");
}
}
}
if (naturalHeight <= seaLevel) {
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type().isSurface()
&& !layer.oceanApron()
&& (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore())) {
throw new IllegalArgumentException(
"Naturally submerged columns cannot contain owned surface hydrology writes."
);
}
}
}
}
public boolean present() {
return !layers.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type() == type) {
return true;
}
}
return false;
}
public boolean hasConnectedFluid() {
for (HydrologyColumnLayer layer : layers) {
if (layer.connectedFluid()) {
return true;
}
}
return false;
}
public Optional<HydrologyColumnLayer> primaryLayer() {
return layers.isEmpty() ? Optional.empty() : Optional.of(layers.getFirst());
}
public Optional<HydrologyColumnLayer> primarySurfaceLayer() {
return selectSurfaceLayer(false);
}
public Optional<HydrologyColumnLayer> primarySurfaceFluidLayer() {
return selectSurfaceLayer(true);
}
public Optional<SurfacePublicationCell> surfacePublicationCellAt(int y) {
if (y <= terrainHeight()) {
return Optional.empty();
}
SurfacePublicationCell selected = null;
for (HydrologyColumnLayer layer : layers) {
if (!layer.publishesSurfaceFluid() || y <= layer.bedY() || y > layer.fluidHeadY()) {
continue;
}
HydrologyCaveAction action = layer.fallingFluid() && y < layer.fluidHeadY()
? HydrologyCaveAction.FALLING_FLUID
: HydrologyCaveAction.WET_SOURCE;
SurfacePublicationCell candidate = new SurfacePublicationCell(layer, action);
if (selected == null || surfaceActionPriority(action) < surfaceActionPriority(selected.action())) {
selected = candidate;
}
}
return Optional.ofNullable(selected);
}
public int terrainHeight() {
Optional<HydrologyColumnLayer> layer = primarySurfaceLayer();
if (layer.isEmpty()) {
return naturalHeight;
}
HydrologyColumnLayer primary = layer.get();
if (primary.channel()) {
return primary.bedY();
}
int terrainHeight = primary.bedY();
for (HydrologyColumnLayer candidate : layers) {
if (candidate.feature().type().isSurface()
&& candidate.terrainOwned()
&& !candidate.channel()) {
terrainHeight = Math.max(terrainHeight, candidate.bedY());
}
}
return terrainHeight;
}
public HydrologyRenderSample renderSample() {
ArrayList<HydrologyFeatureRef> features = new ArrayList<>(layers.size());
for (HydrologyColumnLayer layer : layers) {
features.add(layer.feature());
}
return new HydrologyRenderSample(x, z, features);
}
private Optional<HydrologyColumnLayer> selectSurfaceLayer(boolean fluidOnly) {
HydrologyColumnLayer selected = null;
for (HydrologyColumnLayer layer : layers) {
if (layer.oceanApron() || !layer.feature().type().isSurface()) {
continue;
}
if (fluidOnly) {
if (!layer.channel() || !layer.connectedFluid() || !layer.fluidOwned()) {
continue;
}
} else if (!layer.terrainOwned()) {
continue;
}
if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) {
selected = layer;
}
}
return Optional.ofNullable(selected);
}
private static int surfaceRolePriority(HydrologyColumnLayer layer) {
if (layer.channel()) {
return 0;
}
return layer.shore() ? 1 : 2;
}
private static int surfaceActionPriority(HydrologyCaveAction action) {
return action == HydrologyCaveAction.WET_SOURCE ? 0 : 1;
}
public record SurfacePublicationCell(
HydrologyColumnLayer layer,
HydrologyCaveAction action
) {
public SurfacePublicationCell {
Objects.requireNonNull(layer, "layer");
if (action != HydrologyCaveAction.WET_SOURCE
&& action != HydrologyCaveAction.FALLING_FLUID) {
throw new IllegalArgumentException("Surface publication cells must contain fluid.");
}
}
}
}
@@ -0,0 +1,137 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
final class HydrologyCrossTileCaveAdmission {
private static final Comparator<Claim> CLAIM_PRIORITY = Comparator
.comparing((Claim claim) -> claim.plan().source(), HydrologyCaveConflictPolicy.sourcePriority())
.thenComparing(Claim::profileKey);
private static final Comparator<RankedClaim> BLOCKER_PRIORITY = Comparator
.comparingInt(RankedClaim::ownerRank)
.thenComparing(RankedClaim::claim, CLAIM_PRIORITY)
.thenComparing(RankedClaim::ownerKey);
private HydrologyCrossTileCaveAdmission() {
}
static Result admit(
int currentOwnerRank,
Collection<Claim> currentClaims,
Collection<RankedClaim> blockers
) {
if (currentOwnerRank < 0) {
throw new IllegalArgumentException("currentOwnerRank cannot be negative");
}
ArrayList<Claim> orderedClaims = new ArrayList<>(Objects.requireNonNull(currentClaims, "currentClaims"));
orderedClaims.sort(CLAIM_PRIORITY);
ArrayList<RankedClaim> orderedBlockers = new ArrayList<>(Objects.requireNonNull(blockers, "blockers"));
orderedBlockers.sort(BLOCKER_PRIORITY);
for (RankedClaim blocker : orderedBlockers) {
if (blocker.ownerRank() >= currentOwnerRank) {
throw new IllegalArgumentException("Cross-tile blockers must have a lower owner color rank");
}
}
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim claim : orderedClaims) {
RankedClaim winner = null;
for (RankedClaim blocker : orderedBlockers) {
if (claim.courseId() == blocker.claim().courseId()) {
throw new IllegalArgumentException("A cave course cannot belong to multiple color-ranked owners");
}
if (!conflicts(claim, blocker.claim())) {
continue;
}
if (winner == null || BLOCKER_PRIORITY.compare(blocker, winner) < 0) {
winner = blocker;
}
}
if (winner != null) {
rejections.add(new Rejection(claim, winner));
}
}
return new Result(rejections);
}
private static boolean conflicts(Claim left, Claim right) {
return HydrologyCaveConflictPolicy.hasIncompatibleOverlap(
left.profileKey(),
left.plan().actions(),
right.profileKey(),
right.plan().actions()
);
}
record Claim(String profileKey, HydrologyCavePlan plan) {
Claim {
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
Objects.requireNonNull(plan, "plan");
if (!plan.accepted()) {
throw new IllegalArgumentException("Cross-tile admission requires accepted cave plans");
}
}
long courseId() {
return plan.source().sourceId();
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
if (ownerRank < 0) {
throw new IllegalArgumentException("ownerRank cannot be negative");
}
Objects.requireNonNull(claim, "claim");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
Map<Long, Long> loserCourseIdsToWinnerSourceIds() {
TreeMap<Long, Long> ordered = new TreeMap<>();
for (Rejection rejection : rejections) {
ordered.put(rejection.loser().courseId(), rejection.winnerSourceId());
}
return Collections.unmodifiableMap(new LinkedHashMap<>(ordered));
}
Rejection rejection(Claim claim) {
Objects.requireNonNull(claim, "claim");
for (Rejection rejection : rejections) {
if (rejection.loser().equals(claim)) {
return rejection;
}
}
return null;
}
}
record Rejection(Claim loser, RankedClaim winner) {
Rejection {
Objects.requireNonNull(loser, "loser");
Objects.requireNonNull(winner, "winner");
}
long winnerSourceId() {
return winner.claim().plan().source().sourceId();
}
}
}
@@ -0,0 +1,163 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class HydrologyCrossTileSurfaceAdmission {
private HydrologyCrossTileSurfaceAdmission() {
}
static Result admit(
List<Claim> currentClaims,
List<RankedClaim> blockers,
int minimumSeparation
) {
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim current : currentClaims) {
for (RankedClaim ranked : blockers) {
Claim blocker = ranked.claim();
if (!conflicts(current, blocker, minimumSeparation)) {
continue;
}
rejections.add(new Rejection(current, blocker.courseId()));
break;
}
}
return new Result(List.copyOf(rejections));
}
private static boolean conflicts(Claim first, Claim second, int minimumSeparation) {
if (first.outletId() == second.outletId()) {
return true;
}
long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation;
if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst())
< minimumSeparationSquared) {
return true;
}
if (first.reachesOutlet() && second.reachesOutlet()
&& first.terminal().distanceSquared2D(second.terminal())
< minimumSeparationSquared) {
return true;
}
double clearance = Math.max(4D, (first.maximumWidth() + second.maximumWidth()) / 2D + 4D);
return centerlinesWithin(first.centerline(), second.centerline(), clearance);
}
private static boolean centerlinesWithin(
List<HydrologyPoint> first,
List<HydrologyPoint> second,
double clearance
) {
double clearanceSquared = clearance * clearance;
for (int firstIndex = 1; firstIndex < first.size(); firstIndex++) {
HydrologyPoint firstStart = first.get(firstIndex - 1);
HydrologyPoint firstEnd = first.get(firstIndex);
for (int secondIndex = 1; secondIndex < second.size(); secondIndex++) {
HydrologyPoint secondStart = second.get(secondIndex - 1);
HydrologyPoint secondEnd = second.get(secondIndex);
if (segmentDistanceSquared(firstStart, firstEnd, secondStart, secondEnd) <= clearanceSquared) {
return true;
}
}
}
return false;
}
private static double segmentDistanceSquared(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
if (segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd)) {
return 0D;
}
return Math.min(
Math.min(pointSegmentDistanceSquared(firstStart, secondStart, secondEnd),
pointSegmentDistanceSquared(firstEnd, secondStart, secondEnd)),
Math.min(pointSegmentDistanceSquared(secondStart, firstStart, firstEnd),
pointSegmentDistanceSquared(secondEnd, firstStart, firstEnd))
);
}
private static boolean segmentsIntersect(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
long firstA = orientation(firstStart, firstEnd, secondStart);
long firstB = orientation(firstStart, firstEnd, secondEnd);
long secondA = orientation(secondStart, secondEnd, firstStart);
long secondB = orientation(secondStart, secondEnd, firstEnd);
return Long.signum(firstA) != Long.signum(firstB)
&& Long.signum(secondA) != Long.signum(secondB);
}
private static long orientation(HydrologyPoint start, HydrologyPoint end, HydrologyPoint point) {
long firstX = end.x() - (long) start.x();
long firstZ = end.z() - (long) start.z();
long secondX = point.x() - (long) start.x();
long secondZ = point.z() - (long) start.z();
return firstX * secondZ - firstZ * secondX;
}
private static double pointSegmentDistanceSquared(
HydrologyPoint point,
HydrologyPoint start,
HydrologyPoint end
) {
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
if (lengthSquared == 0D) {
return point.distanceSquared2D(start);
}
double progress = ((point.x() - start.x()) * deltaX + (point.z() - start.z()) * deltaZ)
/ lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double closestX = start.x() + deltaX * progress;
double closestZ = start.z() + deltaZ * progress;
double pointX = point.x() - closestX;
double pointZ = point.z() - closestZ;
return pointX * pointX + pointZ * pointZ;
}
record Claim(
long courseId,
long outletId,
HydrologyPoint terminal,
boolean reachesOutlet,
int maximumWidth,
List<HydrologyPoint> centerline
) {
Claim {
Objects.requireNonNull(terminal, "terminal");
centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline"));
if (maximumWidth < 1 || centerline.size() < 2) {
throw new IllegalArgumentException("Surface claims require a positive width and centerline.");
}
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
Objects.requireNonNull(claim, "claim");
}
}
record Rejection(Claim loser, long winnerCourseId) {
Rejection {
Objects.requireNonNull(loser, "loser");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
}
}
@@ -0,0 +1,19 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyDiagnosticCandidate(
long id,
HydrologyCandidateKind kind,
HydrologyFeatureType projectedType,
HydrologyPoint point,
HydrologyCandidateRejection rejection,
int detail
) {
public HydrologyDiagnosticCandidate {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(projectedType, "projectedType");
Objects.requireNonNull(point, "point");
Objects.requireNonNull(rejection, "rejection");
}
}
@@ -0,0 +1,28 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public record HydrologyDiagnosticRenderSample(
int x,
int z,
List<HydrologyDiagnosticCandidate> candidates
) {
private static final Comparator<HydrologyDiagnosticCandidate> CANDIDATE_ORDER = Comparator
.comparing(HydrologyDiagnosticCandidate::kind)
.thenComparing(HydrologyDiagnosticCandidate::rejection)
.thenComparingLong(HydrologyDiagnosticCandidate::id);
public HydrologyDiagnosticRenderSample {
Objects.requireNonNull(candidates, "candidates");
ArrayList<HydrologyDiagnosticCandidate> ordered = new ArrayList<>(candidates);
ordered.sort(CANDIDATE_ORDER);
candidates = List.copyOf(ordered);
}
public boolean present() {
return !candidates.isEmpty();
}
}
@@ -0,0 +1,100 @@
package art.arcane.iris.engine.hydrology;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String profileKey) {
private static final Set<HydrologyFeatureType> SURFACE = Set.of(
HydrologyFeatureType.SURFACE_POOL,
HydrologyFeatureType.RIFFLE,
HydrologyFeatureType.CASCADE,
HydrologyFeatureType.WATERFALL,
HydrologyFeatureType.MOUTH
);
private static final Set<HydrologyFeatureType> UNDERGROUND = Set.of(
HydrologyFeatureType.UNDERGROUND_POOL,
HydrologyFeatureType.UNDERGROUND_DROP,
HydrologyFeatureType.SINKHOLE
);
private static final Set<HydrologyFeatureType> GROTTOS = Set.of(
HydrologyFeatureType.COASTAL_GROTTO,
HydrologyFeatureType.INLAND_GROTTO
);
private static final Set<HydrologyFeatureType> DEEP = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL
);
private static final Set<HydrologyFeatureType> PROFILED = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL,
HydrologyFeatureType.STANDING_POOL
);
private static final Map<String, Set<HydrologyFeatureType>> BUILT_INS = builtIns();
public HydrologyFeatureQuery {
types = Set.copyOf(types);
if (types.isEmpty()) {
throw new IllegalArgumentException("Hydrology feature query requires at least one feature type.");
}
profileKey = profileKey == null || profileKey.isBlank() ? null : profileKey.trim();
}
public static HydrologyFeatureQuery parse(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Hydrology feature type must not be blank.");
}
String normalized = value.trim();
if (normalized.regionMatches(true, 0, "type=", 0, 5)) {
normalized = normalized.substring(5).trim();
}
String key = normalizeKeyword(normalized);
Set<HydrologyFeatureType> builtInTypes = BUILT_INS.get(key);
return builtInTypes == null
? new HydrologyFeatureQuery(PROFILED, normalized)
: new HydrologyFeatureQuery(builtInTypes, null);
}
public static List<String> suggestions(Collection<String> deepFluidIds) {
LinkedHashSet<String> suggestions = new LinkedHashSet<>(BUILT_INS.keySet());
TreeSet<String> orderedIds = new TreeSet<>();
if (deepFluidIds != null) {
for (String id : deepFluidIds) {
if (id != null && !id.isBlank() && !isReservedKeyword(id)) {
orderedIds.add(id.trim());
}
}
}
suggestions.addAll(orderedIds);
return List.copyOf(suggestions);
}
public static boolean isReservedKeyword(String value) {
return value != null && !value.isBlank() && BUILT_INS.containsKey(normalizeKeyword(value.trim()));
}
private static String normalizeKeyword(String value) {
return value.toLowerCase(Locale.ROOT).replace('-', '_');
}
private static Map<String, Set<HydrologyFeatureType>> builtIns() {
LinkedHashMap<String, Set<HydrologyFeatureType>> builtIns = new LinkedHashMap<>();
builtIns.put("surface", SURFACE);
builtIns.put("waterfall", Set.of(HydrologyFeatureType.WATERFALL));
builtIns.put("sinkhole", Set.of(HydrologyFeatureType.SINKHOLE));
builtIns.put("underground", UNDERGROUND);
builtIns.put("grotto", GROTTOS);
builtIns.put("coastal_grotto", Set.of(HydrologyFeatureType.COASTAL_GROTTO));
builtIns.put("inland_grotto", Set.of(HydrologyFeatureType.INLAND_GROTTO));
builtIns.put("mouth", Set.of(HydrologyFeatureType.MOUTH));
builtIns.put("deep", DEEP);
builtIns.put("pool", Set.of(HydrologyFeatureType.STANDING_POOL));
return Collections.unmodifiableMap(builtIns);
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyFeatureRef(
long id,
HydrologyFeatureType type,
long courseId,
long segmentId,
int x,
int y,
int z,
int flowDeltaX,
int flowDeltaZ,
boolean source
) {
public HydrologyFeatureRef {
Objects.requireNonNull(type, "type");
if (flowDeltaX < -1 || flowDeltaX > 1 || flowDeltaZ < -1 || flowDeltaZ > 1) {
throw new IllegalArgumentException("Flow direction components must be between -1 and 1.");
}
}
public HydrologyPoint point() {
return new HydrologyPoint(x, y, z);
}
}
@@ -0,0 +1,67 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyFeatureType {
SURFACE_POOL,
RIFFLE,
CASCADE,
WATERFALL,
// No planner produces a ridge bore any more. The constant stays because its ordinal seeds the
// segment, outlet and feature ids of every constant after it on the cave and deep-fluid paths.
RIDGE_BORE,
UNDERGROUND_POOL,
UNDERGROUND_DROP,
SINKHOLE,
COASTAL_GROTTO,
INLAND_GROTTO,
MOUTH,
DEEP_POOL,
DEEP_CHANNEL,
// A standing surface pool (lava or any fluid) with no course: a bowl cut into the ground with a lip.
STANDING_POOL;
public boolean isSurface() {
return switch (this) {
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH, STANDING_POOL -> true;
default -> false;
};
}
public boolean isUnderground() {
return switch (this) {
case RIDGE_BORE, UNDERGROUND_POOL, UNDERGROUND_DROP, SINKHOLE, COASTAL_GROTTO, INLAND_GROTTO -> true;
default -> false;
};
}
public boolean isGrotto() {
return this == COASTAL_GROTTO || this == INLAND_GROTTO;
}
public boolean isDeepFluid() {
return this == DEEP_POOL || this == DEEP_CHANNEL;
}
public boolean isDrop() {
return this == RIFFLE || this == CASCADE || this == WATERFALL
|| this == UNDERGROUND_DROP || this == SINKHOLE;
}
int renderPriority() {
return switch (this) {
case WATERFALL -> 0;
case SINKHOLE -> 1;
case CASCADE -> 2;
case RIFFLE -> 3;
case COASTAL_GROTTO -> 4;
case INLAND_GROTTO -> 5;
case MOUTH -> 6;
case RIDGE_BORE -> 7;
case UNDERGROUND_DROP -> 8;
case UNDERGROUND_POOL -> 9;
case DEEP_CHANNEL -> 10;
case DEEP_POOL -> 11;
case SURFACE_POOL -> 12;
case STANDING_POOL -> 13;
};
}
}
@@ -0,0 +1,104 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
@FunctionalInterface
public interface HydrologyGeometrySampler {
int sample(Request request);
default int sample(
Field field,
String profileKey,
int x,
int z,
int minimum,
int maximum
) {
return sample(field, profileKey, x, z, 0L, minimum, maximum);
}
default int sample(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
Request request = new Request(field, profileKey, x, z, stableId, minimum, maximum);
int value = sample(request);
if (value < minimum || value > maximum) {
throw new IllegalStateException(
"Hydrology geometry sampler returned " + value + " outside " + minimum + ".." + maximum
+ " for " + field + " at " + x + "," + z + "."
);
}
return value;
}
static HydrologyGeometrySampler deterministic(HydrologyTerrainSampler terrainSampler) {
Objects.requireNonNull(terrainSampler, "terrainSampler");
return request -> {
if (request.field() == Field.UNDERGROUND_FLUID_LEVEL) {
HydrologyTerrainSample terrain = Objects.requireNonNull(
terrainSampler.sample(request.x(), request.z()),
"Hydrology terrain sampler returned null at " + request.x() + "," + request.z()
);
return Math.max(request.minimum(), Math.min(request.maximum(), terrain.caveFluidY()));
}
if (request.field() == Field.SURFACE_WIDTH
|| request.field() == Field.SURFACE_DEPTH
|| request.field() == Field.UNDERGROUND_WIDTH
|| request.field() == Field.UNDERGROUND_DEPTH) {
return request.minimum();
}
long stable = switch (request.field()) {
case SURFACE_BLEND_WIDTH -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x424c454e44L
);
case UNDERGROUND_HEADROOM -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x48454144L
);
case DEEP_FLUID_HEIGHT -> HydrologyHash.mix(request.stableId(), 0x484549474854L);
default -> throw new IllegalStateException("Missing deterministic geometry rule for " + request.field() + ".");
};
return HydrologyHash.between(stable, request.minimum(), request.maximum());
};
}
enum Field {
SURFACE_WIDTH,
SURFACE_DEPTH,
SURFACE_BLEND_WIDTH,
UNDERGROUND_FLUID_LEVEL,
UNDERGROUND_WIDTH,
UNDERGROUND_DEPTH,
UNDERGROUND_HEADROOM,
DEEP_FLUID_HEIGHT
}
record Request(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
public Request {
field = Objects.requireNonNull(field, "field");
profileKey = profileKey == null ? "" : profileKey;
if (minimum > maximum) {
throw new IllegalArgumentException("Hydrology geometry sample bounds are reversed.");
}
}
}
}
@@ -0,0 +1,43 @@
package art.arcane.iris.engine.hydrology;
public final class HydrologyHash {
private HydrologyHash() {
}
public static long mix(long seed, long... values) {
long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L);
for (long value : values) {
mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L));
}
return mixed;
}
public static long text(String value) {
long hash = 0xcbf29ce484222325L;
for (int index = 0; index < value.length(); index++) {
hash ^= value.charAt(index);
hash *= 0x100000001b3L;
}
return avalanche(hash);
}
public static double unit(long value) {
return (avalanche(value) >>> 11) * 0x1.0p-53;
}
public static int between(long value, int minimum, int maximum) {
if (minimum > maximum) {
throw new IllegalArgumentException("minimum cannot exceed maximum.");
}
long span = (long) maximum - minimum + 1L;
return minimum + (int) Long.remainderUnsigned(avalanche(value), span);
}
private static long avalanche(long value) {
value ^= value >>> 30;
value *= 0xbf58476d1ce4e5b9L;
value ^= value >>> 27;
value *= 0x94d049bb133111ebL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,5 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler {
HydrologyTerrainSample sampleBasis(int blockX, int blockZ);
}
@@ -0,0 +1,48 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxelViewFactory.PlannedSurface {
private final HydrologyCaveVoxelViewFactory.PlannedSurface delegate;
private final Long2ObjectOpenHashMap<ArrayList<Observation>> observations;
public HydrologyObservedPlannedSurface(HydrologyCaveVoxelViewFactory.PlannedSurface delegate) {
this.delegate = Objects.requireNonNull(delegate);
this.observations = new Long2ObjectOpenHashMap<>();
}
@Override
public int resolve(int x, int z, int naturalHeight) {
int resolvedHeight = delegate.resolve(x, z, naturalHeight);
long key = RiverFootprint.pack(x, z);
ArrayList<Observation> column = observations.get(key);
if (column == null) {
column = new ArrayList<>(1);
observations.put(key, column);
}
for (Observation observation : column) {
if (observation.naturalHeight() == naturalHeight) {
return resolvedHeight;
}
}
column.add(new Observation(x, z, naturalHeight, resolvedHeight));
return resolvedHeight;
}
public List<Observation> observationsAt(int x, int z) {
ArrayList<Observation> column = observations.get(RiverFootprint.pack(x, z));
return column == null ? List.of() : List.copyOf(column);
}
public record Observation(
int x,
int z,
int naturalHeight,
int resolvedHeight
) {
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
final class HydrologyOceanBoundaryRefiner {
private HydrologyOceanBoundaryRefiner() {
}
static Result refine(
List<HydrologyPoint> crossing,
HydrologyTerrainSampler terrainSampler,
HydrologyRoutingTerrainSampler routingSampler
) {
Objects.requireNonNull(crossing, "crossing");
Objects.requireNonNull(terrainSampler, "terrainSampler");
Objects.requireNonNull(routingSampler, "routingSampler");
if (crossing.size() < 2) {
return null;
}
HydrologyPoint previous = crossing.getFirst();
for (int index = 1; index < crossing.size(); index++) {
HydrologyPoint point = crossing.get(index);
HydrologyRoutingTerrainSampler.NaturalClassification classification =
routingSampler.classifyNatural(point.x(), point.z());
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE) {
return null;
}
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) {
HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z());
if (landwardTerrain == null || landwardTerrain.ocean()) {
return null;
}
return new Result(previous, point, landwardTerrain);
}
previous = point;
}
return null;
}
record Result(
HydrologyPoint landwardPoint,
HydrologyPoint oceanPoint,
HydrologyTerrainSample landwardTerrain
) {
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,586 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyPlannerSettings(
int seaLevel,
Routing routing,
Surface surface,
Hydraulics hydraulics,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
private static final long PLAN_FORMAT_REVISION = 6L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
public HydrologyPlannerSettings {
if (seaLevel < -2048 || seaLevel > 2048) {
throw new IllegalArgumentException("seaLevel is outside the supported planning range.");
}
if (routing == null || surface == null || hydraulics == null || underground == null
|| outlets == null || geometry == null) {
throw new IllegalArgumentException("Hydrology planner settings cannot contain null sections.");
}
deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids);
surfacePools = surfacePools == null ? List.of() : List.copyOf(surfacePools);
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
}
}
public static HydrologyPlannerSettings defaults() {
Source surfaceSources = new Source(true, 0.5D, 88, 0, 1, 384);
Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512);
return new HydrologyPlannerSettings(
63,
new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0),
new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()),
new Hydraulics(8),
new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true),
new Outlets(
true,
new Grotto(true, 18, 8, 8, 32768),
new Grotto(true, 18, 8, 8, 32768),
false,
12,
64,
8,
12
),
Geometry.defaults(),
List.of(),
List.of()
);
}
public long fingerprint() {
long hash = HydrologyHash.mix(
0x485944524f4c4f47L,
PLAN_FORMAT_REVISION,
routing.hashCode(),
surface.hashCode(),
hydraulics.hashCode(),
underground.hashCode(),
outlets.hashCode(),
geometry.hashCode()
);
for (DeepFluid deepFluid : deepFluids) {
hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id()));
}
for (SurfacePool pool : surfacePools) {
hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id()));
}
return hash;
}
public int publicationRadius() {
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
}
int crossTileColorPeriod() {
return crossTileColorPeriod(publicationRadius(), routing.tileSize());
}
private static int publicationRadius(
Routing routing,
Surface surface,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
int alignedHalo = Math.floorDiv(
Math.min(
routing.maximumRouteLength(),
Math.multiplyExact(routing.sampleSpacing(), 2)
),
routing.sampleSpacing()
) * routing.sampleSpacing();
int radius = 0;
int routeDisplacement = Math.multiplyExact(routing.sampleSpacing(), 3);
if (surface.enabled() && surface.sources().enabled()) {
int blendWidth = surface.banks().maximumBlendWidth();
int surfaceRadius = (int) StrictMath.ceil(
surface.maximumWidth() / 2D + surface.shoreWidth() + blendWidth
);
surfaceRadius = Math.max(
surfaceRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(surface.maximumWidth())
) / 2D + surface.shoreWidth() + blendWidth)
);
surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius());
surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(surfaceRadius, routeDisplacement)));
}
if (underground.enabled() && underground.sources().enabled()) {
int undergroundRadius = (int) StrictMath.ceil(underground.maximumWidth() / 2D);
undergroundRadius = Math.max(
undergroundRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(underground.maximumWidth())
) / 2D)
);
undergroundRadius = Math.max(undergroundRadius, outlets.coastalGrotto().horizontalRadius());
undergroundRadius = Math.max(undergroundRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(undergroundRadius, routeDisplacement)));
}
for (DeepFluid deepFluid : deepFluids) {
if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) {
continue;
}
int poolReach = deepFluid.containedPools() ? deepFluid.maximumHorizontalRadius() : 0;
int channelReach = deepFluid.shortChannels()
? Math.addExact(deepFluid.maximumChannelLength(), (int) StrictMath.ceil(deepFluid.channelWidth() / 2D))
: 0;
radius = Math.max(radius, Math.max(poolReach, channelReach));
}
for (SurfacePool pool : surfacePools) {
if (!pool.enabled()) {
continue;
}
radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth())
+ surface.banks().maximumBlendWidth());
}
return radius;
}
private static int crossTileColorPeriod(int publicationRadius, int tileSize) {
long actionReach = Math.addExact((long) publicationRadius, 1L);
return Math.toIntExact(Math.addExact(Math.floorDiv(Math.multiplyExact(2L, actionReach), tileSize), 2L));
}
public record Routing(
int tileSize,
int sampleSpacing,
int maximumRouteNodes,
int maximumRouteLength,
int minimumSurfaceCourseLength,
int minimumUndergroundCourseLength,
double valleyPreference,
double uphillPenalty,
double slopePenalty,
double confluenceAttraction,
double lengthPreference,
int tributaries
) {
public Routing {
if (tileSize < 32 || sampleSpacing < 4 || tileSize % sampleSpacing != 0) {
throw new IllegalArgumentException("Routing sizes must form an exact bounded tile lattice.");
}
int latticeWidth = tileSize / sampleSpacing + 1;
int halo = Math.min(maximumRouteLength, Math.multiplyExact(sampleSpacing, 2));
int alignedHalo = Math.floorDiv(halo, sampleSpacing) * sampleSpacing;
int expandedWidth = latticeWidth + alignedHalo * 2 / sampleSpacing;
long latticeNodes = (long) expandedWidth * expandedWidth;
if (maximumRouteNodes < latticeNodes || maximumRouteNodes > 1_000_000 || maximumRouteLength < 1) {
throw new IllegalArgumentException("maximumRouteNodes must contain the complete lattice and remain bounded.");
}
if (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768
|| minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) {
throw new IllegalArgumentException("Hydrology course lengths are invalid.");
}
if (minimumSurfaceCourseLength > maximumRouteLength
|| minimumUndergroundCourseLength > maximumRouteLength) {
throw new IllegalArgumentException("Minimum course lengths cannot exceed maximum route length.");
}
requireFiniteNonNegative(valleyPreference, "valleyPreference");
requireFiniteNonNegative(uphillPenalty, "uphillPenalty");
requireFiniteNonNegative(slopePenalty, "slopePenalty");
requireFiniteNonNegative(confluenceAttraction, "confluenceAttraction");
requireFiniteNonNegative(lengthPreference, "lengthPreference");
if (tributaries < 0) {
throw new IllegalArgumentException("tributaries must not be negative.");
}
}
// Route refinement is derived from the lattice, never authored.
public static int refinementSpacing(int sampleSpacing) {
if (sampleSpacing % 4 == 0) {
return 4;
}
return sampleSpacing % 2 == 0 ? 2 : 1;
}
public int refinementSpacing() {
return refinementSpacing(sampleSpacing);
}
public int minimumCourseLength(boolean surface) {
return surface ? minimumSurfaceCourseLength : minimumUndergroundCourseLength;
}
}
public record Source(
boolean enabled,
double density,
int minimumElevation,
int minimumPerTile,
int maximumPerTile,
int minimumSpacing
) {
public Source {
if (!Double.isFinite(density) || density < 0D || density > 64D) {
throw new IllegalArgumentException("Source density must be between 0 and 64 expected sources per tile.");
}
if (minimumPerTile < 0 || maximumPerTile < minimumPerTile || maximumPerTile > 4096) {
throw new IllegalArgumentException("Source tile quotas are invalid.");
}
if (minimumSpacing < 0) {
throw new IllegalArgumentException("Source spacing cannot be negative.");
}
}
}
public record Surface(
boolean enabled,
Source sources,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int maximumIncision,
double shoreWidth,
Banks banks
) {
public Surface {
if (sources == null || banks == null) {
throw new IllegalArgumentException("Surface source and bank settings are required.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "surface width");
requireRange(minimumDepth, maximumDepth, 1, 64, "surface depth");
if (maximumIncision < 0 || !Double.isFinite(shoreWidth) || shoreWidth < 0D || shoreWidth > 32D) {
throw new IllegalArgumentException("Surface incision and shore width are invalid.");
}
}
}
public record Banks(
int inset,
int freeboard,
double blendSlope,
int minimumBlendWidth,
int maximumBlendWidth,
double roughness,
int roughnessWavelength,
int cascadeRun,
int waterfallMinimumDrop,
double mouthFlareRatio,
double springWidthRatio,
int springLength,
boolean exposeCutStrata
) {
// Structural invariants only; authoring bounds live in the pack validator.
public Banks {
if (inset < 0 || freeboard < 0
|| !Double.isFinite(blendSlope) || blendSlope <= 0D
|| minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth
|| !Double.isFinite(roughness) || roughness < 0D || roughness > 1D
|| roughnessWavelength < 1
|| cascadeRun < 1
|| waterfallMinimumDrop < 1
|| !Double.isFinite(mouthFlareRatio) || mouthFlareRatio < 1D
|| !Double.isFinite(springWidthRatio) || springWidthRatio < 1D
|| springLength < 1) {
throw new IllegalArgumentException("Surface bank settings are invalid.");
}
}
public static Banks defaults() {
return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true);
}
}
/** A standing surface pool profile: a bowl of the given radius and depth, filled with its own fluid. */
public record SurfacePool(
String id,
boolean enabled,
double density,
int spacing,
int minimumRadius,
int maximumRadius,
int depth,
int maximumPerTile,
String biomeKey
) {
public SurfacePool {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Surface pool id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || spacing < 1
|| minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1 || maximumPerTile < 0) {
throw new IllegalArgumentException("Surface pool settings are invalid.");
}
biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey.trim();
}
}
// The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall.
public record Hydraulics(int waterfallMinimumDrop) {
public Hydraulics {
if (waterfallMinimumDrop < 1) {
throw new IllegalArgumentException("waterfallMinimumDrop must be at least one block.");
}
}
}
public record Underground(
boolean enabled,
Source sources,
int minimumFluidY,
int maximumFluidY,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int minimumHeadroom,
int maximumHeadroom,
boolean connectToExistingCaves
) {
public Underground {
if (sources == null || minimumFluidY > maximumFluidY) {
throw new IllegalArgumentException("Underground source and height settings are invalid.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "underground width");
requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth");
requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom");
}
}
public record Outlets(
boolean oceanEnabled,
Grotto coastalGrotto,
Grotto inlandGrotto,
boolean surfaceSinkholesEnabled,
int coastalCliffMinimumHeight,
int mouthLevelingDistance,
int maximumOceanApron,
int maximumPerTile
) {
public Outlets {
if (coastalGrotto == null || inlandGrotto == null) {
throw new IllegalArgumentException("Coastal and inland grotto settings are required.");
}
if (surfaceSinkholesEnabled && !inlandGrotto.enabled()) {
throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets.");
}
if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64
|| mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) {
throw new IllegalArgumentException("Outlet bounds are invalid.");
}
}
}
public record Grotto(
boolean enabled,
int horizontalRadius,
int verticalRadius,
int headroom,
int maximumVolume
) {
public Grotto {
if (horizontalRadius < 1 || horizontalRadius > 128 || verticalRadius < 1 || verticalRadius > 128
|| headroom < 1 || headroom > 128 || maximumVolume < 1 || maximumVolume > 1_048_576) {
throw new IllegalArgumentException("Grotto bounds are invalid.");
}
}
}
public record Geometry(
Meanders meanders,
ChannelShape surface,
ChannelShape underground,
ChannelShape grottos,
Drops drops
) {
public Geometry {
if (meanders == null || surface == null || underground == null || grottos == null || drops == null) {
throw new IllegalArgumentException("Hydrology geometry settings cannot contain null sections.");
}
}
public static Geometry defaults() {
ChannelShape channel = new ChannelShape(2.4D, 0.28D, 0.24D, 11);
return new Geometry(
new Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 82D),
channel,
channel,
channel,
new Drops(2, 1.4D, 2, 0.45D, 2, 1.8D, 8)
);
}
}
public record Meanders(
int primaryWavelength,
int detailWavelength,
double primaryStrength,
double detailStrength,
double maximumOffsetRatio,
int smoothingPasses,
double maximumTurnDegrees
) {
public Meanders {
if (primaryWavelength < 8 || primaryWavelength > 512
|| detailWavelength < 4 || detailWavelength > 128
|| detailWavelength > primaryWavelength
|| smoothingPasses < 0 || smoothingPasses > 4
|| !Double.isFinite(primaryStrength) || primaryStrength < 0D || primaryStrength > 2D
|| !Double.isFinite(detailStrength) || detailStrength < 0D || detailStrength > 2D
|| !Double.isFinite(maximumOffsetRatio) || maximumOffsetRatio < 0D || maximumOffsetRatio > 1D
|| !Double.isFinite(maximumTurnDegrees)
|| maximumTurnDegrees < 10D || maximumTurnDegrees > 150D) {
throw new IllegalArgumentException("Hydrology meander geometry is invalid.");
}
}
}
public record ChannelShape(
double bedRoundness,
double bedRoughness,
double wallRoughness,
int roughnessWavelength
) {
public ChannelShape {
if (!Double.isFinite(bedRoundness) || bedRoundness < 1D || bedRoundness > 6D
|| !Double.isFinite(bedRoughness) || bedRoughness < 0D || bedRoughness > 1D
|| !Double.isFinite(wallRoughness) || wallRoughness < 0D || wallRoughness > 1D
|| roughnessWavelength < 3 || roughnessWavelength > 128) {
throw new IllegalArgumentException("Hydrology channel shape is invalid.");
}
}
}
public record Drops(
int cascadeRunPerBlock,
double cascadeExponent,
int maximumCascadeStep,
double flowWidthRatio,
int maximumFlowDepth,
double basinWidthRatio,
int maximumBasinDepth
) {
public Drops {
if (cascadeRunPerBlock < 1 || cascadeRunPerBlock > 16
|| !Double.isFinite(cascadeExponent) || cascadeExponent < 0.25D || cascadeExponent > 6D
|| maximumCascadeStep < 1 || maximumCascadeStep > 4
|| !Double.isFinite(flowWidthRatio) || flowWidthRatio < 0.25D || flowWidthRatio > 1D
|| maximumFlowDepth < 1 || maximumFlowDepth > 16
|| !Double.isFinite(basinWidthRatio) || basinWidthRatio < 1D || basinWidthRatio > 4D
|| maximumBasinDepth < maximumFlowDepth || maximumBasinDepth > 32) {
throw new IllegalArgumentException("Hydrology drop geometry is invalid.");
}
}
public int flowWidth(int channelWidth) {
return Math.max(1, Math.min(channelWidth, (int) StrictMath.ceil(channelWidth * flowWidthRatio)));
}
public int stepLimit(HydrologyFeatureType type) {
return type.isSurface() ? 1 : maximumCascadeStep;
}
public int flowDepth(int channelDepth) {
return Math.max(1, Math.min(channelDepth, maximumFlowDepth));
}
public int basinWidth(int flowWidth) {
return Math.max(flowWidth, (int) StrictMath.ceil(flowWidth * basinWidthRatio));
}
public int basinDepth(int flowDepth, int drop) {
int erodedDepth = Math.addExact(flowDepth, Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(drop))));
return Math.max(flowDepth, Math.min(maximumBasinDepth, erodedDepth));
}
}
public record DeepFluid(
String id,
boolean enabled,
double density,
int spacing,
int minimumY,
int maximumY,
int minimumHorizontalRadius,
int maximumHorizontalRadius,
int minimumVerticalRadius,
int maximumVerticalRadius,
int minimumChannelLength,
int maximumChannelLength,
int channelWidth,
int channelDepth,
int headroom,
int maximumVolume,
int maximumPerTile,
boolean containedPools,
boolean shortChannels
) {
public DeepFluid {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Deep-fluid id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || density > 64D || spacing < 16 || minimumY > maximumY) {
throw new IllegalArgumentException("Deep-fluid density or height range is invalid.");
}
requireRange(minimumHorizontalRadius, maximumHorizontalRadius, 1, 128, "deep-fluid horizontal radius");
requireRange(minimumVerticalRadius, maximumVerticalRadius, 1, 128, "deep-fluid vertical radius");
requireRange(minimumChannelLength, maximumChannelLength, 0, 32_768, "deep-fluid channel length");
if (channelWidth < 1 || channelWidth > 256 || channelDepth < 1 || channelDepth > 64 || headroom < 1
|| maximumVolume < 64 || maximumVolume > 1_048_576
|| maximumPerTile < 0 || maximumPerTile > 1024) {
throw new IllegalArgumentException("Deep-fluid channel bounds are invalid.");
}
long poolEnvelope = ellipsoidVolume(
maximumHorizontalRadius,
Math.max(channelDepth, maximumVerticalRadius),
headroom
);
if (enabled && containedPools && poolEnvelope > maximumVolume) {
throw new IllegalArgumentException("Deep-fluid maximumVolume cannot contain its configured pool ellipsoid.");
}
if (enabled && !containedPools && !shortChannels) {
throw new IllegalArgumentException("An enabled deep fluid requires a pool or channel.");
}
if (enabled && shortChannels && minimumChannelLength < 1) {
throw new IllegalArgumentException("Enabled deep-fluid channels require a positive minimum length.");
}
}
}
private static void requireRange(int minimum, int maximum, int lower, int upper, String name) {
if (minimum < lower || maximum < minimum || maximum > upper) {
throw new IllegalArgumentException(name + " range is invalid.");
}
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
static long ellipsoidVolume(int radius, int lowerExtent, int upperExtent) {
long volume = 0L;
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
double distance = StrictMath.hypot(deltaX, deltaZ);
if (distance > radius + 0.25D) {
continue;
}
double normalized = Math.min(1D, distance / Math.max(1D, radius));
double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized));
int lower = (int) StrictMath.ceil(lowerExtent * scale);
int upper = (int) StrictMath.floor(upperExtent * scale);
volume += lower + upper + 1L;
}
}
return volume;
}
}
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyPoint(int x, int y, int z) {
public long packedColumn() {
return RiverFootprint.pack(x, z);
}
public long distanceSquared2D(HydrologyPoint other) {
long deltaX = (long) x - other.x;
long deltaZ = (long) z - other.z;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -0,0 +1,44 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public record HydrologyRenderSample(int x, int z, List<HydrologyFeatureRef> features) {
private static final Comparator<HydrologyFeatureRef> FEATURE_ORDER = Comparator
.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id);
public HydrologyRenderSample {
Objects.requireNonNull(features, "features");
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(features);
ordered.sort(FEATURE_ORDER);
Map<Long, HydrologyFeatureRef> unique = new LinkedHashMap<>();
for (HydrologyFeatureRef feature : ordered) {
unique.putIfAbsent(feature.id(), feature);
}
features = List.copyOf(unique.values());
}
public boolean present() {
return !features.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyFeatureRef feature : features) {
if (feature.type() == type) {
return true;
}
}
return false;
}
public Optional<HydrologyFeatureRef> primaryFeature() {
return features.isEmpty() ? Optional.empty() : Optional.of(features.getFirst());
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyRoutingTerrainSampler {
HydrologyTerrainSample[] sampleGrid(GridRequest request);
NaturalClassification classifyNatural(int blockX, int blockZ);
enum NaturalClassification {
LAND,
OCEAN,
UNAVAILABLE
}
record GridRequest(int minimumX, int minimumZ, int width, int spacing) {
public GridRequest {
if (width < 1 || spacing < 1) {
throw new IllegalArgumentException("Hydrology routing grid dimensions must be positive");
}
}
}
}
@@ -0,0 +1,107 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
final class HydrologyTerrainCaveVoxelView implements CaveVoxelView {
private final HydrologyTerrainSampler sampler;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> samples;
private final int seaLevel;
private final int minimumY;
private final int maximumY;
HydrologyTerrainCaveVoxelView(HydrologyTerrainSampler sampler, int seaLevel, int worldHeight) {
this(sampler, seaLevel, 0, worldHeight);
}
HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY
) {
this(sampler, seaLevel, minimumY, maximumY, new Long2ObjectOpenHashMap<>());
}
private HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY,
Long2ObjectOpenHashMap<HydrologyTerrainSample> samples
) {
this.sampler = Objects.requireNonNull(sampler);
this.samples = Objects.requireNonNull(samples);
this.seaLevel = seaLevel;
if (maximumY - minimumY < 3) {
throw new IllegalArgumentException("The cave view requires at least three vertical blocks");
}
this.minimumY = minimumY;
this.maximumY = maximumY;
}
HydrologyTerrainCaveVoxelView withSampleCache(
Long2ObjectOpenHashMap<HydrologyTerrainSample> sampleCache
) {
return new HydrologyTerrainCaveVoxelView(
sampler,
seaLevel,
minimumY,
maximumY,
sampleCache
);
}
@Override
public boolean isInWorld(CavePosition position) {
return position.y() > minimumY && position.y() < maximumY - 1;
}
@Override
public CaveVoxel voxelAt(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
if (position.y() <= terrain.naturalHeight()) {
return CaveVoxel.SOLID;
}
if (terrain.ocean() && position.y() <= seaLevel) {
return CaveVoxel.COMPATIBLE_FLUID;
}
return CaveVoxel.CAVE_AIR;
}
@Override
public boolean isOpenToSurface(CavePosition position) {
return isAboveTerrainSurface(position);
}
@Override
public boolean isAboveTerrainSurface(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
return position.y() > terrain.naturalHeight();
}
@Override
public boolean hasAboveTerrainSurface(int x, int z, int requestedMinimumY, int requestedMaximumY) {
int firstY = Math.max(requestedMinimumY, minimumY + 1);
int lastY = Math.min(requestedMaximumY, maximumY - 2);
return firstY <= lastY && lastY > sample(x, z).naturalHeight();
}
private HydrologyTerrainSample sample(int x, int z) {
long key = (long) x << 32 ^ z & 0xffffffffL;
HydrologyTerrainSample cached = samples.get(key);
if (cached != null) {
return cached;
}
HydrologyTerrainSample sampled = Objects.requireNonNull(
sampler.sample(x, z),
"Hydrology terrain sampler returned null at " + x + "," + z
);
samples.put(key, sampled);
return sampled;
}
}
@@ -0,0 +1,235 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyTerrainSample(
int naturalHeight,
double slope,
boolean ocean,
boolean caveAvailable,
int caveFloorY,
int caveFluidY,
boolean transitAllowed,
boolean outletAllowed,
boolean surfaceSourceAllowed,
boolean surfaceSourceRequired,
boolean undergroundSourceAllowed,
boolean undergroundSourceRequired,
double routingCost,
double surfaceSourceWeight,
double undergroundSourceWeight,
double widthMultiplier,
double depthMultiplier,
double incisionMultiplier,
double routingMultiplier,
double bankMultiplier,
String parentBiomeKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey,
List<String> preferredProfileKeys,
List<String> surfacePoolKeys
) {
public HydrologyTerrainSample {
requireFiniteNonNegative(slope, "slope");
requireFiniteNonNegative(routingCost, "routingCost");
requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight");
requireFiniteNonNegative(undergroundSourceWeight, "undergroundSourceWeight");
requireFinitePositive(widthMultiplier, "widthMultiplier");
requireFinitePositive(depthMultiplier, "depthMultiplier");
requireFiniteNonNegative(incisionMultiplier, "incisionMultiplier");
requireFiniteNonNegative(routingMultiplier, "routingMultiplier");
requireFinitePositive(bankMultiplier, "bankMultiplier");
if (caveAvailable && (caveFluidY <= caveFloorY || caveFluidY >= naturalHeight)) {
throw new IllegalArgumentException("Available cave fluid must be above its floor and below natural terrain.");
}
parentBiomeKey = normalizeKey(parentBiomeKey, "parent");
surfaceBiomeKey = normalizeKey(surfaceBiomeKey, parentBiomeKey);
mouthBiomeKey = normalizeKey(mouthBiomeKey, surfaceBiomeKey);
shoreBiomeKey = normalizeKey(shoreBiomeKey, parentBiomeKey);
bankBiomeKey = normalizeKey(bankBiomeKey, parentBiomeKey);
floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey);
preferredProfileKeys = normalizeProfiles(preferredProfileKeys);
surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys);
}
public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
false,
false,
naturalHeight - 32,
naturalHeight - 30,
true,
true,
true,
false,
false,
false,
0D,
1D,
1D,
1D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
);
}
public static HydrologyTerrainSample ocean(int naturalHeight, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
0D,
true,
false,
naturalHeight - 32,
naturalHeight - 30,
false,
false,
false,
false,
false,
false,
0D,
0D,
0D,
1D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default"),
List.of()
);
}
public HydrologyTerrainSample withSurfacePoolKeys(List<String> replacementPoolKeys) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
replacementPoolKeys
);
}
public HydrologyTerrainSample withSlope(double replacementSlope) {
return new HydrologyTerrainSample(
naturalHeight,
replacementSlope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
surfacePoolKeys
);
}
private static List<String> normalizeProfiles(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of("default");
}
if (profilesNormalized(values)) {
return List.copyOf(values);
}
List<String> normalized = values.stream()
.filter((String value) -> value != null && !value.isBlank())
.map(String::trim)
.distinct()
.sorted()
.toList();
return normalized.isEmpty() ? List.of("default") : normalized;
}
private static boolean profilesNormalized(List<String> values) {
String previous = null;
for (String value : values) {
if (value == null
|| value.isBlank()
|| !value.equals(value.trim())
|| previous != null && previous.compareTo(value) >= 0) {
return false;
}
previous = value;
}
return true;
}
private static String normalizeKey(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
private static void requireFinitePositive(double value, String name) {
if (!Double.isFinite(value) || value <= 0D) {
throw new IllegalArgumentException(name + " must be finite and positive.");
}
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology;
@FunctionalInterface
public interface HydrologyTerrainSampler {
HydrologyTerrainSample sample(int blockX, int blockZ);
}
@@ -0,0 +1,618 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class HydrologyTile {
private final HydrologyTileKey key;
private final long worldSeed;
private final long settingsFingerprint;
private final int tileSize;
private final List<DrainageNode> nodes;
private final List<DrainageEdge> edges;
private final List<RiverOutlet> outlets;
private final List<RiverCourse> courses;
private final List<HydrologyCavePlan> cavePlans;
private final List<HydrologyDiagnosticCandidate> diagnosticCandidates;
private final RiverFootprint footprint;
private final Map<Long, DrainageNode> nodesById;
private final Map<Long, RiverOutlet> outletsById;
private final Map<Long, HydrologyCavePlan> cavePlansByCourseId;
private final List<HydrologyFeatureRef> features;
public HydrologyTile(
HydrologyTileKey key,
long worldSeed,
long settingsFingerprint,
int tileSize,
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
List<RiverCourse> courses,
List<HydrologyCavePlan> cavePlans,
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
RiverFootprint footprint
) {
this.key = Objects.requireNonNull(key, "key");
if (tileSize <= 0) {
throw new IllegalArgumentException("tileSize must be positive.");
}
this.worldSeed = worldSeed;
this.settingsFingerprint = settingsFingerprint;
this.tileSize = tileSize;
this.nodes = sortedCopy(nodes, Comparator.comparingLong(DrainageNode::id));
this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id));
this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id));
this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id));
this.cavePlans = sortedCopy(
cavePlans,
Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId())
);
this.diagnosticCandidates = sortedCopy(
diagnosticCandidates,
Comparator.comparingLong(HydrologyDiagnosticCandidate::id)
);
this.footprint = Objects.requireNonNull(footprint, "footprint");
this.nodesById = indexNodes(this.nodes);
this.outletsById = indexOutlets(this.outlets);
this.cavePlansByCourseId = indexCavePlans(this.cavePlans);
validateGraph();
validateCourses();
this.features = collectFeatures(footprint);
}
public HydrologyTileKey key() {
return key;
}
public long worldSeed() {
return worldSeed;
}
public long settingsFingerprint() {
return settingsFingerprint;
}
public int tileSize() {
return tileSize;
}
public List<DrainageNode> nodes() {
return nodes;
}
public List<DrainageEdge> edges() {
return edges;
}
public List<RiverOutlet> outlets() {
return outlets;
}
public List<RiverCourse> courses() {
return courses;
}
public List<HydrologyCavePlan> cavePlans() {
return cavePlans;
}
public List<HydrologyDiagnosticCandidate> diagnosticCandidates() {
return diagnosticCandidates;
}
public RiverFootprint footprint() {
return footprint;
}
public Optional<DrainageNode> node(long id) {
return Optional.ofNullable(nodesById.get(id));
}
public Optional<RiverOutlet> outlet(long id) {
return Optional.ofNullable(outletsById.get(id));
}
public Optional<HydrologyCavePlan> cavePlan(long courseId) {
return Optional.ofNullable(cavePlansByCourseId.get(courseId));
}
public Optional<HydrologyColumnSample> columnAt(int x, int z) {
return footprint.sample(x, z);
}
public HydrologyRenderSample renderAt(int x, int z) {
return footprint.renderSample(x, z);
}
public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) {
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
ArrayList<HydrologyDiagnosticCandidate> selected = new ArrayList<>();
for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) {
long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z));
if (distance <= maximumDistanceSquared) {
selected.add(candidate);
}
}
return new HydrologyDiagnosticRenderSample(x, z, selected);
}
public List<HydrologyFeatureRef> features() {
return features;
}
public Optional<HydrologyFeatureRef> nearestFeature(
HydrologyFeatureType type,
int x,
int z,
int maximumDistance
) {
return nearestFeature(Set.of(Objects.requireNonNull(type, "type")), null, x, z, maximumDistance);
}
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance
) {
Objects.requireNonNull(types, "types");
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
HydrologyFeatureRef nearest = null;
long nearestDistance = Long.MAX_VALUE;
for (RiverCourse course : courses) {
if (profileKey != null && !profileKey.isBlank() && !profileKey.equals(course.profileKey())) {
continue;
}
for (HydraulicSegment segment : course.segments()) {
if (!types.contains(segment.type())) {
continue;
}
for (HydrologyPoint point : segment.centerline()) {
long deltaX = (long) point.x() - x;
long deltaZ = (long) point.z() - z;
long distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance > maximumDistanceSquared || distance > nearestDistance) {
continue;
}
HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point);
if (feature == null) {
continue;
}
if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) {
nearest = feature;
nearestDistance = distance;
}
}
}
}
return Optional.ofNullable(nearest);
}
public boolean acyclic() {
for (DrainageEdge edge : edges) {
DrainageNode upstream = nodesById.get(edge.upstreamNodeId());
DrainageNode downstream = nodesById.get(edge.downstreamNodeId());
if (upstream == null || downstream == null || downstream.potential() >= upstream.potential()) {
return false;
}
}
return true;
}
private void validateGraph() {
if (!acyclic()) {
throw new IllegalArgumentException("Drainage edges must strictly lower potential.");
}
for (DrainageNode node : nodes) {
if (!outletsById.containsKey(node.outletId())) {
throw new IllegalArgumentException("Every drainage node must resolve to an accepted outlet.");
}
}
}
private void validateCourses() {
LinkedHashMap<Long, RiverCourse> coursesById = new LinkedHashMap<>();
for (RiverCourse course : courses) {
if (coursesById.put(course.id(), course) != null) {
throw new IllegalArgumentException("Duplicate river course id: " + course.id());
}
if (!course.hydraulicallyNonRising()) {
throw new IllegalArgumentException("Course heads must be non-rising downstream.");
}
if (course.sourceNodeId().isPresent() && !nodesById.containsKey(course.sourceNodeId().getAsLong())) {
throw new IllegalArgumentException("Course source is not part of the accepted drainage graph.");
}
if (course.outletId().isPresent() && !outletsById.containsKey(course.outletId().getAsLong())) {
throw new IllegalArgumentException("Course outlet is not accepted by this tile.");
}
if (isCaveBearing(course) != cavePlansByCourseId.containsKey(course.id())) {
throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan.");
}
}
validateFootprintCourses(coursesById);
}
private void validateFootprintCourses(Map<Long, RiverCourse> coursesById) {
LinkedHashMap<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions = new LinkedHashMap<>();
for (HydrologyColumnSample sample : footprint.columns().values()) {
ArrayList<Long> caveCourseIds = new ArrayList<>();
for (HydrologyColumnLayer layer : sample.layers()) {
long courseId = layer.feature().courseId();
if (!coursesById.containsKey(courseId)) {
throw new IllegalArgumentException("Footprint references an unaccepted river course: " + courseId);
}
if (layer.oceanApron() || !layer.channel() || !layer.terrainOwned()
|| !isCaveLayer(layer)) {
continue;
}
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.computeIfAbsent(
courseId,
(Long ignored) -> new HashMap<>()
);
if (!caveCourseIds.contains(courseId)) {
caveCourseIds.add(courseId);
}
for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) {
CavePosition position = new CavePosition(sample.x(), y, sample.z());
HydrologyCaveAction action = actionAt(layer, y);
HydrologyCaveAction existing = courseActions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
courseActions.put(position, action);
}
}
}
composeSurfaceActions(sample, caveCourseIds, coursesById, expectedActions);
}
HashSet<Long> validatedCourseIds = new HashSet<>();
for (RiverCourse course : courses) {
if (!isCaveBearing(course)) {
continue;
}
if (!validatedCourseIds.add(course.id())) {
continue;
}
List<RiverCourse> sharedCourses = sharedOutletCaveCourses(course);
HashMap<CavePosition, HydrologyCaveAction> expected = new HashMap<>();
HashMap<CavePosition, HydrologyCaveAction> actual = new HashMap<>();
for (RiverCourse sharedCourse : sharedCourses) {
validatedCourseIds.add(sharedCourse.id());
mergeCaveActions(
expected,
expectedActions.getOrDefault(sharedCourse.id(), Map.of())
);
HydrologyCavePlan plan = cavePlansByCourseId.get(sharedCourse.id());
plan.forEachAction((CavePosition position, HydrologyCaveAction action) -> {
if (action != HydrologyCaveAction.SEAL_GUARD) {
mergeCaveAction(actual, position, action);
}
});
}
if (!actual.equals(expected)) {
throw caveFootprintMismatch(course.id(), expected, actual);
}
}
}
private List<RiverCourse> sharedOutletCaveCourses(RiverCourse course) {
if (course.outletId().isEmpty()) {
return List.of(course);
}
long outletId = course.outletId().getAsLong();
ArrayList<RiverCourse> shared = new ArrayList<>();
for (RiverCourse candidate : courses) {
if (isCaveBearing(candidate)
&& candidate.outletId().isPresent()
&& candidate.outletId().getAsLong() == outletId
&& candidate.profileKey().equals(course.profileKey())) {
shared.add(candidate);
}
}
return List.copyOf(shared);
}
private void mergeCaveActions(
Map<CavePosition, HydrologyCaveAction> target,
Map<CavePosition, HydrologyCaveAction> source
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : source.entrySet()) {
mergeCaveAction(target, entry.getKey(), entry.getValue());
}
}
private void mergeCaveAction(
Map<CavePosition, HydrologyCaveAction> actions,
CavePosition position,
HydrologyCaveAction action
) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
}
private IllegalArgumentException caveFootprintMismatch(
long courseId,
Map<CavePosition, HydrologyCaveAction> expected,
Map<CavePosition, HydrologyCaveAction> actual
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : expected.entrySet()) {
HydrologyCaveAction actualAction = actual.get(entry.getKey());
if (actualAction != entry.getValue()) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " at " + entry.getKey() + ", footprint=" + entry.getValue()
+ ", plan=" + actualAction + ".");
}
}
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : actual.entrySet()) {
if (!expected.containsKey(entry.getKey())) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " has extra plan action " + entry.getValue() + " at " + entry.getKey() + ".");
}
}
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + ".");
}
private void composeSurfaceActions(
HydrologyColumnSample sample,
List<Long> caveCourseIds,
Map<Long, RiverCourse> coursesById,
Map<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions
) {
if (caveCourseIds.isEmpty()) {
return;
}
int minimumY = Integer.MAX_VALUE;
int maximumY = Integer.MIN_VALUE;
for (HydrologyColumnLayer layer : sample.layers()) {
if (!layer.publishesSurfaceFluid()) {
continue;
}
minimumY = Math.min(minimumY, layer.bedY() + 1);
maximumY = Math.max(maximumY, layer.fluidHeadY());
}
for (int y = minimumY; y <= maximumY; y++) {
HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample
.surfacePublicationCellAt(y)
.orElse(null);
if (surfaceCell == null) {
continue;
}
CavePosition position = new CavePosition(sample.x(), y, sample.z());
for (long courseId : caveCourseIds) {
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.get(courseId);
if (!courseActions.containsKey(position)) {
continue;
}
RiverCourse course = coursesById.get(courseId);
if (!course.profileKey().equals(surfaceCell.layer().profileKey())) {
throw new IllegalArgumentException("Surface and cave hydrology profiles conflict at "
+ position + ".");
}
courseActions.put(position, surfaceCell.action());
}
}
}
private boolean isCaveBearing(RiverCourse course) {
for (HydraulicSegment segment : course.segments()) {
if (segment.type().isUnderground() || segment.type().isDeepFluid()) {
return true;
}
}
return false;
}
private boolean isCaveLayer(HydrologyColumnLayer layer) {
return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid();
}
private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) {
if (y > layer.fluidHeadY()) {
return HydrologyCaveAction.DRY_AIR;
}
if (layer.fallingFluid() && y < layer.fluidHeadY()) {
return HydrologyCaveAction.FALLING_FLUID;
}
return HydrologyCaveAction.WET_SOURCE;
}
private int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
private static Map<Long, DrainageNode> indexNodes(List<DrainageNode> values) {
LinkedHashMap<Long, DrainageNode> indexed = new LinkedHashMap<>();
for (DrainageNode value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate drainage node id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, RiverOutlet> indexOutlets(List<RiverOutlet> values) {
LinkedHashMap<Long, RiverOutlet> indexed = new LinkedHashMap<>();
for (RiverOutlet value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate river outlet id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, HydrologyCavePlan> indexCavePlans(List<HydrologyCavePlan> values) {
LinkedHashMap<Long, HydrologyCavePlan> indexed = new LinkedHashMap<>();
for (HydrologyCavePlan value : values) {
if (!value.accepted()) {
throw new IllegalArgumentException("Hydrology tiles cannot contain rejected cave plans.");
}
long courseId = value.source().sourceId();
if (indexed.put(courseId, value) != null) {
throw new IllegalArgumentException("Duplicate cave plan for course: " + courseId);
}
}
return Collections.unmodifiableMap(indexed);
}
private HydrologyFeatureRef publishedCenterlineFeature(
HydraulicSegment segment,
HydrologyPoint point
) {
HydrologyColumnSample column = footprint.columns().get(RiverFootprint.pack(point.x(), point.z()));
if (column == null) {
return null;
}
for (HydrologyColumnLayer layer : column.layers()) {
HydrologyFeatureRef feature = layer.feature();
if (layer.channel()
&& feature.segmentId() == segment.id()
&& feature.type() == segment.type()) {
if (feature.x() == point.x() && feature.z() == point.z()) {
return feature;
}
return new HydrologyFeatureRef(
feature.id(),
feature.type(),
feature.courseId(),
feature.segmentId(),
point.x(),
layer.fluidHeadY(),
point.z(),
feature.flowDeltaX(),
feature.flowDeltaZ(),
feature.source()
);
}
}
return null;
}
static List<HydrologyFeatureRef> collectFeatures(RiverFootprint footprint) {
HashMap<Long, HydrologyColumnLayer> representatives = new HashMap<>();
for (HydrologyColumnSample column : footprint.columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
long segmentId = layer.feature().segmentId();
HydrologyColumnLayer existing = representatives.get(segmentId);
if (existing == null || featureOrder(layer, existing) < 0) {
representatives.put(segmentId, layer);
}
}
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(representatives.size());
for (HydrologyColumnLayer layer : representatives.values()) {
ordered.add(layer.feature());
}
ordered.sort(Comparator.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id));
return List.copyOf(ordered);
}
private static int featureOrder(HydrologyColumnLayer first, HydrologyColumnLayer second) {
int role = Integer.compare(featureRole(first), featureRole(second));
if (role != 0) {
return role;
}
HydrologyFeatureRef firstFeature = first.feature();
HydrologyFeatureRef secondFeature = second.feature();
int y = Integer.compare(firstFeature.y(), secondFeature.y());
if (y != 0) {
return y;
}
int x = Integer.compare(firstFeature.x(), secondFeature.x());
return x != 0 ? x : Integer.compare(firstFeature.z(), secondFeature.z());
}
private static int featureRole(HydrologyColumnLayer layer) {
if (layer.feature().source()) {
return 0;
}
if (layer.fallingFluid()) {
return 1;
}
if (layer.receivingPool()) {
return 2;
}
if (layer.channel() && !layer.oceanApron()) {
return 3;
}
if (layer.channel()) {
return 4;
}
return 5;
}
private static <T> List<T> sortedCopy(List<T> values, Comparator<T> comparator) {
Objects.requireNonNull(values, "values");
ArrayList<T> ordered = new ArrayList<>(values);
ordered.sort(comparator);
return List.copyOf(ordered);
}
@Override
public boolean equals(Object other) {
if (!(other instanceof HydrologyTile tile)) {
return false;
}
return worldSeed == tile.worldSeed
&& settingsFingerprint == tile.settingsFingerprint
&& tileSize == tile.tileSize
&& key.equals(tile.key)
&& nodes.equals(tile.nodes)
&& edges.equals(tile.edges)
&& outlets.equals(tile.outlets)
&& courses.equals(tile.courses)
&& cavePlans.equals(tile.cavePlans)
&& diagnosticCandidates.equals(tile.diagnosticCandidates)
&& footprint.equals(tile.footprint);
}
@Override
public int hashCode() {
return Objects.hash(
key,
worldSeed,
settingsFingerprint,
tileSize,
nodes,
edges,
outlets,
courses,
cavePlans,
diagnosticCandidates,
footprint
);
}
@Override
public String toString() {
return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates="
+ diagnosticCandidates.size() + ", columns=" + footprint.size() + "]";
}
}
@@ -0,0 +1,267 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicLong;
public final class HydrologyTileCache implements AutoCloseable {
private static final int DEFAULT_MAXIMUM_ENTRIES = 64;
private static final int MAXIMUM_COMPOSED_CHUNKS = 256;
private static final int CHUNK_SIZE = 16;
private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE;
private final HydrologyPlanner planner;
private final int maximumEntries;
private final Cache<HydrologyTileKey, HydrologyTile> tiles;
private final Cache<Long, ChunkColumns> composedChunks;
private final AtomicLong cacheEpoch;
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES);
}
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) {
this.planner = Objects.requireNonNull(planner, "planner");
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive.");
}
this.maximumEntries = maximumEntries;
this.tiles = Caffeine.newBuilder()
.maximumSize(maximumEntries)
.build();
this.composedChunks = Caffeine.newBuilder()
.maximumSize(MAXIMUM_COMPOSED_CHUNKS)
.build();
this.cacheEpoch = new AtomicLong();
this.localChunkColumns = new ThreadLocal<>();
}
public HydrologyTile get(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
return tiles.get(key, planner::plan);
}
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
}
public void prepareChunkColumns(int blockX, int blockZ) {
chunkColumns(blockX, blockZ);
}
public HydrologyRenderSample renderAt(int blockX, int blockZ) {
return columnAt(blockX, blockZ)
.map(HydrologyColumnSample::renderSample)
.orElseGet(() -> new HydrologyRenderSample(blockX, blockZ, List.of()));
}
public void invalidate(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
clear();
}
public void clear() {
cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts();
tiles.invalidateAll();
composedChunks.invalidateAll();
localChunkColumns.remove();
}
public int size() {
tiles.cleanUp();
return Math.toIntExact(tiles.estimatedSize());
}
public int maximumEntries() {
return maximumEntries;
}
@Override
public void close() {
clear();
}
private ChunkColumns chunkColumns(int blockX, int blockZ) {
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
long epoch = cacheEpoch.get();
LocalChunkColumns local = localChunkColumns.get();
if (local != null && local.epoch() == epoch
&& local.columns().chunkX() == chunkX
&& local.columns().chunkZ() == chunkZ) {
return local.columns();
}
long packedChunk = RiverFootprint.pack(chunkX, chunkZ);
ChunkColumns columns = composedChunks.get(
packedChunk,
ignored -> composeChunkColumns(chunkX, chunkZ)
);
localChunkColumns.set(new LocalChunkColumns(epoch, columns));
return columns;
}
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) {
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize);
ArrayList<HydrologyTileKey> relevantKeys = new ArrayList<>(
Math.multiplyExact(maximumTileX - minimumTileX + 1, maximumTileZ - minimumTileZ + 1)
);
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
relevantKeys.add(new HydrologyTileKey(tileX, tileZ));
}
}
List<HydrologyTile> relevantTiles = loadTiles(relevantKeys);
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
int blockX = minimumBlockX + localX;
int blockZ = minimumBlockZ + localZ;
columns[localZ * CHUNK_SIZE + localX] = composeColumn(
blockX,
blockZ,
relevantKeys,
relevantTiles
)
.orElse(null);
}
}
return new ChunkColumns(chunkX, chunkZ, columns);
}
private List<HydrologyTile> loadTiles(List<HydrologyTileKey> keys) {
if (keys.size() < 2
|| !IrisPlatforms.isBound()) {
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
loaded.add(get(key));
}
return List.copyOf(loaded);
}
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
futures.add(CompletableFuture.supplyAsync(() -> get(key), MultiBurst.burst));
}
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (CompletableFuture<HydrologyTile> future : futures) {
loaded.add(awaitTile(future));
}
return List.copyOf(loaded);
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
try {
return future.join();
} catch (CompletionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException("Hydrology tile planning failed.", cause);
}
}
private Optional<HydrologyColumnSample> composeColumn(
int blockX,
int blockZ,
List<HydrologyTileKey> relevantKeys,
List<HydrologyTile> relevantTiles
) {
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>();
HydrologyColumnSample template = null;
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) blockX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) blockX + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) blockZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) blockZ + publicationRadius, tileSize);
for (int tileIndex = 0; tileIndex < relevantTiles.size(); tileIndex++) {
HydrologyTileKey key = relevantKeys.get(tileIndex);
if (key.tileX() < minimumTileX || key.tileX() > maximumTileX
|| key.tileZ() < minimumTileZ || key.tileZ() > maximumTileZ) {
continue;
}
HydrologyTile tile = relevantTiles.get(tileIndex);
Optional<HydrologyColumnSample> candidate = tile.columnAt(blockX, blockZ);
if (candidate.isEmpty()) {
continue;
}
HydrologyColumnSample sample = candidate.get();
if (template == null) {
template = sample;
} else {
requireMatchingTerrain(template, sample);
}
layers.addAll(sample.layers());
}
if (template == null) {
return Optional.empty();
}
LinkedHashMap<Long, HydrologyColumnLayer> unique = new LinkedHashMap<>();
for (HydrologyColumnLayer layer : layers) {
HydrologyColumnLayer existing = unique.putIfAbsent(layer.feature().id(), layer);
if (existing != null && !existing.equals(layer)) {
throw new IllegalStateException("Hydrology feature id collision at " + blockX + "," + blockZ + ".");
}
}
return Optional.of(new HydrologyColumnSample(
blockX,
blockZ,
template.naturalHeight(),
template.seaLevel(),
template.ocean(),
template.parentBiomeKey(),
new ArrayList<>(unique.values())
));
}
private static int tileCoordinate(long blockCoordinate, int tileSize) {
return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize));
}
private static void requireMatchingTerrain(
HydrologyColumnSample expected,
HydrologyColumnSample actual
) {
if (expected.naturalHeight() != actual.naturalHeight()
|| expected.seaLevel() != actual.seaLevel()
|| expected.ocean() != actual.ocean()
|| !expected.parentBiomeKey().equals(actual.parentBiomeKey())) {
throw new IllegalStateException(
"Hydrology plans disagree on terrain metadata at " + expected.x() + "," + expected.z() + "."
);
}
}
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
private Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
int localX = Math.floorMod(blockX, CHUNK_SIZE);
int localZ = Math.floorMod(blockZ, CHUNK_SIZE);
return Optional.ofNullable(columns[localZ * CHUNK_SIZE + localX]);
}
}
private record LocalChunkColumns(long epoch, ChunkColumns columns) {
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyTileKey(int tileX, int tileZ) implements Comparable<HydrologyTileKey> {
public static HydrologyTileKey fromBlock(int blockX, int blockZ, int tileSize) {
if (tileSize <= 0) {
throw new IllegalArgumentException("Tile size must be positive.");
}
return new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize));
}
public int minimumBlockX(int tileSize) {
return Math.multiplyExact(tileX, tileSize);
}
public int minimumBlockZ(int tileSize) {
return Math.multiplyExact(tileZ, tileSize);
}
public boolean contains(int blockX, int blockZ, int tileSize) {
int minimumX = minimumBlockX(tileSize);
int minimumZ = minimumBlockZ(tileSize);
return blockX >= minimumX && blockX < minimumX + tileSize
&& blockZ >= minimumZ && blockZ < minimumZ + tileSize;
}
@Override
public int compareTo(HydrologyTileKey other) {
int xComparison = Integer.compare(tileX, other.tileX);
return xComparison != 0 ? xComparison : Integer.compare(tileZ, other.tileZ);
}
}
@@ -0,0 +1,108 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
public record RiverCourse(
long id,
RiverCourseType type,
OptionalLong sourceNodeId,
OptionalLong outletId,
String profileKey,
int discharge,
List<DrainageEdge> drainageEdges,
List<HydraulicSegment> segments
) {
public RiverCourse {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(sourceNodeId, "sourceNodeId");
Objects.requireNonNull(outletId, "outletId");
Objects.requireNonNull(drainageEdges, "drainageEdges");
Objects.requireNonNull(segments, "segments");
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank.");
}
profileKey = profileKey.trim();
if (discharge <= 0) {
throw new IllegalArgumentException("Course discharge must be positive.");
}
drainageEdges = List.copyOf(drainageEdges);
segments = List.copyOf(segments);
if (segments.isEmpty()) {
throw new IllegalArgumentException("A river course requires at least one hydraulic segment.");
}
for (HydraulicSegment segment : segments) {
if (segment.courseId() != id) {
throw new IllegalArgumentException("Every segment must belong to its containing course.");
}
}
validateSinkholeContinuation(type, segments);
boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL;
if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets.");
}
if (!independent && (sourceNodeId.isEmpty() || outletId.isEmpty())) {
throw new IllegalArgumentException("Surface and underground courses require a source and outlet.");
}
}
public boolean hydraulicallyNonRising() {
int previousHead = Integer.MAX_VALUE;
for (HydraulicSegment segment : segments) {
if (segment.upstreamHeadY() > previousHead || segment.downstreamHeadY() > segment.upstreamHeadY()) {
return false;
}
previousHead = segment.downstreamHeadY();
}
return true;
}
public boolean surfaceSinkholeContinuation() {
return type == RiverCourseType.SURFACE
&& segments.size() >= 2
&& segments.get(segments.size() - 2).type() == HydrologyFeatureType.SINKHOLE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
}
private static void validateSinkholeContinuation(
RiverCourseType courseType,
List<HydraulicSegment> segments
) {
int sinkholeCount = 0;
for (HydraulicSegment segment : segments) {
if (segment.type() == HydrologyFeatureType.SINKHOLE) {
sinkholeCount++;
}
}
boolean terminalInlandGrotto = courseType == RiverCourseType.SURFACE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
if (!terminalInlandGrotto) {
if (sinkholeCount > 0) {
throw new IllegalArgumentException("A sinkhole must link a surface course to its terminal inland grotto.");
}
return;
}
if (sinkholeCount != 1 || segments.size() < 3) {
throw new IllegalArgumentException("A surface inland grotto requires one explicit sinkhole link and surface lip.");
}
HydraulicSegment lip = segments.get(segments.size() - 3);
HydraulicSegment sinkhole = segments.get(segments.size() - 2);
HydraulicSegment grotto = segments.getLast();
if (!lip.type().isSurface()
|| sinkhole.type() != HydrologyFeatureType.SINKHOLE
|| sinkhole.drop() <= 0
|| !sinkhole.receivingPool()
|| lip.downstreamHeadY() != sinkhole.upstreamHeadY()
|| sinkhole.downstreamHeadY() != grotto.upstreamHeadY()
|| grotto.upstreamHeadY() != grotto.downstreamHeadY()
|| lip.end().y() != sinkhole.upstreamHeadY()
|| sinkhole.start().y() != sinkhole.upstreamHeadY()
|| sinkhole.end().y() != sinkhole.downstreamHeadY()
|| grotto.start().y() != grotto.upstreamHeadY()
|| lip.end().distanceSquared2D(sinkhole.start()) != 0L
|| sinkhole.end().distanceSquared2D(grotto.start()) != 0L) {
throw new IllegalArgumentException("A surface sinkhole requires a connected lip, graded descent, and receiving grotto.");
}
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology;
public enum RiverCourseType {
SURFACE,
UNDERGROUND,
DEEP_FLUID,
SURFACE_POOL
}
@@ -0,0 +1,115 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
public final class RiverFootprint {
private final Map<Long, HydrologyColumnSample> columns;
public RiverFootprint(Map<Long, HydrologyColumnSample> columns) {
if (columns == null) {
throw new IllegalArgumentException("columns must not be null.");
}
TreeMap<Long, HydrologyColumnSample> ordered = new TreeMap<>();
for (Map.Entry<Long, HydrologyColumnSample> entry : columns.entrySet()) {
HydrologyColumnSample sample = entry.getValue();
if (sample == null || entry.getKey() != pack(sample.x(), sample.z())) {
throw new IllegalArgumentException("Footprint keys must match their column coordinates.");
}
ordered.put(entry.getKey(), sample);
}
Long2ObjectLinkedOpenHashMap<HydrologyColumnSample> indexed =
new Long2ObjectLinkedOpenHashMap<>(ordered.size());
for (Map.Entry<Long, HydrologyColumnSample> entry : ordered.entrySet()) {
indexed.put(entry.getKey().longValue(), entry.getValue());
}
columns = Collections.unmodifiableMap(indexed);
this.columns = columns;
}
public static RiverFootprint empty() {
return new RiverFootprint(Map.of());
}
public Optional<HydrologyColumnSample> sample(int x, int z) {
return Optional.ofNullable(columns.get(pack(x, z)));
}
public HydrologyRenderSample renderSample(int x, int z) {
HydrologyColumnSample sample = columns.get(pack(x, z));
return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample();
}
public Map<Long, HydrologyColumnSample> columns() {
return columns;
}
public List<HydrologyColumnSample> columnsIn(int minimumX, int minimumZ, int maximumX, int maximumZ) {
ArrayList<HydrologyColumnSample> selected = new ArrayList<>();
long width = (long) maximumX - minimumX;
long height = (long) maximumZ - minimumZ;
if (width <= 0L || height <= 0L) {
return List.of();
}
long area = width > Long.MAX_VALUE / height ? Long.MAX_VALUE : width * height;
if (area < columns.size()) {
for (long z = minimumZ; z < maximumZ; z++) {
for (long x = minimumX; x < maximumX; x++) {
HydrologyColumnSample sample = columns.get(pack((int) x, (int) z));
if (sample != null) {
selected.add(sample);
}
}
}
return List.copyOf(selected);
}
for (HydrologyColumnSample sample : columns.values()) {
if (sample.x() >= minimumX && sample.x() < maximumX
&& sample.z() >= minimumZ && sample.z() < maximumZ) {
selected.add(sample);
}
}
return List.copyOf(selected);
}
public int size() {
return columns.size();
}
public boolean isEmpty() {
return columns.isEmpty();
}
public static long pack(int x, int z) {
return ((long) x << 32) ^ (z & 0xffffffffL);
}
public static int unpackX(long packed) {
return (int) (packed >> 32);
}
public static int unpackZ(long packed) {
return (int) packed;
}
@Override
public boolean equals(Object other) {
return other instanceof RiverFootprint footprint && columns.equals(footprint.columns);
}
@Override
public int hashCode() {
return columns.hashCode();
}
@Override
public String toString() {
return "RiverFootprint[columns=" + columns.size() + "]";
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record RiverOutlet(
long id,
HydrologyFeatureType type,
long drainageNodeId,
HydrologyPoint landwardPoint,
HydrologyPoint connectionPoint,
int seaLevel,
boolean directOcean
) {
public RiverOutlet {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(landwardPoint, "landwardPoint");
Objects.requireNonNull(connectionPoint, "connectionPoint");
if (type != HydrologyFeatureType.MOUTH
&& type != HydrologyFeatureType.COASTAL_GROTTO
&& type != HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("Unsupported outlet feature type: " + type);
}
if (directOcean && type == HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("An inland grotto cannot be a direct ocean outlet.");
}
if (directOcean && connectionPoint.y() != seaLevel) {
throw new IllegalArgumentException("A direct ocean connection must be at sea level.");
}
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.hydrology.cave;
public record CavePosition(int x, int y, int z) {
public CavePosition offset(int dx, int dy, int dz) {
return new CavePosition(x + dx, y + dy, z + dz);
}
@Override
public int hashCode() {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collection;
public final class CavePositionIndex {
private static final int DEFAULT_CAPACITY = 16;
private static final int MAXIMUM_CAPACITY = 1 << 30;
private int[] xCoordinates;
private int[] yCoordinates;
private int[] zCoordinates;
private boolean[] occupied;
private int mask;
private int resizeThreshold;
private int size;
public CavePositionIndex() {
initialize(DEFAULT_CAPACITY);
}
public CavePositionIndex(int expectedSize) {
initialize(capacityFor(expectedSize));
}
public static CavePositionIndex copyOf(Collection<CavePosition> positions) {
CavePositionIndex index = new CavePositionIndex(positions.size());
for (CavePosition position : positions) {
index.add(position.x(), position.y(), position.z());
}
return index;
}
public boolean add(int x, int y, int z) {
int slot = findSlot(x, y, z);
if (occupied[slot]) {
return false;
}
occupied[slot] = true;
xCoordinates[slot] = x;
yCoordinates[slot] = y;
zCoordinates[slot] = z;
size++;
if (size > resizeThreshold) {
resize();
}
return true;
}
public boolean contains(int x, int y, int z) {
return occupied[findSlot(x, y, z)];
}
private void initialize(int capacity) {
xCoordinates = new int[capacity];
yCoordinates = new int[capacity];
zCoordinates = new int[capacity];
occupied = new boolean[capacity];
mask = capacity - 1;
resizeThreshold = capacity - (capacity >>> 2);
size = 0;
}
private int findSlot(int x, int y, int z) {
int slot = hash(x, y, z) & mask;
while (occupied[slot]
&& (xCoordinates[slot] != x || yCoordinates[slot] != y || zCoordinates[slot] != z)) {
slot = slot + 1 & mask;
}
return slot;
}
private void resize() {
if (occupied.length == MAXIMUM_CAPACITY) {
resizeThreshold = Integer.MAX_VALUE;
return;
}
int[] previousXCoordinates = xCoordinates;
int[] previousYCoordinates = yCoordinates;
int[] previousZCoordinates = zCoordinates;
boolean[] previousOccupied = occupied;
initialize(occupied.length << 1);
for (int index = 0; index < previousOccupied.length; index++) {
if (previousOccupied[index]) {
add(previousXCoordinates[index], previousYCoordinates[index], previousZCoordinates[index]);
}
}
}
private static int capacityFor(int expectedSize) {
if (expectedSize < 0) {
throw new IllegalArgumentException("Expected position count must not be negative.");
}
long requiredCapacity = Math.max(DEFAULT_CAPACITY, ((long) expectedSize * 4L + 2L) / 3L);
if (requiredCapacity >= MAXIMUM_CAPACITY) {
return MAXIMUM_CAPACITY;
}
return Integer.highestOneBit((int) requiredCapacity - 1) << 1;
}
private static int hash(int x, int y, int z) {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.engine.hydrology.cave;
public enum CaveVoxel {
SOLID,
CAVE_AIR,
COMPATIBLE_FLUID,
LAVA,
INCOMPATIBLE_FLUID,
UNCONDITIONAL
}
@@ -0,0 +1,9 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects;
public record CaveVoxelPrecondition(CaveVoxel voxel, boolean openToSurface) {
public CaveVoxelPrecondition {
Objects.requireNonNull(voxel);
}
}

Some files were not shown because too many files have changed in this diff Show More