diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 4d61690c4..e88004f40 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -4,6 +4,9 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; @@ -16,6 +19,7 @@ import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.Holder; import net.minecraft.core.QuartPos; import net.minecraft.core.Registry; @@ -24,6 +28,8 @@ import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.util.RandomSource; +import net.minecraft.util.Mth; +import net.minecraft.world.level.LevelReader; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.Climate; @@ -80,27 +86,31 @@ public class CustomBiomeSource extends BiomeSource { this.cacheRuntimeId = engine.getCacheID(); } - private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine) { + private static List> getAllBiomes( + Registry customRegistry, + Registry registry, + Engine engine, + boolean includeDimensionStack + ) { LinkedHashSet> biomes = new LinkedHashSet<>(); - for (IrisBiome i : engine.getAllBiomes()) { + for (OwnedBiome ownedBiome : getHostOwnedBiomes(engine)) { + IrisBiome i = ownedBiome.biome(); Holder vanillaHolder = resolveBiomeHolder(registry, i.getStructureDerivativeKey()); if (vanillaHolder == null) { throw new IllegalStateException("Iris structure biome derivative '" + i.getStructureDerivativeKey() + "' is not registered for biome '" + i.getLoadKey() + "'"); } biomes.add(vanillaHolder); + } - if (i.isCustom()) { - for (IrisBiomeCustom j : i.getCustomDerivitives()) { - Holder customHolder = resolveCustomBiomeHolder(customRegistry, engine, j.getId()); - if (customHolder == null) { - throw new IllegalStateException("Iris custom structure biome '" - + engine.getDimension().getLoadKey() + ":" + j.getId() + "' is not registered"); - } - biomes.add(customHolder); - } - } + if (includeDimensionStack) { + biomes.addAll(getVisibleBiomes( + customRegistry, + registry, + engine, + resolveFallbackBiome(registry, customRegistry) + )); } if (biomes.isEmpty()) { @@ -110,6 +120,57 @@ public class CustomBiomeSource extends BiomeSource { return new ArrayList<>(biomes); } + private static List> getVisibleBiomes( + Registry customRegistry, + Registry registry, + Engine engine, + Holder fallback + ) { + LinkedHashSet> biomes = new LinkedHashSet<>(); + boolean fallbackPossible = false; + for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) { + IrisBiome irisBiome = ownedBiome.biome(); + if (irisBiome.isCustom()) { + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + Holder customHolder = resolveCustomBiomeHolder( + customRegistry, ownedBiome.dimension(), customBiome.getId()); + if (customHolder == null) { + throw new IllegalStateException("Iris custom visible biome '" + + customBiomeKey(ownedBiome.dimension(), customBiome.getId()) + + "' is not registered"); + } + biomes.add(customHolder); + } + continue; + } + fallbackPossible |= !addVisibleBiomeHolder( + biomes, registry, irisBiome.getDerivativeKey()); + for (String scatter : irisBiome.getBiomeScatter()) { + fallbackPossible |= !addVisibleBiomeHolder(biomes, registry, scatter); + } + for (String scatter : irisBiome.getBiomeSkyScatter()) { + fallbackPossible |= !addVisibleBiomeHolder(biomes, registry, scatter); + } + } + if (fallbackPossible && fallback != null) { + biomes.add(fallback); + } + return new ArrayList<>(biomes); + } + + private static boolean addVisibleBiomeHolder( + Set> biomes, + Registry registry, + String biomeKey + ) { + Holder holder = resolveBiomeHolder(registry, biomeKey); + if (holder == null) { + return false; + } + biomes.add(holder); + return true; + } + private static Object getFor(Class type, Object source) { Object o = fieldFor(type, source); @@ -175,10 +236,19 @@ public class CustomBiomeSource extends BiomeSource { @Override protected Stream> collectPossibleBiomes() { - return possibleStructureBiomes().stream(); + return possibleBiomes(true).stream(); + } + + @Override + public Set> possibleBiomes() { + return possibleBiomes(true); } Set> possibleStructureBiomes() { + return possibleBiomes(false); + } + + private Set> possibleBiomes(boolean includeDimensionStack) { GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_possible_biomes"); if (lease == null) { throw new IllegalStateException("Iris possible biome lookup was rejected during an engine transition"); @@ -197,7 +267,8 @@ public class CustomBiomeSource extends BiomeSource { .lookup(Registries.BIOME).orElse(null); Registry worldRegistry = ((CraftWorld) world).getHandle().registryAccess() .lookup(Registries.BIOME).orElse(null); - return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine)); + return Set.copyOf(getAllBiomes( + customRegistry, worldRegistry, engine, includeDimensionStack)); } } @@ -207,18 +278,22 @@ public class CustomBiomeSource extends BiomeSource { return m; } - for (IrisBiome i : engine.getAllBiomes()) { + for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) { + IrisBiome i = ownedBiome.biome(); if (i.isCustom()) { for (IrisBiomeCustom j : i.getCustomDerivitives()) { - Holder holder = resolveCustomBiomeHolder(customRegistry, engine, j.getId()); + String key = customBiomeKey(ownedBiome.dimension(), j.getId()); + Holder holder = resolveCustomBiomeHolder( + customRegistry, ownedBiome.dimension(), j.getId()); if (holder == null) { if (fallback != null) { - m.put(j.getId(), fallback); + m.put(key, fallback); } - IrisLogging.error("Cannot find biome for IrisBiomeCustom " + j.getId() + " from engine " + engine.getName()); + IrisLogging.error("Cannot find biome for IrisBiomeCustom " + key + + " from engine " + engine.getName()); continue; } - m.put(j.getId(), holder); + m.put(key, holder); } } } @@ -232,7 +307,8 @@ public class CustomBiomeSource extends BiomeSource { return Collections.unmodifiableMap(spawnBiomes); } - for (IrisBiome irisBiome : engine.getAllBiomes()) { + for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) { + IrisBiome irisBiome = ownedBiome.biome(); if (!irisBiome.isCustom()) { continue; } @@ -241,7 +317,8 @@ public class CustomBiomeSource extends BiomeSource { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - Holder customHolder = resolveCustomBiomeHolder(customRegistry, engine, customBiome.getId()); + Holder customHolder = resolveCustomBiomeHolder( + customRegistry, ownedBiome.dimension(), customBiome.getId()); if (customHolder != null) { spawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); } @@ -268,6 +345,34 @@ public class CustomBiomeSource extends BiomeSource { } } + Holder getVisibleSurfaceBiome(int blockX, int blockZ) { + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_surface_spawn_biome"); + if (lease == null) { + return null; + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + return null; + } + ensureCachesCurrent(); + int quartX = QuartPos.fromBlock(blockX); + int quartZ = QuartPos.fromBlock(blockZ); + int sampleX = QuartPos.toBlock(quartX); + int sampleZ = QuartPos.toBlock(quartZ); + boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ); + DimensionStackContext stackContext = engine.getDimensionStackContext(); + DimensionStackLayout.Layer layer = stackContext == null + ? null + : stackContext.getLayout(sampleX, sampleZ).surfaceLayer(); + int internalY = layer == null + ? Engine.hostHeight(engine, sampleX, sampleZ, true) + : layer.clippedSurfaceY(); + int quartY = QuartPos.fromBlock(internalY + engine.getWorld().minHeight()); + return getVisibleNoiseBiomeWithActiveGenerationLease( + quartX, quartY, quartZ, null, null, cacheable); + } + } + private RegistryAccess registry() { RegistryAccess access = registryAccess.aquire(() -> (RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())); @@ -294,28 +399,38 @@ public class CustomBiomeSource extends BiomeSource { throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); } ensureCachesCurrent(); - if (isGuaranteedSurfaceBiome(y)) { - return getSurfaceStructureBiomeHolder(x, z); - } + return getStructureNoiseBiomeWithActiveGenerationLease(x, y, z); + } + } - long cacheKey = packNoiseKey(x, y, z); + private Holder getStructureNoiseBiomeWithActiveGenerationLease(int x, int y, int z) { + if (isGuaranteedSurfaceBiome(y)) { + return getSurfaceStructureBiomeHolder(x, z); + } + + boolean cacheable = isBiomeCacheable(x, z); + long cacheKey = packNoiseKey(x, y, z); + if (cacheable) { Holder cachedHolder = structureBiomeCache.get(cacheKey); if (cachedHolder != null) { return cachedHolder; } + } - Holder resolvedHolder = resolveStructureBiomeHolder(x, y, z); - Holder existingHolder = structureBiomeCache.putIfAbsent(cacheKey, resolvedHolder); - if (existingHolder != null) { - return existingHolder; - } - - if (structureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - structureBiomeCache.clear(); - } - + Holder resolvedHolder = resolveStructureBiomeHolder(x, y, z); + if (!cacheable) { return resolvedHolder; } + Holder existingHolder = structureBiomeCache.putIfAbsent(cacheKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + + if (structureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + structureBiomeCache.clear(); + } + + return resolvedHolder; } @Override @@ -346,6 +461,62 @@ public class CustomBiomeSource extends BiomeSource { } } + @Override + public Pair> findClosestBiome3d( + BlockPos origin, + int searchRadius, + int sampleResolutionHorizontal, + int sampleResolutionVertical, + Predicate> allowed, + Climate.Sampler sampler, + LevelReader level + ) { + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_locate_visible_biome"); + if (lease == null) { + throw new IllegalStateException("Iris visible biome search was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + prepareVisibleBiomeBatch(); + DimensionStackContext stackContext = engine.getDimensionStackContext(); + if (stackContext == null) { + return super.findClosestBiome3d( + origin, searchRadius, sampleResolutionHorizontal, sampleResolutionVertical, + allowed, sampler, level); + } + Set> candidates = new HashSet<>(); + for (Holder biome : getVisibleBiomes( + biomeCustomRegistry, biomeRegistry, engine, fallbackBiome)) { + if (allowed.test(biome)) { + candidates.add(biome); + } + } + if (candidates.isEmpty()) { + return null; + } + int sampleRadius = Math.floorDiv(searchRadius, sampleResolutionHorizontal); + int[] sampleYs = Mth.outFromOrigin( + origin.getY(), level.getMinY() + 1, level.getMaxY() + 1, sampleResolutionVertical).toArray(); + for (BlockPos.MutableBlockPos sampleColumn : BlockPos.spiralAround( + BlockPos.ZERO, sampleRadius, Direction.EAST, Direction.SOUTH)) { + int blockX = origin.getX() + sampleColumn.getX() * sampleResolutionHorizontal; + int blockZ = origin.getZ() + sampleColumn.getZ() * sampleResolutionHorizontal; + int quartX = QuartPos.fromBlock(blockX); + int quartZ = QuartPos.fromBlock(blockZ); + DimensionStackLayout stackLayout = stackContext.sample( + QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)); + for (int blockY : sampleYs) { + int quartY = QuartPos.fromBlock(blockY); + Holder visibleBiome = resolveVisibleBiomeHolder( + quartX, quartY, quartZ, null, stackLayout); + if (candidates.contains(visibleBiome)) { + return Pair.of(new BlockPos(blockX, blockY, blockZ), visibleBiome); + } + } + } + return null; + } + } + static int horizontalBiomeSearchQuartStep(int blockY, int searchRadius) { return blockY == STRONGHOLD_RING_SEARCH_Y && searchRadius == STRONGHOLD_RING_SEARCH_RADIUS ? STRONGHOLD_RING_SEARCH_QUART_STEP @@ -468,7 +639,7 @@ public class CustomBiomeSource extends BiomeSource { private Holder resolveSurfaceStructureBiomeHolder(int x, int z) { int blockX = x << 2; int blockZ = z << 2; - IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + IrisBiome irisBiome = resolveSurfaceStructureBiome(engine, blockX, blockZ); if (irisBiome == null) { throw new IllegalStateException("Iris returned no surface structure biome at block " + blockX + "," + blockZ); @@ -532,14 +703,32 @@ public class CustomBiomeSource extends BiomeSource { int z, Climate.Sampler sampler, IrisDimensionCarvingResolver.State resolverState + ) { + boolean cacheable = isBiomeCacheable(x, z); + return getVisibleNoiseBiomeWithActiveGenerationLease( + x, y, z, sampler, resolverState, cacheable); + } + + private Holder getVisibleNoiseBiomeWithActiveGenerationLease( + int x, + int y, + int z, + Climate.Sampler sampler, + IrisDimensionCarvingResolver.State resolverState, + boolean cacheable ) { long cacheKey = packNoiseKey(x, y, z); - Holder cachedHolder = noiseBiomeCache.get(cacheKey); - if (cachedHolder != null) { - return cachedHolder; + if (cacheable) { + Holder cachedHolder = noiseBiomeCache.get(cacheKey); + if (cachedHolder != null) { + return cachedHolder; + } } Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z, resolverState); + if (!cacheable) { + return resolvedHolder; + } Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); if (existingHolder != null) { return existingHolder; @@ -552,6 +741,18 @@ public class CustomBiomeSource extends BiomeSource { return resolvedHolder; } + private boolean isBiomeCacheable(int quartX, int quartZ) { + return isBiomeCacheable( + engine, + QuartPos.toBlock(quartX), + QuartPos.toBlock(quartZ) + ); + } + + static boolean isBiomeCacheable(Engine engine, int blockX, int blockZ) { + return !engine.answersFromNaturalTerrain(blockX, blockZ); + } + private GenerationSessionLease tryAcquireGenerationLease(String operation) { if (engine.isClosed()) { return null; @@ -597,7 +798,7 @@ public class CustomBiomeSource extends BiomeSource { } private Holder resolveStructureBiomeHolder(int x, int y, int z) { - BiomeResolution resolution = resolveBiomeResolution(x, y, z); + BiomeResolution resolution = resolveStructureBiomeResolution(x, y, z); if (resolution == null) { throw new IllegalStateException("Iris returned no structure biome at quart " + x + "," + y + "," + z); @@ -619,11 +820,26 @@ public class CustomBiomeSource extends BiomeSource { int z, IrisDimensionCarvingResolver.State resolverState ) { - BiomeResolution resolution = resolveBiomeResolution(x, y, z, resolverState); + return resolveVisibleBiomeHolder(x, y, z, resolverState, null); + } + + private Holder resolveVisibleBiomeHolder( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State resolverState, + DimensionStackLayout stackLayout + ) { + BiomeResolution resolution = resolveBiomeResolution( + x, y, z, resolverState, true, stackLayout); if (resolution == null) { return getFallbackBiome(); } + return resolveVisibleBiomeHolder(resolution); + } + + private Holder resolveVisibleBiomeHolder(BiomeResolution resolution) { if (resolution.irisBiome.isCustom()) { return resolveCustomHolder(resolution); } @@ -642,7 +858,8 @@ public class CustomBiomeSource extends BiomeSource { private Holder resolveCustomHolder(BiomeResolution resolution) { IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ); if (customBiome != null) { - Holder holder = customBiomes.get(customBiome.getId()); + Holder holder = customBiomes.get(customBiomeKey( + resolution.dimension, customBiome.getId())); if (holder != null) { return holder; } @@ -651,8 +868,8 @@ public class CustomBiomeSource extends BiomeSource { return getFallbackBiome(); } - private BiomeResolution resolveBiomeResolution(int x, int y, int z) { - return resolveBiomeResolution(x, y, z, null); + private BiomeResolution resolveStructureBiomeResolution(int x, int y, int z) { + return resolveBiomeResolution(x, y, z, null, false); } private BiomeResolution resolveBiomeResolution( @@ -660,6 +877,28 @@ public class CustomBiomeSource extends BiomeSource { int y, int z, IrisDimensionCarvingResolver.State resolverState + ) { + return resolveBiomeResolution(x, y, z, resolverState, true); + } + + private BiomeResolution resolveBiomeResolution( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State resolverState, + boolean includeDimensionStack + ) { + return resolveBiomeResolution( + x, y, z, resolverState, includeDimensionStack, null); + } + + private BiomeResolution resolveBiomeResolution( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State resolverState, + boolean includeDimensionStack, + DimensionStackLayout stackLayout ) { if (engine == null || engine.isClosed()) { return null; @@ -675,31 +914,63 @@ public class CustomBiomeSource extends BiomeSource { int worldMinHeight = engine.getWorld().minHeight(); int internalY = blockY - worldMinHeight; int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); + DimensionStackLayout.Layer stackLayer = includeDimensionStack + ? stackLayout == null + ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) + : stackLayout.layerAt(internalY) + : null; + IrisDimension owningDimension = stackLayer == null + ? engine.getDimension() + : stackLayer.terrainContext().getDimension(); + if (stackLayer != null && !stackLayer.terrainContext().isSelfReferencing()) { + IrisBiome stackedBiome = stackLayer.biome(); + return stackedBiome == null + ? null + : createBiomeResolution( + stackedBiome, false, owningDimension, blockX, blockY, blockZ); + } boolean deepUnderground = internalY <= caveSwitchInternalY; boolean underground = false; IrisBiome irisBiome; - if (deepUnderground) { - int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); + if (stackLayer != null) { + int surfaceInternalY = stackLayer.surfaceY(); + underground = internalY <= surfaceInternalY - 8; + irisBiome = underground + ? engine.getCaveBiome( + blockX, + internalY, + blockZ, + resolverState, + stackLayer.biome(), + surfaceInternalY + ) + : stackLayer.biome(); + } else if (deepUnderground) { + int surfaceInternalY = Engine.hostHeight(engine, blockX, blockZ, true); underground = internalY <= surfaceInternalY - 8; irisBiome = underground ? engine.getCaveBiome(blockX, internalY, blockZ, resolverState) - : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + : engine.getHostSurfaceBiome(blockX, blockZ); } else { - irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + irisBiome = engine.getHostSurfaceBiome(blockX, blockZ); } if (irisBiome == null && underground) { - irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + irisBiome = stackLayer == null + ? engine.getHostSurfaceBiome(blockX, blockZ) + : stackLayer.biome(); } if (irisBiome == null) { return null; } - return createBiomeResolution(irisBiome, underground, blockX, blockY, blockZ); + return createBiomeResolution( + irisBiome, underground, owningDimension, blockX, blockY, blockZ); } private BiomeResolution createBiomeResolution( IrisBiome irisBiome, boolean underground, + IrisDimension dimension, int blockX, int blockY, int blockZ @@ -708,7 +979,8 @@ public class CustomBiomeSource extends BiomeSource { ^ (((long) blockX) * 341873128712L) ^ (((long) blockY) * 132897987541L) ^ (((long) blockZ) * 42317861L)); - return new BiomeResolution(irisBiome, underground, blockX, blockY, blockZ, noiseRng); + return new BiomeResolution( + irisBiome, underground, dimension, blockX, blockY, blockZ, noiseRng); } private Holder getFallbackBiome() { @@ -734,15 +1006,20 @@ public class CustomBiomeSource extends BiomeSource { return ((long) x << 32) ^ ((long) z & 4294967295L); } - private static Holder resolveCustomBiomeHolder(Registry customRegistry, Engine engine, String customBiomeId) { - if (customRegistry == null || engine == null || customBiomeId == null || customBiomeId.isBlank()) { + private static Holder resolveCustomBiomeHolder( + Registry customRegistry, + IrisDimension dimension, + String customBiomeId + ) { + if (customRegistry == null || dimension == null + || customBiomeId == null || customBiomeId.isBlank()) { return null; } - Identifier resourceLocation = Identifier.fromNamespaceAndPath( - engine.getDimension().getLoadKey().toLowerCase(java.util.Locale.ROOT), - customBiomeId.toLowerCase(java.util.Locale.ROOT) - ); + Identifier resourceLocation = Identifier.tryParse(customBiomeKey(dimension, customBiomeId)); + if (resourceLocation == null) { + return null; + } Biome biome = customRegistry.getValue(resourceLocation); if (biome == null) { return null; @@ -761,6 +1038,63 @@ public class CustomBiomeSource extends BiomeSource { return optionalReferenceHolder.get(); } + static String customBiomeKey(IrisDimension dimension, String customBiomeId) { + return dimension.getCustomBiomeKey(customBiomeId); + } + + private static List getOwnedBiomes(Engine engine) { + ArrayList biomes = new ArrayList<>(getHostOwnedBiomes(engine)); + DimensionStackContext stackContext = engine.getDimensionStackContext(); + if (stackContext == null) { + return biomes; + } + for (DimensionTerrainContext terrainContext : stackContext.getLayersBottomToTop()) { + if (terrainContext.isSelfReferencing()) { + continue; + } + IrisDimension dimension = terrainContext.getDimension(); + addOwnedBiomes( + biomes, + dimension, + dimension.getReachableBiomes(terrainContext) + ); + } + return biomes; + } + + private static List getHostOwnedBiomes(Engine engine) { + IrisDimension hostDimension = engine.getDimension(); + ArrayList biomes = new ArrayList<>(); + addOwnedBiomes(biomes, hostDimension, hostDimension.getReachableBiomes(engine)); + return biomes; + } + + private static void addOwnedBiomes( + List target, + IrisDimension dimension, + Iterable biomes + ) { + for (IrisBiome biome : biomes) { + if (biome != null) { + target.add(new OwnedBiome(dimension, biome)); + } + } + } + + private static DimensionStackLayout.Layer resolveDimensionStackLayer( + Engine engine, + int blockX, + int internalY, + int blockZ + ) { + DimensionStackContext stackContext = engine.getDimensionStackContext(); + return stackContext == null ? null : stackContext.getLayerAt(blockX, internalY, blockZ); + } + + private static IrisBiome resolveSurfaceStructureBiome(Engine engine, int blockX, int blockZ) { + return engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } + private static Holder resolveBiomeHolder(Registry registry, String biomeKey) { if (registry == null || biomeKey == null || biomeKey.isBlank()) { return null; @@ -809,18 +1143,31 @@ public class CustomBiomeSource extends BiomeSource { private static final class BiomeResolution { private final IrisBiome irisBiome; private final boolean underground; + private final IrisDimension dimension; private final int blockX; private final int blockY; private final int blockZ; private final RNG rng; - private BiomeResolution(IrisBiome irisBiome, boolean underground, int blockX, int blockY, int blockZ, RNG rng) { + private BiomeResolution( + IrisBiome irisBiome, + boolean underground, + IrisDimension dimension, + int blockX, + int blockY, + int blockZ, + RNG rng + ) { this.irisBiome = irisBiome; this.underground = underground; + this.dimension = dimension; this.blockX = blockX; this.blockY = blockY; this.blockZ = blockZ; this.rng = rng; } } + + private record OwnedBiome(IrisDimension dimension, IrisBiome biome) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java index 06313f6fd..b821ca629 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java @@ -1,5 +1,7 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; import art.arcane.iris.engine.object.IrisBiome; @@ -40,6 +42,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; /** * Bukkit twin of the modded imported-feature stage. Same control, same semantics, same seeds: with @@ -198,8 +203,7 @@ final class ImportedFeatureStage { */ private Set visibleBiomeKeys() { Set keys = new LinkedHashSet<>(); - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : engine.getAllBiomes()) { + for (IrisBiome irisBiome : engine.getDimension().getReachableBiomes(engine)) { addKey(keys, irisBiome.getStructureDerivativeKey()); addKey(keys, irisBiome.getDerivativeKey()); addKey(keys, irisBiome.getVanillaDerivativeKey()); @@ -213,7 +217,7 @@ final class ImportedFeatureStage { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - addKey(keys, namespace + ":" + customBiome.getId()); + addKey(keys, engine.getDimension().getCustomBiomeKey(customBiome.getId())); } } return keys; @@ -226,8 +230,7 @@ final class ImportedFeatureStage { private Map> customBiomeDerivatives(Registry registry, Map> byKey) { Map> derivatives = new HashMap<>(); - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : engine.getAllBiomes()) { + for (IrisBiome irisBiome : engine.getDimension().getReachableBiomes(engine)) { if (!irisBiome.isCustom()) { continue; } @@ -246,7 +249,7 @@ final class ImportedFeatureStage { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - derivatives.put(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT), derivative); + derivatives.put(engine.getDimension().getCustomBiomeKey(customBiome.getId()), derivative); } } return derivatives; @@ -288,14 +291,24 @@ final class ImportedFeatureStage { List steps = table.steps(); WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ()); - Set> chunkBiomes = chunkBiomes(level, sectionPos, table); IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); int staticMinY = engine.getMinHeight(); - WorldGenLevel placementLevel = staticObjects.isEmpty() ? level : NativeStructureWorldgenAccess.create( + DimensionStackContext stackContext = engine.getDimensionStackContext(); + Set> chunkBiomes = chunkBiomes(level, sectionPos, table, stackContext); + IntBinaryOperator surfaceFirstFreeY = stackContext == null + ? (x, z) -> level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) + : (x, z) -> Engine.hostHeight(engine, x, z, false) + staticMinY + 1; + IntBinaryOperator floorFirstFreeY = stackContext == null + ? (x, z) -> level.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z) + : (x, z) -> Engine.hostHeight(engine, x, z, true) + staticMinY + 1; + WorldGenLevel placementLevel = staticObjects.isEmpty() && stackContext == null + ? level + : NativeStructureWorldgenAccess.create( level, centerPos, - (x, z) -> level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z), - (x, z) -> level.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z), - position -> staticObjects.contains(position.getX(), position.getY() - staticMinY, position.getZ())); + surfaceFirstFreeY, + floorFirstFreeY, + stackContext != null, + importedFeatureProtection(staticObjects, stackContext, staticMinY)); try { for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { @@ -313,6 +326,30 @@ final class ImportedFeatureStage { } } + private static Predicate importedFeatureProtection( + IrisStaticObjectLayer staticObjects, + DimensionStackContext stackContext, + int minimumY + ) { + Map layouts = new ConcurrentHashMap<>(); + return position -> { + if (!staticObjects.isEmpty() && staticObjects.contains( + position.getX(), position.getY() - minimumY, position.getZ())) { + return true; + } + if (stackContext == null) { + return false; + } + long columnKey = ((long) position.getX() << 32) + ^ (position.getZ() & 0xFFFFFFFFL); + DimensionStackLayout layout = layouts.computeIfAbsent( + columnKey, + ignored -> stackContext.sample(position.getX(), position.getZ()) + ); + return layout.isHostFeatureProtectedY(position.getY() - minimumY); + }; + } + private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData, Registry featureRegistry, Set> chunkBiomes, ChunkGenerator owner, WorldgenRandom random, long decorationSeed, @@ -353,12 +390,37 @@ final class ImportedFeatureStage { return id == null ? feature.toString() : id.toString(); } - private Set> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) { + private Set> chunkBiomes( + WorldGenLevel level, + SectionPos sectionPos, + FeatureTable table, + DimensionStackContext stackContext + ) { List> collected = new ArrayList<>(); ChunkPos.rangeClosed(sectionPos.chunk(), 1).forEach((ChunkPos chunkPos) -> { ChunkAccess neighbour = level.getChunk(chunkPos.x(), chunkPos.z()); - for (LevelChunkSection section : neighbour.getSections()) { - section.getBiomes().getAll(collected::add); + LevelChunkSection[] sections = neighbour.getSections(); + for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + LevelChunkSection section = sections[sectionIndex]; + if (stackContext == null) { + section.getBiomes().getAll(collected::add); + continue; + } + int sectionMinimumY = neighbour.getSectionYFromSectionIndex(sectionIndex) << 4; + for (int quartY = 0; quartY < 4; quartY++) { + int internalY = sectionMinimumY + (quartY << 2) - level.getMinY(); + for (int quartX = 0; quartX < 4; quartX++) { + int blockX = chunkPos.getMinBlockX() + (quartX << 2); + for (int quartZ = 0; quartZ < 4; quartZ++) { + int blockZ = chunkPos.getMinBlockZ() + (quartZ << 2); + DimensionStackLayout.Layer layer = stackContext.getLayerAt( + blockX, internalY, blockZ); + if (layer.terrainContext().isSelfReferencing()) { + collected.add(section.getBiomes().get(quartX, quartY, quartZ)); + } + } + } + } } }); Set> present = new LinkedHashSet<>(); diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 972726209..0d5679772 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -1,5 +1,7 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; @@ -115,6 +117,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; public class IrisChunkGenerator extends CustomChunkGenerator { private static final WrappedField BIOME_SOURCE; @@ -429,7 +432,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { undergroundStep, decision.preserveSourceY(), decision.yBand(), - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight()); StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication( start, structure, start.getReferences(), NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()), @@ -726,6 +729,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return (x, z) -> engine.getHeight(x, z, true) + runtimeMinY + 1; } + private IntBinaryOperator hostWorldgenSurfaceHeight() { + return (x, z) -> Engine.hostHeight(engine, x, z, false) + runtimeMinY + 1; + } + + private IntBinaryOperator hostWorldgenFloorHeight() { + return (x, z) -> Engine.hostHeight(engine, x, z, true) + runtimeMinY + 1; + } + @Override public WeightedList getMobsAt(Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { Holder vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder); @@ -868,17 +879,22 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } try { WorldgenTerrainHeightmaps.primeStructurePlacement( - world, chunkPos, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight()); + world, chunkPos, heightmapStarts, + hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight()); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "heightmap priming", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); - int staticMinY = engine.getMinHeight(); - WorldGenLevel boundedWorld = staticObjects.isEmpty() ? world : NativeStructureWorldgenAccess.create( - world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight(), - position -> staticObjects.contains(position.getX(), position.getY() - staticMinY, position.getZ())); + Predicate protectedPosition = nativeStructureProtection(staticObjects); + WorldGenLevel boundedWorld = staticObjects.isEmpty() + && engine.getDimensionStackContext() == null + ? world + : NativeStructureWorldgenAccess.create( + world, chunkPos, hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight(), + engine.getDimensionStackContext() != null, + protectedPosition); try { NativeStructureVegetationClearer.clearIntersectingVegetation( boundedWorld, chunk, area, vegetationTargets); @@ -891,7 +907,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { try { vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( boundedWorld, area, terrainTargets, - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight()); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "terrain integration", nativeStructureBatchContext(placementGroups), @@ -945,17 +961,45 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, IrisNativeStructureDecision decision) { WorldGenLevel boundedWorld = world instanceof NativeStructureWorldgenAccess ? world : NativeStructureWorldgenAccess.create( - world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight(), position -> false); + world, + chunkPos, + hostWorldgenSurfaceHeight(), + hostWorldgenFloorHeight(), + engine.getDimensionStackContext() != null, + nativeStructureProtection( + engine.getDimension().getStaticObjectLayer(engine.getData()))); world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); try { NativeStructurePostProcessor.place(boundedWorld, structureManager, this, random, area, chunkPos, structureId, start, decision, this::resolvePaletteBlock, - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight()); } finally { world.setCurrentlyGenerating(null); } } + private Predicate nativeStructureProtection(IrisStaticObjectLayer staticObjects) { + DimensionStackContext stackContext = engine.getDimensionStackContext(); + int minimumY = engine.getMinHeight(); + Map layouts = new ConcurrentHashMap<>(); + return position -> { + if (!staticObjects.isEmpty() && staticObjects.contains( + position.getX(), position.getY() - minimumY, position.getZ())) { + return true; + } + if (stackContext == null) { + return false; + } + long columnKey = ((long) position.getX() << 32) + ^ (position.getZ() & 0xFFFFFFFFL); + DimensionStackLayout layout = layouts.computeIfAbsent( + columnKey, + ignored -> stackContext.sample(position.getX(), position.getZ()) + ); + return layout.isHostFeatureProtectedY(position.getY() - minimumY); + }; + } + private List> structuresByStep(Registry registry) { StructureStepCache cached = structureStepCache; if (cached != null && cached.registry() == registry) { @@ -1041,7 +1085,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void spawnOriginalMobs(WorldGenRegion region) { ChunkPos center = region.getCenter(); - Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + Holder visibleBiome; + if (engine.getDimensionStackContext() == null) { + visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + } else { + visibleBiome = customBiomeSource.getVisibleSurfaceBiome( + center.getMinBlockX() + 8, + center.getMinBlockZ() + 8); + if (visibleBiome == null) { + visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + } + } Holder vanillaBiome = customBiomeSource.getVanillaSpawnBiome(visibleBiome); WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); @@ -1081,9 +1135,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { public int getBaseHeight(int i, int j, Heightmap.Types heightmap_type, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + boolean ignoreFluid = !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState()); + int height = engine.getDimensionStackContext() == null + ? engine.getHeight(i, j, ignoreFluid) + : Engine.hostHeight(engine, i, j, ignoreFluid); return levelheightaccessor.getMinY() - + engine.getHeight(i, j, !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState())) - + 1; + + height + 1; } catch (GenerationSessionException e) { throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e); } @@ -1093,8 +1150,13 @@ public class IrisChunkGenerator extends CustomChunkGenerator { public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_column"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - int block = engine.getHeight(i, j, true); - int water = engine.getHeight(i, j, false); + boolean dimensionStack = engine.getDimensionStackContext() != null; + int block = dimensionStack + ? Engine.hostHeight(engine, i, j, true) + : engine.getHeight(i, j, true); + int water = dimensionStack + ? Engine.hostHeight(engine, i, j, false) + : engine.getHeight(i, j, false); BlockState[] column = new BlockState[levelheightaccessor.getHeight()]; for (int k = 0; k < column.length; k++) { if (k <= block) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index dd45728a3..11e1bb828 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -824,6 +824,11 @@ public class NMSBinding implements INMSBinding { return NativeStructureVolumeIndex.volumes(engine, minX, minZ, maxX, maxZ); } + @Override + public void invalidateNativeStructureVolumeIndex(Engine engine) { + NativeStructureVolumeIndex.invalidate(engine); + } + @Override public int getBiomeId(Biome biome) { for (World i : Bukkit.getWorlds()) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java index 88c8dc94a..8b7ff3624 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java @@ -64,6 +64,7 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { private final ChunkPos generationCenter; private final IntBinaryOperator surfaceFirstFreeY; private final IntBinaryOperator floorFirstFreeY; + private final boolean virtualizeHeights; private final Predicate protectedPosition; private final Holder fallbackBiome; private final BiomeManager biomeManager; @@ -81,6 +82,7 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { boundary.surfaceFirstFreeY(), "Native structure world access requires a surface resolver"); this.floorFirstFreeY = Objects.requireNonNull( boundary.floorFirstFreeY(), "Native structure world access requires an ocean-floor resolver"); + this.virtualizeHeights = boundary.virtualizeHeights(); this.protectedPosition = Objects.requireNonNull( boundary.protectedPosition(), "World access requires a protected-position predicate"); BlockPos biomeSample = generationCenter.getMiddleBlockPosition(delegate.getSeaLevel()); @@ -103,9 +105,10 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { static NativeStructureWorldgenAccess create(WorldGenLevel delegate, ChunkPos generationCenter, IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator floorFirstFreeY, + boolean virtualizeHeights, Predicate protectedPosition) { return new NativeStructureWorldgenAccess(delegate, new Boundary( - generationCenter, surfaceFirstFreeY, floorFirstFreeY, protectedPosition)); + generationCenter, surfaceFirstFreeY, floorFirstFreeY, virtualizeHeights, protectedPosition)); } @Override @@ -250,7 +253,7 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { @Override public int getHeight(Heightmap.Types type, int x, int z) { - if (isInsideGenerationRegion(x >> 4, z >> 4)) { + if (!virtualizeHeights && isInsideGenerationRegion(x >> 4, z >> 4)) { return delegate.getHeight(type, x, z); } return height(type, x, z); @@ -549,6 +552,7 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { private record Boundary(ChunkPos generationCenter, IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator floorFirstFreeY, + boolean virtualizeHeights, Predicate protectedPosition) { } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 3d4d48e82..6adb555af 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -1,15 +1,29 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; import org.junit.Test; import java.io.IOException; +import java.lang.reflect.Proxy; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; public class CustomBiomeSourceStructureContractTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + @Test public void nativeStructuresUseTerrainSafeDerivativeAtEveryBiomeBoundary() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); @@ -69,14 +83,53 @@ public class CustomBiomeSourceStructureContractTest { String realResolution = source.substring(realResolutionStart, naturalResolutionStart); String naturalResolution = source.substring(naturalResolutionStart, resolutionEnd); - assertTrue(realResolution.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)")); + assertTrue(realResolution.contains("resolveSurfaceStructureBiome(engine, blockX, blockZ)")); assertFalse(realResolution.contains("getNaturalTrueBiomeStream()")); assertTrue(naturalResolution.contains( "engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ)")); assertFalse(naturalResolution.contains("getTrueBiomeStream()")); + int helperStart = source.indexOf("private static IrisBiome resolveSurfaceStructureBiome("); + int helperEnd = source.indexOf("private static Holder resolveBiomeHolder(", helperStart); + String helper = source.substring(helperStart, helperEnd); + assertTrue(helper.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(helper.contains("getDimensionStackContext()")); + assertFalse(helper.contains("getSurfaceBiome(")); assertFalse(source.contains("studioBootstrapSurfaceStructureBiomeCache")); } + @Test + public void stackedCustomBiomesUseTheirOwningDimensionNamespace() { + IrisDimension host = new IrisDimension(); + host.setLoadKey("Host"); + IrisDimension upper = new IrisDimension(); + upper.setLoadKey("Layers/Upper"); + + assertEquals("layers:upper/aurora", CustomBiomeSource.customBiomeKey(upper, "Aurora")); + assertNotEquals( + CustomBiomeSource.customBiomeKey(host, "Aurora"), + CustomBiomeSource.customBiomeKey(upper, "Aurora") + ); + } + + @Test + public void visibleBiomeResolutionSelectsStackOwnershipBeforeHostCaves() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int resolutionStart = source.indexOf("private BiomeResolution resolveBiomeResolution("); + int resolutionEnd = source.indexOf("private BiomeResolution createBiomeResolution(", resolutionStart); + + assertTrue(resolutionStart >= 0); + assertTrue(resolutionEnd > resolutionStart); + String resolution = source.substring(resolutionStart, resolutionEnd); + + assertTrue(resolution.contains("resolveDimensionStackLayer(")); + assertTrue(resolution.contains("!stackLayer.terrainContext().isSelfReferencing()")); + assertTrue(resolution.indexOf("resolveDimensionStackLayer(") + < resolution.indexOf("boolean deepUnderground")); + assertTrue(resolution.contains("includeDimensionStack")); + assertTrue(source.contains("resolveBiomeResolution(x, y, z, null, false)")); + assertTrue(source.contains("resolution.dimension, customBiome.getId()")); + } + @Test public void visibleBiomeBatchReusesTheCallersGenerationLease() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); @@ -94,4 +147,126 @@ public class CustomBiomeSourceStructureContractTest { < lookup.indexOf("getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler)")); assertFalse(source.contains("studioBootstrapNoiseBiomeCache")); } + + @Test + public void stackBiomesAreVisibleButExcludedFromStructureReachability() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + + assertTrue(source.contains("return possibleBiomes(true).stream()")); + assertTrue(source.contains("public Set> possibleBiomes()")); + assertTrue(source.contains("irisBiome.getDerivativeKey()")); + assertTrue(source.contains("irisBiome.getBiomeScatter()")); + assertTrue(source.contains("irisBiome.getBiomeSkyScatter()")); + assertTrue(source.contains("return possibleBiomes(false)")); + assertTrue(source.contains("for (OwnedBiome ownedBiome : getHostOwnedBiomes(engine))")); + assertTrue(source.contains("for (OwnedBiome ownedBiome : getOwnedBiomes(engine))")); + } + + @Test + public void biomeLocatorSearchesTheVisibleStack() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int start = source.indexOf("public Pair> findClosestBiome3d("); + int end = source.indexOf("static int horizontalBiomeSearchQuartStep", start); + String method = source.substring(start, end); + + assertTrue(method.contains("bukkit_locate_visible_biome")); + assertTrue(method.contains("return super.findClosestBiome3d(")); + assertTrue(method.contains("getVisibleBiomes(")); + assertTrue(method.contains("QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)")); + assertTrue(method.contains("resolveVisibleBiomeHolder(")); + assertFalse(method.contains("getStructureNoiseBiomeWithActiveGenerationLease(")); + assertTrue(method.indexOf("tryAcquireGenerationLease(\"bukkit_locate_visible_biome\")") + < method.indexOf("engine.getDimensionStackContext()")); + } + + @Test + public void surfaceSpawnBiomeUsesTheVisibleQuartCell() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int start = source.indexOf("Holder getVisibleSurfaceBiome("); + int end = source.indexOf("private RegistryAccess registry()", start); + String method = source.substring(start, end); + + assertTrue(method.contains("QuartPos.fromBlock(blockX)")); + assertTrue(method.contains("QuartPos.toBlock(quartX)")); + assertTrue(method.contains("QuartPos.fromBlock(internalY + engine.getWorld().minHeight())")); + assertTrue(method.contains("stackContext.getLayout(sampleX, sampleZ).surfaceLayer()")); + assertTrue(method.contains("getVisibleNoiseBiomeWithActiveGenerationLease(")); + assertTrue(method.contains("quartX, quartY, quartZ, null, null, cacheable")); + assertTrue(method.indexOf("boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ)") + < method.indexOf("Engine.hostHeight(engine, sampleX, sampleZ, true)")); + } + + @Test + public void temporaryNaturalBiomeAnswersAreNotMemoized() throws IOException { + Engine temporary = engineAnsweringNaturalFallback(true, 12, -8); + Engine stable = engineAnsweringNaturalFallback(false, 12, -8); + assertFalse(CustomBiomeSource.isBiomeCacheable(temporary, 12, -8)); + assertTrue(CustomBiomeSource.isBiomeCacheable(stable, 12, -8)); + + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int structureStart = source.indexOf( + "private Holder getStructureNoiseBiomeWithActiveGenerationLease("); + int structureEnd = source.indexOf("@Override\n public Pair> findBiomeHorizontal(", + structureStart); + assertCacheGuarded(source.substring(structureStart, structureEnd), "structureBiomeCache"); + + int firstVisible = source.indexOf( + "Holder getVisibleNoiseBiomeWithActiveGenerationLease("); + int visibleStart = source.indexOf( + "Holder getVisibleNoiseBiomeWithActiveGenerationLease(", firstVisible + 1); + int visibleEnd = source.indexOf("private boolean isBiomeCacheable(", visibleStart); + assertCacheGuarded(source.substring(visibleStart, visibleEnd), "noiseBiomeCache"); + + String predicate = source.substring( + visibleEnd, + source.indexOf("private GenerationSessionLease tryAcquireGenerationLease(", visibleEnd) + ); + assertTrue(predicate.contains("QuartPos.toBlock(quartX)")); + assertTrue(predicate.contains("QuartPos.toBlock(quartZ)")); + assertTrue(predicate.contains("return !engine.answersFromNaturalTerrain(blockX, blockZ)")); + assertFalse(predicate.contains("getDimensionStackContext()")); + } + + @Test + public void importedFeaturesCollectOnlyHostOwnedStackCells() throws IOException { + Path biomeSource = Path.of(System.getProperty("iris.customBiomeSource")); + String source = Files.readString(biomeSource.getParent().resolve("ImportedFeatureStage.java")); + + assertTrue(source.contains("stackContext.getLayerAt(")); + assertTrue(source.contains("layer.terrainContext().isSelfReferencing()")); + assertTrue(source.contains("section.getBiomes().get(quartX, quartY, quartZ)")); + } + + private static void assertCacheGuarded(String method, String cacheName) { + int predicate = method.indexOf("boolean cacheable = isBiomeCacheable("); + int read = method.indexOf(cacheName + ".get(", predicate); + int readGuard = method.lastIndexOf("if (cacheable)", read); + int bypass = method.indexOf("if (!cacheable)", read); + int write = method.indexOf(cacheName + ".putIfAbsent(", bypass); + + assertTrue(predicate >= 0); + assertTrue(read > predicate); + assertTrue(readGuard >= predicate); + assertTrue(bypass > read); + assertTrue(write > bypass); + } + + private static Engine engineAnsweringNaturalFallback( + boolean naturalFallback, + int expectedX, + int expectedZ + ) { + return (Engine) Proxy.newProxyInstance( + Engine.class.getClassLoader(), + new Class[]{Engine.class}, + (proxy, method, arguments) -> { + if (!method.getName().equals("answersFromNaturalTerrain")) { + throw new UnsupportedOperationException(method.getName()); + } + assertEquals(Integer.valueOf(expectedX), arguments[0]); + assertEquals(Integer.valueOf(expectedZ), arguments[1]); + return naturalFallback; + } + ); + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index aaeb3b7ab..29c83c914 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -78,6 +78,8 @@ public class IrisChunkGeneratorFailureContractTest { < placement.indexOf("for (NativePlacementGroup group")); assertTrue(placement.indexOf("repairVacuumFoundations") > placement.indexOf("for (NativePlacementGroup group")); + assertTrue(placement.contains("nativeStructureProtection(staticObjects)")); + assertTrue(placement.contains("Engine.hostHeight(engine, x, z, true)")); assertFalse(placement.contains("IrisLogging.reportError")); } @@ -542,8 +544,8 @@ public class IrisChunkGeneratorFailureContractTest { String spawn = source.substring(spawnStart, spawnEnd); assertTrue(spawn.contains("customBiomeSource.getVanillaSpawnBiome(visibleBiome)")); + assertTrue(spawn.contains("customBiomeSource.getVisibleSurfaceBiome(")); assertTrue(spawn.contains("NaturalSpawner.spawnMobsForChunkGeneration(")); - assertTrue(spawn.contains("region.getBiome(center.getWorldPosition().atY(region.getMaxY()))")); assertTrue(spawn.contains("new LegacyRandomSource(RandomSupport.generateUniqueSeed())")); assertTrue(spawn.contains("random.setDecorationSeed(region.getSeed()")); assertFalse(spawn.contains("delegate.spawnOriginalMobs")); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java index b38ff5853..99974602e 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java @@ -135,6 +135,24 @@ public class NativeStructureWorldgenAccessTest { assertEquals(1, recording.blockTicks.count()); } + @Test + public void virtualizedHeightsHideInRegionTerrainFromTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + ChunkPos generationCenter = generationCenter(); + NativeStructureWorldgenAccess access = NativeStructureWorldgenAccess.create( + recording.world(), generationCenter, + (x, z) -> SURFACE_FIRST_FREE_Y, + (x, z) -> FLOOR_FIRST_FREE_Y, + true, + position -> false); + int x = generationCenter.getMiddleBlockX(); + int z = generationCenter.getMiddleBlockZ(); + + assertEquals(SURFACE_FIRST_FREE_Y, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z)); + assertEquals(FLOOR_FIRST_FREE_Y, access.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z)); + assertEquals(0, recording.heightReads); + } + @Test public void protectedCellsRemainReadableButRejectWritesTilesAndTicks() { RecordingDelegate recording = new RecordingDelegate(); @@ -142,7 +160,9 @@ public class NativeStructureWorldgenAccessTest { NativeStructureWorldgenAccess access = NativeStructureWorldgenAccess.create( recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, - (x, z) -> FLOOR_FIRST_FREE_Y, position::equals); + (x, z) -> FLOOR_FIRST_FREE_Y, + false, + position::equals); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertFalse(access.ensureCanWrite(position)); @@ -211,7 +231,9 @@ public class NativeStructureWorldgenAccessTest { return NativeStructureWorldgenAccess.create( recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, - (x, z) -> FLOOR_FIRST_FREE_Y, position -> false); + (x, z) -> FLOOR_FIRST_FREE_Y, + false, + position -> false); } private static ChunkPos generationCenter() { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java index 22904dc89..29994ffce 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java @@ -332,6 +332,18 @@ public class NativeStructureFactoryTest { assertTrue(failureStart < source.indexOf("NativeStructureOwnershipStore.record(")); } + @Test + public void manualNativeStructureGenerationUsesTheRootTerrainHeight() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")), + StandardCharsets.UTF_8); + + assertTrue(source.contains( + "Engine.hostHeight(context.engine(), x, z, true)")); + assertFalse(source.contains( + "context.engine().getHeight(x, z, true)")); + } + @Test public void failedManualStartPublicationClearsRecordedOwnershipAndPreservesTheFailure() throws Exception { String source = Files.readString( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java index 47ad5e198..56957a8b7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java @@ -84,6 +84,11 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { "iris"); } + @Override + public void prepareRuntimeHotload(Engine engine) { + INMS.get().invalidateNativeStructureVolumeIndex(engine); + } + @Override public void applyWorldBoundary(Engine engine) { IrisDimension expectedDimension = engine.getDimension(); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java index 70c756c7d..4b9714b0f 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java @@ -163,7 +163,7 @@ public final class NativeStructureOwnershipRecovery { level, biome -> true, generator.getSeaLevel(), - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight() + (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight() ); return NativeStructureFactory.generate(generationContext, holder, plan, references); } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java index b868bb077..0a5d2d740 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java @@ -90,7 +90,7 @@ public final class NativeStructureStartInjector { chunk, biome -> true, context.generator().getSeaLevel(), - (x, z) -> context.engine().getHeight(x, z, true) + (x, z) -> Engine.hostHeight(context.engine(), x, z, true) + context.engine().getMinHeight() ); StructureStart generated = NativeStructureFactory.generate( diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 1a1a12f1e..e2f0942df 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -89,6 +89,18 @@ public final class NativeStructureVolumeIndex { } } + public static void invalidate(Engine engine) { + if (engine == null) { + return; + } + synchronized (INDEXES) { + NativeStructureVolumeIndex current = INDEXES.get(engine); + if (current != null) { + INDEXES.put(engine, current.fresh()); + } + } + } + public static KList volumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { NativeStructureVolumeIndex index = engine == null ? null : INDEXES.get(engine); return index == null ? NativeStructureVolume.NONE : index.resolve(engine, minX, minZ, maxX, maxZ); @@ -102,6 +114,12 @@ public final class NativeStructureVolumeIndex { return ORIGIN_REACH_CHUNKS; } + private NativeStructureVolumeIndex fresh() { + return context == null + ? new NativeStructureVolumeIndex(origins) + : new NativeStructureVolumeIndex(context); + } + KList resolve(Engine engine, int minX, int minZ, int maxX, int maxZ) { int fromChunkX = Math.min(minX, maxX) >> 4; int toChunkX = Math.max(minX, maxX) >> 4; @@ -323,7 +341,7 @@ public final class NativeStructureVolumeIndex { } private static IntBinaryOperator surfaceHeight(Engine engine) { - return (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight(); + return (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight(); } static KList appendPieces(KList volumes, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java index de3e09a03..3fa69137c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -18,16 +18,22 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.math.RNG; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.Holder; import net.minecraft.core.HolderLookup; import net.minecraft.core.QuartPos; @@ -37,12 +43,15 @@ import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.util.RandomSource; +import net.minecraft.util.Mth; +import net.minecraft.world.level.LevelReader; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.Climate; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureSet; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; @@ -196,11 +205,16 @@ final class IrisModdedBiomeSource extends BiomeSource { private List> collectPossibleBiomeHolders() { BiomeKeySets keys = biomeKeySets(); - Set generatedBiomeKeys = requireConfiguredStructureBiomeKeys(keys.required()); - Set visibleBiomeKeys = keys.visibleOnly(); + LinkedHashSet generatedBiomeKeys = new LinkedHashSet<>( + requireConfiguredStructureBiomeKeys(keys.structureRequired())); + generatedBiomeKeys.addAll(keys.visibleRequired()); + LinkedHashSet visibleBiomeKeys = new LinkedHashSet<>(keys.visibleOnly()); Registry registry = biomeRegistry(); LinkedHashSet> possible = new LinkedHashSet<>(); if (registry == null) { + if (keys.visibleFallbackPossible()) { + visibleBiomeKeys.add("minecraft:plains"); + } for (Holder biome : serializedSource.possibleBiomes()) { String key = holderKey(biome); if (isGeneratedBiomeKey(key, generatedBiomeKeys) @@ -215,6 +229,9 @@ final class IrisModdedBiomeSource extends BiomeSource { throw new IllegalStateException("Iris structure biomes are not registered: " + missingBiomeKeys); } + if (needsVisibleFallback(keys, registry)) { + visibleBiomeKeys.add("minecraft:plains"); + } // Registry-ordered, never a hash-ordered walk: see possibleBiomes(). registry.listElements().forEach((Holder.Reference reference) -> { String key = holderKey(reference); @@ -223,7 +240,7 @@ final class IrisModdedBiomeSource extends BiomeSource { possible.add(reference); } }); - warnUnregisteredVisibleBiomes(registry, visibleBiomeKeys); + warnUnregisteredVisibleBiomes(registry, keys.visibleOnly()); } if (possible.isEmpty()) { String phase = registry == null ? "serialized biome bootstrap" : "biome registry"; @@ -233,6 +250,36 @@ final class IrisModdedBiomeSource extends BiomeSource { return List.copyOf(possible); } + private List> collectVisibleBiomeHolders() { + BiomeKeySets keys = biomeKeySets(); + Set visibleBiomeKeys = keys.visibleEmittable(); + Registry registry = biomeRegistry(); + boolean includeFallback = registry == null + ? keys.visibleFallbackPossible() + : needsVisibleFallback(keys, registry); + ArrayList> visible = new ArrayList<>(); + for (Holder biome : resolvePossibleBiomes().ordered()) { + String key = holderKey(biome); + if (isGeneratedBiomeKey(key, visibleBiomeKeys) + || includeFallback && "minecraft:plains".equals(key)) { + visible.add(biome); + } + } + return List.copyOf(visible); + } + + private static boolean needsVisibleFallback(BiomeKeySets keys, Registry registry) { + if (keys.visibleFallbackPossible()) { + return true; + } + for (String key : keys.visibleEmittable()) { + if (resolveHolder(registry, key) == null) { + return true; + } + } + return false; + } + /** * Scatter and derivative keys are advisory: a typo there must not stop a world from loading the way a * missing structure biome does, so they are reported once and skipped. @@ -273,19 +320,91 @@ final class IrisModdedBiomeSource extends BiomeSource { } } + @Override + public Pair> findClosestBiome3d( + BlockPos origin, + int searchRadius, + int sampleResolutionHorizontal, + int sampleResolutionVertical, + Predicate> allowed, + Climate.Sampler sampler, + LevelReader level + ) { + Engine engine = engineOrNull(); + if (engine == null) { + return serializedSource.findClosestBiome3d( + origin, searchRadius, sampleResolutionHorizontal, sampleResolutionVertical, + allowed, sampler, level); + } + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_locate_visible_biome"); + if (lease == null) { + throw new IllegalStateException("Iris visible biome search was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris visible biome search has no active engine runtime"); + } + DimensionStackContext stackContext = engine.getDimensionStackContext(); + if (stackContext == null) { + return super.findClosestBiome3d( + origin, searchRadius, sampleResolutionHorizontal, sampleResolutionVertical, + allowed, sampler, level); + } + Set> candidates = new HashSet<>(); + for (Holder biome : collectVisibleBiomeHolders()) { + if (allowed.test(biome)) { + candidates.add(biome); + } + } + if (candidates.isEmpty()) { + return null; + } + int sampleRadius = Math.floorDiv(searchRadius, sampleResolutionHorizontal); + int[] sampleYs = Mth.outFromOrigin( + origin.getY(), level.getMinY() + 1, level.getMaxY() + 1, sampleResolutionVertical).toArray(); + for (BlockPos.MutableBlockPos sampleColumn : BlockPos.spiralAround( + BlockPos.ZERO, sampleRadius, Direction.EAST, Direction.SOUTH)) { + int blockX = origin.getX() + sampleColumn.getX() * sampleResolutionHorizontal; + int blockZ = origin.getZ() + sampleColumn.getZ() * sampleResolutionHorizontal; + int quartX = QuartPos.fromBlock(blockX); + int quartZ = QuartPos.fromBlock(blockZ); + DimensionStackLayout stackLayout = stackContext.sample( + QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)); + for (int blockY : sampleYs) { + int quartY = QuartPos.fromBlock(blockY); + Holder visibleBiome = resolveVisibleBiome( + engine, quartX, quartY, quartZ, sampler, stackLayout); + if (candidates.contains(visibleBiome)) { + return Pair.of(new BlockPos(blockX, blockY, blockZ), visibleBiome); + } + } + } + return null; + } + } + private Holder getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, Climate.Sampler sampler) { if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); } + boolean cacheable = isBiomeCacheable( + engine, + QuartPos.toBlock(quartX), + QuartPos.toBlock(quartZ) + ); long key = packNoiseKey(quartX, quartY, quartZ); BiomeHolderTable cache = structureBiomeCache; - Holder cached = cache.get(key); - if (cached != null) { - return cached; + if (cacheable) { + Holder cached = cache.get(key); + if (cached != null) { + return cached; + } } Holder resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler); - cache.put(key, resolved); + if (cacheable) { + cache.put(key, resolved); + } return resolved; } @@ -302,14 +421,81 @@ final class IrisModdedBiomeSource extends BiomeSource { if (!isReady(engine)) { throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); } - long key = packNoiseKey(quartX, quartY, quartZ); - BiomeHolderTable cache = visibleBiomeCache; + return getVisibleNoiseBiomeWithActiveGenerationLease( + engine, quartX, quartY, quartZ, sampler); + } + } + + private Holder getVisibleNoiseBiomeWithActiveGenerationLease( + Engine engine, + int quartX, + int quartY, + int quartZ, + Climate.Sampler sampler + ) { + boolean cacheable = isBiomeCacheable( + engine, + QuartPos.toBlock(quartX), + QuartPos.toBlock(quartZ) + ); + long key = packNoiseKey(quartX, quartY, quartZ); + BiomeHolderTable cache = visibleBiomeCache; + if (cacheable) { Holder cached = cache.get(key); if (cached != null) { return cached; } - Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); + } + Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); + if (cacheable) { cache.put(key, resolved); + } + return resolved; + } + + Holder getVisibleSurfaceBiome(int blockX, int blockZ) { + Engine engine = engineOrNull(); + if (engine == null) { + return null; + } + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome"); + if (lease == null) { + return null; + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + return null; + } + Registry registry = biomeRegistry(); + if (registry == null) { + return null; + } + int quartX = QuartPos.fromBlock(blockX); + int quartZ = QuartPos.fromBlock(blockZ); + int sampleX = QuartPos.toBlock(quartX); + int sampleZ = QuartPos.toBlock(quartZ); + boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ); + DimensionStackContext stackContext = engine.getDimensionStackContext(); + DimensionStackLayout.Layer layer = stackContext == null + ? null + : stackContext.getLayout(sampleX, sampleZ).surfaceLayer(); + int internalY = layer == null + ? Engine.hostHeight(engine, sampleX, sampleZ, true) + : layer.clippedSurfaceY(); + int quartY = QuartPos.fromBlock(internalY + engine.getMinHeight()); + long key = packNoiseKey(quartX, quartY, quartZ); + BiomeHolderTable cache = visibleBiomeCache; + if (cacheable) { + Holder cached = cache.get(key); + if (cached != null) { + return cached; + } + } + Holder resolved = resolveVisibleBiome( + engine, quartX, quartY, quartZ, null); + if (cacheable) { + cache.put(key, resolved); + } return resolved; } } @@ -395,7 +581,9 @@ final class IrisModdedBiomeSource extends BiomeSource { if (registry == null) { throw new IllegalStateException("Iris structure biome lookup has no biome registry"); } - IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); + int blockX = quartX << 2; + int blockZ = quartZ << 2; + IrisBiome irisBiome = resolveSurfaceStructureBiome(engine, blockX, blockZ); if (irisBiome == null) { throw new IllegalStateException("Iris returned no surface structure biome at quart " + quartX + "," + quartZ); @@ -412,7 +600,8 @@ final class IrisModdedBiomeSource extends BiomeSource { private Holder resolveStructureBiome(Engine engine, int quartX, int quartY, int quartZ, Climate.Sampler sampler) { Registry registry = biomeRegistry(); - BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + BiomeResolution resolution = resolveStructureBiomeResolution( + engine, quartX, quartY, quartZ); if (resolution == null) { throw new IllegalStateException("Iris returned no structure biome at quart " + quartX + "," + quartY + "," + quartZ); @@ -431,11 +620,36 @@ final class IrisModdedBiomeSource extends BiomeSource { private Holder resolveVisibleBiome(Engine engine, int quartX, int quartY, int quartZ, Climate.Sampler sampler) { + return resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler, null); + } + + private Holder resolveVisibleBiome( + Engine engine, + int quartX, + int quartY, + int quartZ, + Climate.Sampler sampler, + DimensionStackLayout stackLayout + ) { Registry registry = biomeRegistry(); - BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + BiomeResolution resolution = resolveBiomeResolution( + engine, quartX, quartY, quartZ, true, stackLayout); if (resolution == null || registry == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } + return resolveVisibleBiome( + engine, registry, resolution, quartX, quartY, quartZ, sampler); + } + + private Holder resolveVisibleBiome( + Engine engine, + Registry registry, + BiomeResolution resolution, + int quartX, + int quartY, + int quartZ, + Climate.Sampler sampler + ) { String biomeKey; if (resolution.irisBiome().isCustom()) { IrisBiomeCustom customBiome = resolution.irisBiome().getCustomBiome( @@ -444,7 +658,8 @@ final class IrisModdedBiomeSource extends BiomeSource { return fallbackBiome(registry, "custom derivative of '" + resolution.irisBiome().getLoadKey() + "'", quartX, quartY, quartZ, sampler); } - biomeKey = ModdedWorldgenIds.biomeRef(engine, customBiome.getId()); + biomeKey = customBiomeRef( + resolution.packName(), resolution.dimension(), customBiome.getId()); } else if (resolution.underground()) { biomeKey = resolution.irisBiome().getGroundBiomeKey( resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); @@ -459,6 +674,37 @@ final class IrisModdedBiomeSource extends BiomeSource { } private BiomeResolution resolveBiomeResolution(Engine engine, int quartX, int quartY, int quartZ) { + return resolveBiomeResolution(engine, quartX, quartY, quartZ, true); + } + + private BiomeResolution resolveStructureBiomeResolution( + Engine engine, + int quartX, + int quartY, + int quartZ + ) { + return resolveBiomeResolution(engine, quartX, quartY, quartZ, false); + } + + private BiomeResolution resolveBiomeResolution( + Engine engine, + int quartX, + int quartY, + int quartZ, + boolean includeDimensionStack + ) { + return resolveBiomeResolution( + engine, quartX, quartY, quartZ, includeDimensionStack, null); + } + + private BiomeResolution resolveBiomeResolution( + Engine engine, + int quartX, + int quartY, + int quartZ, + boolean includeDimensionStack, + DimensionStackLayout stackLayout + ) { if (!isReady(engine)) { return null; } @@ -467,27 +713,75 @@ final class IrisModdedBiomeSource extends BiomeSource { int blockZ = quartZ << 2; int internalY = blockY - engine.getMinHeight(); int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); + DimensionStackLayout.Layer stackLayer = includeDimensionStack + ? stackLayout == null + ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) + : stackLayout.layerAt(internalY) + : null; + IrisDimension owningDimension = stackLayer == null + ? engine.getDimension() + : stackLayer.terrainContext().getDimension(); + IrisData owningData = stackLayer == null + ? engine.getData() + : stackLayer.terrainContext().getData(); + if (stackLayer != null && !stackLayer.terrainContext().isSelfReferencing()) { + IrisBiome stackedBiome = stackLayer.biome(); + if (stackedBiome == null) { + return null; + } + return createBiomeResolution( + engine, + stackedBiome, + false, + owningDimension, + owningData, + blockX, + blockY, + blockZ + ); + } boolean underground = false; IrisBiome irisBiome; - if (internalY <= caveSwitchY) { - int surfaceY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); + if (stackLayer != null) { + int surfaceY = stackLayer.surfaceY(); + underground = isUnderground(internalY, surfaceY); + irisBiome = underground + ? engine.getCaveBiome( + blockX, + internalY, + blockZ, + null, + stackLayer.biome(), + surfaceY + ) + : stackLayer.biome(); + } else if (internalY <= caveSwitchY) { + int surfaceY = Engine.hostHeight(engine, blockX, blockZ, true); underground = isUnderground(internalY, surfaceY); irisBiome = underground ? engine.getCaveBiome(blockX, internalY, blockZ) - : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + : engine.getHostSurfaceBiome(blockX, blockZ); } else { - irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + irisBiome = engine.getHostSurfaceBiome(blockX, blockZ); } if (irisBiome == null && underground) { - irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + irisBiome = stackLayer == null + ? engine.getHostSurfaceBiome(blockX, blockZ) + : stackLayer.biome(); } if (irisBiome == null) { return null; } - IrisModdedChunkGenerator current = generator; - long worldSeed = current == null ? engine.getWorld().getRawWorldSeed() : current.visibleBiomeSeed(); - long seed = biomeResolutionSeed(worldSeed, blockX, blockY, blockZ); - return new BiomeResolution(irisBiome, underground, blockX, blockY, blockZ, new RNG(seed)); + return createBiomeResolution( + engine, + irisBiome, + underground, + owningDimension, + owningData, + blockX, + blockY, + blockZ + ); } private Holder resolveRequiredStructureBiome(int quartX, int quartY, int quartZ) { @@ -510,9 +804,10 @@ final class IrisModdedBiomeSource extends BiomeSource { } IrisBiome irisBiome; if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { - irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); + irisBiome = resolveSurfaceStructureBiome(engine, quartX << 2, quartZ << 2); } else { - BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + BiomeResolution resolution = resolveStructureBiomeResolution( + engine, quartX, quartY, quartZ); irisBiome = resolution == null ? null : resolution.irisBiome(); } if (irisBiome == null) { @@ -557,6 +852,10 @@ final class IrisModdedBiomeSource extends BiomeSource { return internalY <= surfaceY - 8; } + static boolean isBiomeCacheable(Engine engine, int blockX, int blockZ) { + return !engine.answersFromNaturalTerrain(blockX, blockZ); + } + static boolean isMonumentSurfaceBiomeQuery(int blockY, int radius, int minHeight, int fluidHeight) { return radius == 29 && blockY == minHeight + fluidHeight; } @@ -612,7 +911,7 @@ final class IrisModdedBiomeSource extends BiomeSource { } private Set exactStructureBiomeKeys() { - return biomeKeySets().required(); + return biomeKeySets().structureRequired(); } /** @@ -636,11 +935,12 @@ final class IrisModdedBiomeSource extends BiomeSource { private BiomeKeySets collectBiomeKeySets(long generation) { IrisModdedChunkGenerator current = generator; if (current == null) { - return new BiomeKeySets(generation, Set.of(), Set.of()); + return new BiomeKeySets(generation, Set.of(), Set.of(), Set.of(), Set.of(), false); } Engine engine = current.structureEngineOrNull(); if (engine == null) { - return new BiomeKeySets(generation, current.configuredStructureBiomeKeys(), Set.of()); + return new BiomeKeySets( + generation, current.configuredStructureBiomeKeys(), Set.of(), Set.of(), Set.of(), false); } GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys"); if (lease == null) { @@ -650,36 +950,154 @@ final class IrisModdedBiomeSource extends BiomeSource { if (!isReady(engine)) { throw new IllegalStateException("Iris structure biome key lookup has no active engine runtime"); } - LinkedHashSet required = new LinkedHashSet<>(); - LinkedHashSet visible = new LinkedHashSet<>(); - for (IrisBiome irisBiome : engine.getAllBiomes()) { - String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); - if (derivative != null) { - required.add(derivative); - } + Set structureRequired = collectStructureBiomeKeys( + engine.getDimension(), engine.getData()); + LinkedHashSet visibleRequired = new LinkedHashSet<>(); + LinkedHashSet visibleEmittable = new LinkedHashSet<>(); + boolean visibleFallbackPossible = false; + for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) { + IrisBiome irisBiome = ownedBiome.biome(); if (irisBiome.isCustom()) { for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId())); + String customKey = customBiomeRef( + ownedBiome.data().getDataFolder().getName(), + ownedBiome.dimension(), + customBiome.getId() + ); + visibleRequired.add(customKey); + visibleEmittable.add(customKey); } + continue; } - addVisibleBiomeKey(visible, irisBiome.getDerivativeKey()); + visibleFallbackPossible |= !addVisibleBiomeKey( + visibleEmittable, irisBiome.getDerivativeKey()); for (String scatter : irisBiome.getBiomeScatter()) { - addVisibleBiomeKey(visible, scatter); + visibleFallbackPossible |= !addVisibleBiomeKey(visibleEmittable, scatter); } for (String scatter : irisBiome.getBiomeSkyScatter()) { - addVisibleBiomeKey(visible, scatter); + visibleFallbackPossible |= !addVisibleBiomeKey(visibleEmittable, scatter); } } - visible.removeAll(required); - return new BiomeKeySets(generation, Set.copyOf(required), Set.copyOf(visible)); + LinkedHashSet visible = new LinkedHashSet<>(visibleEmittable); + visible.removeAll(structureRequired); + visible.removeAll(visibleRequired); + return new BiomeKeySets( + generation, + Set.copyOf(structureRequired), + Set.copyOf(visibleRequired), + Set.copyOf(visible), + Set.copyOf(visibleEmittable), + visibleFallbackPossible + ); } } - private static void addVisibleBiomeKey(Set target, String key) { - String normalized = normalizeKey(key); - if (normalized != null) { - target.add(normalized); + static Set collectStructureBiomeKeys(IrisDimension dimension, IrisData data) { + LinkedHashSet keys = new LinkedHashSet<>(); + for (IrisBiome irisBiome : dimension.getReachableBiomes(() -> data)) { + String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); + if (derivative != null) { + keys.add(derivative); + } } + return Set.copyOf(keys); + } + + private static boolean addVisibleBiomeKey(Set target, String key) { + String normalized = normalizeKey(key); + if (normalized == null) { + return false; + } + target.add(normalized); + return true; + } + + static String customBiomeRef(String packName, IrisDimension dimension, String customBiomeId) { + return ModdedWorldgenIds.biomeRef(packName, dimension.getLoadKey(), customBiomeId); + } + + private BiomeResolution createBiomeResolution( + Engine engine, + IrisBiome irisBiome, + boolean underground, + IrisDimension dimension, + IrisData data, + int blockX, + int blockY, + int blockZ + ) { + IrisModdedChunkGenerator current = generator; + long worldSeed = current == null + ? engine.getWorld().getRawWorldSeed() + : current.visibleBiomeSeed(); + long seed = biomeResolutionSeed(worldSeed, blockX, blockY, blockZ); + return new BiomeResolution( + irisBiome, + underground, + dimension, + data.getDataFolder().getName(), + blockX, + blockY, + blockZ, + new RNG(seed) + ); + } + + private static List getOwnedBiomes(Engine engine) { + ArrayList biomes = new ArrayList<>(); + IrisDimension hostDimension = engine.getDimension(); + IrisData hostData = engine.getData(); + addOwnedBiomes( + biomes, + hostDimension, + hostData, + hostDimension.getReachableBiomes(engine) + ); + DimensionStackContext stackContext = engine.getDimensionStackContext(); + if (stackContext == null) { + return biomes; + } + for (DimensionTerrainContext terrainContext : stackContext.getLayersBottomToTop()) { + if (terrainContext.isSelfReferencing()) { + continue; + } + IrisDimension dimension = terrainContext.getDimension(); + IrisData data = terrainContext.getData(); + addOwnedBiomes( + biomes, + dimension, + data, + dimension.getReachableBiomes(terrainContext) + ); + } + return biomes; + } + + private static void addOwnedBiomes( + List target, + IrisDimension dimension, + IrisData data, + Iterable biomes + ) { + for (IrisBiome biome : biomes) { + if (biome != null) { + target.add(new OwnedBiome(dimension, data, biome)); + } + } + } + + private static DimensionStackLayout.Layer resolveDimensionStackLayer( + Engine engine, + int blockX, + int internalY, + int blockZ + ) { + DimensionStackContext stackContext = engine.getDimensionStackContext(); + return stackContext == null ? null : stackContext.getLayerAt(blockX, internalY, blockZ); + } + + private static IrisBiome resolveSurfaceStructureBiome(Engine engine, int blockX, int blockZ) { + return engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); } private static Set registeredBiomeKeys(Registry registry) { @@ -726,7 +1144,13 @@ final class IrisModdedBiomeSource extends BiomeSource { Holder plains = resolveHolder(registry, "minecraft:plains"); warnUnresolvedBiome(unresolvedKey, plains == null ? "the serialized biome source" : "minecraft:plains", quartX, quartY, quartZ); - return plains == null ? serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler) : plains; + if (plains != null) { + return plains; + } + if (sampler == null) { + throw new IllegalStateException("Iris cannot resolve a registered fallback biome"); + } + return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } private void warnUnresolvedBiome(String unresolvedKey, String fallback, @@ -753,11 +1177,29 @@ final class IrisModdedBiomeSource extends BiomeSource { return ((long) x << 32) ^ ((long) z & 4294967295L); } - private record BiomeResolution(IrisBiome irisBiome, boolean underground, int blockX, int blockY, - int blockZ, RNG rng) { + private record BiomeResolution( + IrisBiome irisBiome, + boolean underground, + IrisDimension dimension, + String packName, + int blockX, + int blockY, + int blockZ, + RNG rng + ) { } - private record BiomeKeySets(long generation, Set required, Set visibleOnly) { + private record OwnedBiome(IrisDimension dimension, IrisData data, IrisBiome biome) { + } + + private record BiomeKeySets( + long generation, + Set structureRequired, + Set visibleRequired, + Set visibleOnly, + Set visibleEmittable, + boolean visibleFallbackPossible + ) { } private record PossibleBiomes(long generation, List> ordered, Set> set) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index d72be750e..d149a70fc 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.nativegen.NativeStructureStartInjector; import art.arcane.iris.nativegen.NativeStructureReferenceRepair; import art.arcane.iris.nativegen.NativeStructureVanillaLocator; +import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -89,7 +90,9 @@ import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -181,11 +184,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } requireGlobalStructureGeneration( level.getServer().getWorldGenSettings().options().generateStructures(), dimensionKey); + Engine activeEngine = engineIfBound(); Engine replacement = ModdedWorldEngines.prepareReplacement(level, pack, packDimensionKey, seed); try { + if (activeEngine != null) { + requireStructureBiomeUniverseCompatible( + activeEngine.getDimension(), replacement.getDimension()); + } + nativeStructures.installVolumeIndex(level, replacement); ModdedWorldEngines.installReplacement(level, replacement); replacement.getPlatformHooks().applyWorldBoundary(replacement); } catch (Throwable error) { + NativeStructureVolumeIndex.uninstall(replacement); try { ModdedWorldEngines.closeUnregistered(replacement); } catch (Throwable cleanupError) { @@ -203,17 +213,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.activePack = pack; this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; - this.engine = replacement; this.configuredStructureBiomeKeys = null; this.configuredPack = null; this.heightMetadata = engineHeights(replacement); this.engineBinding.reset(); - this.engineBinding.complete(replacement); this.announced.set(false); - this.structureBiomeSource.clearCaches(); - this.importedFeatures.invalidate(); - this.nativeStructures.clearWorldCheckStructureShifts(); - this.spawnTables.resetVanillaSpawnBiomes(); + resetRuntimeCaches(); + this.engine = replacement; + this.engineBinding.complete(replacement); // Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off. // Never waits on a build owned by another thread: this method owns the generator monitor and the build // path can need it. @@ -751,7 +758,38 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return lastChunkGenAt; } - public void onHotload() { + public void prepareRuntimeHotload(ServerLevel level, Engine current) { + if (this.engine != current || level.getChunkSource().getGenerator() != this) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' cannot prepare caches for an unrelated engine hotload"); + } + resetRuntimeCaches(); + nativeStructures.installVolumeIndex(level, current); + } + + public static void requireStructureBiomeUniverseCompatible( + IrisDimension active, + IrisDimension replacement + ) { + IrisData activeData = Objects.requireNonNull(active.getLoader(), + "Active Iris dimension has no pack loader"); + IrisData replacementData = Objects.requireNonNull(replacement.getLoader(), + "Replacement Iris dimension has no pack loader"); + requireStructureBiomeUniverseCompatible( + IrisModdedBiomeSource.collectStructureBiomeKeys(active, activeData), + IrisModdedBiomeSource.collectStructureBiomeKeys(replacement, replacementData)); + } + + static void requireStructureBiomeUniverseCompatible(Set active, Set replacement) { + if (active.equals(replacement)) { + return; + } + throw new IllegalArgumentException("Iris cannot hotload a changed host structure-biome derivative set " + + "from " + new TreeSet<>(active) + " to " + new TreeSet<>(replacement) + + ". Restart the server so Minecraft can rebuild its immutable structure state."); + } + + private void resetRuntimeCaches() { configuredStructureBiomeKeys = null; structureBiomeSource.clearCaches(); importedFeatures.invalidate(); @@ -986,7 +1024,17 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Registry registry = region.registryAccess().lookupOrThrow(Registries.BIOME); spawnTables.initializeVanillaSpawnBiomes(registry); ChunkPos center = region.getCenter(); - Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + Holder visibleBiome; + if (engine == null || engine.getDimensionStackContext() == null) { + visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + } else { + visibleBiome = structureBiomeSource.getVisibleSurfaceBiome( + center.getMinBlockX() + 8, + center.getMinBlockZ() + 8); + if (visibleBiome == null) { + visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + } + } Holder vanillaBiome = spawnTables.vanillaSpawnBiome(visibleBiome.value()); WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); @@ -1029,7 +1077,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - return heightAccessor.getMinY() + current.getHeight(x, z, ignoreFluid) + 1; + int height = current.getDimensionStackContext() == null + ? current.getHeight(x, z, ignoreFluid) + : Engine.hostHeight(current, x, z, ignoreFluid); + return heightAccessor.getMinY() + height + 1; } catch (GenerationSessionException e) { throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e); } @@ -1043,8 +1094,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_column"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { BlockState airState = Blocks.AIR.defaultBlockState(); - int surface = current.getHeight(x, z, true); - int fluid = current.getHeight(x, z, false); + boolean dimensionStack = current.getDimensionStackContext() != null; + int surface = dimensionStack + ? Engine.hostHeight(current, x, z, true) + : current.getHeight(x, z, true); + int fluid = dimensionStack + ? Engine.hostHeight(current, x, z, false) + : current.getHeight(x, z, false); BlockState stone = Blocks.STONE.defaultBlockState(); BlockState water = Blocks.WATER.defaultBlockState(); int solidEnd = Math.max(0, Math.min(states.length, surface + 1)); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java index ffe05cfb2..784fb26ba 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java @@ -21,6 +21,7 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiomeWriter; import art.arcane.iris.util.project.context.IrisContext; @@ -97,7 +98,7 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { return key; } Engine engine = context.getEngine(); - String prefix = engine.getDimension().getLoadKey() + ":"; + String prefix = engine.getDimension().getCustomBiomeKeyPrefix(); if (!key.regionMatches(true, 0, prefix, 0, prefix.length())) { return key; } @@ -138,13 +139,7 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { } private int idForDerivative(Registry registry, String key) { - int colon = key.indexOf(':'); - if (colon <= 0 || colon >= key.length() - 1) { - return -1; - } - String dimensionLoadKey = key.substring(0, colon); - String customBiomeId = key.substring(colon + 1); - IrisBiome owner = findCustomBiomeOwner(dimensionLoadKey, customBiomeId); + IrisBiome owner = findCustomBiomeOwner(key); if (owner == null) { return -1; } @@ -155,20 +150,18 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { return idForKey(registry, derivativeKey); } - private IrisBiome findCustomBiomeOwner(String dimensionLoadKey, String customBiomeId) { + private IrisBiome findCustomBiomeOwner(String key) { for (Engine engine : ModdedWorldEngines.activeEngines()) { if (engine == null || engine.isClosed()) { continue; } - if (!dimensionLoadKey.equalsIgnoreCase(engine.getDimension().getLoadKey())) { - continue; - } - for (IrisBiome biome : engine.getDimension().getAllBiomes(engine)) { + IrisDimension dimension = engine.getDimension(); + for (IrisBiome biome : dimension.getAllBiomes(engine)) { if (!biome.isCustom()) { continue; } for (IrisBiomeCustom custom : biome.getCustomDerivitives()) { - if (customBiomeId.equals(custom.getId())) { + if (key.equalsIgnoreCase(dimension.getCustomBiomeKey(custom.getId()))) { return biome; } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java index 21c1d5faf..a5f12408e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -258,7 +258,7 @@ public final class ModdedBlockBreakHandler { if (skipsParents(providers)) { return providers; } - IrisRegion region = engine.getRegion(position.getX(), position.getZ()); + IrisRegion region = engine.getRegion(position.getX(), relativeY, position.getZ()); if (region != null) { addMatching(providers, region.getBlockDrops(), brokenState, data); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java index 3f6090d80..fc2fcb963 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java @@ -62,7 +62,6 @@ final class ModdedDimensionMetadata { static Set collectConfiguredBiomeKeys(Iterable biomes, String dimensionLoadKey) { LinkedHashSet keys = new LinkedHashSet<>(); - String namespace = dimensionLoadKey.toLowerCase(Locale.ROOT); for (IrisBiome irisBiome : biomes) { if (irisBiome == null) { continue; @@ -75,7 +74,7 @@ final class ModdedDimensionMetadata { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - keys.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + keys.add(IrisDimension.customBiomeKey(dimensionLoadKey, customBiome.getId())); } } return Set.copyOf(keys); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java index 600de8b39..f03b7fa23 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java @@ -226,7 +226,7 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement int blockY = floor(player.getY()) - getEngine().getWorld().minHeight(); int blockZ = floor(player.getZ()); state.biome(getEngine().getBiome(blockX, blockY, blockZ)); - state.region(getEngine().getRegion(blockX, blockZ)); + state.region(getEngine().getRegion(blockX, blockY, blockZ)); } private void applyEffects(ServerLevel level, ServerPlayer player, Iterable effects) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index 93f845ee2..d4b4944f5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -52,7 +52,6 @@ import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.HexFormat; import java.util.LinkedHashMap; @@ -514,18 +513,13 @@ public final class ModdedForcedDatapack { if (!dimensionsDirectory.isDirectory()) { return false; } - File[] dimensionFiles = dimensionsDirectory.listFiles( - (File file) -> file.isFile() && file.getName().endsWith(".json")); - if (dimensionFiles == null) { - throw new IOException("Iris could not read dimensions for pack '" + packName + "'"); - } - if (dimensionFiles.length == 0) { + IrisData data = IrisData.get(packFolder); + String[] dimensionKeys = data.getDimensionLoader().getPossibleKeys(); + if (dimensionKeys == null || dimensionKeys.length == 0) { return false; } - Arrays.sort(dimensionFiles, Comparator.comparing(File::getName)); - IrisData data = IrisData.get(packFolder); - for (File dimensionFile : dimensionFiles) { - String dimensionKey = dimensionFile.getName().substring(0, dimensionFile.getName().length() - ".json".length()); + List sortedDimensionKeys = Stream.of(dimensionKeys).sorted().toList(); + for (String dimensionKey : sortedDimensionKeys) { IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); if (dimension == null) { throw new IllegalStateException("Iris pack '" + packName + "' dimension '" diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java index 682bed88d..f8805be38 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java @@ -18,6 +18,8 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; @@ -57,7 +59,10 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; /** * Native placed-feature passthrough for one Iris dimension, gated on {@code importedFeatures.enabled}. @@ -219,7 +224,14 @@ final class ModdedImportedFeatureStage { private FeatureTable buildTable(Engine engine, IrisImportedFeatureControl control, long generation) { // Registry-ordered biome list. FeatureSorter's cycle detection walks it, so an unordered list makes // detection depend on JVM hash order and turns a real cycle into an intermittent one. - List> biomes = biomeSource.orderedPossibleBiomes(); + Set hostBiomeKeys = hostBiomeKeys(engine); + List> biomes = new ArrayList<>(); + for (Holder biome : biomeSource.orderedPossibleBiomes()) { + String key = holderKey(biome); + if (key != null && hostBiomeKeys.contains(key)) { + biomes.add(biome); + } + } if (biomes.isEmpty()) { ModdedIrisLog.error("Iris importedFeatures is on but {} exposes no biomes; features off", dimensionKey(engine)); @@ -262,7 +274,7 @@ final class ModdedImportedFeatureStage { */ private Map> customBiomeDerivatives(Engine engine, Map> byKey) { Map> derivatives = new HashMap<>(); - for (IrisBiome irisBiome : engine.getAllBiomes()) { + for (IrisBiome irisBiome : engine.getDimension().getReachableBiomes(engine)) { if (!irisBiome.isCustom()) { continue; } @@ -322,14 +334,24 @@ final class ModdedImportedFeatureStage { List steps = table.steps(); WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ()); - Set> chunkBiomes = chunkBiomes(level, sectionPos, table); IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); int staticMinY = engine.getMinHeight(); - WorldGenLevel placementLevel = staticObjects.isEmpty() ? level : ModdedNativeStructureWorldgenAccess.create( + DimensionStackContext stackContext = engine.getDimensionStackContext(); + Set> chunkBiomes = chunkBiomes(level, sectionPos, table, stackContext); + IntBinaryOperator surfaceFirstFreeY = stackContext == null + ? (x, z) -> level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) + : (x, z) -> Engine.hostHeight(engine, x, z, false) + staticMinY + 1; + IntBinaryOperator floorFirstFreeY = stackContext == null + ? (x, z) -> level.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z) + : (x, z) -> Engine.hostHeight(engine, x, z, true) + staticMinY + 1; + WorldGenLevel placementLevel = staticObjects.isEmpty() && stackContext == null + ? level + : ModdedNativeStructureWorldgenAccess.create( level, centerPos, - (x, z) -> level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z), - (x, z) -> level.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z), - position -> staticObjects.contains(position.getX(), position.getY() - staticMinY, position.getZ())); + surfaceFirstFreeY, + floorFirstFreeY, + stackContext != null, + importedFeatureProtection(staticObjects, stackContext, staticMinY)); try { for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { @@ -349,6 +371,58 @@ final class ModdedImportedFeatureStage { WorldCheckFeaturePlacement.recordPlacementPass(); } + private static Set hostBiomeKeys(Engine engine) { + Set keys = new LinkedHashSet<>(); + for (IrisBiome irisBiome : engine.getDimension().getReachableBiomes(engine)) { + addKey(keys, irisBiome.getStructureDerivativeKey()); + addKey(keys, irisBiome.getDerivativeKey()); + for (String scatter : irisBiome.getBiomeScatter()) { + addKey(keys, scatter); + } + for (String scatter : irisBiome.getBiomeSkyScatter()) { + addKey(keys, scatter); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + addKey(keys, ModdedWorldgenIds.biomeRef(engine, customBiome.getId())); + } + } + return keys; + } + + private static void addKey(Set keys, String key) { + String normalized = normalizeKey(key); + if (normalized != null) { + keys.add(normalized); + } + } + + private static Predicate importedFeatureProtection( + IrisStaticObjectLayer staticObjects, + DimensionStackContext stackContext, + int minimumY + ) { + Map layouts = new ConcurrentHashMap<>(); + return position -> { + if (!staticObjects.isEmpty() && staticObjects.contains( + position.getX(), position.getY() - minimumY, position.getZ())) { + return true; + } + if (stackContext == null) { + return false; + } + long columnKey = ((long) position.getX() << 32) + ^ (position.getZ() & 0xFFFFFFFFL); + DimensionStackLayout layout = layouts.computeIfAbsent( + columnKey, + ignored -> stackContext.sample(position.getX(), position.getZ()) + ); + return layout.isHostFeatureProtectedY(position.getY() - minimumY); + }; + } + private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData, Registry featureRegistry, Set> chunkBiomes, IrisModdedChunkGenerator owner, WorldgenRandom random, long decorationSeed, @@ -394,12 +468,37 @@ final class ModdedImportedFeatureStage { * shape as vanilla, which drops any section biome its biome source does not claim. Holders are canonicalised * back onto the table's own holders so the index mapping cannot be handed a holder it never saw. */ - private Set> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) { + private Set> chunkBiomes( + WorldGenLevel level, + SectionPos sectionPos, + FeatureTable table, + DimensionStackContext stackContext + ) { List> collected = new ArrayList<>(); ChunkPos.rangeClosed(sectionPos.chunk(), 1).forEach((ChunkPos chunkPos) -> { ChunkAccess neighbour = level.getChunk(chunkPos.x(), chunkPos.z()); - for (LevelChunkSection section : neighbour.getSections()) { - section.getBiomes().getAll(collected::add); + LevelChunkSection[] sections = neighbour.getSections(); + for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + LevelChunkSection section = sections[sectionIndex]; + if (stackContext == null) { + section.getBiomes().getAll(collected::add); + continue; + } + int sectionMinimumY = neighbour.getSectionYFromSectionIndex(sectionIndex) << 4; + for (int quartY = 0; quartY < 4; quartY++) { + int internalY = sectionMinimumY + (quartY << 2) - level.getMinY(); + for (int quartX = 0; quartX < 4; quartX++) { + int blockX = chunkPos.getMinBlockX() + (quartX << 2); + for (int quartZ = 0; quartZ < 4; quartZ++) { + int blockZ = chunkPos.getMinBlockZ() + (quartZ << 2); + DimensionStackLayout.Layer layer = stackContext.getLayerAt( + blockX, internalY, blockZ); + if (layer.terrainContext().isSelfReferencing()) { + collected.add(section.getBiomes().get(quartX, quartY, quartZ)); + } + } + } + } } }); Set> present = new LinkedHashSet<>(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 58a870f4e..68f4c0912 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -18,6 +18,8 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; @@ -78,6 +80,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; /** * Native (vanilla registry) structure stage for {@link IrisModdedChunkGenerator}. The generator keeps the @@ -266,7 +269,7 @@ final class ModdedNativeStructureStage { undergroundStep, decision.preserveSourceY(), decision.yBand(), - (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + (x, z) -> Engine.hostHeight(current, x, z, true) + current.getMinHeight()); StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication( start, structure, start.getReferences(), NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()), @@ -367,10 +370,14 @@ final class ModdedNativeStructureStage { chunkPos.x(), chunkPos.z(), error); } IrisStaticObjectLayer staticObjects = current.getDimension().getStaticObjectLayer(current.getData()); - int staticMinY = current.getMinHeight(); - WorldGenLevel boundedWorld = staticObjects.isEmpty() ? world : ModdedNativeStructureWorldgenAccess.create( + Predicate protectedPosition = nativeStructureProtection(current, staticObjects); + WorldGenLevel boundedWorld = staticObjects.isEmpty() + && current.getDimensionStackContext() == null + ? world + : ModdedNativeStructureWorldgenAccess.create( world, chunkPos, worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()), - position -> staticObjects.contains(position.getX(), position.getY() - staticMinY, position.getZ())); + current.getDimensionStackContext() != null, + protectedPosition); try { NativeStructureVegetationClearer.clearIntersectingVegetation( boundedWorld, chunk, area, vegetationTargets); @@ -383,7 +390,7 @@ final class ModdedNativeStructureStage { try { vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( boundedWorld, area, terrainTargets, - (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + (x, z) -> Engine.hostHeight(current, x, z, true) + current.getMinHeight()); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "terrain integration", nativeStructureBatchContext(placementGroups), @@ -443,18 +450,50 @@ final class ModdedNativeStructureStage { IrisNativeStructureDecision decision) { Engine current = generator.engine(); WorldGenLevel boundedWorld = world instanceof ModdedNativeStructureWorldgenAccess ? world : ModdedNativeStructureWorldgenAccess.create( - world, chunkPos, worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()), position -> false); + world, + chunkPos, + worldgenSurfaceHeight(current, world.getMinY()), + worldgenFloorHeight(current, world.getMinY()), + current.getDimensionStackContext() != null, + nativeStructureProtection( + current, + current.getDimension().getStaticObjectLayer(current.getData()))); world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); try { NativeStructurePostProcessor.place( boundedWorld, structureManager, generator, random, area, chunkPos, structureId, start, decision, this::resolvePaletteBlock, - (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + (x, z) -> Engine.hostHeight(current, x, z, true) + current.getMinHeight()); } finally { world.setCurrentlyGenerating(null); } } + private static Predicate nativeStructureProtection( + Engine engine, + IrisStaticObjectLayer staticObjects + ) { + DimensionStackContext stackContext = engine.getDimensionStackContext(); + int minimumY = engine.getMinHeight(); + Map layouts = new ConcurrentHashMap<>(); + return position -> { + if (!staticObjects.isEmpty() && staticObjects.contains( + position.getX(), position.getY() - minimumY, position.getZ())) { + return true; + } + if (stackContext == null) { + return false; + } + long columnKey = ((long) position.getX() << 32) + ^ (position.getZ() & 0xFFFFFFFFL); + DimensionStackLayout layout = layouts.computeIfAbsent( + columnKey, + ignored -> stackContext.sample(position.getX(), position.getZ()) + ); + return layout.isHostFeatureProtectedY(position.getY() - minimumY); + }; + } + private List> structuresByStep(Registry registry) { StructureStepCache cached = structureStepCache; if (cached != null && cached.registry() == registry) { @@ -523,11 +562,11 @@ final class ModdedNativeStructureStage { } private IntBinaryOperator worldgenSurfaceHeight(Engine generationEngine, int runtimeMinY) { - return (x, z) -> generationEngine.getHeight(x, z, false) + runtimeMinY + 1; + return (x, z) -> Engine.hostHeight(generationEngine, x, z, false) + runtimeMinY + 1; } private IntBinaryOperator worldgenFloorHeight(Engine generationEngine, int runtimeMinY) { - return (x, z) -> generationEngine.getHeight(x, z, true) + runtimeMinY + 1; + return (x, z) -> Engine.hostHeight(generationEngine, x, z, true) + runtimeMinY + 1; } private record NativeStructureStartKey(String structureId, long chunkPosition) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java index bb9c6540c..c575f4dff 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java @@ -63,6 +63,7 @@ final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { private final ChunkPos generationCenter; private final IntBinaryOperator surfaceFirstFreeY; private final IntBinaryOperator floorFirstFreeY; + private final boolean virtualizeHeights; private final Predicate protectedPosition; private final Holder fallbackBiome; private final BiomeManager biomeManager; @@ -80,6 +81,7 @@ final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { boundary.surfaceFirstFreeY(), "Native structure world access requires a surface resolver"); this.floorFirstFreeY = Objects.requireNonNull( boundary.floorFirstFreeY(), "Native structure world access requires an ocean-floor resolver"); + this.virtualizeHeights = boundary.virtualizeHeights(); this.protectedPosition = Objects.requireNonNull( boundary.protectedPosition(), "World access requires a protected-position predicate"); BlockPos biomeSample = generationCenter.getMiddleBlockPosition(delegate.getSeaLevel()); @@ -102,9 +104,10 @@ final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { static ModdedNativeStructureWorldgenAccess create(WorldGenLevel delegate, ChunkPos generationCenter, IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator floorFirstFreeY, + boolean virtualizeHeights, Predicate protectedPosition) { return new ModdedNativeStructureWorldgenAccess(delegate, new Boundary( - generationCenter, surfaceFirstFreeY, floorFirstFreeY, protectedPosition)); + generationCenter, surfaceFirstFreeY, floorFirstFreeY, virtualizeHeights, protectedPosition)); } @Override @@ -241,7 +244,7 @@ final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { @Override public int getHeight(Heightmap.Types type, int x, int z) { - if (isInsideGenerationRegion(x >> 4, z >> 4)) { + if (!virtualizeHeights && isInsideGenerationRegion(x >> 4, z >> 4)) { return delegate.getHeight(type, x, z); } return height(type, x, z); @@ -526,6 +529,7 @@ final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { private record Boundary(ChunkPos generationCenter, IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator floorFirstFreeY, + boolean virtualizeHeights, Predicate protectedPosition) { } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index 76ffee8e9..2b771ed6a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java @@ -108,12 +108,17 @@ public final class ModdedScheduler implements PlatformScheduler { scheduler.drain(); } + public static boolean isMainThread() { + Thread running = mainThread; + return running != null && Thread.currentThread() == running; + } + @Override public void global(Runnable task) { if (task == null) { return; } - if (onMainThread()) { + if (isMainThread()) { runGuarded(task); return; } @@ -250,11 +255,6 @@ public final class ModdedScheduler implements PlatformScheduler { } } - private boolean onMainThread() { - Thread main = mainThread; - return main != null && Thread.currentThread() == main; - } - private void runGuarded(Runnable task) { try { task.run(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java index f7fde8a5c..eaa9aa6c0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java @@ -18,10 +18,14 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.project.context.IrisContext; import net.minecraft.core.Holder; import net.minecraft.core.Registry; @@ -31,32 +35,24 @@ import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.MobSpawnSettings; -import java.util.Locale; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -/** - * Vanilla-derivative spawn table state for {@link IrisModdedChunkGenerator}. Every mutator locks the - * generator monitor because the repoint/bind/reset paths already hold it while resetting this state. - */ final class ModdedSpawnTableMerger { private final IrisModdedChunkGenerator generator; - private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); - private volatile boolean vanillaSpawnBiomesInitialized; + private volatile SpawnTableState state = new SpawnTableState(); ModdedSpawnTableMerger(IrisModdedChunkGenerator generator) { this.generator = generator; } void initializeVanillaSpawnBiomes(Registry registry) { - // Volatile fast path BEFORE the monitor: this runs per mob category per spawn attempt - // on the server thread, and the generator monitor is contended by binds/hotloads. - if (vanillaSpawnBiomesInitialized) { + SpawnTableState active = state; + if (active.initialized) { return; } - synchronized (generator) { - if (vanillaSpawnBiomesInitialized) { + synchronized (active) { + if (active != state || active.initialized) { return; } Engine current = generator.engineOrNull(); @@ -66,29 +62,70 @@ final class ModdedSpawnTableMerger { try (GenerationSessionLease lease = generator.requireGenerationLease(current, "modded_spawn_biomes"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : current.getDimension().getReachableBiomes(current)) { - if (irisBiome == null || !irisBiome.isCustom()) { - continue; - } - Holder vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey()); - if (vanillaHolder == null) { - continue; - } - for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - Holder customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId()); - if (customHolder != null) { - vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + IrisDimension hostDimension = current.getDimension(); + addVanillaSpawnBiomes( + registry, + hostDimension, + current.getData(), + hostDimension.getReachableBiomes(current), + active + ); + DimensionStackContext stackContext = current.getDimensionStackContext(); + if (stackContext != null) { + for (DimensionTerrainContext terrainContext : stackContext.getLayersBottomToTop()) { + if (terrainContext.isSelfReferencing()) { + continue; } + IrisDimension dimension = terrainContext.getDimension(); + addVanillaSpawnBiomes( + registry, + dimension, + terrainContext.getData(), + dimension.getReachableBiomes(terrainContext), + active + ); } } - vanillaSpawnBiomesInitialized = true; + active.initialized = true; + } + } + } + + private void addVanillaSpawnBiomes( + Registry registry, + IrisDimension dimension, + IrisData data, + Iterable biomes, + SpawnTableState state + ) { + for (IrisBiome irisBiome : biomes) { + if (irisBiome == null || !irisBiome.isCustom()) { + continue; + } + Holder vanillaHolder = resolveBiomeHolder( + registry, irisBiome.getVanillaDerivativeKey()); + if (vanillaHolder == null) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + Holder customHolder = resolveBiomeHolder( + registry, + ModdedWorldgenIds.biomeRef( + data.getDataFolder().getName(), + dimension.getLoadKey(), + customBiome.getId() + ) + ); + if (customHolder == null) { + continue; + } + state.vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); } } } Holder vanillaSpawnBiome(Biome biome) { - return vanillaSpawnBiomes.get(biome); + return state.vanillaSpawnBiomes.get(biome); } WeightedList mergedSpawnTable( @@ -96,7 +133,8 @@ final class ModdedSpawnTableMerger { WeightedList vanillaSpawns, WeightedList explicitSpawns) { SpawnTableKey key = new SpawnTableKey(biome, category); - return mergedSpawnTables.computeIfAbsent(key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns)); + return state.mergedSpawnTables.computeIfAbsent( + key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns)); } private Holder resolveBiomeHolder(Registry registry, String key) { @@ -112,13 +150,16 @@ final class ModdedSpawnTableMerger { } void resetVanillaSpawnBiomes() { - synchronized (generator) { - vanillaSpawnBiomes.clear(); - mergedSpawnTables.clear(); - vanillaSpawnBiomesInitialized = false; - } + state = new SpawnTableState(); } private record SpawnTableKey(Biome biome, MobCategory category) { } + + private static final class SpawnTableState { + private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> mergedSpawnTables = + new ConcurrentHashMap<>(); + private volatile boolean initialized; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedEditCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedEditCommands.java index 39dcd2d30..13d92da56 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedEditCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedEditCommands.java @@ -84,7 +84,8 @@ final class ModdedEditCommands { } BlockPos pos = player.blockPosition(); try { - region = engine.getRegion(pos.getX(), pos.getZ()); + region = engine.getRegion( + pos.getX(), pos.getY() - engine.getMinHeight(), pos.getZ()); } catch (Throwable e) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_REGION_LOOKUP_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()))); return 0; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 5f52089d4..813b3c6be 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -73,6 +73,8 @@ import org.zeroturnaround.zip.ZipUtil; import java.awt.Desktop; import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -85,6 +87,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import java.util.regex.Pattern; +import java.util.stream.Stream; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.ModdedCommandMessages; @@ -891,21 +894,30 @@ public final class ModdedStudioCommands { LinkedHashSet objectKeys = new LinkedHashSet<>(); LinkedHashSet markerKeys = new LinkedHashSet<>(); LinkedHashSet structureKeys = new LinkedHashSet<>(); + List dimensions = new ArrayList<>(); - dimension.getAllRegions(() -> dm).forEach((IrisRegion region) -> { - if (region != null && region.getLoadKey() != null) { - regionKeys.add(region.getLoadKey()); + for (String dimensionKey : PackExportClosure.collectDimensionKeys(dimension).stream().sorted().toList()) { + IrisDimension exportedDimension = dm.getDimensionLoader().load(dimensionKey); + if (exportedDimension != null) { + dimensions.add(exportedDimension); } - }); - dimension.getReachableBiomes(() -> dm).forEach((IrisBiome biome) -> { - if (biome != null && biome.getLoadKey() != null) { - biomeKeys.add(biome.getLoadKey()); - } - }); - lootKeys.addAll(dimension.getLoot().getTables()); - spawnerKeys.addAll(dimension.getEntitySpawners()); - collectStructureKeys(structureKeys, dimension.getStructures()); - objectKeys.addAll(PackExportClosure.collectStaticObjectKeys(dimension.getStaticObjects())); + } + for (IrisDimension exportedDimension : dimensions) { + exportedDimension.getAllRegions(() -> dm).forEach((IrisRegion region) -> { + if (region != null && region.getLoadKey() != null) { + regionKeys.add(region.getLoadKey()); + } + }); + exportedDimension.getReachableBiomes(() -> dm).forEach((IrisBiome biome) -> { + if (biome != null && biome.getLoadKey() != null) { + biomeKeys.add(biome.getLoadKey()); + } + }); + lootKeys.addAll(exportedDimension.getLoot().getTables()); + spawnerKeys.addAll(exportedDimension.getEntitySpawners()); + collectStructureKeys(structureKeys, exportedDimension.getStructures()); + objectKeys.addAll(PackExportClosure.collectStaticObjectKeys(exportedDimension.getStaticObjects())); + } for (String regionKey : regionKeys) { IrisRegion region = dm.getRegionLoader().load(regionKey); @@ -975,10 +987,17 @@ public final class ModdedStudioCommands { } } - hashes.append(copyJson(folder, "dimensions", dimension.getLoadKey(), dm.getDimensionLoader().findFile(dimension.getLoadKey()))); + for (IrisDimension exportedDimension : dimensions) { + String key = exportedDimension.getLoadKey(); + hashes.append(copyJson(folder, "dimensions", key, dm.getDimensionLoader().findFile(key))); + } for (String key : generatorKeys) { hashes.append(copyJson(folder, "generators", key, dm.getGeneratorLoader().findFile(key))); } + for (String key : Stream.of(dm.getExpressionLoader().getPossibleKeys()).sorted().toList()) { + hashes.append(copyJson(folder, "expressions", key, dm.getExpressionLoader().findFile(key))); + } + hashes.append(copyStyleSnippets(packFolder, folder)); for (String key : regionKeys) { hashes.append(copyJson(folder, "regions", key, dm.getRegionLoader().findFile(key))); } @@ -1041,6 +1060,25 @@ public final class ModdedStudioCommands { } } + private static String copyStyleSnippets(File packFolder, File folder) throws IOException { + Path sourceRoot = new File(packFolder, "snippet/style").toPath(); + if (!Files.isDirectory(sourceRoot)) { + return ""; + } + StringBuilder hashes = new StringBuilder(); + try (Stream files = Files.walk(sourceRoot)) { + for (Path source : files.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList()) { + String relative = sourceRoot.relativize(source).toString().replace(File.separatorChar, '/'); + String key = relative.substring(0, relative.length() - ".json".length()); + hashes.append(copyJson(folder, "snippet/style", key, source.toFile())); + } + } + return hashes.toString(); + } + private static int regions(CommandSourceStack source, int radius) { ServerPlayer player = source.getPlayer(); if (player == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java index e4e138f66..9c7016f05 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java @@ -216,7 +216,8 @@ public final class ModdedWhatCommands { int centerX = (pos.getX() & ~15) + 8; int centerZ = (pos.getZ() & ~15) + 8; try { - IrisRegion region = engine.getRegion(centerX, centerZ); + IrisRegion region = engine.getRegion( + centerX, pos.getY() - engine.getMinHeight(), centerZ); IrisModdedCommands.ok(source, IrisLanguage.plain( RuntimeUiMessages.WHAT_IRIS_REGION, MessageArgument.untrusted("region", region.getLoadKey()), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index 3170eb2fd..474690798 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -32,6 +32,7 @@ import art.arcane.iris.engine.object.IrisWorldBoundary; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedForcedDatapack; +import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedWorkspaceGenerator; import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.modded.command.ModdedPregenJob; @@ -100,6 +101,11 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, return NativeStructureVolumeIndex.volumes(engine, minX, minZ, maxX, maxZ); } + @Override + public boolean isMainThread() { + return ModdedScheduler.isMainThread(); + } + @Override public void refreshDatapackWorkspace(Engine engine) { IrisData data = engine.getData(); @@ -121,6 +127,20 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, engine.getDimension(), replacement, "irisworldgen"); + IrisModdedChunkGenerator.requireStructureBiomeUniverseCompatible( + engine.getDimension(), replacement); + } + + @Override + public void prepareRuntimeHotload(Engine engine) { + IrisWorld world = engine.getWorld(); + if (world == null || !world.hasPlatformWorld() + || !(world.platformWorld().nativeHandle() instanceof ServerLevel level) + || !(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { + NativeStructureVolumeIndex.invalidate(engine); + return; + } + generator.prepareRuntimeHotload(level, engine); } @Override @@ -285,7 +305,6 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, long start = System.currentTimeMillis(); try { engine.hotloadSilently(); - generator.onHotload(); ModdedIrisLog.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start); } catch (Throwable e) { ModdedIrisLog.error("Iris studio hotload failed for {}", dimensionId, e); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java index 9ed4ebd45..23be3cd5f 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedChunkGeneratorSpawnTest.java @@ -59,6 +59,7 @@ public class IrisModdedChunkGeneratorSpawnTest { String spawn = source.substring(spawnStart, spawnEnd); assertTrue(spawn.contains("spawnTables.initializeVanillaSpawnBiomes(registry)")); + assertTrue(spawn.contains("structureBiomeSource.getVisibleSurfaceBiome(")); assertTrue(spawn.contains("spawnTables.vanillaSpawnBiome(visibleBiome.value())")); assertTrue(spawn.contains("NaturalSpawner.spawnMobsForChunkGeneration(")); assertTrue(spawn.contains("new LegacyRandomSource(RandomSupport.generateUniqueSeed())")); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java index 6cf161e4a..c3c898fa5 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRange; import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import net.minecraft.SharedConstants; @@ -29,6 +30,8 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class IrisModdedStructureParityTest { @BeforeClass @@ -110,6 +113,144 @@ public class IrisModdedStructureParityTest { assertEquals(expected, IrisModdedBiomeSource.biomeResolutionSeed(worldSeed, blockX, blockY, blockZ)); } + @Test + public void stackedCustomBiomesUseTheirOwningDimensionNamespace() { + IrisDimension host = new IrisDimension(); + host.setLoadKey("Host"); + IrisDimension upper = new IrisDimension(); + upper.setLoadKey("Upper"); + + assertEquals( + ModdedWorldgenIds.biomeRef("pack", "Upper", "Aurora"), + IrisModdedBiomeSource.customBiomeRef("pack", upper, "Aurora") + ); + assertFalse(IrisModdedBiomeSource.customBiomeRef("pack", host, "Aurora") + .equals(IrisModdedBiomeSource.customBiomeRef("pack", upper, "Aurora"))); + } + + @Test + public void visibleBiomeResolutionSelectsStackOwnershipBeforeHostCaves() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedBiomeSource.java")); + int resolutionStart = source.indexOf("private BiomeResolution resolveBiomeResolution("); + int resolutionEnd = source.indexOf("private Holder resolveRequiredStructureBiome(", resolutionStart); + + assertTrue(resolutionStart >= 0); + assertTrue(resolutionEnd > resolutionStart); + String resolution = source.substring(resolutionStart, resolutionEnd); + + assertTrue(resolution.contains("resolveDimensionStackLayer(")); + assertTrue(resolution.contains("!stackLayer.terrainContext().isSelfReferencing()")); + assertTrue(resolution.indexOf("resolveDimensionStackLayer(") + < resolution.indexOf("boolean underground")); + assertTrue(resolution.contains("includeDimensionStack")); + assertTrue(resolution.contains( + "return resolveBiomeResolution(engine, quartX, quartY, quartZ, false)")); + assertTrue(source.contains("resolution.packName(), resolution.dimension(), customBiome.getId()")); + int helperStart = source.indexOf("private static IrisBiome resolveSurfaceStructureBiome("); + int helperEnd = source.indexOf("private static Set registeredBiomeKeys(", helperStart); + String helper = source.substring(helperStart, helperEnd); + assertTrue(helper.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(helper.contains("getDimensionStackContext()")); + assertFalse(helper.contains("getSurfaceBiome(")); + } + + @Test + public void stackBiomesAreVisibleButExcludedFromStructureReachability() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedBiomeSource.java")); + + assertTrue(source.contains("return biomeKeySets().structureRequired()")); + assertTrue(source.contains("generatedBiomeKeys.addAll(keys.visibleRequired())")); + assertTrue(source.contains("collectStructureBiomeKeys(")); + assertTrue(source.contains("dimension.getReachableBiomes(() -> data)")); + } + + @Test + public void biomeLocatorSearchesTheVisibleStack() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedBiomeSource.java")); + int start = source.indexOf("public Pair> findClosestBiome3d("); + int end = source.indexOf("private Holder getNoiseBiome(", start); + String method = source.substring(start, end); + + assertTrue(method.contains("modded_locate_visible_biome")); + assertTrue(method.contains("return super.findClosestBiome3d(")); + assertTrue(method.contains("collectVisibleBiomeHolders()")); + assertTrue(method.contains("QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)")); + assertTrue(method.contains("resolveVisibleBiome(")); + assertFalse(method.contains("Holder structureBiome = getNoiseBiome(")); + assertTrue(method.indexOf("tryAcquireGenerationLease(engine, \"modded_locate_visible_biome\")") + < method.indexOf("engine.getDimensionStackContext()")); + } + + @Test + public void surfaceSpawnBiomeUsesTheVisibleQuartCell() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedBiomeSource.java")); + int start = source.indexOf("Holder getVisibleSurfaceBiome("); + int end = source.indexOf("boolean isStructureReachable(", start); + String method = source.substring(start, end); + + assertTrue(method.contains("QuartPos.fromBlock(blockX)")); + assertTrue(method.contains("QuartPos.toBlock(quartX)")); + assertTrue(method.contains("QuartPos.fromBlock(internalY + engine.getMinHeight())")); + assertTrue(method.contains("stackContext.getLayout(sampleX, sampleZ).surfaceLayer()")); + assertTrue(method.contains("visibleBiomeCache")); + assertTrue(method.contains("isBiomeCacheable(engine, sampleX, sampleZ)")); + assertTrue(method.indexOf("boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ)") + < method.indexOf("Engine.hostHeight(engine, sampleX, sampleZ, true)")); + assertCacheGuarded(method, "visibleBiomeCache"); + } + + @Test + public void temporaryNaturalBiomeAnswersAreNotMemoized() throws IOException { + Engine engine = mock(Engine.class); + when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(true); + assertFalse(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8)); + when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(false); + assertTrue(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8)); + + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedBiomeSource.java")); + int structureStart = source.indexOf("private Holder getNoiseBiome(Engine engine"); + int structureEnd = source.indexOf("Holder getVisibleNoiseBiome(", structureStart); + String structure = source.substring(structureStart, structureEnd); + assertTrue(structure.contains("QuartPos.toBlock(quartX)")); + assertTrue(structure.contains("QuartPos.toBlock(quartZ)")); + assertCacheGuarded(structure, "structureBiomeCache"); + + int visibleStart = source.indexOf( + "private Holder getVisibleNoiseBiomeWithActiveGenerationLease("); + int visibleEnd = source.indexOf("Holder getVisibleSurfaceBiome(", visibleStart); + String visible = source.substring(visibleStart, visibleEnd); + assertTrue(visible.contains("QuartPos.toBlock(quartX)")); + assertTrue(visible.contains("QuartPos.toBlock(quartZ)")); + assertCacheGuarded(visible, "visibleBiomeCache"); + + int predicateStart = source.indexOf("static boolean isBiomeCacheable("); + int predicateEnd = source.indexOf("static boolean isMonumentSurfaceBiomeQuery(", predicateStart); + String predicate = source.substring(predicateStart, predicateEnd); + assertTrue(predicate.contains("return !engine.answersFromNaturalTerrain(blockX, blockZ)")); + assertFalse(predicate.contains("getDimensionStackContext()")); + } + + @Test + public void importedFeaturesCollectOnlyHostOwnedStackCells() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedImportedFeatureStage.java")); + + assertTrue(source.contains("stackContext.getLayerAt(")); + assertTrue(source.contains("layer.terrainContext().isSelfReferencing()")); + assertTrue(source.contains("section.getBiomes().get(quartX, quartY, quartZ)")); + } + @Test public void configuredBiomeKeysContainOnlyPackDerivativesAndCustomBiomes() { IrisBiome ocean = new IrisBiome() @@ -132,6 +273,21 @@ public class IrisModdedStructureParityTest { "minecraft:the_void", "overworld:aurora"), keys); assertFalse(keys.contains("minecraft:desert")); assertFalse(keys.contains("minecraft:plains")); + + Set recursiveKeys = ModdedDimensionMetadata.collectConfiguredBiomeKeys( + List.of(custom), "Layers/Sky"); + assertTrue(recursiveKeys.contains("layers:sky/aurora")); + } + + @Test + public void biomeWriterDerivativeFallbackMatchesCanonicalRecursiveKeys() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedBiomeWriter.java")); + + assertTrue(source.contains( + "key.equalsIgnoreCase(dimension.getCustomBiomeKey(custom.getId()))")); + assertFalse(source.contains("String dimensionLoadKey = key.substring(0, colon)")); } @Test @@ -159,6 +315,19 @@ public class IrisModdedStructureParityTest { assertSame(configured, IrisModdedBiomeSource.requireConfiguredStructureBiomeKeys(configured)); } + @Test + public void liveReplacementRequiresTheSameStructureBiomeUniverse() { + IrisModdedChunkGenerator.requireStructureBiomeUniverseCompatible( + Set.of("minecraft:plains", "minecraft:beach"), + Set.of("minecraft:beach", "minecraft:plains")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> IrisModdedChunkGenerator.requireStructureBiomeUniverseCompatible( + Set.of("minecraft:plains"), Set.of("minecraft:desert"))); + + assertTrue(error.getMessage().contains("Restart the server")); + } + @Test public void configuredDimensionMetadataIsExactBeforeEngineBinding() { IrisDimension dimension = new IrisDimension() @@ -240,4 +409,20 @@ public class IrisModdedStructureParityTest { assertSame(MantleFlag.INITIAL_SPAWNED_MARKER, ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG); } + private static void assertCacheGuarded(String method, String cacheName) { + int predicate = method.indexOf("boolean cacheable = isBiomeCacheable("); + int cache = method.indexOf("BiomeHolderTable cache = " + cacheName, predicate); + int read = method.indexOf("cache.get(", predicate); + int readGuard = method.lastIndexOf("if (cacheable)", read); + int write = method.indexOf("cache.put(", read); + int writeGuard = method.lastIndexOf("if (cacheable)", write); + + assertTrue(predicate >= 0); + assertTrue(cache >= 0); + assertTrue(read > predicate); + assertTrue(readGuard >= predicate); + assertTrue(write > read); + assertTrue(writeGuard > read); + } + } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java index 2d128db3e..c4807bca2 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java @@ -135,6 +135,24 @@ public class ModdedNativeStructureWorldgenAccessTest { assertEquals(1, recording.blockTicks.count()); } + @Test + public void virtualizedHeightsHideInRegionTerrainFromTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + ChunkPos generationCenter = generationCenter(); + ModdedNativeStructureWorldgenAccess access = ModdedNativeStructureWorldgenAccess.create( + recording.world(), generationCenter, + (x, z) -> SURFACE_FIRST_FREE_Y, + (x, z) -> FLOOR_FIRST_FREE_Y, + true, + position -> false); + int x = generationCenter.getMiddleBlockX(); + int z = generationCenter.getMiddleBlockZ(); + + assertEquals(SURFACE_FIRST_FREE_Y, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z)); + assertEquals(FLOOR_FIRST_FREE_Y, access.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z)); + assertEquals(0, recording.heightReads); + } + @Test public void protectedCellsRemainReadableButRejectWritesTilesAndTicks() { RecordingDelegate recording = new RecordingDelegate(); @@ -142,7 +160,9 @@ public class ModdedNativeStructureWorldgenAccessTest { ModdedNativeStructureWorldgenAccess access = ModdedNativeStructureWorldgenAccess.create( recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, - (x, z) -> FLOOR_FIRST_FREE_Y, position::equals); + (x, z) -> FLOOR_FIRST_FREE_Y, + false, + position::equals); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertFalse(access.ensureCanWrite(position)); @@ -211,7 +231,9 @@ public class ModdedNativeStructureWorldgenAccessTest { return ModdedNativeStructureWorldgenAccess.create( recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, - (x, z) -> FLOOR_FIRST_FREE_Y, position -> false); + (x, z) -> FLOOR_FIRST_FREE_Y, + false, + position -> false); } private static ChunkPos generationCenter() { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java index 8a04ec78e..f6e3c147b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java @@ -95,8 +95,9 @@ public class NativeStructureFailureContractTest { assertTrue(placement.contains("heightmapStarts.add(start);")); assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(") < placement.indexOf("prepareSurfaceStructures")); - assertTrue(source.contains("generationEngine.getHeight(x, z, false) + runtimeMinY + 1")); - assertTrue(source.contains("generationEngine.getHeight(x, z, true) + runtimeMinY + 1")); + assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, false) + runtimeMinY + 1")); + assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, true) + runtimeMinY + 1")); + assertTrue(placement.contains("current.getDimensionStackContext() != null")); assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } @@ -108,6 +109,8 @@ public class NativeStructureFailureContractTest { String placement = source.substring(placementStart, placementEnd); assertTrue(placement.contains("ModdedNativeStructureWorldgenAccess.create(")); + assertTrue(placement.contains("nativeStructureProtection(current, staticObjects)")); + assertTrue(placement.contains("Engine.hostHeight(current, x, z, true)")); assertTrue(placement.contains("placeVanillaStructure(boundedWorld, structureManager")); assertFalse(placement.contains("NativeStructurePostProcessor.place(world,")); assertTrue(placement.contains("world.setCurrentlyGenerating(null);")); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java index 298fdbf7a..129cb0292 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java @@ -1,10 +1,12 @@ package art.arcane.iris.modded.service; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.modded.ModdedScheduler; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -26,4 +28,21 @@ public class ModdedStudioHotloadServiceTest { assertFalse(ModdedStudioHotloadService.hasDatapackImports(null)); assertFalse(ModdedStudioHotloadService.hasDatapackImports(List.of(empty))); } + + @Test + public void reportsTheCapturedServerThread() throws InterruptedException { + ModdedScheduler scheduler = new ModdedScheduler(); + scheduler.reset(); + try { + ModdedStudioHotloadService service = new ModdedStudioHotloadService(); + assertTrue(service.isMainThread()); + AtomicBoolean offThread = new AtomicBoolean(true); + Thread worker = new Thread(() -> offThread.set(service.isMainThread())); + worker.start(); + worker.join(); + assertFalse(offThread.get()); + } finally { + scheduler.shutdown(); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index 3f52fa5d5..58f99793e 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -222,9 +222,45 @@ public final class IrisDatapackCompiler { new LinkedHashSet<>(), requiredDimension, requiredFixer); + collectDimensionStackRegistryRequirements( + requirements, + requiredDimension, + requiredFixer); return Map.copyOf(requirements); } + private static void collectDimensionStackRegistryRequirements( + Map requirements, + IrisDimension hostDimension, + IDataFixer fixer + ) throws IOException { + if (!hostDimension.hasDimensionStack()) { + return; + } + IrisData data = hostDimension.getLoader(); + if (data == null) { + throw new IOException("Dimension stack host '" + hostDimension.getLoadKey() + + "' has no pack loader"); + } + LinkedHashSet visited = new LinkedHashSet<>(); + visited.add(hostDimension.getLoadKey()); + for (String dimensionKey : hostDimension.getDimensionStack().getDimensions()) { + if (dimensionKey == null || dimensionKey.isBlank() || !visited.add(dimensionKey)) { + continue; + } + IrisDimension sourceDimension = data.getDimensionLoader().load(dimensionKey); + if (sourceDimension == null) { + throw new IOException("Dimension stack host '" + hostDimension.getLoadKey() + + "' references missing dimension '" + dimensionKey + "'"); + } + collectRegistryRequirements( + requirements, + new LinkedHashSet<>(), + sourceDimension, + fixer); + } + } + private static void collectRegistryRequirements( Map requirements, Set installedBiomeKeys, @@ -235,7 +271,6 @@ public final class IrisDatapackCompiler { "dimension_type/iris:" + dimension.getDimensionTypeKey(), fingerprintContent(dimension.getDimensionType().toJson(fixer))); - String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT); ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate(); for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) { if (biome == null || !biome.isCustom()) { @@ -246,7 +281,7 @@ public final class IrisDatapackCompiler { if (customBiome == null) { continue; } - String biomeKey = namespace + ":" + customBiome.getId(); + String biomeKey = dimension.getCustomBiomeKey(customBiome.getId()); if (!installedBiomeKeys.add(biomeKey)) { continue; } @@ -533,7 +568,7 @@ public final class IrisDatapackCompiler { || !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) { return false; } - try (Stream stream = Files.list(dimensions)) { + try (Stream stream = Files.walk(dimensions)) { return stream.anyMatch(path -> !Files.isSymbolicLink(path) && Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.getFileName().toString().endsWith(".json")); diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 0444acf8c..9acfd5009 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -1121,7 +1121,7 @@ public class ServerConfigurator { for (IrisBiome i : dimension.getAllBiomes(dimension::getLoader)) { if (i.isCustom()) { for (IrisBiomeCustom j : i.getCustomDerivitives()) { - keys.add(dimension.getLoadKey() + ":" + j.getId()); + keys.add(dimension.getCustomBiomeKey(j.getId())); } } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java index 7ff2bc134..2cceddd61 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java @@ -134,7 +134,8 @@ public class MythicMobsDataProvider extends ExternalDataProvider implements List if (access == null) return false; Engine engine = access.getEngine(); if (engine == null) return false; - IrisRegion region = engine.getRegion(target.getBlockX(), target.getBlockZ()); + IrisRegion region = engine.getRegion( + target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); return regions.contains(region.getLoadKey()); } } diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 6148d35de..f7d06d6c7 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -156,6 +156,9 @@ public interface INMSBinding { return NativeStructureVolume.NONE; } + default void invalidateNativeStructureVolumeIndex(Engine engine) { + } + int getBiomeId(Biome biome); MCABiomeContainer newBiomeContainer(int min, int max, int[] data); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java index 457a93b9f..04150d9f0 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java @@ -26,7 +26,9 @@ import art.arcane.volmlib.util.json.JSONObject; import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; final class PackDimensionValidator { private PackDimensionValidator() { @@ -35,9 +37,14 @@ final class PackDimensionValidator { static void validateDimensions(File packFolder, File[] dimensionFiles, List blockingErrors, List warnings) { File regionsFolder = new File(packFolder, "regions"); File biomesFolder = new File(packFolder, "biomes"); + File dimensionsFolder = new File(packFolder, PackValidator.DIMENSIONS_FOLDER); + Set dimensionKeys = new LinkedHashSet<>(); + for (File dimensionFile : dimensionFiles) { + dimensionKeys.add(PackValidationIo.deriveKey(dimensionsFolder, dimensionFile)); + } for (File dimFile : dimensionFiles) { - String dimensionKey = PackValidationIo.stripExtension(dimFile.getName()); + String dimensionKey = PackValidationIo.deriveKey(dimensionsFolder, dimFile); JSONObject dimJson; try { dimJson = new JSONObject(Files.readString(dimFile.toPath(), StandardCharsets.UTF_8)); @@ -50,6 +57,7 @@ final class PackDimensionValidator { validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); validateWorldBoundary(dimensionKey, dimJson, blockingErrors); validateStaticObjects(packFolder, dimensionKey, dimJson, blockingErrors); + validateDimensionStack(packFolder, dimensionKey, dimJson, dimensionKeys, blockingErrors); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -94,6 +102,108 @@ final class PackDimensionValidator { } } + static void validateDimensionStack(File packFolder, String dimensionKey, JSONObject dimension, Set dimensionKeys, + List blockingErrors) { + if (!dimension.has("dimensionStack")) { + return; + } + String context = "Dimension '" + dimensionKey + "' dimensionStack"; + JSONObject stack = dimension.optJSONObject("dimensionStack"); + if (stack == null) { + blockingErrors.add(context + " must be an object."); + return; + } + + String upperDimension = dimension.optString("upperDimension", "none"); + if (upperDimension != null && !upperDimension.isEmpty() + && !upperDimension.equalsIgnoreCase("none")) { + blockingErrors.add(context + " cannot be used with upperDimension."); + } + + JSONArray dimensions = stack.optJSONArray("dimensions"); + if (dimensions == null) { + blockingErrors.add(context + ".dimensions must be an array."); + } else { + int hostOccurrences = 0; + if (dimensions.length() < 2) { + blockingErrors.add(context + ".dimensions must contain at least two dimension keys."); + } + for (int index = 0; index < dimensions.length(); index++) { + Object rawKey = dimensions.opt(index); + if (!(rawKey instanceof String key) || key.isBlank()) { + blockingErrors.add(context + ".dimensions[" + index + "] must be a nonblank dimension key."); + continue; + } + if (!dimensionKeys.contains(key)) { + blockingErrors.add(context + ".dimensions[" + index + "] references missing dimension '" + + key + "'."); + } + if (dimensionKey.equals(key)) { + hostOccurrences++; + } + } + if (hostOccurrences != 1) { + blockingErrors.add(context + ".dimensions must contain its host dimension key '" + + dimensionKey + "' exactly once."); + } + if (dimensions.length() > 0) { + Object rawLast = dimensions.opt(dimensions.length() - 1); + if (rawLast instanceof String lastKey && !dimensionKey.equals(lastKey)) { + blockingErrors.add(context + ".dimensions must end with its host dimension key '" + + dimensionKey + "'."); + } + } + } + + validateInteger(stack, "spacer", 0, 256, context, blockingErrors); + validateDimensionStackBlend(packFolder, context, stack, blockingErrors); + } + + private static void validateDimensionStackBlend(File packFolder, String context, JSONObject stack, + List blockingErrors) { + if (!stack.has("blend")) { + return; + } + JSONObject blend = stack.optJSONObject("blend"); + if (blend == null) { + blockingErrors.add(context + ".blend must be an object."); + return; + } + if (blend.has("amplitude")) { + validateInteger(blend, "amplitude", 0, 256, context + ".blend", blockingErrors); + } + if (blend.has("style")) { + Object rawStyle = blend.opt("style"); + boolean styleObject = rawStyle instanceof JSONObject; + boolean styleSnippet = rawStyle instanceof String reference + && reference.startsWith("snippet/style/"); + if (!styleObject && !styleSnippet) { + blockingErrors.add(context + ".blend.style must be an object or style snippet reference."); + return; + } + if (styleSnippet) { + validateDimensionStackStyleSnippet( + packFolder, context + ".blend.style", (String) rawStyle, blockingErrors); + } + } + } + + private static void validateDimensionStackStyleSnippet(File packFolder, String context, String reference, + List blockingErrors) { + try { + File snippetRoot = new File(packFolder, "snippet/style").getCanonicalFile(); + File snippetFile = new File(packFolder, reference + ".json").getCanonicalFile(); + if (!snippetFile.toPath().startsWith(snippetRoot.toPath()) || !snippetFile.isFile()) { + blockingErrors.add(context + " references missing style snippet '" + reference + "'."); + return; + } + new JSONObject(Files.readString(snippetFile.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable error) { + blockingErrors.add(context + " references invalid style snippet '" + reference + "': " + + error.getMessage()); + } + } + static void validateStaticObjects(File packFolder, String dimensionKey, JSONObject dimension, List blockingErrors) { if (!dimension.has("staticObjects")) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java index 894845f6c..bbc186876 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java @@ -18,11 +18,16 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionStack; import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisStaticObject; import art.arcane.volmlib.util.collection.KSet; +import java.util.ArrayDeque; +import java.util.function.Function; + /** * Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the * modded verbatim-copy compiler must export the complete ambient-spawning graph: object placements @@ -33,6 +38,48 @@ public final class PackExportClosure { private PackExportClosure() { } + public static KSet collectDimensionKeys(IrisDimension dimension) { + return collectDimensionKeys(dimension, key -> dimension == null || dimension.getLoader() == null + ? null + : dimension.getLoader().getDimensionLoader().load(key)); + } + + static KSet collectDimensionKeys( + IrisDimension dimension, + Function resolver + ) { + KSet dimensionKeys = new KSet<>(); + if (dimension == null || dimension.getLoadKey() == null || dimension.getLoadKey().isBlank()) { + return dimensionKeys; + } + + ArrayDeque pending = new ArrayDeque<>(); + pending.add(dimension); + while (!pending.isEmpty()) { + IrisDimension current = pending.removeFirst(); + String currentKey = current.getLoadKey(); + if (currentKey == null || currentKey.isBlank() || !dimensionKeys.add(currentKey)) { + continue; + } + IrisDimensionStack stack = current.getDimensionStack(); + if (stack == null || stack.getDimensions() == null) { + continue; + } + for (String key : stack.getDimensions()) { + if (key == null || key.isBlank() || dimensionKeys.contains(key)) { + continue; + } + IrisDimension referenced = resolver.apply(key); + if (referenced == null) { + dimensionKeys.add(key); + } else { + pending.addLast(referenced); + } + } + } + return dimensionKeys; + } + public static KSet collectMarkerKeys(Iterable placements) { KSet markerKeys = new KSet<>(); if (placements == null) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index 8b3391cba..fc4b8210d 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -80,12 +80,13 @@ final class PackHydrologyValidator { List sortedDimensions = new ArrayList<>(List.of(dimensionFiles)); sortedDimensions.sort(Comparator.comparing(File::getPath)); + File dimensionsFolder = new File(packFolder, PackValidator.DIMENSIONS_FOLDER); for (File dimensionFile : sortedDimensions) { JSONObject dimension = PackValidationIo.readJson(dimensionFile); if (dimension == null) { continue; } - String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); + String dimensionKey = PackValidationIo.deriveKey(dimensionsFolder, dimensionFile); String dimensionPath = "Dimension '" + dimensionKey + "'"; JSONObject dimensionPolicy = validatePolicy( dimensionPath, diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java index 086c231e6..39d95e2f2 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java @@ -7,6 +7,7 @@ import art.arcane.iris.engine.image.IrisImageMapValidationException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionStack; import art.arcane.iris.engine.object.IrisExpression; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisImage; @@ -217,11 +218,12 @@ final class PackImageMapValidator { Set warnings ) { File[] sortedFiles = Arrays.copyOf(dimensionFiles, dimensionFiles.length); - Arrays.sort(sortedFiles, (File first, File second) -> first.getName().compareTo(second.getName())); + Arrays.sort(sortedFiles, (File first, File second) -> first.getPath().compareTo(second.getPath())); Map dimensions = new LinkedHashMap<>(); Set bindingReadyDimensions = new LinkedHashSet<>(); + File dimensionsFolder = new File(packFolder, PackValidator.DIMENSIONS_FOLDER); for (File dimensionFile : sortedFiles) { - String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); + String dimensionKey = PackValidationIo.deriveKey(dimensionsFolder, dimensionFile); JSONObject raw = PackValidationIo.readJson(dimensionFile); if (raw == null) { continue; @@ -247,19 +249,35 @@ final class PackImageMapValidator { validateGeneratorStyleReferences(dimensionKey, dimension, data, resources, blockingErrors); } Map>> upperParents = new TreeMap<>(); + Map>> stackParents = new TreeMap<>(); for (Map.Entry entry : dimensions.entrySet()) { String upperKey = normalized(entry.getValue().getUpperDimension()); - if (upperKey == null || upperKey.equalsIgnoreCase("none")) { + if (upperKey != null && !upperKey.equalsIgnoreCase("none")) { + upperParents.computeIfAbsent(upperKey, ignored -> new ArrayList<>()).add(entry); + } + IrisDimensionStack stack = entry.getValue().getDimensionStack(); + if (stack == null || stack.getDimensions() == null) { continue; } - upperParents.computeIfAbsent(upperKey, ignored -> new ArrayList<>()).add(entry); + Set stackKeys = new LinkedHashSet<>(); + for (String stackKey : stack.getDimensions()) { + String normalizedKey = normalized(stackKey); + if (normalizedKey == null || normalizedKey.equals(entry.getKey()) + || !stackKeys.add(normalizedKey)) { + continue; + } + stackParents.computeIfAbsent(normalizedKey, ignored -> new ArrayList<>()).add(entry); + } } for (Map.Entry entry : dimensions.entrySet()) { if (!bindingReadyDimensions.contains(entry.getKey())) { continue; } List> parents = upperParents.get(entry.getKey()); - if (parents == null || parents.isEmpty()) { + List> stackHosts = stackParents.get(entry.getKey()); + boolean hasUpperParents = parents != null && !parents.isEmpty(); + boolean hasStackHosts = stackHosts != null && !stackHosts.isEmpty(); + if (!hasUpperParents && !hasStackHosts) { validateBindings(packFolder, "Dimension '" + entry.getKey() + "'", entry.getValue(), entry.getValue().getWorldBoundary(), data, resources, registries, blockingErrors, warnings); continue; @@ -268,12 +286,23 @@ final class PackImageMapValidator { validateBindings(packFolder, "Dimension '" + entry.getKey() + "'", entry.getValue(), entry.getValue().getWorldBoundary(), data, resources, registries, blockingErrors, warnings); } - parents.sort(Map.Entry.comparingByKey()); - for (Map.Entry parent : parents) { - validateBindings(packFolder, - "Dimension '" + parent.getKey() + "' upper dimension '" + entry.getKey() + "'", - entry.getValue(), parent.getValue().getWorldBoundary(), data, resources, registries, - blockingErrors, warnings); + if (hasUpperParents) { + parents.sort(Map.Entry.comparingByKey()); + for (Map.Entry parent : parents) { + validateBindings(packFolder, + "Dimension '" + parent.getKey() + "' upper dimension '" + entry.getKey() + "'", + entry.getValue(), parent.getValue().getWorldBoundary(), data, resources, registries, + blockingErrors, warnings); + } + } + if (hasStackHosts) { + stackHosts.sort(Map.Entry.comparingByKey()); + for (Map.Entry host : stackHosts) { + validateBindings(packFolder, + "Dimension '" + host.getKey() + "' dimension stack layer '" + entry.getKey() + "'", + entry.getValue(), host.getValue().getWorldBoundary(), data, resources, registries, + blockingErrors, warnings); + } } } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 5227437aa..e09d62545 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -111,11 +111,13 @@ public final class PackValidator { return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } - File[] dimensionFiles = dimensionsFolder.listFiles(f -> f.isFile() && f.getName().endsWith(".json")); - if (dimensionFiles == null || dimensionFiles.length == 0) { + List discoveredDimensions = PackValidationIo.listJsonRecursive(dimensionsFolder); + if (discoveredDimensions.isEmpty()) { blockingErrors.add("No dimension JSON files under dimensions/."); return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } + discoveredDimensions.sort((first, second) -> first.getPath().compareTo(second.getPath())); + File[] dimensionFiles = discoveredDimensions.toArray(File[]::new); PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); PackImageMapValidator.Validation imageMaps = PackImageMapValidator.validate( @@ -195,7 +197,7 @@ public final class PackValidator { if (!data.getContentGate().ready()) { return List.of(); } - boolean dimensionExcluded = loadEverything(data, dimensionFiles); + boolean dimensionExcluded = loadEverything(packFolder, data, dimensionFiles); List findings = data.getCompatReport().findings(); applyCompatFindings(minecraftVersion, dimensionExcluded, findings, blockingErrors, warnings); return findings; @@ -219,11 +221,12 @@ public final class PackValidator { * * @return true when any dimension in the pack cannot generate on this server */ - private static boolean loadEverything(IrisData data, File[] dimensionFiles) { + private static boolean loadEverything(File packFolder, IrisData data, File[] dimensionFiles) { boolean dimensionExcluded = false; + File dimensionsFolder = new File(packFolder, DIMENSIONS_FOLDER); for (File dimensionFile : dimensionFiles) { IrisDimension dimension = data.getDimensionLoader().load( - PackValidationIo.stripExtension(dimensionFile.getName()), false); + PackValidationIo.deriveKey(dimensionsFolder, dimensionFile), false); if (dimension == null) { continue; } diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java index 24d7dc650..154c37a90 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisExpression; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.core.pack.PackExportClosure; @@ -106,11 +107,19 @@ public class IrisPackageCompiler { throw new IllegalStateException("Failed to create structure package staging folder " + folder.getAbsolutePath()); } IrisLogging.info("Packaging Dimension " + dimension.getName() + " " + (obfuscate ? "(Obfuscated)" : "")); + KList dimensions = new KList<>(); + for (String dimensionKey : PackExportClosure.collectDimensionKeys(dimension).stream().sorted().toList()) { + IrisDimension exportedDimension = dm.getDimensionLoader().load(dimensionKey); + if (exportedDimension != null) { + dimensions.add(exportedDimension); + } + } KSet regions = new KSet<>(); KSet biomes = new KSet<>(); KSet entities = new KSet<>(); KSet spawners = new KSet<>(); KSet generators = new KSet<>(); + KSet expressions = new KSet<>(); KSet loot = new KSet<>(); KSet blocks = new KSet<>(); @@ -119,22 +128,30 @@ public class IrisPackageCompiler { for (String i : dm.getBlockLoader().getPossibleKeys()) { addIfPresent(blocks, dm.getBlockLoader().load(i)); } + for (String key : dm.getExpressionLoader().getPossibleKeys()) { + addIfPresent(expressions, dm.getExpressionLoader().load(key)); + } - dimension.getAllRegions(() -> dm).forEach((region) -> addIfPresent(regions, region)); - dimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))); + for (IrisDimension exportedDimension : dimensions) { + exportedDimension.getAllRegions(() -> dm).forEach((region) -> addIfPresent(regions, region)); + exportedDimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))); + exportedDimension.getReachableBiomes(() -> dm).forEach((biome) -> addIfPresent(biomes, biome)); + exportedDimension.getEntitySpawners().forEach((sp) -> + addIfPresent(spawners, dm.getSpawnerLoader().load(sp))); + } regions.forEach((i) -> biomes.addAll(i.getAllBiomes(() -> dm))); - dimension.getReachableBiomes(() -> dm).forEach((biome) -> addIfPresent(biomes, biome)); regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)))); - dimension.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))); biomes.forEach((i) -> i.getGenerators().forEach((j) -> addIfPresent(generators, j.getCachedGenerator(() -> dm)))); biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)))); KList allPlacements = new KList<>(); regions.forEach((r) -> allPlacements.addAll(r.getObjects())); biomes.forEach((i) -> allPlacements.addAll(i.getObjects())); - for (IrisStaticObject placement : dimension.getStaticObjects()) { - allPlacements.add(placement.toPlacement()); + for (IrisDimension exportedDimension : dimensions) { + for (IrisStaticObject placement : exportedDimension.getStaticObjects()) { + allPlacements.add(placement.toPlacement()); + } } KSet markers = new KSet<>(); for (String markerKey : PackExportClosure.collectMarkerKeys(allPlacements)) { @@ -148,7 +165,9 @@ public class IrisPackageCompiler { collectSpawnerEntityKeys(spawners).forEach((i) -> addIfPresent(entities, dm.getEntityLoader().load(i))); entities.forEach((e) -> e.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); Set structureKeys = new LinkedHashSet<>(); - collectStructureKeys(structureKeys, dimension.getStructures()); + for (IrisDimension exportedDimension : dimensions) { + collectStructureKeys(structureKeys, exportedDimension.getStructures()); + } regions.forEach((region) -> collectStructureKeys(structureKeys, region.getStructures())); biomes.forEach((biome) -> collectStructureKeys(structureKeys, biome.getStructures())); KMap renameObjects = new KMap<>(); @@ -176,8 +195,10 @@ public class IrisPackageCompiler { j.setPlace(newNames); } - for (IrisStaticObject placement : dimension.getStaticObjects()) { - placement.setObject(renameObjects.get(placement.getObject())); + for (IrisDimension exportedDimension : dimensions) { + for (IrisStaticObject placement : exportedDimension.getStaticObjects()) { + placement.setObject(renameObjects.get(placement.getObject())); + } } KMap> lookupObjects = renameObjects.flip(); @@ -230,9 +251,12 @@ public class IrisPackageCompiler { } b.append(structureClosure.writeTo(folder, minify)); b.append(ImageMapPackageClosure.writeAll(dm, folder, minify)); - a = new JSONObject(new Gson().toJson(dimension)).toString(minify ? 0 : 4); - IO.writeAll(new File(folder, "dimensions/" + dimension.getLoadKey() + ".json"), a); - b.append(IO.hash(a)); + for (IrisDimension exportedDimension : dimensions.stream() + .sorted(Comparator.comparing(IrisDimension::getLoadKey)).toList()) { + a = new JSONObject(new Gson().toJson(exportedDimension)).toString(minify ? 0 : 4); + IO.writeAll(new File(folder, "dimensions/" + exportedDimension.getLoadKey() + ".json"), a); + b.append(IO.hash(a)); + } for (IrisGenerator i : generators) { a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4); @@ -240,6 +264,13 @@ public class IrisPackageCompiler { b.append(IO.hash(a)); } + for (IrisExpression i : expressions.stream() + .sorted(Comparator.comparing(IrisExpression::getLoadKey)).toList()) { + a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4); + IO.writeAll(new File(folder, "expressions/" + i.getLoadKey() + ".json"), a); + b.append(IO.hash(a)); + } + c.append(IO.hash(b.toString())); b = new StringBuilder(); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java index 7c292dd68..262aafb34 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java @@ -21,6 +21,7 @@ package art.arcane.iris.core.project; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import java.io.File; @@ -179,19 +180,58 @@ public final class IrisProjectCopier { } private static void transformDimension(Path stage, String sourceKey, String targetKey) throws IOException { - Path oldDimension = stage.resolve("dimensions").resolve(sourceKey + ".json"); - Path newDimension = stage.resolve("dimensions").resolve(targetKey + ".json"); + Path dimensionsRoot = stage.resolve("dimensions"); + Path oldDimension = dimensionsRoot.resolve(sourceKey + ".json"); + Path newDimension = dimensionsRoot.resolve(targetKey + ".json"); if (!oldDimension.equals(newDimension)) { Files.move(oldDimension, newDimension); } - JSONObject json = new JSONObject(IO.readAll(newDimension.toFile())); - if (json.has("name")) { - json.put("name", Form.capitalizeWords(targetKey.replace('-', ' '))); - IO.writeAll(newDimension.toFile(), json.toString(4)); + try (Stream files = Files.walk(dimensionsRoot)) { + for (Path dimensionFile : files.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList()) { + JSONObject json = new JSONObject(IO.readAll(dimensionFile.toFile())); + boolean changed = false; + if (dimensionFile.equals(newDimension) && json.has("name")) { + json.put("name", Form.capitalizeWords(targetKey.replace('-', ' '))); + changed = true; + } + if (rewriteDimensionStackReferences(json, sourceKey, targetKey)) { + changed = true; + } + if (changed) { + IO.writeAll(dimensionFile.toFile(), json.toString(4)); + } + } } } + private static boolean rewriteDimensionStackReferences(JSONObject json, String sourceKey, String targetKey) { + JSONObject stack = json.optJSONObject("dimensionStack"); + JSONArray dimensions = stack == null ? null : stack.optJSONArray("dimensions"); + if (dimensions == null) { + return false; + } + + boolean changed = false; + JSONArray rewritten = new JSONArray(); + for (int index = 0; index < dimensions.length(); index++) { + Object value = dimensions.opt(index); + if (sourceKey.equals(value)) { + rewritten.put(targetKey); + changed = true; + } else { + rewritten.put(value == null ? JSONObject.NULL : value); + } + } + if (changed) { + stack.put("dimensions", rewritten); + } + return changed; + } + private static void publish(Path stage, Path target) throws IOException { try { Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE); diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index b67b4e8a3..af26dbc55 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -515,7 +515,7 @@ public class BoardSVC implements IrisService, BoardProvider { } lines.add("&7&m "); - lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_REGION, MessageArgument.untrusted("region", engine.getRegion(x, z).getName()))); + lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_REGION, MessageArgument.untrusted("region", engine.getRegion(x, y, z).getName()))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BIOME, MessageArgument.untrusted("biome", engine.getBiomeOrMantle(x, y, z).getName()))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_HEIGHT, MessageArgument.trusted("height", Math.round(engine.getHeight(x, z))))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_SLOPE, MessageArgument.trusted("slope", Form.f(engine.getSlope(x, z), 2)))); diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index ce8b75c91..bd0c3bf8d 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -326,7 +326,10 @@ public class TreeSVC implements IrisService { // Add more or find any in the region if (isUseAll || placements.isEmpty()) { - IrisRegion region = worldAccess.getEngine().getRegion(location.getBlockX(), location.getBlockZ()); + IrisRegion region = worldAccess.getEngine().getRegion( + location.getBlockX(), + location.getBlockY() - worldAccess.getTarget().getWorld().minHeight(), + location.getBlockZ()); placements.addAll(matchObjectPlacements(region.getObjects(), size, type, worldAccess.getData())); } diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java b/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java new file mode 100644 index 000000000..2e93c8c05 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/DimensionStackContext.java @@ -0,0 +1,363 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionStack; +import art.arcane.iris.engine.object.IrisDimensionStackBlend; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +public final class DimensionStackContext { + private static final int LAYOUT_CACHE_SIZE = 65536; + private static final long SEAM_SALT = 0x44494D535441434BL; + private static final long HASH_OFFSET = 0xCBF29CE484222325L; + private static final long HASH_PRIME = 0x100000001B3L; + + private final Engine engine; + private final int outputHeight; + private final int spacer; + private final List layersTopToBottom; + private final List layersBottomToTop; + private final List seamOffsetSamplersBottomToTop; + private final Cache layoutCache; + + private DimensionStackContext(ContextState state) { + engine = state.engine(); + outputHeight = state.outputHeight(); + spacer = state.spacer(); + layersBottomToTop = List.copyOf(state.layersBottomToTop()); + ArrayList reversed = new ArrayList<>(layersBottomToTop); + Collections.reverse(reversed); + layersTopToBottom = List.copyOf(reversed); + seamOffsetSamplersBottomToTop = List.copyOf(state.seamOffsetSamplersBottomToTop()); + layoutCache = Caffeine.newBuilder().maximumSize(LAYOUT_CACHE_SIZE).build(); + } + + public static DimensionStackContext create(Engine engine, IrisDimensionStack stack) { + if (stack == null) { + throw new IllegalArgumentException("Dimension stack configuration is required"); + } + KList dimensionKeys = stack.getDimensions(); + validateRuntimeContract(engine.getDimension(), dimensionKeys); + if (stack.getSpacer() < 0 || stack.getSpacer() > 256) { + throw new IllegalArgumentException("Dimension stack spacer must be between 0 and 256"); + } + + ArrayList layersBottomToTop = new ArrayList<>(dimensionKeys.size()); + for (int keyIndex = dimensionKeys.size() - 1; keyIndex >= 0; keyIndex--) { + String dimensionKey = dimensionKeys.get(keyIndex); + IrisDimension dimension = dimensionKey.equals(engine.getDimension().getLoadKey()) + ? engine.getDimension() + : engine.getData().getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalArgumentException( + "Dimension stack reference does not resolve in this pack: " + dimensionKey); + } + layersBottomToTop.add(DimensionTerrainContext.forStack(engine, dimension)); + } + + List seamSamplers = createSeamSamplers( + engine, + stack.getBlend(), + layersBottomToTop + ); + return new DimensionStackContext(new ContextState( + engine, + engine.getHeight(), + stack.getSpacer(), + layersBottomToTop, + seamSamplers + )); + } + + private static void validateRuntimeContract(IrisDimension host, KList dimensionKeys) { + if (dimensionKeys == null || dimensionKeys.size() < 2) { + throw new IllegalArgumentException("Dimension stack requires at least two dimensions"); + } + String hostKey = host.getLoadKey(); + if (!hostKey.equals(dimensionKeys.get(dimensionKeys.size() - 1))) { + throw new IllegalArgumentException("Dimension stack host must be its final bottom dimension"); + } + int hostReferences = 0; + for (String dimensionKey : dimensionKeys) { + if (dimensionKey == null || dimensionKey.isBlank()) { + throw new IllegalArgumentException("Dimension stack keys must be nonblank"); + } + if (hostKey.equals(dimensionKey)) { + hostReferences++; + } + } + if (hostReferences != 1) { + throw new IllegalArgumentException("Dimension stack must contain its host exactly once"); + } + } + + private static List createSeamSamplers( + Engine engine, + IrisDimensionStackBlend blend, + List layersBottomToTop + ) { + int seamCount = layersBottomToTop.size() - 1; + ArrayList samplers = new ArrayList<>(seamCount); + if (blend == null || blend.getAmplitude() == 0) { + for (int seamIndex = 0; seamIndex < seamCount; seamIndex++) { + samplers.add((x, z) -> 0); + } + return samplers; + } + if (blend.getAmplitude() < 0 || blend.getAmplitude() > 256) { + throw new IllegalArgumentException("Dimension stack blend amplitude must be between 0 and 256"); + } + IrisGeneratorStyle style = blend.getStyle(); + if (style == null) { + throw new IllegalArgumentException("Dimension stack blend style is required when amplitude is positive"); + } + + for (int seamIndex = 0; seamIndex < seamCount; seamIndex++) { + DimensionTerrainContext lower = layersBottomToTop.get(seamIndex); + DimensionTerrainContext upper = layersBottomToTop.get(seamIndex + 1); + long salt = seamSalt( + seamIndex, + lower.getDimension().getLoadKey(), + upper.getDimension().getLoadKey() + ); + RNG seamRng = new RNG(engine.getSeedManager().getTerrain() ^ salt); + CNG seamNoise = style.create(seamRng, engine.getData(), engine); + int amplitude = blend.getAmplitude(); + samplers.add((x, z) -> clampSeamOffset( + seamNoise.fitDouble(-amplitude, amplitude, x, z), amplitude)); + } + return samplers; + } + + static int clampSeamOffset(double sampledOffset, int amplitude) { + if (!Double.isFinite(sampledOffset)) { + return 0; + } + double clamped = Math.max(-amplitude, Math.min(amplitude, sampledOffset)); + return (int) Math.round(clamped); + } + + static long seamSalt(int seamIndex, String lowerDimensionKey, String upperDimensionKey) { + long hash = HASH_OFFSET ^ SEAM_SALT; + hash = hashString(hash, lowerDimensionKey); + hash ^= seamIndex; + hash *= HASH_PRIME; + return hashString(hash, upperDimensionKey); + } + + private static long hashString(long hash, String value) { + long result = hash; + for (int characterIndex = 0; characterIndex < value.length(); characterIndex++) { + result ^= value.charAt(characterIndex); + result *= HASH_PRIME; + } + return result; + } + + public DimensionStackLayout sample(int x, int z) { + return sample(x, z, usesTemporaryNaturalFallback(x, z)); + } + + private DimensionStackLayout sample(int x, int z, boolean naturalFallback) { + ArrayList layerInputs = + new ArrayList<>(layersBottomToTop.size()); + for (DimensionTerrainContext terrainContext : layersBottomToTop) { + DimensionTerrainContext.ColumnSample column = terrainContext.sampleColumn( + x, z, naturalFallback); + int normalTerrainHeight = localCoordinate( + column.terrainHeight(), + terrainContext.getLocalHeight() + ); + int fluidHeight = localCoordinate( + column.fluidHeight(), + terrainContext.getLocalHeight() + ); + layerInputs.add(new DimensionStackLayout.LayerInput( + terrainContext, + column.biome(), + column.region(), + column.rockBlock(), + column.fluidBlock(), + column.surfaceBlock(), + normalTerrainHeight, + fluidHeight + )); + } + + int[] seamOffsets = new int[seamOffsetSamplersBottomToTop.size()]; + for (int seamIndex = 0; seamIndex < seamOffsets.length; seamIndex++) { + seamOffsets[seamIndex] = seamOffsetSamplersBottomToTop.get(seamIndex).sample(x, z); + } + return DimensionStackLayout.create(outputHeight, spacer, layerInputs, seamOffsets); + } + + private static int localCoordinate(double sample, int localHeight) { + int maximum = Math.max(0, localHeight - 1); + long rounded = Math.round(sample); + return (int) Math.max(0L, Math.min(maximum, rounded)); + } + + public int getStackTerrainHeight(int x, int z) { + return (int) (sampleTopHeights(x, z) >>> 32); + } + + public int getStackTopHeight(int x, int z) { + return (int) sampleTopHeights(x, z); + } + + public DimensionStackLayout getLayout(int x, int z) { + boolean naturalFallback = usesTemporaryNaturalFallback(x, z); + long columnKey = ((long) x << 32) ^ (z & 0xFFFFFFFFL); + return resolveLayout( + layoutCache, + columnKey, + naturalFallback, + () -> sample(x, z, naturalFallback) + ); + } + + public DimensionStackLayout.Layer getLayerAt(int x, int y, int z) { + return getLayout(x, z).layerAt(y); + } + + static DimensionStackLayout resolveLayout( + Cache cache, + long columnKey, + boolean naturalFallback, + Supplier sampler + ) { + if (naturalFallback) { + return sampler.get(); + } + return cache.get(columnKey, ignored -> sampler.get()); + } + + private long sampleTopHeights(int x, int z) { + boolean naturalFallback = usesTemporaryNaturalFallback(x, z); + int baseY = 0; + int renderedTerrainTopY = Integer.MIN_VALUE; + int renderedContentTopY = Integer.MIN_VALUE; + int outputMaxY = outputHeight - 1; + int previousContentTopY = Integer.MIN_VALUE; + int previousRenderMinY = Integer.MIN_VALUE; + int previousRenderMaxY = Integer.MIN_VALUE; + int previousSurfaceY = Integer.MIN_VALUE; + boolean previousVisible = false; + for (int layerIndex = 0; layerIndex < layersBottomToTop.size(); layerIndex++) { + DimensionTerrainContext terrainContext = layersBottomToTop.get(layerIndex); + if (layerIndex > 0) { + int gapMinY = (int) Math.max(0L, (long) previousContentTopY + 1L); + int gapMaxY = (int) Math.min((long) outputMaxY, (long) baseY - 1L); + int previousTerrainY = previousVisible && previousSurfaceY >= previousRenderMinY + ? Math.min(outputMaxY, previousSurfaceY) + : Integer.MIN_VALUE; + renderedTerrainTopY = DimensionStackLayout.eraseHeightThroughGap( + renderedTerrainTopY, + gapMinY, + gapMaxY, + previousTerrainY + ); + renderedContentTopY = DimensionStackLayout.eraseHeightThroughGap( + renderedContentTopY, + gapMinY, + gapMaxY, + previousVisible ? previousRenderMaxY : Integer.MIN_VALUE + ); + } + int localTerrainHeight = localCoordinate( + terrainContext.getNormalTerrainHeight(x, z, naturalFallback), + terrainContext.getLocalHeight() + ); + int localFluidHeight = localCoordinate( + terrainContext.getFluidHeight(x, z, naturalFallback), + terrainContext.getLocalHeight() + ); + int surfaceY = saturatedAdd(baseY, localTerrainHeight); + int fluidY = saturatedAdd(baseY, localFluidHeight); + int layerContentTopY = Math.max(surfaceY, fluidY); + int renderMinY = Math.max(0, baseY); + int renderMaxY = Math.min(outputMaxY, layerContentTopY); + if (renderMinY <= renderMaxY) { + renderedContentTopY = Math.max(renderedContentTopY, renderMaxY); + renderedTerrainTopY = DimensionStackLayout.mergeVisibleTerrainHeight( + renderedTerrainTopY, + renderMinY, + renderMaxY, + surfaceY, + outputMaxY + ); + } + previousContentTopY = layerContentTopY; + previousRenderMinY = renderMinY; + previousRenderMaxY = renderMaxY; + previousSurfaceY = surfaceY; + previousVisible = renderMinY <= renderMaxY; + if (layerIndex + 1 < layersBottomToTop.size()) { + int seamOffset = seamOffsetSamplersBottomToTop.get(layerIndex).sample(x, z); + baseY = saturatedAdd( + layerContentTopY, + saturatedAdd(spacer, saturatedAdd(1, seamOffset)) + ); + } + } + int clippedTerrainTopY = Math.max(0, renderedTerrainTopY); + int clippedContentTopY = Math.max(0, renderedContentTopY); + return ((long) clippedTerrainTopY << 32) | (clippedContentTopY & 0xFFFFFFFFL); + } + + private static int saturatedAdd(int left, int right) { + long sum = (long) left + right; + if (sum > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + if (sum < Integer.MIN_VALUE) { + return Integer.MIN_VALUE; + } + return (int) sum; + } + + public int getOutputHeight() { + return outputHeight; + } + + public int getSpacer() { + return spacer; + } + + public boolean usesTemporaryNaturalFallback(int x, int z) { + return engine.answersFromNaturalTerrain(x, z); + } + + public List getLayersTopToBottom() { + return layersTopToBottom; + } + + public List getLayersBottomToTop() { + return layersBottomToTop; + } + + private interface SeamOffsetSampler { + int sample(double x, double z); + } + + private record ContextState( + Engine engine, + int outputHeight, + int spacer, + List layersBottomToTop, + List seamOffsetSamplersBottomToTop + ) { + } + +} diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java b/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java new file mode 100644 index 000000000..06706788c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/DimensionStackLayout.java @@ -0,0 +1,349 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.PlatformBlockState; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class DimensionStackLayout { + private final List layersBottomToTop; + private final List layersTopToBottom; + private final int stackTerrainTopY; + private final int stackTopY; + private final int clippedStackTerrainTopY; + private final int clippedStackTopY; + + private DimensionStackLayout(LayoutState state) { + layersBottomToTop = List.copyOf(state.layersBottomToTop()); + ArrayList reversed = new ArrayList<>(layersBottomToTop); + Collections.reverse(reversed); + layersTopToBottom = List.copyOf(reversed); + stackTerrainTopY = state.stackTerrainTopY(); + stackTopY = state.stackTopY(); + clippedStackTerrainTopY = state.clippedStackTerrainTopY(); + clippedStackTopY = state.clippedStackTopY(); + } + + static DimensionStackLayout create( + int outputHeight, + int spacer, + List layerInputsBottomToTop, + int[] seamOffsetsBottomToTop + ) { + if (outputHeight < 1) { + throw new IllegalArgumentException("Output height must be positive"); + } + if (spacer < 0) { + throw new IllegalArgumentException("Dimension stack spacer cannot be negative"); + } + if (layerInputsBottomToTop.isEmpty()) { + throw new IllegalArgumentException("Dimension stack requires at least one layer"); + } + if (seamOffsetsBottomToTop.length != layerInputsBottomToTop.size() - 1) { + throw new IllegalArgumentException("Dimension stack seam offset count does not match its layers"); + } + + int outputMaxY = outputHeight - 1; + int baseY = 0; + int stackTerrainTopY = Integer.MIN_VALUE; + int stackTopY = Integer.MIN_VALUE; + int renderedTerrainTopY = Integer.MIN_VALUE; + int renderedContentTopY = Integer.MIN_VALUE; + ArrayList layers = new ArrayList<>(layerInputsBottomToTop.size()); + for (int layerIndex = 0; layerIndex < layerInputsBottomToTop.size(); layerIndex++) { + LayerInput input = layerInputsBottomToTop.get(layerIndex); + int seamOffsetBelow = layerIndex == 0 ? 0 : seamOffsetsBottomToTop[layerIndex - 1]; + int spacerBelow = layerIndex == 0 ? 0 : spacer; + if (layerIndex > 0) { + Layer lower = layers.get(layerIndex - 1); + int gapMinY = (int) Math.max(0L, (long) lower.contentTopY() + 1L); + int gapMaxY = (int) Math.min((long) outputMaxY, (long) baseY - 1L); + renderedTerrainTopY = eraseHeightThroughGap( + renderedTerrainTopY, + gapMinY, + gapMaxY, + visibleTerrainY(lower) + ); + renderedContentTopY = eraseHeightThroughGap( + renderedContentTopY, + gapMinY, + gapMaxY, + lower.visible() ? lower.renderMaxY() : Integer.MIN_VALUE + ); + } + int surfaceY = saturatedAdd(baseY, input.normalTerrainHeight()); + int fluidY = saturatedAdd(baseY, input.fluidHeight()); + int contentTopY = Math.max(surfaceY, fluidY); + int renderMinY = Math.max(0, baseY); + int renderMaxY = Math.min(outputMaxY, contentTopY); + boolean visible = renderMinY <= renderMaxY; + Layer layer = new Layer( + input.terrainContext(), + input.biome(), + input.region(), + input.rockBlock(), + input.fluidBlock(), + input.surfaceBlock(), + baseY, + input.normalTerrainHeight(), + input.fluidHeight(), + surfaceY, + fluidY, + contentTopY, + spacerBelow, + seamOffsetBelow, + renderMinY, + renderMaxY, + clip(surfaceY, outputMaxY), + clip(fluidY, outputMaxY), + visible + ); + layers.add(layer); + stackTerrainTopY = Math.max(stackTerrainTopY, surfaceY); + stackTopY = Math.max(stackTopY, contentTopY); + if (visible) { + renderedContentTopY = Math.max(renderedContentTopY, renderMaxY); + renderedTerrainTopY = mergeVisibleTerrainHeight( + renderedTerrainTopY, + renderMinY, + renderMaxY, + surfaceY, + outputMaxY + ); + } + if (layerIndex + 1 < layerInputsBottomToTop.size()) { + baseY = saturatedAdd( + contentTopY, + saturatedAdd(spacer, saturatedAdd(1, seamOffsetsBottomToTop[layerIndex])) + ); + } + } + + return new DimensionStackLayout(new LayoutState( + layers, + stackTerrainTopY, + stackTopY, + Math.max(0, renderedTerrainTopY), + Math.max(0, renderedContentTopY) + )); + } + + private static int saturatedAdd(int left, int right) { + long sum = (long) left + right; + if (sum > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + if (sum < Integer.MIN_VALUE) { + return Integer.MIN_VALUE; + } + return (int) sum; + } + + private static int clip(int value, int outputMaxY) { + return Math.max(0, Math.min(outputMaxY, value)); + } + + static int mergeVisibleTerrainHeight( + int currentTerrainTopY, + int renderMinY, + int renderMaxY, + int surfaceY, + int outputMaxY + ) { + int terrainTopY = currentTerrainTopY; + if (terrainTopY >= renderMinY && terrainTopY <= renderMaxY) { + terrainTopY = Integer.MIN_VALUE; + } + if (surfaceY >= renderMinY) { + terrainTopY = Math.max(terrainTopY, Math.min(outputMaxY, surfaceY)); + } + return terrainTopY; + } + + static int eraseHeightThroughGap( + int currentTopY, + int gapMinY, + int gapMaxY, + int fallbackTopY + ) { + if (currentTopY >= gapMinY && currentTopY <= gapMaxY) { + return fallbackTopY; + } + return currentTopY; + } + + private static int visibleTerrainY(Layer layer) { + if (!layer.visible() || layer.surfaceY() < layer.renderMinY()) { + return Integer.MIN_VALUE; + } + return layer.clippedSurfaceY(); + } + + public List layersBottomToTop() { + return layersBottomToTop; + } + + public List layersTopToBottom() { + return layersTopToBottom; + } + + public int stackTerrainTopY() { + return stackTerrainTopY; + } + + public int stackTopY() { + return stackTopY; + } + + public int clippedStackTerrainTopY() { + return clippedStackTerrainTopY; + } + + public int clippedStackTopY() { + return clippedStackTopY; + } + + public Layer topTerrainLayer() { + Layer highest = null; + int highestSurfaceY = Integer.MIN_VALUE; + for (int layerIndex = 0; layerIndex < layersBottomToTop.size(); layerIndex++) { + Layer layer = layersBottomToTop.get(layerIndex); + if (layerIndex > 0) { + Layer lower = layersBottomToTop.get(layerIndex - 1); + if ((long) highestSurfaceY > lower.contentTopY() + && (long) highestSurfaceY < layer.localBaseY()) { + highest = visibleTerrainY(lower) == Integer.MIN_VALUE ? null : lower; + highestSurfaceY = visibleTerrainY(lower); + } + } + if (layer.containsRenderedY(highestSurfaceY)) { + highest = null; + highestSurfaceY = Integer.MIN_VALUE; + } + if (!layer.visible() || layer.surfaceY() < layer.renderMinY()) { + continue; + } + if (layer.clippedSurfaceY() >= highestSurfaceY) { + highest = layer; + highestSurfaceY = layer.clippedSurfaceY(); + } + } + return highest; + } + + public Layer surfaceLayer() { + Layer terrainLayer = topTerrainLayer(); + return terrainLayer == null ? topVisibleLayer() : terrainLayer; + } + + private Layer topVisibleLayer() { + Layer highest = null; + int highestContentY = Integer.MIN_VALUE; + for (int layerIndex = 0; layerIndex < layersBottomToTop.size(); layerIndex++) { + Layer layer = layersBottomToTop.get(layerIndex); + if (layerIndex > 0) { + Layer lower = layersBottomToTop.get(layerIndex - 1); + if ((long) highestContentY > lower.contentTopY() + && (long) highestContentY < layer.localBaseY()) { + highest = lower.visible() ? lower : null; + highestContentY = lower.visible() + ? lower.renderMaxY() + : Integer.MIN_VALUE; + } + } + if (layer.visible() && layer.renderMaxY() >= highestContentY) { + highest = layer; + highestContentY = layer.renderMaxY(); + } + } + return highest; + } + + public Layer layerAt(int y) { + Layer bottom = layersBottomToTop.get(0); + Layer owner = bottom; + for (int layerIndex = 1; layerIndex < layersBottomToTop.size(); layerIndex++) { + Layer lower = layersBottomToTop.get(layerIndex - 1); + Layer layer = layersBottomToTop.get(layerIndex); + if ((long) y > lower.contentTopY() && (long) y < layer.localBaseY()) { + owner = bottom; + } + if (layer.containsRenderedY(y)) { + owner = layer; + } + } + return owner; + } + + public boolean containsUpperLayerY(int y) { + for (int layerIndex = 1; layerIndex < layersBottomToTop.size(); layerIndex++) { + if (layersBottomToTop.get(layerIndex).containsRenderedY(y)) { + return true; + } + } + return false; + } + + public boolean isHostFeatureProtectedY(int y) { + for (int layerIndex = 1; layerIndex < layersBottomToTop.size(); layerIndex++) { + Layer lower = layersBottomToTop.get(layerIndex - 1); + Layer upper = layersBottomToTop.get(layerIndex); + if (upper.containsRenderedY(y) + || ((long) y > lower.contentTopY() && (long) y < upper.localBaseY())) { + return true; + } + } + return false; + } + + static record LayerInput( + DimensionTerrainContext terrainContext, + IrisBiome biome, + IrisRegion region, + PlatformBlockState rockBlock, + PlatformBlockState fluidBlock, + PlatformBlockState surfaceBlock, + int normalTerrainHeight, + int fluidHeight + ) { + } + + public record Layer( + DimensionTerrainContext terrainContext, + IrisBiome biome, + IrisRegion region, + PlatformBlockState rockBlock, + PlatformBlockState fluidBlock, + PlatformBlockState surfaceBlock, + int localBaseY, + int normalTerrainHeight, + int fluidHeight, + int surfaceY, + int fluidY, + int contentTopY, + int spacerBelow, + int seamOffsetBelow, + int renderMinY, + int renderMaxY, + int clippedSurfaceY, + int clippedFluidY, + boolean visible + ) { + public boolean containsRenderedY(int y) { + return visible && y >= renderMinY && y <= renderMaxY; + } + + } + + private record LayoutState( + List layersBottomToTop, + int stackTerrainTopY, + int stackTopY, + int clippedStackTerrainTopY, + int clippedStackTopY + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java new file mode 100644 index 000000000..85f5b34f7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java @@ -0,0 +1,764 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisInterpolator; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.iris.util.project.interpolation.NoiseBounds; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class DimensionTerrainContext implements DataProvider { + private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); + + private final Engine engine; + private final IrisDimension dimension; + private final IrisData data; + private final int localHeight; + private final ProceduralStream heightStream; + private final ProceduralStream slopeStream; + private final ProceduralStream fluidHeightStream; + private final ProceduralStream biomeStream; + private final ProceduralStream regionStream; + private final ProceduralStream rockStream; + private final FluidBlockSampler fluidBlockSampler; + private final IrisImageMapRuntime imageMapRuntime; + private final boolean selfReferencing; + private final SelfFallback selfFallback; + + private DimensionTerrainContext(ContextState state) { + engine = state.engine(); + dimension = state.dimension(); + data = state.data(); + localHeight = state.localHeight(); + heightStream = state.heightStream(); + slopeStream = state.slopeStream(); + fluidHeightStream = state.fluidHeightStream(); + biomeStream = state.biomeStream(); + regionStream = state.regionStream(); + rockStream = state.rockStream(); + fluidBlockSampler = state.fluidBlockSampler(); + imageMapRuntime = state.imageMapRuntime(); + selfReferencing = state.selfReferencing(); + selfFallback = state.selfFallback(); + } + + public static DimensionTerrainContext forStack(Engine engine, IrisDimension dimension) { + boolean selfReferencing = dimension.getLoadKey().equals(engine.getDimension().getLoadKey()); + if (selfReferencing) { + return createSelfReferencing(engine, false); + } + + int localHeight = dimension.getMaxHeight() - dimension.getMinHeight(); + return createCrossReferencing( + engine, + dimension, + localHeight, + dimension.getMinHeight(), + "stack-" + dimension.getLoadKey(), + true + ); + } + + static DimensionTerrainContext forUpper(Engine engine, IrisDimension dimension) { + boolean selfReferencing = dimension.getLoadKey().equals(engine.getDimension().getLoadKey()); + if (selfReferencing) { + return createSelfReferencing(engine, true); + } + + return createCrossReferencing( + engine, + dimension, + engine.getHeight(), + engine.getMinHeight(), + "upper", + false + ); + } + + private static DimensionTerrainContext createSelfReferencing(Engine engine, boolean natural) { + IrisComplex complex = engine.getComplex(); + double fluidHeight = engine.getDimension().getFluidHeight(); + ProceduralStream fluidHeightStream = natural + ? ProceduralStream.ofDouble((x, z) -> fluidHeight) + : complex.getRiverWaterSurfaceStream(); + FluidBlockSampler fluidBlockSampler = natural + ? (x, z) -> complex.getFluidStream().get(x, z) + : complex::resolveSurfaceFluid; + SelfFallback selfFallback = natural + ? null + : new SelfFallback( + complex.getNaturalHeightStream(), + complex.getNaturalTrueBiomeStream(), + fluidHeight, + (x, z) -> complex.getFluidStream().get(x, z) + ); + return new DimensionTerrainContext(new ContextState( + engine, + engine.getDimension(), + engine.getData(), + engine.getHeight(), + natural ? complex.getNaturalHeightStream() : complex.getHeightStream(), + natural ? complex.getNaturalSlopeStream() : complex.getSlopeStream(), + fluidHeightStream, + natural ? complex.getNaturalTrueBiomeStream() : complex.getTrueBiomeStream(), + complex.getRegionStream(), + complex.getRockStream(), + fluidBlockSampler, + complex.getImageMapRuntime(), + true, + selfFallback + )); + } + + private static DimensionTerrainContext createCrossReferencing( + Engine engine, + IrisDimension dimension, + int localHeight, + int minimumWorldHeight, + String cachePrefix, + boolean includeFluid + ) { + IrisData resolvedData = dimension.getLoader(); + if (resolvedData == null) { + resolvedData = engine.getData(); + } + IrisData dimensionData = resolvedData; + IrisImageMapRuntime imageMapRuntime = IrisImageMapRuntime.compile( + dimensionData, + dimension, + minimumWorldHeight + ); + long seedOffset = dimension.getLoadKey().hashCode(); + RNG rng = new RNG(engine.getSeedManager().getComplex() ^ seedOffset); + double fluidHeight = dimension.getFluidHeight(); + int cacheSize = IrisSettings.get().getPerformance().getNoiseCacheSize(); + DataProvider dataProvider = () -> dimensionData; + IrisBiome configuredFocusBiome = loadFocusBiome(dimension, dimensionData); + IrisBiome focusBiome = configuredFocusBiome == null + ? null + : configuredFocusBiome.withInferredType(InferredType.LAND); + IrisRegion focusRegion = focusBiome == null + ? loadFocusRegion(dimension, dimensionData) + : findFocusRegion(focusBiome, dimension, dataProvider, dimensionData); + + Map> generators = new HashMap<>(); + Set allBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); + Map childSelectionPlans = + Collections.synchronizedMap(new IdentityHashMap<>()); + KList preparedRegions = new KList<>(); + if (focusRegion != null) { + preparedRegions.add(focusRegion); + focusRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + biome, dataProvider, allBiomes, generators)); + } else { + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + preparedRegions.add(region); + region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + biome, dataProvider, allBiomes, generators)); + } + for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) { + if (!mappedBiome.isCompatExcluded()) { + registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators); + } + } + } + if (focusBiome != null) { + allBiomes.add(focusBiome); + } + + IrisComplex.GeneratorGroup[] generatorGroups = IrisComplex.freezeGeneratorGroups(generators); + Map> generatorBounds = new HashMap<>(); + for (IrisComplex.GeneratorGroup group : generatorGroups) { + IdentityHashMap interpolatorBounds = + new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); + for (IrisBiome biome : allBiomes) { + double minimum = 0D; + double maximum = 0D; + for (IrisGenerator generator : group.generators()) { + String key = generator.getLoadKey(); + if (key == null || key.isBlank()) { + continue; + } + maximum += biome.getGenLinkMax(key, engine); + minimum += biome.getGenLinkMin(key, engine); + } + interpolatorBounds.put(biome, new NoiseBounds(minimum, maximum)); + } + generatorBounds.put(group.interpolator(), interpolatorBounds); + } + + ProceduralStream regionStyleStream = dimension.getRegionStyle() + .create(rng.nextParallelRNG(883), dimensionData).stream() + .zoom(dimension.getRegionZoom()); + KList regionPool = new KList<>(); + for (IrisRegion region : dimensionData.getRegionLoader().loadAll(dimension.getRegions())) { + if (!region.isCompatExcluded()) { + regionPool.add(region); + } + } + ProceduralStream proceduralRegionStream = focusRegion == null + ? regionStyleStream.selectRarity(regionPool) + : ProceduralStream.of( + (x, z) -> focusRegion, + Interpolated.of(value -> 0D, value -> focusRegion) + ); + ProceduralStream regionStream = focusRegion == null + ? proceduralRegionStream + .convertAware2D((region, x, z) -> mappedRegion(imageMapRuntime, region, x, z)) + .cache2D(cachePrefix + "ImageMappedRegionStream", engine, cacheSize) + : proceduralRegionStream; + Map>> preparedBiomeStreams = + compileInferredBiomeStreams(dimension, dimensionData, rng, preparedRegions); + + ProceduralStream landBiomeStream = regionStream + .convert(region -> IrisComplex.preparedInferredBiomeStream( + preparedBiomeStreams, region, InferredType.LAND)) + .convertAware2D(ProceduralStream::get); + ProceduralStream seaBiomeStream = regionStream + .convert(region -> IrisComplex.preparedInferredBiomeStream( + preparedBiomeStreams, region, InferredType.SEA)) + .convertAware2D(ProceduralStream::get); + ProceduralStream shoreBiomeStream = regionStream + .convert(region -> IrisComplex.preparedInferredBiomeStream( + preparedBiomeStreams, region, InferredType.SHORE)) + .convertAware2D(ProceduralStream::get); + + Map> inferredStreams = new HashMap<>(); + inferredStreams.put(InferredType.LAND, landBiomeStream); + inferredStreams.put(InferredType.SEA, seaBiomeStream); + inferredStreams.put(InferredType.SHORE, shoreBiomeStream); + + ProceduralStream bridgeStream = focusBiome == null + ? dimension.getContinentalStyle() + .create(rng.nextParallelRNG(234234565), dimensionData) + .bake().scale(1D / dimension.getContinentZoom()).bake().stream() + .convert(value -> value >= dimension.getLandChance() + ? InferredType.SEA + : InferredType.LAND) + : ProceduralStream.of( + (x, z) -> focusBiome.getInferredType(), + Interpolated.of(value -> 0D, value -> focusBiome.getInferredType()) + ); + + ProceduralStream proceduralBaseBiomeStream = focusBiome == null + ? bridgeStream + .convertAware2D((type, x, z) -> { + ProceduralStream stream = inferredStreams.get(type); + return stream != null + ? stream.get(x, z) + : inferredStreams.get(InferredType.LAND).get(x, z); + }) + .convertAware2D((biome, x, z) -> implode( + biome, x, z, rng, dataProvider, childSelectionPlans, 3)) + : ProceduralStream.of( + (x, z) -> focusBiome, + Interpolated.of(value -> 0D, value -> focusBiome) + ); + ProceduralStream baseBiomeStream = focusBiome == null + ? proceduralBaseBiomeStream + .convertAware2D((biome, x, z) -> mappedBiome(imageMapRuntime, biome, x, z)) + .cache2D(cachePrefix + "ImageMappedBaseBiomeStream", engine, cacheSize) + : proceduralBaseBiomeStream; + + KList overlayNoise = dimension.getOverlayNoise(); + ProceduralStream overlayStream = overlayNoise.isEmpty() + ? ProceduralStream.ofDouble((x, z) -> 0D) + : ProceduralStream.ofDouble((x, z) -> { + double value = 0D; + for (IrisShapedGeneratorStyle style : overlayNoise) { + value += style.get(rng, dimensionData, x, z); + } + return value; + }); + + long heightSeed = engine.getSeedManager().getHeight() ^ seedOffset; + ProceduralStream heightStream = ProceduralStream.of((x, z) -> { + IrisBiome biome = baseBiomeStream.get(x, z); + if (biome == null) { + return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z); + } + double interpolatedHeight = 0D; + for (IrisComplex.GeneratorGroup group : generatorGroups) { + IrisInterpolator interpolator = group.interpolator(); + IrisGenerator[] groupGenerators = group.generators(); + if (groupGenerators.length == 0) { + continue; + } + IdentityHashMap cachedBounds = generatorBounds.get(interpolator); + NoiseBounds sampledBounds = interpolator.interpolateBounds(x, z, (sampleX, sampleZ) -> { + try { + IrisBiome sampledBiome = baseBiomeStream.get(sampleX, sampleZ); + if (sampledBiome == null) { + return ZERO_NOISE_BOUNDS; + } + NoiseBounds bounds = cachedBounds == null ? null : cachedBounds.get(sampledBiome); + if (bounds != null) { + return bounds; + } + double minimum = 0D; + double maximum = 0D; + for (IrisGenerator generator : groupGenerators) { + String key = generator.getLoadKey(); + if (key == null || key.isBlank()) { + continue; + } + maximum += sampledBiome.getGenLinkMax(key, engine); + minimum += sampledBiome.getGenLinkMin(key, engine); + } + return new NoiseBounds(minimum, maximum); + } catch (Throwable e) { + IrisLogging.reportError(e); + return ZERO_NOISE_BOUNDS; + } + }); + interpolatedHeight += IrisComplex.averageGeneratorHeights( + groupGenerators, + sampledBounds.min(), + sampledBounds.max(), + x, + z, + heightSeed + 239945 + ); + } + double proceduralHeight = Math.max( + Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), localHeight), + 0D + ); + return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z); + }, Interpolated.DOUBLE).cache2DDouble( + cachePrefix + "ImageMappedHeightStream", engine, cacheSize); + ProceduralStream slopeStream = heightStream.slope(3) + .cache2DDouble(cachePrefix + "SlopeStream", engine, cacheSize); + + ProceduralStream finalBiomeStream = focusBiome == null + ? heightStream.convertAware2D((height, x, z) -> { + IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z); + if (mappedBiome != null) { + return mappedBiome; + } + IrisBiome baseBiome = baseBiomeStream.get(x, z); + IrisBiome resolvedBiome = IrisComplex.resolveSurfaceBiome( + height, + baseBiome, + regionStream.get(x, z), + x, + z, + fluidHeight, + landBiomeStream, + seaBiomeStream, + shoreBiomeStream + ); + return resolvedBiome == baseBiome + ? baseBiome + : implode(resolvedBiome, x, z, rng, dataProvider, childSelectionPlans, 3); + }).cache2D(cachePrefix + "ImageMappedFinalBiomeStream", engine, cacheSize) + : ProceduralStream.of( + (x, z) -> focusBiome, + Interpolated.of(value -> 0D, value -> focusBiome) + ).cache2D(cachePrefix + "FinalBiomeStreamFocus", engine, cacheSize); + + ProceduralStream rockStream = dimension.getRockPalette() + .getLayerGenerator(rng.nextParallelRNG(45), dimensionData).stream() + .select(dimension.getRockPalette().getBlockData(dimensionData)); + FluidBlockSampler fluidBlockSampler; + if (includeFluid) { + ProceduralStream fluidStream = dimension.getFluidPalette() + .getLayerGenerator(rng.nextParallelRNG(78), dimensionData).stream() + .select(dimension.getFluidPalette().getBlockData(dimensionData)); + fluidBlockSampler = fluidStream::get; + } else { + fluidBlockSampler = (x, z) -> null; + } + + return new DimensionTerrainContext(new ContextState( + engine, + dimension, + dimensionData, + localHeight, + heightStream, + slopeStream, + ProceduralStream.ofDouble((x, z) -> fluidHeight), + finalBiomeStream, + regionStream, + rockStream, + fluidBlockSampler, + imageMapRuntime, + false, + null + )); + } + + private static void registerBiomeGenerators( + IrisBiome biome, + DataProvider dataProvider, + Set allBiomes, + Map> generators + ) { + allBiomes.add(biome); + biome.getGenerators().forEach(link -> { + IrisGenerator generator = link.getCachedGenerator(dataProvider); + if (generator != null) { + generators.computeIfAbsent(generator.getInterpolator(), key -> new HashSet<>()).add(generator); + } + }); + } + + static IrisRegion mappedRegion( + IrisImageMapRuntime imageMapRuntime, + IrisRegion proceduralRegion, + double worldX, + double worldZ + ) { + IrisRegion mappedRegion = imageMapRuntime.sampleRegion(worldX, worldZ); + return mappedRegion == null ? proceduralRegion : mappedRegion; + } + + static double mappedTerrainHeight( + IrisImageMapRuntime imageMapRuntime, + double proceduralHeight, + double worldX, + double worldZ + ) { + return imageMapRuntime.sampleTerrainHeight(worldX, worldZ, proceduralHeight); + } + + static IrisBiome mappedBiome( + IrisImageMapRuntime imageMapRuntime, + IrisBiome proceduralBiome, + double worldX, + double worldZ + ) { + IrisBiome mappedBiome = imageMapRuntime.sampleBiome(worldX, worldZ); + return mappedBiome == null ? proceduralBiome : mappedBiome; + } + + static PlatformBlockState mappedSurfaceBlock( + IrisImageMapRuntime imageMapRuntime, + PlatformBlockState proceduralBlock, + double worldX, + double worldZ + ) { + PlatformBlockState mappedBlock = imageMapRuntime.sampleSurfaceBlock(worldX, worldZ); + return mappedBlock == null ? proceduralBlock : mappedBlock; + } + + private static Map>> compileInferredBiomeStreams( + IrisDimension dimension, + IrisData data, + RNG rng, + Iterable regions + ) { + IdentityHashMap>> compiled = + new IdentityHashMap<>(); + for (IrisRegion region : regions) { + if (compiled.containsKey(region)) { + continue; + } + EnumMap> streams = new EnumMap<>(InferredType.class); + streams.put(InferredType.LAND, createInferredBiomeStream( + dimension, data, rng, region, InferredType.LAND)); + streams.put(InferredType.SEA, createInferredBiomeStream( + dimension, data, rng, region, InferredType.SEA)); + streams.put(InferredType.SHORE, createInferredBiomeStream( + dimension, data, rng, region, InferredType.SHORE)); + compiled.put(region, Collections.unmodifiableMap(streams)); + } + return Collections.unmodifiableMap(compiled); + } + + private static ProceduralStream createInferredBiomeStream( + IrisDimension dimension, + IrisData data, + RNG rng, + IrisRegion region, + InferredType type + ) { + return switch (type) { + case LAND -> dimension.getLandBiomeStyle() + .create(rng.nextParallelRNG(InferredType.LAND.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()) + .zoom(dimension.getLandZoom()) + .zoom(region.getLandBiomeZoom()) + .selectRarity(loadInferredBiomes(data, region.getLandBiomes(), InferredType.LAND)); + case SEA -> dimension.getSeaBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SEA.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()) + .zoom(dimension.getSeaZoom()) + .zoom(region.getSeaBiomeZoom()) + .selectRarity(loadInferredBiomes(data, region.getSeaBiomes(), InferredType.SEA)); + case SHORE -> dimension.getShoreBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()) + .zoom(region.getShoreBiomeZoom()) + .selectRarity(loadInferredBiomes(data, region.getShoreBiomes(), InferredType.SHORE)); + default -> throw new IllegalArgumentException("Unsupported stacked terrain biome type: " + type); + }; + } + + private static KList loadInferredBiomes( + IrisData data, + KList keys, + InferredType type + ) { + KList inferredBiomes = new KList<>(); + for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + if (biome.isCompatExcluded()) { + continue; + } + inferredBiomes.add(biome.withInferredType(type)); + } + return inferredBiomes; + } + + private static IrisBiome loadFocusBiome(IrisDimension dimension, IrisData data) { + String key = dimension.getFocus(); + if (key == null || key.isBlank()) { + return null; + } + IrisBiome biome = data.getBiomeLoader().load(key); + return biome == null || biome.isCompatExcluded() ? null : biome; + } + + private static IrisRegion loadFocusRegion(IrisDimension dimension, IrisData data) { + String key = dimension.getFocusRegion(); + if (key == null || key.isBlank()) { + return null; + } + IrisRegion region = data.getRegionLoader().load(key); + return region == null || region.isCompatExcluded() ? null : region; + } + + private static IrisRegion findFocusRegion( + IrisBiome focusBiome, + IrisDimension dimension, + DataProvider dataProvider, + IrisData data + ) { + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + if (region.getAllBiomeIds().contains(focusBiome.getLoadKey())) { + return region; + } + } + String key = UUID.nameUUIDFromBytes((dimension.getLoadKey() + "\u0000" + focusBiome.getLoadKey()) + .getBytes(StandardCharsets.UTF_8)).toString(); + IrisRegion region = new IrisRegion(); + region.getLandBiomes().add(focusBiome.getLoadKey()); + region.getSeaBiomes().add(focusBiome.getLoadKey()); + region.getShoreBiomes().add(focusBiome.getLoadKey()); + region.setLoadKey(key); + region.setLoader(data); + region.setLoadFile(new File( + data.getDataFolder(), + data.getRegionLoader().getFolderName() + "/" + key + ".json" + )); + return region; + } + + private static IrisBiome implode( + IrisBiome biome, + double x, + double z, + RNG rng, + DataProvider dataProvider, + Map childSelectionPlans, + int remainingDepth + ) { + if (biome == null || remainingDepth < 0 || biome.getChildren().isEmpty()) { + return biome; + } + + IrisComplex.ChildSelectionPlan selectionPlan = childSelectionPlans.get(biome); + if (selectionPlan == null) { + synchronized (childSelectionPlans) { + selectionPlan = childSelectionPlans.get(biome); + if (selectionPlan == null) { + KList options = new KList<>(); + for (IrisBiome child : biome.getRealChildren(dataProvider)) { + if (child != null) { + options.add(child); + } + } + options.add(biome); + selectionPlan = IrisComplex.ChildSelectionPlan.create(options); + childSelectionPlans.put(biome, selectionPlan); + } + } + } + + IrisBiome selected = selectionPlan.select( + biome.getChildrenGenerator(rng, 123, biome.getChildShrinkFactor()), x, z); + if (selected == null) { + return biome; + } + return implode( + selected.withInferredType(biome.getInferredType()), + x, + z, + rng, + dataProvider, + childSelectionPlans, + remainingDepth - 1 + ); + } + + public double getNormalTerrainHeight(double x, double z) { + return getNormalTerrainHeight(x, z, usesNaturalFallback(x, z)); + } + + double getNormalTerrainHeight(double x, double z, boolean naturalFallback) { + if (naturalFallback && selfFallback != null) { + return selfFallback.heightStream().getDouble(x, z); + } + return heightStream.getDouble(x, z); + } + + public double getFluidHeight(double x, double z) { + return getFluidHeight(x, z, usesNaturalFallback(x, z)); + } + + double getFluidHeight(double x, double z, boolean naturalFallback) { + if (naturalFallback && selfFallback != null) { + return selfFallback.fluidHeight(); + } + return fluidHeightStream.getDouble(x, z); + } + + public ProceduralStream getSlopeStream() { + return slopeStream; + } + + public IrisBiome getBiome(double x, double z) { + return getBiome(x, z, usesNaturalFallback(x, z)); + } + + private IrisBiome getBiome(double x, double z, boolean naturalFallback) { + if (naturalFallback && selfFallback != null) { + return selfFallback.biomeStream().get(x, z); + } + return biomeStream.get(x, z); + } + + public IrisRegion getRegion(double x, double z) { + return regionStream == null ? null : regionStream.get(x, z); + } + + public PlatformBlockState getRockBlock(double x, double z) { + return rockStream.get(x, z); + } + + public PlatformBlockState getFluidBlock(double x, double z) { + return getFluidBlock(x, z, usesNaturalFallback(x, z)); + } + + private PlatformBlockState getFluidBlock(double x, double z, boolean naturalFallback) { + if (naturalFallback && selfFallback != null) { + return selfFallback.fluidBlockSampler().sample(x, z); + } + return fluidBlockSampler.sample(x, z); + } + + public PlatformBlockState getSurfaceBlock(double x, double z) { + return imageMapRuntime.sampleSurfaceBlock(x, z); + } + + public IrisDimension getDimension() { + return dimension; + } + + @Override + public IrisData getData() { + return data; + } + + public int getLocalHeight() { + return localHeight; + } + + public boolean isSelfReferencing() { + return selfReferencing; + } + + ColumnSample sampleColumn(double x, double z, boolean naturalFallback) { + return new ColumnSample( + getNormalTerrainHeight(x, z, naturalFallback), + getFluidHeight(x, z, naturalFallback), + getBiome(x, z, naturalFallback), + getRegion(x, z), + getRockBlock(x, z), + getFluidBlock(x, z, naturalFallback), + getSurfaceBlock(x, z) + ); + } + + private boolean usesNaturalFallback(double x, double z) { + return selfFallback != null && engine.answersFromNaturalTerrain( + (int) Math.floor(x), + (int) Math.floor(z) + ); + } + + private interface FluidBlockSampler { + PlatformBlockState sample(double x, double z); + } + + private record ContextState( + Engine engine, + IrisDimension dimension, + IrisData data, + int localHeight, + ProceduralStream heightStream, + ProceduralStream slopeStream, + ProceduralStream fluidHeightStream, + ProceduralStream biomeStream, + ProceduralStream regionStream, + ProceduralStream rockStream, + FluidBlockSampler fluidBlockSampler, + IrisImageMapRuntime imageMapRuntime, + boolean selfReferencing, + SelfFallback selfFallback + ) { + } + + private record SelfFallback( + ProceduralStream heightStream, + ProceduralStream biomeStream, + double fluidHeight, + FluidBlockSampler fluidBlockSampler + ) { + } + + record ColumnSample( + double terrainHeight, + double fluidHeight, + IrisBiome biome, + IrisRegion region, + PlatformBlockState rockBlock, + PlatformBlockState fluidBlock, + PlatformBlockState surfaceBlock + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 763325b0f..d9e3df093 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -59,15 +59,22 @@ final class EngineHotloader { IrisComplex nextComplex = null; try { engine.sealForTransition("complex hotload", false); + engine.prepareRuntimeHotload(); RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), previous.target()); engine.runtimeAssembly.set(assembly); EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { assembly.complex = new IrisComplex(engine); nextComplex = assembly.complex; + assembly.dimensionStackContext = engine.runtimeBuilder.buildDimensionStackContext(); assembly.upperContext = engine.runtimeBuilder.buildUpperContext(); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); - next = previous.withComplex(assembly.cacheId, assembly.complex, assembly.upperContext, biomeMaxes); + next = previous.withComplex( + assembly.cacheId, + assembly.complex, + assembly.upperContext, + assembly.dimensionStackContext, + biomeMaxes); } finally { engine.runtimeAssembly.remove(); } @@ -110,6 +117,7 @@ final class EngineHotloader { boolean published = false; try { engine.sealForTransition("hotload", false); + engine.prepareRuntimeHotload(); replacementData = IrisData.openRuntime(previousData.getDataFolder()); IrisDimension replacement = replacementData.getDimensionLoader().load(previousDimension.getLoadKey()); if (replacement == null) { diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java index 1686edc9d..145af6cb2 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java @@ -34,6 +34,7 @@ record EngineRuntime( EngineTarget target, IrisComplex complex, UpperDimensionContext upperContext, + DimensionStackContext dimensionStackContext, EngineEffects effects, EngineMode mode, EngineWorldManager worldManager, @@ -44,6 +45,7 @@ record EngineRuntime( int nextCacheId, IrisComplex nextComplex, UpperDimensionContext nextUpperContext, + DimensionStackContext nextDimensionStackContext, BiomeMaxes nextBiomeMaxes ) { return new EngineRuntime( @@ -51,6 +53,7 @@ record EngineRuntime( target, nextComplex, nextUpperContext, + nextDimensionStackContext, effects, mode, worldManager, diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index 2ec39f3b8..cba1e25e5 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -22,12 +22,14 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.EngineRuntime.BiomeMaxes; import art.arcane.iris.engine.IrisEngine.LifecycleState; +import art.arcane.iris.engine.actuator.IrisDimensionStackActuator; import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EngineEffectsProvider; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; +import art.arcane.iris.engine.modifier.IrisCustomModifier; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomePaletteLayer; import art.arcane.iris.engine.object.IrisDecorator; @@ -74,6 +76,9 @@ final class EngineRuntimeBuilder { assembly.complex = new IrisComplex(engine); IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms"); started = M.ms(); + assembly.dimensionStackContext = buildDimensionStackContext(); + IrisLogging.debug("[IrisEngine timing] buildDimensionStackContext=" + (M.ms() - started) + "ms"); + started = M.ms(); assembly.upperContext = buildUpperContext(); IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - started) + "ms"); started = M.ms(); @@ -91,6 +96,14 @@ final class EngineRuntimeBuilder { IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) -> staticObjects.apply(engine, x, z, blocks)); + if (assembly.dimensionStackContext != null) { + IrisDimensionStackActuator dimensionStack = new IrisDimensionStackActuator(engine); + assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) -> + dimensionStack.actuate(x, z, blocks, multicore, context)); + IrisCustomModifier stackCustom = new IrisCustomModifier(engine); + assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) -> + stackCustom.modify(x, z, blocks, multicore, context)); + } IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.worldManager = IrisServices.get(EngineWorldManagerProvider.class).create(engine); @@ -205,6 +218,21 @@ final class EngineRuntimeBuilder { return null; } + DimensionStackContext buildDimensionStackContext() { + IrisDimension dimension = engine.getDimension(); + if (!dimension.hasDimensionStack()) { + return null; + } + if (dimension.hasUpperDimension()) { + throw new IllegalStateException("Dimension '" + dimension.getLoadKey() + + "' cannot enable both dimensionStack and upperDimension."); + } + DimensionStackContext context = DimensionStackContext.create(engine, dimension.getDimensionStack()); + IrisLogging.info("Dimension stack enabled: " + + String.join(" -> ", dimension.getDimensionStack().getDimensions())); + return context; + } + private EngineMode createMode() { Throwable configuredFailure = null; try { @@ -300,6 +328,7 @@ final class EngineRuntimeBuilder { } IrisComplex complex; UpperDimensionContext upperContext; + DimensionStackContext dimensionStackContext; EngineEffects effects; EngineMode mode; EngineWorldManager worldManager; @@ -319,6 +348,7 @@ final class EngineRuntimeBuilder { target, complex, upperContext, + dimensionStackContext, effects, mode, worldManager, diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 0e27aaa85..004db6496 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -501,10 +501,14 @@ public class IrisEngine implements Engine { } public void hotloadSilently() { - nativeStructureVolumeMemo.clear(); hotloader.hotloadSilently(); } + void prepareRuntimeHotload() { + nativeStructureVolumeMemo.clear(); + platformHooks.prepareRuntimeHotload(this); + } + @Override public KList getNativeStructureVolumes(int minX, int minZ, int maxX, int maxZ) { if (!nativeStructureVolumeQueriesEnabled.get()) { @@ -632,6 +636,16 @@ public class IrisEngine implements Engine { return current == null ? null : current.upperContext(); } + @Override + public DimensionStackContext getDimensionStackContext() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.dimensionStackContext; + } + EngineRuntime current = runtime; + return current == null ? null : current.dimensionStackContext(); + } + @Override public CompletableFuture getHash32() { RuntimeAssembly assembly = runtimeAssembly.get(); diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index 231c6112f..3a28fdbc5 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -1,311 +1,46 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package art.arcane.iris.engine; -import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.image.IrisImageMapRuntime; -import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.IrisGenerator; -import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.iris.util.common.data.DataProvider; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.interpolation.NoiseBounds; import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.util.project.stream.ProceduralStream; -import art.arcane.iris.util.project.stream.interpolation.Interpolated; - -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.Map; -import java.util.Set; +import art.arcane.iris.util.common.data.DataProvider; public class UpperDimensionContext implements DataProvider { - private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); - private final IrisDimension dimension; - private final IrisData data; + private final DimensionTerrainContext terrainContext; private final int chunkHeight; - private final ProceduralStream heightStream; - private final ProceduralStream biomeStream; - private final ProceduralStream regionStream; - private final ProceduralStream rockStream; - private final IrisImageMapRuntime imageMapRuntime; - private final boolean selfReferencing; - private UpperDimensionContext(ContextState state) { - this.dimension = state.dimension(); - this.data = state.data(); - this.chunkHeight = state.chunkHeight(); - this.heightStream = state.heightStream(); - this.biomeStream = state.biomeStream(); - this.regionStream = state.regionStream(); - this.rockStream = state.rockStream(); - this.imageMapRuntime = state.imageMapRuntime(); - this.selfReferencing = state.selfReferencing(); + private UpperDimensionContext(DimensionTerrainContext terrainContext, int chunkHeight) { + this.terrainContext = terrainContext; + this.chunkHeight = chunkHeight; } - public static UpperDimensionContext create(Engine engine, IrisDimension upperDim) { - boolean selfRef = upperDim.getLoadKey().equals(engine.getDimension().getLoadKey()); - int chunkHeight = engine.getHeight(); - if (selfRef) { - return createSelfReferencing(engine, chunkHeight); - } - return createCrossReferencing(engine, upperDim, chunkHeight); - } - - private static UpperDimensionContext createSelfReferencing(Engine engine, int chunkHeight) { - IrisComplex complex = engine.getComplex(); - return new UpperDimensionContext(new ContextState( - engine.getDimension(), - engine.getData(), - chunkHeight, - complex.getNaturalHeightStream(), - complex.getNaturalTrueBiomeStream(), - complex.getRegionStream(), - complex.getRockStream(), - complex.getImageMapRuntime(), - true - )); - } - - private static UpperDimensionContext createCrossReferencing(Engine engine, IrisDimension upperDim, int chunkHeight) { - IrisData resolvedData = upperDim.getLoader(); - if (resolvedData == null) { - resolvedData = engine.getData(); - } - IrisData upperData = resolvedData; - IrisImageMapRuntime imageMapRuntime = IrisImageMapRuntime.compile( - upperData, - upperDim, - engine.getMinHeight() + public static UpperDimensionContext create(Engine engine, IrisDimension upperDimension) { + return new UpperDimensionContext( + DimensionTerrainContext.forUpper(engine, upperDimension), + engine.getHeight() ); - long seedOffset = upperDim.getLoadKey().hashCode(); - RNG rng = new RNG(engine.getSeedManager().getComplex() ^ seedOffset); - double fluidHeight = upperDim.getFluidHeight(); - int cacheSize = IrisSettings.get().getPerformance().getNoiseCacheSize(); - DataProvider dataProvider = () -> upperData; - - Map> generators = new HashMap<>(); - Set allBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); - Map childSelectionPlans = - Collections.synchronizedMap(new IdentityHashMap<>()); - upperDim.getRegions().forEach(regionKey -> { - IrisRegion region = upperData.getRegionLoader().load(regionKey); - if (region != null) { - region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( - biome, dataProvider, allBiomes, generators)); - } - }); - for (IrisRegion mappedRegion : imageMapRuntime.getMappedRegions()) { - mappedRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( - biome, dataProvider, allBiomes, generators)); - } - for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) { - registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators); - } - - IrisComplex.GeneratorGroup[] generatorGroups = IrisComplex.freezeGeneratorGroups(generators); - Map> generatorBounds = new HashMap<>(); - for (IrisComplex.GeneratorGroup group : generatorGroups) { - IdentityHashMap interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); - for (IrisBiome biome : allBiomes) { - double min = 0D; - double max = 0D; - for (IrisGenerator gen : group.generators()) { - String key = gen.getLoadKey(); - if (key == null || key.isBlank()) { - continue; - } - max += biome.getGenLinkMax(key, engine); - min += biome.getGenLinkMin(key, engine); - } - interpolatorBounds.put(biome, new NoiseBounds(min, max)); - } - generatorBounds.put(group.interpolator(), interpolatorBounds); - } - - ProceduralStream regionStyleStream = upperDim.getRegionStyle() - .create(rng.nextParallelRNG(883), upperData).stream() - .zoom(upperDim.getRegionZoom()); - ProceduralStream proceduralRegionStream = regionStyleStream - .selectRarity(upperData.getRegionLoader().loadAll(upperDim.getRegions())); - ProceduralStream regionStream = proceduralRegionStream - .convertAware2D((region, x, z) -> mappedRegion(imageMapRuntime, region, x, z)) - .cache2D("upperImageMappedRegionStream", engine, cacheSize); - - ProceduralStream landBiomeStream = regionStream - .convert(r -> upperDim.getLandBiomeStyle() - .create(rng.nextParallelRNG(InferredType.LAND.ordinal()), upperData).stream() - .zoom(upperDim.getBiomeZoom()) - .zoom(upperDim.getLandZoom()) - .zoom(r.getLandBiomeZoom()) - .selectRarity(loadInferredBiomes(upperData, r.getLandBiomes(), InferredType.LAND))) - .convertAware2D(ProceduralStream::get); - ProceduralStream seaBiomeStream = regionStream - .convert(r -> upperDim.getSeaBiomeStyle() - .create(rng.nextParallelRNG(InferredType.SEA.ordinal()), upperData).stream() - .zoom(upperDim.getBiomeZoom()) - .zoom(upperDim.getSeaZoom()) - .zoom(r.getSeaBiomeZoom()) - .selectRarity(loadInferredBiomes(upperData, r.getSeaBiomes(), InferredType.SEA))) - .convertAware2D(ProceduralStream::get); - ProceduralStream shoreBiomeStream = regionStream - .convert(r -> upperDim.getShoreBiomeStyle() - .create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), upperData).stream() - .zoom(upperDim.getBiomeZoom()) - .zoom(r.getShoreBiomeZoom()) - .selectRarity(loadInferredBiomes(upperData, r.getShoreBiomes(), InferredType.SHORE))) - .convertAware2D(ProceduralStream::get); - - Map> inferredStreams = new HashMap<>(); - inferredStreams.put(InferredType.LAND, landBiomeStream); - inferredStreams.put(InferredType.SEA, seaBiomeStream); - inferredStreams.put(InferredType.SHORE, shoreBiomeStream); - - ProceduralStream bridgeStream = upperDim.getContinentalStyle() - .create(rng.nextParallelRNG(234234565), upperData) - .bake().scale(1D / upperDim.getContinentZoom()).bake().stream() - .convert(v -> v >= upperDim.getLandChance() ? InferredType.SEA : InferredType.LAND); - - ProceduralStream proceduralBaseBiomeStream = bridgeStream - .convertAware2D((t, x, z) -> { - ProceduralStream stream = inferredStreams.get(t); - return stream != null ? stream.get(x, z) : inferredStreams.get(InferredType.LAND).get(x, z); - }) - .convertAware2D((biome, x, z) -> implode( - biome, x, z, rng, dataProvider, childSelectionPlans, 3)); - ProceduralStream baseBiomeStream = proceduralBaseBiomeStream - .convertAware2D((biome, x, z) -> mappedBiome(imageMapRuntime, biome, x, z)) - .cache2D("upperImageMappedBaseBiomeStream", engine, cacheSize); - - KList overlayNoise = upperDim.getOverlayNoise(); - ProceduralStream overlayStream = overlayNoise.isEmpty() - ? ProceduralStream.ofDouble((x, z) -> 0.0D) - : ProceduralStream.ofDouble((x, z) -> { - double value = 0D; - for (IrisShapedGeneratorStyle style : overlayNoise) { - value += style.get(rng, upperData, x, z); - } - return value; - }); - - long heightSeed = engine.getSeedManager().getHeight() ^ seedOffset; - - ProceduralStream heightStream = ProceduralStream.of((x, z) -> { - IrisBiome b = baseBiomeStream.get(x, z); - if (b == null) { - return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z); - } - double interpolatedHeight = 0; - for (IrisComplex.GeneratorGroup group : generatorGroups) { - IrisInterpolator interpolator = group.interpolator(); - IrisGenerator[] groupGenerators = group.generators(); - if (groupGenerators.length == 0) { - continue; - } - IdentityHashMap cachedBounds = generatorBounds.get(interpolator); - NoiseBounds sampledBounds = interpolator.interpolateBounds(x, z, (xx, zz) -> { - try { - IrisBiome bx = baseBiomeStream.get(xx, zz); - if (bx == null) { - return ZERO_NOISE_BOUNDS; - } - NoiseBounds bounds = cachedBounds != null ? cachedBounds.get(bx) : null; - if (bounds != null) { - return bounds; - } - double bMin = 0D; - double bMax = 0D; - for (IrisGenerator gen : groupGenerators) { - String key = gen.getLoadKey(); - if (key == null || key.isBlank()) { - continue; - } - bMax += bx.getGenLinkMax(key, engine); - bMin += bx.getGenLinkMin(key, engine); - } - return new NoiseBounds(bMin, bMax); - } catch (Throwable e) { - IrisLogging.reportError(e); - return ZERO_NOISE_BOUNDS; - } - }); - double hi = sampledBounds.max(); - double lo = sampledBounds.min(); - interpolatedHeight += IrisComplex.averageGeneratorHeights( - groupGenerators, - lo, - hi, - x, - z, - heightSeed + 239945 - ); - } - double proceduralHeight = Math.max( - Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), - 0D - ); - return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z); - }, Interpolated.DOUBLE).cache2DDouble("upperImageMappedHeightStream", engine, cacheSize); - - ProceduralStream finalBiomeStream = heightStream.convertAware2D((height, x, z) -> { - IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z); - if (mappedBiome != null) { - return mappedBiome; - } - IrisBiome baseBiome = baseBiomeStream.get(x, z); - IrisBiome resolved = IrisComplex.resolveSurfaceBiome( - height, - baseBiome, - regionStream.get(x, z), - x, - z, - fluidHeight, - landBiomeStream, - seaBiomeStream, - shoreBiomeStream); - return resolved == baseBiome - ? baseBiome - : implode(resolved, x, z, rng, dataProvider, childSelectionPlans, 3); - }).cache2D("upperImageMappedFinalBiomeStream", engine, cacheSize); - - ProceduralStream rockStream = upperDim.getRockPalette() - .getLayerGenerator(rng.nextParallelRNG(45), upperData).stream() - .select(upperDim.getRockPalette().getBlockData(upperData)); - - return new UpperDimensionContext(new ContextState( - upperDim, - upperData, - chunkHeight, - heightStream, - finalBiomeStream, - regionStream, - rockStream, - imageMapRuntime, - false - )); - } - - private static void registerBiomeGenerators( - IrisBiome biome, - DataProvider dataProvider, - Set allBiomes, - Map> generators - ) { - allBiomes.add(biome); - biome.getGenerators().forEach(link -> { - IrisGenerator generator = link.getCachedGenerator(dataProvider); - if (generator != null) { - generators.computeIfAbsent(generator.getInterpolator(), key -> new HashSet<>()).add(generator); - } - }); } static IrisRegion mappedRegion( @@ -314,8 +49,8 @@ public class UpperDimensionContext implements DataProvider { double worldX, double worldZ ) { - IrisRegion mappedRegion = imageMapRuntime.sampleRegion(worldX, worldZ); - return mappedRegion == null ? proceduralRegion : mappedRegion; + return DimensionTerrainContext.mappedRegion( + imageMapRuntime, proceduralRegion, worldX, worldZ); } static double mappedTerrainHeight( @@ -324,7 +59,8 @@ public class UpperDimensionContext implements DataProvider { double worldX, double worldZ ) { - return imageMapRuntime.sampleTerrainHeight(worldX, worldZ, proceduralHeight); + return DimensionTerrainContext.mappedTerrainHeight( + imageMapRuntime, proceduralHeight, worldX, worldZ); } static IrisBiome mappedBiome( @@ -333,8 +69,8 @@ public class UpperDimensionContext implements DataProvider { double worldX, double worldZ ) { - IrisBiome mappedBiome = imageMapRuntime.sampleBiome(worldX, worldZ); - return mappedBiome == null ? proceduralBiome : mappedBiome; + return DimensionTerrainContext.mappedBiome( + imageMapRuntime, proceduralBiome, worldX, worldZ); } static PlatformBlockState mappedSurfaceBlock( @@ -343,108 +79,40 @@ public class UpperDimensionContext implements DataProvider { double worldX, double worldZ ) { - PlatformBlockState mappedBlock = imageMapRuntime.sampleSurfaceBlock(worldX, worldZ); - return mappedBlock == null ? proceduralBlock : mappedBlock; - } - - private static KList loadInferredBiomes(IrisData data, KList keys, InferredType type) { - KList inferred = new KList<>(); - for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { - inferred.add(biome.withInferredType(type)); - } - return inferred; - } - - private static IrisBiome implode( - IrisBiome biome, - double x, - double z, - RNG rng, - DataProvider dataProvider, - Map childSelectionPlans, - int remainingDepth - ) { - if (biome == null || remainingDepth < 0 || biome.getChildren().isEmpty()) { - return biome; - } - - IrisComplex.ChildSelectionPlan selectionPlan = childSelectionPlans.get(biome); - if (selectionPlan == null) { - synchronized (childSelectionPlans) { - selectionPlan = childSelectionPlans.get(biome); - if (selectionPlan == null) { - KList options = new KList<>(); - for (IrisBiome child : biome.getRealChildren(dataProvider)) { - if (child != null) { - options.add(child); - } - } - options.add(biome); - selectionPlan = IrisComplex.ChildSelectionPlan.create(options); - childSelectionPlans.put(biome, selectionPlan); - } - } - } - - IrisBiome selected = selectionPlan.select( - biome.getChildrenGenerator(rng, 123, biome.getChildShrinkFactor()), x, z); - if (selected == null) { - return biome; - } - return implode( - selected.withInferredType(biome.getInferredType()), - x, - z, - rng, - dataProvider, - childSelectionPlans, - remainingDepth - 1); + return DimensionTerrainContext.mappedSurfaceBlock( + imageMapRuntime, proceduralBlock, worldX, worldZ); } public int getUpperSurfaceY(int x, int z) { - double rawHeight = heightStream.get((double) x, (double) z); - return chunkHeight - 1 - (int) Math.round(rawHeight); + return chunkHeight - 1 - (int) Math.round(terrainContext.getNormalTerrainHeight(x, z)); } public IrisBiome getUpperBiome(int x, int z) { - return biomeStream.get((double) x, (double) z); + return terrainContext.getBiome(x, z); } public IrisRegion getUpperRegion(int x, int z) { - return regionStream == null ? null : regionStream.get((double) x, (double) z); + return terrainContext.getRegion(x, z); } public PlatformBlockState getRockBlock(int x, int z) { - return rockStream.get((double) x, (double) z); + return terrainContext.getRockBlock(x, z); } public PlatformBlockState getSurfaceBlock(int x, int z) { - return imageMapRuntime.sampleSurfaceBlock(x, z); + return terrainContext.getSurfaceBlock(x, z); } public IrisDimension getDimension() { - return dimension; + return terrainContext.getDimension(); } @Override public IrisData getData() { - return data; + return terrainContext.getData(); } public boolean isSelfReferencing() { - return selfReferencing; - } - - private record ContextState( - IrisDimension dimension, - IrisData data, - int chunkHeight, - ProceduralStream heightStream, - ProceduralStream biomeStream, - ProceduralStream regionStream, - ProceduralStream rockStream, - IrisImageMapRuntime imageMapRuntime, - boolean selfReferencing - ) { + return terrainContext.isSelfReferencing(); } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java index 0bc50bcec..55fd7cf49 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java @@ -18,10 +18,13 @@ package art.arcane.iris.engine.actuator; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.ChunkedDataCache; import art.arcane.volmlib.util.collection.KMap; @@ -38,6 +41,8 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; +import java.util.List; + public class IrisBiomeActuator extends EngineAssignedActuator { private final RNG rng; private final ChronoLatch cl = new ChronoLatch(5000); @@ -60,33 +65,167 @@ public class IrisBiomeActuator extends EngineAssignedActuator { Engine engine = getEngine(); Mantle mantle = engine.getMantle().getMantle(); ChunkedDataCache biomeCache = context.getBiome(); + DimensionStackContext dimensionStackContext = engine.getDimensionStackContext(); for (int xf = 0; xf < width; xf++) { - IrisBiome ib; for (int zf = 0; zf < depth; zf++) { - ib = biomeCache.get(xf, zf); - String key; + int worldX = x + xf; + int worldZ = z + zf; + IrisBiome biome = biomeCache.get(xf, zf); + ResolvedBiome resolved = resolve(biomeKey( + biome, + getDimension(), + engine, + worldX, + 0, + worldZ + )); + PlatformBiome platformBiome = resolved.biome(); - if (ib.isCustom()) { - IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf); - key = getDimension().getLoadKey() + ":" + custom.getId(); - } else { - key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf); + if (platformBiome != null) { + h.set(xf, 0, zf, xf, height - 1, zf, platformBiome); } - ResolvedBiome resolved = resolve(key); - PlatformBiome biome = resolved.biome(); - - if (biome != null) { - h.set(xf, 0, zf, xf, height - 1, zf, biome); + mantle.set(worldX, 0, worldZ, resolved.matter()); + if (dimensionStackContext != null) { + PlatformBiome bottomBiome = platformBiome == null + ? h.getRaw(xf, 0, zf) + : platformBiome; + applyDimensionStackBiomes( + xf, + zf, + worldX, + worldZ, + h, + mantle, + engine, + new ResolvedBiome(bottomBiome, resolved.matter()), + context.getDimensionStackLayout(xf, zf) + ); } - - mantle.set(x + xf, 0, z + zf, resolved.matter()); } } engine.getMetrics().getBiome().put(p.getMilliseconds()); } + private void applyDimensionStackBiomes( + int localX, + int localZ, + int worldX, + int worldZ, + Hunk output, + Mantle mantle, + Engine engine, + ResolvedBiome bottom, + DimensionStackLayout layout + ) { + List layers = layout.layersBottomToTop(); + for (int layerIndex = 1; layerIndex < layers.size(); layerIndex++) { + DimensionStackLayout.Layer lower = layers.get(layerIndex - 1); + DimensionStackLayout.Layer layer = layers.get(layerIndex); + int gapMinY = (int) Math.max(0L, (long) lower.contentTopY() + 1L); + int gapMaxY = (int) Math.min( + (long) output.getHeight() - 1L, + (long) layer.localBaseY() - 1L + ); + applyBiomeRange( + localX, + localZ, + worldX, + worldZ, + gapMinY, + gapMaxY, + output, + mantle, + bottom + ); + if (!layer.visible()) { + continue; + } + ResolvedBiome resolved = bottom; + if (layer.biome() != null) { + IrisDimension dimension = layer.terrainContext().getDimension(); + resolved = resolve(biomeKey( + layer.biome(), + dimension, + engine, + worldX, + layer.clippedSurfaceY(), + worldZ + )); + } + applyBiomeRange( + localX, + localZ, + worldX, + worldZ, + layer.renderMinY(), + layer.renderMaxY(), + output, + mantle, + resolved + ); + } + } + + private void applyBiomeRange( + int localX, + int localZ, + int worldX, + int worldZ, + int minimumY, + int maximumY, + Hunk output, + Mantle mantle, + ResolvedBiome resolved + ) { + if (minimumY > maximumY) { + return; + } + clearBiomeMatterRange(mantle, worldX, worldZ, minimumY, maximumY); + if (resolved.biome() != null) { + output.set( + localX, + minimumY, + localZ, + localX, + maximumY, + localZ, + resolved.biome() + ); + } + if (resolved.matter() != null) { + mantle.set(worldX, minimumY, worldZ, resolved.matter()); + } + } + + static void clearBiomeMatterRange( + Mantle mantle, + int worldX, + int worldZ, + int minimumY, + int maximumY + ) { + for (int y = minimumY; y <= maximumY; y++) { + mantle.remove(worldX, y, worldZ, MatterBiomeInject.class); + } + } + + private String biomeKey( + IrisBiome biome, + IrisDimension dimension, + Engine engine, + int x, + int y, + int z + ) { + if (biome.isCustom()) { + IrisBiomeCustom custom = biome.getCustomBiome(rng, engine, x, y, z); + return dimension.getCustomBiomeKey(custom.getId()); + } + return biome.getSkyBiomeKey(rng, engine, x, y, z); + } + /** * The registry lookup and the biome id only depend on the scatter resolved key, so cache the pair * instead of paying two string keyed lookups per column. Unresolved biomes are never cached, so a diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java new file mode 100644 index 000000000..1f46714b1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuator.java @@ -0,0 +1,258 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.DimensionTerrainContext; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedActuator; +import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.documentation.BlockCoordinates; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterMarker; +import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.matter.MatterStructurePOI; +import art.arcane.volmlib.util.matter.MatterUpdate; + +import java.util.ArrayList; +import java.util.List; + +public final class IrisDimensionStackActuator extends EngineAssignedActuator { + private static final PlatformBlockState AIR = B.getState("AIR"); + private static final PlatformBlockState BEDROCK = B.getState("BEDROCK"); + private static final List> REPLACED_METADATA = List.of( + TileWrapper.class, + Identifier.class, + String.class, + TreeBlockMaterial.class, + MatterCavern.class, + HydrologyCaveCell.class, + MatterMarker.class, + MatterStructurePOI.class, + MatterUpdate.class + ); + + private final RNG rng; + + public IrisDimensionStackActuator(Engine engine) { + super(engine, "Dimension Stack"); + rng = new RNG(engine.getSeedManager().getTerrain()); + } + + @BlockCoordinates + @Override + public void onActuate( + int x, + int z, + Hunk blocks, + boolean multicore, + ChunkContext context + ) { + if (getEngine().getDimensionStackContext() == null) { + return; + } + MantleChunk mantleChunk = getEngine().getMantle().getMantle() + .getChunk(x >> 4, z >> 4).use(); + try { + MetadataCleaner metadata = new MetadataCleaner(mantleChunk, blocks.getHeight()); + for (int localX = 0; localX < blocks.getWidth(); localX++) { + int worldX = x + localX; + for (int localZ = 0; localZ < blocks.getDepth(); localZ++) { + renderLayers( + localX, + localZ, + worldX, + z + localZ, + blocks, + context.getDimensionStackLayout(localX, localZ), + metadata + ); + } + } + } finally { + mantleChunk.release(); + } + } + + private void renderLayers( + int localX, + int localZ, + int worldX, + int worldZ, + Hunk blocks, + DimensionStackLayout layout, + MetadataCleaner metadata + ) { + if (layout == null) { + return; + } + List layers = layout.layersBottomToTop(); + for (int layerIndex = 1; layerIndex < layers.size(); layerIndex++) { + DimensionStackLayout.Layer layer = layers.get(layerIndex); + clearGap(localX, localZ, blocks, layers.get(layerIndex - 1), layer, metadata); + if (layer.visible()) { + renderLayer(localX, localZ, worldX, worldZ, blocks, layer, metadata); + } + } + } + + private void clearGap( + int localX, + int localZ, + Hunk output, + DimensionStackLayout.Layer lower, + DimensionStackLayout.Layer upper, + MetadataCleaner metadata + ) { + int minimumY = (int) Math.max(0L, (long) lower.contentTopY() + 1L); + int maximumY = (int) Math.min( + (long) output.getHeight() - 1L, + (long) upper.localBaseY() - 1L + ); + for (int y = minimumY; y <= maximumY; y++) { + metadata.clear(localX, y, localZ); + output.setRaw(localX, y, localZ, AIR); + } + } + + private void renderLayer( + int localX, + int localZ, + int worldX, + int worldZ, + Hunk output, + DimensionStackLayout.Layer layer, + MetadataCleaner metadata + ) { + DimensionTerrainContext terrainContext = layer.terrainContext(); + IrisDimension dimension = terrainContext.getDimension(); + IrisData data = terrainContext.getData(); + IrisBiome biome = layer.biome(); + int surfaceDepth = Math.max(0, layer.normalTerrainHeight()); + int fluidDepth = Math.max(0, layer.fluidHeight() - layer.normalTerrainHeight()); + KList surfaceBlocks = biome == null + ? null + : biome.generateLayersWithSlope( + dimension, + worldX, + worldZ, + rng, + surfaceDepth, + layer.normalTerrainHeight(), + data, + terrainContext.getSlopeStream() + ); + KList seaBlocks = biome == null || fluidDepth == 0 + ? null + : biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data); + + for (int y = layer.renderMaxY(); y >= layer.renderMinY(); y--) { + int sourceY = y - layer.localBaseY(); + if (sourceY == 0 && dimension.isBedrock()) { + writeBlock(output, metadata, localX, y, localZ, BEDROCK); + continue; + } + if (sourceY > layer.normalTerrainHeight() && sourceY <= layer.fluidHeight()) { + int fluidLayerDepth = layer.fluidY() - y; + writeBlock(output, metadata, localX, y, localZ, HydrologyFluidLayerSelector.select( + seaBlocks, + fluidLayerDepth, + layer.fluidBlock(), + false + )); + continue; + } + if (sourceY > layer.normalTerrainHeight()) { + continue; + } + int depth = layer.surfaceY() - y; + if (depth == 0 && layer.surfaceBlock() != null) { + writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock()); + } else if (surfaceBlocks != null && surfaceBlocks.hasIndex(depth)) { + writeBlock(output, metadata, localX, y, localZ, surfaceBlocks.get(depth)); + } else { + writeBlock(output, metadata, localX, y, localZ, layer.rockBlock()); + } + } + } + + private void writeBlock( + Hunk output, + MetadataCleaner metadata, + int x, + int y, + int z, + PlatformBlockState block + ) { + metadata.clear(x, y, z); + output.setRaw(x, y, z, block); + } + + static final class MetadataCleaner { + private final MantleChunk chunk; + private final List>[] slicesBySection; + + @SuppressWarnings("unchecked") + MetadataCleaner(MantleChunk chunk, int height) { + this.chunk = chunk; + slicesBySection = (List>[]) new List[(height + 15) >> 4]; + } + + void clear(int x, int y, int z) { + int sectionIndex = y >> 4; + List> slices = slicesBySection[sectionIndex]; + if (slices == null) { + slices = resolveSlices(sectionIndex); + slicesBySection[sectionIndex] = slices; + } + for (MatterSlice slice : slices) { + slice.set(x, y & 15, z, null); + } + } + + private List> resolveSlices(int sectionIndex) { + Matter section = chunk.get(sectionIndex); + if (section == null) { + return List.of(); + } + ArrayList> slices = new ArrayList<>(REPLACED_METADATA.size()); + for (Class type : REPLACED_METADATA) { + MatterSlice slice = section.getSlice(type); + if (slice != null) { + slices.add(slice); + } + } + return List.copyOf(slices); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 3c25925c5..40c865ec6 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -28,6 +28,9 @@ import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisBiome; @@ -90,6 +93,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return null; } + default @Nullable DimensionStackContext getDimensionStackContext() { + return null; + } + EngineMode getMode(); EnginePlatformHooks getPlatformHooks(); @@ -255,6 +262,25 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisRegion getRegion(int x, int z) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + DimensionStackLayout.Layer layer = dimensionStackContext.getLayout(x, z).surfaceLayer(); + if (layer != null && layer.region() != null) { + return layer.region(); + } + } + return getComplex().getRegionStream().get(x, z); + } + + @BlockCoordinates + default IrisRegion getRegion(int x, int y, int z) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + DimensionStackLayout.Layer layer = dimensionStackContext.getLayout(x, z).layerAt(y); + if (layer != null && layer.region() != null) { + return layer.region(); + } + } return getComplex().getRegionStream().get(x, z); } @@ -301,7 +327,45 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getCaveBiome(int x, int y, int z, IrisDimensionCarvingResolver.State state) { - IrisBiome surfaceBiome = getSurfaceBiome(x, z); + IrisBiome configuredBiome = resolveConfiguredCaveBiome(x, y, z, state); + if (configuredBiome != null) { + return configuredBiome; + } + boolean naturalFallback = answersFromNaturalTerrain(x, z); + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + IrisBiome surfaceBiome = naturalFallback || dimensionStackContext == null + ? naturalFallback + ? getComplex().naturalSurfaceBiome(x, z) + : getSurfaceBiome(x, z) + : getComplex().getTrueBiomeStream().get(x, z); + int surfaceY = naturalFallback + ? getComplex().naturalTrueHeight(x, z) + : getComplex().getHeightStream().get(x, z).intValue(); + return resolveDepthCaveBiome(x, y, z, surfaceBiome, surfaceY); + } + + @BlockCoordinates + default IrisBiome getCaveBiome( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State state, + IrisBiome surfaceBiome, + int surfaceY + ) { + IrisBiome configuredBiome = resolveConfiguredCaveBiome(x, y, z, state); + if (configuredBiome != null) { + return configuredBiome; + } + return resolveDepthCaveBiome(x, y, z, surfaceBiome, surfaceY); + } + + private IrisBiome resolveConfiguredCaveBiome( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State state + ) { int worldY = y + getWorld().minHeight(); IrisDimensionCarvingEntry rootCarvingEntry = IrisDimensionCarvingResolver.resolveRootEntry(this, worldY, state); if (rootCarvingEntry != null) { @@ -311,13 +375,21 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return resolvedCarvingBiome; } } + return null; + } + private IrisBiome resolveDepthCaveBiome( + int x, + int y, + int z, + IrisBiome surfaceBiome, + int surfaceY + ) { IrisBiome caveBiome = getCaveBiome(x, z); if (caveBiome == null) { return surfaceBiome; } - int surfaceY = getComplex().getHeightStream().get(x, z).intValue(); int depthBelowSurface = surfaceY - y; if (depthBelowSurface <= 0) { return surfaceBiome; @@ -333,6 +405,18 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getSurfaceBiome(int x, int z) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + DimensionStackLayout.Layer layer = dimensionStackContext.getLayout(x, z).surfaceLayer(); + if (layer != null && layer.biome() != null) { + return layer.biome(); + } + } + return getHostSurfaceBiome(x, z); + } + + @BlockCoordinates + default IrisBiome getHostSurfaceBiome(int x, int z) { if (answersFromNaturalTerrain(x, z)) { return getComplex().naturalSurfaceBiome(x, z); } @@ -364,6 +448,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default int getHeight(int x, int z, boolean ignoreFluid) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + return ignoreFluid + ? dimensionStackContext.getStackTerrainHeight(x, z) + : dimensionStackContext.getStackTopHeight(x, z); + } if (answersFromNaturalTerrain(x, z)) { int natural = getComplex().naturalTrueHeight(x, z); return ignoreFluid ? natural : Math.max(natural, getMantle().getFluidHeight()); @@ -371,6 +461,24 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return getMantle().getHighest(x, z, getData(), ignoreFluid); } + @BlockCoordinates + static int hostHeight(Engine engine, int x, int z, boolean ignoreFluid) { + EngineMantle mantle = engine.getMantle(); + if (mantle == null) { + return engine.getHeight(x, z, ignoreFluid); + } + if (engine.answersFromNaturalTerrain(x, z)) { + int naturalHeight = engine.getComplex().naturalTrueHeight(x, z); + return ignoreFluid + ? naturalHeight + : Math.max(naturalHeight, mantle.getFluidHeight()); + } + int terrainHeight = mantle.trueHeight(x, z); + return ignoreFluid + ? terrainHeight + : Math.max(terrainHeight, mantle.getFluidHeight(x, z)); + } + @BlockCoordinates @Override default void catchBlockUpdates(int x, int y, int z, PlatformBlockState data) { @@ -410,6 +518,15 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisDimension dim = getDimension(); dim.getReachableBiomes(this).forEach((i) -> v.put(i.getLoadKey(), i)); + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + for (DimensionTerrainContext terrainContext : dimensionStackContext.getLayersBottomToTop()) { + if (!terrainContext.isSelfReferencing()) { + terrainContext.getDimension().getReachableBiomes(terrainContext) + .forEach((i) -> v.put(i.getLoadKey(), i)); + } + } + } return v.v(); } @@ -454,6 +571,22 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, boolean isStudio(); default IrisBiome getBiome(int x, int y, int z) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + DimensionStackLayout layout = dimensionStackContext.getLayout(x, z); + IrisBiome stackedBiome = dimensionStackBiome(layout, y); + if (stackedBiome != null) { + return stackedBiome; + } + DimensionStackLayout.Layer bottomLayer = layout.layersBottomToTop().get(0); + if (y > bottomLayer.renderMaxY()) { + return bottomLayer.biome(); + } + if (y <= bottomLayer.clippedSurfaceY() - 2) { + return getCaveBiome(x, y, z); + } + return bottomLayer.biome(); + } if (y <= getHeight(x, z) - 2) { return getCaveBiome(x, y, z); } @@ -462,6 +595,22 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, } default IrisBiome getBiomeOrMantle(int x, int y, int z) { + DimensionStackContext dimensionStackContext = getDimensionStackContext(); + if (dimensionStackContext != null) { + DimensionStackLayout layout = dimensionStackContext.getLayout(x, z); + IrisBiome stackedBiome = dimensionStackBiome(layout, y); + if (stackedBiome != null) { + return stackedBiome; + } + DimensionStackLayout.Layer bottomLayer = layout.layersBottomToTop().get(0); + if (y > bottomLayer.renderMaxY()) { + return bottomLayer.biome(); + } + if (y <= bottomLayer.clippedSurfaceY() - 2) { + return getCaveOrMantleBiome(x, y, z); + } + return bottomLayer.biome(); + } if (y <= getHeight(x, z) - 2) { return getCaveOrMantleBiome(x, y, z); } @@ -469,6 +618,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return getSurfaceBiome(x, z); } + private static IrisBiome dimensionStackBiome(DimensionStackLayout layout, int y) { + DimensionStackLayout.Layer bottom = layout.layersBottomToTop().get(0); + DimensionStackLayout.Layer owner = layout.layerAt(y); + return owner == bottom ? null : owner.biome(); + } + default String getObjectPlacementKey(int x, int y, int z) { PlacedObject o = getObjectPlacement(x, y, z); @@ -524,7 +679,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, } - IrisRegion region = getRegion(x, z); + IrisRegion region = getComplex().getRegionStream().get(x, z); for (IrisObjectPlacement i : region.getObjects()) { if (i.compatPlace(getData()).contains(object)) { @@ -532,7 +687,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, } } - IrisBiome biome = getSurfaceBiome(x, z); + IrisBiome biome = getComplex().getTrueBiomeStream().get(x, z); for (IrisObjectPlacement i : biome.getObjects()) { if (i.compatPlace(getData()).contains(object)) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index d3314a512..54865692d 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -151,7 +151,16 @@ public interface EngineMode extends Staged { default void generate(int x, int z, Hunk blocks, Hunk biomes, boolean multicore, long generationSessionId) { boolean cacheContext = !getEngine().getPlatformHooks().shouldDisableChunkContextCache(getEngine()); ChunkContext.PrefillPlan prefillPlan = cacheContext ? ChunkContext.PrefillPlan.NO_CAVE : ChunkContext.PrefillPlan.NONE; - ChunkContext ctx = new ChunkContext(x, z, getComplex(), generationSessionId, cacheContext, prefillPlan, getEngine().getMetrics()); + ChunkContext ctx = new ChunkContext( + x, + z, + getComplex(), + generationSessionId, + cacheContext, + prefillPlan, + getEngine().getMetrics(), + getEngine().getDimensionStackContext() + ); EngineStage[] stages = getStages().toArray(new EngineStage[0]); try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), generationSessionId, ctx)) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java index 4c6028526..6b6c613d9 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java @@ -46,6 +46,9 @@ public interface EnginePlatformHooks { default void validateDimensionHotload(Engine engine, IrisDimension replacement) { } + default void prepareRuntimeHotload(Engine engine) { + } + default void applyWorldBoundary(Engine engine) { } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index 00bec61ba..38878852e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -19,6 +19,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisBiome; @@ -282,6 +284,13 @@ public final class HintedLocator implements Locator { if (engine.getFocus() != null || engine.getFocusRegion() != null) { return SearchPlan.unpruned(); } + if (engine.getDimensionStackContext() != null) { + for (IrisBiome biome : engine.getAllBiomes()) { + if (biome != null && biomeKey.equals(biome.getLoadKey())) { + return SearchPlan.unpruned(); + } + } + } IrisComplex complex = engine.getComplex(); BiomeSource source = new BiomeSource(engine); @@ -380,6 +389,17 @@ public final class HintedLocator implements Locator { return SearchPlan.unpruned(); } + DimensionStackContext stackContext = engine.getDimensionStackContext(); + if (stackContext != null) { + for (DimensionTerrainContext terrainContext : stackContext.getLayersTopToBottom()) { + for (IrisRegion region : terrainContext.getDimension().getAllRegions(terrainContext)) { + if (region != null && regionKey.equals(region.getLoadKey())) { + return SearchPlan.unpruned(); + } + } + } + } + if (!engine.getDimension().getRegions().contains(regionKey)) { return SearchPlan.impossible(); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 5d99e823b..f515ae773 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -655,7 +655,7 @@ public final class IrisStructureLocator { return null; } if (placement.resolvedAnchor() == IrisStructureAnchorMode.SURFACE) { - int surfaceY = engine.getHeight(originX, originZ, true) + engine.getMinHeight(); + int surfaceY = Engine.hostHeight(engine, originX, originZ, true) + engine.getMinHeight(); return surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight() ? null : surfaceY; } int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); @@ -766,7 +766,7 @@ public final class IrisStructureLocator { if (surfaceHeights.size() >= MAX_BURIAL_COLUMNS) { return Integer.MIN_VALUE; } - surfaceY = engine.getHeight(x, z, true) + engine.getMinHeight(); + surfaceY = Engine.hostHeight(engine, x, z, true) + engine.getMinHeight(); surfaceHeights.put(columnKey, surfaceY); } int allowedTopY = surfaceY - UNDERGROUND_SURFACE_CLEARANCE; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index ed44a5490..517983283 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -142,7 +142,7 @@ public final class NativeStructurePlacementPlanner { return null; } if (!placement.isUnderground()) { - int surfaceY = engine.getHeight(blockX, blockZ, true) + engine.getMinHeight(); + int surfaceY = Engine.hostHeight(engine, blockX, blockZ, true) + engine.getMinHeight(); if (surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight()) { return null; } @@ -160,7 +160,7 @@ public final class NativeStructurePlacementPlanner { int localFluidHeight = engine.getComplex() == null ? engine.getDimension().getFluidHeight() : (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)); - return engine.getHeight(blockX, blockZ, true) < localFluidHeight; + return Engine.hostHeight(engine, blockX, blockZ, true) < localFluidHeight; } private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 091b7c940..908813cdf 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -21,6 +21,8 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; @@ -36,6 +38,7 @@ import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.flag.MantleFlag; @@ -196,17 +199,37 @@ public interface EngineMantle extends MatterGenerator { } @ChunkCoordinates - default void insertMatter(int x, int z, Hunk blocks, boolean multicore) { + default void insertMatter( + int x, + int z, + Hunk blocks, + boolean multicore, + ChunkContext context + ) { if (!getEngine().getDimension().isUseMantle()) { return; } UpperDimensionContext upperCtx = getEngine().getUpperContext(); boolean protectUpper = upperCtx != null; + DimensionStackContext stackContext = getEngine().getDimensionStackContext(); MantleChunk chunk = getMantle().getChunk(x, z).use(); try { - if (protectUpper) { + if (stackContext != null) { + DimensionStackLayout[] layouts = new DimensionStackLayout[256]; + for (int i = 0; i < layouts.length; i++) { + int localX = i >> 4; + int localZ = i & 15; + layouts[i] = context.getDimensionStackLayout(localX, localZ); + } + chunk.iterate(PlatformBlockState.class, (localX, y, localZ, value) -> { + DimensionStackLayout layout = layouts[(localX << 4) | (localZ & 15)]; + if (!layout.isHostFeatureProtectedY(y)) { + blocks.set(localX, y, localZ, value); + } + }); + } else if (protectUpper) { int chunkBlockX = x << 4; int chunkBlockZ = z << 4; int gap = getEngine().getDimension().getUpperDimensionGap(); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java index 6b0be485b..b3781a8c8 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java @@ -227,7 +227,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { if (cached != CACHE_MISS) { return cached; } - int surfaceY = engine.getHeight(x, z, true); + int surfaceY = Engine.hostHeight(engine, x, z, true); surfaceHeights.put(key, surfaceY); return surfaceY; } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 79c73eb3c..6f0f18733 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -286,7 +286,7 @@ public class IrisCaveCarver3D { if (precomputedSurfaceHeights != null && precomputedSurfaceHeights.length > index) { columnSurfaceY = precomputedSurfaceHeights[index]; } else { - columnSurfaceY = engine.getHeight(x, z); + columnSurfaceY = Engine.hostHeight(engine, x, z, true); } int unclampedClearanceTopY = columnSurfaceY - surfaceClearance; int clearanceTopY = Math.min(maxY, Math.max(minY, unclampedClearanceTopY)); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index a49bd7d8a..55a8a74c4 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -189,7 +189,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int zzz = 8 + (z << 4); IrisRegion region = complex.getRegionStream().get(xxx, zzz); IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(xxx, zzz); - int surfaceY = getEngineMantle().getEngine().getHeight(xxx, zzz, true); + int surfaceY = complex.getRoundedHeighteightStream().get(xxx, zzz); IrisBiome caveBiome = resolveCaveObjectBiome(xxx, zzz, surfaceY, surfaceBiome); if (IrisSettings.get().getGeneral().isDebug() && (x & 31) == 0 && (z & 31) == 0) { int carvedBlocks = 0; @@ -948,7 +948,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } Engine engine = getEngineMantle().getEngine(); IrisBiome at = engine.getCaveBiome(x, y, z); - IrisBiome surface = engine.getSurfaceBiome(x, z); + IrisBiome surface = engine.getComplex().getTrueBiomeStream().get(x, z); return caveAnchorBiomeConflicts(at, surface, expectedCaveBiomeKey); } @@ -1631,8 +1631,10 @@ public class MantleObjectComponent extends IrisMantleComponent { public Set guess(int x, int z) { // todo The guess doesnt bring into account that the placer may return -1 RNG rng = applyNoise(x, z, Cache.key(x, z) + seed()); - IrisBiome biome = getEngineMantle().getEngine().getSurfaceBiome((x << 4) + 8, (z << 4) + 8); - IrisRegion region = getEngineMantle().getEngine().getRegion((x << 4) + 8, (z << 4) + 8); + int blockX = (x << 4) + 8; + int blockZ = (z << 4) + 8; + IrisBiome biome = getComplex().getTrueBiomeStream().get(blockX, blockZ); + IrisRegion region = getComplex().getRegionStream().get(blockX, blockZ); Set v = new KSet<>(); for (IrisObjectPlacement i : biome.getSurfaceObjects()) { if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) { @@ -1879,7 +1881,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return surfaceY; } - surfaceY = getEngineMantle().getEngine().getHeight(x, z); + surfaceY = getEngineMantle().trueHeight(x, z); surfaceHeights.put(columnKey, surfaceY); return surfaceY; } diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeEnclosure.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeEnclosure.java index 09d2f9f15..a6f12a5f1 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeEnclosure.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeEnclosure.java @@ -27,8 +27,8 @@ import art.arcane.iris.engine.framework.IrisEngineMode; public class ModeEnclosure extends IrisEngineMode implements EngineMode { public ModeEnclosure(Engine engine) { super(engine); - var terrain = new IrisTerrainNormalActuator(getEngine()); - var biome = new IrisBiomeActuator(getEngine()); + IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine()); + IrisBiomeActuator biome = new IrisBiomeActuator(getEngine()); registerStage(burst( (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c), diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeIslands.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeIslands.java index 494b46bc8..6485f9b96 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeIslands.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeIslands.java @@ -27,8 +27,8 @@ import art.arcane.iris.engine.framework.IrisEngineMode; public class ModeIslands extends IrisEngineMode implements EngineMode { public ModeIslands(Engine engine) { super(engine); - var terrain = new IrisTerrainNormalActuator(getEngine()); - var biome = new IrisBiomeActuator(getEngine()); + IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine()); + IrisBiomeActuator biome = new IrisBiomeActuator(getEngine()); registerStage(burst( (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c), diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index 28fd45de6..b9a4d8615 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -87,7 +87,7 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { if (shouldBypassMantleStages()) { return; } - getMantle().insertMatter(x >> 4, z >> 4, K, m); + getMantle().insertMatter(x >> 4, z >> 4, K, m, c); }; EngineStage sFloatingTerrainSolid = (x, z, k, p, m, c) -> floatingChildBiomes.modify(x, z, k, m, c); EngineStage sFloatingDecorate = (x, z, k, p, m, c) -> floatingChildBiomes.decorateColumns(x, z, k, m, c); @@ -118,7 +118,9 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { registerStage(sDecorant); registerStage(sFloatingDecorate); registerStage(sPerfection); - registerStage(sCustom); + if (getEngine().getDimensionStackContext() == null) { + registerStage(sCustom); + } } private boolean shouldBypassMantleStages() { diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeSuperFlat.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeSuperFlat.java index 6cc04308b..62a8f212d 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeSuperFlat.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeSuperFlat.java @@ -27,8 +27,8 @@ import art.arcane.iris.engine.framework.IrisEngineMode; public class ModeSuperFlat extends IrisEngineMode implements EngineMode { public ModeSuperFlat(Engine engine) { super(engine); - var terrain = new IrisTerrainNormalActuator(getEngine()); - var biome = new IrisBiomeActuator(getEngine()); + IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine()); + IrisBiomeActuator biome = new IrisBiomeActuator(getEngine()); registerStage(burst( (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c), diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 7d0f3f072..b10d571df 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -19,6 +19,8 @@ package art.arcane.iris.engine.modifier; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.actuator.IrisDecorantActuator; import art.arcane.iris.engine.framework.Engine; @@ -91,6 +93,8 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk = mantle.getChunk(x, z).use(); @@ -117,6 +124,7 @@ public class IrisCarveModifier extends EngineAssignedModifier= context.upperSurfaceHeights()[columnIndex]) { return; } + if (context.stackLayouts() != null + && context.stackLayouts()[columnIndex].isHostFeatureProtectedY(y)) { + return; + } PlatformBlockState current = context.output().getRaw(localX, y, localZ); if (hydrology != null && hydrology.action() == HydrologyCaveAction.SEAL_GUARD) { @@ -405,6 +417,7 @@ public class IrisCarveModifier extends EngineAssignedModifier matterByEntry = new IdentityHashMap<>(); for (int k = 0; k <= sample.topIdx; k++) { @@ -378,6 +395,9 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier= chunkHeight) { continue; } + if (!shouldWriteHostBiomeMarker(stackLayout, y)) { + continue; + } IrisFloatingChildBiomes entry = sample.entryAt(k); MatterBiomeInject matter = matterByEntry.get(entry); if (matter == null) { @@ -395,10 +415,15 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier generateLayersWithSlope(IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, ProceduralStream slopeStream) { + return IrisBiomeLayerGenerator.generateLayers(this, dim, wx, wz, random, maxDepth, height, rdata, slopeStream); + } + public KList generateCeilingLayers(IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) { return IrisBiomeLayerGenerator.generateCeilingLayers(this, dim, wx, wz, random, maxDepth, height, rdata, complex); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java index 599751296..e551f1237 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java @@ -26,6 +26,7 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.noise.CNG; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; @@ -42,8 +43,16 @@ final class IrisBiomeLayerGenerator { } static KList generateLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) { + return generateLayers(biome, dim, wx, wz, random, maxDepth, height, rdata, complex, null); + } + + static KList generateLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, ProceduralStream slopeStream) { + return generateLayers(biome, dim, wx, wz, random, maxDepth, height, rdata, null, slopeStream); + } + + private static KList generateLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex, ProceduralStream slopeStream) { if (biome.isLockLayers()) { - return generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex); + return generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex, slopeStream); } KList data = new KList<>(); @@ -70,7 +79,7 @@ final class IrisBiomeLayerGenerator { IrisSlopeClip sc = layer.getSlopeCondition(); if (!sc.isDefault()) { - if (!sc.isValid(complex.getSlopeStream().get(wx, wz))) { + if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) { d = 0; } } @@ -168,6 +177,10 @@ final class IrisBiomeLayerGenerator { } static KList generateLockedLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepthf, int height, IrisData rdata, IrisComplex complex) { + return generateLockedLayers(biome, wx, wz, random, maxDepthf, height, rdata, complex, null); + } + + private static KList generateLockedLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepthf, int height, IrisData rdata, IrisComplex complex, ProceduralStream slopeStream) { KList data = new KList<>(); KList real = new KList<>(); int maxDepth = Math.min(maxDepthf, biome.getLockLayersMax()); @@ -190,7 +203,7 @@ final class IrisBiomeLayerGenerator { IrisSlopeClip sc = layer.getSlopeCondition(); if (!sc.isDefault()) { - if (!sc.isValid(complex.getSlopeStream().get(wx, wz))) { + if (!sc.isValid(resolveSlopeStream(complex, slopeStream).getDouble(wx, wz))) { d = 0; } } @@ -222,6 +235,13 @@ final class IrisBiomeLayerGenerator { return real; } + private static ProceduralStream resolveSlopeStream( + IrisComplex complex, + ProceduralStream slopeStream + ) { + return slopeStream == null ? complex.getSlopeStream() : slopeStream; + } + static KList generateSeaLayers(IrisBiome biome, double wx, double wz, RNG random, int maxDepth, IrisData rdata) { KList data = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 81320574d..c6c9842c0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -222,6 +222,8 @@ public class IrisDimension extends IrisRegistrant { private boolean upperDimensionObjects = false; @Desc("When true, upper-dimension objects force-place regardless of placement restrictions (slope, underwater, clamp, collisions, carving). Normal dimension objects always place first; upper objects place second and may clip or occlude lower-dimension placements when this is enabled.") private boolean upperObjectsForcePlace = false; + @Desc("Stack dimension terrain upright in declared top-to-bottom order. The final dimension key must reference this dimension.") + private IrisDimensionStack dimensionStack = null; @RegistryListResource(IrisBiome.class) @Desc("Keep this either undefined or empty. Setting any biome name into this will force iris to only generate the specified biome. Great for testing.") private String focus = ""; @@ -528,6 +530,12 @@ public class IrisDimension extends IrisRegistrant { } } } + if (hasFocusRegion()) { + IrisRegion focus = data.getRegionLoader().load(getFocusRegion()); + if (focus != null && !focus.isCompatExcluded()) { + regions.put(getFocusRegion(), focus); + } + } return regions.v(); } @@ -564,6 +572,7 @@ public class IrisDimension extends IrisRegistrant { } Deque pending = new ArrayDeque<>(); + addReachableBiomeKey(pending, getFocus()); IrisHydrology configuredHydrology = getHydrology(); IrisRiverHydrology configuredRivers = configuredHydrology == null ? null : configuredHydrology.getRivers(); boolean hydrologyPoliciesActive = configuredRivers != null && configuredRivers.isEnabled(); @@ -721,8 +730,14 @@ public class IrisDimension extends IrisRegistrant { } public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, KSet biomes) throws IOException { - String namespace = getLoadKey().toLowerCase(Locale.ROOT); - installBiomes(fixer, data, datapackRoots, namespace, "", biomes); + String keyPrefix = getCustomBiomeKeyPrefix(); + int separator = keyPrefix.indexOf(':'); + String namespace = keyPrefix.substring(0, separator); + String pathPrefix = keyPrefix.substring(separator + 1); + if (pathPrefix.endsWith("/")) { + pathPrefix = pathPrefix.substring(0, pathPrefix.length() - 1); + } + installBiomes(fixer, data, datapackRoots, namespace, pathPrefix, biomes); } public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, @@ -747,18 +762,19 @@ public class IrisDimension extends IrisRegistrant { for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { String customBiomeId = customBiome.getId(); + String customBiomePath = sanitizeRegistryPath(customBiomeId, "biome"); String json = customBiome.generateJson(fixer, contentGate); synchronized (biomes) { - if (!biomes.add(customBiomeId)) { + if (!biomes.add(customBiomePath)) { IrisLogging.debug("Duplicate Data Pack Biome: " + getLoadKey() + "/" + customBiomeId); continue; } } String biomePath = pathPrefix.isBlank() - ? customBiomeId - : pathPrefix + "/" + customBiomeId; + ? customBiomePath + : pathPrefix + "/" + customBiomePath; for (File datapackRoot : datapackRoots) { File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + biomePath + ".json"); @@ -900,6 +916,28 @@ public class IrisDimension extends IrisRegistrant { return sanitizeDimensionTypeKeyValue(getLoadKey()); } + public String getCustomBiomeKey(String customBiomeId) { + return customBiomeKey(getLoadKey(), customBiomeId); + } + + public String getCustomBiomeKeyPrefix() { + return customBiomeKeyPrefix(getLoadKey()); + } + + public static String customBiomeKey(String dimensionLoadKey, String customBiomeId) { + return customBiomeKeyPrefix(dimensionLoadKey) + sanitizeRegistryPath(customBiomeId, "biome"); + } + + private static String customBiomeKeyPrefix(String dimensionLoadKey) { + String dimensionPath = sanitizeRegistryPath(dimensionLoadKey, "dimension"); + int separator = dimensionPath.indexOf('/'); + if (separator < 0) { + return sanitizeRegistryNamespace(dimensionPath) + ":"; + } + String namespace = sanitizeRegistryNamespace(dimensionPath.substring(0, separator)); + return namespace + ":" + dimensionPath.substring(separator + 1) + "/"; + } + public static String sanitizeDimensionTypeKeyValue(String value) { if (value == null || value.isBlank()) { return "dimension"; @@ -918,6 +956,26 @@ public class IrisDimension extends IrisRegistrant { return sanitized.isBlank() ? "dimension" : sanitized; } + private static String sanitizeRegistryNamespace(String value) { + String sanitized = value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_"); + return sanitized.isBlank() ? "dimension" : sanitized; + } + + private static String sanitizeRegistryPath(String value, String fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + String sanitized = value.trim().toLowerCase(Locale.ROOT).replace("\\", "/"); + sanitized = sanitized.replaceAll("[^a-z0-9/._-]", "_"); + sanitized = sanitized.replaceAll("/+", "/"); + sanitized = sanitized.replaceAll("^/+", ""); + sanitized = sanitized.replaceAll("/+$", ""); + while (sanitized.contains("..")) { + sanitized = sanitized.replace("..", "_"); + } + return sanitized.isBlank() ? fallback : sanitized; + } + public IrisDimensionType getDimensionType() { IrisDimensionTypeOptions options = getDimensionOptions(); if (fullbright) { @@ -930,6 +988,10 @@ public class IrisDimension extends IrisRegistrant { return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none"); } + public boolean hasDimensionStack() { + return dimensionStack != null; + } + public void installDimensionType(IDataFixer fixer, KList datapackRoots) throws IOException { IrisDimensionType type = getDimensionType(); String json = type.toJson(fixer); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStack.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStack.java new file mode 100644 index 000000000..740d4ef83 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStack.java @@ -0,0 +1,50 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Stacks dimension terrain upright in declared top-to-bottom order") +@Data +public class IrisDimensionStack { + @Required + @ArrayType(min = 2, type = String.class) + @RegistryListResource(IrisDimension.class) + @Desc("Dimension load keys ordered from the highest layer to the lowest layer. The final key must be this dimension.") + private KList dimensions = new KList<>(); + + @MinNumber(0) + @MaxNumber(256) + @Desc("Nominal air gap in blocks between adjacent dimension layers") + private int spacer = 32; + + @Desc("Optional noise that blends adjacent layer boundaries. Omit this object for a constant configured gap.") + private IrisDimensionStackBlend blend = null; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStackBlend.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStackBlend.java new file mode 100644 index 000000000..2bb15a9a3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionStackBlend.java @@ -0,0 +1,40 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls noise blending at each dimension stack boundary") +@Data +public class IrisDimensionStackBlend { + @MinNumber(0) + @MaxNumber(256) + @Desc("Maximum vertical boundary displacement in blocks") + private int amplitude = 8; + + @Desc("Noise style sampled independently for each adjacent layer boundary") + private IrisGeneratorStyle style = NoiseStyle.SIMPLEX.style(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 7970d600a..cc5eeef3a 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -553,11 +553,15 @@ public final class EngineBukkitOps { } public static IrisRegion getRegion(Engine engine, Location l) { - return engine.getRegion(l.getBlockX(), l.getBlockZ()); + return engine.getRegion( + l.getBlockX(), l.getBlockY() - engine.getWorld().minHeight(), l.getBlockZ()); } public static IrisBiome getBiomeOrMantle(Engine engine, Location l) { - return engine.getBiomeOrMantle(l.getBlockX(), l.getBlockY(), l.getBlockZ()); + return engine.getBiomeOrMantle( + l.getBlockX(), + l.getBlockY() - engine.getWorld().minHeight(), + l.getBlockZ()); } public static IrisBiome getSurfaceBiome(Engine engine, Chunk c) { diff --git a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java index 3f90ca276..216d4d572 100644 --- a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java @@ -1,5 +1,7 @@ package art.arcane.iris.util.project.context; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.EngineMetrics; @@ -27,6 +29,8 @@ public class ChunkContext { private final ChunkedDataCache fluid; private final ChunkedDataCache region; private final FloatingIslandBoundarySampler floatingIslandBoundarySampler; + private final DimensionStackContext dimensionStackContext; + private final DimensionStackLayout[] dimensionStackLayouts; public ChunkContext(int x, int z, IrisComplex complex) { this(x, z, complex, 0L, true, PrefillPlan.NO_CAVE, null); @@ -45,6 +49,19 @@ public class ChunkContext { } public ChunkContext(int x, int z, IrisComplex complex, long generationSessionId, boolean cache, PrefillPlan prefillPlan, EngineMetrics metrics) { + this(x, z, complex, generationSessionId, cache, prefillPlan, metrics, null); + } + + public ChunkContext( + int x, + int z, + IrisComplex complex, + long generationSessionId, + boolean cache, + PrefillPlan prefillPlan, + EngineMetrics metrics, + DimensionStackContext dimensionStackContext + ) { this.x = x; this.z = z; this.complex = complex; @@ -57,6 +74,8 @@ public class ChunkContext { this.fluid = new ChunkedDataCache<>(complex.getFluidStream(), x, z, cache); this.region = new ChunkedDataCache<>(complex.getRegionStream(), x, z, cache); this.floatingIslandBoundarySampler = new FloatingIslandBoundarySampler((wx, wz) -> complex.getTrueBiomeStream().get(wx, wz)); + this.dimensionStackContext = dimensionStackContext; + this.dimensionStackLayouts = dimensionStackContext == null ? null : new DimensionStackLayout[256]; if (cache) { PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan; @@ -165,6 +184,19 @@ public class ChunkContext { return region; } + public DimensionStackLayout getDimensionStackLayout(int localX, int localZ) { + if (dimensionStackContext == null) { + return null; + } + int index = (localZ << 4) | localX; + DimensionStackLayout layout = dimensionStackLayouts[index]; + if (layout == null) { + layout = dimensionStackContext.sample(x + localX, z + localZ); + dimensionStackLayouts[index] = layout; + } + return layout; + } + public enum PrefillPlan { ALL(true, true, true, true, true, true), NO_CAVE(true, true, false, true, true, true), diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index 468c621ae..440aba347 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.core; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; import org.junit.Rule; @@ -88,6 +90,26 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta"))); } + @Test + public void compilesRecursiveDimensionBiomeToCanonicalRegistryPath() throws Exception { + Path packRoot = temporaryFolder.newFolder("recursive-dimension-pack").toPath(); + Path datapackRoot = temporaryFolder.newFolder("recursive-dimension-datapack").toPath(); + createPack(packRoot, "layers/sky", "Aurora"); + + IrisDatapackCompiler.compile( + List.of(packRoot.toFile()), + new KList().qadd(datapackRoot.toFile()), + List.of(), + new DataFixerV1217(), + false + ); + + assertTrue(Files.isRegularFile( + datapackRoot.resolve("data/layers/worldgen/biome/sky/aurora.json"))); + assertTrue(Files.isRegularFile( + datapackRoot.resolve("data/iris/dimension_type/layers_sky.json"))); + } + @Test public void collectsInstalledPacksThroughSymbolicLinkWorkspace() throws Exception { Path dataDirectory = temporaryFolder.newFolder("linked-data").toPath(); @@ -252,6 +274,57 @@ public class IrisDatapackCompilerTest { assertFalse(ServerConfigurator.loadedRegistrySatisfies(loaded, changedBiomeTags)); } + @Test + public void stackRuntimeRequirementsIncludeSourceDimensionRegistries() throws Exception { + Path pack = temporaryFolder.newFolder("registry-stack-pack").toPath(); + createPack(pack, "host", "stack_custom", "NORMAL"); + Files.writeString( + pack.resolve("dimensions/host.json"), + """ + { + "name": "Host", + "environment": "NORMAL", + "logicalHeight": 256, + "dimensionHeight": {"min": -64, "max": 320}, + "dimensionStack": { + "dimensions": ["layers/source", "host"], + "spacer": 8 + } + } + """, + StandardCharsets.UTF_8 + ); + Files.createDirectories(pack.resolve("dimensions/layers")); + Files.writeString( + pack.resolve("dimensions/layers/source.json"), + """ + { + "name": "Source", + "environment": "NORMAL", + "logicalHeight": 256, + "dimensionHeight": {"min": -64, "max": 320} + } + """, + StandardCharsets.UTF_8 + ); + + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + try { + IrisDimension host = data.getDimensionLoader().load("host"); + Map requirements = IrisDatapackCompiler.computeRegistryRequirements( + host, + new DataFixerV1217() + ); + + assertTrue(requirements.containsKey("dimension_type/iris:host")); + assertTrue(requirements.containsKey("dimension_type/iris:layers_source")); + assertTrue(requirements.containsKey("worldgen/biome/host:stack_custom")); + assertTrue(requirements.containsKey("worldgen/biome/layers:source/stack_custom")); + } finally { + data.close(); + } + } + @Test public void rejectsBindingToMissingDimension() throws Exception { Path packRoot = temporaryFolder.newFolder("missing-binding-pack").toPath(); @@ -325,6 +398,7 @@ public class IrisDatapackCompilerTest { ) throws Exception { Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("biomes")); + Files.createDirectories(root.resolve("dimensions").resolve(dimensionKey).getParent()); Files.writeString( root.resolve("dimensions").resolve(dimensionKey + ".json"), """ diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java index ba2ab7d23..01e54f84e 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java @@ -80,6 +80,7 @@ public class ServerConfiguratorRegistryVerificationTest { IrisDimension dimension = mock(IrisDimension.class); when(dimension.getAllBiomes(any())).thenReturn(new KList<>(biome)); when(dimension.getLoadKey()).thenReturn("overworld"); + when(dimension.getCustomBiomeKey("missing")).thenReturn("overworld:missing"); when(dimension.getLoadFile()).thenReturn( new File("packs/overworld/dimensions/overworld.json")); when(dimension.getDimensionTypeKey()).thenReturn("iris:overworld"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java index 9186e262e..c3c3aed2c 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionStack; import art.arcane.iris.engine.object.IrisObjectMarker; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisStaticObject; @@ -9,6 +11,7 @@ import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -63,6 +66,38 @@ public class PackExportClosureTest { PackExportClosure.collectStaticObjectKeys(placements).stream().sorted().toList()); } + @Test + public void collectsRootAndStackedDimensionKeys() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("main"); + dimension.setDimensionStack(new IrisDimensionStack() + .setDimensions(new KList<>("sky", "middle", "main", "sky"))); + + assertEquals(List.of("main", "middle", "sky"), + PackExportClosure.collectDimensionKeys(dimension).stream().sorted().toList()); + } + + @Test + public void collectsTransitiveStackDimensionsWithoutLooping() { + IrisDimension main = dimension("main", "sky", "main"); + IrisDimension sky = dimension("sky", "cloud", "sky"); + IrisDimension cloud = dimension("cloud", "main", "cloud"); + Map dimensions = Map.of( + "main", main, + "sky", sky, + "cloud", cloud); + + assertEquals(List.of("cloud", "main", "sky"), + PackExportClosure.collectDimensionKeys(main, dimensions::get).stream().sorted().toList()); + } + + private static IrisDimension dimension(String key, String... stackKeys) { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey(key); + dimension.setDimensionStack(new IrisDimensionStack().setDimensions(new KList<>(stackKeys))); + return dimension; + } + /** * Source guard: both packagers must export the ambient-spawning graph. Spawner and marker * folders were silently omitted from exports, leaving dangling entitySpawners references. @@ -75,10 +110,16 @@ public class PackExportClosureTest { assertTrue("Bukkit packager must write markers/", bukkit.contains("\"markers/\"")); assertTrue("Bukkit packager must export region objects alongside biome objects", bukkit.contains("regions.forEach((r) -> allPlacements.addAll(r.getObjects()))")); - assertTrue("Bukkit packager must export static objects", bukkit.contains("dimension.getStaticObjects()")); + assertTrue("Bukkit packager must export static objects", + bukkit.contains("exportedDimension.getStaticObjects()")); assertTrue("Bukkit obfuscation must rewrite static object references", bukkit.contains("placement.setObject(renameObjects.get(placement.getObject()))")); assertTrue("Bukkit packager must export entity loot tables", bukkit.contains("getLoot().getTables()")); + assertTrue("Bukkit packager must include dimension-stack resources", + bukkit.contains("PackExportClosure.collectDimensionKeys(dimension)")); + assertTrue("Bukkit packager must export expression-backed stack blend styles", + bukkit.contains("dm.getExpressionLoader().getPossibleKeys()") + && bukkit.contains("\"expressions/\"")); int bukkitValidation = bukkit.indexOf("PackValidator.validateForPackaging(project.getPath())"); assertTrue("Bukkit packager must validate before opening or mutating package state", bukkitValidation >= 0 @@ -92,7 +133,14 @@ public class PackExportClosureTest { assertTrue("modded packager must include initial spawns", modded.contains("getInitialSpawns")); assertTrue("modded packager must export region objects", modded.contains("region.getObjects()")); assertTrue("modded packager must export static objects", - modded.contains("PackExportClosure.collectStaticObjectKeys(dimension.getStaticObjects())")); + modded.contains("PackExportClosure.collectStaticObjectKeys(exportedDimension.getStaticObjects())")); + assertTrue("modded packager must include dimension-stack resources", + modded.contains("PackExportClosure.collectDimensionKeys(dimension)")); + assertTrue("modded packager must export expression-backed stack blend styles", + modded.contains("dm.getExpressionLoader().getPossibleKeys()") + && modded.contains("\"expressions\"")); + assertTrue("modded packager must retain referenced style snippets", + modded.contains("copyStyleSnippets(packFolder, folder)")); int moddedValidation = modded.indexOf("PackValidator.validateForPackaging(packFolder)"); assertTrue("modded packager must validate before mutating package state", moddedValidation >= 0 && moddedValidation < modded.indexOf("IO.delete(folder)")); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java index 8c0a871b9..18e2995cc 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java @@ -317,6 +317,48 @@ public class PackImageMapValidatorTest { "Dimension 'upper' image-map 'terrain' source footprint does not cover")); } + @Test + public void validatesStackLayerCoverageAgainstHostBoundary() throws Exception { + File pack = createPack("stack-host-boundary", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 16}, + "dimensionStack": { + "dimensions": ["upper", "upper", "main"], + "spacer": 24 + } + } + """); + write(pack, "dimensions/upper.json", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 2}, + "imageMaps": [ + {"key": "terrain", "map": "upper-terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(pack, "upper-terrain", 4, 4); + write(pack, "image-maps/upper-terrain.json", """ + { + "source": "upper-terrain", + "type": "GRAYSCALE_HEIGHT", + "origin": {"x": -2, "z": -2}, + "outOfBounds": "ERROR" + } + """); + + PackImageMapValidator.Validation validation = validate(pack, false); + + assertTrue(validation.errors().toString(), contains(validation.errors(), + "Dimension 'main' dimension stack layer 'upper' image-map 'terrain' source footprint does not cover")); + assertEquals(1L, validation.errors().stream() + .filter(error -> error.contains("Dimension 'main' dimension stack layer 'upper'")) + .count()); + assertFalse(validation.errors().toString(), contains(validation.errors(), + "Dimension 'upper' image-map 'terrain' source footprint does not cover")); + } + @Test public void acceptsUpperDimensionWithoutStandaloneBoundaryWhenParentIsCovered() throws Exception { File pack = createPack("upper-parent-only-boundary", """ diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionStackTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionStackTest.java new file mode 100644 index 000000000..3f35c8bdd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorDimensionStackTest.java @@ -0,0 +1,260 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorDimensionStackTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsCanonicalTopToBottomStack() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "spacer": 24, + "blend": { + "amplitude": 8, + "style": {"style": "SIMPLEX", "zoom": 128} + } + } + } + """); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void rejectsMissingBlendStyleSnippet() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "blend": {"style": "snippet/style/missing"} + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "references missing style snippet 'snippet/style/missing'")); + } + + @Test + public void rejectsWrongBlendSnippetType() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "blend": {"style": "snippet/generator/stack-blend"} + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "blend.style must be an object or style snippet reference")); + } + + @Test + public void rejectsMissingLayerAndWrongLowestDimension() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["missing", "sky"], + "spacer": 24 + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "dimensions[0] references missing dimension 'missing'")); + assertTrue(contains(result, "dimensions must end with its host dimension key 'main'")); + } + + @Test + public void rejectsUpperDimensionAndUnsafeNumericValues() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "upperDimension": "sky", + "dimensionStack": { + "dimensions": ["sky", "main"], + "spacer": 24.5, + "blend": { + "amplitude": 257, + "style": {} + } + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "dimensionStack cannot be used with upperDimension")); + assertTrue(contains(result, "spacer must be an integer between 0 and 256")); + assertTrue(contains(result, "amplitude must be an integer between 0 and 256")); + } + + @Test + public void rejectsShortStackAndMalformedBlendStyle() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["main"], + "blend": {"style": 42} + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "dimensions must contain at least two dimension keys")); + assertTrue(contains(result, "blend.style must be an object or style snippet reference")); + } + + @Test + public void acceptsBlendStyleSnippetReference() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "blend": {"style": "snippet/style/stack-blend"} + } + } + """, "{\"style\":\"SIMPLEX\",\"zoom\":128}"); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void rejectsMalformedBlendStyleSnippet() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "blend": {"style": "snippet/style/stack-blend"} + } + } + """, "{"); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "references invalid style snippet 'snippet/style/stack-blend'")); + } + + @Test + public void acceptsNestedDimensionKeys() throws Exception { + File pack = temporaryFolder.newFolder("nested-pack"); + write(pack, "dimensions/root/main.json", """ + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky/clouds", "root/main"] + } + } + """); + write(pack, "dimensions/sky/clouds.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + + PackValidationResult result = PackValidator.validateForPackaging(pack); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void acceptsEmptyBlendWithDefaults() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "main"], + "blend": {} + } + } + """); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void acceptsRepeatedNonHostDimensions() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["sky", "sky", "main"] + } + } + """); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void rejectsDuplicateHostDimension() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": { + "dimensions": ["main", "sky", "main"] + } + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "host dimension key 'main' exactly once")); + } + + @Test + public void rejectsExplicitNullConfiguration() throws Exception { + PackValidationResult result = validate(""" + { + "regions": ["region"], + "dimensionStack": null + } + """); + + assertFalse(result.isLoadable()); + assertTrue(contains(result, "dimensionStack must be an object")); + } + + private PackValidationResult validate(String mainDimension) throws Exception { + return validate(mainDimension, null); + } + + private PackValidationResult validate(String mainDimension, String styleSnippet) throws Exception { + File pack = temporaryFolder.newFolder("pack-" + System.nanoTime()); + write(pack, "dimensions/main.json", mainDimension); + write(pack, "dimensions/sky.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + if (styleSnippet != null) { + write(pack, "snippet/style/stack-blend.json", styleSnippet); + } + return PackValidator.validateForPackaging(pack); + } + + private static boolean contains(PackValidationResult result, String fragment) { + return result.getBlockingErrors().stream().anyMatch(error -> error.contains(fragment)); + } + + private static void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisDimensionStackSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisDimensionStackSchemaTest.java new file mode 100644 index 000000000..a23b21b25 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisDimensionStackSchemaTest.java @@ -0,0 +1,108 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionStack; +import art.arcane.iris.engine.object.IrisDimensionStackBlend; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.Gson; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDimensionStackSchemaTest { + @Test + public void dimensionExposesNullableStackConfiguration() throws Exception { + IrisDimension dimension = new IrisDimension(); + Field stackField = IrisDimension.class.getDeclaredField("dimensionStack"); + Field dimensionsField = IrisDimensionStack.class.getDeclaredField("dimensions"); + + assertNull(dimension.getDimensionStack()); + assertFalse(dimension.hasDimensionStack()); + dimension.setDimensionStack(new IrisDimensionStack()); + assertTrue(dimension.hasDimensionStack()); + assertEquals(IrisDimensionStack.class, stackField.getType()); + assertEquals(2, dimensionsField.getAnnotation(ArrayType.class).min()); + assertEquals(IrisDimension.class, + dimensionsField.getAnnotation(RegistryListResource.class).value()); + } + + @Test + public void schemaCarriesStackOrderAndNumericBounds() { + JSONObject schema = new SchemaBuilder(IrisDimensionStack.class, schemaData()).construct(); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject dimensions = properties.getJSONObject("dimensions"); + JSONObject spacer = properties.getJSONObject("spacer"); + JSONObject blend = referencedSchema(schema, properties.getJSONObject("blend")); + JSONObject blendProperties = blend.getJSONObject("properties"); + JSONObject amplitude = blendProperties.getJSONObject("amplitude"); + + assertEquals("array", dimensions.getString("type")); + assertEquals(2, dimensions.getInt("minItems")); + assertEquals(0, spacer.getInt("minimum")); + assertEquals(256, spacer.getInt("maximum")); + assertEquals(0, amplitude.getInt("minimum")); + assertEquals(256, amplitude.getInt("maximum")); + assertTrue(contains(schema.getJSONArray("required"), "dimensions")); + assertFalse(blend.has("required")); + } + + @Test + public void gsonUsesTheCanonicalNestedShape() { + IrisDimensionStack stack = new IrisDimensionStack() + .setDimensions(new KList<>("sky", "main")) + .setSpacer(24) + .setBlend(new IrisDimensionStackBlend().setAmplitude(8)); + + JSONObject json = new JSONObject(new Gson().toJson(stack)); + + assertEquals(2, json.getJSONArray("dimensions").length()); + assertEquals("main", json.getJSONArray("dimensions").getString(1)); + assertEquals(24, json.getInt("spacer")); + assertEquals(8, json.getJSONObject("blend").getInt("amplitude")); + assertTrue(json.getJSONObject("blend").getJSONObject("style").has("style")); + } + + private static JSONObject referencedSchema(JSONObject schema, JSONObject property) { + String key = property.getString("$ref").substring("#/definitions/".length()); + return schema.getJSONObject("definitions").getJSONObject(key); + } + + private static boolean contains(JSONArray values, String expected) { + for (int index = 0; index < values.length(); index++) { + if (expected.equals(values.optString(index, null))) { + return true; + } + } + return false; + } + + @SuppressWarnings("unchecked") + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + ResourceLoader dimensionLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisDimension.class, dimensionLoader); + when(data.getLoaders()).thenReturn(loaders); + when(dimensionLoader.getFolderName()).thenReturn("dimensions"); + when(dimensionLoader.getResourceTypeName()).thenReturn("Dimension"); + when(dimensionLoader.getPossibleKeys()).thenReturn(new String[]{"sky", "main"}); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java index fa5756209..68f130bf5 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java @@ -86,6 +86,46 @@ public class IrisProjectCopierTest { assertTrue(Files.isRegularFile(target.resolve("dimensions/the_nether.json"))); } + @Test + public void rewritesDimensionStackReferencesAcrossCopiedDimensionsWhenRenaming() throws Exception { + Path workspace = temporaryFolder.newFolder("dimension-stack-reference").toPath(); + Path source = workspace.resolve("template-pack"); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/overworld.json"), """ + { + "name": "Source", + "dimensionStack": { + "dimensions": ["layers/sky", "overworld"], + "spacer": 24 + } + } + """); + Files.createDirectories(source.resolve("dimensions/layers")); + Files.writeString(source.resolve("dimensions/layers/sky.json"), """ + { + "name": "Sky", + "dimensionStack": { + "dimensions": ["overworld", "layers/sky"] + } + } + """); + + IrisProjectCopier.copyProject(source.toFile(), workspace.toFile(), "overworld", "new-project"); + + JSONObject dimension = new JSONObject(Files.readString( + workspace.resolve("new-project/dimensions/new-project.json"))); + assertEquals("layers/sky", dimension.getJSONObject("dimensionStack") + .getJSONArray("dimensions").getString(0)); + assertEquals("new-project", dimension.getJSONObject("dimensionStack") + .getJSONArray("dimensions").getString(1)); + JSONObject supportingDimension = new JSONObject(Files.readString( + workspace.resolve("new-project/dimensions/layers/sky.json"))); + assertEquals("new-project", supportingDimension.getJSONObject("dimensionStack") + .getJSONArray("dimensions").getString(0)); + assertEquals("layers/sky", supportingDimension.getJSONObject("dimensionStack") + .getJSONArray("dimensions").getString(1)); + } + @Test public void existingTargetRemainsUnchanged() throws Exception { Path workspace = temporaryFolder.newFolder("packs").toPath(); diff --git a/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java b/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java new file mode 100644 index 000000000..3f8d630ee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/DimensionStackLayoutTest.java @@ -0,0 +1,337 @@ +package art.arcane.iris.engine; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class DimensionStackLayoutTest { + @Test + public void spacerCountsExactAirBlocksBetweenInclusiveLayers() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 8, + List.of(input(15, 15), input(15, 15), input(15, 15)), + new int[]{0, 0} + ); + + List layers = layout.layersBottomToTop(); + assertEquals(0, layers.get(0).localBaseY()); + assertEquals(15, layers.get(0).surfaceY()); + assertEquals(24, layers.get(1).localBaseY()); + assertEquals(39, layers.get(1).surfaceY()); + assertEquals(48, layers.get(2).localBaseY()); + assertEquals(63, layers.get(2).surfaceY()); + assertEquals(8, layers.get(1).localBaseY() - layers.get(0).contentTopY() - 1); + assertEquals(8, layers.get(2).localBaseY() - layers.get(1).contentTopY() - 1); + assertEquals(63, layout.stackTerrainTopY()); + assertEquals(63, layout.stackTopY()); + } + + @Test + public void independentlySignedSeamOffsetsShiftOnlyTheirUpperLayer() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 4, + List.of(input(10, 7), input(10, 7), input(10, 7)), + new int[]{3, -2} + ); + + List layers = layout.layersBottomToTop(); + assertEquals(0, layers.get(0).localBaseY()); + assertEquals(18, layers.get(1).localBaseY()); + assertEquals(31, layers.get(2).localBaseY()); + assertEquals(3, layers.get(1).seamOffsetBelow()); + assertEquals(-2, layers.get(2).seamOffsetBelow()); + assertEquals(41, layout.stackTerrainTopY()); + assertEquals(41, layout.stackTopY()); + } + + @Test + public void renderingBoundsClipWithoutChangingRawLayout() { + DimensionStackLayout layout = DimensionStackLayout.create( + 32, + 8, + List.of(input(15, 15), input(15, 15), input(15, 15)), + new int[]{0, 0} + ); + + List layers = layout.layersBottomToTop(); + DimensionStackLayout.Layer middle = layers.get(1); + DimensionStackLayout.Layer top = layers.get(2); + assertEquals(39, middle.contentTopY()); + assertEquals(24, middle.renderMinY()); + assertEquals(31, middle.renderMaxY()); + assertEquals(31, middle.clippedSurfaceY()); + assertTrue(middle.visible()); + assertTrue(middle.containsRenderedY(31)); + assertFalse(middle.containsRenderedY(32)); + assertTrue(layout.containsUpperLayerY(31)); + assertFalse(layout.containsUpperLayerY(23)); + assertEquals(48, top.localBaseY()); + assertFalse(top.visible()); + assertFalse(top.containsRenderedY(31)); + assertEquals(63, layout.stackTerrainTopY()); + assertEquals(63, layout.stackTopY()); + assertEquals(31, layout.clippedStackTerrainTopY()); + assertEquals(31, layout.clippedStackTopY()); + } + + @Test + public void invisibleLayersDoNotRaiseRenderedHeightToTheCeiling() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 64, + List.of(input(15, 15), input(15, 15)), + new int[]{0} + ); + + assertFalse(layout.layersBottomToTop().get(1).visible()); + assertEquals(15, layout.clippedStackTerrainTopY()); + assertEquals(15, layout.clippedStackTopY()); + } + + @Test + public void layerLookupReturnsRenderedOwnerAndBottomOwnerForAir() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 8, + List.of(input(15, 15), input(15, 15)), + new int[]{0} + ); + + assertSame(layout.layersBottomToTop().get(0), layout.layerAt(8)); + assertSame(layout.layersBottomToTop().get(0), layout.layerAt(20)); + assertSame(layout.layersBottomToTop().get(1), layout.layerAt(24)); + } + + @Test + public void laterGapsEraseEarlierUpperLayerOwnership() { + DimensionStackLayout layout = DimensionStackLayout.create( + 128, + 0, + List.of(input(100, 100), input(60, 60), input(10, 10), input(10, 10)), + new int[]{-81, -41, 39} + ); + + List layers = layout.layersBottomToTop(); + assertSame(layers.get(1), layout.layerAt(30)); + assertSame(layers.get(2), layout.layerAt(45)); + assertSame(layers.get(0), layout.layerAt(60)); + assertSame(layers.get(3), layout.layerAt(95)); + } + + @Test + public void hostFeaturesCannotFillStackGapsOrOverwriteUpperLayers() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 8, + List.of(input(15, 15), input(15, 15)), + new int[]{0} + ); + + assertFalse(layout.isHostFeatureProtectedY(15)); + assertTrue(layout.isHostFeatureProtectedY(20)); + assertTrue(layout.isHostFeatureProtectedY(24)); + } + + @Test + public void fluidExtendsContentWithoutChangingTerrainTop() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 2, + List.of(input(5, 9), input(4, 7)), + new int[]{0} + ); + + List layers = layout.layersBottomToTop(); + assertEquals(9, layers.get(0).contentTopY()); + assertEquals(12, layers.get(1).localBaseY()); + assertEquals(16, layers.get(1).surfaceY()); + assertEquals(19, layers.get(1).contentTopY()); + assertEquals(16, layout.stackTerrainTopY()); + assertEquals(19, layout.stackTopY()); + } + + @Test + public void physicallyHighestTerrainLayerWinsWhenBlendEmbedsAnUpperLayer() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 0, + List.of(input(20, 20), input(5, 5)), + new int[]{-15} + ); + + assertSame(layout.layersBottomToTop().get(0), layout.topTerrainLayer()); + } + + @Test + public void declaredUpperLayerWinsWhenRenderedSurfacesTie() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 0, + List.of(input(10, 10), input(5, 5)), + new int[]{-6} + ); + + assertSame(layout.layersBottomToTop().get(1), layout.topTerrainLayer()); + } + + @Test + public void upperFluidOccludesAPhysicallyHigherLowerSurface() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 0, + List.of(input(20, 20), input(5, 15)), + new int[]{-11} + ); + + assertEquals(15, layout.clippedStackTerrainTopY()); + assertEquals(25, layout.clippedStackTopY()); + assertSame(layout.layersBottomToTop().get(1), layout.topTerrainLayer()); + } + + @Test + public void laterGapErasesProtrudingTerrainWhenItsUpperLayerIsClipped() { + DimensionStackLayout layout = DimensionStackLayout.create( + 128, + 0, + List.of(input(100, 100), input(10, 10), input(10, 10)), + new int[]{-51, 89} + ); + + assertEquals(50, layout.layersBottomToTop().get(1).localBaseY()); + assertEquals(150, layout.layersBottomToTop().get(2).localBaseY()); + assertFalse(layout.layersBottomToTop().get(2).visible()); + assertEquals(60, layout.clippedStackTerrainTopY()); + assertEquals(60, layout.clippedStackTopY()); + assertSame(layout.layersBottomToTop().get(1), layout.topTerrainLayer()); + } + + @Test + public void visibleFluidOwnsSurfaceQueriesWhenAllTerrainIsOccludedBelowTheFloor() { + DimensionStackLayout layout = DimensionStackLayout.create( + 64, + 0, + List.of(input(5, 5), input(2, 30)), + new int[]{-20} + ); + + assertEquals(-14, layout.layersBottomToTop().get(1).localBaseY()); + assertEquals(0, layout.clippedStackTerrainTopY()); + assertEquals(16, layout.clippedStackTopY()); + assertNull(layout.topTerrainLayer()); + assertSame(layout.layersBottomToTop().get(1), layout.surfaceLayer()); + } + + @Test + public void layoutListsAreImmutableAndExposeBothOrders() { + ArrayList inputs = new ArrayList<>(); + DimensionStackLayout.LayerInput bottom = input(3, 3); + DimensionStackLayout.LayerInput top = input(4, 4); + inputs.add(bottom); + inputs.add(top); + DimensionStackLayout layout = DimensionStackLayout.create(32, 1, inputs, new int[]{0}); + inputs.clear(); + + assertEquals(2, layout.layersBottomToTop().size()); + assertEquals( + layout.layersBottomToTop().get(0), + layout.layersTopToBottom().get(1) + ); + assertThrows( + UnsupportedOperationException.class, + () -> layout.layersBottomToTop().clear() + ); + } + + @Test + public void seamSaltsAreStableAndBoundarySpecific() { + long first = DimensionStackContext.seamSalt(0, "root", "middle"); + long repeat = DimensionStackContext.seamSalt(0, "root", "middle"); + long differentIndex = DimensionStackContext.seamSalt(1, "root", "middle"); + long differentUpper = DimensionStackContext.seamSalt(0, "root", "top"); + + assertEquals(first, repeat); + assertNotEquals(first, differentIndex); + assertNotEquals(first, differentUpper); + } + + @Test + public void seamOffsetsClampUnboundedAndNonFiniteNoise() { + assertEquals(4, DimensionStackContext.clampSeamOffset(12D, 4)); + assertEquals(-4, DimensionStackContext.clampSeamOffset(-12D, 4)); + assertEquals(2, DimensionStackContext.clampSeamOffset(1.6D, 4)); + assertEquals(0, DimensionStackContext.clampSeamOffset(Double.NaN, 4)); + assertEquals(0, DimensionStackContext.clampSeamOffset(Double.POSITIVE_INFINITY, 4)); + } + + @Test + public void queryLayoutsCacheStableColumnsAndBypassTemporaryNaturalFallbacks() { + Cache cache = Caffeine.newBuilder().maximumSize(4).build(); + DimensionStackLayout stable = DimensionStackLayout.create( + 32, 0, List.of(input(10, 10), input(10, 10)), new int[]{0}); + DimensionStackLayout unusedStable = DimensionStackLayout.create( + 32, 0, List.of(input(11, 11), input(10, 10)), new int[]{0}); + AtomicInteger stableSamples = new AtomicInteger(); + + DimensionStackLayout first = DimensionStackContext.resolveLayout( + cache, 7L, false, () -> { + stableSamples.incrementAndGet(); + return stable; + }); + DimensionStackLayout second = DimensionStackContext.resolveLayout( + cache, 7L, false, () -> { + stableSamples.incrementAndGet(); + return unusedStable; + }); + + assertSame(stable, first); + assertSame(stable, second); + assertEquals(1, stableSamples.get()); + + DimensionStackLayout naturalFirst = DimensionStackLayout.create( + 32, 0, List.of(input(12, 12), input(10, 10)), new int[]{0}); + DimensionStackLayout naturalSecond = DimensionStackLayout.create( + 32, 0, List.of(input(13, 13), input(10, 10)), new int[]{0}); + AtomicInteger naturalSamples = new AtomicInteger(); + + assertSame(naturalFirst, DimensionStackContext.resolveLayout( + cache, 7L, true, () -> { + naturalSamples.incrementAndGet(); + return naturalFirst; + })); + assertSame(naturalSecond, DimensionStackContext.resolveLayout( + cache, 7L, true, () -> { + naturalSamples.incrementAndGet(); + return naturalSecond; + })); + assertEquals(2, naturalSamples.get()); + assertSame(stable, cache.getIfPresent(7L)); + } + + private static DimensionStackLayout.LayerInput input(int terrainHeight, int fluidHeight) { + return new DimensionStackLayout.LayerInput( + mock(DimensionTerrainContext.class), + null, + null, + null, + null, + null, + terrainHeight, + fluidHeight + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java b/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java new file mode 100644 index 000000000..05b286848 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/DimensionTerrainContextFallbackTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DimensionTerrainContextFallbackTest { + @Test + @SuppressWarnings("unchecked") + public void selfSamplerUsesNaturalValuesOnlyForNonblockingColumns() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream naturalHeight = mock(ProceduralStream.class); + ProceduralStream resolvedHeight = mock(ProceduralStream.class); + ProceduralStream resolvedFluidHeight = mock(ProceduralStream.class); + ProceduralStream naturalBiome = mock(ProceduralStream.class); + ProceduralStream resolvedBiome = mock(ProceduralStream.class); + ProceduralStream region = mock(ProceduralStream.class); + ProceduralStream rock = mock(ProceduralStream.class); + ProceduralStream configuredFluid = mock(ProceduralStream.class); + IrisImageMapRuntime imageMapRuntime = mock(IrisImageMapRuntime.class); + IrisBiome naturalBiomeValue = mock(IrisBiome.class); + IrisBiome resolvedBiomeValue = mock(IrisBiome.class); + PlatformBlockState naturalFluid = mock(PlatformBlockState.class); + PlatformBlockState resolvedFluid = mock(PlatformBlockState.class); + + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(128); + when(dimension.getLoadKey()).thenReturn("root"); + when(dimension.getFluidHeight()).thenReturn(12); + when(complex.getNaturalHeightStream()).thenReturn(naturalHeight); + when(complex.getHeightStream()).thenReturn(resolvedHeight); + when(complex.getRiverWaterSurfaceStream()).thenReturn(resolvedFluidHeight); + when(complex.getNaturalTrueBiomeStream()).thenReturn(naturalBiome); + when(complex.getTrueBiomeStream()).thenReturn(resolvedBiome); + when(complex.getRegionStream()).thenReturn(region); + when(complex.getRockStream()).thenReturn(rock); + when(complex.getFluidStream()).thenReturn(configuredFluid); + when(complex.getImageMapRuntime()).thenReturn(imageMapRuntime); + when(naturalHeight.getDouble(12D, -8D)).thenReturn(15D); + when(resolvedHeight.getDouble(12D, -8D)).thenReturn(18D); + when(resolvedFluidHeight.getDouble(12D, -8D)).thenReturn(14D); + when(naturalBiome.get(12D, -8D)).thenReturn(naturalBiomeValue); + when(resolvedBiome.get(12D, -8D)).thenReturn(resolvedBiomeValue); + when(configuredFluid.get(12D, -8D)).thenReturn(naturalFluid); + when(complex.resolveSurfaceFluid(12D, -8D)).thenReturn(resolvedFluid); + + DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension); + when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(true); + assertEquals(15D, context.getNormalTerrainHeight(12D, -8D), 0D); + assertEquals(12D, context.getFluidHeight(12D, -8D), 0D); + assertSame(naturalBiomeValue, context.getBiome(12D, -8D)); + assertSame(naturalFluid, context.getFluidBlock(12D, -8D)); + + when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(false); + assertEquals(18D, context.getNormalTerrainHeight(12D, -8D), 0D); + assertEquals(14D, context.getFluidHeight(12D, -8D), 0D); + assertSame(resolvedBiomeValue, context.getBiome(12D, -8D)); + assertSame(resolvedFluid, context.getFluidBlock(12D, -8D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java index c475db299..885c74ddb 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java @@ -37,6 +37,20 @@ public class EngineRuntimePublicationContractTest { assertBefore(publish, "sealAndAwait(", "closeRuntime(next, e)"); } + @Test + public void dimensionStackRunsAfterHostStaticObjects() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n"); + int buildStart = source.indexOf("EngineRuntime buildRuntime(EngineTarget runtimeTarget)"); + int buildEnd = source.indexOf("DimensionStackContext buildDimensionStackContext", buildStart); + String build = source.substring(buildStart, buildEnd); + + assertBefore(build, "staticObjects.apply(engine, x, z, blocks)", + "dimensionStack.actuate(x, z, blocks, multicore, context)"); + assertBefore(build, "dimensionStack.actuate(x, z, blocks, multicore, context)", + "stackCustom.modify(x, z, blocks, multicore, context)"); + } + private static void assertBefore(String source, String first, String second) { int firstIndex = source.indexOf(first); int secondIndex = source.indexOf(second); diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java index d1a286194..9fff5afa6 100644 --- a/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java @@ -17,6 +17,7 @@ import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterBiomeInject; import org.junit.After; import org.junit.Test; @@ -30,6 +31,8 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public class IrisBiomeActuatorCoordinateTest { @@ -69,6 +72,19 @@ public class IrisBiomeActuatorCoordinateTest { assertEquals(List.of("100,-200", "100,-199", "101,-200", "101,-199"), samples); } + @Test + @SuppressWarnings("unchecked") + public void stackedBiomeReplacementRemovesEveryExistingMarkerWithItsTypedSlice() { + Mantle mantle = mock(Mantle.class); + + IrisBiomeActuator.clearBiomeMatterRange(mantle, 100, -200, 4, 6); + + verify(mantle).remove(100, 4, -200, MatterBiomeInject.class); + verify(mantle).remove(100, 5, -200, MatterBiomeInject.class); + verify(mantle).remove(100, 6, -200, MatterBiomeInject.class); + verifyNoMoreInteractions(mantle); + } + @SuppressWarnings("unchecked") private Engine engine() { Mantle mantle = mock(Mantle.class); diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuatorMetadataTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuatorMetadataTest.java new file mode 100644 index 000000000..211bf3114 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDimensionStackActuatorMetadataTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.Test; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisDimensionStackActuatorMetadataTest { + @Test + @SuppressWarnings("unchecked") + public void overwrittenStackCellsClearHostHydrologyCaveOwnership() { + MantleChunk chunk = mock(MantleChunk.class); + Matter section = mock(Matter.class); + MatterSlice hydrology = mock(MatterSlice.class); + when(chunk.get(1)).thenReturn(section); + doReturn(hydrology).when(section).getSlice(HydrologyCaveCell.class); + + IrisDimensionStackActuator.MetadataCleaner cleaner = + new IrisDimensionStackActuator.MetadataCleaner(chunk, 64); + cleaner.clear(3, 20, 7); + + verify(hydrology).set(3, 4, 7, null); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineHostHeightTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineHostHeightTest.java new file mode 100644 index 000000000..d7373a45f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineHostHeightTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EngineHostHeightTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBiome biome = mock(PlatformBiome.class); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.biome(anyString())).thenReturn(biome); + when(registries.block(anyString())).thenReturn(block); + PlatformBiomeWriter biomeWriter = mock(PlatformBiomeWriter.class); + when(biomeWriter.biomeIdFor(anyString())).thenReturn(1); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.biomeWriter()).thenReturn(biomeWriter); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void coldMainThreadQueryUsesNaturalHostTerrain() { + Engine engine = mock(Engine.class); + EngineMantle mantle = mock(EngineMantle.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getMantle()).thenReturn(mantle); + when(engine.getComplex()).thenReturn(complex); + when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(true); + when(complex.naturalTrueHeight(12, -8)).thenReturn(42); + when(mantle.getFluidHeight()).thenReturn(50); + + assertEquals(42, Engine.hostHeight(engine, 12, -8, true)); + assertEquals(50, Engine.hostHeight(engine, 12, -8, false)); + verify(mantle, never()).trueHeight(12, -8); + verify(mantle, never()).getFluidHeight(12, -8); + } + + @Test + public void plannedQueryUsesHostColumnTerrainAndFluid() { + Engine engine = mock(Engine.class); + EngineMantle mantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(mantle); + when(mantle.trueHeight(3, 7)).thenReturn(45); + when(mantle.getFluidHeight(3, 7)).thenReturn(48); + + assertEquals(45, Engine.hostHeight(engine, 3, 7, true)); + assertEquals(48, Engine.hostHeight(engine, 3, 7, false)); + } + + @Test + public void yAwareRegionUsesTheOwningStackLayer() { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + IrisRegion expected = mock(IrisRegion.class); + when(engine.getDimensionStackContext()).thenReturn(stack); + when(stack.getLayout(12, -8)).thenReturn(layout); + when(layout.layerAt(96)).thenReturn(layer); + when(layer.region()).thenReturn(expected); + + assertEquals(expected, engine.getRegion(12, 96, -8)); + } + + @Test + public void stackQueryConsumersUseCacheAwareLayouts() { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer bottom = mock(DimensionStackLayout.Layer.class); + DimensionStackLayout.Layer upper = mock(DimensionStackLayout.Layer.class); + IrisRegion region = mock(IrisRegion.class); + IrisBiome biome = mock(IrisBiome.class); + when(engine.getDimensionStackContext()).thenReturn(stack); + when(stack.getLayout(12, -8)).thenReturn(layout); + when(layout.layersBottomToTop()).thenReturn(List.of(bottom, upper)); + when(layout.surfaceLayer()).thenReturn(upper); + when(layout.layerAt(96)).thenReturn(upper); + when(upper.region()).thenReturn(region); + when(upper.biome()).thenReturn(biome); + + assertSame(region, engine.getRegion(12, -8)); + assertSame(biome, engine.getSurfaceBiome(12, -8)); + assertSame(biome, engine.getBiome(12, 96, -8)); + assertSame(biome, engine.getBiomeOrMantle(12, 96, -8)); + + verify(stack, times(4)).getLayout(12, -8); + verify(stack, never()).sample(anyInt(), anyInt()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java index 91446c2cd..e0473e811 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java @@ -2,6 +2,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionTerrainContext; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; @@ -22,6 +24,7 @@ import org.junit.Test; import java.io.File; import java.nio.file.Files; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; @@ -301,6 +304,20 @@ public class HintedLocatorTest { assertEquals(1, plan.getStrideChunks()); } + @Test + public void biomePlanIsUnprunedForStackLayerBiome() { + DimensionStackContext stackContext = mock(DimensionStackContext.class); + IrisBiome stacked = biome("stacked"); + when(engine.getDimensionStackContext()).thenReturn(stackContext); + when(engine.getAllBiomes()).thenReturn(new KList<>(stacked)); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "stacked"); + + assertTrue(plan.isPossible()); + assertNull(plan.getCoarse()); + assertEquals(1, plan.getStrideChunks()); + } + @Test public void regionPlanMatchesOnlyTargetRegionCells() { IrisRegion regionA = region("reg_a"); @@ -326,6 +343,24 @@ public class HintedLocatorTest { assertFalse(plan.isPossible()); } + @Test + public void regionPlanIsUnprunedForStackLayerRegion() { + DimensionStackContext stackContext = mock(DimensionStackContext.class); + DimensionTerrainContext terrainContext = mock(DimensionTerrainContext.class); + IrisDimension stackedDimension = mock(IrisDimension.class); + IrisRegion stackedRegion = region("stacked_region"); + when(engine.getDimensionStackContext()).thenReturn(stackContext); + when(stackContext.getLayersTopToBottom()).thenReturn(List.of(terrainContext)); + when(terrainContext.getDimension()).thenReturn(stackedDimension); + when(stackedDimension.getAllRegions(terrainContext)).thenReturn(new KList<>(stackedRegion)); + + HintedLocator.SearchPlan plan = HintedLocator.regionPlan(engine, "stacked_region"); + + assertTrue(plan.isPossible()); + assertNull(plan.getCoarse()); + assertEquals(1, plan.getStrideChunks()); + } + @Test public void objectPlanPrunesToHostBiomes() { IrisRegion regionA = region("reg_a"); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifierStackTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifierStackTest.java new file mode 100644 index 000000000..2a60fa76f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifierStackTest.java @@ -0,0 +1,22 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.DimensionStackLayout; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisFloatingChildBiomeModifierStackTest { + @Test + public void floatingBiomeMarkersSkipStackProtectedCells() { + DimensionStackLayout layout = mock(DimensionStackLayout.class); + when(layout.isHostFeatureProtectedY(60)).thenReturn(true); + when(layout.isHostFeatureProtectedY(30)).thenReturn(false); + + assertFalse(IrisFloatingChildBiomeModifier.shouldWriteHostBiomeMarker(layout, 60)); + assertTrue(IrisFloatingChildBiomeModifier.shouldWriteHostBiomeMarker(layout, 30)); + assertTrue(IrisFloatingChildBiomeModifier.shouldWriteHostBiomeMarker(null, 60)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java new file mode 100644 index 000000000..ed445edb6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLayerSlopeSourceTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public final class IrisBiomeLayerSlopeSourceTest { + @Test + public void explicitSlopeStreamControlsSurfacePaletteSelection() { + assertExplicitSlopeStreamControlsSurfacePaletteSelection(false); + } + + @Test + public void explicitSlopeStreamControlsLockedSurfacePaletteSelection() { + assertExplicitSlopeStreamControlsSurfacePaletteSelection(true); + } + + @SuppressWarnings("unchecked") + private static void assertExplicitSlopeStreamControlsSurfacePaletteSelection(boolean lockLayers) { + IrisData data = mock(IrisData.class); + IrisComplex hostComplex = mock(IrisComplex.class); + ProceduralStream hostSlope = mock(ProceduralStream.class); + ProceduralStream sourceSlope = mock(ProceduralStream.class); + IrisBiomePaletteLayer paletteLayer = mock(IrisBiomePaletteLayer.class); + CNG heightGenerator = mock(CNG.class); + PlatformBlockState surfaceBlock = mock(PlatformBlockState.class); + RNG rng = new RNG(19L); + + IrisBiome biome = new IrisBiome(); + biome.setLockLayers(lockLayers); + KList paletteLayers = new KList<>(); + paletteLayers.add(paletteLayer); + biome.setLayers(paletteLayers); + + when(hostComplex.getSlopeStream()).thenReturn(hostSlope); + when(hostSlope.getDouble(12D, -8D)).thenReturn(0D); + when(sourceSlope.getDouble(12D, -8D)).thenReturn(5D); + when(paletteLayer.getZoom()).thenReturn(1D); + when(paletteLayer.getMinHeight()).thenReturn(1); + when(paletteLayer.getMaxHeight()).thenReturn(1); + when(paletteLayer.getSlopeCondition()).thenReturn(new IrisSlopeClip(5D, 5D)); + when(paletteLayer.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator); + when(heightGenerator.fit(1, 1, 12D, -8D)).thenReturn(1); + when(paletteLayer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock); + + KList hostLayers = biome.generateLayers( + new IrisDimension(), 12D, -8D, rng, 1, 32, data, hostComplex); + KList sourceLayers = biome.generateLayersWithSlope( + new IrisDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope); + + assertTrue(hostLayers.isEmpty()); + assertEquals(1, sourceLayers.size()); + assertSame(surfaceBlock, sourceLayers.get(0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java index 0f2906e41..836955f41 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java @@ -16,6 +16,43 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisDimensionReachableBiomesTest { + @Test + @SuppressWarnings("unchecked") + public void includesOutOfPoolFocusBiomeAndRegion() { + IrisDimension dimension = new IrisDimension() + .setRegions(new KList<>("declared")) + .setFocus("focused-biome") + .setFocusRegion("focused-region"); + IrisRegion declared = new IrisRegion().setLandBiomes(new KList<>("declared-biome")); + IrisRegion focused = new IrisRegion().setLandBiomes(new KList<>("focused-region-biome")); + declared.setLoadKey("declared"); + focused.setLoadKey("focused-region"); + IrisBiome declaredBiome = biome("declared-biome"); + IrisBiome focusedBiome = biome("focused-biome"); + IrisBiome focusedRegionBiome = biome("focused-region-biome"); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("declared")).thenReturn(declared); + when(regionLoader.load("focused-region")).thenReturn(focused); + when(biomeLoader.load("declared-biome")).thenReturn(declaredBiome); + when(biomeLoader.load("focused-biome")).thenReturn(focusedBiome); + when(biomeLoader.load("focused-region-biome")).thenReturn(focusedRegionBiome); + + Set regionKeys = dimension.getAllRegions(() -> data).stream() + .map(IrisRegion::getLoadKey) + .collect(Collectors.toSet()); + Set biomeKeys = dimension.getReachableBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey) + .collect(Collectors.toSet()); + + assertEquals(Set.of("declared", "focused-region"), regionKeys); + assertEquals(Set.of("declared-biome", "focused-biome", "focused-region-biome"), biomeKeys); + } + @Test @SuppressWarnings("unchecked") public void includesExactRecursiveGenerationClosure() { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java index ae0c15bf6..3585da9f2 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java @@ -20,4 +20,20 @@ public class IrisDimensionTypeKeyTest { assertEquals("worlds_my_pack", dimension.getDimensionTypeKey()); } + + @Test + public void customBiomeKeyPreservesFlatDimensionNamespace() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("Overworld"); + + assertEquals("overworld:aurora", dimension.getCustomBiomeKey("Aurora")); + } + + @Test + public void customBiomeKeyMapsRecursiveDimensionPath() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("Layers/Sky"); + + assertEquals("layers:sky/aurora", dimension.getCustomBiomeKey("Aurora")); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsBiomeTest.java new file mode 100644 index 000000000..b4040b7a0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsBiomeTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisWorld; +import org.bukkit.Location; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EngineBukkitOpsBiomeTest { + @Test + public void mantleBiomeLookupConvertsWorldYToInternalY() { + Engine engine = mock(Engine.class); + IrisWorld world = IrisWorld.builder().minHeight(-64).maxHeight(320).build(); + IrisBiome biome = mock(IrisBiome.class); + Location location = mock(Location.class); + when(engine.getWorld()).thenReturn(world); + when(location.getBlockX()).thenReturn(12); + when(location.getBlockY()).thenReturn(-25); + when(location.getBlockZ()).thenReturn(-7); + when(engine.getBiomeOrMantle(12, 39, -7)).thenReturn(biome); + + assertSame(biome, EngineBukkitOps.getBiomeOrMantle(engine, location)); + verify(engine).getBiomeOrMantle(12, 39, -7); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java index 5d5b26b06..60109b857 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -33,6 +33,7 @@ import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.matter.MatterUpdate; @@ -3763,7 +3764,7 @@ public final class HydrologyPackProbe { if (custom == null || custom.getId() == null || custom.getId().isBlank()) { throw new IllegalStateException("Generated custom surface Iris biome resolved no custom biome id."); } - return dimensionKey + ":" + custom.getId(); + return IrisDimension.customBiomeKey(dimensionKey, custom.getId()); } private static void verifyCaveWitness( diff --git a/probe/src/test/java/art/arcane/iris/probe/DimensionStackSuperflatProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/DimensionStackSuperflatProbeTest.java new file mode 100644 index 000000000..7bcfd1a3c --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/DimensionStackSuperflatProbeTest.java @@ -0,0 +1,437 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import java.io.File; +import java.net.URL; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class DimensionStackSuperflatProbeTest { + private static final long SEED = 1_337L; + private static final long ALTERNATE_SEED = 8_675_309L; + private static final int WORLD_HEIGHT = 128; + private static final int TERRAIN_HEIGHT = 15; + private static final int SPACER = 8; + private static final int BLEND_AMPLITUDE = 4; + private static final String AIR = "minecraft:air"; + private static final String BOTTOM_ROCK = "minecraft:blue_concrete"; + private static final String BOTTOM_SURFACE = "minecraft:light_blue_wool"; + private static final String MIDDLE_ROCK = "minecraft:red_concrete"; + private static final String MIDDLE_SURFACE = "minecraft:pink_wool"; + private static final String TOP_ROCK = "minecraft:green_concrete"; + private static final String TOP_SURFACE = "minecraft:lime_wool"; + private static final String FLUID_ROCK = "minecraft:purple_concrete"; + private static final String FLUID_SURFACE = "minecraft:yellow_wool"; + private static final String FLUID = "minecraft:water"; + private static final String FLUID_SEA_LAYER = "minecraft:ice"; + private static final String BOTTOM_BIOME = "minecraft:plains"; + private static final String MIDDLE_BIOME = "minecraft:desert"; + private static final String TOP_BIOME = "minecraft:forest"; + private static final List EXACT_CHUNKS = List.of( + new ChunkCoordinate(-1, -1), + new ChunkCoordinate(0, 0), + new ChunkCoordinate(1, 1) + ); + private static final List BLENDED_CHUNKS = List.of( + new ChunkCoordinate(-1, 0), + new ChunkCoordinate(0, 0), + new ChunkCoordinate(1, 0), + new ChunkCoordinate(0, 1) + ); + + @Test + public void stackedSuperflatDimensionsKeepExactUprightBandsAcrossSignedChunks() throws Exception { + File pack = packFixture(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + pack, "stack-root", "[dimension-stack-superflat-test]")) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + SEED, false, "exact-stack")) { + Engine engine = session.engine(); + assertHostEnvelopeAndSourceBounds(engine); + for (ChunkCoordinate coordinate : EXACT_CHUNKS) { + RealPackProbeSupport.GeneratedChunk chunk = RealPackProbeSupport.generateChunk( + engine, coordinate.x(), coordinate.z()); + assertExactChunk(chunk); + assertExactBiomes(chunk); + } + for (ChunkCoordinate coordinate : EXACT_CHUNKS) { + int blockX = coordinate.x() << 4; + int blockZ = coordinate.z() << 4; + assertEquals("height query must resolve the top stacked surface at " + + blockX + "," + blockZ, + 63, engine.getHeight(blockX, blockZ, false)); + } + } + } + } + + @Test + public void outOfPoolStackFocusRegionIdentityIsStableAcrossEngines() throws Exception { + File pack = packFixture(); + String first; + String repeated; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + pack, "stack-root", "[dimension-stack-focus-region-test]")) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + SEED, false, "focus-region-first")) { + first = session.engine().getRegion(0, 0).getLoadKey(); + } + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + SEED, false, "focus-region-repeated")) { + repeated = session.engine().getRegion(0, 0).getLoadKey(); + } + } + + assertEquals(first, repeated); + } + + @Test + public void blendedStackProducesDeterministicBoundedSmoothGapsAcrossChunkSeams() throws Exception { + File pack = packFixture(); + Map first; + Map repeated; + Map alternateSeed; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + pack, "stack-blended", "[dimension-stack-blended-test]")) { + first = captureBlendedColumns(workspace, SEED, "first"); + repeated = captureBlendedColumns(workspace, SEED, "repeated"); + alternateSeed = captureBlendedColumns(workspace, ALTERNATE_SEED, "alternate-seed"); + } + + assertEquals("same seed must reproduce every blended stack boundary", first, repeated); + assertDifferentSeedLayouts(first, alternateSeed); + assertGapVariation(first, ColumnLayout::middleGap, "middle"); + assertGapVariation(first, ColumnLayout::topGap, "top"); + assertChunkSeam(first, -1, 0, 0, 0); + assertChunkSeam(first, 15, 0, 16, 0); + assertChunkSeam(first, 0, 15, 0, 16); + } + + @Test + public void stackedFluidLayerKeepsTerrainAndFluidHeightsAndUsesItsOwnPalettes() throws Exception { + File pack = packFixture(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + pack, "stack-fluid", "[dimension-stack-fluid-test]")) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + SEED, false, "fluid-stack")) { + Engine engine = session.engine(); + DimensionStackContext stackContext = requireStackContext(engine); + DimensionStackLayout layout = stackContext.sample(0, 0); + DimensionStackLayout.Layer fluidLayer = layout.layersTopToBottom().get(0); + + assertEquals("fluid", fluidLayer.terrainContext().getDimension().getLoadKey()); + assertEquals(28, fluidLayer.surfaceY()); + assertEquals(36, fluidLayer.fluidY()); + assertEquals(36, fluidLayer.contentTopY()); + assertTrue(fluidLayer.visible()); + assertTrue(fluidLayer.fluidY() > fluidLayer.surfaceY()); + assertEquals(28, stackContext.getStackTerrainHeight(0, 0)); + assertEquals(36, stackContext.getStackTopHeight(0, 0)); + assertEquals(28, engine.getHeight(0, 0, true)); + assertEquals(36, engine.getHeight(0, 0, false)); + + RealPackProbeSupport.GeneratedChunk chunk = RealPackProbeSupport.generateChunk(engine, 0, 0); + assertRange(chunk, 0, 0, 0, 14, BOTTOM_ROCK); + assertBlock(chunk, 0, 0, 15, BOTTOM_SURFACE); + assertRange(chunk, 0, 0, 16, 23, AIR); + assertRange(chunk, 0, 0, 24, 27, FLUID_ROCK); + assertBlock(chunk, 0, 0, 28, FLUID_SURFACE); + assertRange(chunk, 0, 0, 29, 35, FLUID); + assertBlock(chunk, 0, 0, 36, FLUID_SEA_LAYER); + assertRange(chunk, 0, 0, 37, WORLD_HEIGHT - 1, AIR); + } + } + } + + @Test + public void whollyCeilingClippedLayerDoesNotRaiseEngineOrRenderedContextHeights() throws Exception { + File pack = packFixture(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + pack, "stack-clipped", "[dimension-stack-clipped-test]")) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + SEED, false, "clipped-stack")) { + Engine engine = session.engine(); + DimensionStackContext stackContext = requireStackContext(engine); + DimensionStackLayout layout = stackContext.sample(0, 0); + DimensionStackLayout.Layer bottomLayer = layout.layersBottomToTop().get(0); + DimensionStackLayout.Layer clippedLayer = layout.layersBottomToTop().get(1); + + assertEquals(-64, engine.getMinHeight()); + assertEquals(64, engine.getHeight()); + assertTrue(bottomLayer.visible()); + assertFalse(clippedLayer.visible()); + assertEquals(80, clippedLayer.localBaseY()); + assertEquals(95, layout.stackTerrainTopY()); + assertEquals(95, layout.stackTopY()); + assertEquals(15, layout.clippedStackTerrainTopY()); + assertEquals(15, layout.clippedStackTopY()); + assertEquals(15, stackContext.getStackTerrainHeight(0, 0)); + assertEquals(15, stackContext.getStackTopHeight(0, 0)); + assertEquals(15, engine.getHeight(0, 0, true)); + assertEquals(15, engine.getHeight(0, 0, false)); + assertEquals("stack-clipped", stackContext.getLayerAt(0, 63, 0) + .terrainContext().getDimension().getLoadKey()); + } + } + } + + private static Map captureBlendedColumns( + RealPackProbeSupport.Workspace workspace, + long seed, + String runLabel + ) throws Exception { + LinkedHashMap layouts = new LinkedHashMap<>(); + try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, runLabel)) { + Engine engine = session.engine(); + for (ChunkCoordinate coordinate : BLENDED_CHUNKS) { + RealPackProbeSupport.GeneratedChunk chunk = RealPackProbeSupport.generateChunk( + engine, coordinate.x(), coordinate.z()); + int minimumX = coordinate.x() << 4; + int minimumZ = coordinate.z() << 4; + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int blockX = minimumX + localX; + int blockZ = minimumZ + localZ; + ColumnLayout layout = assertBlendedColumn(chunk, blockX, blockZ); + assertEquals("height query must match blended top surface at " + + blockX + "," + blockZ, + layout.topSurface(), engine.getHeight(blockX, blockZ, false)); + layouts.put(new ColumnCoordinate(blockX, blockZ), layout); + } + } + } + } + return Map.copyOf(layouts); + } + + private static void assertHostEnvelopeAndSourceBounds(Engine engine) { + assertEquals(-64, engine.getMinHeight()); + assertEquals(WORLD_HEIGHT, engine.getHeight()); + DimensionStackLayout layout = requireStackContext(engine).sample(0, 0); + List layers = layout.layersBottomToTop(); + assertLayerBounds(layers.get(0), "stack-root", -64, 64, 128); + assertLayerBounds(layers.get(1), "middle", -32, 32, 64); + assertLayerBounds(layers.get(2), "top", 0, 64, 64); + } + + private static void assertLayerBounds( + DimensionStackLayout.Layer layer, + String dimensionKey, + int minimumY, + int maximumY, + int localHeight + ) { + assertEquals(dimensionKey, layer.terrainContext().getDimension().getLoadKey()); + assertEquals(minimumY, layer.terrainContext().getDimension().getMinHeight()); + assertEquals(maximumY, layer.terrainContext().getDimension().getMaxHeight()); + assertEquals(localHeight, layer.terrainContext().getLocalHeight()); + } + + private static DimensionStackContext requireStackContext(Engine engine) { + DimensionStackContext stackContext = engine.getDimensionStackContext(); + assertNotNull("dimension stack context is missing", stackContext); + return stackContext; + } + + private static void assertExactChunk(RealPackProbeSupport.GeneratedChunk chunk) { + int minimumX = chunk.chunkX() << 4; + int minimumZ = chunk.chunkZ() << 4; + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int blockX = minimumX + localX; + int blockZ = minimumZ + localZ; + assertRange(chunk, blockX, blockZ, 0, 14, BOTTOM_ROCK); + assertBlock(chunk, blockX, blockZ, 15, BOTTOM_SURFACE); + assertRange(chunk, blockX, blockZ, 16, 23, AIR); + assertRange(chunk, blockX, blockZ, 24, 38, MIDDLE_ROCK); + assertBlock(chunk, blockX, blockZ, 39, MIDDLE_SURFACE); + assertRange(chunk, blockX, blockZ, 40, 47, AIR); + assertRange(chunk, blockX, blockZ, 48, 62, TOP_ROCK); + assertBlock(chunk, blockX, blockZ, 63, TOP_SURFACE); + assertRange(chunk, blockX, blockZ, 64, WORLD_HEIGHT - 1, AIR); + } + } + } + + private static void assertExactBiomes(RealPackProbeSupport.GeneratedChunk chunk) { + int blockX = chunk.chunkX() << 4; + int blockZ = chunk.chunkZ() << 4; + assertEquals(BOTTOM_BIOME, chunk.biomeAt(blockX, 15, blockZ).key()); + assertEquals(BOTTOM_BIOME, chunk.biomeAt(blockX, 20, blockZ).key()); + assertEquals(MIDDLE_BIOME, chunk.biomeAt(blockX, 30, blockZ).key()); + assertEquals(TOP_BIOME, chunk.biomeAt(blockX, 55, blockZ).key()); + } + + private static ColumnLayout assertBlendedColumn( + RealPackProbeSupport.GeneratedChunk chunk, + int blockX, + int blockZ + ) { + assertRange(chunk, blockX, blockZ, 0, TERRAIN_HEIGHT - 1, BOTTOM_ROCK); + assertBlock(chunk, blockX, blockZ, TERRAIN_HEIGHT, BOTTOM_SURFACE); + + int middleBase = findBlock(chunk, blockX, blockZ, TERRAIN_HEIGHT + 1, MIDDLE_ROCK); + int middleSurface = findBlock(chunk, blockX, blockZ, middleBase + 1, MIDDLE_SURFACE); + int topBase = findBlock(chunk, blockX, blockZ, middleSurface + 1, TOP_ROCK); + int topSurface = findBlock(chunk, blockX, blockZ, topBase + 1, TOP_SURFACE); + ColumnLayout layout = new ColumnLayout(middleBase, middleSurface, topBase, topSurface); + + assertRange(chunk, blockX, blockZ, TERRAIN_HEIGHT + 1, middleBase - 1, AIR); + assertRange(chunk, blockX, blockZ, middleBase, middleSurface - 1, MIDDLE_ROCK); + assertRange(chunk, blockX, blockZ, middleSurface + 1, topBase - 1, AIR); + assertRange(chunk, blockX, blockZ, topBase, topSurface - 1, TOP_ROCK); + assertRange(chunk, blockX, blockZ, topSurface + 1, WORLD_HEIGHT - 1, AIR); + assertEquals("middle band must remain upright and sixteen blocks thick at " + + blockX + "," + blockZ, + TERRAIN_HEIGHT, middleSurface - middleBase); + assertEquals("top band must remain upright and sixteen blocks thick at " + + blockX + "," + blockZ, + TERRAIN_HEIGHT, topSurface - topBase); + assertGapBounds(layout.middleGap(), blockX, blockZ, "middle"); + assertGapBounds(layout.topGap(), blockX, blockZ, "top"); + return layout; + } + + private static void assertGapBounds(int gap, int blockX, int blockZ, String layer) { + int minimum = SPACER - BLEND_AMPLITUDE; + int maximum = SPACER + BLEND_AMPLITUDE; + assertTrue(layer + " gap at " + blockX + "," + blockZ + " was " + gap + + ", expected " + minimum + ".." + maximum, + gap >= minimum && gap <= maximum); + } + + private static void assertGapVariation( + Map layouts, + GapSelector selector, + String layer + ) { + Set gaps = new HashSet<>(); + for (ColumnLayout layout : layouts.values()) { + gaps.add(selector.gap(layout)); + } + assertTrue(layer + " blend must produce more than one gap width, observed " + gaps, + gaps.size() > 1); + } + + private static void assertDifferentSeedLayouts( + Map first, + Map second + ) { + assertEquals(first.keySet(), second.keySet()); + int changedColumns = 0; + for (Map.Entry entry : first.entrySet()) { + ColumnLayout alternate = second.get(entry.getKey()); + ColumnLayout original = entry.getValue(); + if (original.middleGap() != alternate.middleGap() + || original.topGap() != alternate.topGap()) { + changedColumns++; + } + } + assertTrue("different seeds must change at least one blended boundary across the sampled area", + changedColumns > 0); + } + + private static void assertChunkSeam( + Map layouts, + int firstX, + int firstZ, + int secondX, + int secondZ + ) { + ColumnLayout first = layouts.get(new ColumnCoordinate(firstX, firstZ)); + ColumnLayout second = layouts.get(new ColumnCoordinate(secondX, secondZ)); + assertNotNull("missing first chunk-seam column", first); + assertNotNull("missing second chunk-seam column", second); + assertTrue("middle gap jumped across chunk seam " + firstX + "," + firstZ + + " -> " + secondX + "," + secondZ, + Math.abs(first.middleGap() - second.middleGap()) <= 1); + assertTrue("top gap jumped across chunk seam " + firstX + "," + firstZ + + " -> " + secondX + "," + secondZ, + Math.abs(first.topGap() - second.topGap()) <= 1); + } + + private static int findBlock( + RealPackProbeSupport.GeneratedChunk chunk, + int blockX, + int blockZ, + int startY, + String expected + ) { + for (int y = startY; y < WORLD_HEIGHT; y++) { + if (expected.equals(blockKey(chunk.blockAt(blockX, y, blockZ)))) { + return y; + } + } + throw new AssertionError("Missing " + expected + " at " + blockX + "," + blockZ + + " from Y " + startY); + } + + private static void assertRange( + RealPackProbeSupport.GeneratedChunk chunk, + int blockX, + int blockZ, + int minimumY, + int maximumY, + String expected + ) { + for (int y = minimumY; y <= maximumY; y++) { + assertBlock(chunk, blockX, blockZ, y, expected); + } + } + + private static void assertBlock( + RealPackProbeSupport.GeneratedChunk chunk, + int blockX, + int blockZ, + int y, + String expected + ) { + assertEquals("unexpected block at " + blockX + "," + y + "," + blockZ, + expected, blockKey(chunk.blockAt(blockX, y, blockZ))); + } + + private static String blockKey(PlatformBlockState state) { + return state == null || state.isAir() ? AIR : state.key(); + } + + private static File packFixture() throws Exception { + URL resource = DimensionStackSuperflatProbeTest.class.getClassLoader() + .getResource("dimension-stack-superflat"); + assertNotNull("dimension stack fixture is missing", resource); + return new File(resource.toURI()); + } + + private record ChunkCoordinate(int x, int z) { + } + + private record ColumnCoordinate(int x, int z) { + } + + private record ColumnLayout(int middleBase, int middleSurface, int topBase, int topSurface) { + int middleGap() { + return middleBase - TERRAIN_HEIGHT - 1; + } + + int topGap() { + return topBase - middleSurface - 1; + } + } + + @FunctionalInterface + private interface GapSelector { + int gap(ColumnLayout layout); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java index 5e6bab082..db4ae470e 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java @@ -12,6 +12,8 @@ import art.arcane.iris.engine.hydrology.RiverFootprint; import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; @@ -251,6 +253,24 @@ public final class HydrologyPackProbeTest { ); } + @Test + public void generatedCustomSurfaceBiomeUsesRecursiveDimensionRegistryPath() { + IrisBiome biome = new IrisBiome() + .setCustomDerivitives(new KList<>(new IrisBiomeCustom().setId("Aurora"))); + + assertEquals( + "layers:sky/aurora", + HydrologyPackProbe.generatedSurfaceBiomeKey( + biome, + null, + 19L, + "Layers/Sky", + 32, + -48 + ) + ); + } + @Test public void generatedWitnessDescriptorRoundTripsUnsignedIdsAndNegativeCoordinates() { HydrologyPackProbe.GeneratedWitnessDescriptor descriptor = diff --git a/probe/src/test/resources/dimension-stack-superflat/biomes/bottom.json b/probe/src/test/resources/dimension-stack-superflat/biomes/bottom.json new file mode 100644 index 000000000..431e88d35 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/biomes/bottom.json @@ -0,0 +1,19 @@ +{ + "name": "Bottom", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [{ "block": "minecraft:light_blue_wool" }] + } + ], + "generators": [ + { + "generator": "flat", + "min": 15, + "max": 15 + } + ] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/biomes/fluid.json b/probe/src/test/resources/dimension-stack-superflat/biomes/fluid.json new file mode 100644 index 000000000..b7e4ea501 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/biomes/fluid.json @@ -0,0 +1,26 @@ +{ + "name": "Fluid", + "derivative": "minecraft:ocean", + "vanillaDerivative": "minecraft:ocean", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [{ "block": "minecraft:yellow_wool" }] + } + ], + "seaLayers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [{ "block": "minecraft:ice" }] + } + ], + "generators": [ + { + "generator": "flat", + "min": -8, + "max": -8 + } + ] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/biomes/middle.json b/probe/src/test/resources/dimension-stack-superflat/biomes/middle.json new file mode 100644 index 000000000..04ada5f56 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/biomes/middle.json @@ -0,0 +1,19 @@ +{ + "name": "Middle", + "derivative": "minecraft:desert", + "vanillaDerivative": "minecraft:desert", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [{ "block": "minecraft:pink_wool" }] + } + ], + "generators": [ + { + "generator": "flat", + "min": 15, + "max": 15 + } + ] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/biomes/top.json b/probe/src/test/resources/dimension-stack-superflat/biomes/top.json new file mode 100644 index 000000000..25d952e96 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/biomes/top.json @@ -0,0 +1,19 @@ +{ + "name": "Top", + "derivative": "minecraft:forest", + "vanillaDerivative": "minecraft:forest", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [{ "block": "minecraft:lime_wool" }] + } + ], + "generators": [ + { + "generator": "flat", + "min": 15, + "max": 15 + } + ] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/fluid.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/fluid.json new file mode 100644 index 000000000..83f3faf13 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/fluid.json @@ -0,0 +1,20 @@ +{ + "name": "Fluid Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["fluid"], + "focusRegion": "fluid", + "focus": "fluid", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -32, "max": 32 }, + "logicalHeight": 64, + "fluidHeight": -20, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:purple_concrete" }] + }, + "fluidPalette": { + "palette": [{ "block": "minecraft:water" }] + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/middle.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/middle.json new file mode 100644 index 000000000..173a62eb7 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/middle.json @@ -0,0 +1,17 @@ +{ + "name": "Middle Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["top"], + "focusRegion": "middle", + "focus": "", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -32, "max": 32 }, + "logicalHeight": 64, + "fluidHeight": -32, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:red_concrete" }] + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-blended.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-blended.json new file mode 100644 index 000000000..41a329e1d --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-blended.json @@ -0,0 +1,25 @@ +{ + "name": "Blended Stacked Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["bottom"], + "focusRegion": "bottom", + "focus": "bottom", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -64, "max": 64 }, + "logicalHeight": 128, + "fluidHeight": -64, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:blue_concrete" }] + }, + "dimensionStack": { + "dimensions": ["top", "middle", "stack-blended"], + "spacer": 8, + "blend": { + "amplitude": 4, + "style": { "style": "SIMPLEX", "zoom": 4 } + } + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-clipped.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-clipped.json new file mode 100644 index 000000000..7b7ab43fa --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-clipped.json @@ -0,0 +1,21 @@ +{ + "name": "Clipped Stacked Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["bottom"], + "focusRegion": "bottom", + "focus": "bottom", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -64, "max": 0 }, + "logicalHeight": 64, + "fluidHeight": -64, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:blue_concrete" }] + }, + "dimensionStack": { + "dimensions": ["top", "stack-clipped"], + "spacer": 64 + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-fluid.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-fluid.json new file mode 100644 index 000000000..ea6086230 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-fluid.json @@ -0,0 +1,21 @@ +{ + "name": "Fluid Stacked Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["bottom"], + "focusRegion": "bottom", + "focus": "bottom", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -64, "max": 64 }, + "logicalHeight": 128, + "fluidHeight": -64, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:blue_concrete" }] + }, + "dimensionStack": { + "dimensions": ["fluid", "stack-fluid"], + "spacer": 8 + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-root.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-root.json new file mode 100644 index 000000000..21394ab1b --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/stack-root.json @@ -0,0 +1,21 @@ +{ + "name": "Stacked Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["bottom"], + "focusRegion": "bottom", + "focus": "bottom", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": -64, "max": 64 }, + "logicalHeight": 128, + "fluidHeight": -64, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:blue_concrete" }] + }, + "dimensionStack": { + "dimensions": ["top", "middle", "stack-root"], + "spacer": 8 + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/dimensions/top.json b/probe/src/test/resources/dimension-stack-superflat/dimensions/top.json new file mode 100644 index 000000000..03a103364 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/dimensions/top.json @@ -0,0 +1,17 @@ +{ + "name": "Top Superflat", + "version": 1, + "mode": { "type": "SUPERFLAT" }, + "regions": ["middle"], + "focusRegion": "middle", + "focus": "top", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { "min": 0, "max": 64 }, + "logicalHeight": 64, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [{ "block": "minecraft:green_concrete" }] + } +} diff --git a/probe/src/test/resources/dimension-stack-superflat/generators/flat.json b/probe/src/test/resources/dimension-stack-superflat/generators/flat.json new file mode 100644 index 000000000..2d0df66dc --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/generators/flat.json @@ -0,0 +1,10 @@ +{ + "interpolator": { "function": "NONE", "horizontalScale": 1 }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { "style": "FLAT" } + } + ] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/regions/bottom.json b/probe/src/test/resources/dimension-stack-superflat/regions/bottom.json new file mode 100644 index 000000000..047352e59 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/regions/bottom.json @@ -0,0 +1,6 @@ +{ + "name": "Bottom", + "landBiomes": ["bottom"], + "seaBiomes": ["bottom"], + "shoreBiomes": ["bottom"] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/regions/fluid.json b/probe/src/test/resources/dimension-stack-superflat/regions/fluid.json new file mode 100644 index 000000000..0a1c61220 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/regions/fluid.json @@ -0,0 +1,6 @@ +{ + "name": "Fluid", + "landBiomes": ["fluid"], + "seaBiomes": ["fluid"], + "shoreBiomes": ["fluid"] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/regions/middle.json b/probe/src/test/resources/dimension-stack-superflat/regions/middle.json new file mode 100644 index 000000000..40da6eeb8 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/regions/middle.json @@ -0,0 +1,6 @@ +{ + "name": "Middle", + "landBiomes": ["middle"], + "seaBiomes": ["middle"], + "shoreBiomes": ["middle"] +} diff --git a/probe/src/test/resources/dimension-stack-superflat/regions/top.json b/probe/src/test/resources/dimension-stack-superflat/regions/top.json new file mode 100644 index 000000000..11e638c71 --- /dev/null +++ b/probe/src/test/resources/dimension-stack-superflat/regions/top.json @@ -0,0 +1,6 @@ +{ + "name": "Top", + "landBiomes": ["top"], + "seaBiomes": ["top"], + "shoreBiomes": ["top"] +}