Refine organic cave generation

- Add seeded cave surface variation and rounded basin undersides.
- Keep dry cave intersections open while preserving fluid seals.
- Apply room biome decorators and preserve their floor substrates.
- Allow ceiling growth above reserved walking clearance.
- Restrict feature placement by region across live and saved generation.
- Cover cave geometry, placement, decoration, and regional filtering with regression tests.
This commit is contained in:
Brian Neumann-Fopiano
2026-10-05 16:22:55 -04:00
parent c479e94c49
commit 7226fc4400
25 changed files with 785 additions and 86 deletions
@@ -82,7 +82,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
return CommitResult.REJECTED_BOUNDS;
}
SubterrainCell feature = engine.getSubterrainCell(mutation.x(), mutation.y(), mutation.z());
if (SubterrainRasterizer.protectsPlacement(feature)) {
if (SubterrainRasterizer.protectsPlacement(feature)
|| feature != null && feature.solid() && delegate.isSolid(mutation.x(), mutation.y(), mutation.z())) {
discard();
return CommitResult.REJECTED_SUBTERRAIN;
}
@@ -178,6 +178,9 @@ public class MantleCarvingComponent extends IrisMantleComponent {
if (y <= 0 || y >= writer.getMantle().getWorldHeight()) {
return;
}
if (cell.solid() && !cell.room().reservedSolid()) {
return;
}
if (cell.solid()) {
writer.clearData(x, y, z, MatterCavern.class);
} else {
@@ -64,6 +64,7 @@ import art.arcane.iris.generation.terrain.transform.TerrainTransformer;
import art.arcane.iris.generation.noise.IrisInterpolator;
import art.arcane.iris.generation.terrain.IrisMaterialPalette;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.terrain.RegionSelection;
import art.arcane.iris.generation.hydrology.IrisDeepFluidConfig;
import art.arcane.iris.generation.hydrology.IrisSurfacePoolConfig;
import art.arcane.iris.generation.hydrology.IrisHydrology;
@@ -274,7 +275,8 @@ public class IrisComplex implements DataProvider {
subterrainPlanner = new SubterrainPlanner(new SubterrainPlanner.Options(
engine.getDimension().allowsSubterrainFeatures() ? engine.getDimension().getSubterrainFeatures() : List.of(),
engine.getSeedManager().getBodies(),
engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight()),
engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight(),
(x, z) -> regionStream.get(x, z).getLoadKey()),
bounds -> transitionGenerationPlan == null || transitionGenerationPlan.allowsNewFootprint(
bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ()));
biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep();
@@ -376,15 +378,10 @@ public class IrisComplex implements DataProvider {
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
ProceduralStream<IrisRegion> proceduralRegionStream = focusedRegions != null ? focusedRegions
: GenerationStreams.cache2D(regionStyleStream
.selectRarity(compatRegionPool(engine)), "regionStream", engine, cacheSize);
regionStream = focusedRegions != null || !imageMapRuntime.has(IrisImageMapApplication.REGION)
? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream
.convertAware2D((region, x, z) -> {
IrisRegion mapped = imageMapRuntime.sampleRegion(x, z);
return mapped == null ? region : mapped;
}), "imageMappedRegionStream", engine, cacheSize);
regionStream = focusedRegions != null ? focusedRegions
: GenerationStreams.cache2D(RegionSelection.select(new RegionSelection.Options(
engine.getDimension(), data, regionStyleStream, imageMapRuntime, null)),
"regionStream", engine, cacheSize);
regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i),
String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(regionStream, engine, (c, x, z) -> c.getRegion().get(x, z))
@@ -1849,17 +1846,6 @@ public class IrisComplex implements DataProvider {
return inferred;
}
/** The dimension's region pool with regions the version-content gate excluded removed. */
private KList<IrisRegion> compatRegionPool(Engine engine) {
KList<IrisRegion> pool = new KList<>();
for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) {
if (!region.isCompatExcluded()) {
pool.add(region);
}
}
return pool;
}
private static <T extends IrisRegistrant> T compatUsable(T registrant) {
return registrant == null || registrant.isCompatExcluded() ? null : registrant;
}
@@ -242,7 +242,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
output.setRaw(localX, surfaceY, localZ, AIR);
}
}
restoreSubterrainSolids(output, x, z);
restoreSubterrainSolids(output, x, z, false);
} finally {
getEngine().getMetrics().getCarveApply().put(applyStopwatch.getMilliseconds());
}
@@ -272,6 +272,12 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
int x = worldX & 15;
int z = worldZ & 15;
NativeBlockState existing = output.getRaw(x, y, z);
if (cell.solid() && !cell.room().reservedSolid() && (existing == null || !existing.isSolid())) {
return;
}
if (materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) {
return;
}
NativeBlockState planned = materials.state(worldX, worldY, worldZ, cell);
if (cell.solid() || cell.room().reservedPassage() || cell.fluid() && (existing == null || B.isAir(existing) || B.isFluid(existing))) {
output.setRaw(x, y, z, planned);
@@ -281,7 +287,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
});
}
private void restoreSubterrainSolids(Hunk<NativeBlockState> output, int chunkX, int chunkZ) {
private void restoreSubterrainSolids(Hunk<NativeBlockState> output, int chunkX, int chunkZ, boolean preserveFloorSupports) {
SubterrainPlanner planner = getComplex().getSubterrainPlanner();
if (planner == null || planner.isEmpty()) {
return;
@@ -290,7 +296,14 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
SubterrainSurfaceMaterials materials = subterrainMaterials(planner, chunkX, chunkZ);
SubterrainRasterizer.rasterize(planner, chunkX, chunkZ, (worldX, worldY, worldZ, cell) -> {
int y = worldY - minimumY;
if (cell.solid() && y > 0 && y < output.getHeight()) {
if (!cell.solid() || y <= 0 || y >= output.getHeight()) {
return;
}
NativeBlockState existing = output.getRaw(worldX & 15, y, worldZ & 15);
if (preserveFloorSupports && materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) {
return;
}
if (cell.room().reservedSolid() || existing != null && existing.isSolid()) {
output.setRaw(worldX & 15, y, worldZ & 15, materials.state(worldX, worldY, worldZ, cell));
}
});
@@ -421,7 +434,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
SubterrainCell feature = context.subterrainPlanner() == null ? SubterrainCell.OUTSIDE : context.subterrainPlanner().sample(context.subterrainPlans(),
context.chunkBlockX() + localX, y + getEngine().getWorld().minHeight(), context.chunkBlockZ() + localZ);
if (feature.owned()) {
if (feature.owned() && (!feature.solid() || feature.room().reservedSolid())) {
context.output().setRaw(localX, y, localZ, SubterrainRasterizer.state(feature));
if (feature.carve()) {
context.columnMasks()[columnIndex].add(y);
@@ -1007,28 +1020,27 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
}
}
}
restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4);
restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4, true);
}
private void decorateZone(Hunk<NativeBlockState> output, CaveZone zone,
int rx, int rz, int xx, int zz, IrisBiome floorBiome, IrisBiome ceilingBiome) {
SubterrainCell feature = subterrainCell(xx, zone.getFloor(), zz);
if (feature.owned() && feature.room().reservedPassage()) {
return;
}
boolean reservedPassage = feature.owned() && feature.room().reservedPassage();
int maxY = output.getHeight();
IrisDecorator[] surfaceDecorators = floorBiome == null
? new IrisDecorator[0]
: floorBiome.getDecoratorBucket(IrisDecorationPart.NONE);
if (surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) {
if (!reservedPassage && surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) {
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, floorBiome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness());
}
IrisDecorator[] ceilingDecorators = ceilingBiome == null
? new IrisDecorator[0]
: ceilingBiome.getDecoratorBucket(IrisDecorationPart.CEILING);
if (ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), zone.airThickness());
int ceilingSpace = zone.airThickness() - (reservedPassage ? 3 : 0);
if (ceilingSpace > 0 && ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), ceilingSpace);
}
}
@@ -1075,6 +1087,10 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
}
IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, Map<String, IrisBiome> customBiomeCache) {
IrisBiome featureBiome = getEngine().getSubterrainBiome(worldX, y, worldZ);
if (featureBiome != null) {
return featureBiome;
}
if (isNaturalTerrainOpening(resolverState.complex, worldX, y, worldZ)) {
return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ);
}
@@ -1,6 +1,8 @@
package art.arcane.iris.generation.subterrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.pack.schema.annotation.MaxNumber;
import art.arcane.iris.pack.schema.annotation.MinNumber;
import art.arcane.iris.pack.schema.annotation.RegistryListResource;
@@ -26,6 +28,9 @@ public class IrisSubterrainFeature {
@RegistryListResource(IrisBiome.class)
@Description("Biome that owns occupied cells in this feature. Solid rims and surrounding terrain retain their biome.")
private String biome = "";
@RegistryListResource(IrisRegion.class)
@Description("Allowed region keys at the feature placement center. Empty allows all regions.")
private KList<String> allowedRegions = new KList<>();
@Description("Inclusive absolute world Y bounds. The entire feature including seals must fit inside this band.")
private IrisRange worldYRange = new IrisRange(-48, 48);
@MinNumber(32)
@@ -48,6 +53,10 @@ public class IrisSubterrainFeature {
@MaxNumber(192)
@Description("Main room height in blocks.")
private int height = 32;
@MinNumber(0)
@MaxNumber(1)
@Description("Seeded erosion and shape distortion strength for lobed basins, winding passages, uneven banks and irregular vaults.")
private double shapeWarp = 0;
@Description("Retained fluid. When omitted, lava tubes use LAVA and cenotes and terraces use WATER. Faults remain dry.")
private IrisSubterrainFluid fluid;
@MinNumber(0)
@@ -11,6 +11,7 @@ public final class SubterrainPlan {
private final String id;
private final SubterrainBounds bounds;
private final double shapePhase;
private final long shapeSeed;
private final ThreadLocal<ColumnCache> columnCache = ThreadLocal.withInitial(ColumnCache::new);
SubterrainPlan(Options options) {
@@ -21,11 +22,12 @@ public final class SubterrainPlan {
rotated = options.rotated();
id = options.id();
shapePhase = (SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt()) >>> 11) * 0x1.0p-53 * Math.PI * 2;
shapeSeed = SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt());
int along = family() == IrisSubterrainFamily.CENOTE ? definition.radius() : definition.length() / 2;
int across = definition.radius();
int extentX = (rotated ? across : along) + 2;
int extentZ = (rotated ? along : across) + 2;
bounds = new SubterrainBounds(centerX - extentX, baseY() - 2, centerZ - extentZ,
bounds = new SubterrainBounds(centerX - extentX, baseY() - 2 - definition.baseDepth(), centerZ - extentZ,
centerX + extentX, baseY() + definition.height() + 2
+ (family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0), centerZ + extentZ);
}
@@ -125,18 +127,20 @@ public final class SubterrainPlan {
if (column == null) {
return SubterrainCell.OUTSIDE;
}
int sealFloor = family() == IrisSubterrainFamily.CENOTE ? baseY() : column.floor();
if (y < sealFloor - 2 || y > column.ceiling() + 2) {
double horizontalOutside = Math.max(0, -column.boundary());
double verticalOutside = Math.max(0, Math.max(column.baseFloor() - y, y - column.ceiling()));
if (horizontalOutside * horizontalOutside + verticalOutside * verticalOutside > 4) {
return SubterrainCell.OUTSIDE;
}
SubterrainCell.Kind kind = classify(column, u, v, y);
int pathX = rotated ? centerX + column.pathOffset() : x;
int pathZ = rotated ? z : centerZ + column.pathOffset();
boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0;
boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0
&& y <= Math.max(column.floor(), column.head()) + 3;
SubterrainRoom room = new SubterrainRoom(id, biome(), family(), centerX, centerY, centerZ,
pathX, Math.max(column.floor() + 2, column.head() + 2), pathZ,
column.floor(), column.ceiling(), column.boundary(), column.head(), passage,
kind == SubterrainCell.Kind.SOLID, kind);
kind == SubterrainCell.Kind.SOLID && retainedSolid(column, u, v, y), kind);
String material = switch (kind) {
case SOLID -> definition.solid();
case AIR -> "minecraft:cave_air";
@@ -147,6 +151,12 @@ public final class SubterrainPlan {
return new SubterrainCell(kind, material, room);
}
private boolean retainedSolid(Column column, int u, int v, int y) {
return family() != IrisSubterrainFamily.TECTONIC_FAULT && definition.fluidDepth() > 0 && y <= column.head() + 1
|| column.interior() && y > column.floor() && y < column.ceiling()
&& formation(column, u, v, y);
}
private Column cachedColumn(int u, int v) {
ColumnCache cache = columnCache.get();
long key = ((long) u << 32) ^ (v & 0xffffffffL);
@@ -159,7 +169,7 @@ public final class SubterrainPlan {
case CENOTE -> cenote(u, v);
case TECTONIC_FAULT -> fault(u, v);
case TRAVERTINE_TERRACES -> terraces(u, v);
case LAVA_TUBE -> throw new IllegalStateException("Tube columns depend on height");
case LAVA_TUBE -> erodedTube(u, v);
};
int slot = cache.next;
cache.keys[slot] = key;
@@ -173,6 +183,9 @@ public final class SubterrainPlan {
if (!column.interior() || y <= column.floor() || y >= column.ceiling() || column.rim()) {
return SubterrainCell.Kind.SOLID;
}
if (erodedWall(column, u, v, y)) {
return SubterrainCell.Kind.SOLID;
}
if (formation(column, u, v, y)) {
return SubterrainCell.Kind.SOLID;
}
@@ -185,6 +198,18 @@ public final class SubterrainPlan {
return SubterrainCell.Kind.AIR;
}
private boolean erodedWall(Column column, int u, int v, int y) {
double strength = definition.shapeWarp();
double reach = Math.min(7, definition.radius() * 0.28) * strength;
if (reach == 0 || column.boundary() >= reach || column.pathDistance() <= 2) {
return false;
}
double height = y - baseY();
double inset = reach * (0.48 + 0.34 * noise(u / 13.0 + height / 17.0, v / 13.0 - height / 19.0, 149)
+ 0.18 * noise(u / 4.0 - height / 5.0, v / 4.0 + height / 7.0, 151));
return column.boundary() < inset;
}
private boolean formation(Column column, int u, int v, int y) {
if (column.pathDistance() <= 3 || column.boundary() < 4) {
return false;
@@ -225,6 +250,9 @@ public final class SubterrainPlan {
}
private Column cenote(int u, int v) {
if (definition.shapeWarp() > 0) {
return erodedCenote(u, v);
}
double distance = Math.hypot(u, v);
int radius = definition.radius();
if (distance > radius + 2) {
@@ -237,7 +265,7 @@ public final class SubterrainPlan {
double slope = bank * bank * (3 - 2 * bank);
int floor = baseY() + (int) Math.round((definition.fluidDepth() + 2) * slope);
boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2;
return new Column(floor, ceiling, head, distance < radius, rim,
return new Column(floor, floor, ceiling, head, distance < radius, rim,
radius - distance, Math.abs(v), 0);
}
@@ -249,12 +277,36 @@ public final class SubterrainPlan {
int step = Math.max(2, definition.radius() / 4);
int shelf = Math.max(0, (int) (Math.abs(footprint.offset()) - footprint.width() * 0.5) / step)
* Math.max(2, definition.height() / 8);
int ceiling = vaultedCeiling(u, footprint, baseY() + shelf, baseY() - 1);
return new Column(baseY() + shelf, ceiling, baseY() - 1,
int floor = baseY() + shelf;
if (definition.shapeWarp() > 0) {
floor = baseY() + (int) Math.round((1 - definition.shapeWarp()) * shelf
+ definition.shapeWarp() * definition.height() * (0.10 + 0.08 * erosion(u, v, 43)));
}
int ceiling = vaultedCeiling(u, footprint, floor, baseY() - 1);
return new Column(floor, floor, ceiling, baseY() - 1,
footprint.boundary() > 0, false, footprint.boundary(), Math.abs(footprint.offset()), footprint.path());
}
private Column tube(int u, int v, int y) {
if (definition.shapeWarp() > 0) {
Column column = cachedColumn(u, v);
if (column == null || definition.chimneyHeight() == 0) {
return column;
}
int halfLength = definition.length() / 2;
double chimneyRadius = Math.hypot(Math.min(Math.abs(u - halfLength / 2), Math.abs(u + halfLength / 2)),
v - column.pathOffset());
int top = baseY() + definition.height() + definition.chimneyHeight();
if (chimneyRadius < 3) {
return new Column(column.floor(), column.baseFloor(), top, column.head(), column.interior(), column.rim(),
column.boundary(), column.pathDistance(), column.pathOffset());
}
if (chimneyRadius <= 5 && y >= column.ceiling() - 1) {
return new Column(column.ceiling() - 1, column.ceiling() - 1, top, column.head(), false, true,
5 - chimneyRadius, column.pathDistance(), column.pathOffset());
}
return column;
}
int halfLength = definition.length() / 2;
int path = (int) Math.round(Math.sin(u * Math.PI / Math.max(1, halfLength)) * definition.radius() * 0.15);
int width = Math.max(6, (int) Math.floor(definition.radius() * 0.8));
@@ -282,12 +334,12 @@ public final class SubterrainPlan {
if (chimneyRadius < 3) {
ceiling = baseY() + definition.height() + definition.chimneyHeight();
} else if (y >= tubeCeiling - 1) {
return new Column(tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(),
return new Column(tubeCeiling - 1, tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(),
head, false, true, 5 - chimneyRadius, Math.abs(offset), path);
}
}
boolean rim = definition.fluidDepth() > 0 && floor >= head && floor < head + 2;
return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth,
return new Column(floor, floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth,
rim, Math.min(halfLength - Math.abs(u), taperedWidth - Math.abs(offset)), Math.abs(offset), path);
}
@@ -298,12 +350,16 @@ public final class SubterrainPlan {
}
double warped = u + definition.radius() * 0.2 * Math.sin(v / (double) Math.max(6, definition.radius()) * 2.4 + shapePhase)
+ definition.radius() * 0.08 * Math.sin(u / 29.0 + v / 19.0 + shapePhase);
warped += definition.shapeWarp() * definition.radius() * (0.7 * noise(u / 43.0, v / 27.0, 71)
+ 0.23 * noise(u / 13.0, v / 11.0, 73));
double segment = Math.max(4.0, definition.length() / (double) definition.terraceCount());
double terrace = (warped + definition.length() / 2.0) / segment;
int index = Math.max(0, Math.min(definition.terraceCount() - 1, (int) Math.floor(terrace)));
int rise = Math.max(1, (definition.height() / 3) / definition.terraceCount());
int floor = baseY() + index * rise;
int head = floor + definition.fluidDepth();
floor += (int) Math.round(definition.shapeWarp() * Math.max(0, definition.fluidDepth() - 1)
* (0.5 + 0.5 * erosion(u, v, 79)));
double local = (terrace - Math.floor(terrace)) * segment;
boolean rim = definition.fluidDepth() > 0 && (local <= 1.5 || local >= segment - 1.5 || footprint.boundary() <= 2);
double pathOffset = Math.copySign(Math.max(0, footprint.width() - 4), footprint.offset());
@@ -312,7 +368,7 @@ public final class SubterrainPlan {
floor = Math.max(floor, head + 1);
}
int ceiling = vaultedCeiling(u, footprint, floor, head);
return new Column(floor, ceiling, head, footprint.boundary() > 0,
return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0,
false, footprint.boundary(), pathDistance, footprint.path() + (int) Math.round(pathOffset));
}
@@ -330,6 +386,18 @@ public final class SubterrainPlan {
double bend = family() == IrisSubterrainFamily.TECTONIC_FAULT ? 0.08 : 0.12;
int path = (int) Math.round(definition.radius() * bend * Math.sin(u / 47.0 + shapePhase) * taper);
double vaultWave = 0.87 + 0.07 * Math.sin(u / 37.0 + shapePhase) + 0.04 * Math.sin(u / 17.0 - shapePhase);
if (definition.shapeWarp() > 0) {
double strength = definition.shapeWarp();
double scale = Math.max(12, definition.radius());
double erodedWidth = definition.radius() * taper * (0.45
+ 0.22 * noise(u / (scale * 1.8), 0.37, 11)
+ 0.15 * noise(u / (scale * 0.48), 0.71, 13));
double erodedPath = definition.radius() * taper * (0.72 * noise(u / (scale * 2.2), 0.19, 17)
+ 0.25 * noise(u / (scale * 0.73), 0.53, 19));
width += strength * (erodedWidth - width);
path = (int) Math.round(path + strength * (erodedPath - path));
vaultWave += strength * (0.76 + 0.18 * noise(u / (scale * 1.1), 0.31, 23) - vaultWave);
}
Profile profile = new Profile(Math.min(width, definition.radius() - Math.abs(path)), path, vaultWave);
int slot = cache.profileNext;
cache.profileKeys[slot] = u;
@@ -348,6 +416,12 @@ public final class SubterrainPlan {
double width = profile.width();
int path = profile.path();
double offset = v - path;
if (definition.shapeWarp() > 0) {
double strength = definition.shapeWarp();
width += strength * (definition.radius() * 0.20 * noise(u / 17.0, v / 19.0, 29)
+ Math.min(2.5, definition.radius() * 0.08) * noise(u / 5.0, v / 5.0, 31));
width = Math.max(0, Math.min(width, definition.radius() - Math.abs(path)));
}
double boundary = Math.min(halfLength - Math.abs(u), width - Math.abs(offset));
if (boundary < -2) {
return null;
@@ -362,9 +436,112 @@ public final class SubterrainPlan {
double roofVariation = 0.05 * Math.sin(u / 37.0 + footprint.offset() / 17.0 + shapePhase) * arch
+ 0.06 * footprint.offset() / Math.max(1, footprint.width()) * Math.sin(u / 41.0 - shapePhase);
int ceiling = baseY() + (int) Math.floor(definition.height() * wave * (0.4 + 0.6 * arch + roofVariation));
if (definition.shapeWarp() > 0) {
double shoulder = 1 - smooth(Math.max(0, Math.min(1, footprint.boundary() / Math.min(8, footprint.width() * 0.6))));
double erodedRoof = definition.height() * (wave - 0.08 - shoulder * 0.24
+ 0.32 * noise(u / 23.0, footprint.offset() / 17.0, 35)
+ 0.20 * erosion(u, footprint.offset(), 37));
ceiling = baseY() + (int) Math.floor((1 - definition.shapeWarp()) * (ceiling - baseY())
+ definition.shapeWarp() * erodedRoof);
}
return Math.min(baseY() + definition.height(), Math.max(Math.max(floor, head) + 3, ceiling));
}
private Column erodedCenote(int u, int v) {
double radius = definition.radius();
if (Math.hypot(u, v) > radius + 2) {
return null;
}
double strength = definition.shapeWarp();
double scale = Math.max(8, radius * 0.65);
double shiftedU = u + strength * radius * 0.18 * noise(u / scale, v / scale, 101);
double shiftedV = v + strength * radius * 0.18 * noise(u / scale, v / scale, 103);
double lobes = 0.69 + 0.24 * noise(u / scale, v / scale, 105)
+ 0.13 * noise(u / (scale * 0.46), v / (scale * 0.46), 106);
double outline = radius * (1 - strength + strength * lobes)
+ strength * (radius * 0.07 * noise(u / 13.0, v / 13.0, 107)
+ Math.min(2, radius * 0.05) * noise(u / 4.0, v / 4.0, 109));
double distance = Math.hypot(shiftedU, shiftedV * (1 + 0.12 * strength));
double boundary = Math.min(radius - Math.hypot(u, v), outline - distance);
if (boundary < -2) {
return null;
}
double normalized = Math.min(1, distance / Math.max(1, outline));
double bankStart = 0.65 - strength * 0.11;
double bankWidth = 0.25 + strength * 0.15;
double bank = smooth(Math.max(0, Math.min(1, (normalized - bankStart) / bankWidth)));
double bed = Math.max(0, 0.42 + 0.58 * erosion(u, v, 113));
double bedRelief = definition.fluidDepth() == 0 ? definition.height() * 0.30
: Math.max(1, definition.fluidDepth() - 1);
int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 2 + strength)
+ strength * bed * bedRelief);
double arch = Math.sqrt(Math.max(0, 1 - normalized * normalized));
double shoulder = 1 - smooth(Math.max(0, Math.min(1, boundary / Math.max(4, radius * 0.2))));
double roof = definition.height() * ((1 - strength) * arch
+ strength * (0.64 - 0.24 * shoulder
+ 0.40 * noise(u / (scale * 0.6), v / (scale * 0.6), 123)
+ 0.23 * erosion(u, v, 127)));
int head = baseY() + definition.fluidDepth();
int ceiling = Math.min(baseY() + definition.height(), Math.max(floor + 3, baseY() + (int) Math.floor(roof)));
boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2;
return new Column(floor, baseFloor(floor, boundary, u, v), ceiling, head, boundary > 0, rim, boundary, Math.abs(v), 0);
}
private Column erodedTube(int u, int v) {
Footprint footprint = footprint(u, v);
if (footprint == null) {
return null;
}
double cross = Math.min(1, Math.abs(footprint.offset()) / Math.max(1, footprint.width()));
double bank = smooth(Math.max(0, Math.min(1, (cross - 0.38) / 0.55)));
int head = baseY() + definition.fluidDepth();
int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 3)
+ definition.shapeWarp() * definition.height() * 0.11
* Math.max(0, 0.35 + 0.65 * erosion(u, v, 137)));
int ceiling = vaultedCeiling(u, footprint, floor, head);
return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0, false,
footprint.boundary(), Math.abs(footprint.offset()), footprint.path());
}
private int baseFloor(int floor, double boundary, int u, int v) {
int depth = definition.baseDepth();
if (depth == 0 || boundary <= 0) {
return floor;
}
double taper = Math.sqrt(Math.min(1, boundary / Math.max(4, definition.radius() * 0.55)));
double scale = Math.max(8, definition.radius() * 0.7);
double relief = Math.max(0, Math.min(1, 0.73 + 0.25 * noise(u / scale, v / scale, 157)
+ 0.14 * noise(u / 7.0, v / 7.0, 163)));
return floor - (int) Math.round(depth * taper * relief);
}
private double erosion(double u, double v, long salt) {
return 0.58 * noise(u / 31.0, v / 29.0, salt)
+ 0.29 * noise(u / 11.0, v / 13.0, salt + 1)
+ 0.13 * noise(u / 4.0, v / 4.0, salt + 2);
}
private double noise(double u, double v, long salt) {
int x = (int) Math.floor(u);
int z = (int) Math.floor(v);
double dx = smooth(u - x);
double dz = smooth(v - z);
double near = lattice(x, z, salt);
near += dx * (lattice(x + 1, z, salt) - near);
double far = lattice(x, z + 1, salt);
far += dx * (lattice(x + 1, z + 1, salt) - far);
return near + dz * (far - near);
}
private double lattice(int x, int z, long salt) {
return (SubterrainPlanner.mix(shapeSeed ^ x * 0x9e3779b97f4a7c15L
^ z * 0xc2b2ae3d27d4eb4fL ^ salt * 0x165667b19e3779f9L) >>> 11) * 0x1.0p-52 - 1;
}
private double smooth(double value) {
return value * value * (3 - 2 * value);
}
private SubterrainPosition occupiedAnchor(int x, int preferredY, int z) {
if (anchorCell(sample(x, preferredY, z))) {
return new SubterrainPosition(x, preferredY, z);
@@ -406,7 +583,7 @@ public final class SubterrainPlan {
private record Footprint(double width, double offset, double boundary, int path) {
}
private record Column(int floor, int ceiling, int head, boolean interior, boolean rim,
private record Column(int floor, int baseFloor, int ceiling, int head, boolean interior, boolean rim,
double boundary, double pathDistance, int pathOffset) {
}
}
@@ -15,6 +15,7 @@ public final class SubterrainPlanner {
private final int maxYExclusive;
private final ThreadLocal<CandidateTile> sampleTile = new ThreadLocal<>();
private final Predicate<SubterrainBounds> allowedBounds;
private final RegionResolver regionResolver;
public SubterrainPlanner(Options options) {
this(options, bounds -> true);
@@ -23,6 +24,7 @@ public final class SubterrainPlanner {
public SubterrainPlanner(Options options, Predicate<SubterrainBounds> allowedBounds) {
Objects.requireNonNull(options);
this.allowedBounds = Objects.requireNonNull(allowedBounds);
regionResolver = Objects.requireNonNull(options.regionResolver());
seed = options.seed();
minY = options.minY();
maxYExclusive = options.maxYExclusive();
@@ -49,6 +51,7 @@ public final class SubterrainPlanner {
private SubterrainPlanner(SubterrainPlanner source, Predicate<SubterrainBounds> allowedBounds) {
definitions = source.definitions;
regionResolver = source.regionResolver;
seed = source.seed;
minY = source.minY;
maxYExclusive = source.maxYExclusive;
@@ -128,10 +131,10 @@ public final class SubterrainPlanner {
SubterrainCell selected = SubterrainCell.OUTSIDE;
for (SubterrainPlan plan : candidates) {
SubterrainCell cell = plan.sample(x, y, z);
if (cell.solid()) {
if (cell.solid() && cell.room().reservedSolid()) {
return cell;
}
if (!selected.owned() && cell.owned()) {
if (!selected.owned() && cell.owned() || selected.solid() && cell.occupied()) {
selected = cell;
}
}
@@ -164,7 +167,7 @@ public final class SubterrainPlanner {
int spacing = definition.spacing();
long x = gridX * spacing + spacing / 4L + Math.floorMod(mix(value + 1), Math.max(1, spacing / 2));
long z = gridZ * spacing + spacing / 4L + Math.floorMod(mix(value + 2), Math.max(1, spacing / 2));
int bottomMargin = definition.height() / 2 + 3;
int bottomMargin = definition.height() / 2 + 3 + definition.baseDepth();
int topMargin = definition.height() - definition.height() / 2 + 3
+ (definition.family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0);
long low = (long) Math.max(minY, definition.minY()) + bottomMargin;
@@ -173,6 +176,10 @@ public final class SubterrainPlanner {
|| z < Integer.MIN_VALUE + spacing || z > Integer.MAX_VALUE - spacing) {
return null;
}
if (!definition.allowedRegions().isEmpty()
&& !definition.allowedRegions().contains(regionResolver.regionAt((int) x, (int) z))) {
return null;
}
int y = (int) (low + Math.floorMod(mix(value + 3), high - low + 1));
SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options(definition, (int) x, y, (int) z,
(value & 1L) != 0, definition.id() + ":" + Long.toUnsignedString(value, 16)));
@@ -189,9 +196,16 @@ public final class SubterrainPlanner {
return (value >>> 11) * 0x1.0p-53;
}
public record Options(List<IrisSubterrainFeature> features, long seed, int minY, int maxYExclusive) {
@FunctionalInterface
public interface RegionResolver {
String regionAt(int x, int z);
}
public record Options(List<IrisSubterrainFeature> features, long seed, int minY, int maxYExclusive,
RegionResolver regionResolver) {
public Options {
features = List.copyOf(Objects.requireNonNull(features));
Objects.requireNonNull(regionResolver);
}
}
@@ -199,9 +213,14 @@ public final class SubterrainPlanner {
}
record Definition(String id, IrisSubterrainFamily family, IrisSubterrainFluid fluid, String biome, int spacing, double probability,
int length, int radius, int height, int fluidDepth, int chimneyHeight, int terraceCount,
int length, int radius, int height, double shapeWarp, int fluidDepth, int chimneyHeight, int terraceCount,
int pillarSpacing, double formationFraction, String solid, int priority,
int minY, int maxY, long seedSalt) {
int minY, int maxY, long seedSalt, Set<String> allowedRegions) {
int baseDepth() {
return family == IrisSubterrainFamily.TECTONIC_FAULT || fluidDepth == 0 ? 0
: (int) Math.ceil(Math.min(24, radius * 0.45) * shapeWarp);
}
static Definition from(IrisSubterrainFeature feature) {
String id = Objects.requireNonNull(feature.getId(), "Subterrain id").trim();
IrisSubterrainFamily family = Objects.requireNonNull(feature.getFamily(), "Subterrain family");
@@ -214,6 +233,7 @@ public final class SubterrainPlanner {
int horizontalReach = family == IrisSubterrainFamily.CENOTE ? radius : Math.max(length / 2, radius);
if (!id.matches("[a-zA-Z0-9_/-]+") || length < 16 || length > 2048 || radius < 6 || radius > 256
|| height < 8 || height > 192 || chimney < 0 || chimney > 96
|| !Double.isFinite(feature.getShapeWarp()) || feature.getShapeWarp() < 0 || feature.getShapeWarp() > 1
|| feature.getSpacing() < Math.max(32, 2 * horizontalReach + 8) || feature.getSpacing() > 8192
|| !Double.isFinite(feature.getProbability()) || feature.getProbability() < 0 || feature.getProbability() > 1
|| feature.getFluidDepth() < 0 || feature.getFluidDepth() > Math.min(32, height / 3)
@@ -239,15 +259,21 @@ public final class SubterrainPlanner {
if (!biome.isEmpty() && !biome.matches("[a-zA-Z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid subterrain biome key: " + id);
}
Set<String> allowedRegions = Set.copyOf(Objects.requireNonNull(feature.getAllowedRegions()));
for (String region : allowedRegions) {
if (!region.matches("[a-zA-Z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid subterrain region key: " + id);
}
}
long salt = 0xcbf29ce484222325L;
for (int i = 0; i < id.length(); i++) {
salt = (salt ^ id.charAt(i)) * 0x100000001b3L;
}
return new Definition(id, family, fluid, biome,
feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getFluidDepth(),
feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getShapeWarp(), feature.getFluidDepth(),
chimney, feature.getTerraceCount(), feature.getPillarSpacing(), feature.getFormationFraction(),
solid, feature.getPriority(), (int) Math.ceil(feature.getWorldYRange().getMin()),
(int) Math.floor(feature.getWorldYRange().getMax()), salt);
(int) Math.floor(feature.getWorldYRange().getMax()), salt, allowedRegions);
}
}
}
@@ -74,7 +74,7 @@ public final class SubterrainRasterizer {
}
public static boolean protectsPlacement(SubterrainCell cell) {
return cell != null && cell.owned() && (cell.solid() || cell.room().reservedPassage());
return cell != null && cell.owned() && (cell.room().reservedSolid() || cell.room().reservedPassage());
}
@FunctionalInterface
@@ -1,6 +1,8 @@
package art.arcane.iris.generation.subterrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.decoration.IrisDecorationPart;
import art.arcane.iris.generation.decoration.IrisDecorator;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.loading.IrisData;
@@ -9,8 +11,10 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class SubterrainSurfaceMaterials {
private final SubterrainPlanner planner;
@@ -21,6 +25,7 @@ public final class SubterrainSurfaceMaterials {
private final RNG random;
private final int minimumY;
private final Map<String, IrisBiome> biomes = new HashMap<>();
private final Map<String, Set<String>> floorSupports = new HashMap<>();
public SubterrainSurfaceMaterials(SubterrainPlanner planner, List<SubterrainPlan> plans,
IrisDimension dimension, IrisComplex complex, IrisData data,
@@ -43,11 +48,7 @@ public final class SubterrainSurfaceMaterials {
if (surface == Surface.INTERIOR) {
return fallback;
}
String key = cell.room().biome();
if (!biomes.containsKey(key)) {
biomes.put(key, data.getBiomeLoader().load(key));
}
IrisBiome biome = biomes.get(key);
IrisBiome biome = biome(cell.room().biome());
if (biome == null) {
return fallback;
}
@@ -63,6 +64,31 @@ public final class SubterrainSurfaceMaterials {
return SubterrainRasterizer.isRetainedSolid(candidate) ? candidate : fallback;
}
public boolean retainsFloorSupport(int x, int worldY, int z, SubterrainCell cell, NativeBlockState existing) {
if (!cell.solid() || cell.room().biome().isEmpty() || !SubterrainRasterizer.isRetainedSolid(existing)) {
return false;
}
String key = cell.room().biome();
Set<String> supports = floorSupports.get(key);
if (supports == null) {
supports = new HashSet<>();
IrisBiome biome = biome(key);
if (biome != null) {
for (IrisDecorator decorator : biome.getDecoratorBucket(IrisDecorationPart.NONE)) {
if (decorator.getForceBlock() == null) {
continue;
}
NativeBlockState support = decorator.getForceBlock().getBlockData(data);
if (SubterrainRasterizer.isRetainedSolid(support)) {
supports.add(support.key());
}
}
}
floorSupports.put(key, supports);
}
return supports.contains(existing.key()) && surface(x, worldY, z, cell) == Surface.FLOOR;
}
public Surface surface(int x, int worldY, int z, SubterrainCell cell) {
if (!cell.solid()) {
return Surface.INTERIOR;
@@ -86,6 +112,13 @@ public final class SubterrainSurfaceMaterials {
return neighbor.occupied() && neighbor.room().featureId().equals(cell.room().featureId());
}
private IrisBiome biome(String key) {
if (!biomes.containsKey(key)) {
biomes.put(key, data.getBiomeLoader().load(key));
}
return biomes.get(key);
}
public enum Surface {
FLOOR,
CEILING,
@@ -0,0 +1,80 @@
package art.arcane.iris.generation.terrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.image.IrisImageMapRuntime;
import art.arcane.iris.generation.image.IrisImageMapApplication;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.generation.BiomeBuffetLayout;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
public final class RegionSelection {
private RegionSelection() {
}
public static ProceduralStream<IrisRegion> select(Options options) {
if (options.focused() != null) {
return options.focused();
}
KList<IrisRegion> regions = new KList<>();
for (IrisRegion region : options.data().getRegionLoader().loadAll(options.dimension().getRegions())) {
if (!region.isCompatExcluded()) {
regions.add(region);
}
}
ProceduralStream<IrisRegion> selected = options.style().selectRarity(regions);
if (!options.imageMaps().has(IrisImageMapApplication.REGION)) {
return selected;
}
return selected.convertAware2D((region, x, z) -> {
IrisRegion mapped = options.imageMaps().sampleRegion(x, z);
return mapped == null ? region : mapped;
});
}
public static ProceduralStream<IrisRegion> restore(Snapshot snapshot) {
IrisData data = snapshot.data();
IrisDimension dimension = snapshot.dimension();
ProceduralStream<IrisRegion> focused = null;
if (snapshot.buffet() && dimension.getStudioMode().biomeSizeChunks() > 0) {
BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data);
if (!layout.cells().isEmpty()) {
focused = ProceduralStream.of((x, z) -> layout.terrain(x, z).region(),
Interpolated.of(value -> 0D, value -> layout.cells().getFirst().region()));
}
}
if (focused == null) {
IrisRegion focus = focusRegion(data, dimension);
if (focus != null) {
focused = ProceduralStream.of((x, z) -> focus, Interpolated.of(value -> 0D, value -> focus));
}
}
return select(new Options(dimension, data,
dimension.getRegionStyle().create(new RNG(snapshot.complexSeed()).nextParallelRNG(883), data)
.stream().zoom(dimension.getRegionZoom()),
IrisImageMapRuntime.compile(data, dimension, dimension.getMinHeight()), focused));
}
private static IrisRegion focusRegion(IrisData data, IrisDimension dimension) {
if (dimension.getFocus() != null && !dimension.getFocus().isBlank()) {
IrisBiome focus = data.getBiomeLoader().load(dimension.getFocus());
if (focus != null && !focus.isCompatExcluded()) {
return dimension.resolveFocusRegion(focus.withInferredType(InferredType.LAND), () -> data);
}
}
if (dimension.getFocusRegion() == null || dimension.getFocusRegion().isBlank()) {
return null;
}
IrisRegion focus = data.getRegionLoader().load(dimension.getFocusRegion());
return focus == null || focus.isCompatExcluded() ? null : focus;
}
public record Options(IrisDimension dimension, IrisData data, ProceduralStream<Double> style,
IrisImageMapRuntime imageMaps, ProceduralStream<IrisRegion> focused) {
}
public record Snapshot(IrisDimension dimension, IrisData data, long complexSeed, boolean buffet) {
}
}
@@ -11,6 +11,10 @@ import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.terrain.RegionSelection;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.cache.AtomicCache;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.iris.spi.IrisLogging;
import java.io.IOException;
@@ -521,11 +525,16 @@ public final class SavedBiomeRuntime implements AutoCloseable {
if (dimension.allowsSubterrainFeatures()) {
SubterrainRasterizer.validateMaterials(dimension.getSubterrainFeatures());
}
AtomicCache<ProceduralStream<IrisRegion>> regionSelection = new AtomicCache<>();
Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data),
resolveFocusRegions(data, dimension), new SubterrainPlanner(new SubterrainPlanner.Options(
dimension.allowsSubterrainFeatures() ? dimension.getSubterrainFeatures() : List.of(),
new SeedManager(epoch.worldSeed()).getBodies(),
dimension.getMinHeight(), dimension.getMaxHeight())));
dimension.getMinHeight(), dimension.getMaxHeight(),
(x, z) -> regionSelection.aquire(() -> RegionSelection.restore(new RegionSelection.Snapshot(
dimension, data, new SeedManager(epoch.worldSeed()).getComplex(),
engine.isStudio() && "bukkit".equals(IrisPlatforms.get().platformName()))))
.get(x, z).getLoadKey())));
data.prepareBlockDropRules();
definitions.put(epochId, loaded);
return loaded;
@@ -43,7 +43,7 @@ public class SubterrainLocatorTest {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave")
.setProbability(1).setWorldYRange(new IrisRange(-100, 100)).setRadius(28).setHeight(40)
.setFluidDepth(8).setFluid(fluid).setPillarSpacing(0).setFormationFraction(0);
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -128, 256));
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -128, 256, (x, z) -> "test-region"));
}
@Test
@@ -101,6 +101,6 @@ public class SubterrainLocatorTest {
private static SubterrainPlanner planner() {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave")
.setProbability(1).setWorldYRange(new IrisRange(-48, 48));
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -64, 320));
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -64, 320, (x, z) -> "test-region"));
}
}
@@ -131,6 +131,28 @@ public class CaveObjectPlacementTransactionTest {
}
}
@Test
public void drySkinProtectsExistingRockButAllowsAnAlreadyOpenCaveJoin() {
for (boolean solid : new boolean[]{true, false}) {
IObjectPlacer delegate = createPlacer(128, 80, 20, 60);
SubterrainRoom room = new SubterrainRoom("cenote", "dry-cave", IrisSubterrainFamily.CENOTE,
4, 35, 7, 4, 21, 7, 19, 61, 0, 18, false, false, SubterrainCell.Kind.SOLID);
when(delegate.getEngine().getSubterrainCell(4, 30, 7))
.thenReturn(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room));
when(delegate.isSolid(4, 30, 7)).thenReturn(solid);
CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10);
NativeBlockState decoration = mock(NativeBlockState.class);
transaction.set(4, 30, 7, decoration);
assertEquals(solid ? CaveObjectPlacementTransaction.CommitResult.REJECTED_SUBTERRAIN
: CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit());
if (solid) {
verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any());
} else {
verify(delegate).set(4, 30, 7, decoration);
}
}
}
@Test
public void ownedWaterAndLavaUseTheirExclusiveAbsoluteFluidHeadAtTheOriginalAnchor() {
for (int minimumY : new int[]{-64, 32}) {
@@ -195,7 +195,7 @@ public class NaturalCaveProceduralPlacementTest {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("natural-room").setProbability(1)
.setRadius(32).setHeight(40).setFluid(options.fluid()).setFluidDepth(options.fluidDepth())
.setPillarSpacing(0).setFormationFraction(0).setWorldYRange(new IrisRange(20, 120));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, 0, 256));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, 0, 256, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst();
int x = plan.centerX() + 8;
int z = plan.centerZ() + 8;
@@ -28,7 +28,7 @@ public class SubterrainBiomeOwnershipTest {
@Test
public void occupiedFeatureOwnsExactVolumeAndYWithoutSurfaceDepthGate() {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1);
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst();
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
IrisComplex complex = mock(IrisComplex.class);
@@ -51,7 +51,7 @@ public class WorldEntitySpawnerUndergroundOwnershipTest {
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1);
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst();
SubterrainPosition anchor = plan.anchor();
Location candidate = new Location(world, anchor.x(), anchor.y(), anchor.z());
@@ -83,6 +83,31 @@ public class IrisCarveModifierBoundarySupportTest {
assertSame(resolvedCeiling, ceiling);
}
@Test
@SuppressWarnings("unchecked")
public void authoredRoomKeepsItsBiomeInsideNaturalTerrainOpening() {
IrisBiome room = new IrisBiome();
IrisBiome surface = new IrisBiome();
IrisComplex complex = mock(IrisComplex.class);
ProceduralStream<IrisBiome> biomes = mock(ProceduralStream.class);
doReturn(true).when(complex).isTerrain3DOpening(40, 42, 44);
doReturn(biomes).when(complex).getTrueBiomeStream();
doReturn(surface).when(biomes).get(40, 44);
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
Engine engine = mock(Engine.class);
doReturn(engine).when(modifier).getEngine();
doReturn(complex).when(engine).getComplex();
doReturn(room).when(engine).getSubterrainBiome(40, 42, 44);
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(engine);
assertSame(room, modifier.resolveCaveBoundaryBiome(
new MatterCavern(true, "carving/other", (byte) 0), 40, 42, 44,
inputs, new Long2ObjectOpenHashMap<>(), new HashMap<>()));
assertSame(room, modifier.resolveCaveBoundaryBiome(
null, 40, 42, 44,
inputs, new Long2ObjectOpenHashMap<>(), new HashMap<>()));
}
@Test
@SuppressWarnings("unchecked")
public void terrainOpeningKeepsItsSurfaceBiomeOverCaveMatter() {
@@ -1,6 +1,8 @@
package art.arcane.iris.generation.stage;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.subterrain.SubterrainCell;
import art.arcane.iris.generation.subterrain.SubterrainRoom;
import art.arcane.iris.generation.decoration.IrisCeilingDecorator;
import art.arcane.iris.generation.decoration.IrisSurfaceDecorator;
import art.arcane.iris.generation.runtime.Engine;
@@ -78,6 +80,28 @@ public class IrisNaturalCaveScanTest {
verifyScan(16, 16);
}
@Test
public void reservedWalkingPassageKeepsFloorClearAndDecoratesHighCeiling() throws Exception {
Fixture fixture = new Fixture();
SubterrainRoom room = mock(SubterrainRoom.class);
doReturn(true).when(room).reservedPassage();
SubterrainCell passage = new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", room);
Engine engine = fixture.modifier.getEngine();
doReturn(passage).when(engine).getSubterrainCell(-32, 2, 48);
Hunk<NativeBlockState> output = Hunk.newArrayHunk(1, 32, 1);
for (int y = 0; y < 32; y++) {
output.setRaw(0, y, 0, y >= 2 && y <= 12 ? fixture.air : fixture.stone);
}
fixture.modifier.decorateNaturalCaves(-32, 48, output, null);
assertEquals(List.of("ceiling:0:0:12:6"), fixture.calls);
assertSame(fixture.air, output.getRaw(0, 2, 0));
assertSame(fixture.air, output.getRaw(0, 3, 0));
assertSame(fixture.air, output.getRaw(0, 4, 0));
assertSame(fixture.ceiling, output.getRaw(0, 12, 0));
}
@Test
public void decorationReusesOnlyTheCarveSnapshotOfItsOwnChunk() throws Exception {
Fixture fixture = new Fixture();
@@ -20,6 +20,87 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class SubterrainPlannerTest {
@Test
public void warpedFamiliesHaveAsymmetricSeededReliefAndRetainedFluids() {
for (IrisSubterrainFamily family : IrisSubterrainFamily.values()) {
IrisSubterrainFeature feature = feature(family).setShapeWarp(1).setRadius(16)
.setHeight(28).setLength(family == IrisSubterrainFamily.TECTONIC_FAULT ? 208 : 80)
.setChimneyHeight(0);
SubterrainPlanner planner = planner(feature);
for (int seed = 0; seed < 3; seed++) {
SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options(
SubterrainPlanner.Definition.from(feature), 0, 0, 0, false, feature.getId() + ":" + seed));
SubterrainPlan rotated = new SubterrainPlan(new SubterrainPlan.Options(
SubterrainPlanner.Definition.from(feature), 0, 0, 0, true, plan.id()));
SubterrainPosition anchor = plan.anchor();
assertTrue(plan.sample(anchor.x(), anchor.y(), anchor.z()).occupied());
Set<Integer> floors = new HashSet<>();
Set<Integer> ceilings = new HashSet<>();
int asymmetric = 0;
SubterrainBounds bounds = plan.bounds();
for (int x = bounds.minX(); x <= bounds.maxX(); x += 2) {
for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 2) {
for (int y = bounds.minY(); y <= bounds.maxY(); y += 2) {
SubterrainCell cell = plan.sample(x, y, z);
assertEquals(cell.kind(), rotated.sample(z, y, x).kind());
if (cell.occupied()) {
floors.add(cell.room().floorY());
ceilings.add(cell.room().ceilingY());
assertTrue(x > bounds.minX() && x < bounds.maxX());
assertTrue(z > bounds.minZ() && z < bounds.maxZ());
assertTrue(y > bounds.minY() && y < bounds.maxY());
}
if (cell.kind() != plan.sample(-x, y, -z).kind()) {
asymmetric++;
}
}
}
}
assertTrue(family + " floors", floors.size() >= 3);
assertTrue(family + " ceilings", ceilings.size() >= 6);
assertTrue(family + " asymmetry", asymmetric > 100);
if (family != IrisSubterrainFamily.TECTONIC_FAULT) {
assertTrue(family + " retained fluid", assertRetained(planner, List.of(plan),
family == IrisSubterrainFamily.LAVA_TUBE ? SubterrainCell.Kind.LAVA : SubterrainCell.Kind.WATER) > 30);
}
}
}
}
@Test
public void shapeWarpRejectsInvalidStrengthsAndSnapshotsConfiguration() {
for (double invalid : new double[]{-0.1, 1.1, Double.NaN, Double.POSITIVE_INFINITY}) {
assertThrows(IllegalArgumentException.class,
() -> planner(feature(IrisSubterrainFamily.CENOTE).setShapeWarp(invalid)));
}
IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setShapeWarp(0.9);
SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature);
feature.setShapeWarp(0.2);
assertEquals(0.9, definition.shapeWarp(), 0);
}
@Test
public void serializedWarpedDefinitionRestoresTheSamePlan() {
Gson gson = new Gson();
IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setShapeWarp(0.93);
String saved = gson.toJson(feature);
IrisSubterrainFeature restored = gson.fromJson(saved, IrisSubterrainFeature.class);
assertEquals(0.93, restored.getShapeWarp(), 0);
assertEquals(SubterrainPlanner.Definition.from(feature), SubterrainPlanner.Definition.from(restored));
SubterrainPlan before = direct(feature);
feature.setShapeWarp(0);
SubterrainPlan after = direct(restored);
assertEquals(before.anchor(), after.anchor());
SubterrainBounds bounds = before.bounds();
for (int x = bounds.minX(); x <= bounds.maxX(); x += 3) {
for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 3) {
for (int y = bounds.minY(); y <= bounds.maxY(); y += 3) {
assertEquals(before.sample(x, y, z), after.sample(x, y, z));
}
}
}
}
@Test
public void terraceAnchorsUseOccupiedPathsWhenCenterIsPillarOrUpperStep() {
for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) {
@@ -314,7 +395,8 @@ public class SubterrainPlannerTest {
}
previous = layer;
}
assertTrue(plan.sample(0, 0, 0).room().reservedPassage());
assertTrue(plan.sample(0, -10, 0).room().reservedPassage());
assertFalse(plan.sample(0, 0, 0).room().reservedPassage());
assertFalse(plan.sample(0, 0, 0).solid());
int formationCells = 0;
for (int x = -20; x <= 20; x++) {
@@ -338,9 +420,9 @@ public class SubterrainPlannerTest {
IrisSubterrainFeature cenote = feature(IrisSubterrainFamily.CENOTE);
IrisSubterrainFeature fault = feature(IrisSubterrainFamily.TECTONIC_FAULT);
List<IrisSubterrainFeature> features = new ArrayList<>(List.of(cenote, fault));
SubterrainPlanner first = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97));
SubterrainPlanner first = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97, (x, z) -> "test-region"));
Collections.reverse(features);
SubterrainPlanner reordered = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97));
SubterrainPlanner reordered = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97, (x, z) -> "test-region"));
List<SubterrainPlan> plans = first.plansForBounds(-1024, -1024, 1024, 1024);
assertEquals(plans.stream().map(SubterrainPlan::id).toList(),
reordered.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList());
@@ -367,7 +449,7 @@ public class SubterrainPlannerTest {
feature.setRadius(8).setBiome("changed").setId("changed");
assertEquals(before, planner.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList());
assertEquals("subterranean/example", first(planner).biome());
SubterrainPlanner anotherSeed = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 999, -64, 97));
SubterrainPlanner anotherSeed = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 999, -64, 97, (x, z) -> "test-region"));
assertNotEquals(before, anotherSeed.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList());
}
@@ -384,9 +466,81 @@ public class SubterrainPlannerTest {
SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature);
SubterrainPlan upper = new SubterrainPlan(new SubterrainPlan.Options(definition, 32, 0, 0, false, "fault:other"));
SubterrainPlanner planner = planner(feature);
assertTrue(upper.sample(32, -14, 0).occupied());
assertTrue(lower.sample(32, -14, 0).solid());
assertTrue(planner.sample(List.of(upper, lower), 32, -14, 0).solid());
assertTrue(upper.sample(25, -13, 0).occupied());
assertTrue(lower.sample(25, -13, 0).solid());
assertTrue(lower.sample(25, -13, 0).room().reservedSolid());
assertTrue(planner.sample(List.of(upper, lower), 25, -13, 0).solid());
}
@Test
public void dryFeatureIntersectionsJoinAcrossEitherShellPriority() {
IrisSubterrainFeature basin = feature(IrisSubterrainFamily.CENOTE).setFluidDepth(0).setShapeWarp(1);
IrisSubterrainFeature fault = feature(IrisSubterrainFamily.TECTONIC_FAULT).setShapeWarp(1);
SubterrainPlan first = direct(basin);
SubterrainPlan second = direct(fault);
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(basin, fault), 7, -64, 97, (x, z) -> "test-region"));
int joined = 0;
for (int x = -32; x <= 32; x++) {
for (int z = -32; z <= 32; z++) {
for (int y = -16; y <= 16; y++) {
SubterrainCell shell = first.sample(x, y, z);
SubterrainCell passage = second.sample(x, y, z);
if (shell.solid() && !shell.room().reservedSolid() && passage.kind() == SubterrainCell.Kind.AIR) {
assertEquals(passage, planner.sample(List.of(first, second), x, y, z));
assertEquals(passage, planner.sample(List.of(second, first), x, y, z));
joined++;
}
}
}
}
assertTrue(joined > 100);
}
@Test
public void exteriorCornersAndRaisedBanksDoNotExtrudeToFlatBoundingPlanes() {
SubterrainPlan plan = direct(feature(IrisSubterrainFamily.CENOTE).setFluidDepth(0));
assertTrue(plan.sample(32, -14, 0).solid());
assertFalse(plan.sample(33, -16, 0).owned());
assertFalse(plan.sample(30, -17, 0).owned());
}
@Test
public void roundedWetBasesHaveReliefAndRemainInsideThePlacementBand() {
for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.CENOTE,
IrisSubterrainFamily.LAVA_TUBE, IrisSubterrainFamily.TRAVERTINE_TERRACES)) {
IrisSubterrainFeature feature = feature(family).setShapeWarp(1).setChimneyHeight(0);
SubterrainPlan plan = direct(feature);
SubterrainBounds bounds = plan.bounds();
int maximumBaseDepth = SubterrainPlanner.Definition.from(feature).baseDepth();
Set<Integer> bottomHeights = new HashSet<>();
int deepest = 0;
for (int x = bounds.minX(); x <= bounds.maxX(); x += 2) {
for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 2) {
for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
SubterrainCell cell = plan.sample(x, y, z);
if (!cell.solid() || !cell.room().reservedSolid()) {
continue;
}
bottomHeights.add(y);
int depth = cell.room().floorY() - y;
assertTrue(depth <= maximumBaseDepth + 2);
deepest = Math.max(deepest, depth);
break;
}
}
}
assertTrue(family + " base relief", bottomHeights.size() > 5);
assertTrue(family + " rock depth", deepest > 6);
feature.setWorldYRange(new IrisRange(0, feature.getHeight() + 6));
assertTrue(planner(feature).plansForBounds(-512, -512, 512, 512).isEmpty());
feature.setWorldYRange(new IrisRange(0, 80));
List<SubterrainPlan> placedPlans = planner(feature).plansForBounds(-512, -512, 512, 512);
assertFalse(placedPlans.isEmpty());
for (SubterrainPlan placed : placedPlans) {
assertTrue(placed.bounds().minY() >= 0);
assertTrue(placed.bounds().maxY() <= 80);
}
}
}
@Test(expected = IllegalArgumentException.class)
@@ -413,7 +567,7 @@ public class SubterrainPlannerTest {
@Test(expected = IllegalArgumentException.class)
public void duplicateIdentifiersAreRejected() {
IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE);
new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature, feature), 1, -64, 97));
new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature, feature), 1, -64, 97, (x, z) -> "test-region"));
}
private int assertRetained(SubterrainPlanner planner, List<SubterrainPlan> candidates, SubterrainCell.Kind kind) {
@@ -450,7 +604,7 @@ public class SubterrainPlannerTest {
}
private SubterrainPlanner planner(IrisSubterrainFeature feature) {
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97));
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97, (x, z) -> "test-region"));
}
private SubterrainPlan first(SubterrainPlanner planner) {
@@ -31,7 +31,7 @@ public class SubterrainRasterizerTest {
.setFluid(IrisSubterrainFluid.LAVA).setProbability(1).setLength(64).setRadius(12)
.setHeight(24).setPillarSpacing(0).setFormationFraction(0)
.setBiome("underground/lava").setWorldYRange(new IrisRange(-48, 60));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst();
int[] fluids = {0};
SubterrainRasterizer.rasterize(planner, plan.centerX() >> 4, plan.centerZ() >> 4, (x, y, z, cell) -> {
@@ -85,7 +85,7 @@ public class SubterrainRasterizerTest {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("retained-cenote")
.setProbability(1).setRadius(20).setHeight(24).setPillarSpacing(12)
.setWorldYRange(new IrisRange(-48, 60));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst();
int firstX = plan.centerX() >> 4;
int firstZ = plan.centerZ() >> 4;
@@ -121,7 +121,10 @@ public class SubterrainRasterizerTest {
0, 0, 0, 0, 0, 0, -8, 12, 10, -4, false, false, SubterrainCell.Kind.AIR);
assertFalse(SubterrainRasterizer.protectsPlacement(SubterrainCell.OUTSIDE));
assertFalse(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", room)));
assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room)));
assertFalse(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room)));
SubterrainRoom seal = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE,
0, 0, 0, 0, 0, 0, -8, 12, 10, -4, false, true, SubterrainCell.Kind.SOLID);
assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", seal)));
SubterrainRoom passage = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE,
0, 0, 0, 0, 0, 0, -8, 12, 10, -4, true, false, SubterrainCell.Kind.AIR);
assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", passage)));
@@ -0,0 +1,70 @@
package art.arcane.iris.generation.subterrain;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.Gson;
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.assertThrows;
import static org.junit.Assert.assertTrue;
public class SubterrainRegionSelectionTest {
@Test
public void restrictedRoomsOnlyPlaceInTheirAllowedRegion() {
IrisSubterrainFeature feature = feature().setAllowedRegions(new KList<>("hot"));
SubterrainPlanner planner = planner(feature, (x, z) -> x < 0 ? "hot" : "frozen");
List<SubterrainPlan> plans = planner.plansForBounds(-1024, -1024, 1024, 1024);
assertFalse(plans.isEmpty());
for (SubterrainPlan plan : plans) {
assertTrue(plan.centerX() < 0);
SubterrainPosition anchor = plan.anchor();
assertTrue(planner.sample(anchor.x(), anchor.y(), anchor.z()).occupied());
}
assertTrue(planner(feature, (x, z) -> "frozen").plansForBounds(-1024, -1024, 1024, 1024).isEmpty());
}
@Test
public void unrestrictedRoomsDoNotSampleRegions() {
SubterrainPlanner planner = planner(feature(), (x, z) -> {
throw new AssertionError("Unrestricted room queried region");
});
assertFalse(planner.plansForBounds(-1024, -1024, 1024, 1024).isEmpty());
}
@Test
public void restrictionsSurviveSerializationSnapshotAndFootprintFiltering() {
IrisSubterrainFeature feature = feature().setAllowedRegions(new KList<>("hot"));
Gson gson = new Gson();
IrisSubterrainFeature restored = gson.fromJson(gson.toJson(feature), IrisSubterrainFeature.class);
SubterrainPlanner planner = planner(restored, (x, z) -> "hot");
List<SubterrainPlan> expected = planner.plansForBounds(-1024, -1024, 1024, 1024);
restored.getAllowedRegions().clear();
restored.getAllowedRegions().add("frozen");
SubterrainPlanner restricted = planner.restrictTo(bounds -> bounds.maxZ() < 0);
List<SubterrainPlan> actual = restricted.plansForBounds(-1024, -1024, 1024, 1024);
assertFalse(actual.isEmpty());
assertEquals(expected.stream().filter(plan -> plan.bounds().maxZ() < 0).map(SubterrainPlan::id).toList(),
actual.stream().map(SubterrainPlan::id).toList());
assertTrue(planner(restored, (x, z) -> "hot").plansForBounds(-1024, -1024, 1024, 1024).isEmpty());
}
@Test
public void invalidRegionKeysAreRejected() {
assertThrows(IllegalArgumentException.class,
() -> planner(feature().setAllowedRegions(new KList<>("bad region")), (x, z) -> "hot"));
}
private IrisSubterrainFeature feature() {
return new IrisSubterrainFeature().setId("regional-room").setProbability(1).setSpacing(256)
.setRadius(16).setHeight(24).setFluidDepth(0).setPillarSpacing(0).setFormationFraction(0)
.setWorldYRange(new IrisRange(-64, 64));
}
private SubterrainPlanner planner(IrisSubterrainFeature feature, SubterrainPlanner.RegionResolver regions) {
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1337L, -64, 128, regions));
}
}
@@ -2,6 +2,8 @@ package art.arcane.iris.generation.subterrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
import art.arcane.iris.generation.decoration.IrisDecorationPart;
import art.arcane.iris.generation.decoration.IrisDecorator;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.runtime.IrisComplex;
@@ -20,6 +22,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -60,7 +63,7 @@ public class SubterrainSurfaceMaterialsTest {
default -> fixture.fallback;
};
assertSame(expected, fixture.materials.state(position.x(), position.y(), position.z(), before));
assertTrue(SubterrainRasterizer.protectsPlacement(before));
assertEquals(before.room().reservedSolid(), SubterrainRasterizer.protectsPlacement(before));
assertEquals(before, fixture.cell(position));
int chunkX = position.x() >> 4;
int chunkZ = position.z() >> 4;
@@ -100,10 +103,61 @@ public class SubterrainSurfaceMaterialsTest {
output.setRaw(x, y, z, null);
modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output);
NativeBlockState expected = fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position));
assertSame(expected, output.getRaw(x, y, z));
assertSame(fixture.cell(position).room().reservedSolid() ? expected : null, output.getRaw(x, y, z));
output.setRaw(x, y, z, fixture.fallback);
modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output);
assertSame(expected, output.getRaw(x, y, z));
if (!fixture.cell(position).room().reservedSolid()) {
for (String existingKey : List.of("minecraft:cave_air", "minecraft:water", "minecraft:lava")) {
NativeBlockState opening = B.getState(existingKey);
output.setRaw(x, y, z, opening);
modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output);
assertSame(opening, output.getRaw(x, y, z));
}
}
}
}
}
@Test
public void floorDecoratorSubstratesSurviveBothPreservationPasses() throws Exception {
try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) {
Engine engine = mock(Engine.class);
when(engine.getComplex()).thenReturn(fixture.complex);
when(engine.getData()).thenReturn(fixture.data);
when(engine.getDimension()).thenReturn(fixture.dimension);
when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(128).build());
when(fixture.complex.getSubterrainPlanner()).thenReturn(fixture.planner);
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
doReturn(engine).when(modifier).getEngine();
Field random = IrisCarveModifier.class.getDeclaredField("rng");
random.setAccessible(true);
random.set(modifier, fixture.random);
Method restore = IrisCarveModifier.class.getDeclaredMethod("restoreSubterrainSolids", Hunk.class, int.class, int.class, boolean.class);
restore.setAccessible(true);
SubterrainPosition position = fixture.surfaces().get(SubterrainSurfaceMaterials.Surface.FLOOR);
int chunkX = position.x() >> 4;
int chunkZ = position.z() >> 4;
int x = position.x() & 15;
int y = position.y() + 64;
int z = position.z() & 15;
Hunk<NativeBlockState> output = Hunk.newArrayHunk(16, 192, 16);
for (String key : List.of("minecraft:moss_block", "minecraft:crimson_nylium")) {
NativeBlockState support = solid(key);
IrisBlockData forceBlock = mock(IrisBlockData.class);
when(forceBlock.getBlockData(fixture.data)).thenReturn(support);
when(fixture.biome.getDecoratorBucket(IrisDecorationPart.NONE))
.thenReturn(new IrisDecorator[]{new IrisDecorator().setForceBlock(forceBlock)});
output.setRaw(x, y, z, support);
restore.invoke(modifier, output, chunkX, chunkZ, true);
assertSame(support, output.getRaw(x, y, z));
modifier.preserveSubterrain(chunkX << 4, chunkZ << 4, output);
assertSame(support, output.getRaw(x, y, z));
when(support.isOccluding()).thenReturn(false);
modifier.preserveSubterrain(chunkX << 4, chunkZ << 4, output);
assertSame(fixture.floor, output.getRaw(x, y, z));
}
}
}
@@ -183,13 +237,14 @@ public class SubterrainSurfaceMaterialsTest {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("room").setBiome("room-biome")
.setFamily(family).setProbability(1).setRadius(12).setHeight(16).setLength(200)
.setChimneyHeight(8).setWorldYRange(new IrisRange(-48, 64));
planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128));
planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region"));
plan = planner.plansForBounds(-512, -512, 512, 512).getFirst();
plans = planner.plansForBounds(plan.bounds().minX() - 1, plan.bounds().minZ() - 1,
plan.bounds().maxX() + 1, plan.bounds().maxZ() + 1);
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
when(data.getBiomeLoader()).thenReturn(loader);
when(loader.load("room-biome")).thenReturn(biome);
when(biome.getDecoratorBucket(IrisDecorationPart.NONE)).thenReturn(new IrisDecorator[0]);
IrisBiomePaletteLayer wallPalette = mock(IrisBiomePaletteLayer.class);
when(biome.getWall()).thenReturn(wallPalette);
when(wallPalette.get(eq(random), anyDouble(), anyDouble(), anyDouble(), eq(data))).thenReturn(wall);
@@ -240,6 +295,7 @@ public class SubterrainSurfaceMaterialsTest {
Map<SubterrainPosition, String> result = new HashMap<>();
for (SubterrainPosition chunk : chunks) {
Hunk<NativeBlockState> output = Hunk.newArrayHunk(16, 192, 16);
output.fill(fallback);
for (int draw = 0; draw < 19; draw++) {
random.nextLong();
}
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "6f6dae0cd1077c45fe3f5ba512c1da4b0ab43bc8516f2d759bc8f7a798eb1c58";
private static final String EXPECTED_SCHEMA_DIGEST = "cc10defa73f5288d10714cdbdef6bda866acfc656efe54e062fac9995bc5607f";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -138,7 +138,7 @@ public class SchemaBuilderIdentityTest {
IrisDimension dimension = gson.fromJson("""
{"subterrainFeatures":[{"id":"flooded-vault","family":"CENOTE",
"biome":"pack/cavern","fluid":"LAVA","worldYRange":{"min":-40,"max":24},
"spacing":384,"probability":0.75,"radius":28,"fluidDepth":6}]}
"spacing":384,"probability":0.75,"radius":28,"fluidDepth":6,"shapeWarp":0.94,"allowedRegions":["hot"]}]}
""", IrisDimension.class);
assertEquals(1, dimension.getSubterrainFeatures().size());
IrisSubterrainFeature feature = dimension.getSubterrainFeatures().get(0);
@@ -152,6 +152,8 @@ public class SchemaBuilderIdentityTest {
assertEquals(0.75D, feature.getProbability(), 0D);
assertEquals(28, feature.getRadius());
assertEquals(6, feature.getFluidDepth());
assertEquals(0.94D, feature.getShapeWarp(), 0D);
assertEquals(List.of("hot"), feature.getAllowedRegions());
IrisDimension restored = gson.fromJson(gson.toJson(dimension), IrisDimension.class);
assertEquals(feature, restored.getSubterrainFeatures().get(0));
assertTrue(new IrisDimension().getSubterrainFeatures().isEmpty());
@@ -178,11 +180,14 @@ public class SchemaBuilderIdentityTest {
assertTrue(properties.has("biome"));
assertTrue(properties.has("fluid"));
assertTrue(properties.has("worldYRange"));
assertEquals("array", properties.getJSONObject("allowedRegions").getString("type"));
assertEquals(32, properties.getJSONObject("spacing").getInt("minimum"));
assertEquals(8192, properties.getJSONObject("spacing").getInt("maximum"));
assertEquals(0D, properties.getJSONObject("probability").getDouble("minimum"), 0D);
assertEquals(1D, properties.getJSONObject("probability").getDouble("maximum"), 0D);
assertEquals(32, properties.getJSONObject("fluidDepth").getInt("maximum"));
assertEquals(0D, properties.getJSONObject("shapeWarp").getDouble("minimum"), 0D);
assertEquals(1D, properties.getJSONObject("shapeWarp").getDouble("maximum"), 0D);
}
private static String digest(String value) {
@@ -213,7 +213,7 @@ public class SavedBiomeCaptureTest {
public void capturesFeatureBoundariesAtExactBlockCoordinates() throws Exception {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("exact-basin").setBiome("authored-biome")
.setProbability(1).setPillarSpacing(0).setFormationFraction(0);
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320));
SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region"));
SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst();
SubterrainPosition anchor = plan.anchor();
int edgeX = plan.bounds().minX();
@@ -33,7 +33,7 @@ public class SavedSubterrainQueriesTest {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("old-basin").setProbability(1D)
.setBiome("retired-cave").setRadius(28).setHeight(40).setFluidDepth(8)
.setWorldYRange(new IrisRange(-100, 100)).setFormationFraction(0).setPillarSpacing(0);
SubterrainPlanner historical = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -128, 256));
SubterrainPlanner historical = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -128, 256, (x, z) -> "test-region"));
SubterrainPlan plan = historical.plansForBounds(0, 0, 511, 511).getFirst();
SubterrainPosition anchor = plan.anchor();
SubterrainLocator.Result target = new SubterrainLocator.Result(plan.id(), plan.family(), plan.biome(),
@@ -126,6 +126,6 @@ public class SavedSubterrainQueriesTest {
private static SubterrainPlanner planner(String id) {
IrisSubterrainFeature feature = new IrisSubterrainFeature().setId(id).setProbability(1D);
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320));
return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region"));
}
}