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 9720bf82f..1ff76da7c 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 @@ -2,6 +2,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.nativegen.NativeGenerationWriteGuard; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; import art.arcane.iris.engine.object.IrisBiome; @@ -44,7 +45,6 @@ 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 @@ -299,7 +299,7 @@ final class ImportedFeatureStage { surfaceFirstFreeY, floorFirstFreeY, stackContext != null, - importedFeatureProtection(staticObjects, stackContext, staticMinY)); + NativeGenerationWriteGuard.protectedPositions(staticObjects, stackContext, staticMinY)); try { for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { @@ -317,30 +317,6 @@ 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, 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 8f6c0690b..d9857b2db 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 @@ -7,7 +7,6 @@ import java.util.Optional; import art.arcane.iris.nativegen.NativeGenerationWriteGuard; import java.util.function.LongPredicate; 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.EngineLifecycleTasks; import art.arcane.iris.engine.framework.GenerationSessionException; @@ -1125,7 +1124,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred chunkPos.x(), chunkPos.z(), error); } IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); - Predicate protectedPosition = nativeStructureProtection(staticObjects); + Predicate protectedPosition = NativeGenerationWriteGuard.protectedPositions( + staticObjects, engine.getDimensionStackContext(), engine.getMinHeight()); WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create( world, chunkPos, hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight(), engine.getDimensionStackContext() != null, @@ -1216,8 +1216,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight(), engine.getDimensionStackContext() != null, - nativeStructureProtection( - engine.getDimension().getStaticObjectLayer(engine.getData()))); + NativeGenerationWriteGuard.protectedPositions( + engine.getDimension().getStaticObjectLayer(engine.getData()), + engine.getDimensionStackContext(), engine.getMinHeight())); world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); try { NativeStructurePostProcessor.place(boundedWorld, structureManager, this, random, area, chunkPos, @@ -1228,28 +1229,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } } - 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) { int runtimeId = engine.getCacheID(); StructureStepCache cached = structureStepCache; diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeGenerationWriteGuardTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeGenerationWriteGuardTest.java index 46b4bb592..071fc4524 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeGenerationWriteGuardTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeGenerationWriteGuardTest.java @@ -1,13 +1,16 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; -import java.util.Optional; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisStaticObjectLayer; import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.WorldGenLevel; @@ -16,10 +19,17 @@ import net.minecraft.world.level.chunk.status.ChunkStatus; import org.junit.BeforeClass; import org.junit.Test; +import java.util.Optional; +import java.util.function.Predicate; + import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public final class NativeGenerationWriteGuardTest { @@ -29,6 +39,42 @@ public final class NativeGenerationWriteGuardTest { Bootstrap.bootStrap(); } + @Test + public void staticObjectsProtectRelativeHeightBeforeStackSampling() { + IrisStaticObjectLayer objects = mock(IrisStaticObjectLayer.class); + DimensionStackContext stack = mock(DimensionStackContext.class); + when(objects.contains(-3, 74, -7)).thenReturn(true); + + Predicate protectedPositions = NativeGenerationWriteGuard.protectedPositions(objects, stack, -64); + + assertTrue(protectedPositions.test(new BlockPos(-3, 10, -7))); + verifyNoInteractions(stack); + assertFalse(NativeGenerationWriteGuard.protectedPositions(objects, null, -64) + .test(new BlockPos(-3, 11, -7))); + } + + @Test + public void emptyStaticLayerSkipsContainsAndCachesEachSignedColumn() { + IrisStaticObjectLayer objects = mock(IrisStaticObjectLayer.class); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout negative = mock(DimensionStackLayout.class); + DimensionStackLayout positive = mock(DimensionStackLayout.class); + when(objects.isEmpty()).thenReturn(true); + when(stack.sample(-1, -1)).thenReturn(negative); + when(stack.sample(-1, 1)).thenReturn(positive); + when(negative.isHostFeatureProtectedY(64)).thenReturn(true); + when(positive.isHostFeatureProtectedY(65)).thenReturn(true); + + Predicate protectedPositions = NativeGenerationWriteGuard.protectedPositions(objects, stack, -64); + + assertTrue(protectedPositions.test(new BlockPos(-1, 0, -1))); + assertFalse(protectedPositions.test(new BlockPos(-1, 1, -1))); + assertTrue(protectedPositions.test(new BlockPos(-1, 1, 1))); + verify(stack, times(1)).sample(-1, -1); + verify(stack, times(1)).sample(-1, 1); + verify(objects, never()).contains(anyInt(), anyInt(), anyInt()); + } + @Test public void preTerrainSavedStampDistinguishesEarlierNativeStarts() { IrisEngine engine = mock(IrisEngine.class); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeGenerationWriteGuard.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeGenerationWriteGuard.java index c3fe2428e..eb1c4d170 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeGenerationWriteGuard.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeGenerationWriteGuard.java @@ -1,14 +1,22 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.DimensionStackContext; +import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.object.IrisStaticObjectLayer; +import net.minecraft.core.BlockPos; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.status.ChunkPyramid; import net.minecraft.world.level.chunk.status.ChunkStatus; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + public final class NativeGenerationWriteGuard { private NativeGenerationWriteGuard() { } @@ -43,4 +51,28 @@ public final class NativeGenerationWriteGuard { && !chunk.getPersistedStatus().isOrAfter(stage) && !engine.getComplex().allowsMantleChunkWrite(chunk.getPos().x(), chunk.getPos().z()); } + + public static Predicate protectedPositions( + 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); + }; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java index f4be1800f..6733c8c88 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java @@ -18,13 +18,13 @@ package art.arcane.iris.modded; +import art.arcane.iris.platform.BlockStateKey; import art.arcane.iris.spi.PlatformBlockState; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.tags.BlockTags; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.Property; -import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -52,7 +52,7 @@ public final class ModdedBlockState implements PlatformBlockState { this.state = state; this.parsedProperties = parsedProperties; this.key = key; - this.namespace = parseNamespace(key); + this.namespace = BlockStateKey.namespace(key); this.deferredPlacementKey = deferredPlacementKey; } @@ -101,44 +101,6 @@ public final class ModdedBlockState implements PlatformBlockState { return Objects.hash(state, deferredPlacementKey); } - private static String parseNamespace(String key) { - String base = key; - int bracket = base.indexOf('['); - if (bracket >= 0) { - base = base.substring(0, bracket); - } - int colon = base.indexOf(':'); - return colon >= 0 ? base.substring(0, colon) : "minecraft"; - } - - private static String mergeProperty(String key, String name, String value) { - int bracket = key.indexOf('['); - if (bracket < 0) { - return key + "[" + name + "=" + value + "]"; - } - String base = key.substring(0, bracket); - String body = key.substring(bracket + 1, key.lastIndexOf(']')); - LinkedHashMap properties = new LinkedHashMap<>(); - for (String entry : body.split(",")) { - int equals = entry.indexOf('='); - if (equals < 0) { - continue; - } - properties.put(entry.substring(0, equals).trim(), entry.substring(equals + 1).trim()); - } - properties.put(name, value); - StringBuilder merged = new StringBuilder(base).append('['); - boolean first = true; - for (Map.Entry property : properties.entrySet()) { - if (!first) { - merged.append(','); - } - merged.append(property.getKey()).append('=').append(property.getValue()); - first = false; - } - return merged.append(']').toString(); - } - @Override public String key() { return key; @@ -338,7 +300,7 @@ public final class ModdedBlockState implements PlatformBlockState { @Override public PlatformBlockState withProperty(String name, String value) { - String merged = mergeProperty(key, name, value); + String merged = BlockStateKey.withProperty(key, name, value); ModdedBlockState resolved = ModdedBlockResolution.strictParse(merged); return withHandle(resolved.handle(), resolved.parsedProperties()); } 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 31ae93544..c8df3c137 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 @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.DimensionStackContext; import art.arcane.iris.engine.DimensionStackLayout; +import art.arcane.iris.nativegen.NativeGenerationWriteGuard; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; @@ -62,7 +63,6 @@ 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}. @@ -353,7 +353,7 @@ final class ModdedImportedFeatureStage { surfaceFirstFreeY, floorFirstFreeY, stackContext != null, - importedFeatureProtection(staticObjects, stackContext, staticMinY)); + NativeGenerationWriteGuard.protectedPositions(staticObjects, stackContext, staticMinY)); try { for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { @@ -401,30 +401,6 @@ final class ModdedImportedFeatureStage { } } - 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); - }; - } - void evictRuntime(int runtimeIdentity) { featureTables.remove(runtimeIdentity); inertGenerations.remove(runtimeIdentity); 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 0659c02d3..679203341 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 @@ -23,7 +23,6 @@ import net.minecraft.world.level.chunk.status.ChunkStatus; import art.arcane.iris.nativegen.NativeGenerationWriteGuard; 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; @@ -397,7 +396,8 @@ final class ModdedNativeStructureStage { chunkPos.x(), chunkPos.z(), error); } IrisStaticObjectLayer staticObjects = current.getDimension().getStaticObjectLayer(current.getData()); - Predicate protectedPosition = nativeStructureProtection(current, staticObjects); + Predicate protectedPosition = NativeGenerationWriteGuard.protectedPositions( + staticObjects, current.getDimensionStackContext(), current.getMinHeight()); WorldGenLevel boundedWorld = ModdedNativeStructureWorldgenAccess.create( world, chunkPos, worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()), current.getDimensionStackContext() != null, @@ -480,9 +480,9 @@ final class ModdedNativeStructureStage { worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()), current.getDimensionStackContext() != null, - nativeStructureProtection( - current, - current.getDimension().getStaticObjectLayer(current.getData()))); + NativeGenerationWriteGuard.protectedPositions( + current.getDimension().getStaticObjectLayer(current.getData()), + current.getDimensionStackContext(), current.getMinHeight())); world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); try { NativeStructurePostProcessor.place( @@ -494,31 +494,6 @@ final class ModdedNativeStructureStage { } } - 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) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java index b10ae55e5..c1f5ed255 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java @@ -3,6 +3,7 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -91,13 +92,7 @@ public final class ModdedWorldgenIds { } private static String encode(String value) { - byte[] bytes = value.getBytes(StandardCharsets.UTF_8); - StringBuilder encoded = new StringBuilder(bytes.length * 2); - for (byte current : bytes) { - encoded.append(Character.forDigit((current >>> 4) & 0xF, 16)); - encoded.append(Character.forDigit(current & 0xF, 16)); - } - return encoded.toString(); + return HexFormat.of().formatHex(value.getBytes(StandardCharsets.UTF_8)); } private static String decode(String value) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java new file mode 100644 index 000000000..516799013 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ModdedWorldgenIdsTest { + @Test + public void scopedIdsRetainExactUtf8HexEncoding() { + assertEquals("irisworldgen:packs/5061636b2f412d5f/dimensions/c389e5b1b1/preset", + ModdedWorldgenIds.presetRef("Pack/A-_", "É山")); + assertEquals("irisworldgen:packs//dimensions//preset", ModdedWorldgenIds.presetRef("", "")); + assertEquals("irisworldgen:packs/3f/dimensions/f0a08080/dimension_type", + ModdedWorldgenIds.dimensionTypeRef("\uD800", "\uD840\uDC00")); + assertEquals(new ModdedWorldgenIds.ScopedDimension("Pack/A-_", "É山"), + ModdedWorldgenIds.scopedDimensionType( + ModdedWorldgenIds.dimensionTypeRef("Pack/A-_", "É山")).orElseThrow()); + } + + @Test + public void malformedIdentifiersRetainRejectionBehavior() { + assertTrue(ModdedWorldgenIds.scopedDimensionType( + "irisworldgen:packs/7/dimensions/61/dimension_type").isEmpty()); + assertTrue(ModdedWorldgenIds.scopedDimensionType( + "irisworldgen:packs/gg/dimensions/61/dimension_type").isEmpty()); + assertThrows(NullPointerException.class, () -> ModdedWorldgenIds.presetRef(null, "dimension")); + } +} diff --git a/build.gradle b/build.gradle index be95a213f..7aa3e00a3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,4 @@ +import art.arcane.volmit.packaging.PackagingMode import org.gradle.api.file.CopySpec import org.gradle.api.file.DeleteSpec import org.gradle.api.file.FileSystemOperations @@ -169,6 +170,7 @@ nmsBindings.each { key, value -> transitive = false } testRuntimeOnly(project(':core')) + testRuntimeOnly(rootProject.libs.caffeine) } } } @@ -790,7 +792,9 @@ pluginPackaging { policyName = 'Iris Bukkit' stripDirectories = true stripLocalVariables = true - releaseCompression = providers.gradleProperty('compactRelease').map { it.toBoolean() }.getOrElse(false) + releaseCompression = providers.gradleProperty('compactRelease') + .map { String value -> value.toBoolean() } + .getOrElse(!PackagingMode.resolve(providers).development()) prunePrefixes = ['art/arcane/volmlib/'] keepPrefixes = ['art/arcane/volmlib/util/matter/slices/'] shrinkRules = [file('packaging/shrink.pro')] diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java b/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java index d73484b3d..c535523e5 100644 --- a/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java @@ -53,22 +53,16 @@ public record CompatFinding(CompatRegistry registry, /** {@code (): at } */ public String line() { StringBuilder sb = new StringBuilder(96); - sb.append(key).append(" (").append(registry.label()).append("): ").append(action.label()); - if (!subjectType.isEmpty()) { - sb.append(' ').append(subjectType); - } - if (!subjectKey.isEmpty()) { - sb.append(' ').append(subjectKey); - } - if (!detail.isEmpty()) { - sb.append(" at ").append(detail); - } - return sb.toString(); + sb.append(key).append(" (").append(registry.label()).append("): "); + return appendSubject(sb); } /** The part after the key, for lines already grouped under the key. */ public String subjectLine() { - StringBuilder sb = new StringBuilder(64); + return appendSubject(new StringBuilder(64)); + } + + private String appendSubject(StringBuilder sb) { sb.append(action.label()); if (!subjectType.isEmpty()) { sb.append(' ').append(subjectType); diff --git a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java index 7e287f683..8c4fcb7ab 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java @@ -22,7 +22,6 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.image.IrisImageMapCompiler; import art.arcane.iris.engine.object.IrisImage; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.data.KCache; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; @@ -143,7 +142,7 @@ public class ImageResourceLoader extends ResourceLoader { for (File file : listedFiles) { if (file.isFile() && file.getName().endsWith(".png")) { - m.add(prefix + file.getName().replaceAll("\\Q.png\\E", "")); + m.add(prefix + file.getName().replace(".png", "")); } else if (file.isDirectory()) { getPNGFiles(file, prefix + file.getName() + "/", m, visitedDirectories); } @@ -164,8 +163,7 @@ public class ImageResourceLoader extends ResourceLoader { getPNGFiles(i, "", m, visitedDirectories); } - KList v = new KList<>(m); - possibleKeys = v.toArray(new String[0]); + possibleKeys = m.toArray(new String[0]); return possibleKeys; } diff --git a/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java index 25a2c14f3..e08b70b83 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java @@ -21,7 +21,6 @@ package art.arcane.iris.core.loader; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.object.matter.IrisMatterObject; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.data.KCache; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; @@ -84,7 +83,7 @@ public class MatterObjectResourceLoader extends ResourceLoader for (File file : listedFiles) { if (file.isFile() && file.getName().endsWith(".mat")) { - m.add(prefix + file.getName().replaceAll("\\Q.mat\\E", "")); + m.add(prefix + file.getName().replace(".mat", "")); } else if (file.isDirectory()) { findMatFiles(file, prefix + file.getName() + "/", m, visitedDirectories); } @@ -104,8 +103,7 @@ public class MatterObjectResourceLoader extends ResourceLoader findMatFiles(folder, "", m, visitedDirectories); } - KList v = new KList<>(m); - possibleKeys = v.toArray(new String[0]); + possibleKeys = m.toArray(new String[0]); return possibleKeys; } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index c05bbf181..0543e83b4 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -397,12 +397,11 @@ public class ResourceLoader implements MeteredCache { HashSet m = new HashSet<>(); for (File i : files) { for (File j : matchAllFiles(i, (f) -> f.getName().endsWith(".json"))) { - m.add(i.toURI().relativize(j.toURI()).getPath().replaceAll("\\Q.json\\E", "")); + m.add(i.toURI().relativize(j.toURI()).getPath().replace(".json", "")); } } - KList v = new KList<>(m); - possibleKeys = v.toArray(new String[0]); + possibleKeys = m.toArray(new String[0]); return possibleKeys; } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java index a8bba28d5..71ca995fe 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java @@ -345,20 +345,7 @@ public final class StructureTransactionWriter { if (owner != null) { verifyRecoveryClaim(transactionRoot, token.transactionId(), owner); } - Path journalPath = recoveryJournalPath(transactionRoot); - if (journalPath == null || !files.isRegularFile(journalPath)) { - throw new IOException("Missing prepared removal journal at " + transactionRoot); - } - StructureTransactionJournal journal; - try { - journal = StructureTransactionJournal.fromJson(readBoundedBytes( - journalPath, - MAX_STRUCTURE_STATE_BYTES, - "Prepared removal journal" - )); - } catch (RuntimeException e) { - throw new IOException("Invalid prepared removal journal at " + journalPath, e); - } + StructureTransactionJournal journal = readPreparedRemovalJournal(transactionRoot); if (!journal.transactionId().equals(token.transactionId())) { throw new IOException("Prepared removal journal id does not match " + token.transactionId()); } @@ -973,20 +960,7 @@ public final class StructureTransactionWriter { } private void verifyPreparedRemovalAuthority(Path transactionRoot, UUID transactionId) throws IOException { - Path journalPath = recoveryJournalPath(transactionRoot); - if (journalPath == null || !files.isRegularFile(journalPath)) { - throw new IOException("Missing prepared removal journal at " + transactionRoot); - } - StructureTransactionJournal journal; - try { - journal = StructureTransactionJournal.fromJson(readBoundedBytes( - journalPath, - MAX_STRUCTURE_STATE_BYTES, - "Prepared removal journal" - )); - } catch (RuntimeException e) { - throw new IOException("Invalid prepared removal journal at " + journalPath, e); - } + StructureTransactionJournal journal = readPreparedRemovalJournal(transactionRoot); if (!journal.transactionId().equals(transactionId) || journal.phase() != StructureTransactionJournal.Phase.PREPARED) { throw new IOException("Prepared removal journal does not match its datapack coordinator"); @@ -1015,6 +989,22 @@ public final class StructureTransactionWriter { } } + private StructureTransactionJournal readPreparedRemovalJournal(Path transactionRoot) throws IOException { + Path journalPath = recoveryJournalPath(transactionRoot); + if (journalPath == null || !files.isRegularFile(journalPath)) { + throw new IOException("Missing prepared removal journal at " + transactionRoot); + } + try { + return StructureTransactionJournal.fromJson(readBoundedBytes( + journalPath, + MAX_STRUCTURE_STATE_BYTES, + "Prepared removal journal" + )); + } catch (RuntimeException e) { + throw new IOException("Invalid prepared removal journal at " + journalPath, e); + } + } + private RecoveryClaim readRecoveryClaim(Path claimPath) throws IOException { if (!files.isRegularFile(claimPath)) { throw new IOException("Invalid external recovery claim " + claimPath); diff --git a/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java b/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java index 4e1a0bbe2..18b91cca4 100644 --- a/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java +++ b/core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java @@ -1,6 +1,5 @@ package art.arcane.iris.engine.data.cache; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Objects; import java.util.function.Function; @@ -35,9 +34,7 @@ public final class LazyBoundedCache { } entries.put(requiredKey, computed); if (entries.size() > maximumSize) { - Iterator iterator = entries.keySet().iterator(); - iterator.next(); - iterator.remove(); + entries.sequencedKeySet().removeFirst(); } return computed; } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java index c9ed90490..eaa32b8e6 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java @@ -2692,15 +2692,7 @@ public final class GenerationSemanticIndex { } } } - byte[] body = bodyBytes.toByteArray(); - CRC32 checksum = new CRC32(); - checksum.update(body); - ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); - encodedBytes.write(body); - try (DataOutputStream output = new DataOutputStream(encodedBytes)) { - output.writeInt((int) checksum.getValue()); - } - return encodedBytes.toByteArray(); + return withChecksum(bodyBytes.toByteArray()); } private record Summary( diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java index 4c81832a4..619f06075 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java @@ -283,26 +283,12 @@ final class HydrologyCaveCourseFilter { || !compatibleSharedCandidates(firstCandidate, secondCandidate, coursesById)) { continue; } - LinkedHashMap firstActions = null; - LinkedHashMap secondActions = null; - for (Map.Entry entry : first.actions().entrySet()) { - HydrologyCaveAction secondAction = second.actions().get(entry.getKey()); - if (secondAction == null || secondAction == entry.getValue()) { - continue; - } - HydrologyCaveAction sharedAction = sharedTerminalAction(entry.getValue(), secondAction); - if (firstActions == null) { - firstActions = new LinkedHashMap<>(first.actions()); - secondActions = new LinkedHashMap<>(second.actions()); - } - firstActions.put(entry.getKey(), sharedAction); - secondActions.put(entry.getKey(), sharedAction); - } - if (firstActions == null) { + TerminalActions actions = alignTerminalActions(first.actions(), second.actions()); + if (actions == null) { continue; } - aligned.set(firstIndex, withActions(first, firstActions)); - aligned.set(secondIndex, withActions(second, secondActions)); + aligned.set(firstIndex, withActions(first, actions.first())); + aligned.set(secondIndex, withActions(second, actions.second())); } } return List.copyOf(aligned); @@ -336,26 +322,12 @@ final class HydrologyCaveCourseFilter { && !sharesTerminalGrotto(firstCourse, secondCourse, first)) { continue; } - LinkedHashMap firstActions = null; - LinkedHashMap secondActions = null; - for (Map.Entry entry : first.actions().entrySet()) { - HydrologyCaveAction secondAction = second.actions().get(entry.getKey()); - if (secondAction == null || secondAction == entry.getValue()) { - continue; - } - HydrologyCaveAction sharedAction = sharedTerminalAction(entry.getValue(), secondAction); - if (firstActions == null) { - firstActions = new LinkedHashMap<>(first.actions()); - secondActions = new LinkedHashMap<>(second.actions()); - } - firstActions.put(entry.getKey(), sharedAction); - secondActions.put(entry.getKey(), sharedAction); - } - if (firstActions == null) { + TerminalActions actions = alignTerminalActions(first.actions(), second.actions()); + if (actions == null) { continue; } - HydrologyCaveCandidate alignedFirst = withActions(first, firstActions); - HydrologyCaveCandidate alignedSecond = withActions(second, secondActions); + HydrologyCaveCandidate alignedFirst = withActions(first, actions.first()); + HydrologyCaveCandidate alignedSecond = withActions(second, actions.second()); replaceExposureCandidate(exposureValidatedCandidates, first, alignedFirst); replaceExposureCandidate(exposureValidatedCandidates, second, alignedSecond); candidates.set(firstIndex, alignedFirst); @@ -364,6 +336,28 @@ final class HydrologyCaveCourseFilter { } } + private TerminalActions alignTerminalActions( + Map first, + Map second + ) { + LinkedHashMap firstActions = null; + LinkedHashMap secondActions = null; + for (Map.Entry entry : first.entrySet()) { + HydrologyCaveAction secondAction = second.get(entry.getKey()); + if (secondAction == null || secondAction == entry.getValue()) { + continue; + } + HydrologyCaveAction sharedAction = sharedTerminalAction(entry.getValue(), secondAction); + if (firstActions == null) { + firstActions = new LinkedHashMap<>(first); + secondActions = new LinkedHashMap<>(second); + } + firstActions.put(entry.getKey(), sharedAction); + secondActions.put(entry.getKey(), sharedAction); + } + return firstActions == null ? null : new TerminalActions(firstActions, secondActions); + } + HydrologyCaveAction sharedTerminalAction( HydrologyCaveAction first, HydrologyCaveAction second @@ -1251,6 +1245,12 @@ final class HydrologyCaveCourseFilter { return List.copyOf(openings); } + private record TerminalActions( + LinkedHashMap first, + LinkedHashMap second + ) { + } + private record GeneratedChannelView(CaveVoxelView terrain) implements CaveVoxelView { @Override public boolean isInWorld(CavePosition position) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java index 32d66fa6c..c196ceb61 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.surface.SurfaceNoise; + final class HydrologyChannelGeometry { private final HydrologyFootprintCompiler compiler; @@ -149,7 +151,7 @@ final class HydrologyChannelGeometry { if (maximum == 0) { return 0; } - double sampled = organicNoise(seed, x, z, 12); + double sampled = SurfaceNoise.value(seed, x, z, 12); return (int) StrictMath.round(sampled * maximum); } @@ -222,13 +224,13 @@ final class HydrologyChannelGeometry { )) * StrictMath.PI * 2D; double firstLobe = 0.5D + 0.5D * StrictMath.sin(angle * 3D + firstPhase); double secondLobe = 0.5D + 0.5D * StrictMath.sin(angle * 5D + secondPhase); - double coherent = signedOrganicNoise( + double coherent = SurfaceNoise.signed( segment.courseId(), worldX, worldZ, channelShape.roughnessWavelength() ); - double detail = signedOrganicNoise( + double detail = SurfaceNoise.signed( HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT), worldX, worldZ, @@ -262,14 +264,14 @@ final class HydrologyChannelGeometry { } double signedCross = (deltaX * -flowZ + deltaZ * flowX) / flowLength; HydrologyPlannerSettings.ChannelShape channelShape = compiler.settings.geometry().surface(); - double thalweg = signedOrganicNoise( + double thalweg = SurfaceNoise.signed( HydrologyHash.mix(segment.courseId(), ORGANIC_BED_VARIATION_SALT), worldX, worldZ, channelShape.roughnessWavelength() ) * shape.channelRadius() * channelShape.wallRoughness(); long bankSeed = HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT); - double bankNoise = signedOrganicNoise( + double bankNoise = SurfaceNoise.signed( bankSeed, worldX, worldZ, @@ -297,10 +299,6 @@ final class HydrologyChannelGeometry { return compiler.settings.geometry().surface(); } - double signedOrganicNoise(long seed, int x, int z, int scale) { - return organicNoise(seed, x, z, scale) * 2D - 1D; - } - double deepPoolDistance( FootprintLayerShape shape, HydraulicSegment segment, @@ -331,32 +329,4 @@ final class HydrologyChannelGeometry { ); return normalizedDistance * radius / radialScale; } - - double organicNoise(long seed, int x, int z, int scale) { - int cellX = Math.floorDiv(x, scale); - int cellZ = Math.floorDiv(z, scale); - double localX = Math.floorMod(x, scale) / (double) scale; - double localZ = Math.floorMod(z, scale) / (double) scale; - double smoothX = localX * localX * (3D - 2D * localX); - double smoothZ = localZ * localZ * (3D - 2D * localZ); - double top = interpolate( - organicCorner(seed, cellX, cellZ), - organicCorner(seed, cellX + 1, cellZ), - smoothX - ); - double bottom = interpolate( - organicCorner(seed, cellX, cellZ + 1), - organicCorner(seed, cellX + 1, cellZ + 1), - smoothX - ); - return interpolate(top, bottom, smoothZ); - } - - double organicCorner(long seed, int cellX, int cellZ) { - return HydrologyHash.unit(HydrologyHash.mix(seed, cellX, cellZ)); - } - - double interpolate(double first, double second, double progress) { - return first + (second - first) * progress; - } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java index b9b47aeea..2fa6eb6ce 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -4,6 +4,7 @@ import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint; import art.arcane.iris.engine.hydrology.surface.SurfaceBounds; import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; +import art.arcane.iris.engine.hydrology.surface.SurfaceNoise; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; @@ -763,7 +764,7 @@ final class HydrologyFootprintCompiler { (normalized - thalwegFraction) / (1D - thalwegFraction) ); int maximumDepth = Math.max(1, shape.fluidHead() - shape.bed()); - double roughness = channelGeometry.signedOrganicNoise( + double roughness = SurfaceNoise.signed( HydrologyHash.mix(segment.id(), HydrologyChannelGeometry.ORGANIC_BED_VARIATION_SALT), worldX, worldZ, @@ -802,7 +803,7 @@ final class HydrologyFootprintCompiler { return shape.bed(); } int lowerExtent = shape.fluidHead() - shape.bed(); - double roughness = channelGeometry.signedOrganicNoise( + double roughness = SurfaceNoise.signed( HydrologyHash.mix(segment.id(), HydrologyChannelGeometry.ORGANIC_BED_VARIATION_SALT), worldX, worldZ, @@ -833,7 +834,7 @@ final class HydrologyFootprintCompiler { int upperExtent = shape.ceiling() - shape.fluidHead(); double extent = upperExtent * scale; if (channelShape.ceilingRoughness() > 0D) { - double roughness = channelGeometry.signedOrganicNoise( + double roughness = SurfaceNoise.signed( HydrologyHash.mix(segment.id(), HydrologyChannelGeometry.ORGANIC_CEILING_VARIATION_SALT), worldX, worldZ, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index d0204642a..597ef723c 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -1,8 +1,5 @@ package art.arcane.iris.engine.hydrology.runtime; -import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; -import art.arcane.iris.engine.hydrology.HydrologyHash; - import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; @@ -21,64 +18,39 @@ import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; import art.arcane.iris.engine.hydrology.HydrologyTile; import art.arcane.iris.engine.hydrology.HydrologyTileCache; import art.arcane.iris.engine.hydrology.HydrologyTileKey; -import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.engine.hydrology.policy.EffectiveRiverPolicy; import art.arcane.iris.engine.hydrology.policy.RiverConfinement; import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver; import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig; import art.arcane.iris.engine.object.IrisDeepFluidConfig; -import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisHydrology; -import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig; -import art.arcane.iris.engine.object.IrisRiverDropShapeConfig; -import art.arcane.iris.engine.object.IrisRiverGeometryConfig; -import art.arcane.iris.engine.object.IrisRiverGrottoConfig; import art.arcane.iris.engine.object.IrisRiverHydrology; -import art.arcane.iris.engine.object.IrisRiverInlandOutlet; -import art.arcane.iris.engine.object.IrisRiverMeanderConfig; -import art.arcane.iris.engine.object.IrisRiverMouthConfig; -import art.arcane.iris.engine.object.IrisRiverPolicy; import art.arcane.iris.engine.object.IrisRiverProfile; -import art.arcane.iris.engine.object.IrisRiverRoutingConfig; import art.arcane.iris.engine.object.IrisRiverRoutingMode; -import art.arcane.iris.engine.object.IrisSeaCaveConfig; import art.arcane.iris.engine.object.IrisStyledRange; -import art.arcane.iris.engine.object.IrisSurfacePoolConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverErosionConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverPondConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverShapeConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverSourceConfig; import art.arcane.iris.engine.object.IrisUndergroundRiverConfig; -import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.math.RNG; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; - +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.TreeMap; -import java.nio.file.Path; import java.util.function.IntConsumer; import java.util.function.Predicate; @@ -90,7 +62,6 @@ public final class IrisHydrologyRuntime implements AutoCloseable { private static final int BIOME_PATCH_SCALE = 32; private static final long SURFACE_WIDTH_SALT = 0x5355524657494454L; private static final long SURFACE_DEPTH_SALT = 0x5355524644455054L; - private static final long SURFACE_BLEND_SALT = 0x53555246424c454eL; private static final long UNDERGROUND_LEVEL_SALT = 0x554e4445524c564cL; private static final long UNDERGROUND_WIDTH_SALT = 0x554e444552574944L; private static final long UNDERGROUND_DEPTH_SALT = 0x554e444552444550L; @@ -121,7 +92,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { this.defaultProfileKey = profileKeys.isEmpty() ? "default" : profileKeys.iterator().next(); this.terrainSampleLock = new Object(); this.terrainSamples = new LinkedHashMap<>(MAXIMUM_TERRAIN_SAMPLES, 0.75F, true); - this.settings = createSettings(context.dimension(), hydrology, context::data); + this.settings = IrisHydrologySettingsCompiler.compile(context.dimension(), hydrology, context::data); HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology); IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources( this::createTerrainBasis, @@ -851,463 +822,10 @@ public final class IrisHydrologyRuntime implements AutoCloseable { return range.stream(new RNG(context.seed() ^ salt), context.data()); } - static HydrologyPlannerSettings createSettings( - IrisDimension dimension, - IrisHydrology hydrology, - DataProvider data - ) { - IrisRiverHydrology rivers = hydrology.getRivers(); - IrisRiverRoutingConfig routing = rivers.getRouting(); - IrisSurfaceRiverConfig surface = rivers.getSurface(); - IrisSurfaceRiverSourceConfig surfaceSources = surface.getSources(); - IrisSurfaceRiverChannelConfig channel = surface.getChannel(); - IrisSurfaceRiverBankConfig banks = surface.getBanks(); - IrisSurfaceRiverErosionConfig erosion = surface.getErosion(); - IrisSurfaceRiverFlowConfig flow = surface.getFlow(); - IrisRiverMouthConfig mouths = surface.getMouths(); - IrisUndergroundRiverConfig underground = rivers.getUnderground(); - IrisUndergroundRiverSourceConfig undergroundSources = underground.getSources(); - int routeNodes = maximumRouteNodes(routing); - int minimumWorldY = dimension.getMinHeight(); - boolean riversEnabled = rivers.isEnabled(); - boolean surfaceEnabled = riversEnabled && surface.isEnabled(); - boolean undergroundEnabled = riversEnabled && underground.isEnabled(); - HydrologyPlannerSettings.Routing plannerRouting = new HydrologyPlannerSettings.Routing( - routing.getTileSize(), - routing.getSampleSpacing(), - routeNodes, - routing.getMaximumRouteLength(), - routing.getMinimumSurfaceCourseLength(), - routing.getMinimumUndergroundCourseLength(), - routing.getValleyPreference(), - routing.getUphillPenalty(), - routing.getSlopePenalty(), - routing.getConfluenceAttraction(), - routing.getLengthPreference(), - routing.getTributaries(), - new HydrologyPlannerSettings.Regional( - routing.getRegional().isEnabled(), - routing.getRegional().getSampleSpacing(), - routing.getRegional().getMinimumLength(), - routing.getRegional().getMaximumTrunks(), - routing.getRegional().getMaximumCachedBasins(), - routing.getRegional().getMaximumCachedStations(), - routing.getRegional().isCoastalChannels(), - routing.getRegional().getCoastalChannelChance(), - routing.getRegional().getMaximumCoastalIncision() - ) - ); - HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source( - surfaceEnabled, - surfaceSources.getDensity(), - surfaceSources.getMinimumElevation() - minimumWorldY, - surfaceSources.getMinimumPerTile(), - maximumSources(surfaceSources.getDensity(), surfaceSources.getMinimumPerTile()), - surfaceSources.getMinimumSpacing() - ); - HydrologyPlannerSettings.Surface plannerSurface = new HydrologyPlannerSettings.Surface( - surfaceEnabled, - plannerSurfaceSources, - minimumInt(channel.getWidth()), - maximumInt(channel.getWidth()), - minimumInt(channel.getDepth()), - maximumInt(channel.getDepth()), - channel.getMaximumIncision(), - banks.getShoreWidth(), - new HydrologyPlannerSettings.Banks( - channel.getSink(), - banks.getBlendSlope(), - banks.getMinimumBlendWidth(), - banks.getMaximumBlendWidth(), - channel.getRoughness(), - channel.getRoughnessWavelength(), - flow.getCascadeRun(), - flow.getWaterfallMinimumDrop(), - mouths.getFlareRatio(), - new HydrologyPlannerSettings.Inlet( - mouths.getInletLength(), - mouths.getInletDepth(), - mouths.getMaximumIncision(), - mouths.getInletCourseFraction(), - mouths.getInletRampSlope() - ), - channel.getSpringWidthRatio(), - channel.getSpringLength(), - banks.isExposeCutStrata(), - new HydrologyPlannerSettings.Erosion( - erosion.isEnabled(), - erosion.getSmoothingRadius(), - erosion.getThalwegFraction(), - erosion.getBlendCurve(), - erosion.getBedNoise(), - erosion.getStyle(), - erosion.getTerraceSteps(), - erosion.getCliffFraction(), - erosion.getBedProfile(), - banks.getShoreRise(), - banks.getBlendBaseWidth(), - new HydrologyPlannerSettings.Excavation( - banks.getExcavation().getMaximumDepth(), - banks.getExcavation().getMaximumWidth(), - banks.getExcavation().getMaximumVolumePerBlock() - ) - ), - new HydrologyPlannerSettings.Ponds( - pond(surface.getPonds().getSource()), - pond(surface.getPonds().getTerminal()) - ), - new HydrologyPlannerSettings.Channel( - channel.getSmoothingRadius(), - channel.getOutlineMinimumRatio(), - channel.getOutlineMaximumRatio(), - channel.getSpringExtraDepth() - ), - new HydrologyPlannerSettings.Flow( - flow.getWaterfallThalwegFraction(), - flow.getPlungeBasinMinimumDrop(), - flow.getPlungeBasinLengthRatio(), - flow.getPlungeBasinDepth() - ) - ) - ); - HydrologyPlannerSettings.Hydraulics plannerHydraulics = - new HydrologyPlannerSettings.Hydraulics(flow.getWaterfallMinimumDrop()); - HydrologyPlannerSettings.Source plannerUndergroundSources = new HydrologyPlannerSettings.Source( - undergroundEnabled, - undergroundSources.getDensity(), - Integer.MIN_VALUE, - undergroundSources.getMinimumPerTile(), - maximumSources(undergroundSources.getDensity(), undergroundSources.getMinimumPerTile()), - undergroundSources.getMinimumSpacing() - ); - HydrologyPlannerSettings.Underground plannerUnderground = new HydrologyPlannerSettings.Underground( - undergroundEnabled, - plannerUndergroundSources, - minimumInt(underground.getFluidLevel()) - minimumWorldY, - maximumInt(underground.getFluidLevel()) - minimumWorldY, - minimumInt(underground.getChannelWidth()), - maximumInt(underground.getChannelWidth()), - minimumInt(underground.getDepth()), - maximumInt(underground.getDepth()), - minimumInt(underground.getHeadroom()), - maximumInt(underground.getHeadroom()), - underground.isConnectToExistingCaves(), - underground.getTributaries(), - underground.getMinimumRockCover(), - underground.getMinimumFloorCover(), - underground.getWideningSources() - ); - IrisRiverGrottoConfig grottos = rivers.getGrottos(); - IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal(); - IrisInlandRiverGrottoConfig inland = grottos.getInland(); - boolean inlandEnabled = inland.isEnabled() - && routing.getInlandOutlets().contains(IrisRiverInlandOutlet.SINKHOLE_GROTTO); - HydrologyPlannerSettings.Outlets plannerOutlets = new HydrologyPlannerSettings.Outlets( - routing.isOceanOutlets(), - grotto(coastal.isEnabled(), coastal.getHorizontalRadius(), coastal.getVerticalRadius(), - coastal.getHeadroom(), coastal.getMaximumVolume()), - grotto(inlandEnabled, inland.getHorizontalRadius(), inland.getVerticalRadius(), - inland.getHeadroom(), inland.getMaximumVolume()), - inlandEnabled && inland.isConnectSurfaceRivers(), - coastal.getCliffMinimumHeight() == null - ? Math.max(4, coastal.getVerticalRadius()) - : coastal.getCliffMinimumHeight(), - underground.getMouthLevelingDistance(), - mouths.getMaximumOceanApron(), - routing.getMaximumOutletsPerTile(), - routing.getMaximumCoastalOutletsPerTile(), - coastal.getCliffSlopeFactor() - ); - HydrologyPlannerSettings.Geometry plannerGeometry = geometry( - rivers.getGeometry(), - surfaceShape(rivers.getGeometry().getSurface(), channel) - ); - return new HydrologyPlannerSettings( - dimension.getFluidHeight(), - plannerRouting, - plannerSurface, - plannerHydraulics, - plannerUnderground, - plannerOutlets, - plannerGeometry, - deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()), - surfacePools(hydrology.getSurfacePools()), - widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()), - seaCaves(coastal), - surfacePolicyBounds(dimension, data) - ); - } - - static HydrologyPlannerSettings.SurfacePolicyBounds surfacePolicyBounds(IrisDimension dimension, DataProvider data) { - TreeMap policies = new TreeMap<>(); - addSurfacePolicy(policies, "dimension", dimension.getRiverPolicy()); - for (IrisRegion region : dimension.getAllRegions(data)) { - addSurfacePolicy(policies, "region:" + region.getLoadKey(), region.getRiverPolicy()); - } - for (IrisBiome biome : dimension.getAllBiomes(data)) { - addSurfacePolicy(policies, "biome:" + biome.getLoadKey(), biome.getRiverPolicy()); - } - long fingerprint = 0L; - int maximumSpacing = 0; - for (SurfaceRiverPolicy policy : policies.values()) { - fingerprint = HydrologyHash.mix(fingerprint, policy.hashCode(), HydrologyHash.text(policy.areaKey())); - maximumSpacing = Math.max(maximumSpacing, policy.sourceSpacing(0)); - } - return new HydrologyPlannerSettings.SurfacePolicyBounds(fingerprint, maximumSpacing); - } - - private static void addSurfacePolicy(Map policies, String key, IrisRiverPolicy policy) { - if (policy == null) { - return; - } - SurfaceRiverPolicy surface = policy.surfacePolicy(key); - if (surface.configured()) { - policies.put(key, surface); - } - } - - /** - * The widest shore band any policy of the dimension asks for, whether as a shore biome band or as a - * geometric shore width, never below the dimension's geometric shore width, so the publication - * envelope reaches every column a river can give shore content to. - */ - static double widestShoreBiomeWidth(IrisDimension dimension, DataProvider data, double shoreWidth) { - double widest = Math.max(0D, shoreWidth); - widest = Math.max(widest, policyShoreReach(dimension.getRiverPolicy())); - for (IrisRegion region : dimension.getAllRegions(data)) { - widest = Math.max(widest, policyShoreReach(region.getRiverPolicy())); - } - for (IrisBiome biome : dimension.getAllBiomes(data)) { - widest = Math.max(widest, policyShoreReach(biome.getRiverPolicy())); - } - return widest; - } - - private static double policyShoreReach(IrisRiverPolicy policy) { - if (policy == null) { - return 0D; - } - double shoreBiomeWidth = policy.getShoreBiomeWidth() == null ? 0D : policy.getShoreBiomeWidth(); - double shoreWidth = policy.getShoreWidth() == null ? 0D : policy.getShoreWidth(); - return Math.max(shoreBiomeWidth, shoreWidth); - } - - private static List surfacePools(List configurations) { - ArrayList pools = new ArrayList<>(); - for (IrisSurfacePoolConfig configuration : configurations) { - pools.add(new HydrologyPlannerSettings.SurfacePool( - configuration.getId(), - configuration.getDensity() > 0D, - configuration.getDensity(), - configuration.getSpacing(), - configuration.getMinimumRadius(), - configuration.getMaximumRadius(), - configuration.getDepth(), - Math.min(64, maximumSources(configuration.getDensity(), 0)), - configuration.getBiome() - )); - } - return List.copyOf(pools); - } - - private static HydrologyPlannerSettings.Geometry geometry( - IrisRiverGeometryConfig geometry, - HydrologyPlannerSettings.ChannelShape surfaceShape - ) { - IrisRiverMeanderConfig meanders = geometry.getMeanders(); - IrisRiverDropShapeConfig drops = geometry.getDrops(); - return new HydrologyPlannerSettings.Geometry( - new HydrologyPlannerSettings.Meanders( - meanders.getPrimaryWavelength(), - meanders.getDetailWavelength(), - meanders.getPrimaryStrength(), - meanders.getDetailStrength(), - meanders.getMaximumOffsetRatio(), - meanders.getSmoothingPasses(), - meanders.getMaximumTurnDegrees() - ), - surfaceShape, - channelShape(geometry.getUnderground()), - channelShape(geometry.getGrottos()), - new HydrologyPlannerSettings.Drops( - drops.getCascadeRunPerBlock(), - drops.getCascadeExponent(), - drops.getMaximumCascadeStep(), - drops.getFlowWidthRatio(), - drops.getMaximumFlowDepth(), - drops.getBasinWidthRatio(), - drops.getMaximumBasinDepth(), - drops.getUndergroundCascadeRunPerBlock() - ) - ); - } - - private static HydrologyPlannerSettings.ChannelShape channelShape(IrisRiverChannelShapeConfig shape) { - return new HydrologyPlannerSettings.ChannelShape( - shape.getBedRoundness(), - shape.getBedRoughness(), - shape.getWallRoughness(), - shape.getRoughnessWavelength(), - shape.getRadialBase(), - shape.getRadialMinimum(), - shape.getRadialMaximum(), - shape.getPrimaryLobeStrength(), - shape.getDetailLobeStrength(), - shape.getCeilingRoughness(), - shape.getAspectMinimum(), - shape.getAspectRange() - ); - } - - // The surface shape's roughness fields are optional and fall back to the channel roughness. - private static HydrologyPlannerSettings.ChannelShape surfaceShape( - IrisSurfaceRiverShapeConfig shape, - IrisSurfaceRiverChannelConfig channel - ) { - return new HydrologyPlannerSettings.ChannelShape( - shape.getBedRoundness(), - shape.getBedRoughness() == null ? channel.getRoughness() : shape.getBedRoughness(), - shape.getWallRoughness() == null ? channel.getRoughness() : shape.getWallRoughness(), - shape.getRoughnessWavelength() == null ? channel.getRoughnessWavelength() : shape.getRoughnessWavelength(), - shape.getRadialBase(), - shape.getRadialMinimum(), - shape.getRadialMaximum(), - shape.getPrimaryLobeStrength(), - shape.getDetailLobeStrength(), - shape.getCeilingRoughness(), - shape.getAspectMinimum(), - shape.getAspectRange() - ); - } - - private static HydrologyPlannerSettings.Grotto grotto( - boolean enabled, - int horizontalRadius, - int verticalRadius, - int headroom, - int maximumVolume - ) { - return new HydrologyPlannerSettings.Grotto( - enabled, - horizontalRadius, - verticalRadius, - headroom, - maximumVolume - ); - } - - // A sea cave is a coastal grotto without a river, so it needs the coastal grotto itself enabled. - private static HydrologyPlannerSettings.SeaCaves seaCaves(IrisCoastalRiverGrottoConfig coastal) { - IrisSeaCaveConfig seaCaves = coastal.getSeaCaves(); - return new HydrologyPlannerSettings.SeaCaves( - coastal.isEnabled() && seaCaves.isEnabled(), - seaCaves.getMaximumPerTile(), - seaCaves.getMinimumSpacing(), - seaCaves.getMinimumCoastHeight(), - seaCaves.getDepth(), - seaCaves.getSweepJitterDegrees() - ); - } - - private static List deepFluids( - List configurations, - int minimumWorldY, - IrisRiverRoutingConfig routing, - int refinementSpacing - ) { - ArrayList deepFluids = new ArrayList<>(); - for (IrisDeepFluidConfig configuration : configurations) { - int radius = configuration.getHorizontalRadius(); - int verticalRadius = configuration.getVerticalRadius(); - int maximumVolume = boundedVolume(radius, verticalRadius, configuration.getHeadroom()); - int maximumPerTile = Math.min(64, maximumSources(configuration.getDensity(), 0)); - int maximumChannelLength = maximumDeepChannelLength(configuration, routing); - int minimumChannelLength = maximumChannelLength == 0 - ? 0 - : Math.min(maximumChannelLength, Math.max(refinementSpacing, radius)); - deepFluids.add(new HydrologyPlannerSettings.DeepFluid( - configuration.getId(), - configuration.getDensity() > 0D - && (configuration.isContainedPools() || configuration.isShortChannels()), - configuration.getDensity(), - configuration.getSpacing(), - minimumInt(configuration.getHeight()) - minimumWorldY, - maximumInt(configuration.getHeight()) - minimumWorldY, - radius, - radius, - verticalRadius, - verticalRadius, - minimumChannelLength, - maximumChannelLength, - configuration.getChannelWidth(), - configuration.getDepth(), - configuration.getHeadroom(), - maximumVolume, - maximumPerTile, - configuration.isContainedPools(), - configuration.isShortChannels() - )); - } - return List.copyOf(deepFluids); - } - - static int maximumDeepChannelLength( - IrisDeepFluidConfig configuration, - IrisRiverRoutingConfig routing - ) { - if (!configuration.isShortChannels()) { - return 0; - } - int configuredLength = Math.max( - HydrologyPlannerSettings.Routing.refinementSpacing(routing.getSampleSpacing()), - configuration.getSpacing() / 3); - return Math.min(configuredLength, routing.getTileSize() / 2); - } - - private static int maximumRouteNodes(IrisRiverRoutingConfig routing) { - int halo = Math.min(routing.getMaximumRouteLength(), routing.getTileSize() / 2); - int alignedHalo = Math.floorDiv(halo, routing.getSampleSpacing()) * routing.getSampleSpacing(); - int width = routing.getTileSize() / routing.getSampleSpacing() + 1 - + alignedHalo * 2 / routing.getSampleSpacing(); - long nodes = (long) width * width; - if (nodes > 1_000_000L) { - throw new IllegalArgumentException("Hydrology routing lattice exceeds one million nodes."); - } - return (int) nodes; - } - private static int tileCoordinate(long blockCoordinate, int tileSize) { return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize)); } - private static int maximumSources(double density, int minimumPerTile) { - int expected = (int) Math.ceil(Math.max(0D, density)); - return Math.min(64, Math.max(minimumPerTile, expected)); - } - - private static int boundedVolume(int radius, int verticalRadius, int headroom) { - long diameter = radius * 2L + 1L; - long volume = diameter * diameter * (verticalRadius + headroom + 1L); - return (int) Math.min(1_048_576L, Math.max(64L, volume)); - } - - private static HydrologyPlannerSettings.Pond pond(IrisSurfaceRiverPondConfig config) { - return new HydrologyPlannerSettings.Pond( - config.isEnabled(), - config.getMinimumRadius(), - config.getMaximumRadius(), - config.getDepth() - ); - } - - private static int minimumInt(IrisStyledRange range) { - return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax())); - } - - private static int maximumInt(IrisStyledRange range) { - return (int) StrictMath.ceil(Math.max(range.getMin(), range.getMax())); - } - private static Set profileKeys(List profiles) { LinkedHashSet keys = new LinkedHashSet<>(); for (IrisRiverProfile profile : profiles) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompiler.java new file mode 100644 index 000000000..c816f1d91 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompiler.java @@ -0,0 +1,499 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyHash; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig; +import art.arcane.iris.engine.object.IrisRiverDropShapeConfig; +import art.arcane.iris.engine.object.IrisRiverGeometryConfig; +import art.arcane.iris.engine.object.IrisRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverInlandOutlet; +import art.arcane.iris.engine.object.IrisRiverMeanderConfig; +import art.arcane.iris.engine.object.IrisRiverMouthConfig; +import art.arcane.iris.engine.object.IrisRiverPolicy; +import art.arcane.iris.engine.object.IrisRiverRoutingConfig; +import art.arcane.iris.engine.object.IrisSeaCaveConfig; +import art.arcane.iris.engine.object.IrisStyledRange; +import art.arcane.iris.engine.object.IrisSurfacePoolConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverErosionConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverPondConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverShapeConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverSourceConfig; +import art.arcane.iris.engine.object.IrisUndergroundRiverConfig; +import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig; +import art.arcane.iris.util.common.data.DataProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +final class IrisHydrologySettingsCompiler { + private IrisHydrologySettingsCompiler() { + } + + static HydrologyPlannerSettings compile( + IrisDimension dimension, + IrisHydrology hydrology, + DataProvider data + ) { + IrisRiverHydrology rivers = hydrology.getRivers(); + IrisRiverRoutingConfig routing = rivers.getRouting(); + IrisSurfaceRiverConfig surface = rivers.getSurface(); + IrisSurfaceRiverSourceConfig surfaceSources = surface.getSources(); + IrisSurfaceRiverChannelConfig channel = surface.getChannel(); + IrisSurfaceRiverBankConfig banks = surface.getBanks(); + IrisSurfaceRiverErosionConfig erosion = surface.getErosion(); + IrisSurfaceRiverFlowConfig flow = surface.getFlow(); + IrisRiverMouthConfig mouths = surface.getMouths(); + IrisUndergroundRiverConfig underground = rivers.getUnderground(); + IrisUndergroundRiverSourceConfig undergroundSources = underground.getSources(); + int routeNodes = maximumRouteNodes(routing); + int minimumWorldY = dimension.getMinHeight(); + boolean riversEnabled = rivers.isEnabled(); + boolean surfaceEnabled = riversEnabled && surface.isEnabled(); + boolean undergroundEnabled = riversEnabled && underground.isEnabled(); + HydrologyPlannerSettings.Routing plannerRouting = new HydrologyPlannerSettings.Routing( + routing.getTileSize(), + routing.getSampleSpacing(), + routeNodes, + routing.getMaximumRouteLength(), + routing.getMinimumSurfaceCourseLength(), + routing.getMinimumUndergroundCourseLength(), + routing.getValleyPreference(), + routing.getUphillPenalty(), + routing.getSlopePenalty(), + routing.getConfluenceAttraction(), + routing.getLengthPreference(), + routing.getTributaries(), + new HydrologyPlannerSettings.Regional( + routing.getRegional().isEnabled(), + routing.getRegional().getSampleSpacing(), + routing.getRegional().getMinimumLength(), + routing.getRegional().getMaximumTrunks(), + routing.getRegional().getMaximumCachedBasins(), + routing.getRegional().getMaximumCachedStations(), + routing.getRegional().isCoastalChannels(), + routing.getRegional().getCoastalChannelChance(), + routing.getRegional().getMaximumCoastalIncision() + ) + ); + HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source( + surfaceEnabled, + surfaceSources.getDensity(), + surfaceSources.getMinimumElevation() - minimumWorldY, + surfaceSources.getMinimumPerTile(), + maximumSources(surfaceSources.getDensity(), surfaceSources.getMinimumPerTile()), + surfaceSources.getMinimumSpacing() + ); + HydrologyPlannerSettings.Surface plannerSurface = new HydrologyPlannerSettings.Surface( + surfaceEnabled, + plannerSurfaceSources, + minimumInt(channel.getWidth()), + maximumInt(channel.getWidth()), + minimumInt(channel.getDepth()), + maximumInt(channel.getDepth()), + channel.getMaximumIncision(), + banks.getShoreWidth(), + new HydrologyPlannerSettings.Banks( + channel.getSink(), + banks.getBlendSlope(), + banks.getMinimumBlendWidth(), + banks.getMaximumBlendWidth(), + channel.getRoughness(), + channel.getRoughnessWavelength(), + flow.getCascadeRun(), + flow.getWaterfallMinimumDrop(), + mouths.getFlareRatio(), + new HydrologyPlannerSettings.Inlet( + mouths.getInletLength(), + mouths.getInletDepth(), + mouths.getMaximumIncision(), + mouths.getInletCourseFraction(), + mouths.getInletRampSlope() + ), + channel.getSpringWidthRatio(), + channel.getSpringLength(), + banks.isExposeCutStrata(), + new HydrologyPlannerSettings.Erosion( + erosion.isEnabled(), + erosion.getSmoothingRadius(), + erosion.getThalwegFraction(), + erosion.getBlendCurve(), + erosion.getBedNoise(), + erosion.getStyle(), + erosion.getTerraceSteps(), + erosion.getCliffFraction(), + erosion.getBedProfile(), + banks.getShoreRise(), + banks.getBlendBaseWidth(), + new HydrologyPlannerSettings.Excavation( + banks.getExcavation().getMaximumDepth(), + banks.getExcavation().getMaximumWidth(), + banks.getExcavation().getMaximumVolumePerBlock() + ) + ), + new HydrologyPlannerSettings.Ponds( + pond(surface.getPonds().getSource()), + pond(surface.getPonds().getTerminal()) + ), + new HydrologyPlannerSettings.Channel( + channel.getSmoothingRadius(), + channel.getOutlineMinimumRatio(), + channel.getOutlineMaximumRatio(), + channel.getSpringExtraDepth() + ), + new HydrologyPlannerSettings.Flow( + flow.getWaterfallThalwegFraction(), + flow.getPlungeBasinMinimumDrop(), + flow.getPlungeBasinLengthRatio(), + flow.getPlungeBasinDepth() + ) + ) + ); + HydrologyPlannerSettings.Hydraulics plannerHydraulics = + new HydrologyPlannerSettings.Hydraulics(flow.getWaterfallMinimumDrop()); + HydrologyPlannerSettings.Source plannerUndergroundSources = new HydrologyPlannerSettings.Source( + undergroundEnabled, + undergroundSources.getDensity(), + Integer.MIN_VALUE, + undergroundSources.getMinimumPerTile(), + maximumSources(undergroundSources.getDensity(), undergroundSources.getMinimumPerTile()), + undergroundSources.getMinimumSpacing() + ); + HydrologyPlannerSettings.Underground plannerUnderground = new HydrologyPlannerSettings.Underground( + undergroundEnabled, + plannerUndergroundSources, + minimumInt(underground.getFluidLevel()) - minimumWorldY, + maximumInt(underground.getFluidLevel()) - minimumWorldY, + minimumInt(underground.getChannelWidth()), + maximumInt(underground.getChannelWidth()), + minimumInt(underground.getDepth()), + maximumInt(underground.getDepth()), + minimumInt(underground.getHeadroom()), + maximumInt(underground.getHeadroom()), + underground.isConnectToExistingCaves(), + underground.getTributaries(), + underground.getMinimumRockCover(), + underground.getMinimumFloorCover(), + underground.getWideningSources() + ); + IrisRiverGrottoConfig grottos = rivers.getGrottos(); + IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal(); + IrisInlandRiverGrottoConfig inland = grottos.getInland(); + boolean inlandEnabled = inland.isEnabled() + && routing.getInlandOutlets().contains(IrisRiverInlandOutlet.SINKHOLE_GROTTO); + HydrologyPlannerSettings.Outlets plannerOutlets = new HydrologyPlannerSettings.Outlets( + routing.isOceanOutlets(), + grotto(coastal.isEnabled(), coastal.getHorizontalRadius(), coastal.getVerticalRadius(), + coastal.getHeadroom(), coastal.getMaximumVolume()), + grotto(inlandEnabled, inland.getHorizontalRadius(), inland.getVerticalRadius(), + inland.getHeadroom(), inland.getMaximumVolume()), + inlandEnabled && inland.isConnectSurfaceRivers(), + coastal.getCliffMinimumHeight() == null + ? Math.max(4, coastal.getVerticalRadius()) + : coastal.getCliffMinimumHeight(), + underground.getMouthLevelingDistance(), + mouths.getMaximumOceanApron(), + routing.getMaximumOutletsPerTile(), + routing.getMaximumCoastalOutletsPerTile(), + coastal.getCliffSlopeFactor() + ); + HydrologyPlannerSettings.Geometry plannerGeometry = geometry( + rivers.getGeometry(), + surfaceShape(rivers.getGeometry().getSurface(), channel) + ); + return new HydrologyPlannerSettings( + dimension.getFluidHeight(), + plannerRouting, + plannerSurface, + plannerHydraulics, + plannerUnderground, + plannerOutlets, + plannerGeometry, + deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()), + surfacePools(hydrology.getSurfacePools()), + widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()), + seaCaves(coastal), + surfacePolicyBounds(dimension, data) + ); + } + + static HydrologyPlannerSettings.SurfacePolicyBounds surfacePolicyBounds(IrisDimension dimension, DataProvider data) { + TreeMap policies = new TreeMap<>(); + addSurfacePolicy(policies, "dimension", dimension.getRiverPolicy()); + for (IrisRegion region : dimension.getAllRegions(data)) { + addSurfacePolicy(policies, "region:" + region.getLoadKey(), region.getRiverPolicy()); + } + for (IrisBiome biome : dimension.getAllBiomes(data)) { + addSurfacePolicy(policies, "biome:" + biome.getLoadKey(), biome.getRiverPolicy()); + } + long fingerprint = 0L; + int maximumSpacing = 0; + for (SurfaceRiverPolicy policy : policies.values()) { + fingerprint = HydrologyHash.mix(fingerprint, policy.hashCode(), HydrologyHash.text(policy.areaKey())); + maximumSpacing = Math.max(maximumSpacing, policy.sourceSpacing(0)); + } + return new HydrologyPlannerSettings.SurfacePolicyBounds(fingerprint, maximumSpacing); + } + + private static void addSurfacePolicy(Map policies, String key, IrisRiverPolicy policy) { + if (policy == null) { + return; + } + SurfaceRiverPolicy surface = policy.surfacePolicy(key); + if (surface.configured()) { + policies.put(key, surface); + } + } + + /** + * The widest shore band any policy of the dimension asks for, whether as a shore biome band or as a + * geometric shore width, never below the dimension's geometric shore width, so the publication + * envelope reaches every column a river can give shore content to. + */ + static double widestShoreBiomeWidth(IrisDimension dimension, DataProvider data, double shoreWidth) { + double widest = Math.max(0D, shoreWidth); + widest = Math.max(widest, policyShoreReach(dimension.getRiverPolicy())); + for (IrisRegion region : dimension.getAllRegions(data)) { + widest = Math.max(widest, policyShoreReach(region.getRiverPolicy())); + } + for (IrisBiome biome : dimension.getAllBiomes(data)) { + widest = Math.max(widest, policyShoreReach(biome.getRiverPolicy())); + } + return widest; + } + + private static double policyShoreReach(IrisRiverPolicy policy) { + if (policy == null) { + return 0D; + } + double shoreBiomeWidth = policy.getShoreBiomeWidth() == null ? 0D : policy.getShoreBiomeWidth(); + double shoreWidth = policy.getShoreWidth() == null ? 0D : policy.getShoreWidth(); + return Math.max(shoreBiomeWidth, shoreWidth); + } + + private static List surfacePools(List configurations) { + ArrayList pools = new ArrayList<>(); + for (IrisSurfacePoolConfig configuration : configurations) { + pools.add(new HydrologyPlannerSettings.SurfacePool( + configuration.getId(), + configuration.getDensity() > 0D, + configuration.getDensity(), + configuration.getSpacing(), + configuration.getMinimumRadius(), + configuration.getMaximumRadius(), + configuration.getDepth(), + Math.min(64, maximumSources(configuration.getDensity(), 0)), + configuration.getBiome() + )); + } + return List.copyOf(pools); + } + + private static HydrologyPlannerSettings.Geometry geometry( + IrisRiverGeometryConfig geometry, + HydrologyPlannerSettings.ChannelShape surfaceShape + ) { + IrisRiverMeanderConfig meanders = geometry.getMeanders(); + IrisRiverDropShapeConfig drops = geometry.getDrops(); + return new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders( + meanders.getPrimaryWavelength(), + meanders.getDetailWavelength(), + meanders.getPrimaryStrength(), + meanders.getDetailStrength(), + meanders.getMaximumOffsetRatio(), + meanders.getSmoothingPasses(), + meanders.getMaximumTurnDegrees() + ), + surfaceShape, + channelShape(geometry.getUnderground()), + channelShape(geometry.getGrottos()), + new HydrologyPlannerSettings.Drops( + drops.getCascadeRunPerBlock(), + drops.getCascadeExponent(), + drops.getMaximumCascadeStep(), + drops.getFlowWidthRatio(), + drops.getMaximumFlowDepth(), + drops.getBasinWidthRatio(), + drops.getMaximumBasinDepth(), + drops.getUndergroundCascadeRunPerBlock() + ) + ); + } + + private static HydrologyPlannerSettings.ChannelShape channelShape(IrisRiverChannelShapeConfig shape) { + return new HydrologyPlannerSettings.ChannelShape( + shape.getBedRoundness(), + shape.getBedRoughness(), + shape.getWallRoughness(), + shape.getRoughnessWavelength(), + shape.getRadialBase(), + shape.getRadialMinimum(), + shape.getRadialMaximum(), + shape.getPrimaryLobeStrength(), + shape.getDetailLobeStrength(), + shape.getCeilingRoughness(), + shape.getAspectMinimum(), + shape.getAspectRange() + ); + } + + // The surface shape's roughness fields are optional and fall back to the channel roughness. + private static HydrologyPlannerSettings.ChannelShape surfaceShape( + IrisSurfaceRiverShapeConfig shape, + IrisSurfaceRiverChannelConfig channel + ) { + return new HydrologyPlannerSettings.ChannelShape( + shape.getBedRoundness(), + shape.getBedRoughness() == null ? channel.getRoughness() : shape.getBedRoughness(), + shape.getWallRoughness() == null ? channel.getRoughness() : shape.getWallRoughness(), + shape.getRoughnessWavelength() == null ? channel.getRoughnessWavelength() : shape.getRoughnessWavelength(), + shape.getRadialBase(), + shape.getRadialMinimum(), + shape.getRadialMaximum(), + shape.getPrimaryLobeStrength(), + shape.getDetailLobeStrength(), + shape.getCeilingRoughness(), + shape.getAspectMinimum(), + shape.getAspectRange() + ); + } + + private static HydrologyPlannerSettings.Grotto grotto( + boolean enabled, + int horizontalRadius, + int verticalRadius, + int headroom, + int maximumVolume + ) { + return new HydrologyPlannerSettings.Grotto( + enabled, + horizontalRadius, + verticalRadius, + headroom, + maximumVolume + ); + } + + // A sea cave is a coastal grotto without a river, so it needs the coastal grotto itself enabled. + private static HydrologyPlannerSettings.SeaCaves seaCaves(IrisCoastalRiverGrottoConfig coastal) { + IrisSeaCaveConfig seaCaves = coastal.getSeaCaves(); + return new HydrologyPlannerSettings.SeaCaves( + coastal.isEnabled() && seaCaves.isEnabled(), + seaCaves.getMaximumPerTile(), + seaCaves.getMinimumSpacing(), + seaCaves.getMinimumCoastHeight(), + seaCaves.getDepth(), + seaCaves.getSweepJitterDegrees() + ); + } + + private static List deepFluids( + List configurations, + int minimumWorldY, + IrisRiverRoutingConfig routing, + int refinementSpacing + ) { + ArrayList deepFluids = new ArrayList<>(); + for (IrisDeepFluidConfig configuration : configurations) { + int radius = configuration.getHorizontalRadius(); + int verticalRadius = configuration.getVerticalRadius(); + int maximumVolume = boundedVolume(radius, verticalRadius, configuration.getHeadroom()); + int maximumPerTile = Math.min(64, maximumSources(configuration.getDensity(), 0)); + int maximumChannelLength = maximumDeepChannelLength(configuration, routing); + int minimumChannelLength = maximumChannelLength == 0 + ? 0 + : Math.min(maximumChannelLength, Math.max(refinementSpacing, radius)); + deepFluids.add(new HydrologyPlannerSettings.DeepFluid( + configuration.getId(), + configuration.getDensity() > 0D + && (configuration.isContainedPools() || configuration.isShortChannels()), + configuration.getDensity(), + configuration.getSpacing(), + minimumInt(configuration.getHeight()) - minimumWorldY, + maximumInt(configuration.getHeight()) - minimumWorldY, + radius, + radius, + verticalRadius, + verticalRadius, + minimumChannelLength, + maximumChannelLength, + configuration.getChannelWidth(), + configuration.getDepth(), + configuration.getHeadroom(), + maximumVolume, + maximumPerTile, + configuration.isContainedPools(), + configuration.isShortChannels() + )); + } + return List.copyOf(deepFluids); + } + + static int maximumDeepChannelLength( + IrisDeepFluidConfig configuration, + IrisRiverRoutingConfig routing + ) { + if (!configuration.isShortChannels()) { + return 0; + } + int configuredLength = Math.max( + HydrologyPlannerSettings.Routing.refinementSpacing(routing.getSampleSpacing()), + configuration.getSpacing() / 3); + return Math.min(configuredLength, routing.getTileSize() / 2); + } + + private static int maximumRouteNodes(IrisRiverRoutingConfig routing) { + int halo = Math.min(routing.getMaximumRouteLength(), routing.getTileSize() / 2); + int alignedHalo = Math.floorDiv(halo, routing.getSampleSpacing()) * routing.getSampleSpacing(); + int width = routing.getTileSize() / routing.getSampleSpacing() + 1 + + alignedHalo * 2 / routing.getSampleSpacing(); + long nodes = (long) width * width; + if (nodes > 1_000_000L) { + throw new IllegalArgumentException("Hydrology routing lattice exceeds one million nodes."); + } + return (int) nodes; + } + + private static int maximumSources(double density, int minimumPerTile) { + int expected = (int) Math.ceil(Math.max(0D, density)); + return Math.min(64, Math.max(minimumPerTile, expected)); + } + + private static int boundedVolume(int radius, int verticalRadius, int headroom) { + long diameter = radius * 2L + 1L; + long volume = diameter * diameter * (verticalRadius + headroom + 1L); + return (int) Math.min(1_048_576L, Math.max(64L, volume)); + } + + private static HydrologyPlannerSettings.Pond pond(IrisSurfaceRiverPondConfig config) { + return new HydrologyPlannerSettings.Pond( + config.isEnabled(), + config.getMinimumRadius(), + config.getMaximumRadius(), + config.getDepth() + ); + } + + private static int minimumInt(IrisStyledRange range) { + return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax())); + } + + private static int maximumInt(IrisStyledRange range) { + return (int) StrictMath.ceil(Math.max(range.getMin(), range.getMax())); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index 1eb46550d..f6f9967e5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -316,10 +316,7 @@ public final class IrisObjectIO { private static void writeValidated(IrisObject self, OutputStream o) throws IOException { DataOutputStream dos = new DataOutputStream(o); - dos.writeInt(self.w); - dos.writeInt(self.h); - dos.writeInt(self.d); - dos.writeUTF("Iris V2 IOB;"); + writeHeader(self, dos); Palette palette = buildPalette(self); dos.writeShort(palette.size()); @@ -330,21 +327,13 @@ public final class IrisObjectIO { dos.writeInt(self.blocks.size()); - for (var entry : self.blocks) { - var i = entry.getKey(); - dos.writeShort(i.getBlockX()); - dos.writeShort(i.getBlockY()); - dos.writeShort(i.getBlockZ()); - dos.writeShort(palette.indexOf(entry.getValue().key())); + for (Map.Entry entry : self.blocks) { + writeBlock(dos, palette, entry); } dos.writeInt(self.states.size()); - for (var entry : self.states) { - var i = entry.getKey(); - dos.writeShort(i.getBlockX()); - dos.writeShort(i.getBlockY()); - dos.writeShort(i.getBlockZ()); - entry.getValue().toBinary(dos); + for (Map.Entry entry : self.states) { + writeState(dos, entry); } } @@ -369,10 +358,7 @@ public final class IrisObjectIO { public void execute() { try { DataOutputStream dos = new DataOutputStream(o); - dos.writeInt(self.w); - dos.writeInt(self.h); - dos.writeInt(self.d); - dos.writeUTF("Iris V2 IOB;"); + writeHeader(self, dos); Palette palette = buildPalette(self); c += self.blocks.size(); @@ -387,22 +373,14 @@ public final class IrisObjectIO { dos.writeInt(self.blocks.size()); - for (var entry : self.blocks) { - var i = entry.getKey(); - dos.writeShort(i.getBlockX()); - dos.writeShort(i.getBlockY()); - dos.writeShort(i.getBlockZ()); - dos.writeShort(palette.indexOf(entry.getValue().key())); + for (Map.Entry entry : self.blocks) { + writeBlock(dos, palette, entry); ++c; } dos.writeInt(self.states.size()); - for (var entry : self.states) { - var i = entry.getKey(); - dos.writeShort(i.getBlockX()); - dos.writeShort(i.getBlockY()); - dos.writeShort(i.getBlockZ()); - entry.getValue().toBinary(dos); + for (Map.Entry entry : self.states) { + writeState(dos, entry); ++c; } } catch (IOException e) { @@ -436,6 +414,31 @@ public final class IrisObjectIO { throw ref.get(); } + private static void writeHeader(IrisObject self, DataOutputStream output) throws IOException { + output.writeInt(self.w); + output.writeInt(self.h); + output.writeInt(self.d); + output.writeUTF(V2_HEADER); + } + + private static void writeBlock(DataOutputStream output, Palette palette, + Map.Entry entry) throws IOException { + IrisBlockVector position = entry.getKey(); + output.writeShort(position.getBlockX()); + output.writeShort(position.getBlockY()); + output.writeShort(position.getBlockZ()); + output.writeShort(palette.indexOf(entry.getValue().key())); + } + + private static void writeState(DataOutputStream output, + Map.Entry entry) throws IOException { + IrisBlockVector position = entry.getKey(); + output.writeShort(position.getBlockX()); + output.writeShort(position.getBlockY()); + output.writeShort(position.getBlockZ()); + entry.getValue().toBinary(output); + } + static void write(IrisObject self, File file) throws IOException { if (file == null) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java index f0e23f988..f75cdb90f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java @@ -33,32 +33,27 @@ public final class TreeBlockResolver { } public static PlatformBlockState resolve(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Cell cell, TreeBlockCanvas.Vec pos, RNG paletteRng) { - switch (cell.role()) { - case TRUNK -> { - PlatformBlockState state = resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng); - return finishTrunk(state, cell); - } + return switch (cell.role()) { + case TRUNK -> finishTrunk(resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng), cell); case SECONDARY_TRUNK -> { PlatformBlockState state = resolveBlock(tree.getSecondaryTrunk(), tree.getSecondaryTrunkPalette(), data, pos, paletteRng); if (state == null) { state = resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng); } - return finishTrunk(state, cell); - } - case LEAF -> { - return resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); + yield finishTrunk(state, cell); } + case LEAF -> resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); case SECONDARY_LEAF -> { PlatformBlockState state = resolveSecondaryLeaf(tree, data, pos, paletteRng); if (state == null) { state = resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); } - return state; + yield state; } case DECORATOR -> { int index = cell.decoratorIndex(); if (index < 0 || tree.getDecorators() == null || index >= tree.getDecorators().size()) { - return null; + yield null; } IrisTreeDecorator dec = tree.getDecorators().get(index); PlatformBlockState state = resolveBlock(dec.getBlock(), dec.getPalette(), data, pos, paletteRng); @@ -68,12 +63,10 @@ public final class TreeBlockResolver { } catch (IllegalArgumentException ignored) { } } - return state; + yield state; } - default -> { - return null; - } - } + default -> null; + }; } private static PlatformBlockState finishTrunk(PlatformBlockState state, TreeBlockCanvas.Cell cell) { @@ -93,7 +86,7 @@ public final class TreeBlockResolver { } private static PlatformBlockState resolveSecondaryLeaf(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng) { - if (TreeTrunkBuilder.paletteSet(tree.getSecondaryLeavesPalette())) { + if (IrisProceduralBlocks.paletteSet(tree.getSecondaryLeavesPalette())) { return tree.getSecondaryLeavesPalette().get(paletteRng, pos.x(), pos.y(), pos.z(), data); } if (tree.getWeightedSecondaryLeaves() != null && !tree.getWeightedSecondaryLeaves().isEmpty()) { @@ -126,7 +119,7 @@ public final class TreeBlockResolver { } private static PlatformBlockState resolveBlock(String block, IrisMaterialPalette palette, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng) { - if (TreeTrunkBuilder.paletteSet(palette)) { + if (IrisProceduralBlocks.paletteSet(palette)) { return palette.get(paletteRng, pos.x(), pos.y(), pos.z(), data); } if (block != null && !block.isEmpty()) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java index ae59834af..52a646ee5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object.tree; +import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisTreeBranches; import art.arcane.iris.engine.object.IrisTreeCanopy; @@ -245,7 +246,7 @@ public final class TreeCanopyBuilder { } private static TreeBlockCanvas.Role resolveLeaf(IrisProceduralTree tree, RNG rng) { - boolean hasSecondary = TreeTrunkBuilder.paletteSet(tree.getSecondaryLeavesPalette()) + boolean hasSecondary = IrisProceduralBlocks.paletteSet(tree.getSecondaryLeavesPalette()) || (tree.getWeightedSecondaryLeaves() != null && !tree.getWeightedSecondaryLeaves().isEmpty()) || (tree.getSecondaryLeaves() != null && !tree.getSecondaryLeaves().isEmpty()); if (!hasSecondary || tree.getSecondaryLeafFraction() <= 0.0) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java index 6d36a0469..4bc07708e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object.tree; +import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisTreeDecorator; import art.arcane.volmlib.util.collection.KList; @@ -40,7 +41,7 @@ public final class TreeDecoratorApplier { for (int idx = 0; idx < decorators.size(); idx++) { IrisTreeDecorator dec = decorators.get(idx); - boolean hasBlock = (dec.getBlock() != null && !dec.getBlock().isEmpty()) || TreeTrunkBuilder.paletteSet(dec.getPalette()); + boolean hasBlock = (dec.getBlock() != null && !dec.getBlock().isEmpty()) || IrisProceduralBlocks.paletteSet(dec.getPalette()); if (!hasBlock) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java index 26a8a19a6..c85fbdedd 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java @@ -18,7 +18,7 @@ package art.arcane.iris.engine.object.tree; -import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisTreeAzimuthMode; @@ -146,16 +146,12 @@ public final class TreeTrunkBuilder { } private static boolean hasSecondaryTrunk(IrisProceduralTree tree) { - if (paletteSet(tree.getSecondaryTrunkPalette())) { + if (IrisProceduralBlocks.paletteSet(tree.getSecondaryTrunkPalette())) { return true; } return tree.getSecondaryTrunk() != null && !tree.getSecondaryTrunk().isEmpty(); } - static boolean paletteSet(IrisMaterialPalette palette) { - return palette != null && palette.getPalette() != null && !palette.getPalette().isEmpty(); - } - private static double[] leanOffset(IrisProceduralTree tree, int y, int height) { if (tree.getLeanAngle() == 0.0 || height <= 1) { return new double[]{0.0, 0.0}; diff --git a/core/src/main/java/art/arcane/iris/platform/BlockStateKey.java b/core/src/main/java/art/arcane/iris/platform/BlockStateKey.java new file mode 100644 index 000000000..80bfa70c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/platform/BlockStateKey.java @@ -0,0 +1,47 @@ +package art.arcane.iris.platform; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class BlockStateKey { + private BlockStateKey() { + } + + public static String namespace(String key) { + String base = key; + int bracket = base.indexOf('['); + if (bracket >= 0) { + base = base.substring(0, bracket); + } + int colon = base.indexOf(':'); + return colon >= 0 ? base.substring(0, colon) : "minecraft"; + } + + public static String withProperty(String key, String name, String value) { + int bracket = key.indexOf('['); + if (bracket < 0) { + return key + "[" + name + "=" + value + "]"; + } + String base = key.substring(0, bracket); + String body = key.substring(bracket + 1, key.lastIndexOf(']')); + LinkedHashMap properties = new LinkedHashMap<>(); + for (String entry : body.split(",")) { + int equals = entry.indexOf('='); + if (equals < 0) { + continue; + } + properties.put(entry.substring(0, equals).trim(), entry.substring(equals + 1).trim()); + } + properties.put(name, value); + StringBuilder merged = new StringBuilder(base).append('['); + boolean first = true; + for (Map.Entry property : properties.entrySet()) { + if (!first) { + merged.append(','); + } + merged.append(property.getKey()).append('=').append(property.getValue()); + first = false; + } + return merged.append(']').toString(); + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java index 76f291ddc..17182dbb6 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.platform.BlockStateKey; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.math.IrisBlockVector; @@ -33,7 +34,6 @@ import org.bukkit.Tag; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -84,7 +84,7 @@ public final class BukkitBlockState implements PlatformBlockState { private BukkitBlockState(BlockData data, String key) { this.data = data; this.key = key; - this.namespace = parseNamespace(key); + this.namespace = BlockStateKey.namespace(key); } public static BukkitBlockState of(BlockData data) { @@ -175,44 +175,6 @@ public final class BukkitBlockState implements PlatformBlockState { return key.hashCode(); } - private static String parseNamespace(String key) { - String base = key; - int bracket = base.indexOf('['); - if (bracket >= 0) { - base = base.substring(0, bracket); - } - int colon = base.indexOf(':'); - return colon >= 0 ? base.substring(0, colon) : "minecraft"; - } - - private static String mergeProperty(String key, String name, String value) { - int bracket = key.indexOf('['); - if (bracket < 0) { - return key + "[" + name + "=" + value + "]"; - } - String base = key.substring(0, bracket); - String body = key.substring(bracket + 1, key.lastIndexOf(']')); - LinkedHashMap properties = new LinkedHashMap<>(); - for (String entry : body.split(",")) { - int equals = entry.indexOf('='); - if (equals < 0) { - continue; - } - properties.put(entry.substring(0, equals).trim(), entry.substring(equals + 1).trim()); - } - properties.put(name, value); - StringBuilder merged = new StringBuilder(base).append('['); - boolean first = true; - for (Map.Entry property : properties.entrySet()) { - if (!first) { - merged.append(','); - } - merged.append(property.getKey()).append('=').append(property.getValue()); - first = false; - } - return merged.append(']').toString(); - } - @Override public String key() { return key; @@ -448,7 +410,7 @@ public final class BukkitBlockState implements PlatformBlockState { public PlatformBlockState withProperty(String name, String value) { if (data instanceof IrisCustomData custom) { if (ExternalDataSVC.parseState(custom.getCustom()).getB().containsKey(name)) { - String merged = mergeProperty(key, name, value); + String merged = BlockStateKey.withProperty(key, name, value); BlockData resolved = BukkitBlockResolution.resolveOrNull(merged); if (!(resolved instanceof IrisCustomData)) { throw new IllegalArgumentException("Cannot resolve custom block state " + merged); @@ -457,11 +419,11 @@ public final class BukkitBlockState implements PlatformBlockState { } // Re-attach the custom identity (as the proxy's own merge/clone cases do) after // editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla. - String merged = mergeProperty(custom.getBase().getAsString(), name, value); + String merged = BlockStateKey.withProperty(custom.getBase().getAsString(), name, value); BlockData resolved = Bukkit.createBlockData(merged); return of(IrisCustomData.of(resolved, custom.getCustom())); } - return of(Bukkit.createBlockData(mergeProperty(key, name, value))); + return of(Bukkit.createBlockData(BlockStateKey.withProperty(key, name, value))); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java index 665c01533..dcd5be950 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java @@ -37,18 +37,11 @@ public class ObjectTargetHandler implements DirectorParameterHandler { IrisData data = data(); if (data != null) { - for (String k : data.getObjectLoader().getPossibleKeys()) { - out.add(k); - collectPrefixes(k, prefixes); - } + collectObjectKeys(data, out, prefixes); } else { File packsFolder = IrisPlatforms.get().packsFolder(); for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { - IrisData d = IrisData.get(pack); - for (String k : d.getObjectLoader().getPossibleKeys()) { - out.add(k); - collectPrefixes(k, prefixes); - } + collectObjectKeys(IrisData.get(pack), out, prefixes); } } @@ -58,6 +51,13 @@ public class ObjectTargetHandler implements DirectorParameterHandler { return out; } + private static void collectObjectKeys(IrisData data, KList out, Set prefixes) { + for (String key : data.getObjectLoader().getPossibleKeys()) { + out.add(key); + collectPrefixes(key, prefixes); + } + } + private static void collectPrefixes(String key, Set prefixes) { int idx = 0; while ((idx = key.indexOf('/', idx)) >= 0) { diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/NullSafeStream.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/NullSafeStream.java index 97ce20903..69d83b8a8 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/NullSafeStream.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/utility/NullSafeStream.java @@ -43,22 +43,12 @@ public class NullSafeStream extends BasicStream implements ProceduralStrea @Override public T get(double x, double z) { T t = stream.get(x, z); - - if (t == null) { - return ifNull; - } - - return t; + return t == null ? ifNull : t; } @Override public T get(double x, double y, double z) { T t = stream.get(x, y, z); - - if (t == null) { - return ifNull; - } - - return t; + return t == null ? ifNull : t; } } diff --git a/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java b/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java index 63db0b14e..4a73e76d1 100644 --- a/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java +++ b/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java @@ -64,8 +64,27 @@ public class PackCompatReportTest { public void findingLineAndDedupKey() { CompatFinding finding = finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[0].palette[1]"); assertEquals("minecraft:sulfur (block): excluded Biome cave/sulfur at layers[0].palette[1]", finding.line()); + assertEquals("excluded Biome cave/sulfur at layers[0].palette[1]", finding.subjectLine()); assertEquals("BLOCK:minecraft:sulfur|EXCLUDED|Biome|cave/sulfur", finding.dedupKey()); CompatFinding blank = new CompatFinding(CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, null, null, null); assertEquals("minecraft:camel (entity): dropped", blank.line()); + assertEquals("dropped", blank.subjectLine()); + } + + @Test + public void findingRendersEachOptionalSubjectFieldIndependently() { + CompatFinding typeOnly = new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "Biome", "", ""); + CompatFinding keyOnly = new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "", "cave/sulfur", ""); + CompatFinding detailOnly = new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "", "", "layers[0]"); + + assertEquals("excluded Biome", typeOnly.subjectLine()); + assertEquals("excluded cave/sulfur", keyOnly.subjectLine()); + assertEquals("excluded at layers[0]", detailOnly.subjectLine()); + assertEquals("minecraft:sulfur (block): excluded Biome", typeOnly.line()); + assertEquals("minecraft:sulfur (block): excluded cave/sulfur", keyOnly.line()); + assertEquals("minecraft:sulfur (block): excluded at layers[0]", detailOnly.line()); } } diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataAuthoringCacheTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataAuthoringCacheTest.java index 6e6733c1f..8c0136fdf 100644 --- a/core/src/test/java/art/arcane/iris/core/loader/IrisDataAuthoringCacheTest.java +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataAuthoringCacheTest.java @@ -16,6 +16,9 @@ import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -77,4 +80,29 @@ public class IrisDataAuthoringCacheTest { assertEquals(3.0, retained.getZoom(), 0.0); assertEquals("{\"zoom\":3.0}", Files.readString(epochRoot.resolve("generators/sample.json"))); } + + @Test + public void resourceKeyDiscoveryPreservesNestedNamesAndCachedArrays() throws Exception { + Path root = temporary.newFolder("resource-keys").toPath(); + for (String name : List.of( + "generators/plain.json", "generators/nested.json/ridge.json.alt.json", "generators/ignored.JSON", + "images/plain.png", "images/nested.png/ridge.png.alt.png", "images/ignored.PNG", + "matter/plain.mat", "matter/nested.mat/ridge.mat.alt.mat", "matter/ignored.MAT")) { + Path file = root.resolve(name); + Files.createDirectories(file.getParent()); + Files.write(file, new byte[0]); + } + source = IrisData.get(root.toFile()); + Map, Set> expected = Map.of( + source.getGeneratorLoader(), Set.of("plain", "nested/ridge.alt"), + source.getImageLoader(), Set.of("plain", "nested.png/ridge.alt"), + source.getMatterLoader(), Set.of("plain", "nested.mat/ridge.alt")); + + for (Map.Entry, Set> entry : expected.entrySet()) { + String[] keys = entry.getKey().getPossibleKeys(); + assertEquals(entry.getValue(), Set.of(keys)); + assertEquals(entry.getValue().size(), keys.length); + assertSame(keys, entry.getKey().getPossibleKeys()); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java index e4621cc07..96e98c30f 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java @@ -703,6 +703,33 @@ public class StructureTransactionWriterTest { assertFalse(Files.exists(transactionRoot(root, token.transactionId()))); } + @Test + public void preparedRemovalRejectsMissingAndInvalidJournalsWithoutDeletingRecoveryData() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-invalid-journal").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureTransactionWriter.PreparedRemovalToken token = + new StructureTransactionWriter.PreparedRemovalToken(root, UUID.randomUUID()); + Path transactionRoot = transactionRoot(token.packRoot(), token.transactionId()); + Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); + Files.createDirectories(transactionRoot); + + for (boolean commit : new boolean[]{false, true}) { + IOException failure = assertThrows( + IOException.class, () -> writer.resolvePreparedRemoval(token, commit)); + assertEquals("Missing prepared removal journal at " + transactionRoot, failure.getMessage()); + assertTrue(Files.isDirectory(transactionRoot)); + } + + Files.writeString(journalPath, "not-json", StandardCharsets.UTF_8); + for (boolean commit : new boolean[]{false, true}) { + IOException failure = assertThrows( + IOException.class, () -> writer.resolvePreparedRemoval(token, commit)); + assertEquals("Invalid prepared removal journal at " + journalPath, failure.getMessage()); + assertTrue(failure.getCause() instanceof RuntimeException); + assertEquals("not-json", Files.readString(journalPath, StandardCharsets.UTF_8)); + } + } + @Test public void symbolicLinkAncestorsCannotEscapeThePackRoot() throws IOException { Path root = temporaryFolder.newFolder("symlink-pack").toPath(); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveTerminalAlignmentTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveTerminalAlignmentTest.java new file mode 100644 index 000000000..b259b6eef --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveTerminalAlignmentTest.java @@ -0,0 +1,191 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveFluidPolicy; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveGrottoShape; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlannerSettings; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class HydrologyCaveTerminalAlignmentTest { + private static final CavePosition SHARED = new CavePosition(0, 20, 0); + private static final List PRIORITY = List.of( + HydrologyCaveAction.FALLING_FLUID, + HydrologyCaveAction.WET_SOURCE, + HydrologyCaveAction.DRY_AIR, + HydrologyCaveAction.SEAL_GUARD + ); + + @Test + public void candidatesPreserveActionOrderAndExposureIdentityForEveryPriorityPair() { + HydrologyCaveCourseFilter filter = filter(); + Map courses = Map.of(1L, course(1L, 10L), 2L, course(2L, 10L)); + for (int firstPriority = 0; firstPriority < PRIORITY.size(); firstPriority++) { + for (int secondPriority = 0; secondPriority < PRIORITY.size(); secondPriority++) { + HydrologyCaveCandidate first = candidate(1L, PRIORITY.get(firstPriority)); + HydrologyCaveCandidate second = candidate(2L, PRIORITY.get(secondPriority)); + ArrayList candidates = new ArrayList<>(List.of(first, second)); + Set exposed = Collections.newSetFromMap(new IdentityHashMap<>()); + exposed.add(first); + + filter.alignSharedTerminalCandidateActions(candidates, courses, exposed); + + HydrologyCaveAction expected = PRIORITY.get(Math.min(firstPriority, secondPriority)); + assertEquals(expected, candidates.getFirst().actions().get(SHARED)); + assertEquals(expected, candidates.getLast().actions().get(SHARED)); + assertEquals(List.copyOf(first.actions().keySet()), List.copyOf(candidates.getFirst().actions().keySet())); + assertEquals(List.copyOf(second.actions().keySet()), List.copyOf(candidates.getLast().actions().keySet())); + assertEquals(PRIORITY.get(firstPriority), first.actions().get(SHARED)); + assertEquals(PRIORITY.get(secondPriority), second.actions().get(SHARED)); + assertEquals(1, exposed.size()); + assertTrue(exposed.contains(candidates.getFirst())); + assertFalse(exposed.contains(candidates.getLast())); + if (firstPriority == secondPriority) { + assertSame(first, candidates.getFirst()); + assertSame(second, candidates.getLast()); + } else { + assertNotSame(first, candidates.getFirst()); + assertNotSame(second, candidates.getLast()); + assertFalse(exposed.contains(first)); + } + } + } + } + + @Test + public void plansPreserveActionOrderAndBaselineForEveryPriorityPair() { + HydrologyCaveCourseFilter filter = filter(); + Map courses = Map.of(1L, course(1L, 10L), 2L, course(2L, 10L)); + for (int firstPriority = 0; firstPriority < PRIORITY.size(); firstPriority++) { + for (int secondPriority = 0; secondPriority < PRIORITY.size(); secondPriority++) { + HydrologyCaveCandidate firstCandidate = candidate(1L, PRIORITY.get(firstPriority)); + HydrologyCaveCandidate secondCandidate = candidate(2L, PRIORITY.get(secondPriority)); + HydrologyCavePlan first = plan(firstCandidate); + HydrologyCavePlan second = plan(secondCandidate); + + List aligned = filter.alignSharedTerminalPlanActions( + List.of(first, second), List.of(firstCandidate, secondCandidate), courses); + + HydrologyCaveAction expected = PRIORITY.get(Math.min(firstPriority, secondPriority)); + assertEquals(expected, aligned.getFirst().actions().get(SHARED)); + assertEquals(expected, aligned.getLast().actions().get(SHARED)); + assertEquals(List.copyOf(first.actions().keySet()), List.copyOf(aligned.getFirst().actions().keySet())); + assertEquals(List.copyOf(second.actions().keySet()), List.copyOf(aligned.getLast().actions().keySet())); + assertEquals(first.baselinePreconditions(), aligned.getFirst().baselinePreconditions()); + assertEquals(second.baselinePreconditions(), aligned.getLast().baselinePreconditions()); + assertEquals(List.copyOf(first.baselinePreconditions().keySet()), + List.copyOf(aligned.getFirst().baselinePreconditions().keySet())); + assertEquals(List.copyOf(second.baselinePreconditions().keySet()), + List.copyOf(aligned.getLast().baselinePreconditions().keySet())); + assertEquals(PRIORITY.get(firstPriority), first.actions().get(SHARED)); + assertEquals(PRIORITY.get(secondPriority), second.actions().get(SHARED)); + if (firstPriority == secondPriority) { + assertSame(first, aligned.getFirst()); + assertSame(second, aligned.getLast()); + } else { + assertNotSame(first, aligned.getFirst()); + assertNotSame(second, aligned.getLast()); + } + } + } + } + + @Test + public void unrelatedOutletsRetainCandidateIdentity() { + HydrologyCaveCandidate first = candidate(1L, HydrologyCaveAction.WET_SOURCE); + HydrologyCaveCandidate second = candidate(2L, HydrologyCaveAction.DRY_AIR); + ArrayList candidates = new ArrayList<>(List.of(first, second)); + + filter().alignSharedTerminalCandidateActions(candidates, + Map.of(1L, course(1L, 10L), 2L, course(2L, 20L)), Set.of()); + + assertSame(first, candidates.getFirst()); + assertSame(second, candidates.getLast()); + } + + @Test + public void incompatibleOrRejectedPlansRetainIdentity() { + HydrologyCaveCandidate firstCandidate = candidate(1L, HydrologyCaveAction.WET_SOURCE); + HydrologyCaveCandidate secondCandidate = candidate(2L, HydrologyCaveAction.DRY_AIR); + HydrologyCaveCandidate incompatible = new HydrologyCaveCandidate(secondCandidate.source(), "lava", + secondCandidate.settings(), false, secondCandidate.actions(), secondCandidate.intentionalOpenings()); + HydrologyCavePlan first = plan(firstCandidate); + HydrologyCavePlan second = plan(secondCandidate); + HydrologyCavePlan rejected = new HydrologyCavePlan(secondCandidate.source(), HydrologyCaveRejection.OPEN_SURFACE, + Map.of(), Map.of(), OptionalLong.empty()); + Map courses = Map.of(1L, course(1L, 10L), 2L, course(2L, 10L)); + + List incompatibleResult = filter().alignSharedTerminalPlanActions( + List.of(first, second), List.of(firstCandidate, incompatible), courses); + List rejectedResult = filter().alignSharedTerminalPlanActions( + List.of(first, rejected), List.of(firstCandidate, secondCandidate), courses); + + assertSame(first, incompatibleResult.getFirst()); + assertSame(second, incompatibleResult.getLast()); + assertSame(first, rejectedResult.getFirst()); + assertSame(rejected, rejectedResult.getLast()); + } + + private HydrologyCaveCourseFilter filter() { + return new HydrologyCaveCourseFilter(mock(CaveVoxelView.class), + new HydrologyCaveCourseFilter.Options(false, 8192, 8192)); + } + + private HydrologyCaveCandidate candidate(long id, HydrologyCaveAction sharedAction) { + CavePosition own = new CavePosition((int) id, 20, 0); + LinkedHashMap actions = new LinkedHashMap<>(); + if (id == 2L) { + actions.put(SHARED, sharedAction); + } + actions.put(own, HydrologyCaveAction.DRY_AIR); + actions.put(SHARED, sharedAction); + return new HydrologyCaveCandidate( + new HydrologyCaveSource(id, SHARED, SHARED, 20, HydrologyCaveMode.GENERATED_GROTTO), + "water", new HydrologyCavePlannerSettings(8, 8, 32, 1, 1, 1, 1, 0, + HydrologyCaveFluidPolicy.REJECT_EXISTING, HydrologyCaveGrottoShape.ELLIPSOID, 8, 8), + false, actions, Set.of(own)); + } + + private HydrologyCavePlan plan(HydrologyCaveCandidate candidate) { + LinkedHashMap baseline = new LinkedHashMap<>(); + for (CavePosition position : candidate.actions().keySet()) { + baseline.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } + baseline.put(new CavePosition(-(int) candidate.source().sourceId(), 20, 0), + new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + return new HydrologyCavePlan(candidate.source(), HydrologyCaveRejection.NONE, candidate.actions(), + baseline, OptionalLong.empty()); + } + + private RiverCourse course(long id, long outletId) { + HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.UNDERGROUND_POOL, + 20, 20, 2, 2, false, false, List.of(new HydrologyPoint(0, 20, 0)), + HydraulicChannelProfile.uniform(2, 2)); + return new RiverCourse(id, RiverCourseType.UNDERGROUND, OptionalLong.of(id), OptionalLong.of(outletId), + "water", 1, List.of(), List.of(segment)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompilerTest.java similarity index 90% rename from core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java rename to core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompilerTest.java index 956eb3bb9..47c1581ba 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologySettingsCompilerTest.java @@ -27,7 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -public class IrisHydrologyRuntimeSettingsTest { +public class IrisHydrologySettingsCompilerTest { @Test @SuppressWarnings("unchecked") public void regionalSurfaceControlsFingerprintAllScopesAndExpandSpacingReach() { @@ -47,20 +47,20 @@ public class IrisHydrologyRuntimeSettingsTest { when(biomes.loadAll(any(String[].class))).thenReturn(new KList<>(volcanic)); HydrologyPlannerSettings baseline = settings(dimension); - HydrologyPlannerSettings regional = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data); + HydrologyPlannerSettings regional = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> data); assertEquals(HydrologyPlannerSettings.SurfacePolicyBounds.NONE, baseline.surfacePolicyBounds()); assertNotEquals(baseline.fingerprint(), regional.fingerprint()); assertEquals(384, regional.maximumSurfaceSourceSpacing()); volcanic.getRiverPolicy().setSurfaceSourceSpacing(1600); - HydrologyPlannerSettings widened = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data); + HydrologyPlannerSettings widened = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> data); assertNotEquals(regional.fingerprint(), widened.fingerprint()); assertEquals(1600, widened.maximumSurfaceSourceSpacing()); assertTrue(widened.publicationRadius() >= 1600); - assertEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint()); + assertEquals(widened.fingerprint(), IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> data).fingerprint()); dimension.getRiverPolicy().setSurfaceInlandOutlets(7); - assertNotEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint()); + assertNotEquals(widened.fingerprint(), IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> data).fingerprint()); } @Test @@ -90,7 +90,7 @@ public class IrisHydrologyRuntimeSettingsTest { IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig() .setTileSize(256); - assertEquals(128, IrisHydrologyRuntime.maximumDeepChannelLength(deepFluid, routing)); + assertEquals(128, IrisHydrologySettingsCompiler.maximumDeepChannelLength(deepFluid, routing)); } @Test @@ -101,7 +101,7 @@ public class IrisHydrologyRuntimeSettingsTest { IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig() .setTileSize(256); - assertEquals(0, IrisHydrologyRuntime.maximumDeepChannelLength(deepFluid, routing)); + assertEquals(0, IrisHydrologySettingsCompiler.maximumDeepChannelLength(deepFluid, routing)); } @Test @@ -112,12 +112,12 @@ public class IrisHydrologyRuntimeSettingsTest { rivers.getGrottos().getInland().setEnabled(true).setConnectSurfaceRivers(true); rivers.getRouting().getInlandOutlets().add(IrisRiverInlandOutlet.SINKHOLE_GROTTO); - assertTrue(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertTrue(IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .outlets().surfaceSinkholesEnabled()); rivers.getRouting().getInlandOutlets().clear(); - assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertFalse(IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .outlets().surfaceSinkholesEnabled()); } @@ -132,13 +132,13 @@ public class IrisHydrologyRuntimeSettingsTest { rivers.getUnderground().getSources().setDensity(4.5D); rivers.getRouting().setSampleSpacing(64); - assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertEquals(5, IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .surface().sources().maximumPerTile()); - assertEquals(384, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertEquals(384, IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .surface().sources().minimumSpacing()); - assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertEquals(5, IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .underground().sources().maximumPerTile()); - assertEquals(512, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + assertEquals(512, IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .underground().sources().minimumSpacing()); } @@ -173,7 +173,7 @@ public class IrisHydrologyRuntimeSettingsTest { .setSlopePenalty(3D) .setConfluenceAttraction(0.4D); - HydrologyPlannerSettings settings = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null); + HydrologyPlannerSettings settings = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null); HydrologyPlannerSettings.Banks banks = settings.surface().banks(); assertEquals(2, banks.sink()); @@ -220,8 +220,7 @@ public class IrisHydrologyRuntimeSettingsTest { .setMaximumOutletsPerTile(2) .setMaximumCoastalOutletsPerTile(5); - HydrologyPlannerSettings.Outlets outlets = IrisHydrologyRuntime - .createSettings(dimension, dimension.getHydrology(), () -> null) + HydrologyPlannerSettings.Outlets outlets = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .outlets(); assertEquals(2, outlets.maximumPerTile()); @@ -252,8 +251,7 @@ public class IrisHydrologyRuntimeSettingsTest { .setBasinWidthRatio(2.1D) .setMaximumBasinDepth(9); - HydrologyPlannerSettings.Geometry geometry = IrisHydrologyRuntime - .createSettings(dimension, dimension.getHydrology(), () -> null) + HydrologyPlannerSettings.Geometry geometry = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .geometry(); assertEquals(47, geometry.meanders().primaryWavelength()); @@ -284,12 +282,12 @@ public class IrisHydrologyRuntimeSettingsTest { coastal.setEnabled(true); coastal.getSeaCaves().setEnabled(true).setMaximumPerTile(5).setMinimumSpacing(200).setMinimumCoastHeight(9).setDepth(20); - HydrologyPlannerSettings.SeaCaves mapped = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + HydrologyPlannerSettings.SeaCaves mapped = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .seaCaves(); assertEquals(HydrologyPlannerSettings.SeaCaves.of(true, 5, 200, 9, 20), mapped); coastal.setEnabled(false); - HydrologyPlannerSettings.SeaCaves withoutCoastalGrottos = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + HydrologyPlannerSettings.SeaCaves withoutCoastalGrottos = IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null) .seaCaves(); assertFalse(withoutCoastalGrottos.enabled()); assertEquals(5, withoutCoastalGrottos.maximumPerTile()); @@ -297,7 +295,7 @@ public class IrisHydrologyRuntimeSettingsTest { coastal.setEnabled(true); coastal.getSeaCaves().setEnabled(false); - assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null).seaCaves().enabled()); + assertFalse(IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null).seaCaves().enabled()); } @Test @@ -488,7 +486,7 @@ public class IrisHydrologyRuntimeSettingsTest { IrisDimension dimension = new IrisDimension().setRegions(new KList<>("valley")); dimension.getRiverPolicy().setShoreWidth(4D); - assertEquals(4D, IrisHydrologyRuntime.widestShoreBiomeWidth(dimension, () -> null, 1.5D), 0D); + assertEquals(4D, IrisHydrologySettingsCompiler.widestShoreBiomeWidth(dimension, () -> null, 1.5D), 0D); assertEquals(4D, settings(dimension).widestShoreBiomeWidth(), 0D); IrisRegion valley = new IrisRegion().setRiverPolicy(new IrisRiverPolicy().setShoreBiomeWidth(6D)); @@ -503,18 +501,18 @@ public class IrisHydrologyRuntimeSettingsTest { when(biomeLoader.getPossibleKeys()).thenReturn(new String[]{"beach", "plain"}); when(biomeLoader.loadAll(any(String[].class))).thenReturn(new KList<>(beach, plain)); - assertEquals(9D, IrisHydrologyRuntime.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); + assertEquals(9D, IrisHydrologySettingsCompiler.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); beach.getRiverPolicy().setShoreWidth(null).setShoreBiomeWidth(11D); - assertEquals(11D, IrisHydrologyRuntime.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); + assertEquals(11D, IrisHydrologySettingsCompiler.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); valley.getRiverPolicy().setShoreWidth(12D); - assertEquals(12D, IrisHydrologyRuntime.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); + assertEquals(12D, IrisHydrologySettingsCompiler.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); dimension.getRiverPolicy().setShoreWidth(null); valley.getRiverPolicy().setShoreWidth(null).setShoreBiomeWidth(null); beach.getRiverPolicy().setShoreBiomeWidth(null); - assertEquals(1.5D, IrisHydrologyRuntime.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); + assertEquals(1.5D, IrisHydrologySettingsCompiler.widestShoreBiomeWidth(dimension, () -> data, 1.5D), 0D); } @Test @@ -537,6 +535,6 @@ public class IrisHydrologyRuntimeSettingsTest { } private static HydrologyPlannerSettings settings(IrisDimension dimension) { - return IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null); + return IrisHydrologySettingsCompiler.compile(dimension, dimension.getHydrology(), () -> null); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java index d759214a8..7410c6378 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java @@ -6,6 +6,39 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class SurfaceNoiseTest { + @Test + public void scalarBitsPreserveChannelGeometry() { + long[][] samples = { + {0L, 0, 0, 1, 0x3fc9ff45ea7ce2bcL, 0xbfe3005d0ac18ea2L}, + {77L, -40, 39, 1, 0x3fe1608c5d9f966eL, 0x3fb608c5d9f966e0L}, + {77L, -40, 39, 2, 0x3fd12fd3b2255266L, 0xbfdda0589bb55b34L}, + {77L, -40, 39, 3, 0x3fde59c9633f9d3eL, 0xbfaa6369cc062c20L}, + {77L, -40, 39, 7, 0x3febee1ee702b638L, 0x3fe7dc3dce056c70L}, + {77L, -40, 39, 12, 0x3fe58a3d8c6a3a26L, 0x3fd628f631a8e898L}, + {77L, -40, 39, 16, 0x3fe379956dd75dfbL, 0x3fcbccab6ebaefd8L}, + {77L, -40, 39, 31, 0x3fdd9757689aa0dcL, 0xbfb34544bb2af920L}, + {77L, -40, 39, 64, 0x3fe0968a66559de7L, 0x3fa2d14ccab3bce0L}, + {0L, -12, -12, 12, 0x3fa2ed0fbd6e4760L, 0xbfeda25e08523714L}, + {0L, -13, -11, 12, 0x3fabab89552088a4L, 0xbfec8a8ed55beeecL}, + {-1L, 12, -12, 12, 0x3fc3b99ed5ce495cL, 0xbfe623309518db52L}, + {Long.MIN_VALUE, -1, 1, 31, 0x3fe213950b5ed096L, 0x3fc09ca85af684b0L}, + {Long.MAX_VALUE, 255, -257, 256, 0x3fcb1a5a870174baL, 0xbfe272d2bc7f45a3L}, + {1337L, -12345, 67890, 8192, 0x3fd4e8d04021f6aaL, 0xbfd62e5f7fbc12acL}, + {1337L, Integer.MIN_VALUE, Integer.MAX_VALUE, 1, 0x3fdc5d75fb3dcf0cL, 0xbfbd1450261187a0L}, + {1337L, Integer.MIN_VALUE, Integer.MAX_VALUE, 12, 0x3feb8585bd4cd270L, 0x3fe70b0b7a99a4e0L}, + {1337L, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, 0x3fed6ff84c27d2e8L, 0x3feadff0984fa5d0L} + }; + for (long[] sample : samples) { + long seed = sample[0]; + int x = (int) sample[1]; + int z = (int) sample[2]; + int wavelength = (int) sample[3]; + String context = "seed=" + seed + ", x=" + x + ", z=" + z + ", wavelength=" + wavelength; + assertEquals(context, sample[4], Double.doubleToLongBits(SurfaceNoise.value(seed, x, z, wavelength))); + assertEquals(context, sample[5], Double.doubleToLongBits(SurfaceNoise.signed(seed, x, z, wavelength))); + } + } + @Test public void valuesStayInRangeAndAreDeterministic() { for (int x = -40; x < 40; x++) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java index e0000cf74..c28bd8ae7 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectIoWriteLimitsTest.java @@ -2,12 +2,15 @@ package art.arcane.iris.engine.object; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KMap; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -51,6 +54,39 @@ public class IrisObjectIoWriteLimitsTest { return object; } + @Test + public void preservesV2HeaderSignedCoordinatesAndTilePayload() throws IOException { + IrisObject object = new IrisObject(7, 9, 11); + object.blocks.put(new IrisBlockVector(-3, 2, -1), state("minecraft:chest")); + KMap properties = new KMap<>(); + properties.put("marker", "stored"); + object.states.put(new IrisBlockVector(-3, 2, -1), new TileData("minecraft:chest", properties)); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + + IrisObjectIO.write(object, encoded); + + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded.toByteArray()))) { + assertEquals(7, input.readInt()); + assertEquals(9, input.readInt()); + assertEquals(11, input.readInt()); + assertEquals("Iris V2 IOB;", input.readUTF()); + assertEquals(1, input.readShort()); + assertEquals("minecraft:chest", input.readUTF()); + assertEquals(1, input.readInt()); + assertEquals(-3, input.readShort()); + assertEquals(2, input.readShort()); + assertEquals(-1, input.readShort()); + assertEquals(0, input.readShort()); + assertEquals(1, input.readInt()); + assertEquals(-3, input.readShort()); + assertEquals(2, input.readShort()); + assertEquals(-1, input.readShort()); + assertEquals("minecraft:chest", input.readUTF()); + assertEquals("{\"marker\":\"stored\"}", input.readUTF()); + assertEquals(-1, input.read()); + } + } + @Test public void rejectsPaletteOverflowInsteadOfWrappingTheShort() { IrisObject object = oversizedPaletteObject; diff --git a/core/src/test/java/art/arcane/iris/platform/BlockStateKeyTest.java b/core/src/test/java/art/arcane/iris/platform/BlockStateKeyTest.java new file mode 100644 index 000000000..237bde79c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/BlockStateKeyTest.java @@ -0,0 +1,36 @@ +package art.arcane.iris.platform; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class BlockStateKeyTest { + @Test + public void namespaceIgnoresPropertyValues() { + assertEquals("minecraft", BlockStateKey.namespace("stone")); + assertEquals("minecraft", BlockStateKey.namespace("stone[variant=custom:stone]")); + assertEquals("custom", BlockStateKey.namespace("custom:stone[variant=minecraft:stone]")); + assertEquals("", BlockStateKey.namespace(":stone")); + } + + @Test + public void addsAndReplacesPropertiesInEncounterOrder() { + assertEquals("minecraft:stairs[facing=east]", + BlockStateKey.withProperty("minecraft:stairs", "facing", "east")); + assertEquals("minecraft:stairs[facing=east,half=top]", + BlockStateKey.withProperty("minecraft:stairs[facing=north,half=top]", "facing", "east")); + assertEquals("minecraft:stairs[facing=north,half=top,waterlogged=true]", + BlockStateKey.withProperty("minecraft:stairs[facing=north,half=top]", "waterlogged", "true")); + } + + @Test + public void retainsDuplicateAndMalformedEntryHandling() { + assertEquals("custom:block[a=last,b=left=right,c=value]", + BlockStateKey.withProperty("custom:block[ a = first ,invalid,b=left=right,a=last]", "c", "value")); + assertEquals("stone[x=y]", BlockStateKey.withProperty("stone[]", "x", "y")); + assertThrows(StringIndexOutOfBoundsException.class, + () -> BlockStateKey.withProperty("stone[x=y", "x", "z")); + assertThrows(NullPointerException.class, () -> BlockStateKey.namespace(null)); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java index 48e819fe3..bcf687660 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java @@ -211,21 +211,7 @@ public final class GenerationOrderProbe { System.out.println(LOG_PREFIX + " FAIL: probe execution failed"); failure.printStackTrace(System.out); AggregateSignature unavailable = new AggregateSignature("unavailable", "unavailable", "unavailable"); - ProbeResult failed = new ProbeResult( - "FAIL", - configuration.dimensionKey(), - configuration.seed(), - configuration.chunkCount(), - configuration.minimumChunkX(), - configuration.maximumChunkX(), - configuration.minimumChunkZ(), - configuration.maximumChunkZ(), - configuration.parallelism(), - configuration.shuffleSeed(), - configuration.multicore(), - configuration.studio(), - unavailable - ); + ProbeResult failed = result(configuration, "FAIL", unavailable); System.out.println(failed.machineLine()); System.exit(1); } @@ -264,11 +250,12 @@ public final class GenerationOrderProbe { for (Map.Entry entry : ordered.entrySet()) { ChunkCoordinate coordinate = entry.getKey(); ChunkHash hash = entry.getValue(); - updateDigest(blocks, coordinate.x() + "," + coordinate.z()); + byte[] encodedCoordinate = (coordinate.x() + "," + coordinate.z()).getBytes(StandardCharsets.UTF_8); + updateDigest(blocks, encodedCoordinate); updateDigest(blocks, hash.blocks()); - updateDigest(biomes, coordinate.x() + "," + coordinate.z()); + updateDigest(biomes, encodedCoordinate); updateDigest(biomes, hash.biomes()); - updateDigest(combined, coordinate.x() + "," + coordinate.z()); + updateDigest(combined, encodedCoordinate); updateDigest(combined, hash.combined()); } return new AggregateSignature( @@ -391,8 +378,12 @@ public final class GenerationOrderProbe { + mismatches.size() + " chunk(s)."); } + return result(configuration, "PASS", baseline.signature()); + } + + private static ProbeResult result(ProbeConfiguration configuration, String status, AggregateSignature signature) { return new ProbeResult( - "PASS", + status, configuration.dimensionKey(), configuration.seed(), configuration.chunkCount(), @@ -404,7 +395,7 @@ public final class GenerationOrderProbe { configuration.shuffleSeed(), configuration.multicore(), configuration.studio(), - baseline.signature() + signature ); } diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java index d8acd9926..6677a9760 100644 --- a/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java @@ -102,6 +102,11 @@ public final class GenerationOrderProbeTest { GenerationOrderProbe.aggregateSignature(forward), GenerationOrderProbe.aggregateSignature(reverse) ); + assertEquals(new GenerationOrderProbe.AggregateSignature( + "f5a500c97351e576fb335317c0a716c48c320b1304a1f4dc9658f1634b80de98", + "1bf3a08fecb828a69b7c504459d2a53b7d089aa9b16040000bb5e06f9e1e2b5f", + "d667a0482bb425cee8f7384284472ce915cfdd6a1b9a123fa8a58ee26b67f9f9"), + GenerationOrderProbe.aggregateSignature(forward)); reverse.put(first, new GenerationOrderProbe.ChunkHash("changed", "b", "changed")); assertNotEquals( GenerationOrderProbe.aggregateSignature(forward), diff --git a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireReader.java b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireReader.java index e0d30de4a..36696c0c6 100644 --- a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireReader.java +++ b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireReader.java @@ -125,13 +125,7 @@ public final class IrisWireReader { * @throws ProtocolException if the length is negative or exceeds the remaining bytes */ public String readString() throws ProtocolException { - int declaredLength = readVarInt(); - if (declaredLength < 0) { - throw new ProtocolException("negative string length"); - } - if (declaredLength > remaining()) { - throw new ProtocolException("string length exceeds remaining frame bytes"); - } + int declaredLength = readLength("negative string length", "string length exceeds remaining frame bytes"); String value = new String(frame, position, declaredLength, StandardCharsets.UTF_8); position += declaredLength; return value; @@ -143,19 +137,24 @@ public final class IrisWireReader { * @throws ProtocolException if the length is negative or exceeds the remaining bytes */ public byte[] readBytes() throws ProtocolException { - int declaredLength = readVarInt(); - if (declaredLength < 0) { - throw new ProtocolException("negative byte array length"); - } - if (declaredLength > remaining()) { - throw new ProtocolException("byte array length exceeds remaining frame bytes"); - } + int declaredLength = readLength("negative byte array length", "byte array length exceeds remaining frame bytes"); byte[] value = new byte[declaredLength]; System.arraycopy(frame, position, value, 0, declaredLength); position += declaredLength; return value; } + private int readLength(String negativeMessage, String oversizedMessage) throws ProtocolException { + int declaredLength = readVarInt(); + if (declaredLength < 0) { + throw new ProtocolException(negativeMessage); + } + if (declaredLength > remaining()) { + throw new ProtocolException(oversizedMessage); + } + return declaredLength; + } + private int remaining() { return limit - position; } diff --git a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireWriter.java b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireWriter.java index c462c6477..6f1fbc351 100644 --- a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireWriter.java +++ b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireWriter.java @@ -104,11 +104,7 @@ public final class IrisWireWriter { * Writes a varint-length-prefixed UTF-8 string. {@code value} must not be null. */ public void writeString(String value) { - byte[] encoded = value.getBytes(StandardCharsets.UTF_8); - writeVarInt(encoded.length); - ensure(encoded.length); - System.arraycopy(encoded, 0, buffer, length, encoded.length); - length += encoded.length; + writeBytes(value.getBytes(StandardCharsets.UTF_8)); } /** diff --git a/spi/src/test/java/art/arcane/iris/spi/protocol/IrisWirePayloadTest.java b/spi/src/test/java/art/arcane/iris/spi/protocol/IrisWirePayloadTest.java new file mode 100644 index 000000000..c2a178dbb --- /dev/null +++ b/spi/src/test/java/art/arcane/iris/spi/protocol/IrisWirePayloadTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.spi.protocol; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisWirePayloadTest { + @Test + public void preservesMixedPayloadEncodingAndIndependentCopies() throws Exception { + IrisWireWriter writer = new IrisWireWriter(); + byte[] payload = {0, (byte) 0xff}; + writer.writeString("é\u0000"); + writer.writeBytes(payload); + writer.writeString(""); + writer.writeBytes(new byte[0]); + writer.writeInt(0x12345678); + payload[0] = 42; + + byte[] frame = writer.toByteArray(); + assertArrayEquals(new byte[]{3, (byte) 0xc3, (byte) 0xa9, 0, 2, 0, (byte) 0xff, + 0, 0, 0x12, 0x34, 0x56, 0x78}, frame); + IrisWireReader reader = new IrisWireReader(frame); + assertEquals("é\u0000", reader.readString()); + byte[] decoded = reader.readBytes(); + assertArrayEquals(new byte[]{0, (byte) 0xff}, decoded); + decoded[0] = 42; + assertEquals(0, frame[5]); + assertEquals("", reader.readString()); + assertArrayEquals(new byte[0], reader.readBytes()); + assertEquals(0x12345678, reader.readInt()); + } + + @Test + public void preservesPayloadLengthErrorsAndConsumesOnlyThePrefix() throws Exception { + byte[] negative = {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x0f, 1}; + byte[] oversized = {2, 1}; + IrisWireReader negativeString = new IrisWireReader(negative); + IrisWireReader negativeBytes = new IrisWireReader(negative); + IrisWireReader oversizedString = new IrisWireReader(oversized); + IrisWireReader oversizedBytes = new IrisWireReader(oversized); + + assertEquals("negative string length", assertThrows(ProtocolException.class, negativeString::readString).getMessage()); + assertEquals("negative byte array length", assertThrows(ProtocolException.class, negativeBytes::readBytes).getMessage()); + assertEquals("string length exceeds remaining frame bytes", + assertThrows(ProtocolException.class, oversizedString::readString).getMessage()); + assertEquals("byte array length exceeds remaining frame bytes", + assertThrows(ProtocolException.class, oversizedBytes::readBytes).getMessage()); + assertTrue(negativeString.readBoolean()); + assertTrue(negativeBytes.readBoolean()); + assertTrue(oversizedString.readBoolean()); + assertTrue(oversizedBytes.readBoolean()); + } + + @Test + public void preservesFrameCapAndPrefixWrittenBeforePayloadOverflow() { + int maximumPayload = IrisProtocol.MAX_FRAME_BYTES - 3; + IrisWireWriter exactString = new IrisWireWriter(); + IrisWireWriter exactBytes = new IrisWireWriter(); + exactString.writeString("x".repeat(maximumPayload)); + exactBytes.writeBytes(new byte[maximumPayload]); + assertEquals(IrisProtocol.MAX_FRAME_BYTES, exactString.toByteArray().length); + assertEquals(IrisProtocol.MAX_FRAME_BYTES, exactBytes.toByteArray().length); + + IrisWireWriter oversizedString = new IrisWireWriter(); + IrisWireWriter oversizedBytes = new IrisWireWriter(); + IrisWireWriter prefix = new IrisWireWriter(); + prefix.writeVarInt(maximumPayload + 1); + String message = "Iris protocol frame exceeds " + IrisProtocol.MAX_FRAME_BYTES + " byte cap"; + assertEquals(message, assertThrows(IllegalStateException.class, + () -> oversizedString.writeString("x".repeat(maximumPayload + 1))).getMessage()); + assertEquals(message, assertThrows(IllegalStateException.class, + () -> oversizedBytes.writeBytes(new byte[maximumPayload + 1])).getMessage()); + assertArrayEquals(prefix.toByteArray(), oversizedString.toByteArray()); + assertArrayEquals(prefix.toByteArray(), oversizedBytes.toByteArray()); + } + + @Test + public void rejectsNullPayloadsWithoutWriting() { + IrisWireWriter writer = new IrisWireWriter(); + assertThrows(NullPointerException.class, () -> writer.writeString(null)); + assertThrows(NullPointerException.class, () -> writer.writeBytes(null)); + assertArrayEquals(new byte[0], writer.toByteArray()); + } +}