diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index d01e8ae60..510d8e682 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -17,6 +17,7 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; +import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; @@ -64,6 +65,7 @@ public class CustomBiomeSource extends BiomeSource { private final long seed; private final Engine engine; + private final BukkitChunkGenerator platformGenerator; private final Registry biomeCustomRegistry; private final Registry biomeRegistry; private final AtomicCache registryAccess = new AtomicCache<>(); @@ -76,6 +78,9 @@ public class CustomBiomeSource extends BiomeSource { public CustomBiomeSource(long seed, Engine engine, World world) { this.engine = engine; + this.platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator generator + ? generator + : null; this.seed = seed; this.biomeCustomRegistry = registry().lookup(Registries.BIOME).orElse(null); this.biomeRegistry = ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())).lookup(Registries.BIOME).orElse(null); @@ -353,6 +358,9 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { return null; } + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + return fallbackBiome; + } runtimeBiomeState(); int quartX = QuartPos.fromBlock(blockX); int quartZ = QuartPos.fromBlock(blockZ); @@ -405,6 +413,9 @@ public class CustomBiomeSource extends BiomeSource { } private Holder getStructureNoiseBiomeWithActiveGenerationLease(int x, int y, int z) { + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + return fallbackBiome; + } if (isGuaranteedSurfaceBiome(y)) { return getSurfaceStructureBiomeHolder(x, z); } @@ -731,6 +742,9 @@ public class CustomBiomeSource extends BiomeSource { IrisDimensionCarvingResolver.State resolverState, boolean cacheable ) { + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + return fallbackBiome; + } RuntimeNoiseKey cacheKey = new RuntimeNoiseKey( engine.getCacheID(), packNoiseKey(x, y, z)); if (cacheable) { 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 b41fa876c..2b209be01 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 @@ -13,6 +13,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.platform.BukkitChunkGenerator; +import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisMaterialPalette; @@ -123,6 +124,13 @@ import java.util.function.IntBinaryOperator; import java.util.function.Predicate; public class IrisChunkGenerator extends CustomChunkGenerator { + private static final Set AUTHORING_HEIGHTMAPS = Set.of( + Heightmap.Types.WORLD_SURFACE_WG, + Heightmap.Types.OCEAN_FLOOR_WG, + Heightmap.Types.WORLD_SURFACE, + Heightmap.Types.OCEAN_FLOOR, + Heightmap.Types.MOTION_BLOCKING, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES); private static final WrappedField BIOME_SOURCE; private static final WrappedReturningMethod SET_HEIGHT; private static final Runnable NO_OP = () -> { @@ -854,9 +862,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } private void primeWorldgenHeightmaps(ChunkAccess chunkAccess) { + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + primeAuthoringHeightmaps(chunkAccess); + return; + } WorldgenTerrainHeightmaps.primeTerrain(chunkAccess, worldgenSurfaceHeight(), worldgenFloorHeight()); } + static void primeAuthoringHeightmaps(ChunkAccess chunkAccess) { + Heightmap.primeHeightmaps(chunkAccess, AUTHORING_HEIGHTMAPS); + } + private IntBinaryOperator worldgenSurfaceHeight() { return (x, z) -> engine.getHeight(x, z, false) + runtimeMinY + 1; } @@ -934,15 +950,20 @@ public class IrisChunkGenerator extends CustomChunkGenerator { GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { if (allowsRoutedDiscreteGeneration(ichunkaccess, ChunkStatus.FEATURES)) { - // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a - // feature-order cycle is reported once and degraded to features-off. - importedFeatures.prepare(generatoraccessseed); + boolean flatStudioTerrain = platformGenerator != null && platformGenerator.usesFlatStudioTerrain(); + if (!flatStudioTerrain) { + // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a + // feature-order cycle is reported once and degraded to features-off. + importedFeatures.prepare(generatoraccessseed); + } addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); - placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); - // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with - // addVanillaDecorations=false below, so the vanilla half never runs twice. Inert unless the - // dimension set importedFeatures.enabled. - importedFeatures.run(generatoraccessseed, ichunkaccess, this); + if (!flatStudioTerrain) { + placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); + // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with + // addVanillaDecorations=false below, so the vanilla half never runs twice. Inert unless the + // dimension set importedFeatures.enabled. + importedFeatures.run(generatoraccessseed, ichunkaccess, this); + } delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); } claimGeneratedSemantics(route, "bukkit_nms_biome_decoration"); @@ -1219,6 +1240,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { BlockPos blockPos = sectionPos.origin(); primeWorldgenHeightmaps(chunkAccess); + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + return; + } Heightmap motion = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING); Heightmap motionNoLeaves = chunkAccess.getOrCreateHeightmapUnprimed(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES); @@ -1310,6 +1334,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getBaseHeight(int i, int j, Heightmap.Types heightmap_type, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + return platformGenerator.getAuthoringBaseHeight(); + } try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( i, j, "bukkit_nms_base_height"); GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); @@ -1327,6 +1354,19 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { + if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { + BlockState[] column = new BlockState[levelheightaccessor.getHeight()]; + int floorIndex = platformGenerator.getAuthoringFloorY() - levelheightaccessor.getMinY(); + BlockState floor = platformGenerator.isJigsawStudioActive() && JigsawStudioGenerator.isLightFloor(i, j) + ? Blocks.SMOOTH_STONE.defaultBlockState() + : Blocks.POLISHED_DEEPSLATE.defaultBlockState(); + for (int index = 0; index < column.length; index++) { + column[index] = index == floorIndex + ? floor + : Blocks.AIR.defaultBlockState(); + } + return new NoiseColumn(levelheightaccessor.getMinY(), column); + } try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( i, j, "bukkit_nms_base_column"); GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_column"); diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index aea901c43..308fa9abb 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -1279,9 +1279,9 @@ public class NMSBinding implements INMSBinding { : null; boolean studioBootstrap = platformGenerator != null && platformGenerator.isStudioEntryBootstrapActive(); - boolean jigsawStudio = platformGenerator != null - && platformGenerator.isJigsawStudioActive(); - if (jigsawStudio) { + boolean authoringStudio = platformGenerator != null + && platformGenerator.isAuthoringStudio(); + if (authoringStudio) { requireIrisGenerator(generator); if (currentState.possibleStructureSets().isEmpty()) { currentState.ensureStructuresGenerated(); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 172fb00e2..cacda43c5 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -24,6 +24,31 @@ public class CustomBiomeSourceStructureContractTest { Bootstrap.bootStrap(); } + @Test + public void authoringBiomesBypassTerrainResolutionBeforeDestinationChunksGenerate() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int visibleStart = source.indexOf("private Holder getVisibleNoiseBiomeWithActiveGenerationLease("); + int visibleEnd = source.indexOf("private boolean isBiomeCacheable(", visibleStart); + String visible = source.substring(visibleStart, visibleEnd); + int constantBiome = visible.indexOf("platformGenerator.usesFlatStudioTerrain()"); + int terrainResolution = visible.indexOf("resolveVisibleBiomeHolder("); + + assertTrue(source.contains("platformGenerator.usesFlatStudioTerrain()")); + assertTrue(constantBiome >= 0); + assertTrue(visible.indexOf("return fallbackBiome;", constantBiome) < terrainResolution); + assertTrue(terrainResolution > constantBiome); + + int structureStart = source.indexOf("private Holder getStructureNoiseBiomeWithActiveGenerationLease("); + int structureEnd = source.indexOf("boolean cacheable", structureStart); + assertTrue(source.substring(structureStart, structureEnd).contains("platformGenerator.usesFlatStudioTerrain()")); + + String generator = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int createBiomes = generator.indexOf("public CompletableFuture createBiomes("); + int buildSurface = generator.indexOf("public void buildSurface(", createBiomes); + assertTrue(generator.substring(createBiomes, buildSurface) + .contains("customBiomeSource.getVisibleNoiseBiomeWithActiveGenerationLease(")); + } + @Test public void nativeStructuresUseTerrainSafeDerivativeAtEveryBiomeBoundary() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index 067fcf84d..92959a007 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -221,6 +221,49 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } + @Test + public void authoringHeightmapStagesReturnBeforeAnyTerrainHeightResolver() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int primeStart = source.indexOf("private void primeWorldgenHeightmaps("); + int primeEnd = source.indexOf("private IntBinaryOperator worldgenSurfaceHeight()", primeStart); + String prime = source.substring(primeStart, primeEnd); + assertBefore(prime, "platformGenerator.usesFlatStudioTerrain()", "worldgenSurfaceHeight()"); + assertBefore(prime, "primeAuthoringHeightmaps(chunkAccess);", "return;"); + assertBefore(prime, "return;", "WorldgenTerrainHeightmaps.primeTerrain("); + + int decorationStart = source.indexOf("public void addVanillaDecorations("); + int decorationEnd = source.indexOf("public void spawnOriginalMobs(", decorationStart); + String decorations = source.substring(decorationStart, decorationEnd); + assertBefore(decorations, "primeWorldgenHeightmaps(chunkAccess);", "platformGenerator.usesFlatStudioTerrain()"); + assertBefore(decorations, "platformGenerator.usesFlatStudioTerrain()", "return;"); + assertBefore(decorations, "return;", "engine.getHeight("); + } + + @Test + public void authoringDecorationSkipsNativeFeaturesButRetainsHeightmapsAndBukkitPopulators() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + String decoration = method(source, + "public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)", + "public BiomeGenerationSettings getBiomeGenerationSettings"); + int preparationGuard = decoration.indexOf("if (!flatStudioTerrain)"); + int preparationEnd = decoration.indexOf("\n }", preparationGuard); + String preparation = decoration.substring(preparationGuard, preparationEnd); + assertTrue(preparation.contains("importedFeatures.prepare(generatoraccessseed);")); + + int placementGuard = decoration.indexOf("if (!flatStudioTerrain)", preparationEnd); + int placementEnd = decoration.indexOf("\n }", placementGuard); + String placement = decoration.substring(placementGuard, placementEnd); + assertTrue(placement.contains("placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager);")); + assertTrue(placement.contains("importedFeatures.run(generatoraccessseed, ichunkaccess, this);")); + int heightmaps = decoration.indexOf("addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager);"); + int populators = decoration.indexOf("delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false);"); + assertTrue(heightmaps > preparationEnd && heightmaps < placementGuard); + assertTrue(populators > placementEnd); + assertTrue(decoration.indexOf("claimGeneratedSemantics(route,") > populators); + assertTrue(decoration.contains("requireGenerationStage(\"bukkit_nms_biome_decoration\")")); + assertTrue(decoration.contains("requireGenerationLease(\"bukkit_nms_biome_decoration\")")); + } + @Test public void everyChunkUsesTheProductionGenerationStages() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))) diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java index fcf2f33da..6e06599ff 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java @@ -352,7 +352,7 @@ public class NMSBindingDatapackStructureScopeTest { } @Test - public void standardPublishesOneDeferredStateWhileJigsawPublishesInitializedEmptyState() throws IOException { + public void standardPublishesOneDeferredStateWhileAuthoringPublishesInitializedEmptyState() throws IOException { Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")).replace("\r\n", "\n"); int methodStart = source.indexOf("public DatapackStructureScopeResult scopeDatapackStructures("); @@ -365,8 +365,8 @@ public class NMSBindingDatapackStructureScopeTest { String method = source.substring(methodStart, methodEnd); int filteredState = method.indexOf("possibleSetsField.set(scopedState, selection.structureSets());"); int jigsawMode = method.indexOf( - "boolean jigsawStudio = platformGenerator != null"); - int jigsawOnly = method.indexOf("if (jigsawStudio)"); + "boolean authoringStudio = platformGenerator != null"); + int jigsawOnly = method.indexOf("if (authoringStudio)"); int emptyCreation = method.indexOf("ChunkGeneratorStructureState bootstrapState = createStructureState("); int emptyFiltering = method.indexOf("bootstrapSetsField.set(bootstrapState, List.of());"); int emptyInitialization = method.indexOf("bootstrapState.ensureStructuresGenerated();"); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java index e7219736c..757a450fe 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java @@ -64,6 +64,21 @@ public class WorldgenTerrainHeightmapsTest { Bootstrap.bootStrap(); } + @Test + public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() { + ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0)); + + IrisChunkGenerator.primeAuthoringHeightmaps(chunk); + + assertEquals(LAND_CANOPY_TOP, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, LAND_X, LAND_Z)); + assertEquals(LAND_CANOPY_TOP, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, LAND_X, LAND_Z)); + assertEquals(LAND_CANOPY_TOP, chunk.getHeight(Heightmap.Types.MOTION_BLOCKING, LAND_X, LAND_Z)); + assertEquals(LAND_TERRAIN_TOP + 6, + chunk.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, LAND_X, LAND_Z)); + assertEquals(OCEAN_FLUID_TOP, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, OCEAN_X, OCEAN_Z)); + assertEquals(OCEAN_FLOOR_TOP, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, OCEAN_X, OCEAN_Z)); + } + @Test public void primeTerrainWritesEngineSurfaceAndOceanFloorHeights() { ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0)); diff --git a/core/generation-kernels/abi-1.seal b/core/generation-kernels/abi-1.seal index 625bdb916..5fe234d65 100644 --- a/core/generation-kernels/abi-1.seal +++ b/core/generation-kernels/abi-1.seal @@ -16,11 +16,11 @@ scope core/src/main/java scope lombok.config scope spi/src/main/java exclude core/src/main/java/art/arcane/iris/engine/kernels -source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java a8987a308db171229d6379bae2698ad827ee58b6f86eee4164f3e594aeaf522f +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java d195942b064c0f3d694f824870a44883043e5c47b19099e81072a871fbe0a9a5 source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java b1ae6b3ab4e0ed1d0292681e90a875dbbf449fef29cc7af281ec01a34100fa14 source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java 8eafc6ddd5dc3023e80fd97a718183e142c58998444502a835a8f8331a53ff5a -source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java 145cadbccef9253ca8b29ba441c164a27d2c50e6a7c2177b18d1b999c0391ef7 -source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java 0b0823ddc0b03c7788ec33678f70acda0a26979158bbefd3809058cbf898a9b5 +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java bdf785a793ba615f45d4a375984ab695f5abc56d54e360623df94a6eddfa048d +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java baeb4056e11f72108d920b1013eb05a66b9c08c0c8f384f0c7ee73335d5ca557 source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java a33a05dfcd1467a7b27a3245b819d21d9dac6024306901bcaafc43ff0f61bebc source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java 8e7aa89be6166c40801edbe3c76bc7ff767e6a80874bdfc454c98ac7615da297 source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java bc9ef399119dd325e9c7256ffb9c9d070022f30af7c23c3711aeb2f736ba724b @@ -462,7 +462,7 @@ source core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java c3 source core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b51b82898fe5dc618880ff0af5553e21f82b41499eb2d2dc13fe7c3c1295be14 source core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java 2e88ed567e1f477f1b77450932983ebf5c9e2fae866a66ad4713879f1f233d11 source core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java 6d0f68a637ef260729d5e67da6b2211d204c78fb2d07866467923740e9f9ffb6 -source core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java f06951398de67eef6d2ea60c607673113353991b3c0db838ea4ce33d73d35be2 +source core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java 2965a14213a6a463ab8c9a2b2bc47ef68ba37ede248263bdcc5bb2144bac35ab source core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java 8a01dfff59d07b75f90edffef9599d9a0ae44b3a33cced65af190fd9576690fb source core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java aa6fca878aedd70c394c034cf62b6b7a0051875275682b154805121422da00d3 source core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java bdf3ba2e9e2ccd4b34d272635bd615de4be7a09166ed01798811c0574b1d24d2 @@ -472,7 +472,7 @@ source core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformancePro source core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java d57637b5a9e7c8e09c58e71fc8a1ff23eb295435c9fcd411746d5e8176a9e015 source core/src/main/java/art/arcane/iris/core/pregenerator/PregenRateTracker.java 53304e35af5b0e32b86dd75bdb1d4052630150ace2751fc8ba86ca38e94ae527 source core/src/main/java/art/arcane/iris/core/pregenerator/PregenRates.java d348acd7898d701e71bda83da20eb9ba09bad4f6779966ad87aa8a60522c3c8d -source core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java a52c1ff8be510f5357bc721b88e735b6bf14c389956f50e249f74f8970af2320 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java 04c174899d60a48e4ec9ed2bf0e72e4376b1f020bac7049f52766a59207855f3 source core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java 6b259a5b14fd513c441f26c053e5713a646b7aac88542dc5c4e53f6aa4c008af source core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCache.java e6af20dfaeea6967ee0d561631f612e0b2dae7e22182773001844d3522bd79de source core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java 2dafaaeb0b5bc8e65db9b0cf25ed2786cfa0f561c6712b7b5817568c239a9eb6 @@ -512,7 +512,7 @@ source core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.j source core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioActivation.java b3fa731a267ace9e052f1d4cbc437ecc1b6102c2c0ae030c21c9262c41069386 source core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java b78868e5adefca27c3f1f9544256317b1949fe208f02ccee7dbffc61c389e129 source core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java 443047b33624db675576a189c843952854fb8852c9df979eca044e8f0e3c23e6 -source core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b501913f1704a3e59bcf6982d6683110565c549725d00311061a7dbb7dc0ef88 +source core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java e8ad851c680934278f1cefd954ecb40d48b0d4c1e1213dca9e6dc31a8c648946 source core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java 54fe5614808ffa4cef920e6428f6a1fd70c23d01e3ab16d75fac862271dd2e97 source core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java 4e77be2cb6c1bde9abc38253208c4a86bae0e575d8b6ddd7defdada414248ac3 source core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java 0827d2b8412ffdc33ab35f1abb732584f85935dac3e882e9e04994b0cfd3fcce @@ -656,8 +656,8 @@ source core/src/main/java/art/arcane/iris/engine/EngineTickRegistry.java 9ae20d3 source core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java 821455b42549511328dfd90e5447dffcdf8ea1311490f1b0ada2e65ca1a311d2 source core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java 09e250e72e1ac63c38c29dc0860c951955ad71a7f9ec7a36ac27d7d27839b11c source core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java a05de937d2d8ed1b5017549f1083e5f6592d944fa5a136dcfb5e9bc33eb78f2d -source core/src/main/java/art/arcane/iris/engine/IrisComplex.java 257916da58a83632887123debc9f0dea9a63087de61391550774982f5c613fb6 -source core/src/main/java/art/arcane/iris/engine/IrisEngine.java c64aabd254a8e75d0a52c5c4b7b43f06013d6762c7a1cac4bb1c324e9447db80 +source core/src/main/java/art/arcane/iris/engine/IrisComplex.java 5ae7ffc3ee932fd140100d48a9127db8c1216f950c8c0c20c8f27d537d4683a4 +source core/src/main/java/art/arcane/iris/engine/IrisEngine.java 739b16fd1c283f6499328138406b4ffdbcb1be08c6bc696e4dc9f0cee372258d source core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java 67cf96aed6c213dbd6d6cb81866afd29cd30b555cd4e88712395bc089e50f43c source core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java 2c4251a6ad59b5435488fc3b83191c3753d5e356bc5c58832bba2c56a3a5aae9 source core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java 5f441c4ba85045b4c273e934599e65df0122586d97cb23ddae3ed76c9471c759 @@ -823,14 +823,14 @@ source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticRe source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java 60e6478e03dd339f57e7d6633c02bb6308d2de27ea86a634b26745b697121708 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureRef.java 6e02a66c7ec7c9ee4732176eea248e4ba3174c66c164da02c12dee7e229cb0ee source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java aa574734891b581bd140320cd23707d9f61bd9b3afa851909d4c8fe6e20dc1e4 -source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java 076eb55abe053524cd1990c62458e007092fcba69118c620592edfef0fbad4f2 -source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java 25005438b669b33bad5344ad6dd08127d0e928a3d383c2bb25610acb385322f1 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b8e486b2c8ff0769737c21120a582acef7520d47c760ba8c8558f482093f1857 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java 50d933ca908f7c3322e34b866fb422baa353c8c4741df2ddecb1fb4c76e803ca source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java 77ce4ed588dd7238ae448e8997a688df8cc3a21ed4fc8f829d593b9bfbfdd7c5 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java 41771991828e67f219c36985ffc8dd3f65e9664b0a51f4e30be16b13b55e9c1d source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java 2ddc53c8961e1db153ac36f7f5f4e71742a22316bb644187e02c52740dbcf41d source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java eac4e7f4ba41814d01c2fc4bade4653c89e53db7bd8ef498e0c54ad22179fb15 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java 7f6aa7fa674a4f0293cbd42de1808347d0c101874ba0f5c870117b9ede5a562f -source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b3c98015b4623049027effae6a2f8dccc5c82c97787304e2de1cac6338e62f51 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java c31a0312508c178a0e8e41cae7debb87dff32aa90458a1b441d62891ea22cf95 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java 5165f5b6faf5ab79156d93f94687c8f7249f16726a63592fb51c46c1b1ddb75c source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPoint.java 015a50924678d09c8447e0d66fa24c93da6a23178bc819bc0b8c320b9d82e7dd source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRenderSample.java 03587336c3d95369ba7c2dba1e17346b637c5cda75b6ce93e1aa3761bc64815c @@ -839,7 +839,7 @@ source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveV source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java f1ea65b71befeb5d0cdb6d3c8402f0023f925fb13c16cb0c07ff1610f4079cd6 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampler.java 190fd761a0be72700e05f5bd26626dcb51a87e59e280e9861ad3d79d5c86c494 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java 33ee976cdba3b051d31738ab3acc6d5c4eea01c7043fa4d2ed24cb094b0cd704 -source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java 0f39c34037f39945f99fbf17e27327d7cbc890956aab578a19129030f28f0da1 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java e86e564915bc0f19b678d3d6a23fae3782ea339258febe91d2a132b6917bf985 source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileKey.java b806c0df952d005f0f0543225b18dc1990ed6f9a85f68a9489c96011b3ef5a5c source core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java f919c7ce0953a08a65e015d84195e6a43980d9c409bbf5d3dd9cde86a9307e7f source core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java 7bbb28a498f0d7705caf5e278e68922bacdec16a16fa75643e14feca721ebb40 @@ -919,7 +919,7 @@ source core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPla source core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingIslandSampleResolver.java b40d3996c47afc832467567c7f5b00739398c76821a7bf6e2fb5172213a83af6 source core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java d3ae16cddedf58673d4ca50d4058b6d13500c1c6d85dbd7acf5a93fa72383ca1 source core/src/main/java/art/arcane/iris/engine/mantle/components/GoldenDebugObjectPlacer.java fd1230b323182e5e44b1810a3ff4d2934c1d98704ae93ec8275e73c0be940634 -source core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java 2a767b374403b6285a9b010821ac17eb1163f2296134c1493ba76fe23c271ce6 +source core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java bea4ecf5ce08814e5fa56f94d6b20156bb16332b67a0261eeaabd322e1df55af source core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java 9449a3e411e818d5e6e47dcc7eff357fa6735fe6b67236bc1d45f5c7762ccdf9 source core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java 617a452172d46289a1a9464ae32ca19996db8fc6fef31cf0819cecb3ee12b094 source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java 4462fbbe6d904be0eff1a1b9a8b2cb753709d60be63c135d0410b7a247a3b4fb @@ -1009,7 +1009,7 @@ source core/src/main/java/art/arcane/iris/engine/object/IrisDepositHeightDistrib source core/src/main/java/art/arcane/iris/engine/object/IrisDepositPlacementScope.java 0b1b15482a85d473eac3b9d58be5b681b8e210846900ab5a2b4e945bf3873105 source core/src/main/java/art/arcane/iris/engine/object/IrisDepositShape.java 07647e01b010a338e1cf01f5372f8dfbccf34f3915742032baecacccda5293fc source core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java 0234c4caa8c723047be7293fea0452df2221450b05ef3102bbc152170a5e5038 -source core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java 806dcdc5b7c57b766bff0e48655c1bc9381854f9ba83d08a22cca37409ae218d +source core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java 5e9f5942a92248c216844f7840e39b0ede97afef9734bb737bfa95e2576a014d source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java a0c5971a455b2ff2e4c82c7a0387110ab4f1e0dd27238fbcd57b7a0b3b8899dc source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java 51ecf9f7f108311951baa621614f33085a569ffa88f7d314daf0fcdaf39e44b2 source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionContractException.java 6d64f24dd2ff4b759ade515a2f6bff218fa7b59a68cd856f7e82cf2ae7d0fb65 @@ -1274,7 +1274,7 @@ source core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java d source core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java df4248d6dd1508b4856508a2a8363b7cffae0d8db66a10495ffe9593b8127f68 source core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java 11c72c83a632d34945e3032d68ba89f0727ff3e7af3fae64988d17a8e9bf0fd5 source core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java 856d0b46117a485df832cfadd2c31b491fde8609b43799b060110246524527d1 -source core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java 6b036e6e3246df6a8e850344c67b0b5b1f8cc5e36914d304b5e9eaeb3a214243 +source core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java 8f4521b6bb462e0a6afeaccb5112698ba1ca0cd493c0fe13cc432dcbeee0fbae source core/src/main/java/art/arcane/iris/engine/platform/DummyBiomeProvider.java 560036f370d550f7916e6ed4b79df907fc0787567c27eabc49df1ef108cf5b13 source core/src/main/java/art/arcane/iris/engine/platform/DummyChunkGenerator.java 12ecd07d0189672a7c161734bce487f6cb6b4797a71df3692f29fa7ccc34cf32 source core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java bee55e2704b0e2405b631408fa42ca4c7baef8c83c0b00af8c1dc98e8f3792f0 @@ -1282,8 +1282,8 @@ source core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator source core/src/main/java/art/arcane/iris/engine/platform/studio/EnginedStudioGenerator.java 5e279de24da76030fdfaf8b037bebf375fd35bd3e388fbaa2c4a24942ec1cedb source core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java a94909957b4a6c60b9ac995312a72ef4208d5fd8862c4c03c2525693e9ebe69f source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java 5d169970dcd15011a6dfb9a8ddf76b0c03de2bef09b0f7c8ffbe3ce6787194f9 -source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java 65b7b09536bcc9a92b8588f06f0d1e94d1301fd19be8e8e26a17b79ff67e3e86 -source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java 93d9b05fbbcb579ac898d83aadc4bddc084aa05ffaebd84f72c3e51b5f4115b3 +source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java 8b7e18180158951ebb7c09fdea310d31721b56f2ffb2056a36f2a05f07d9ceec +source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java 581a56e0b482a44b14f403b8ffd5dc9f40c8a119eb09def3684615ef6601870d source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java 4d6cbcac0788b593616e063ed102dbbd93db57f9354f8b5294f19b8660748889 source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiomeWriter.java 8c37ba79a8299a4b05f5e561380b03a54a10cc2f32f7d79b25ffd544211a7d54 source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java a032f34aa55fb67d8f8435689f75ae2323edcb80204c93716a8b5fd8cf86b784 @@ -1415,10 +1415,10 @@ source core/src/main/java/art/arcane/iris/util/project/interpolation/Interpolati source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationMethod.java 8948ead4aeb36e734b32dd7c247c0546c59e41d7792bf913053ee4f5154793e2 source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationMethod3D.java 4d5831fc15fe506432ec0f747ccd8cadc1f4976c561812d18a53fea3af121e2e source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationType.java 50266ebcd1552f781fee3409f01ff24259f6a33dd7c1e9876075b7462f5bca2e -source core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java b217803f57df1bcff429bbc9319598294ec3f1c68bab3e34b7bc4fd722136aef +source core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java cafad8d3698cf733f6432a1f1d40db7494e89cb82e3d781f7cfcb2437f920d72 source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBounds.java 484d465f6e7ee86c567570a3d044bf871d716728b936ea1909fcc5b6fb8834d8 source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsProvider.java 5da0eaf2db701a21db2fd3009660449b5c2ee306ac3bfaf0522b15f82890008a -source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java 7a8928a4e3649c7120c5da17e11eab45bd1f39ed9ebfa4b3a58ff42c5a0f60c5 +source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java a114e3c7997f81a1cdbc31a1d861a45475f46369461b77d54fc80cf9dbe05395 source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java 2a7c13310ec82c1211b73e4925bd0262fab32fc7e474ab4c41c9f803e18e107b source core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterContext.java b8eca540d775aac3ff015b12e2d1c56967443c186ea8e28678c210ce5a67c0be source core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java 0ed6c5fceaed9c5f88a6f431e5264707f402a63fc95307b1fee175fea6d50354 diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index f1b63d029..d0c43bdc8 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -203,7 +203,7 @@ public class IrisPregenerator { // fires — leaving a phantom job that suppresses spawns and blocks future pregens. try { init(); - task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet()); + totalChunks.set(task.chunkCount()); long startedAt = M.ms(); startTime.set(startedAt); rateTracker.reset(startedAt, generated.get()); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index 6092bb258..3dcd1cc3f 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -215,6 +215,11 @@ public class PregenTask { return new int[]{bound.minX(), bound.minZ(), bound.maxX(), bound.maxZ()}; } + public long chunkCount() { + Bound bound = bounds.chunk(); + return (long) bound.sizeX() * bound.sizeZ(); + } + @FunctionalInterface public interface InterleavedChunkConsumer { boolean on(int regionX, int regionZ, int chunkX, int chunkZ, boolean firstChunkInRegion, boolean lastChunkInRegion); diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index b98d0b75b..e5c683c76 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -952,7 +952,7 @@ public final class StudioOpenCoordinator { } public boolean prepareGeneratorState() { - return this != JIGSAW; + return this == STANDARD || this == FORCED_STANDARD; } public IrisCreator.DatapackPreparation datapackPreparation() { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index e38b4af44..d69181efa 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -38,6 +38,9 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisRegion; @@ -67,6 +70,7 @@ import lombok.Getter; import lombok.ToString; import java.io.File; +import java.lang.ref.WeakReference; import java.util.Arrays; import java.util.ArrayList; import java.util.Collections; @@ -133,6 +137,8 @@ public class IrisComplex implements DataProvider { private ProceduralStream seaBiomeStream; private ProceduralStream shoreBiomeStream; private ProceduralStream baseBiomeStream; + @Getter(AccessLevel.NONE) + private final transient boolean reuseNaturalBaseBiome; private ProceduralStream baseBiomeIDStream; private ProceduralStream naturalTrueBiomeStream; private ProceduralStream unblendedNaturalTrueBiomeStream; @@ -228,6 +234,7 @@ public class IrisComplex implements DataProvider { } imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators); } + reuseNaturalBaseBiome = hasFixedNaturalBiomeNoise(engine.getDimension(), generatorBiomes); inferredBiomeStreams = compileInferredBiomeStreams( preparedRegions, (region, inferredType) -> compileInferredBiomeStream(engine, region, inferredType, emptyBiome) @@ -519,16 +526,19 @@ public class IrisComplex implements DataProvider { InferredType inferredType, IrisRegion region, double naturalHeight, - double x, - double z + int x, + int z ) { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); if (mapped != null) { return mapped; } - IrisBiome baseBiome = implode(sampleInferredBiome(region, inferredType, x, z), x, z); + IrisContext context = IrisContext.get(); + IrisBiome baseBiome = reuseNaturalBaseBiome && (context == null || context.getChunkContext() == null) + ? baseBiomeStream.get(x, z) + : implode(sampleInferredBiome(region, inferredType, x, z), (double) x, (double) z); IrisBiome resolved = resolveNaturalSurfaceBiome(naturalHeight, baseBiome, region, x, z); - return resolved == baseBiome ? baseBiome : implode(resolved, x, z); + return resolved == baseBiome ? baseBiome : implode(resolved, (double) x, (double) z); } private IrisBiome resolveNaturalSurfaceBiome( @@ -1371,6 +1381,12 @@ public class IrisComplex implements DataProvider { return 0D; } double height = 0D; + if (low == high && Double.isFinite(low)) { + for (int i = 0; i < generators.length; i++) { + height += low; + } + return height / generators.length; + } for (IrisGenerator generator : generators) { height += M.lerp(low, high, generator.getHeight(x, z, seed)); } @@ -1455,9 +1471,34 @@ public class IrisComplex implements DataProvider { CNG childCell = b.getChildrenGenerator(rng, 123, b.getChildShrinkFactor()); ChildSelectionPlan childSelectionPlan = resolveChildSelectionPlan(b); IrisBiome biome = childSelectionPlan.select(childCell, x, z).withInferredType(b.getInferredType()); + if (biome == b && hasFixedNoise(b.getChildStyle())) { + return biome; + } return implode(biome, x, z, max - 1); } + static boolean hasFixedNaturalBiomeNoise(IrisDimension dimension, Iterable biomes) { + if (!hasFixedNoise(dimension.getRegionStyle()) || !hasFixedNoise(dimension.getContinentalStyle()) + || !hasFixedNoise(dimension.getLandBiomeStyle()) || !hasFixedNoise(dimension.getSeaBiomeStyle())) { + return false; + } + for (IrisBiome biome : biomes) { + if (!biome.getChildren().isEmpty() && !hasFixedNoise(biome.getChildStyle())) { + return false; + } + } + return true; + } + + private static boolean hasFixedNoise(IrisGeneratorStyle style) { + for (IrisGeneratorStyle current = style; current != null; current = current.getFracture()) { + if (current.getExpression() != null) { + return false; + } + } + return true; + } + private ChildSelectionPlan resolveChildSelectionPlan(IrisBiome biome) { ChildSelectionPlan cachedPlan = childSelectionPlans.get(biome); if (cachedPlan != null) { @@ -1517,14 +1558,15 @@ public class IrisComplex implements DataProvider { /** * Reusable per-thread bounds provider. Holds the biome memo and the lazily computed generator * bounds for one sampleBoundsRaw pass so the pass allocates nothing; {@link #bind} - * resets both, giving each pass the same empty-scratch semantics a fresh allocation had. + * resets generator bounds while retaining bounded coordinate samples for the same biome stream. *

* Single threaded and non reentrant by contract, matching the thread local sample caches in * IrisInterpolation that this provider is invoked through. If a nested pass ever does appear, * {@link #isInUse()} makes the caller fall back to a freshly allocated sampler. */ private static final class BoundsSampler implements NoiseBoundsProvider { - private final CoordinateBiomeCache sampleCache = new CoordinateBiomeCache(64); + private final CoordinateBiomeCache sampleCache = new CoordinateBiomeCache(4096); + private WeakReference> sampleSource = new WeakReference<>(null); private final IdentityHashMap localBounds = new IdentityHashMap<>(8); private IrisComplex complex; private Engine engine; @@ -1542,7 +1584,10 @@ public class IrisComplex implements DataProvider { this.generators = generators; this.cachedBounds = cachedBounds; this.inUse = true; - sampleCache.clear(); + if (sampleSource.get() != complex.baseBiomeStream) { + sampleCache.clear(); + sampleSource = new WeakReference<>(complex.baseBiomeStream); + } if (!localBounds.isEmpty()) { localBounds.clear(); @@ -1585,73 +1630,40 @@ public class IrisComplex implements DataProvider { } /** - * Open addressed biome memo keyed on the packed coordinate bits, replacing a linear scan that was + * Bounded biome memo keyed on the packed coordinate bits, replacing a linear scan that was * quadratic in the number of columns a wide starcast touches. Single threaded by contract. */ private static final class CoordinateBiomeCache { - private long[] xBits; - private long[] zBits; - private IrisBiome[] values; - private byte[] states; - private int mask; - private int resizeThreshold; - private int size; + private final long[] xBits; + private final long[] zBits; + private final IrisBiome[] values; + private final int mask; - private CoordinateBiomeCache(int initialCapacity) { - int minimumCapacity = Math.max(8, initialCapacity); - int tableSize = tableSizeFor((minimumCapacity << 1) + minimumCapacity); - xBits = new long[tableSize]; - zBits = new long[tableSize]; - values = new IrisBiome[tableSize]; - states = new byte[tableSize]; - mask = tableSize - 1; - resizeThreshold = Math.max(1, (tableSize * 3) >> 2); - size = 0; + private CoordinateBiomeCache(int capacity) { + xBits = new long[capacity]; + zBits = new long[capacity]; + values = new IrisBiome[capacity]; + mask = capacity - 1; } private void clear() { - if (size == 0) { - return; - } - - Arrays.fill(states, (byte) 0); - size = 0; + Arrays.fill(values, null); } private IrisBiome get(double x, double z) { - int slot = findSlot(Double.doubleToLongBits(x), Double.doubleToLongBits(z)); - return states[slot] == 0 ? null : values[slot]; + long xb = Double.doubleToLongBits(x); + long zb = Double.doubleToLongBits(z); + int slot = mix(xb, zb) & mask; + return xBits[slot] == xb && zBits[slot] == zb ? values[slot] : null; } private void put(double x, double z, IrisBiome biome) { long xb = Double.doubleToLongBits(x); long zb = Double.doubleToLongBits(z); - int slot = findSlot(xb, zb); - boolean occupied = states[slot] != 0; + int slot = mix(xb, zb) & mask; xBits[slot] = xb; zBits[slot] = zb; values[slot] = biome; - states[slot] = 1; - - if (occupied) { - return; - } - - size++; - if (size >= resizeThreshold) { - grow(); - } - } - - private int findSlot(long xb, long zb) { - int slot = mix(xb, zb) & mask; - while (states[slot] != 0) { - if (xBits[slot] == xb && zBits[slot] == zb) { - break; - } - slot = (slot + 1) & mask; - } - return slot; } private int mix(long xb, long zb) { @@ -1662,49 +1674,6 @@ public class IrisComplex implements DataProvider { hash ^= (hash >>> 33); return (int) hash; } - - private void grow() { - long[] previousXBits = xBits; - long[] previousZBits = zBits; - IrisBiome[] previousValues = values; - byte[] previousStates = states; - - int nextLength = previousXBits.length << 1; - xBits = new long[nextLength]; - zBits = new long[nextLength]; - values = new IrisBiome[nextLength]; - states = new byte[nextLength]; - mask = nextLength - 1; - resizeThreshold = Math.max(1, (nextLength * 3) >> 2); - size = 0; - - for (int i = 0; i < previousStates.length; i++) { - if (previousStates[i] == 0) { - continue; - } - - int slot = findSlot(previousXBits[i], previousZBits[i]); - xBits[slot] = previousXBits[i]; - zBits[slot] = previousZBits[i]; - values[slot] = previousValues[i]; - states[slot] = 1; - size++; - } - } - - private int tableSizeFor(int value) { - int n = value - 1; - n |= n >>> 1; - n |= n >>> 2; - n |= n >>> 4; - n |= n >>> 8; - n |= n >>> 16; - int tableSize = n + 1; - if (tableSize < 8) { - return 8; - } - return tableSize; - } } static final class ChildSelectionPlan { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 7a3e0f085..0d33033fe 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -447,6 +447,36 @@ public class IrisEngine implements Engine { return packRoot.resolve(".iris").resolve("studio-hydrology"); } + public void startStudioEntryHydrology(int blockX, int blockZ) { + GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding(); + if (!backgroundTasks.scheduleTrackedTask(() -> prepareStudioEntryHydrology(binding, blockX, blockZ))) { + throw new IllegalStateException("Iris background task admission closed before Studio entry preparation."); + } + } + + private void prepareStudioEntryHydrology(GenerationRuntimeBinding binding, int blockX, int blockZ) { + if (closing.get()) { + return; + } + GenerationSessionLease lease; + try { + lease = acquireGenerationLease("studio_entry_hydrology"); + } catch (GenerationSessionException failure) { + if (closing.get()) { + return; + } + throw new IllegalStateException("Studio entry hydrology preparation could not acquire its generation session.", failure); + } + try (lease; + GenerationRuntimeScope runtimeScope = generationRuntimeScopes.open(binding); + IrisContext.Scope context = IrisContext.open(this, lease.sessionId(), null)) { + getComplex().getHydrologyRuntime().prepareChunkColumns(blockX, blockZ); + } catch (Exception failure) { + throw new IllegalStateException("Studio entry hydrology preparation failed at " + + blockX + "," + blockZ + ".", failure); + } + } + private void startGenerationCacheWarm(long phaseStartedAtNanos) { if (!backgroundTasks.scheduleTrackedTask(() -> warmGenerationCaches(phaseStartedAtNanos))) { throw new IllegalStateException("Iris background task admission closed before generation cache warming."); @@ -1466,6 +1496,7 @@ public class IrisEngine implements Engine { public enum InitializationMode { RUNTIME(false, true), STUDIO(true, true), + OBJECT_STUDIO(true, false), JIGSAW_STUDIO(true, false); private final boolean studio; 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 bd3d00efa..bb66f0b63 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 @@ -1694,7 +1694,7 @@ final class HydrologyFootprintCompiler { if (cached != null) { return cached; } - HydrologyTerrainSample sampled = naturalSampler.sampleBasis(x, z); + HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(x, z); if (sampled != null) { terrainBases.put(packed, sampled); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java index 73abd9359..48e61e6ff 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java @@ -4,18 +4,17 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; -import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; /** - * Runs a batch of independent planning tasks in parallel without ever parking a pool worker on work - * that lives in another pool. On a ForkJoin worker the tasks are forked into that worker's own pool - * and joined with work helping, so a saturated pool still finishes them on the joining thread; on any - * other thread they run on the fallback executor, or inline when there is none. Results keep the - * task order and a task's unchecked exception is rethrown as is. + * Runs independent planning tasks and returns results in request order, using the current pool or fallback. + * A worker claims its own queued tasks before waiting, and never helps unrelated work while holding + * an owner draft. Every started task finishes before the batch returns or throws. */ public final class HydrologyForkJoin { private HydrologyForkJoin() { @@ -23,48 +22,45 @@ public final class HydrologyForkJoin { public static List invokeAll(List> tasks, Executor fallback) { Objects.requireNonNull(tasks, "tasks"); - if (tasks.size() < 2 || fallback == null && !(Thread.currentThread() instanceof ForkJoinWorkerThread)) { - return runInline(tasks); - } - if (Thread.currentThread() instanceof ForkJoinWorkerThread) { - return forkJoin(tasks); - } - return runOn(tasks, fallback); - } - - private static List runInline(List> tasks) { - ArrayList results = new ArrayList<>(tasks.size()); - for (Callable task : tasks) { - results.add(call(task)); - } - return results; - } - - private static List forkJoin(List> tasks) { - ArrayList> forked = new ArrayList<>(tasks.size()); - for (Callable task : tasks) { - forked.add(ForkJoinTask.adapt(task)); - } - ForkJoinTask.invokeAll(forked); - ArrayList results = new ArrayList<>(tasks.size()); - for (ForkJoinTask task : forked) { - results.add(task.join()); - } - return results; - } - - private static List runOn(List> tasks, Executor executor) { - ArrayList> futures = new ArrayList<>(tasks.size()); - for (Callable task : tasks) { - futures.add(CompletableFuture.supplyAsync(() -> call(task), executor)); - } - ArrayList results = new ArrayList<>(tasks.size()); - for (CompletableFuture future : futures) { - try { - results.add(future.join()); - } catch (CompletionException failure) { - throw unwrap(failure.getCause()); + boolean callerRuns = Thread.currentThread() instanceof ForkJoinWorkerThread; + if (tasks.size() < 2 || fallback == null && !callerRuns) { + ArrayList results = new ArrayList<>(tasks.size()); + for (Callable task : tasks) { + results.add(call(task)); } + return results; + } + Executor executor = callerRuns ? ((ForkJoinWorkerThread) Thread.currentThread()).getPool() : fallback; + ArrayList> pending = new ArrayList<>(tasks.size()); + for (Callable task : tasks) { + pending.add(new Task<>(task)); + } + Throwable failure = null; + int submitted = callerRuns ? 1 : 0; + for (int index = callerRuns ? 1 : 0; index < pending.size(); index++) { + try { + executor.execute(pending.get(index)); + submitted = index + 1; + } catch (RuntimeException | Error rejected) { + failure = rejected; + break; + } + } + ArrayList results = new ArrayList<>(tasks.size()); + int drainCount = callerRuns ? pending.size() : submitted; + for (int index = 0; index < drainCount; index++) { + try { + results.add(pending.get(index).await(callerRuns)); + } catch (RuntimeException | Error failed) { + if (failure == null) { + failure = failed; + } else if (failure != failed) { + failure.addSuppressed(failed); + } + } + } + if (failure != null) { + throw unwrap(failure); } return results; } @@ -80,12 +76,92 @@ public final class HydrologyForkJoin { } private static RuntimeException unwrap(Throwable cause) { - if (cause instanceof RuntimeException runtimeException) { - return runtimeException; - } if (cause instanceof Error error) { throw error; } + if (cause instanceof RuntimeException runtime) { + return runtime; + } return new IllegalStateException("Hydrology planning task failed.", cause); } + + static final class Task implements Runnable, ForkJoinPool.ManagedBlocker { + private final Callable work; + private final AtomicBoolean claimed = new AtomicBoolean(); + private final CountDownLatch complete = new CountDownLatch(1); + private T result; + private Throwable failure; + + Task(Callable work) { + this.work = Objects.requireNonNull(work); + } + + T await() { + return await(true); + } + + private T await(boolean claimPending) { + if (claimPending) { + run(); + } + boolean interrupted = false; + boolean managed = true; + Throwable blockingFailure = null; + while (!isReleasable()) { + try { + if (managed) { + ForkJoinPool.managedBlock(this); + } else { + complete.await(); + } + } catch (InterruptedException interruption) { + interrupted = true; + managed = false; + } catch (RejectedExecutionException unavailable) { + managed = false; + } catch (RuntimeException | Error failed) { + blockingFailure = failed; + managed = false; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + if (blockingFailure != null) { + if (failure != null && failure != blockingFailure) { + blockingFailure.addSuppressed(failure); + } + throw unwrap(blockingFailure); + } + if (failure != null) { + throw unwrap(failure); + } + return result; + } + + @Override + public boolean isReleasable() { + return complete.getCount() == 0; + } + + @Override + public boolean block() throws InterruptedException { + complete.await(); + return true; + } + + @Override + public void run() { + if (!claimed.compareAndSet(false, true)) { + return; + } + try { + result = call(work); + } catch (RuntimeException | Error failed) { + failure = failed; + } finally { + complete.countDown(); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 45c503abb..827cbb2c1 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -39,6 +40,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ForkJoinWorkerThread; import java.util.function.IntPredicate; public final class HydrologyPlanner { @@ -124,6 +126,7 @@ public final class HydrologyPlanner { private final int minimumY; private final HydrologyCaveVoxelViewFactory caveViewFactory; private final Cache routingContexts; + private final ConcurrentHashMap resolvingRoutingContexts; private final Cache resolvedOwners; private final Cache> refinedEdgeCache; private final ConcurrentHashMap> resolvingOwners; @@ -237,6 +240,7 @@ public final class HydrologyPlanner { this.routingContexts = Caffeine.newBuilder() .maximumSize(ROUTING_CONTEXT_CACHE_SIZE) .build(); + this.resolvingRoutingContexts = new ConcurrentHashMap<>(); this.resolvedOwners = Caffeine.newBuilder() .maximumSize(RESOLVED_OWNER_CACHE_SIZE) .build(); @@ -252,7 +256,7 @@ public final class HydrologyPlanner { this.routeWormDetail = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_DETAIL_SALT)); this.surfaceCourseBuilder = new SurfaceCourseBuilder( settings.surface(), - this::sampleBasis, + this::sampleBasisWithoutSlope, geometrySampler, settings.seaLevel(), settings.routing().minimumSurfaceCourseLength() @@ -260,7 +264,7 @@ public final class HydrologyPlanner { // A tributary only has to be a real reach before it joins its stem: half the course minimum. this.tributaryCourseBuilder = new SurfaceCourseBuilder( settings.surface(), - this::sampleBasis, + this::sampleBasisWithoutSlope, geometrySampler, settings.seaLevel(), settings.routing().minimumSurfaceCourseLength() / 2 @@ -418,6 +422,10 @@ public final class HydrologyPlanner { sourceRoutingContexts ); phaseStarted = profile.record(DraftPhase.SELECT, phaseStarted); + if (crossTileAdmission != null && (!surfaceSelection.selectedCandidateIndices.isEmpty() + || !undergroundSelection.selectedCandidateIndices.isEmpty())) { + crossTileAdmission.prepare(); + } } HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( settings, @@ -780,15 +788,24 @@ public final class HydrologyPlanner { try { int ownerRank = ownerColorRank(key); ColorRankedDraftAdmission admission = new ColorRankedDraftAdmission(key, ownerRank, context); - HydrologyOwnerDraft draft = compileOwnerDraft( - key, - admission, - key.equals(context.root()) - ); + HydrologyOwnerDraft draft; + try (admission) { + try { + draft = compileOwnerDraft( + key, + admission, + key.equals(context.root()) + ); + } catch (RuntimeException | Error failure) { + admission.primaryFailure = failure; + throw failure; + } + } CrossTileResolvedOwner resolved = new CrossTileResolvedOwner(draft, admission.observedRejections()); context.remember(key, resolved); - resolvedOwners.put(key, resolved.withoutFootprintCompiler()); - owned.complete(resolved); + CrossTileResolvedOwner shared = resolved.withoutFootprintCompiler(); + resolvedOwners.put(key, shared); + owned.complete(shared); return resolved; } catch (Throwable failure) { owned.completeExceptionally(failure); @@ -803,7 +820,8 @@ public final class HydrologyPlanner { HydrologyTileKey ownerKey, HydrologyCaveCourseFilter.Result result, int ownerRank, - CrossTileResolutionContext context + CrossTileResolutionContext context, + Map> preparedOwners ) { ArrayList blockers = new ArrayList<>(); ArrayList surfaceBlockers = new ArrayList<>(); @@ -822,7 +840,7 @@ public final class HydrologyPlanner { candidateKeys.add(candidateKey); } long dependenciesStarted = System.nanoTime(); - List resolvedCandidates = resolveLowerRankOwners(candidateKeys, context); + List resolvedCandidates = resolveLowerRankOwners(candidateKeys, context, preparedOwners); currentDraftProfile().recordDependencies(candidateKeys.size(), dependenciesStarted); for (int index = 0; index < candidateKeys.size(); index++) { HydrologyTileKey candidateKey = candidateKeys.get(index); @@ -848,20 +866,26 @@ public final class HydrologyPlanner { private List resolveLowerRankOwners( List candidateKeys, - CrossTileResolutionContext context + CrossTileResolutionContext context, + Map> preparedOwners ) { if (candidateKeys.size() < 2 || !IrisPlatforms.isBound()) { ArrayList resolved = new ArrayList<>(candidateKeys.size()); for (HydrologyTileKey candidateKey : candidateKeys) { - resolved.add(resolveCrossTileOwner(candidateKey, context)); + HydrologyForkJoin.Task prepared = preparedOwners.get(candidateKey); + CrossTileResolvedOwner owner = prepared == null + ? resolveCrossTileOwner(candidateKey, context) : prepared.await(); + context.remember(candidateKey, owner); + resolved.add(owner); } return List.copyOf(resolved); } - // Neighbour drafts fork into the pool this thread belongs to (or the burst pool from outside - // any pool) and are joined with work helping, so a saturated pool never deadlocks on them. + // Neighbour drafts use the current pool (or the burst pool from outside any pool). + // Waiting workers claim their own tasks without helping unrelated owner drafts. ArrayList> tasks = new ArrayList<>(candidateKeys.size()); for (HydrologyTileKey candidateKey : candidateKeys) { - tasks.add(() -> resolveIndependentOwner(candidateKey)); + HydrologyForkJoin.Task prepared = preparedOwners.get(candidateKey); + tasks.add(prepared == null ? () -> resolveIndependentOwner(candidateKey) : prepared::await); } List owners = HydrologyForkJoin.invokeAll(tasks, MultiBurst.burst); ArrayList resolved = new ArrayList<>(candidateKeys.size()); @@ -1227,7 +1251,11 @@ public final class HydrologyPlanner { if (includeDeepFluids && grid != null) { compileSeaCaves(grid, courses, diagnostics); } + DraftProfile profile = currentDraftProfile(); + long rasterStarted = System.nanoTime(); HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(courses); + profile.rasterNanos += System.nanoTime() - rasterStarted; + profile.rasterCalls++; HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface( validation.plannedSurface() ); @@ -1243,6 +1271,7 @@ public final class HydrologyPlanner { samples == null ? null : samples.caveCandidates; HydrologyCaveContainmentPlanner.ValidationCache validationCache = samples == null ? null : samples.caveValidations; + long filterStarted = System.nanoTime(); HydrologyCaveCourseFilter.Result containment = new HydrologyCaveCourseFilter( caveView, new HydrologyCaveCourseFilter.Options( @@ -1261,6 +1290,8 @@ public final class HydrologyPlanner { validation, diagnostics ); + profile.filterNanos += System.nanoTime() - filterStarted; + profile.filterCalls++; return new PublicationAttempt(containment, diagnostics); } @@ -1650,7 +1681,7 @@ public final class HydrologyPlanner { settled = true; break; } - int floor = sampleBasis(point.x(), point.z()).naturalHeight() - maximumIncision; + int floor = sampleBasisWithoutSlope(point.x(), point.z()).naturalHeight() - maximumIncision; int lowered = Math.min(point.y(), Math.max(Math.max(allowed, floor), downstreamHead)); if (lowered < point.y()) { centerline.set(pointIndex, new HydrologyPoint(point.x(), lowered, point.z())); @@ -3031,7 +3062,36 @@ public final class HydrologyPlanner { } private SourceRoutingContext sourceRoutingContext(HydrologyTileKey key) { - return routingContexts.get(key, this::compileSourceRoutingContext); + SourceRoutingContext cached = routingContexts.getIfPresent(key); + if (cached != null) { + return cached; + } + RoutingContextLoad owned = new RoutingContextLoad(Thread.currentThread(), new CompletableFuture<>()); + RoutingContextLoad existing = resolvingRoutingContexts.putIfAbsent(key, owned); + if (existing != null) { + if (existing.owner() == Thread.currentThread()) { + throw new IllegalStateException("Hydrology routing context cannot recursively load its own tile."); + } + try { + return existing.future().join(); + } catch (CompletionException failure) { + throw propagateOwnerFailure(failure.getCause()); + } + } + try { + SourceRoutingContext resolved = routingContexts.getIfPresent(key); + if (resolved == null) { + resolved = compileSourceRoutingContext(key); + routingContexts.put(key, resolved); + } + owned.future().complete(resolved); + return resolved; + } catch (Throwable failure) { + owned.future().completeExceptionally(failure); + throw propagateOwnerFailure(failure); + } finally { + resolvingRoutingContexts.remove(key, owned); + } } private SourceRoutingContext compileSourceRoutingContext(HydrologyTileKey key) { @@ -3487,16 +3547,19 @@ public final class HydrologyPlanner { int desiredZ, double maximumOffsetRatio ) { - HydrologyTerrainSample terrain = sampleLandBasis(x, z); + double distanceFromNode = StrictMath.hypot(x - node.x(), z - node.z()); + if (distanceFromNode > settings.routing().sampleSpacing() * maximumOffsetRatio) { + return Double.POSITIVE_INFINITY; + } + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(x, z); if (terrain == null || !terrain.transitAllowed() || !withinConfines(terrain, node.terrain().confinesKey())) { return Double.POSITIVE_INFINITY; } - double distanceFromDesired = StrictMath.hypot(x - desiredX, z - desiredZ); - double distanceFromNode = StrictMath.hypot(x - node.x(), z - node.z()); - if (distanceFromNode > settings.routing().sampleSpacing() * maximumOffsetRatio - || !traversableHop(node.naturalPoint(), new HydrologyPoint(x, terrain.naturalHeight(), z))) { + if (!traversableHop(node.naturalPoint(), new HydrologyPoint(x, terrain.naturalHeight(), z))) { return Double.POSITIVE_INFINITY; } + terrain = sampleLandBasis(x, z); + double distanceFromDesired = StrictMath.hypot(x - desiredX, z - desiredZ); double score = terrain.naturalHeight() * settings.routing().valleyPreference() + terrain.slope() * settings.routing().slopePenalty() + terrain.routingCost() * terrain.routingMultiplier() @@ -3637,7 +3700,7 @@ public final class HydrologyPlanner { ArrayList points = new ArrayList<>(direct.size()); for (int index = 0; index < direct.size(); index++) { HydrologyPoint point = direct.get(index); - HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(point.x(), point.z()); if (terrain == null || !terrain.transitAllowed()) { return List.of(); } @@ -3661,7 +3724,7 @@ public final class HydrologyPlanner { for (int direction : List.of(-1, 1)) { int x = (int) StrictMath.round(point.x() + perpendicularX * refinement * direction); int z = (int) StrictMath.round(point.z() + perpendicularZ * refinement * direction); - HydrologyTerrainSample terrain = sampleLandBasis(x, z); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(x, z); if (terrain != null && terrain.transitAllowed()) { return true; } @@ -3827,11 +3890,13 @@ public final class HydrologyPlanner { candidates.put(packed, candidate); } } + if (!candidates.isEmpty()) { + return List.copyOf(candidates.values()); + } int fallbackX = (int) StrictMath.round(nominal.baseX()); int fallbackZ = (int) StrictMath.round(nominal.baseZ()); HydrologyTerrainSample fallbackTerrain = sampleLandBasis(fallbackX, fallbackZ); - if (candidates.isEmpty() - && fallbackTerrain != null + if (fallbackTerrain != null && fallbackTerrain.transitAllowed() && withinConfines(fallbackTerrain, confines)) { long packed = RiverFootprint.pack(fallbackX, fallbackZ); @@ -4141,6 +4206,7 @@ public final class HydrologyPlanner { } double[][] localPenalties = new double[layerCount][]; double[][][] transitionCosts = new double[layerCount][][]; + double[][][][] turnCosts = new double[layerCount][][][]; for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) { int layerSize = layers.get(layerIndex).size(); localPenalties[layerIndex] = new double[layerSize]; @@ -4163,7 +4229,8 @@ public final class HydrologyPlanner { maximumTurn, turnCost, localPenalties, - transitionCosts + transitionCosts, + turnCosts ); if (selection.route().length == 0) { return selection.route(); @@ -4187,7 +4254,8 @@ public final class HydrologyPlanner { double maximumTurn, double turnCost, double[][] localPenalties, - double[][][] transitionCosts + double[][][] transitionCosts, + double[][][][] turnCosts ) { int layerCount = layers.size(); double[][][] costs = new double[layerCount][][]; @@ -4222,6 +4290,9 @@ public final class HydrologyPlanner { List currentLayer = layers.get(layerIndex); costs[layerIndex] = new double[previousLayer.size()][currentLayer.size()]; predecessors[layerIndex] = new int[previousLayer.size()][currentLayer.size()]; + if (turnCosts[layerIndex] == null) { + turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][]; + } for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) { Arrays.fill(costs[layerIndex][previousIndex], Double.POSITIVE_INFINITY); Arrays.fill(predecessors[layerIndex][previousIndex], -1); @@ -4243,20 +4314,32 @@ public final class HydrologyPlanner { if (!Double.isFinite(previousCost)) { continue; } - RouteCandidate before = beforeLayer.get(beforeIndex); - double turn = routeTurnDegrees( - before.point(), - previous.point(), - current.point() - ); - if (turn > maximumTurn) { + double[] candidateTurns = turnCosts[layerIndex][previousIndex][currentIndex]; + if (candidateTurns == null) { + candidateTurns = new double[beforeLayer.size()]; + Arrays.fill(candidateTurns, Double.NaN); + turnCosts[layerIndex][previousIndex][currentIndex] = candidateTurns; + } + double turnPenalty = candidateTurns[beforeIndex]; + if (Double.isNaN(turnPenalty)) { + double turn = routeTurnDegrees( + beforeLayer.get(beforeIndex).point(), + previous.point(), + current.point() + ); + turnPenalty = turn > maximumTurn + ? Double.POSITIVE_INFINITY + : curvatureCost(turn, maximumTurn, turnCost); + candidateTurns[beforeIndex] = turnPenalty; + } + if (turnPenalty == Double.POSITIVE_INFINITY) { continue; } double cost = previousCost + current.localScore() + localPenalty + transition - + curvatureCost(turn, maximumTurn, turnCost); + + turnPenalty; if (cost < costs[layerIndex][previousIndex][currentIndex]) { costs[layerIndex][previousIndex][currentIndex] = cost; predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex; @@ -4480,8 +4563,8 @@ public final class HydrologyPlanner { if (offsetX == 0 && offsetZ == 0) { return false; } - HydrologyTerrainSample before = sampleLandBasis(point.x() - offsetX, point.z() - offsetZ); - HydrologyTerrainSample after = sampleLandBasis(point.x() + offsetX, point.z() + offsetZ); + HydrologyTerrainSample before = sampleLandBasisWithoutSlope(point.x() - offsetX, point.z() - offsetZ); + HydrologyTerrainSample after = sampleLandBasisWithoutSlope(point.x() + offsetX, point.z() + offsetZ); int threshold = settings.hydraulics().waterfallMinimumDrop(); return before != null && after != null @@ -4504,7 +4587,7 @@ public final class HydrologyPlanner { HydrologyPoint next = current.get(pointIndex + 1); int x = (int) StrictMath.round(previous.x() * 0.25D + point.x() * 0.5D + next.x() * 0.25D); int z = (int) StrictMath.round(previous.z() * 0.25D + point.z() * 0.5D + next.z() * 0.25D); - HydrologyTerrainSample terrain = sampleLandBasis(x, z); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(x, z); smoothed.add(terrain == null || !terrain.transitAllowed() ? point : new HydrologyPoint(x, terrain.naturalHeight(), z)); @@ -4661,8 +4744,8 @@ public final class HydrologyPlanner { if (crossing.size() < 3) { return false; } - HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); - HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); + HydrologyTerrainSample startTerrain = sampleLandBasisWithoutSlope(start.x(), start.z()); + HydrologyTerrainSample endTerrain = sampleLandBasisWithoutSlope(end.x(), end.z()); if (startTerrain == null || endTerrain == null) { return true; } @@ -4670,7 +4753,7 @@ public final class HydrologyPlanner { int threshold = settings.hydraulics().waterfallMinimumDrop(); for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { HydrologyPoint point = crossing.get(pointIndex); - HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(point.x(), point.z()); if (terrain == null || boundaryHeight - terrain.naturalHeight() >= threshold) { return true; } @@ -5275,7 +5358,7 @@ public final class HydrologyPlanner { preferredHeads[0] = initialHead; for (int index = 0; index < pairCount; index++) { HydrologyPoint point = path.points().get(index); - HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + HydrologyTerrainSample terrain = sampleBasisWithoutSlope(point.x(), point.z()); int discharge = Math.max(1, path.pairEdges().get(index).contributingUndergroundSources()); widths[index] = scaledDimension( sampleGeometry( @@ -5978,8 +6061,8 @@ public final class HydrologyPlanner { for (int pointIndex = 0; pointIndex < raster.size() - 1; pointIndex++) { HydrologyPoint current = raster.get(pointIndex); HydrologyPoint downstream = raster.get(pointIndex + 1); - HydrologyTerrainSample currentTerrain = sampleBasis(current.x(), current.z()); - HydrologyTerrainSample downstreamTerrain = sampleBasis(downstream.x(), downstream.z()); + HydrologyTerrainSample currentTerrain = sampleBasisWithoutSlope(current.x(), current.z()); + HydrologyTerrainSample downstreamTerrain = sampleBasisWithoutSlope(downstream.x(), downstream.z()); long decline = (long) currentTerrain.naturalHeight() - downstreamTerrain.naturalHeight(); if (decline > strongestDecline) { strongestDecline = decline; @@ -6078,11 +6161,29 @@ public final class HydrologyPlanner { edges.getFirst().upstreamNodeId(), outlet.id() ); - List points = surfaceCourseCenterline( - Objects.requireNonNull(grid, "Surface course routing grid is required"), - surfaceGuide, - geometryId - ); + Objects.requireNonNull(grid, "Surface course routing grid is required"); + PlanningSamples samples = planningSamples.get(); + DraftProfile profile = currentDraftProfile(); + profile.routeCalls++; + List points; + if (samples == null) { + profile.routeSolves++; + long solveStarted = System.nanoTime(); + points = surfaceCourseCenterline(grid, surfaceGuide, geometryId); + profile.routeSolveNanos += System.nanoTime() - solveStarted; + } else { + HashMap> routes = samples.surfaceRoutes.computeIfAbsent( + grid, ignored -> new HashMap<>() + ); + SurfaceRouteKey routeKey = new SurfaceRouteKey(surfaceGuide, geometryId); + points = routes.computeIfAbsent(routeKey, ignored -> { + profile.routeSolves++; + long solveStarted = System.nanoTime(); + List solved = surfaceCourseCenterline(grid, surfaceGuide, geometryId); + profile.routeSolveNanos += System.nanoTime() - solveStarted; + return solved; + }); + } if (points.size() < 2) { return null; } @@ -7865,6 +7966,7 @@ public final class HydrologyPlanner { private final HashMap> radialOffsets; private final HashMap undergroundSegmentCaps; private final HashMap routeAnchors; + private final IdentityHashMap>> surfaceRoutes; private final HydrologyCaveCourseFilter.CandidateCache caveCandidates; private final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; @@ -7878,6 +7980,7 @@ public final class HydrologyPlanner { this.radialOffsets = new HashMap<>(); this.undergroundSegmentCaps = new HashMap<>(); this.routeAnchors = new HashMap<>(); + this.surfaceRoutes = new IdentityHashMap<>(); this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); } @@ -7924,6 +8027,9 @@ public final class HydrologyPlanner { private record RoutePosition(double x, double z, double baseX, double baseZ, Direction tangent) { } + private record SurfaceRouteKey(List guide, long geometryId) { + } + private record RouteCandidate( HydrologyPoint point, double offset, @@ -8307,6 +8413,9 @@ public final class HydrologyPlanner { ) { } + private record RoutingContextLoad(Thread owner, CompletableFuture future) { + } + private record SourceRoutingContext( SampledGrid grid, RoutingPlan surfaceRouting, @@ -8385,16 +8494,20 @@ public final class HydrologyPlanner { } } - @FunctionalInterface private interface CrossTileDraftAdmission { + void prepare(); + CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result); } - private final class ColorRankedDraftAdmission implements CrossTileDraftAdmission { + private final class ColorRankedDraftAdmission implements CrossTileDraftAdmission, AutoCloseable { private final HydrologyTileKey ownerKey; private final int ownerRank; private final CrossTileResolutionContext context; private final Set rejectedCourseIds; + private final Map> preparedOwners = new LinkedHashMap<>(); + private final Set demandedOwners = new HashSet<>(); + private Throwable primaryFailure; private final LinkedHashMap observedRejections; private ColorRankedDraftAdmission( @@ -8409,6 +8522,77 @@ public final class HydrologyPlanner { this.observedRejections = new LinkedHashMap<>(); } + @Override + public void prepare() { + if (ownerRank == 0 || settings.crossTileColorPeriod() != 2 || !hasRoutedSourceSearch() + || !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)) { + return; + } + int radius = settings.crossTileColorPeriod() - 1; + ArrayList candidates = new ArrayList<>(); + for (long tileZ = (long) ownerKey.tileZ() - radius; tileZ <= (long) ownerKey.tileZ() + radius; tileZ++) { + for (long tileX = (long) ownerKey.tileX() - radius; tileX <= (long) ownerKey.tileX() + radius; tileX++) { + if (tileX < Integer.MIN_VALUE || tileX > Integer.MAX_VALUE + || tileZ < Integer.MIN_VALUE || tileZ > Integer.MAX_VALUE) { + continue; + } + HydrologyTileKey key = new HydrologyTileKey((int) tileX, (int) tileZ); + if (ownerColorRank(key) >= ownerRank || preparedOwners.containsKey(key) + || resolvedOwners.getIfPresent(key) != null || resolvingOwners.containsKey(key)) { + continue; + } + candidates.add(key); + } + } + candidates.sort(Comparator.comparingInt(HydrologyPlanner.this::ownerColorRank).reversed() + .thenComparing(HydrologyTileKey::compareTo)); + for (HydrologyTileKey key : candidates) { + if (resolvedOwners.getIfPresent(key) != null || resolvingOwners.containsKey(key)) { + continue; + } + HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(() -> resolveIndependentOwner(key)); + preparedOwners.put(key, task); + try { + worker.getPool().execute(task); + } catch (RuntimeException | Error failure) { + preparedOwners.remove(key); + throw failure; + } + currentDraftProfile().earlyOwners++; + } + } + + @Override + public void close() { + Error failure = null; + Map unusedFailures = new LinkedHashMap<>(); + for (Map.Entry> entry : preparedOwners.entrySet()) { + try { + entry.getValue().await(); + } catch (RuntimeException unusedFailure) { + if (!demandedOwners.contains(entry.getKey())) { + unusedFailures.put(entry.getKey(), unusedFailure); + } + } catch (Error failed) { + if (failed == primaryFailure) { + continue; + } + if (failure == null) { + failure = failed; + } else if (failure != failed) { + failure.addSuppressed(failed); + } + } + } + for (Map.Entry entry : unusedFailures.entrySet()) { + IrisLogging.reportError("Unused early hydrology owner " + entry.getKey().tileX() + + "," + entry.getKey().tileZ() + " failed to prepare.", entry.getValue()); + } + if (failure != null) { + throw failure; + } + } + @Override public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) { context.recordIteration(); @@ -8416,11 +8600,13 @@ public final class HydrologyPlanner { result, rejectedCourseIds ); + demandedOwners.addAll(conflictOwnerKeys(ownerKey, current)); CrossTileBlockers blockers = lowerRankBlockers( ownerKey, current, ownerRank, - context + context, + preparedOwners ); HydrologyCrossTileSurfaceAdmission.Result surfaceAdmission = HydrologyCrossTileSurfaceAdmission.admit( @@ -8536,6 +8722,14 @@ public final class HydrologyPlanner { private long dependencyNanos; private int dependencies; private int admissions; + private int earlyOwners; + private int routeCalls; + private int routeSolves; + private long routeSolveNanos; + private int rasterCalls; + private long rasterNanos; + private int filterCalls; + private long filterNanos; private long record(DraftPhase phase, long started) { long now = System.nanoTime(); @@ -8551,7 +8745,7 @@ public final class HydrologyPlanner { private void log(HydrologyTileKey key, int rank, long totalNanos) { IrisLogging.debug( - "Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d on %s", + "Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d earlyOwners=%d routes=%d reuses=%d routeSolve=%dms rasters=%d raster=%dms filters=%d filter=%dms on %s", key.tileX(), key.tileZ(), rank, @@ -8563,6 +8757,14 @@ public final class HydrologyPlanner { dependencies, dependencyNanos / 1_000_000L, admissions, + earlyOwners, + routeCalls, + routeCalls - routeSolves, + routeSolveNanos / 1_000_000L, + rasterCalls, + rasterNanos / 1_000_000L, + filterCalls, + filterNanos / 1_000_000L, Thread.currentThread().getName() ); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index f614331b7..9231ec367 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; @@ -48,12 +49,17 @@ public final class HydrologyTileCache implements AutoCloseable { private final Executor prefetchExecutor; private final ConcurrentLinkedQueue prefetchQueue; private final Set queuedPrefetches; + private final Set> demandedPlans; private final AtomicBoolean prefetchActive; - private final ConcurrentHashMap> planning; + private final ConcurrentHashMap, CompletableFuture> planning; + private final ConcurrentHashMap, PendingLoad> loading; + private final ConcurrentHashMap, PendingLoad> composing; + private final Object publicationLock = new Object(); private final BooleanSupplier waitingForbidden; private volatile SharedCacheScope sharedCacheScope; private volatile StudioHydrologyTileStore persistentStore; private volatile boolean neighbourPrefetchEnabled; + private int demandBatches; public HydrologyTileCache(HydrologyPlanner planner) { this(planner, DEFAULT_MAXIMUM_ENTRIES, null, null, null); @@ -96,12 +102,15 @@ public final class HydrologyTileCache implements AutoCloseable { this.sharedCacheScope = sharedCacheScope; this.persistentStore = null; this.planning = new ConcurrentHashMap<>(); + this.loading = new ConcurrentHashMap<>(); + this.composing = new ConcurrentHashMap<>(); if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive."); } this.maximumEntries = maximumEntries; this.prefetchQueue = new ConcurrentLinkedQueue<>(); this.queuedPrefetches = ConcurrentHashMap.newKeySet(); + this.demandedPlans = new HashSet<>(); this.prefetchActive = new AtomicBoolean(); this.tiles = Caffeine.newBuilder() .maximumSize(maximumEntries) @@ -122,10 +131,13 @@ public final class HydrologyTileCache implements AutoCloseable { */ public HydrologyTile get(HydrologyTileKey key) { Objects.requireNonNull(key, "key"); - if (prefetchExecutor == null || plansInline()) { - return planInline(key); + if (prefetchExecutor == null) { + return planInline(key, cacheEpoch.get()); } - return awaitTile(planDemandAsync(key)); + if (plansInline()) { + return planDemandInline(key); + } + return awaitPlan(planDemandAsync(key)); } private boolean plansInline() { @@ -137,12 +149,33 @@ public final class HydrologyTileCache implements AutoCloseable { && worker.getPool() == pool; } - private HydrologyTile planInline(HydrologyTileKey key) { - if (tiles.getIfPresent(key) == null && planning.containsKey(key)) { - IrisLogging.debug("Hydrology tile %d,%d awaited by %s while the planning pool plans it", - key.tileX(), key.tileZ(), Thread.currentThread().getName()); + private HydrologyTile planInline(HydrologyTileKey key, long epoch) { + HydrologyTile present = tiles.getIfPresent(key); + if (present != null) { + return present; + } + CacheLoadKey loadKey = new CacheLoadKey<>(epoch, key); + PendingLoad owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); + PendingLoad existing = loading.putIfAbsent(loadKey, owned); + if (existing != null) { + return awaitLoad(existing); + } + try { + present = tiles.getIfPresent(key); + HydrologyTile tile = present == null ? planOrEmpty(key, epoch) : present; + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == epoch) { + tiles.put(key, tile); + } + } + owned.future().complete(tile); + return tile; + } catch (Throwable failure) { + owned.future().completeExceptionally(failure); + throw failure; + } finally { + loading.remove(loadKey, owned); } - return tiles.get(key, this::planOrEmpty); } /** @@ -152,8 +185,7 @@ public final class HydrologyTileCache implements AutoCloseable { * stays consistent. A plan that was interrupted is not a bad tile: it is rethrown without being * cached so the next request plans it again. */ - private HydrologyTile planOrEmpty(HydrologyTileKey key) { - long planningEpoch = cacheEpoch.get(); + private HydrologyTile planOrEmpty(HydrologyTileKey key, long planningEpoch) { SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key); HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey); if (shared != null && validSharedTile(shared, sharedKey)) { @@ -176,10 +208,12 @@ public final class HydrologyTileCache implements AutoCloseable { } try { HydrologyTile planned = planner.plan(key); - if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { - HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned); - if (existing == null) { - IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + synchronized (publicationLock) { + if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { + HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned); + if (existing == null) { + IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + } } } if (store != null && persistentStudioKey(key) && !closed.get() && planningEpoch == cacheEpoch.get()) { @@ -277,14 +311,18 @@ public final class HydrologyTileCache implements AutoCloseable { } public void clear() { - cacheEpoch.incrementAndGet(); - planner.clearOwnerDrafts(); synchronized (prefetchQueue) { + synchronized (publicationLock) { + cacheEpoch.incrementAndGet(); + planner.clearOwnerDrafts(); + tiles.invalidateAll(); + composedChunks.invalidateAll(); + } prefetchQueue.clear(); queuedPrefetches.clear(); + demandedPlans.clear(); + demandBatches = 0; } - tiles.invalidateAll(); - composedChunks.invalidateAll(); localChunkColumns.remove(); } @@ -302,16 +340,19 @@ public final class HydrologyTileCache implements AutoCloseable { } public void preparePregeneration(int centerBlockX, int centerBlockZ) { - neighbourPrefetchEnabled = true; int tileSize = planner.settings().routing().tileSize(); - prefetchArea( - Math.subtractExact(centerBlockX, tileSize), - Math.subtractExact(centerBlockZ, tileSize), - Math.addExact(centerBlockX, tileSize), - Math.addExact(centerBlockZ, tileSize), + int minimumBlockX = Math.subtractExact(centerBlockX, tileSize); + int minimumBlockZ = Math.subtractExact(centerBlockZ, tileSize); + int maximumBlockX = Math.addExact(centerBlockX, tileSize); + int maximumBlockZ = Math.addExact(centerBlockZ, tileSize); + enqueuePrefetchArea(prefetchAreaKeys( + minimumBlockX, + minimumBlockZ, + maximumBlockX, + maximumBlockZ, centerBlockX, centerBlockZ - ); + ), true); } public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) { @@ -366,14 +407,41 @@ public final class HydrologyTileCache implements AutoCloseable { // The empty result is never cached, so the next query composes the real columns. return ChunkColumns.empty(chunkX, chunkZ); } - ChunkColumns columns = composedChunks.get( - packedChunk, - ignored -> composeChunkColumns(chunkX, chunkZ) - ); + ChunkColumns columns = loadChunkColumns(new CacheLoadKey<>(epoch, packedChunk), chunkX, chunkZ); localChunkColumns.set(new LocalChunkColumns(epoch, columns)); return columns; } + private ChunkColumns loadChunkColumns(CacheLoadKey loadKey, int chunkX, int chunkZ) { + long epoch = loadKey.epoch(); + long packedChunk = loadKey.key(); + ChunkColumns present = composedChunks.getIfPresent(packedChunk); + if (present != null) { + return present; + } + PendingLoad owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); + PendingLoad existing = composing.putIfAbsent(loadKey, owned); + if (existing != null) { + return awaitLoad(existing); + } + try { + present = composedChunks.getIfPresent(packedChunk); + ChunkColumns columns = present == null ? composeChunkColumns(chunkX, chunkZ) : present; + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == epoch) { + composedChunks.put(packedChunk, columns); + } + } + owned.future().complete(columns); + return columns; + } catch (Throwable failure) { + owned.future().completeExceptionally(failure); + throw failure; + } finally { + composing.remove(loadKey, owned); + } + } + /** The tiles a chunk's columns compose from: every tile within the publication radius of the chunk. */ private ArrayList relevantKeys(int chunkX, int chunkZ) { int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); @@ -447,6 +515,12 @@ public final class HydrologyTileCache implements AutoCloseable { if (prefetchExecutor == null) { return; } + enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, + centreBlockX, centreBlockZ), false); + } + + private List prefetchAreaKeys(int minimumBlockX, int minimumBlockZ, int maximumBlockX, + int maximumBlockZ, int centreBlockX, int centreBlockZ) { int tileSize = planner.settings().routing().tileSize(); int publicationRadius = planner.settings().publicationRadius(); int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); @@ -464,31 +538,66 @@ public final class HydrologyTileCache implements AutoCloseable { keys.sort(Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX() - centreTileX), Math.abs(key.tileZ() - centreTileZ))) .thenComparingInt(HydrologyTileKey::tileZ) .thenComparingInt(HydrologyTileKey::tileX)); - for (HydrologyTileKey key : keys) { - if (!prefetch(key) && speculativeEntryCount() >= maximumEntries) { - break; + return keys; + } + + private void enqueuePrefetchArea(List keys, boolean discardOutsideArea) { + Set area = discardOutsideArea ? new HashSet<>(keys) : Set.of(); + tiles.cleanUp(); + synchronized (prefetchQueue) { + if (discardOutsideArea) { + prefetchQueue.removeIf(key -> { + if (!area.contains(key)) { + queuedPrefetches.remove(key); + return true; + } + return false; + }); + neighbourPrefetchEnabled = true; + } + if (prefetchExecutor == null) { + return; + } + for (HydrologyTileKey key : keys) { + if (!enqueuePrefetch(key) && speculativeEntryCount() >= maximumEntries) { + break; + } } } + pumpPrefetch(); } private boolean prefetch(HydrologyTileKey key) { + if (closed.get() + || tiles.getIfPresent(key) != null + || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) + || queuedPrefetches.contains(key)) { + return false; + } + tiles.cleanUp(); synchronized (prefetchQueue) { - if (closed.get() - || tiles.getIfPresent(key) != null - || planning.containsKey(key) - || queuedPrefetches.contains(key) - || speculativeEntryCount() >= maximumEntries) { + if (!enqueuePrefetch(key)) { return false; } - queuedPrefetches.add(key); - prefetchQueue.add(key); } pumpPrefetch(); return true; } + private boolean enqueuePrefetch(HydrologyTileKey key) { + if (closed.get() + || tiles.getIfPresent(key) != null + || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) + || queuedPrefetches.contains(key) + || speculativeEntryCount() >= maximumEntries) { + return false; + } + queuedPrefetches.add(key); + prefetchQueue.add(key); + return true; + } + private int speculativeEntryCount() { - tiles.cleanUp(); return Math.toIntExact(Math.min( maximumEntries, tiles.estimatedSize() + planning.size() + queuedPrefetches.size() @@ -496,22 +605,30 @@ public final class HydrologyTileCache implements AutoCloseable { } private void pumpPrefetch() { - if (prefetchExecutor == null || closed.get() || !prefetchActive.compareAndSet(false, true)) { - return; + synchronized (prefetchQueue) { + if (prefetchExecutor == null || closed.get() || demandBatches != 0 || !demandedPlans.isEmpty() + || !prefetchActive.compareAndSet(false, true)) { + return; + } } scheduleNextPrefetch(); } private void scheduleNextPrefetch() { - HydrologyTileKey key = pollPrefetch(); - if (key == null || closed.get()) { - prefetchActive.set(false); - if (!prefetchQueue.isEmpty()) { - pumpPrefetch(); + CacheLoadKey loadKey; + synchronized (prefetchQueue) { + if (closed.get() || demandBatches != 0 || !demandedPlans.isEmpty()) { + prefetchActive.set(false); + return; } - return; + HydrologyTileKey key = pollPrefetch(); + if (key == null) { + prefetchActive.set(false); + return; + } + loadKey = new CacheLoadKey<>(cacheEpoch.get(), key); } - planAsync(key).whenComplete((tile, failure) -> scheduleNextPrefetch()); + planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch()); } private HydrologyTileKey pollPrefetch() { @@ -536,65 +653,154 @@ public final class HydrologyTileCache implements AutoCloseable { public List tiles(List keys) { Objects.requireNonNull(keys, "keys"); HydrologyTile[] loaded = new HydrologyTile[keys.size()]; - if (prefetchExecutor == null || plansInline()) { + boolean missing = false; + for (int index = 0; index < loaded.length; index++) { + loaded[index] = tiles.getIfPresent(keys.get(index)); + missing |= loaded[index] == null; + } + if (!missing) { + return List.of(loaded); + } + if (prefetchExecutor == null) { for (int index = 0; index < loaded.length; index++) { - loaded[index] = planInline(keys.get(index)); + if (loaded[index] == null) { + loaded[index] = get(keys.get(index)); + } } return List.of(loaded); } - int batch = Math.max(1, maximumEntries / 2); - ArrayList> futures = new ArrayList<>(Math.min(batch, loaded.length)); - for (int start = 0; start < loaded.length; start += batch) { - int end = Math.min(loaded.length, start + batch); - futures.clear(); - for (int index = start; index < end; index++) { - futures.add(planDemandAsync(keys.get(index))); + long demandEpoch = beginDemandBatch(); + try { + if (plansInline()) { + for (int index = 0; index < loaded.length; index++) { + if (loaded[index] == null) { + loaded[index] = get(keys.get(index)); + } + } + return List.of(loaded); } - for (int index = start; index < end; index++) { - loaded[index] = awaitTile(futures.get(index - start)); + int batch = Math.max(1, maximumEntries / 2); + ArrayList> futures = new ArrayList<>(Math.min(batch, loaded.length)); + for (int start = 0; start < loaded.length; start += batch) { + int end = Math.min(loaded.length, start + batch); + futures.clear(); + for (int index = start; index < end; index++) { + futures.add(planDemandAsync(keys.get(index))); + } + for (int index = start; index < end; index++) { + loaded[index] = awaitPlan(futures.get(index - start)); + } + } + return List.of(loaded); + } finally { + finishDemandBatch(demandEpoch); + } + } + + private long beginDemandBatch() { + synchronized (prefetchQueue) { + demandBatches++; + return cacheEpoch.get(); + } + } + + private void finishDemandBatch(long epoch) { + synchronized (prefetchQueue) { + if (epoch == cacheEpoch.get()) { + demandBatches--; } } - return List.of(loaded); + pumpPrefetch(); } /** * The tile, planned on the prefetch executor unless it is cached or already being planned, in which * case the caller shares that plan instead of occupying a second executor thread with it. */ - private CompletableFuture planAsync(HydrologyTileKey key) { + private CompletableFuture planAsync(CacheLoadKey loadKey) { + HydrologyTileKey key = loadKey.key(); HydrologyTile present = tiles.getIfPresent(key); if (present != null) { return CompletableFuture.completedFuture(present); } CompletableFuture future = new CompletableFuture<>(); - CompletableFuture existing = planning.putIfAbsent(key, future); + CompletableFuture existing = planning.putIfAbsent(loadKey, future); if (existing != null) { return existing; } try { prefetchExecutor.execute(() -> { try { - future.complete(planInline(key)); + future.complete(planInline(key, loadKey.epoch())); } catch (Throwable failure) { future.completeExceptionally(failure); IrisLogging.reportError(failure); } finally { - planning.remove(key, future); + planning.remove(loadKey, future); } }); } catch (RuntimeException rejected) { - planning.remove(key, future); + planning.remove(loadKey, future); future.completeExceptionally(rejected); } return future; } private CompletableFuture planDemandAsync(HydrologyTileKey key) { - queuedPrefetches.remove(key); - return planAsync(key); + HydrologyTile present = tiles.getIfPresent(key); + if (present != null) { + return CompletableFuture.completedFuture(present); + } + CacheLoadKey loadKey = new CacheLoadKey<>(cacheEpoch.get(), key); + boolean tracked = beginDemand(loadKey); + CompletableFuture future = planAsync(loadKey); + if (tracked) { + future.whenComplete((tile, failure) -> finishDemand(loadKey)); + } + return future; } - private static HydrologyTile awaitTile(CompletableFuture future) { + private HydrologyTile planDemandInline(HydrologyTileKey key) { + HydrologyTile present = tiles.getIfPresent(key); + if (present != null) { + return present; + } + CacheLoadKey loadKey = new CacheLoadKey<>(cacheEpoch.get(), key); + boolean tracked = beginDemand(loadKey); + try { + return planInline(key, loadKey.epoch()); + } finally { + if (tracked) { + finishDemand(loadKey); + } + } + } + + private boolean beginDemand(CacheLoadKey loadKey) { + synchronized (prefetchQueue) { + if (loadKey.epoch() != cacheEpoch.get()) { + return false; + } + queuedPrefetches.remove(loadKey.key()); + return demandedPlans.add(loadKey); + } + } + + private void finishDemand(CacheLoadKey loadKey) { + synchronized (prefetchQueue) { + demandedPlans.remove(loadKey); + } + pumpPrefetch(); + } + + private static T awaitLoad(PendingLoad load) { + if (load.owner() == Thread.currentThread()) { + throw new IllegalStateException("Recursive hydrology cache load."); + } + return awaitPlan(load.future()); + } + + private static T awaitPlan(CompletableFuture future) { try { return future.join(); } catch (CompletionException failure) { @@ -697,6 +903,12 @@ public final class HydrologyTileCache implements AutoCloseable { private record LocalChunkColumns(long epoch, ChunkColumns columns) { } + private record CacheLoadKey(long epoch, K key) { + } + + private record PendingLoad(Thread owner, CompletableFuture future) { + } + public record SharedCacheScope( String runtimeIdentity, long worldSeed, diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 6f0f18733..f7f6483f1 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -24,6 +24,7 @@ import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.object.IrisCaveFieldModule; import art.arcane.iris.engine.object.IrisCaveProfile; import art.arcane.iris.engine.object.IrisRange; +import art.arcane.iris.engine.object.IrisGeneratorStyle; import art.arcane.iris.engine.object.IrisStyledRange; import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; @@ -38,6 +39,11 @@ import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinWorkerThread; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; public class IrisCaveCarver3D { private static final byte LIQUID_AIR = 0; @@ -51,6 +57,12 @@ public class IrisCaveCarver3D { private static final int SURFACE_CEILING_FADE_DEPTH = 12; private static final double SURFACE_CEILING_SOLID_EPSILON = 0.000001D; + private static final byte DENSITY_CARVE = 1; + private static final byte DENSITY_FLUID = 2; + private static final int MINIMUM_PARALLEL_DENSITY_CELLS = 16_384; + private static final int MAXIMUM_DENSITY_WORKERS = 4; + private static final int[] LATTICE_TILE_OFFSETS = {0, 1, 16, 17}; + private final Engine engine; private final IrisData data; private final IrisCaveProfile profile; @@ -79,6 +91,8 @@ public class IrisCaveCarver3D { private final int fluidMinDepthBelowSurface; private final int fluidHeight; private final int aquiferCeilingY; + private final boolean parallelDensity; + private final ThreadLocal carving = ThreadLocal.withInitial(() -> false); private final ThreadLocal scratchCache = ThreadLocal.withInitial(CaveCarveScratch::new); public IrisCaveCarver3D(Engine engine, IrisCaveProfile profile) { @@ -125,6 +139,7 @@ public class IrisCaveCarver3D { hasModules = modules.length > 0; detailMinContribution = -detailWeight; detailMaxContribution = detailWeight; + parallelDensity = fixedDensityStyles(profile); } public int carve(MantleWriter writer, int chunkX, int chunkZ) { @@ -204,6 +219,12 @@ public class IrisCaveCarver3D { CaveFluidSupportPlan fluidSupportPlan ) { PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start(); + boolean nested = carving.get(); + CaveCarveScratch previousScratch = scratchCache.get(); + if (nested) { + scratchCache.set(new CaveCarveScratch()); + } + carving.set(true); try { CaveCarveScratch scratch = scratchCache.get(); if (columnWeights == null || columnWeights.length < 256) { @@ -415,10 +436,289 @@ public class IrisCaveCarver3D { return carved; } finally { + carving.set(nested); + if (nested) { + scratchCache.set(previousScratch); + } engine.getMetrics().getCarveApply().put(applyStopwatch.getMilliseconds()); } } + private static boolean fixedDensityStyles(IrisCaveProfile profile) { + if (!fixedStyle(profile.getBaseDensityStyle()) || !fixedStyle(profile.getDetailDensityStyle()) + || !fixedStyle(profile.getWarpStyle()) || !fixedStyle(profile.getSurfaceBreakStyle()) + || !fixedStyle(profile.getDensityThreshold().getStyle())) { + return false; + } + for (IrisCaveFieldModule module : profile.getModules()) { + if (!fixedStyle(module.getStyle())) { + return false; + } + } + return true; + } + + private static boolean fixedStyle(IrisGeneratorStyle style) { + for (IrisGeneratorStyle current = style; current != null; current = current.getFracture()) { + if (current.getExpression() != null) { + return false; + } + } + return true; + } + + private byte[] classifyPassParallel(DensityPass pass) { + int height = pass.maximumY() - pass.minimumY() + 1; + if (!parallelDensity || height < 64 + || (long) height * pass.columnCount() < MINIMUM_PARALLEL_DENSITY_CELLS + || !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)) { + return null; + } + ForkJoinPool pool = worker.getPool(); + int workers = Math.min(MAXIMUM_DENSITY_WORKERS, pool.getParallelism()); + if (workers < 2) { + return null; + } + byte[] classified = new byte[Math.multiplyExact(height, 256)]; + int rows = Math.ceilDiv(height, workers); + ArrayList tasks = new ArrayList<>(workers); + for (int index = 0; index < workers; index++) { + int minimumY = pass.minimumY() + index * rows; + int maximumY = Math.min(pass.maximumY(), minimumY + rows - 1); + if (minimumY <= maximumY) { + tasks.add(() -> classifyRows(pass, minimumY, maximumY, classified)); + } + } + runDensityTasks(tasks, pool); + return classified; + } + + private void classifyRows(DensityPass pass, int minimumY, int maximumY, byte[] classified) { + CaveCarveScratch scratch = new CaveCarveScratch(); + for (int y = minimumY; y <= maximumY; y++) { + int count = 0; + for (int index = 0; index < pass.columnCount(); index++) { + int column = pass.columns()[index]; + if (pass.columnTops()[index] < y + || SurfaceFluidBoundaryPlan.protects(pass.fluidBoundaries(), column, y)) { + continue; + } + double threshold = pass.thresholds()[column]; + if (pass.surfaceBreakColumns()[column] && y >= pass.surfaceBreakFloors()[column]) { + threshold += pass.surfaceBreakBoost(); + } + threshold -= pass.verticalFade()[y - pass.minimumY()]; + threshold = applySurfaceCeilingFade(pass.sourceScratch(), threshold, column, y, pass.minimumY()); + scratch.planeColumnIndices[count] = column; + scratch.planeThresholdLimit[count++] = threshold * normalizationFactor; + } + if (count == 0) { + continue; + } + if (pass.adaptiveStep() == 0) { + classifyDensityPlane(scratch, pass.x(), pass.z(), y, scratch.planeColumnIndices, + scratch.planeThresholdLimit, count, scratch.planeCarve); + } else { + classifyDensityPlaneAdaptive(scratch, pass.x(), pass.z(), y, scratch.planeColumnIndices, + scratch.planeThresholdLimit, count, scratch.planeCarve, + pass.adaptiveStep(), pass.adaptiveMargin()); + } + int offset = (y - pass.minimumY()) * 256; + for (int index = 0; index < count; index++) { + if (!scratch.planeCarve[index]) { + continue; + } + int column = scratch.planeColumnIndices[index]; + double threshold = scratch.planeThresholdLimit[index] * inverseNormalization; + MatterCavern matter = resolveMatter(scratch, pass.matterByY()[y - pass.minimumY()], + pass.x() + PowerOfTwoCoordinates.unpackLocal16X(column), y, pass.z() + (column & 15), + column, pass.fluidTops(), threshold); + classified[offset + column] = matter == carveFluid + ? DENSITY_CARVE | DENSITY_FLUID : DENSITY_CARVE; + } + } + } + + private double[] sampleLatticeParallel( + int x, int z, int minimumY, int maximumY, int step, double[] thresholds, int[] tops + ) { + int rows = (maximumY - minimumY) / step + 1; + if (!parallelDensity || rows < 64 + || !(Thread.currentThread() instanceof ForkJoinWorkerThread worker)) { + return null; + } + ForkJoinPool pool = worker.getPool(); + int workers = Math.min(MAXIMUM_DENSITY_WORKERS, pool.getParallelism()); + if (workers < 2) { + return null; + } + int[] tileTops = new int[64]; + int samples = 0; + for (int tile = 0; tile < 64; tile++) { + int column = (tile >> 3) * 32 + (tile & 7) * 2; + int top = Integer.MIN_VALUE; + for (int offset : LATTICE_TILE_OFFSETS) { + if (!Double.isNaN(thresholds[column + offset])) { + top = Math.max(top, tops[column + offset]); + } + } + tileTops[tile] = top; + if (top >= minimumY) { + samples += (top - minimumY) / step + 1; + } + } + if (samples < 4096) { + return null; + } + double[] densities = new double[Math.multiplyExact(rows, 64)]; + LatticePass pass = new LatticePass(x, z, minimumY, step, rows, tileTops); + int tilesPerWorker = Math.ceilDiv(64, workers); + ArrayList tasks = new ArrayList<>(workers); + for (int index = 0; index < workers; index++) { + int firstTile = index * tilesPerWorker; + int lastTile = Math.min(64, firstTile + tilesPerWorker); + tasks.add(() -> sampleLatticeTiles(pass, firstTile, lastTile, densities)); + } + runDensityTasks(tasks, pool); + return densities; + } + + private void sampleLatticeTiles(LatticePass pass, int firstTile, int lastTile, double[] densities) { + CaveCarveScratch scratch = new CaveCarveScratch(); + for (int tile = firstTile; tile < lastTile; tile++) { + int blockX = pass.x() + (tile >> 3) * 2; + int blockZ = pass.z() + (tile & 7) * 2; + int offset = tile * pass.rows(); + for (int y = pass.minimumY(); y <= pass.tileTops()[tile]; y += pass.step()) { + densities[offset++] = sampleDensityOptimized(scratch, blockX, y, blockZ); + } + } + } + + private static void copyClassifiedPlane(byte[] classified, int row, int[] columns, int count, boolean[] result) { + int offset = row * 256; + for (int index = 0; index < count; index++) { + result[index] = (classified[offset + columns[index]] & DENSITY_CARVE) != 0; + } + } + + static void runDensityTasks(List work, ForkJoinPool pool) { + ArrayList tasks = new ArrayList<>(work.size()); + for (Runnable runnable : work) { + tasks.add(new DensityTask(runnable)); + } + Throwable failure = null; + for (int index = 1; index < tasks.size(); index++) { + try { + pool.execute(tasks.get(index)); + } catch (RuntimeException | Error rejected) { + failure = rejected; + break; + } + } + for (DensityTask task : tasks) { + task.run(); + } + boolean interrupted = Thread.interrupted(); + for (DensityTask task : tasks) { + boolean complete = false; + boolean managed = true; + while (!complete) { + try { + if (managed) { + ForkJoinPool.managedBlock(task); + } else { + task.complete.await(); + } + complete = true; + } catch (InterruptedException interruption) { + interrupted = true; + managed = false; + } catch (RejectedExecutionException unavailable) { + managed = false; + } catch (RuntimeException | Error blockingFailure) { + if (failure == null) { + failure = blockingFailure; + } else if (failure != blockingFailure) { + failure.addSuppressed(blockingFailure); + } + managed = false; + } + } + if (task.failure != null) { + if (failure == null) { + failure = task.failure; + } else if (failure != task.failure) { + failure.addSuppressed(task.failure); + } + } + } + interrupted |= Thread.interrupted(); + if (interrupted) { + Thread.currentThread().interrupt(); + InterruptedException interruption = new InterruptedException("Cave density classification interrupted."); + if (failure == null) { + failure = new IllegalStateException(interruption); + } else { + failure.addSuppressed(interruption); + } + } + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + } + + private record LatticePass(int x, int z, int minimumY, int step, int rows, int[] tileTops) { + } + + private record DensityPass( + int x, int z, int minimumY, int maximumY, + int[] columns, int[] columnTops, int columnCount, double[] thresholds, + long[] fluidBoundaries, boolean[] surfaceBreakColumns, int[] surfaceBreakFloors, + double surfaceBreakBoost, double[] verticalFade, CaveCarveScratch sourceScratch, + int adaptiveStep, double adaptiveMargin, int[] fluidTops, MatterCavern[] matterByY + ) { + } + + private static final class DensityTask implements Runnable, ForkJoinPool.ManagedBlocker { + private final Runnable work; + private final AtomicBoolean claimed = new AtomicBoolean(); + private final CountDownLatch complete = new CountDownLatch(1); + private Throwable failure; + + private DensityTask(Runnable work) { + this.work = work; + } + + @Override + public boolean isReleasable() { + return complete.getCount() == 0; + } + + @Override + public boolean block() throws InterruptedException { + complete.await(); + return true; + } + + @Override + public void run() { + if (!claimed.compareAndSet(false, true)) { + return; + } + try { + work.run(); + } catch (RuntimeException | Error failed) { + failure = failed; + } finally { + complete.countDown(); + } + } + } + private int carvePassExact( MantleChunk chunk, int x0, @@ -474,6 +774,11 @@ public class IrisCaveCarver3D { int passMaxY = Math.min(maxY, activeMaxY); int maxSection = PowerOfTwoCoordinates.floorDivPow2(passMaxY, 4); + byte[] classified = skipExistingCarved ? null : classifyPassParallel(new DensityPass( + x0, z0, minY, passMaxY, activeColumnIndices, activeColumnTopY, activeColumnCount, + passThreshold, surfaceFluidBoundaries, surfaceBreakColumn, surfaceBreakFloorY, + surfaceBreakThresholdBoost, verticalEdgeFade, scratch, 0, 0D, fluidMaxY, matterByY)); + for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex)); int sectionMaxY = Math.min(passMaxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); @@ -505,7 +810,11 @@ public class IrisCaveCarver3D { continue; } - classifyDensityPlane(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve); + if (classified == null) { + classifyDensityPlane(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve); + } else { + copyClassifiedPlane(classified, y - minY, planeColumnIndices, planeCount, planeCarve); + } int fadeIndex = y - minY; int localY = y & 15; MatterCavern verticalMatter = matterByY[fadeIndex]; @@ -541,8 +850,11 @@ public class IrisCaveCarver3D { int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, - columnIndex, fluidMaxY, localThreshold); + MatterCavern matter = classified == null + ? resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, fluidMaxY, localThreshold) + : (classified[fadeIndex * 256 + columnIndex] & DENSITY_FLUID) != 0 + ? carveFluid : verticalMatter; writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; } @@ -615,6 +927,12 @@ public class IrisCaveCarver3D { effectiveAdaptiveSampleStep ); + byte[] classified = skipExistingCarved ? null : classifyPassParallel(new DensityPass( + x0, z0, minY, passMaxY, activeColumnIndices, activeColumnTopY, activeColumnCount, + passThreshold, surfaceFluidBoundaries, surfaceBreakColumn, surfaceBreakFloorY, + surfaceBreakThresholdBoost, verticalEdgeFade, scratch, effectiveAdaptiveSampleStep, effectiveAdaptiveThresholdMargin, + fluidMaxY, matterByY)); + for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex)); int sectionMaxY = Math.min(passMaxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); @@ -646,18 +964,22 @@ public class IrisCaveCarver3D { continue; } - classifyDensityPlaneAdaptive( - scratch, - x0, - z0, - y, - planeColumnIndices, - planeThresholdLimit, - planeCount, - planeCarve, - effectiveAdaptiveSampleStep, - effectiveAdaptiveThresholdMargin - ); + if (classified == null) { + classifyDensityPlaneAdaptive( + scratch, + x0, + z0, + y, + planeColumnIndices, + planeThresholdLimit, + planeCount, + planeCarve, + effectiveAdaptiveSampleStep, + effectiveAdaptiveThresholdMargin + ); + } else { + copyClassifiedPlane(classified, y - minY, planeColumnIndices, planeCount, planeCarve); + } int fadeIndex = y - minY; int localY = y & 15; MatterCavern verticalMatter = matterByY[fadeIndex]; @@ -693,8 +1015,11 @@ public class IrisCaveCarver3D { int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; - MatterCavern matter = resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, - columnIndex, fluidMaxY, localThreshold); + MatterCavern matter = classified == null + ? resolveMatter(scratch, verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, fluidMaxY, localThreshold) + : (classified[fadeIndex * 256 + columnIndex] & DENSITY_FLUID) != 0 + ? carveFluid : verticalMatter; writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan); carved++; } @@ -756,6 +1081,9 @@ public class IrisCaveCarver3D { passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); } + double[] latticeDensity = sampleLatticeParallel(x0, z0, minY, maxY, latticeStep, passThreshold, columnMaxY); + int latticeRows = (maxY - minY) / latticeStep + 1; + for (int lx = 0; lx < 16; lx += 2) { int x = x0 + lx; int lx1 = lx + 1; @@ -815,7 +1143,9 @@ public class IrisCaveCarver3D { } for (int y = minY; y <= tileMaxY; y += latticeStep) { - double density = sampleDensityOptimized(scratch, x, y, z); + double density = latticeDensity == null + ? sampleDensityOptimized(scratch, x, y, z) + : latticeDensity[((lx >> 1) * 8 + (lz >> 1)) * latticeRows + (y - minY) / latticeStep]; int stampMaxY = Math.min(maxY, y + 1); for (int yy = y; yy <= stampMaxY; yy++) { MatterCavern verticalMatter = matterByY[yy - minY]; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 814bc6946..9bdf2163e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -1018,11 +1018,17 @@ public class IrisDimension extends IrisRegistrant { } private static String sanitizeRegistryNamespace(String value) { + if (isCanonicalRegistryValue(value, false)) { + return value; + } String sanitized = value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_"); return sanitized.isBlank() ? "dimension" : sanitized; } private static String sanitizeRegistryPath(String value, String fallback) { + if (isCanonicalRegistryValue(value, true)) { + return value; + } if (value == null || value.isBlank()) { return fallback; } @@ -1037,6 +1043,29 @@ public class IrisDimension extends IrisRegistrant { return sanitized.isBlank() ? fallback : sanitized; } + private static boolean isCanonicalRegistryValue(String value, boolean path) { + if (value == null || value.isEmpty()) { + return false; + } + char previous = 0; + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + if (current == '/') { + if (!path || i == 0 || i == value.length() - 1 || previous == '/') { + return false; + } + } else if (!((current >= 'a' && current <= 'z') || (current >= '0' && current <= '9') + || current == '_' || current == '-' || current == '.')) { + return false; + } + if (path && current == '.' && previous == '.') { + return false; + } + previous = current; + } + return true; + } + public IrisDimensionType getDimensionType() { IrisDimensionTypeOptions options = getDimensionOptions(); if (fullbright) { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 01fe75b41..96ff30c43 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -33,6 +33,7 @@ import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.runtime.ObjectStudioActivation; +import art.arcane.iris.core.runtime.ObjectStudioLayout; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; import art.arcane.iris.core.service.StudioSVC; @@ -138,6 +139,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private volatile StudioMode lastMode; private volatile UUID lastJigsawStudioRequestId; private volatile boolean jigsawStudioActive; + private volatile boolean objectStudioActive; private volatile DummyBiomeProvider dummyBiomeProvider; private volatile Throwable initializationFailure; private volatile IrisDimension validatedDimension; @@ -258,6 +260,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun /** Starts nearest-first hydrology planning around a new world's initial spawn. */ private void prefetchSpawnHydrology(Engine engine, World world) { + if (usesFlatStudioTerrain()) { + return; + } if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { return; } @@ -267,6 +272,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (world.isChunkGenerated(spawnX >> 4, spawnZ >> 4)) { return; } + if (studio && engine.getDimension().getStudioMode() == StudioMode.NORMAL + && engine instanceof IrisEngine irisEngine) { + irisEngine.startStudioEntryHydrology(spawnX, spawnZ); + return; + } int reach = Math.max(16, hydrologyTileSize() / 2); engine.getComplex().getHydrologyRuntime().prefetchArea( spawnX - reach, @@ -401,6 +411,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun jigsawStudioActive = request != null && session != null && session.sessionId().equals(request.requestId()); + objectStudioActive = studio && ObjectStudioActivation.isActive(packKey); IrisEngine createdEngine = createEngine(engineTarget); if (generationHistory != null) { long initialActivationId = generationHistory.activeActivation().activationId(); @@ -433,7 +444,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( - jigsawStudioActive, + isAuthoringStudio(), studioEntryBootstrapActive.get(), initializationFailure != null)); engine = createdEngine; @@ -471,7 +482,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private IrisEngine createEngine(EngineTarget engineTarget) { - IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive); + IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive, objectStudioActive); if (generationHistory == null) { return new IrisEngine(engineTarget, mode); } @@ -501,12 +512,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return new IllegalStateException("Unable to attach Iris generation history.", failure); } - static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive) { + static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive, boolean objectStudioActive) { if (!studio) { return IrisEngine.InitializationMode.RUNTIME; } return jigsawStudioActive ? IrisEngine.InitializationMode.JIGSAW_STUDIO + : objectStudioActive + ? IrisEngine.InitializationMode.OBJECT_STUDIO : IrisEngine.InitializationMode.STUDIO; } @@ -721,7 +734,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun Engine activeEngine = engine; if (activeEngine instanceof IrisEngine irisEngine) { irisEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( - jigsawStudioActive, + isAuthoringStudio(), false, initializationFailure != null)); } @@ -736,6 +749,33 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return jigsawStudioActive; } + public boolean isAuthoringStudio() { + return jigsawStudioActive || objectStudioActive; + } + + public boolean usesFlatStudioTerrain() { + Engine activeEngine = engine; + return usesFlatStudioTerrain(studio, isAuthoringStudio(), + activeEngine == null ? null : activeEngine.getDimension().getStudioMode()); + } + + static boolean usesFlatStudioTerrain(boolean studio, boolean authoringStudio, StudioMode mode) { + return studio && (authoringStudio || mode == StudioMode.OBJECT_BUFFET); + } + + public int getAuthoringFloorY() { + return Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y); + } + + public int getAuthoringBaseHeight() { + return authoringBaseHeight(engine.getMinHeight(), engine.getMaxHeight()); + } + + static int authoringBaseHeight(int minimumY, int maximumY) { + int floorY = Math.max(minimumY, ObjectStudioLayout.FLOOR_Y); + return floorY < maximumY ? floorY + 1 : minimumY; + } + @Override public void hotload() { if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { @@ -1087,6 +1127,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public int getBaseHeight(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull HeightMap heightMap) { Engine currentEngine = getEngine(worldInfo); + if (usesFlatStudioTerrain()) { + return getAuthoringBaseHeight(); + } boolean ignoreFluid = switch (heightMap) { case OCEAN_FLOOR, OCEAN_FLOOR_WG -> true; @@ -1163,17 +1206,17 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public boolean shouldGenerateStructures() { return shouldGenerateNativeStructures( - jigsawStudioActive, + isAuthoringStudio(), studioEntryBootstrapActive.get(), initializationFailure != null); } static boolean shouldGenerateNativeStructures( - boolean jigsawStudioActive, + boolean authoringStudio, boolean studioEntryBootstrapActive, boolean initializationFailed ) { - return !jigsawStudioActive && !studioEntryBootstrapActive && !initializationFailed; + return !authoringStudio && !studioEntryBootstrapActive && !initializationFailed; } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java index d57e0cdc0..fb6e724d9 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java @@ -62,6 +62,12 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { private final ConcurrentMap renderedBays = new ConcurrentHashMap<>(); private final ConcurrentMap jigsawStates = new ConcurrentHashMap<>(); + public static boolean isLightFloor(int blockX, int blockZ) { + int tileX = Math.floorDiv(blockX, CHECKER_SIZE); + int tileZ = Math.floorDiv(blockZ, CHECKER_SIZE); + return ((tileX + tileZ) & 1) == 0; + } + public JigsawStudioGenerator( Engine engine, JigsawStudioActivation.Request request, @@ -262,9 +268,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { for (int localZ = 0; localZ < 16; localZ++) { int worldX = chunkWorldX + localX; int worldZ = chunkWorldZ + localZ; - int tileX = Math.floorDiv(worldX, CHECKER_SIZE); - int tileZ = Math.floorDiv(worldZ, CHECKER_SIZE); - PlatformBlockState block = ((tileX + tileZ) & 1) == 0 ? lightFloor : darkFloor; + PlatformBlockState block = isLightFloor(worldX, worldZ) ? lightFloor : darkFloor; terrainChunk.setBlock(localX, floorY, localZ, block); } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java index 68e68dc36..e2cd6b87c 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java @@ -93,9 +93,11 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { } private void generateChunkWithinSession(Engine engine, TerrainChunk tc, int x, int z) { - ensureLayout(engine); - int floorY = Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y); + if (floorY >= tc.getMaxHeight()) { + return; + } + ensureLayout(engine); for (int bx = 0; bx < 16; bx++) { for (int bz = 0; bz < 16; bz++) { tc.setBiome(bx, floorY, bz, DEFAULT_BIOME); diff --git a/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java b/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java index 2bfaea3aa..c98d48e12 100644 --- a/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java +++ b/core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java @@ -667,13 +667,21 @@ public class IrisInterpolation { // outer pass's table — its entries belong to a different bound provider. cache = new NoiseBoundsSampleCache2D(64); } + NoiseSampleCache2D scalarCache = usesSampleCache(method) ? NOISE_SAMPLE_CACHE_2D.get() : null; + boolean ownsScalarCache = scalarCache != null && !scalarCache.isInUse(); cache.beginUse(); NoiseBoundsProvider previous = cache.bindProvider(noise); + if (ownsScalarCache) { + scalarCache.beginUse(); + } try { - double min = getNoise(method, x, z, h, cache.minView()); - double max = getNoise(method, x, z, h, cache.maxView()); + double min = dispatch(method, x, z, h, cache.minView()); + double max = dispatch(method, x, z, h, cache.replayMaxView()); return new NoiseBounds(min, max); } finally { + if (ownsScalarCache) { + scalarCache.endUse(); + } cache.bindProvider(previous); cache.endUse(); } diff --git a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java index 7697153bc..c7d826a2b 100644 --- a/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java @@ -24,15 +24,15 @@ import java.util.Arrays; /** * Open-addressed memo table that samples a {@link NoiseBoundsProvider} once per column and serves - * both the min and max side from the same entry. Single threaded by contract; instances are held in + * the minimum while recording maxima for an identical second interpolation pass. Single threaded; held in * thread locals. *

- * The min/max {@link NoiseProvider} views are allocated once per cache instance and read the - * currently bound provider, so a bounds interpolation pass allocates no lambdas per column. + * The minimum view samples the bound provider; the maximum view replays the same sampling sequence. + * Both views are allocated once per cache instance. */ final class NoiseBoundsSampleCache2D { private final NoiseProvider minView = this::sampleMin; - private final NoiseProvider maxView = this::sampleMax; + private final NoiseProvider replayMaxView = this::replayMax; private NoiseBoundsProvider boundProvider; private long[] xBits; private long[] zBits; @@ -43,6 +43,9 @@ final class NoiseBoundsSampleCache2D { private int resizeThreshold; private int size; private boolean inUse; + private double[] sampledMaximums = new double[64]; + private int sampledMaximumCount; + private int maximumReplayIndex; public NoiseBoundsSampleCache2D(int initialCapacity) { int minimumCapacity = Math.max(8, initialCapacity); @@ -75,6 +78,8 @@ final class NoiseBoundsSampleCache2D { public void beginUse() { inUse = true; + sampledMaximumCount = 0; + maximumReplayIndex = 0; clear(); } @@ -83,7 +88,7 @@ final class NoiseBoundsSampleCache2D { } /** - * Binds the provider that {@link #minView()} and {@link #maxView()} delegate to, returning the + * Binds the provider that {@link #minView()} samples, returning the * previously bound provider so callers can restore it. */ public NoiseBoundsProvider bindProvider(NoiseBoundsProvider provider) { @@ -96,44 +101,34 @@ final class NoiseBoundsSampleCache2D { return minView; } - public NoiseProvider maxView() { - return maxView; + public NoiseProvider replayMaxView() { + return replayMaxView; } private double sampleMin(double sampleX, double sampleZ) { - return getOrSampleMin(sampleX, sampleZ, boundProvider); - } - - private double sampleMax(double sampleX, double sampleZ) { - return getOrSampleMax(sampleX, sampleZ, boundProvider); - } - - public double getOrSampleMin(double sampleX, double sampleZ, NoiseBoundsProvider provider) { - long xBitsValue = Double.doubleToLongBits(sampleX); - long zBitsValue = Double.doubleToLongBits(sampleZ); - int slot = findSlot(xBitsValue, zBitsValue); + long sampleXBits = Double.doubleToLongBits(sampleX); + long sampleZBits = Double.doubleToLongBits(sampleZ); + int slot = findSlot(sampleXBits, sampleZBits); + double minimum; + double maximum; if (states[slot] != 0) { - return minValues[slot]; + minimum = minValues[slot]; + maximum = maxValues[slot]; + } else { + NoiseBounds bounds = boundProvider.noise(sampleX, sampleZ); + minimum = bounds.min(); + maximum = bounds.max(); + insert(findSlot(sampleXBits, sampleZBits), sampleXBits, sampleZBits, minimum, maximum); } - - NoiseBounds bounds = provider.noise(sampleX, sampleZ); - // Recompute the slot: the provider call can mutate the table (clear/grow) via nesting. - insert(findSlot(xBitsValue, zBitsValue), xBitsValue, zBitsValue, bounds.min(), bounds.max()); - return bounds.min(); + if (sampledMaximumCount == sampledMaximums.length) { + sampledMaximums = Arrays.copyOf(sampledMaximums, sampledMaximums.length << 1); + } + sampledMaximums[sampledMaximumCount++] = maximum; + return minimum; } - public double getOrSampleMax(double sampleX, double sampleZ, NoiseBoundsProvider provider) { - long xBitsValue = Double.doubleToLongBits(sampleX); - long zBitsValue = Double.doubleToLongBits(sampleZ); - int slot = findSlot(xBitsValue, zBitsValue); - if (states[slot] != 0) { - return maxValues[slot]; - } - - NoiseBounds bounds = provider.noise(sampleX, sampleZ); - // Recompute the slot: the provider call can mutate the table (clear/grow) via nesting. - insert(findSlot(xBitsValue, zBitsValue), xBitsValue, zBitsValue, bounds.min(), bounds.max()); - return bounds.max(); + private double replayMax(double sampleX, double sampleZ) { + return sampledMaximums[maximumReplayIndex++]; } private int findSlot(long xb, long zb) { diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskChunkCountTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskChunkCountTest.java new file mode 100644 index 000000000..b90f16f93 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskChunkCountTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.core.pregenerator; + +import art.arcane.volmlib.util.math.Position2; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; + +public class PregenTaskChunkCountTest { + @Test + public void countMatchesTraversalAcrossChunkAndRegionBoundaries() { + int[][] areas = { + {0, 0, 1, 1}, + {0, 0, 16, 32}, + {17, -17, 15, 33}, + {-16, -32, 16, 16}, + {-513, 511, 32, 17}, + {512, -512, 512, 256}, + {PregenTask.MAX_WORLD_BLOCK - 32, 0, 32, 1}, + {0, -PregenTask.MAX_WORLD_BLOCK + 32, 1, 32} + }; + for (int[] area : areas) { + PregenTask task = PregenTask.builder() + .center(new Position2(area[0], area[1])) + .radiusX(area[2]) + .radiusZ(area[3]) + .build(); + AtomicLong visited = new AtomicLong(); + + task.iterateAllChunks((x, z) -> visited.incrementAndGet()); + + assertEquals("center=" + area[0] + "," + area[1], visited.get(), task.chunkCount()); + } + } + + @Test(timeout = 2_000L) + public void entireWorldCountUsesLongArithmeticWithoutTraversal() { + PregenTask task = PregenTask.builder() + .radiusX(PregenTask.MAX_WORLD_BLOCK) + .radiusZ(PregenTask.MAX_WORLD_BLOCK) + .build(); + + assertEquals(14_062_492_500_001L, task.chunkCount()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java index 3e8b9389f..caa3c772f 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java @@ -56,7 +56,7 @@ public class StudioOpenCoordinatorOpenKindTest { assertTrue(kind.openWorkspace()); assertFalse(kind.teleportThroughStandardEntry()); - assertTrue(kind.prepareGeneratorState()); + assertFalse(kind.prepareGeneratorState()); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexBoundsBiomeCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexBoundsBiomeCacheTest.java new file mode 100644 index 000000000..2e69475c2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexBoundsBiomeCacheTest.java @@ -0,0 +1,188 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisInterpolator; +import art.arcane.iris.util.project.interpolation.NoiseBounds; +import art.arcane.iris.util.project.interpolation.NoiseBoundsProvider; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class IrisComplexBoundsBiomeCacheTest { + @Test + public void adjacentPassesReuseSamplesWithoutChangingInterpolationBits() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + AtomicInteger reads = new AtomicInteger(); + ProceduralStream stream = ProceduralStream.of((x, z) -> { + reads.incrementAndGet(); + return x + z < 0D ? first : second; + }, Interpolated.of(value -> 0D, value -> first)); + IrisInterpolator interpolator = new IrisInterpolator(); + Fixture fixture = new Fixture(stream); + fixture.setBounds(interpolator, first, -15.25D, 63.5D); + fixture.setBounds(interpolator, second, 7.75D, 127.25D); + int uncachedReads = 0; + + for (int z = -32; z <= 32; z += 4) { + for (int x = -32; x <= 32; x += 4) { + AtomicInteger expectedReads = new AtomicInteger(); + NoiseBounds expected = interpolator.interpolateBounds(x, z, (xx, zz) -> { + expectedReads.incrementAndGet(); + return xx + zz < 0D ? new NoiseBounds(-15.25D, 63.5D) : new NoiseBounds(7.75D, 127.25D); + }); + assertBits(expected, fixture.sample(interpolator, x, z)); + uncachedReads += expectedReads.get(); + } + } + + assertTrue(reads.get() < uncachedReads / 4); + } + + @Test + public void replacingBiomeStreamInvalidatesRetainedSamples() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + Fixture fixture = new Fixture(constant(first)); + IrisInterpolator interpolator = new IrisInterpolator(); + fixture.setBounds(interpolator, first, -10D, 20D); + fixture.setBounds(interpolator, second, 50D, 80D); + + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(interpolator, 0, 0)); + fixture.complex.setBaseBiomeStream(constant(second)); + assertBits(new NoiseBounds(50D, 80D), fixture.sample(interpolator, 0, 0)); + } + + @Test + public void newComplexDoesNotReusePreviousComplexSamples() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + Fixture firstFixture = new Fixture(constant(first)); + Fixture secondFixture = new Fixture(constant(second)); + IrisInterpolator interpolator = new IrisInterpolator(); + firstFixture.setBounds(interpolator, first, -10D, 20D); + secondFixture.setBounds(interpolator, second, 50D, 80D); + + assertBits(new NoiseBounds(-10D, 20D), firstFixture.sample(interpolator, 0, 0)); + assertBits(new NoiseBounds(50D, 80D), secondFixture.sample(interpolator, 0, 0)); + assertBits(new NoiseBounds(-10D, 20D), firstFixture.sample(interpolator, 0, 0)); + } + + @Test + public void nestedPassDoesNotReplaceOuterGeneratorBounds() throws Exception { + IrisBiome biome = new IrisBiome(); + Fixture fixture = new Fixture(constant(biome)); + IrisInterpolator nested = new IrisInterpolator().setHorizontalScale(19D); + fixture.setBounds(nested, biome, 50D, 80D); + IrisInterpolator outer = new IrisInterpolator() { + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + NoiseBounds before = provider.noise(x, z); + try { + assertBits(new NoiseBounds(50D, 80D), fixture.sample(nested, x, z)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + assertBits(before, provider.noise(x, z)); + return before; + } + }; + fixture.setBounds(outer, biome, -10D, 20D); + + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(outer, 0D, 0D)); + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(outer, 0D, 0D)); + } + + @Test + public void coordinateMemoKeepsExactKeysAndBoundedStorage() throws Exception { + Class type = Class.forName("art.arcane.iris.engine.IrisComplex$CoordinateBiomeCache"); + Constructor constructor = type.getDeclaredConstructor(int.class); + constructor.setAccessible(true); + Object cache = constructor.newInstance(4096); + Method get = type.getDeclaredMethod("get", double.class, double.class); + Method put = type.getDeclaredMethod("put", double.class, double.class, IrisBiome.class); + get.setAccessible(true); + put.setAccessible(true); + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + double[][] coordinates = {{0D, -0D}, {-0D, 0D}, {-0.25D, 0.75D}, {-0.75D, 0.25D}, + {0.125D, 0.125D}, {Math.nextUp(0.125D), 0.125D}, {1e12D, -1e12D}}; + + for (double[] coordinate : coordinates) { + put.invoke(cache, coordinate[0], coordinate[1], first); + assertSame(first, get.invoke(cache, coordinate[0], coordinate[1])); + assertNull(get.invoke(cache, Math.nextUp(coordinate[0]), coordinate[1])); + } + for (int i = 0; i < 100_000; i++) { + put.invoke(cache, (double) i, (double) -i, second); + assertSame(second, get.invoke(cache, (double) i, (double) -i)); + } + for (double[] coordinate : coordinates) { + Object result = get.invoke(cache, coordinate[0], coordinate[1]); + assertTrue(result == null || result == first); + } + Field values = type.getDeclaredField("values"); + values.setAccessible(true); + assertEquals(4096, ((IrisBiome[]) values.get(cache)).length); + } + + private static ProceduralStream constant(IrisBiome biome) { + return ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)); + } + + private static void assertBits(NoiseBounds expected, NoiseBounds actual) { + assertEquals(Double.doubleToLongBits(expected.min()), Double.doubleToLongBits(actual.min())); + assertEquals(Double.doubleToLongBits(expected.max()), Double.doubleToLongBits(actual.max())); + } + + private static final class Fixture { + private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + private final Map> bounds = new HashMap<>(); + private final Object cache; + private final Method sample; + private final Constructor boundsConstructor; + + private Fixture(ProceduralStream stream) throws Exception { + complex.setBaseBiomeStream(stream); + Field field = IrisComplex.class.getDeclaredField("generatorBounds"); + field.setAccessible(true); + field.set(complex, bounds); + Class cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache"); + Constructor constructor = cacheClass.getDeclaredConstructor(); + constructor.setAccessible(true); + cache = constructor.newInstance(); + sample = IrisComplex.class.getDeclaredMethod("sampleBoundsRaw", cacheClass, Engine.class, + IrisInterpolator.class, IrisGenerator[].class, double.class, double.class); + sample.setAccessible(true); + boundsConstructor = Class.forName("art.arcane.iris.engine.IrisComplex$GeneratorBounds") + .getDeclaredConstructor(double.class, double.class); + boundsConstructor.setAccessible(true); + } + + private void setBounds(IrisInterpolator interpolator, IrisBiome biome, double min, double max) throws Exception { + bounds.computeIfAbsent(interpolator, ignored -> new IdentityHashMap<>()) + .put(biome, boundsConstructor.newInstance(min, max)); + } + + private NoiseBounds sample(IrisInterpolator interpolator, double x, double z) throws ReflectiveOperationException { + return (NoiseBounds) sample.invoke(complex, cache, null, interpolator, new IrisGenerator[0], x, z); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexConstantGeneratorBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexConstantGeneratorBoundsTest.java new file mode 100644 index 000000000..d83c9b381 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexConstantGeneratorBoundsTest.java @@ -0,0 +1,86 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.volmlib.util.math.M; +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisComplexConstantGeneratorBoundsTest { + @Test + public void constantBoundsPreserveFiniteArithmeticAndSkipNoise() { + double[] bounds = {0D, -0D, 0.1D, -0.1D, Double.MIN_VALUE, -Double.MIN_VALUE, + Double.MAX_VALUE, -Double.MAX_VALUE}; + double[] noises = {0D, -0D, 0.3D, -1D, Double.MAX_VALUE, -Double.MAX_VALUE}; + int[] sizes = {1, 2, 3, 11}; + for (double low : bounds) { + for (double high : bounds) { + if (low != high) { + continue; + } + for (double noise : noises) { + for (int size : sizes) { + CountingGenerator generator = new CountingGenerator(noise); + IrisGenerator[] generators = new IrisGenerator[size]; + Arrays.fill(generators, generator); + double expected = 0D; + for (int i = 0; i < size; i++) { + expected += M.lerp(low, high, noise); + } + expected /= size; + assertBits(expected, IrisComplex.averageGeneratorHeights(generators, low, high, 12D, -9D, 7331L)); + assertEquals(0, generator.calls); + } + } + } + } + } + + @Test + public void inactiveInvalidNoiseMatchesExistingConstantFitContract() { + for (double noise : new double[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + CountingGenerator generator = new CountingGenerator(noise); + assertBits(generator.fitDouble(0D, 0D, 7331L, 12D, -9D), + IrisComplex.averageGeneratorHeights(new IrisGenerator[]{generator}, 0D, 0D, 12D, -9D, 7331L)); + assertEquals(0, generator.calls); + assertTrue(Double.isNaN(M.lerp(0D, 0D, noise))); + } + } + + @Test + public void nonconstantAndNonfiniteBoundsRetainNoiseEvaluation() { + double[][] bounds = {{0D, 1D}, {-9D, 12D}, {Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY}, + {Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY}, {Double.NaN, Double.NaN}}; + for (double[] pair : bounds) { + for (double noise : new double[]{0.3D, Double.NaN, Double.POSITIVE_INFINITY}) { + CountingGenerator generator = new CountingGenerator(noise); + double expected = (0D + M.lerp(pair[0], pair[1], noise)) / 1; + assertBits(expected, IrisComplex.averageGeneratorHeights(new IrisGenerator[]{generator}, + pair[0], pair[1], 12D, -9D, 7331L)); + assertEquals(1, generator.calls); + } + } + } + + private static void assertBits(double expected, double actual) { + assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual)); + } + + private static final class CountingGenerator extends IrisGenerator { + private final double value; + private int calls; + + private CountingGenerator(double value) { + this.value = value; + } + + @Override + public double getHeight(double x, double z, long seed) { + calls++; + return value; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexNaturalBiomeReuseTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexNaturalBiomeReuseTest.java new file mode 100644 index 000000000..2fdba2ec2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexNaturalBiomeReuseTest.java @@ -0,0 +1,137 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class IrisComplexNaturalBiomeReuseTest { + @Test + public void reusesBaseSelectionAndRetainsHeightDependentShoreSelection() throws Exception { + Fixture fixture = new Fixture(); + assertSame(fixture.land, fixture.sample(90D)); + assertEquals(1, fixture.cachedCalls.get()); + assertEquals(0, fixture.directCalls.get()); + assertSame(fixture.shore, fixture.sample(63D)); + assertEquals(1, fixture.directCalls.get()); + assertSame(fixture.sea, fixture.sample(40D)); + assertEquals(2, fixture.directCalls.get()); + } + + @Test + public void mappedBiomesBypassBothSelections() throws Exception { + Fixture fixture = new Fixture(); + IrisBiome mapped = new IrisBiome(); + doReturn(mapped).when(fixture.images).sampleBiome(anyDouble(), anyDouble()); + assertSame(mapped, fixture.sample(90D)); + assertEquals(0, fixture.cachedCalls.get()); + assertEquals(0, fixture.directCalls.get()); + } + + @Test + public void disabledReuseAndBoundChunkContextsKeepDirectSampling() throws Exception { + Fixture fixture = new Fixture(); + fixture.setReuse(false); + assertSame(fixture.land, fixture.sample(90D)); + fixture.setReuse(true); + try (IrisContext.Scope ignored = IrisContext.open(mock(Engine.class), 1L, mock(ChunkContext.class))) { + assertSame(fixture.land, fixture.sample(90D)); + } + assertEquals(0, fixture.cachedCalls.get()); + assertEquals(2, fixture.directCalls.get()); + try (IrisContext.Scope ignored = IrisContext.open(mock(Engine.class), 1L, null)) { + assertSame(fixture.land, fixture.sample(90D)); + } + assertEquals(1, fixture.cachedCalls.get()); + } + + @Test + public void allActiveSelectionExpressionsAndFracturesDisableReuse() { + IrisDimension dimension = new IrisDimension(); + IrisBiome leaf = new IrisBiome(); + assertTrue(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); + for (IrisGeneratorStyle style : List.of(dimension.getRegionStyle(), dimension.getContinentalStyle(), + dimension.getLandBiomeStyle(), dimension.getSeaBiomeStyle())) { + style.setFracture(new IrisGeneratorStyle().setExpression("context")); + assertFalse(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); + style.setFracture(null); + } + leaf.setChildStyle(new IrisGeneratorStyle().setExpression("context")); + assertTrue(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); + leaf.setChildren(new KList<>("child")); + assertFalse(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); + leaf.setChildStyle(new IrisGeneratorStyle().setFracture(new IrisGeneratorStyle().setExpression("context"))); + assertFalse(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); + } + + private static final class Fixture { + private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + private final IrisImageMapRuntime images = mock(IrisImageMapRuntime.class); + private final IrisRegion region = mock(IrisRegion.class); + private final IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + private final IrisBiome sea = new IrisBiome().setInferredType(InferredType.SEA); + private final IrisBiome shore = new IrisBiome().setInferredType(InferredType.SHORE); + private final AtomicInteger cachedCalls = new AtomicInteger(); + private final AtomicInteger directCalls = new AtomicInteger(); + private final Method sample; + + private Fixture() throws Exception { + complex.setImageMapRuntime(images); + complex.setFluidHeight(63D); + setReuse(true); + complex.setBaseBiomeStream(counted(land, cachedCalls)); + doReturn(2D).when(region).getShoreHeight(anyDouble(), anyDouble()); + Field streams = IrisComplex.class.getDeclaredField("inferredBiomeStreams"); + streams.setAccessible(true); + streams.set(complex, IrisComplex.compileInferredBiomeStreams(List.of(region), + (ignored, type) -> counted(switch (type) { + case SEA -> sea; + case SHORE -> shore; + default -> land; + }, directCalls))); + sample = IrisComplex.class.getDeclaredMethod("sampleNaturalBiome", InferredType.class, + IrisRegion.class, double.class, int.class, int.class); + sample.setAccessible(true); + } + + private void setReuse(boolean enabled) throws Exception { + Field reuse = IrisComplex.class.getDeclaredField("reuseNaturalBaseBiome"); + reuse.setAccessible(true); + reuse.setBoolean(complex, enabled); + } + + private IrisBiome sample(double height) throws Exception { + return (IrisBiome) sample.invoke(complex, InferredType.LAND, region, height, -17, 262144); + } + + private static ProceduralStream counted(IrisBiome biome, AtomicInteger calls) { + return ProceduralStream.of((x, z) -> { + calls.incrementAndGet(); + return biome; + }, Interpolated.of(value -> 0D, value -> biome)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexSelfSelectionTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexSelfSelectionTest.java new file mode 100644 index 000000000..fa62ee478 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSelfSelectionTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.IdentityHashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class IrisComplexSelfSelectionTest { + @Test + public void parentSelectionStopsAfterOneIdenticalNoiseSample() throws Exception { + Fixture fixture = new Fixture(); + CountingBiome parent = new CountingBiome(); + fixture.select(parent, parent); + + assertSame(parent, fixture.implode(parent, 3)); + assertEquals(1, parent.noise.calls); + } + + @Test + public void cyclicChildrenRetainDepthLimit() throws Exception { + Fixture fixture = new Fixture(); + CountingBiome first = new CountingBiome(); + CountingBiome second = new CountingBiome(); + fixture.select(first, second); + fixture.select(second, first); + + assertSame(first, fixture.implode(first, 3)); + assertEquals(2, first.noise.calls); + assertEquals(2, second.noise.calls); + } + + @Test + public void exhaustedDepthAndLeafDoNotSelectChildren() throws Exception { + Fixture fixture = new Fixture(); + CountingBiome parent = new CountingBiome(); + fixture.select(parent, parent); + assertSame(parent, fixture.implode(parent, -1)); + assertEquals(0, parent.noise.calls); + + parent.getChildren().clear(); + assertSame(parent, fixture.implode(parent, 3)); + assertEquals(0, parent.noise.calls); + } + + @Test + public void expressionsRetainRepeatedEvaluationIncludingFractureLayers() throws Exception { + for (int fractureDepth = 0; fractureDepth < 3; fractureDepth++) { + Fixture fixture = new Fixture(); + CountingBiome parent = new CountingBiome(); + IrisGeneratorStyle style = parent.getChildStyle(); + for (int level = 0; level < fractureDepth; level++) { + IrisGeneratorStyle fracture = new IrisGeneratorStyle(); + style.setFracture(fracture); + style = fracture; + } + style.setExpression("contextual-expression"); + fixture.select(parent, parent); + + assertSame(parent, fixture.implode(parent, 3)); + assertEquals(4, parent.noise.calls); + } + } + + private static final class Fixture { + private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + private final IdentityHashMap plans = new IdentityHashMap<>(); + private final Method implode; + + private Fixture() throws Exception { + complex.setRng(new RNG(7331L)); + Field field = IrisComplex.class.getDeclaredField("childSelectionPlans"); + field.setAccessible(true); + field.set(complex, plans); + implode = IrisComplex.class.getDeclaredMethod("implode", IrisBiome.class, Double.class, Double.class, int.class); + implode.setAccessible(true); + } + + private void select(IrisBiome parent, IrisBiome selected) { + IrisBiome alternative = new IrisBiome(); + plans.put(parent, IrisComplex.ChildSelectionPlan.create(new KList<>(alternative, selected))); + } + + private IrisBiome implode(IrisBiome parent, int depth) throws Exception { + return (IrisBiome) implode.invoke(complex, parent, -17.25D, 8.5D, depth); + } + } + + private static final class CountingBiome extends IrisBiome { + private final CountingNoise noise = new CountingNoise(); + + private CountingBiome() { + setChildren(new KList<>("child")); + setInferredType(InferredType.LAND); + } + + @Override + public CNG getChildrenGenerator(RNG rng, int signature, double scale) { + return noise; + } + } + + private static final class CountingNoise extends CNG { + private int calls; + + private CountingNoise() { + super(new RNG(7331L)); + } + + @Override + public int fit2D(int minimum, int maximum, double x, double z) { + calls++; + return 0; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineStudioEntryHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineStudioEntryHydrologyTest.java new file mode 100644 index 000000000..ba8b8f7cf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineStudioEntryHydrologyTest.java @@ -0,0 +1,337 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.hydrology.HydrologyPlanner; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +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.runtime.IrisHydrologyRuntime; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.iris.util.project.context.IrisContext; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisEngineStudioEntryHydrologyTest { + @Test + @SuppressWarnings("unchecked") + public void queuesScopedEntryDemandWithoutBlockingInitialization() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + IrisEngine.GenerationRuntimeBinding binding = mock(IrisEngine.GenerationRuntimeBinding.class); + GenerationSessionLease lease = mock(GenerationSessionLease.class); + IrisHydrologyRuntime hydrology = hydrology(engine); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(binding); + when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease); + when(lease.sessionId()).thenReturn(93L); + doAnswer(invocation -> { + assertSame(engine, IrisContext.require().getEngine()); + assertEquals(93L, IrisContext.require().getGenerationSessionId()); + assertSame(binding, engine.generationRuntimeScopes.current()); + return null; + }).when(hydrology).prepareChunkColumns(0, 0); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = scheduler(queued)) { + engine.startStudioEntryHydrology(0, 0); + assertNotNull(queued.get()); + verifyNoInteractions(hydrology, lease); + verify(engine, never()).acquireGenerationLease(any()); + queued.get().call(); + verify(hydrology).prepareChunkColumns(0, 0); + verify(lease).close(); + assertNull(IrisContext.get()); + assertNull(engine.generationRuntimeScopes.current()); + tasks.drainBackgroundTasks("test").requireComplete("test"); + } + } + + @Test + public void planningFailureStillLogsIfTransitionStartsDuringPlanning() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + GenerationSessionLease lease = mock(GenerationSessionLease.class); + when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease); + IrisHydrologyRuntime hydrology = hydrology(engine); + IllegalStateException cause = new IllegalStateException("planning failed"); + doAnswer(invocation -> { + closing(engine).set(true); + throw cause; + }).when(hydrology).prepareChunkColumns(0, 0); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = scheduler(queued); + MockedStatic logging = mockStatic(IrisLogging.class)) { + engine.startStudioEntryHydrology(0, 0); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> queued.get().call()); + assertSame(cause, failure.getCause()); + assertTrue(failure.getMessage().contains("Studio entry hydrology preparation failed at 0,0")); + logging.verify(() -> IrisLogging.reportError(failure)); + verify(lease).close(); + } + } + + @Test + public void closedBackgroundAdmissionAndSchedulerFailureAcquireNoLease() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + IrisEngine engine = engine(tasks); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> engine.startStudioEntryHydrology(0, 0)); + assertTrue(failure.getMessage().contains("admission closed")); + tasks.openBackgroundTaskAdmission(); + RejectedExecutionException rejected = new RejectedExecutionException("scheduler closed"); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.a(any(Callable.class))).thenThrow(rejected); + assertSame(rejected, assertThrows(RejectedExecutionException.class, + () -> engine.startStudioEntryHydrology(0, 0))); + } + verify(engine, never()).acquireGenerationLease(any()); + } + + @Test + public void queuedEntrySkipsClosedOrHotloadingSessionAndDrainsWithoutErrors() throws Exception { + for (boolean teardown : new boolean[]{false, true}) { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + GenerationSessionManager sessions = new GenerationSessionManager(); + when(engine.acquireGenerationLease(any())).thenAnswer(invocation -> sessions.acquire("entry")); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = scheduler(queued); + MockedStatic logging = mockStatic(IrisLogging.class)) { + engine.startStudioEntryHydrology(0, 0); + assertEquals(0, sessions.activeLeases()); + closing(engine).set(true); + tasks.closeBackgroundTaskAdmission(); + sessions.sealAndAwait("transition", 0L, teardown); + queued.get().call(); + tasks.drainBackgroundTasks("transition").requireComplete("transition"); + assertEquals(0, sessions.activeLeases()); + verify(engine, never()).acquireGenerationLease(any()); + logging.verifyNoInteractions(); + } + } + } + + @Test + public void admissionRaceCancelsOnlyWhenClosing() throws Exception { + for (boolean transition : new boolean[]{false, true}) { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + GenerationSessionException rejected = new GenerationSessionException("sealed", false); + when(engine.acquireGenerationLease(any())).thenAnswer(invocation -> { + closing(engine).set(transition); + throw rejected; + }); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = scheduler(queued); + MockedStatic logging = mockStatic(IrisLogging.class)) { + engine.startStudioEntryHydrology(0, 0); + if (transition) { + queued.get().call(); + logging.verifyNoInteractions(); + } else { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> queued.get().call()); + assertSame(rejected, failure.getCause()); + logging.verify(() -> IrisLogging.reportError(failure)); + } + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void canceledFutureBeforeStartOwnsNoGenerationLease() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + GenerationSessionManager sessions = new GenerationSessionManager(); + when(engine.acquireGenerationLease(any())).thenAnswer(invocation -> sessions.acquire("entry")); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + FutureTask future = new FutureTask<>(invocation.>getArgument(0)); + queued.set(future); + return future; + }); + engine.startStudioEntryHydrology(0, 0); + assertTrue(queued.get().cancel(false)); + queued.get().run(); + sessions.sealAndAwait("close", 0L, true); + assertEquals(0, sessions.activeLeases()); + verify(engine, never()).acquireGenerationLease(any()); + } + } + + @Test + public void activeLeaseFinishesWhileTransitionHoldsLifecycleLockAndDrains() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + IrisEngine engine = engine(tasks); + IrisHydrologyRuntime hydrology = hydrology(engine); + GenerationSessionManager sessions = new GenerationSessionManager(); + CountDownLatch admitted = new CountDownLatch(1); + CountDownLatch proceed = new CountDownLatch(1); + when(engine.acquireGenerationLease(any())).thenAnswer(invocation -> { + GenerationSessionLease lease = sessions.acquire("entry"); + admitted.countDown(); + assertTrue(proceed.await(5, TimeUnit.SECONDS)); + return lease; + }); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduler = scheduler(queued); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + engine.startStudioEntryHydrology(0, 0); + Future worker = executor.submit(queued.get()); + assertTrue(admitted.await(5, TimeUnit.SECONDS)); + Future drain = executor.submit(() -> { + synchronized (engine.lifecycleLock) { + closing(engine).set(true); + tasks.closeBackgroundTaskAdmission(); + sessions.sealAndAwait("hotload", 5000L); + tasks.drainBackgroundTasks("hotload").requireComplete("hotload"); + } + return null; + }); + try { + awaitSealed(sessions); + assertFalse(drain.isDone()); + } finally { + proceed.countDown(); + } + worker.get(5, TimeUnit.SECONDS); + drain.get(5, TimeUnit.SECONDS); + verify(hydrology).prepareChunkColumns(0, 0); + assertEquals(0, sessions.activeLeases()); + } + } + + private void awaitSealed(GenerationSessionManager sessions) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + try (GenerationSessionLease probe = sessions.acquire("seal probe")) { + Thread.yield(); + } catch (GenerationSessionException sealed) { + return; + } + } + throw new AssertionError("Session admission did not seal"); + } + + @SuppressWarnings("unchecked") + private MockedStatic scheduler(AtomicReference> queued) { + MockedStatic scheduler = mockStatic(J.class); + scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + queued.set(invocation.getArgument(0)); + return new CompletableFuture(); + }); + return scheduler; + } + + private IrisHydrologyRuntime hydrology(IrisEngine engine) { + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); + return hydrology; + } + + private AtomicBoolean closing(IrisEngine engine) throws Exception { + Field field = IrisEngine.class.getDeclaredField("closing"); + field.setAccessible(true); + return (AtomicBoolean) field.get(engine); + } + + @Test + public void exactOriginChunkDemandsAllFourTilesConcurrently() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class); + when(settings.routing()).thenReturn(HydrologyPlannerSettings.defaults().routing()); + when(settings.publicationRadius()).thenReturn(64); + when(planner.settings()).thenReturn(settings); + CountDownLatch started = new CountDownLatch(4); + CountDownLatch release = new CountDownLatch(1); + Set keys = ConcurrentHashMap.newKeySet(); + when(planner.plan(any())).thenAnswer(invocation -> { + assertTrue(keys.add(invocation.getArgument(0))); + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + HydrologyTile tile = mock(HydrologyTile.class); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + return tile; + }); + try (ExecutorService planning = Executors.newFixedThreadPool(4); + ExecutorService caller = Executors.newSingleThreadExecutor()) { + HydrologyTileCache cache = new HydrologyTileCache(planner, 16, planning); + cache.setNeighbourPrefetchEnabled(false); + Future preparation = caller.submit(() -> cache.prepareChunkColumns(0, 0)); + try { + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertEquals(Set.of(new HydrologyTileKey(-1, -1), new HydrologyTileKey(0, -1), + new HydrologyTileKey(-1, 0), new HydrologyTileKey(0, 0)), keys); + } finally { + release.countDown(); + } + preparation.get(5, TimeUnit.SECONDS); + cache.prepareChunkColumns(0, 0); + assertEquals(4, keys.size()); + cache.close(); + } + } + + private IrisEngine engine(EngineBackgroundTasks tasks) throws Exception { + IrisEngine engine = mock(IrisEngine.class); + Field field = IrisEngine.class.getDeclaredField("backgroundTasks"); + field.setAccessible(true); + field.set(engine, tasks); + Field closing = IrisEngine.class.getDeclaredField("closing"); + closing.setAccessible(true); + closing.set(engine, new AtomicBoolean()); + Field scopes = IrisEngine.class.getDeclaredField("generationRuntimeScopes"); + scopes.setAccessible(true); + scopes.set(engine, new GenerationRuntimeScopeState()); + Field lifecycle = IrisEngine.class.getDeclaredField("lifecycleLock"); + lifecycle.setAccessible(true); + lifecycle.set(engine, new Object()); + doCallRealMethod().when(engine).startStudioEntryHydrology(anyInt(), anyInt()); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java index 2ebec9b66..a55287fd7 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -67,6 +67,51 @@ public class HydrologyFootprintCompilerTest { return maximum; } + @Test + public void slopeFreeBasisPreservesCompleteSurfaceAndCaveFootprints() { + List types = List.of( + HydrologyFeatureType.SURFACE_POOL, + HydrologyFeatureType.MOUTH, + HydrologyFeatureType.UNDERGROUND_POOL, + HydrologyFeatureType.COASTAL_GROTTO + ); + for (HydrologyFeatureType type : types) { + int head = type == HydrologyFeatureType.MOUTH || type == HydrologyFeatureType.COASTAL_GROTTO + ? 63 : 70; + HydraulicSegment segment = new HydraulicSegment( + 9001L, 9000L, type, head, head, 6, 3, false, false, + List.of(new HydrologyPoint(-12, head, -3), new HydrologyPoint(32, head, 5))); + RiverCourse course = course(9000L, + type.isSurface() ? RiverCourseType.SURFACE : RiverCourseType.UNDERGROUND, segment); + SlopeCountingSampler original = new SlopeCountingSampler(false); + SlopeCountingSampler slopeFree = new SlopeCountingSampler(true); + HydrologyFootprintCompiler baseline = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling(original, request -> request.minimum(), original)); + HydrologyFootprintCompiler optimized = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling(slopeFree, request -> request.minimum(), slopeFree)); + + HydrologyFootprintCompiler.ValidationRaster expectedValidation = baseline.compileValidation(List.of(course)); + HydrologyFootprintCompiler.ValidationRaster actualValidation = optimized.compileValidation(List.of(course)); + RiverFootprint expected = baseline.compile(List.of(course)); + RiverFootprint actual = optimized.compile(List.of(course)); + + assertFalse(type.toString(), expected.isEmpty()); + assertEquals(type.toString(), expected, actual); + assertEquals(type.toString(), expectedValidation.columns(), actualValidation.columns()); + for (HydrologyColumnSample column : expected.columns().values()) { + assertEquals(type.toString(), + expectedValidation.plannedSurface().resolve(column.x(), column.z(), column.naturalHeight()), + actualValidation.plannedSurface().resolve(column.x(), column.z(), column.naturalHeight())); + } + assertTrue(type.toString(), original.basisCalls > 0); + assertEquals(type.toString(), 0, slopeFree.basisCalls); + assertEquals(type.toString(), original.basisCalls, slopeFree.slopeFreeCalls); + assertEquals(type.toString(), original.detailedCalls, slopeFree.detailedCalls); + } + } + @Test public void surfaceChannelNeverRaisesTerrainOrPublishesUnsupportedFluid() { HydraulicSegment segment = new HydraulicSegment( @@ -2290,6 +2335,56 @@ public class HydrologyFootprintCompilerTest { return visited.size(); } + private static final class SlopeCountingSampler implements HydrologyNaturalTerrainSampler, HydrologyTerrainSampler { + private final boolean omitSlope; + private int basisCalls; + private int slopeFreeCalls; + private int detailedCalls; + + private SlopeCountingSampler(boolean omitSlope) { + this.omitSlope = omitSlope; + } + + @Override + public HydrologyTerrainSample sample(int x, int z) { + detailedCalls++; + return terrain(x, z); + } + + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + basisCalls++; + return terrain(x, z); + } + + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + if (!omitSlope) { + return sampleBasis(x, z); + } + slopeFreeCalls++; + return terrain(x, z).withSlope(0D); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + return x >= 24 ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + + private HydrologyTerrainSample terrain(int x, int z) { + double slope = 1D + Math.floorMod(x * 13 - z * 7, 101) * 0.5D; + int relief = Math.floorMod(x * 3 + z * 5, 7); + return x >= 24 + ? HydrologyTerrainSample.ocean(52 + relief, "ocean").withSlope(slope) + : HydrologyTerrainSample.openLand(80 + relief, slope, z < 0 ? "north" : "south"); + } + } + private static final class CountingNaturalSampler implements HydrologyNaturalTerrainSampler { private final HydrologyTerrainSample basis; private final NaturalClassification classification; diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java index b9179aaec..43fbf58fa 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java @@ -6,16 +6,195 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyForkJoinTest { + @Test + public void blockedGridBatchNeverHelpsUnrelatedOwnerWork() throws Exception { + ForkJoinPool pool = new ForkJoinPool(2, ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, false, 2, 2, 2, null, 60, TimeUnit.SECONDS); + CountDownLatch childStarted = new CountDownLatch(1); + CountDownLatch releaseChild = new CountDownLatch(1); + CountDownLatch unrelatedRan = new CountDownLatch(1); + try { + Future> result = pool.submit(() -> { + return HydrologyForkJoin.invokeAll(List.of(() -> { + await(childStarted); + return 1; + }, () -> { + pool.execute(() -> { + unrelatedRan.countDown(); + }); + childStarted.countDown(); + await(releaseChild); + return 2; + }), null); + }); + assertTrue(childStarted.await(5, TimeUnit.SECONDS)); + assertFalse(unrelatedRan.await(100, TimeUnit.MILLISECONDS)); + releaseChild.countDown(); + assertEquals(List.of(1, 2), result.get(5, TimeUnit.SECONDS)); + assertTrue(unrelatedRan.await(5, TimeUnit.SECONDS)); + } finally { + releaseChild.countDown(); + pool.shutdownNow(); + } + } + + @Test + public void failedCallerDrainsAnAlreadyStartedGridRow() throws Exception { + ForkJoinPool pool = new ForkJoinPool(2); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger completed = new AtomicInteger(); + IllegalStateException expected = new IllegalStateException("grid failure"); + try { + Future result = pool.submit(() -> HydrologyForkJoin.invokeAll(List.of(() -> { + await(started); + failed.countDown(); + throw expected; + }, () -> { + started.countDown(); + await(release); + completed.incrementAndGet(); + return 2; + }), null)); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertFalse(result.isDone()); + release.countDown(); + assertThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + assertEquals(1, completed.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + } + } + + @Test + public void rejectedFallbackSchedulingNeverRunsUnsubmittedTasks() { + AtomicInteger completed = new AtomicInteger(); + RejectedExecutionException expected = new RejectedExecutionException("closed executor"); + RejectedExecutionException actual = assertThrows(RejectedExecutionException.class, + () -> HydrologyForkJoin.invokeAll(List.of(completed::incrementAndGet, completed::incrementAndGet), + runnable -> { throw expected; })); + assertEquals(expected, actual); + assertEquals(0, completed.get()); + } + + @Test + public void partiallyRejectedFallbackDrainsAcceptedWorkOnItsExecutor() throws Exception { + ExecutorService fallback = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "fallback-executor")); + ExecutorService caller = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch rejected = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger submissions = new AtomicInteger(); + AtomicInteger unsubmittedCalls = new AtomicInteger(); + AtomicReference callbackThread = new AtomicReference<>(); + RejectedExecutionException expected = new RejectedExecutionException("closed executor"); + try { + Future result = caller.submit(() -> assertThrows(RejectedExecutionException.class, + () -> HydrologyForkJoin.invokeAll(List.of(() -> { + callbackThread.set(Thread.currentThread().getName()); + started.countDown(); + await(release); + return 1; + }, unsubmittedCalls::incrementAndGet), runnable -> { + if (submissions.getAndIncrement() == 0) { + fallback.execute(runnable); + } else { + rejected.countDown(); + throw expected; + } + }))); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertTrue(rejected.await(5, TimeUnit.SECONDS)); + assertFalse(result.isDone()); + release.countDown(); + assertEquals(expected, result.get(5, TimeUnit.SECONDS)); + assertEquals("fallback-executor", callbackThread.get()); + assertEquals(0, unsubmittedCalls.get()); + } finally { + release.countDown(); + caller.shutdownNow(); + fallback.shutdownNow(); + } + } + + @Test + public void repeatedDemandClaimsAQueuedOwnerOnlyOnceAndRestoresInterrupts() throws Exception { + AtomicInteger calls = new AtomicInteger(); + HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(calls::incrementAndGet); + Thread.currentThread().interrupt(); + try { + assertEquals(Integer.valueOf(1), task.await()); + assertEquals(Integer.valueOf(1), task.await()); + task.run(); + assertEquals(1, calls.get()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + public void interruptWhileWaitingStillDrainsTheStartedTask() throws Exception { + ForkJoinPool pool = new ForkJoinPool(2); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference waiter = new AtomicReference<>(); + HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(() -> { + started.countDown(); + await(release); + return 1; + }); + try { + pool.execute(task); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future result = pool.submit(() -> { + waiter.set(Thread.currentThread()); + waiting.countDown(); + assertEquals(Integer.valueOf(1), task.await()); + return Thread.currentThread().isInterrupted(); + }); + assertTrue(waiting.await(5, TimeUnit.SECONDS)); + waiter.get().interrupt(); + assertFalse(result.isDone()); + release.countDown(); + assertTrue(result.get(5, TimeUnit.SECONDS)); + } finally { + release.countDown(); + pool.shutdownNow(); + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for planning task"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + @Test public void nestedFanOutCompletesOnASaturatedForkJoinPool() throws Exception { ForkJoinPool pool = new ForkJoinPool(1); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java index d2b2b6144..1ddae1d83 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -5,25 +5,625 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import com.github.benmanes.caffeine.cache.Cache; import org.junit.Test; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Consumer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyPlannerTest { private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + @Test + public void sharedOwnerFutureExcludesMutableCompilerWhileCreatorRetainsIt() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + HydrologyPlanner planner = routingContextPlanner(request -> { + entered.countDown(); + awaitContext(release); + }); + Method resolve = HydrologyPlanner.class.getDeclaredMethod("resolveCrossTileOwner", HydrologyTileKey.class); + resolve.setAccessible(true); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future creator = caller.submit(() -> resolve.invoke(planner, TILE)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + CompletableFuture shared = runningOwners(planner).get(TILE); + assertNotNull(shared); + release.countDown(); + Object creatorDraft = ownerDraft(creator.get(5, TimeUnit.SECONDS)); + Object sharedDraft = ownerDraft(shared.get(5, TimeUnit.SECONDS)); + Method compiler = creatorDraft.getClass().getDeclaredMethod("footprintCompiler"); + compiler.setAccessible(true); + assertNotNull(compiler.invoke(creatorDraft)); + assertNull(compiler.invoke(sharedDraft)); + Method withoutCompiler = creatorDraft.getClass().getDeclaredMethod("withoutFootprintCompiler"); + withoutCompiler.setAccessible(true); + assertEquals(withoutCompiler.invoke(creatorDraft), sharedDraft); + assertEquals(sharedDraft, ownerDraft(resolve.invoke(planner, TILE))); + } finally { + release.countDown(); + caller.shutdownNow(); + } + } + + private static Object ownerDraft(Object resolution) throws Exception { + Method draft = resolution.getClass().getDeclaredMethod("draft"); + draft.setAccessible(true); + return draft.invoke(resolution); + } + + @Test + public void collidingRoutingContextKeysCompileIndependently() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger grids = new AtomicInteger(); + HydrologyPlanner planner = routingContextPlanner(request -> { + grids.incrementAndGet(); + if (request.minimumZ() == -32) { + entered.countDown(); + awaitContext(release); + } + }); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future first = callers.submit(() -> routingContext(planner, new HydrologyTileKey(0, 0))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Future second = callers.submit(() -> routingContext(planner, new HydrologyTileKey(0, -1))); + assertNotNull(second.get(5, TimeUnit.SECONDS)); + assertFalse(first.isDone()); + assertEquals(2, grids.get()); + release.countDown(); + assertNotNull(first.get(5, TimeUnit.SECONDS)); + } finally { + release.countDown(); + callers.shutdownNow(); + } + } + + @Test + public void concurrentSameKeyRoutingContextsShareOneCompilation() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch secondEntered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger grids = new AtomicInteger(); + HydrologyPlanner planner = routingContextPlanner(request -> { + grids.incrementAndGet(); + entered.countDown(); + awaitContext(release); + }); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future first = callers.submit(() -> routingContext(planner, TILE)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Future second = callers.submit(() -> { + secondEntered.countDown(); + return routingContext(planner, TILE); + }); + assertTrue(secondEntered.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> second.get(50, TimeUnit.MILLISECONDS)); + assertEquals(1, grids.get()); + release.countDown(); + Object expected = first.get(5, TimeUnit.SECONDS); + assertSame(expected, second.get(5, TimeUnit.SECONDS)); + assertSame(expected, routingContext(planner, TILE)); + assertEquals(1, grids.get()); + } finally { + release.countDown(); + callers.shutdownNow(); + } + } + + @Test + public void failedRoutingContextCanRetryWithoutRetainingFailedWork() throws Exception { + AtomicInteger grids = new AtomicInteger(); + IllegalStateException expected = new IllegalStateException("routing input failed"); + HydrologyPlanner planner = routingContextPlanner(request -> { + if (grids.incrementAndGet() == 1) { + throw expected; + } + }); + InvocationTargetException failure = assertThrows(InvocationTargetException.class, + () -> routingContext(planner, TILE)); + assertSame(expected, failure.getCause()); + Object retried = routingContext(planner, TILE); + assertSame(retried, routingContext(planner, TILE)); + assertEquals(2, grids.get()); + Field pending = HydrologyPlanner.class.getDeclaredField("resolvingRoutingContexts"); + pending.setAccessible(true); + assertTrue(((Map) pending.get(planner)).isEmpty()); + } + + @Test(timeout = 5000) + public void recursiveRoutingContextFailsImmediatelyAndCanRetry() throws Exception { + AtomicInteger grids = new AtomicInteger(); + AtomicReference reference = new AtomicReference<>(); + HydrologyPlanner planner = routingContextPlanner(request -> { + if (grids.incrementAndGet() != 1) { + return; + } + try { + routingContext(reference.get(), TILE); + } catch (InvocationTargetException failure) { + throw (RuntimeException) failure.getCause(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + }); + reference.set(planner); + InvocationTargetException failure = assertThrows(InvocationTargetException.class, + () -> routingContext(planner, TILE)); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertTrue(failure.getCause().getMessage().contains("recursively load")); + Object retried = routingContext(planner, TILE); + assertSame(retried, routingContext(planner, TILE)); + assertEquals(2, grids.get()); + } + + private HydrologyPlanner routingContextPlanner(Consumer beforeGrid) { + HydrologyTerrainSample sample = blockedTerrain(); + HydrologyTerrainSampler terrain = (x, z) -> sample; + HydrologyPlannerSettings settings = standardSettings(4D, 2D, true, false, List.of()); + return new HydrologyPlanner(77L, settings, terrain, new ContextRoutingSampler(sample, beforeGrid), + HydrologyGeometrySampler.deterministic(terrain), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); + } + + private static Object routingContext(HydrologyPlanner planner, HydrologyTileKey key) throws Exception { + Method method = HydrologyPlanner.class.getDeclaredMethod("sourceRoutingContext", HydrologyTileKey.class); + method.setAccessible(true); + return method.invoke(planner, key); + } + + private static void awaitContext(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Routing context did not release"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + private static final class ContextRoutingSampler implements HydrologyRoutingTerrainSampler { + private final HydrologyTerrainSample sample; + private final Consumer beforeGrid; + + private ContextRoutingSampler(HydrologyTerrainSample sample, Consumer beforeGrid) { + this.sample = sample; + this.beforeGrid = beforeGrid; + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + beforeGrid.accept(request); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()]; + Arrays.fill(samples, sample); + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.UNAVAILABLE; + } + } + + @Test + public void earlyOwnersPrioritizeHigherRanksAndSkipRunningOwners() throws Exception { + HydrologyPlannerSettings settings = withPeriodTwo(standardSettings(4D, 2D, true, false, List.of())); + HydrologyPlanner planner = new HydrologyPlanner(77L, settings, (x, z) -> blockedTerrain()); + AutoCloseable admission = earlyAdmission(planner, new HydrologyTileKey(-1, -1)); + Map> tasks = earlyTasks(admission); + Map> running = runningOwners(planner); + HydrologyTileKey alreadyRunning = new HydrologyTileKey(0, -1); + CompletableFuture existing = new CompletableFuture<>(); + running.put(alreadyRunning, existing); + Method prepare = admission.getClass().getDeclaredMethod("prepare"); + prepare.setAccessible(true); + CapturingHydrologyPool pool = new CapturingHydrologyPool(); + try { + pool.submit(() -> { + prepare.invoke(admission); + prepare.invoke(admission); + return null; + }).get(5, TimeUnit.SECONDS); + assertEquals(List.of(new HydrologyTileKey(-2, -1), + new HydrologyTileKey(-1, -2), new HydrologyTileKey(-1, 0), + new HydrologyTileKey(-2, -2), new HydrologyTileKey(-2, 0), + new HydrologyTileKey(0, -2), new HydrologyTileKey(0, 0)), new ArrayList<>(tasks.keySet())); + assertEquals(new ArrayList<>(tasks.values()), pool.submitted); + assertEquals(existing, running.get(alreadyRunning)); + } finally { + running.remove(alreadyRunning, existing); + admission.close(); + pool.shutdownNow(); + } + } + + private static final class CapturingHydrologyPool extends ForkJoinPool { + private final ArrayList submitted = new ArrayList<>(); + + private CapturingHydrologyPool() { + super(1); + } + + @Override + public void execute(Runnable task) { + submitted.add(task); + } + } + + @SuppressWarnings("unchecked") + private static Map> runningOwners(HydrologyPlanner planner) throws Exception { + Field field = HydrologyPlanner.class.getDeclaredField("resolvingOwners"); + field.setAccessible(true); + return (Map>) field.get(planner); + } + + @Test + public void earlyNeighborOwnersPreserveCompleteColdOwnerOutputs() throws Exception { + HydrologyTileKey key = new HydrologyTileKey(-1, -1); + Set parallelThreads = ConcurrentHashMap.newKeySet(); + boolean observedParallelOwner = false; + ForkJoinPool pool = new ForkJoinPool(4); + try { + for (boolean organic : new boolean[]{false, true}) { + HydrologyPlannerSettings settings = organic ? organicShapeSettings() + : standardSettings(4D, 2D, true, false, List.of()); + settings = withPeriodTwo(settings); + assertEquals(2, settings.crossTileColorPeriod()); + HydrologyTerrainSampler original = organic ? organicShapeTerrain() : rollingCoast(112); + int tileSize = settings.routing().tileSize(); + HydrologyTerrainSampler terrain = (x, z) -> original.sample(x + tileSize, z + tileSize); + HydrologyTerrainSampler counted = (x, z) -> { + parallelThreads.add(Thread.currentThread()); + return terrain.sample(x, z); + }; + for (long seed : new long[]{19L, 77L, 642L}) { + HydrologyTile expected = new HydrologyPlanner(seed, settings, terrain).plan(key); + HydrologyPlanner candidate = new HydrologyPlanner(seed, settings, counted); + parallelThreads.clear(); + HydrologyTile actual = pool.submit(() -> candidate.plan(key)).get(30, TimeUnit.SECONDS); + observedParallelOwner |= parallelThreads.size() > 1; + assertTileContentsEqual(expected, actual); + assertEquals(expected.courses(), actual.courses()); + assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates()); + } + } + } finally { + pool.shutdownNow(); + } + assertTrue("Expected selected sources to prepare lower-rank owners", observedParallelOwner); + } + + @Test + public void earlyOwnersCompleteWithOneWorkerAndSaturatedRootCallers() throws Exception { + HydrologyTileKey key = new HydrologyTileKey(-1, -1); + HydrologyPlannerSettings settings = standardSettings(4D, 2D, true, false, List.of()); + settings = withPeriodTwo(settings); + int tileSize = settings.routing().tileSize(); + HydrologyTerrainSampler base = rollingCoast(112); + HydrologyTerrainSampler terrain = (x, z) -> base.sample(x + tileSize, z + tileSize); + HydrologyTile expected = new HydrologyPlanner(77L, settings, terrain).plan(key); + for (int workers : new int[]{1, 2}) { + ForkJoinPool pool = new ForkJoinPool(workers); + CountDownLatch started = new CountDownLatch(workers); + ArrayList> results = new ArrayList<>(); + try { + HydrologyPlanner shared = new HydrologyPlanner(77L, settings, terrain); + for (int index = 0; index < workers; index++) { + results.add(pool.submit(() -> { + started.countDown(); + if (!started.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Owner callers did not start"); + } + return shared.plan(key); + })); + } + for (Future result : results) { + assertTileContentsEqual(expected, result.get(30, TimeUnit.SECONDS)); + } + } finally { + pool.shutdownNow(); + } + } + } + + private static void assertTileContentsEqual(HydrologyTile expected, HydrologyTile actual) { + assertEquals("nodes", expected.nodes(), actual.nodes()); + assertEquals("edges", expected.edges(), actual.edges()); + assertEquals("outlets", expected.outlets(), actual.outlets()); + assertEquals("courses", expected.courses(), actual.courses()); + assertEquals("cave plans", expected.cavePlans(), actual.cavePlans()); + assertEquals("diagnostics", expected.diagnosticCandidates(), actual.diagnosticCandidates()); + assertEquals("footprint column count", expected.footprint().columns().size(), actual.footprint().columns().size()); + for (Map.Entry entry : expected.footprint().columns().entrySet()) { + assertEquals("footprint column " + entry.getKey(), entry.getValue(), actual.footprint().columns().get(entry.getKey())); + } + assertEquals(expected, actual); + } + + @Test + public void emptySelectionsAndLargerColorPeriodsDoNotExpandOwnerWork() throws Exception { + HydrologyPlannerSettings base = standardSettings(4D, 2D, true, false, List.of()); + HydrologyPlannerSettings periodTwo = withPeriodTwo(base); + HydrologyTileKey key = new HydrologyTileKey(-1, -1); + ForkJoinPool pool = new ForkJoinPool(4); + try { + HydrologyPlanner empty = new HydrologyPlanner(77L, periodTwo, (x, z) -> blockedTerrain()); + HydrologyTile expectedEmpty = new HydrologyPlanner(77L, periodTwo, (x, z) -> blockedTerrain()).plan(key); + assertEquals(expectedEmpty, pool.submit(() -> empty.plan(key)).get(30, TimeUnit.SECONDS)); + assertEquals(Set.of(key), cachedOwnerKeys(empty)); + assertTrue(base.crossTileColorPeriod() > 2); + HydrologyPlanner larger = new HydrologyPlanner(77L, base, rollingCoast(112)); + HydrologyPlanner serial = new HydrologyPlanner(77L, base, rollingCoast(112)); + HydrologyTile expected = serial.plan(key); + assertEquals(expected, pool.submit(() -> larger.plan(key)).get(30, TimeUnit.SECONDS)); + assertEquals(cachedOwnerKeys(serial), cachedOwnerKeys(larger)); + } finally { + pool.shutdownNow(); + } + } + + private static HydrologyPlannerSettings withPeriodTwo(HydrologyPlannerSettings settings) { + HydrologyPlannerSettings.Routing routing = settings.routing(); + int spacing = routing.sampleSpacing(); + int tileSize = (2 * (settings.publicationRadius() + 1) / spacing + 1) * spacing; + int latticeWidth = tileSize / spacing + 5; + return new HydrologyPlannerSettings(settings.seaLevel(), + new HydrologyPlannerSettings.Routing(tileSize, spacing, Math.max(routing.maximumRouteNodes(), latticeWidth * latticeWidth), + routing.maximumRouteLength(), routing.minimumSurfaceCourseLength(), routing.minimumUndergroundCourseLength(), + routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), + routing.confluenceAttraction(), routing.lengthPreference(), routing.tributaries()), + settings.surface(), settings.hydraulics(), settings.underground(), settings.outlets(), settings.geometry(), + settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves()); + } + + private static Set cachedOwnerKeys(HydrologyPlanner planner) throws Exception { + Field cacheField = HydrologyPlanner.class.getDeclaredField("resolvedOwners"); + cacheField.setAccessible(true); + Cache cache = (Cache) cacheField.get(planner); + return Set.copyOf(cache.asMap().keySet()); + } + + @Test + public void earlyOwnerFailureDrainsRemainingTasksAndPreservesParentFailure() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(19L, + standardSettings(4D, 2D, true, false, List.of()), rollingCoast(112)); + HydrologyTileKey key = new HydrologyTileKey(-1, -1); + AutoCloseable admission = earlyAdmission(planner, key); + Map> tasks = earlyTasks(admission); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch parentFailed = new CountDownLatch(1); + AtomicInteger completed = new AtomicInteger(); + AssertionError childFailure = new AssertionError("owner failure"); + IllegalArgumentException parentFailure = new IllegalArgumentException("parent failure"); + try (ForkJoinPool pool = new ForkJoinPool(3)) { + HydrologyForkJoin.Task failedTask = new HydrologyForkJoin.Task<>(() -> { throw childFailure; }); + tasks.put(new HydrologyTileKey(-2, -2), failedTask); + pool.execute(failedTask); + HydrologyForkJoin.Task delayedTask = new HydrologyForkJoin.Task<>(() -> { + started.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Owner did not release"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + completed.incrementAndGet(); + return null; + }); + tasks.put(new HydrologyTileKey(-2, -1), delayedTask); + pool.execute(delayedTask); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future parent = pool.submit(() -> { + try (admission) { + parentFailed.countDown(); + throw parentFailure; + } + }); + try { + assertTrue(parentFailed.await(5, TimeUnit.SECONDS)); + assertFalse(parent.isDone()); + } finally { + release.countDown(); + } + ExecutionException failure = assertThrows(ExecutionException.class, + () -> parent.get(5, TimeUnit.SECONDS)); + Throwable cause = failure.getCause(); + if (cause != parentFailure) { + cause = cause.getCause(); + } + assertEquals(parentFailure, cause); + assertEquals(1, parentFailure.getSuppressed().length); + assertEquals(1, completed.get()); + } + } + + @Test + public void unusedOwnerFailureDoesNotFailPublicationButDemandUsesItsOriginalFailure() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(19L, + standardSettings(4D, 2D, true, false, List.of()), rollingCoast(112)); + HydrologyTileKey key = new HydrologyTileKey(-1, -1); + HydrologyTileKey dependency = new HydrologyTileKey(-2, -2); + AutoCloseable admission = earlyAdmission(planner, key); + Map> tasks = earlyTasks(admission); + AtomicInteger attempts = new AtomicInteger(); + IllegalStateException expected = new IllegalStateException("unused owner failure"); + HydrologyForkJoin.Task failed = new HydrologyForkJoin.Task<>(() -> { + attempts.incrementAndGet(); + throw expected; + }); + tasks.put(dependency, failed); + assertThrows(IllegalStateException.class, failed::await); + Field contextField = admission.getClass().getDeclaredField("context"); + contextField.setAccessible(true); + Object context = contextField.get(admission); + Method resolve = HydrologyPlanner.class.getDeclaredMethod("resolveLowerRankOwners", + List.class, context.getClass(), Map.class); + resolve.setAccessible(true); + for (int demand = 0; demand < 2; demand++) { + InvocationTargetException failure = assertThrows( + InvocationTargetException.class, + () -> resolve.invoke(planner, List.of(dependency), context, tasks)); + assertEquals(expected, failure.getCause()); + } + assertEquals(1, attempts.get()); + admission.close(); + } + + @Test + public void demandedOwnerErrorIsNotSuppressedOntoItselfDuringDrain() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(19L, + standardSettings(4D, 2D, true, false, List.of()), rollingCoast(112)); + AutoCloseable admission = earlyAdmission(planner, new HydrologyTileKey(-1, -1)); + AssertionError expected = new AssertionError("demanded owner failure"); + HydrologyForkJoin.Task failed = new HydrologyForkJoin.Task<>(() -> { throw expected; }); + earlyTasks(admission).put(new HydrologyTileKey(-2, -2), failed); + assertThrows(AssertionError.class, failed::await); + Field primary = admission.getClass().getDeclaredField("primaryFailure"); + primary.setAccessible(true); + primary.set(admission, expected); + AssertionError actual = assertThrows(AssertionError.class, () -> { + try (admission) { + throw expected; + } + }); + assertEquals(expected, actual); + assertEquals(0, actual.getSuppressed().length); + } + + private static AutoCloseable earlyAdmission(HydrologyPlanner planner, HydrologyTileKey key) throws Exception { + Class contextType = Class.forName(HydrologyPlanner.class.getName() + "$CrossTileResolutionContext"); + Constructor contextConstructor = contextType.getDeclaredConstructor(HydrologyTileKey.class, long.class, int.class); + contextConstructor.setAccessible(true); + Object context = contextConstructor.newInstance(key, 64L, 4096); + Class admissionType = Class.forName(HydrologyPlanner.class.getName() + "$ColorRankedDraftAdmission"); + Constructor admissionConstructor = admissionType.getDeclaredConstructor( + HydrologyPlanner.class, HydrologyTileKey.class, int.class, contextType); + admissionConstructor.setAccessible(true); + return (AutoCloseable) admissionConstructor.newInstance(planner, key, 3, context); + } + + @SuppressWarnings("unchecked") + private static Map> earlyTasks(AutoCloseable admission) throws Exception { + Field prepared = admission.getClass().getDeclaredField("preparedOwners"); + prepared.setAccessible(true); + return (Map>) prepared.get(admission); + } + + @Test + public void anchorRadiusAndAdmissionRejectBeforeSlopeSampling() throws Exception { + HydrologyTerrainSample land = terrain(100, 7D, false, false, false, false, false, false); + CountingNaturalSampler outside = new CountingNaturalSampler((x, z) -> land); + assertEquals(Double.POSITIVE_INFINITY, anchorScore(countedPlanner(outside), land, 20, 0), 0D); + assertEquals(0, outside.basisCalls); + assertEquals(0, outside.slopeCalls); + + CountingNaturalSampler blocked = new CountingNaturalSampler((x, z) -> blockedTerrain()); + assertEquals(Double.POSITIVE_INFINITY, anchorScore(countedPlanner(blocked), land, 2, 0), 0D); + assertEquals(1, blocked.basisCalls); + assertEquals(0, blocked.slopeCalls); + + CountingNaturalSampler crossing = new CountingNaturalSampler((x, z) -> x == 1 ? blockedTerrain() : land); + assertEquals(Double.POSITIVE_INFINITY, anchorScore(countedPlanner(crossing), land, 2, 0), 0D); + assertTrue(crossing.basisCalls > 1); + assertEquals(0, crossing.slopeCalls); + + CountingNaturalSampler admitted = new CountingNaturalSampler((x, z) -> land); + HydrologyPlannerSettings.Routing routing = standardSettings(4D, 0D, true, false, List.of()).routing(); + double expected = land.naturalHeight() * routing.valleyPreference() + + land.slope() * routing.slopePenalty() + + land.routingCost() * land.routingMultiplier() + 2D * 2.4D + 2D * 0.08D; + expected += HydrologyHash.unit(HydrologyHash.mix(91L, 719L, 2, 0)) * 1.0E-6D; + assertEquals(Double.doubleToLongBits(expected), + Double.doubleToLongBits(anchorScore(countedPlanner(admitted), land, 2, 0))); + assertEquals(1, admitted.slopeCalls); + } + + @Test + public void refinedRouteSamplesFallbackOnlyWhenAllCandidatesFailAdmission() throws Exception { + HydrologyTerrainSample land = terrain(100, 7D, false, false, false, false, false, false); + AtomicInteger fallbackCalls = new AtomicInteger(); + CountingNaturalSampler admitted = new CountingNaturalSampler((x, z) -> { + if (x == 100) { + fallbackCalls.incrementAndGet(); + } + return land; + }); + assertEquals(3, routeCandidates(countedPlanner(admitted)).size()); + assertEquals(0, fallbackCalls.get()); + CountingNaturalSampler rejected = new CountingNaturalSampler((x, z) -> { + if (x == 100) { + fallbackCalls.incrementAndGet(); + return land; + } + return blockedTerrain(); + }); + assertEquals(1, routeCandidates(countedPlanner(rejected)).size()); + assertEquals(1, fallbackCalls.get()); + } + + @Test + public void surfaceRouteMemoPreservesCompleteOwnerOutputAcrossPublicationPasses() throws Exception { + for (boolean organic : new boolean[]{false, true}) { + HydrologyPlannerSettings settings = organic ? organicShapeSettings() + : standardSettings(4D, 2D, true, false, List.of()); + HydrologyTerrainSampler terrain = organic ? organicShapeTerrain() : rollingCoast(112); + SurfaceRouteMemos uncached = new SurfaceRouteMemos(false); + SurfaceRouteMemos cached = new SurfaceRouteMemos(true); + HydrologyTile expected = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), uncached); + HydrologyTile actual = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), cached); + + assertEquals(expected, actual); + assertEquals(expected.courses(), actual.courses()); + assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates()); + assertTrue(cached.computations <= uncached.computations); + if (organic) { + assertTrue("hits=" + cached.hits, cached.hits > 0); + assertTrue("uncached=" + uncached.computations + " cached=" + cached.computations, + cached.computations < uncached.computations); + } + } + } + @Test public void biomeIncisionMultiplierCannotExceedTheConfiguredSurfaceMaximum() { assertEquals(6, HydrologyPlanner.permittedSurfaceIncision(6, 2D)); @@ -2761,4 +3361,124 @@ public class HydrologyPlannerTest { } return false; } + @SuppressWarnings("unchecked") + private HydrologyTile planWithSurfaceRouteMemos(HydrologyPlanner planner, SurfaceRouteMemos memos) throws Exception { + Class samplesClass = Class.forName(HydrologyPlanner.class.getName() + "$PlanningSamples"); + Constructor constructor = samplesClass.getDeclaredConstructor(); + constructor.setAccessible(true); + Object samples = constructor.newInstance(); + Field routes = samplesClass.getDeclaredField("surfaceRoutes"); + routes.setAccessible(true); + routes.set(samples, memos); + Field scope = HydrologyPlanner.class.getDeclaredField("planningSamples"); + scope.setAccessible(true); + ThreadLocal local = (ThreadLocal) scope.get(planner); + local.set(samples); + try { + return planner.plan(TILE); + } finally { + local.remove(); + } + } + + private static final class SurfaceRouteMemos extends IdentityHashMap>> { + private final boolean retain; + private int hits; + private int computations; + + private SurfaceRouteMemos(boolean retain) { + this.retain = retain; + } + + @Override + public HashMap> computeIfAbsent(Object key, + Function>> ignored) { + return super.computeIfAbsent(key, unused -> new HashMap<>() { + @Override + public List computeIfAbsent(Object route, + Function> computation) { + if (!retain) { + clear(); + } + if (containsKey(route)) { + hits++; + } + return super.computeIfAbsent(route, missing -> { + computations++; + return computation.apply(missing); + }); + } + }); + } + } + + private HydrologyPlanner countedPlanner(CountingNaturalSampler sampler) { + return new HydrologyPlanner(91L, standardSettings(4D, 0D, true, false, List.of()), + sampler.delegate, sampler, HydrologyGeometrySampler.deterministic(sampler.delegate), + -4096, footprint -> solidCaveView()); + } + + private double anchorScore(HydrologyPlanner planner, HydrologyTerrainSample terrain, int x, int z) throws Exception { + Class nodeType = Class.forName(HydrologyPlanner.class.getName() + "$GridNode"); + Constructor constructor = nodeType.getDeclaredConstructor(int.class, int.class, int.class, + int.class, int.class, long.class, HydrologyTerrainSample.class); + constructor.setAccessible(true); + Object node = constructor.newInstance(0, 0, 0, 0, 0, 719L, terrain); + Method method = HydrologyPlanner.class.getDeclaredMethod("anchorScore", nodeType, + int.class, int.class, int.class, int.class, double.class); + method.setAccessible(true); + return (double) method.invoke(planner, node, x, z, 0, 0, 0.3D); + } + + private List routeCandidates(HydrologyPlanner planner) throws Exception { + Class directionType = Class.forName(HydrologyPlanner.class.getName() + "$Direction"); + Constructor directionConstructor = directionType.getDeclaredConstructor(double.class, double.class); + directionConstructor.setAccessible(true); + Object direction = directionConstructor.newInstance(1D, 0D); + Class positionType = Class.forName(HydrologyPlanner.class.getName() + "$RoutePosition"); + Constructor positionConstructor = positionType.getDeclaredConstructor(double.class, double.class, + double.class, double.class, directionType); + positionConstructor.setAccessible(true); + Object position = positionConstructor.newInstance(0D, 0D, 100D, 0D, direction); + Method method = HydrologyPlanner.class.getDeclaredMethod("routeCandidates", long.class, long.class, + positionType, double.class, int.class, int.class, String.class); + method.setAccessible(true); + return (List) method.invoke(planner, 1L, 2L, position, 0.5D, 4, 1, null); + } + + private static final class CountingNaturalSampler implements HydrologyNaturalTerrainSampler { + private final HydrologyTerrainSampler delegate; + private int basisCalls; + private int slopeCalls; + + private CountingNaturalSampler(HydrologyTerrainSampler delegate) { + this.delegate = delegate; + } + + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + slopeCalls++; + return delegate.sample(x, z); + } + + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + basisCalls++; + HydrologyTerrainSample sample = delegate.sample(x, z); + return sample == null ? null : sample.withSlope(0D); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("Point sampling fixture does not request a grid."); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + HydrologyTerrainSample sample = delegate.sample(x, z); + return sample == null ? NaturalClassification.UNAVAILABLE + : sample.ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + } + } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java index 3c6997845..58b7e8124 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java @@ -13,6 +13,39 @@ import static org.junit.Assert.assertTrue; public class HydrologyPlannerTributaryTest { private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + @Test + public void heightOnlySamplingPreservesSurfacePlansWithFewerSlopeSamples() { + HydrologyPlannerSettings settings = settings(1); + HydrologyTerrainSampler terrain = (int x, int z) -> valley(x, z).withSlope(0.5D); + CountingNaturalSampler referenceSampler = new CountingNaturalSampler(terrain, false); + CountingNaturalSampler optimizedSampler = new CountingNaturalSampler(terrain, true); + + HydrologyTile reference = planner(settings, terrain, referenceSampler).plan(TILE); + HydrologyTile optimized = planner(settings, terrain, optimizedSampler).plan(TILE); + + assertFalse(reference.courses().isEmpty()); + assertEquals(reference, optimized); + assertTrue(optimizedSampler.heightOnlyCalls > 0); + assertTrue("reference=" + referenceSampler.slopeCalls + ", optimized=" + optimizedSampler.slopeCalls, + optimizedSampler.slopeCalls < referenceSampler.slopeCalls); + } + + private static HydrologyPlanner planner( + HydrologyPlannerSettings settings, + HydrologyTerrainSampler terrain, + HydrologyNaturalTerrainSampler naturalSampler + ) { + return new HydrologyPlanner( + 19L, + settings, + terrain, + naturalSampler, + HydrologyGeometrySampler.deterministic(terrain), + -4096, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096) + ); + } + @Test public void aSecondSourceJoinsTheMainStemAsATributaryWhenAllowed() { HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::valley; @@ -419,4 +452,49 @@ public class HydrologyPlannerTributaryTest { HydrologyPlannerSettings.SeaCaves.disabled() ); } + + private static final class CountingNaturalSampler implements HydrologyNaturalTerrainSampler { + private final HydrologyTerrainSampler terrain; + private final boolean omitUnusedSlope; + private int slopeCalls; + private int heightOnlyCalls; + + private CountingNaturalSampler(HydrologyTerrainSampler terrain, boolean omitUnusedSlope) { + this.terrain = terrain; + this.omitUnusedSlope = omitUnusedSlope; + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()]; + for (int gridZ = 0; gridZ < request.width(); gridZ++) { + for (int gridX = 0; gridX < request.width(); gridX++) { + int x = request.minimumX() + gridX * request.spacing(); + int z = request.minimumZ() + gridZ * request.spacing(); + samples[gridZ * request.width() + gridX] = terrain.sample(x, z); + } + } + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return terrain.sample(blockX, blockZ).ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + + @Override + public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { + slopeCalls++; + return terrain.sample(blockX, blockZ); + } + + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) { + if (!omitUnusedSlope) { + return sampleBasis(blockX, blockZ); + } + heightOnlyCalls++; + return terrain.sample(blockX, blockZ).withSlope(0D); + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java new file mode 100644 index 000000000..1e19e69e7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java @@ -0,0 +1,233 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +public class HydrologyRouteTurnCacheTest { + @Test + public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception { + Random random = new Random(712319L); + Solver solver = new Solver(); + for (int scenario = 0; scenario < 40; scenario++) { + int count = 4 + random.nextInt(4); + HydrologyPoint[][] points = new HydrologyPoint[count][]; + for (int layer = 0; layer < count; layer++) { + points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 3]; + for (int candidate = 0; candidate < points[layer].length; candidate++) { + points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80, + candidate * 9 + random.nextInt(7)); + } + } + double maximumTurn = scenario % 2 == 0 ? 35D : 115D; + double[][][][] cache = new double[count][][][]; + for (int refinement = 0; refinement < 8; refinement++) { + Fixture fixture = fixture(points, random); + assertArrayEquals("scenario=" + scenario + " refinement=" + refinement, + exhaustive(fixture, maximumTurn), solver.solve(fixture, maximumTurn, cache)); + assertCacheGeometry(fixture, maximumTurn, cache); + } + } + } + + @Test + public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception { + HydrologyPoint[][] points = { + {new HydrologyPoint(0, 80, 0)}, + {new HydrologyPoint(0, 90, 0), new HydrologyPoint(0, 70, 0)}, + {new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 0)}, + {new HydrologyPoint(24, 80, 0)} + }; + Fixture fixture = fixture(points, new Random(2L)); + for (double[] scores : fixture.scores()) { + Arrays.fill(scores, 0D); + } + for (double[] penalties : fixture.penalties()) { + Arrays.fill(penalties, 0D); + } + for (double[][] transitions : fixture.transitions()) { + if (transitions != null) { + for (double[] row : transitions) { + Arrays.fill(row, 0D); + } + } + } + assertArrayEquals(new int[]{0, 0, 0, 0}, new Solver().solve(fixture, 75D, new double[4][][][])); + } + + private static Fixture fixture(HydrologyPoint[][] coordinates, Random random) { + int count = coordinates.length; + HydrologyPoint[][] points = new HydrologyPoint[count][]; + double[][] scores = new double[count][]; + double[][] penalties = new double[count][]; + double[][][] transitions = new double[count][][]; + for (int layer = 0; layer < count; layer++) { + int size = coordinates[layer].length; + points[layer] = new HydrologyPoint[size]; + scores[layer] = new double[size]; + penalties[layer] = new double[size]; + for (int candidate = 0; candidate < size; candidate++) { + HydrologyPoint point = coordinates[layer][candidate]; + points[layer][candidate] = new HydrologyPoint(point.x(), random.nextInt(200), point.z()); + scores[layer][candidate] = random.nextDouble() * 20D; + penalties[layer][candidate] = random.nextDouble() * 10D; + } + if (layer > 0) { + transitions[layer] = new double[coordinates[layer - 1].length][size]; + for (double[] row : transitions[layer]) { + for (int current = 0; current < size; current++) { + row[current] = random.nextInt(8) == 0 ? Double.POSITIVE_INFINITY : random.nextDouble() * 30D; + } + } + } + } + return new Fixture(points, scores, penalties, transitions); + } + + private static int[] exhaustive(Fixture fixture, double maximumTurn) { + ArrayList routes = new ArrayList<>(); + enumerate(fixture.points(), new int[fixture.points().length], 0, routes); + int[] selected = new int[0]; + double selectedCost = Double.POSITIVE_INFINITY; + for (int[] route : routes) { + double cost = fixture.scores()[0][route[0]] + fixture.scores()[1][route[1]] + + fixture.penalties()[1][route[1]] + fixture.transitions()[1][route[0]][route[1]]; + for (int layer = 2; layer < route.length && Double.isFinite(cost); layer++) { + double turn = turn(fixture.points()[layer - 2][route[layer - 2]], + fixture.points()[layer - 1][route[layer - 1]], fixture.points()[layer][route[layer]]); + if (turn > maximumTurn) { + cost = Double.POSITIVE_INFINITY; + break; + } + double excess = Math.max(0D, turn - 8D); + cost = cost + fixture.scores()[layer][route[layer]] + fixture.penalties()[layer][route[layer]] + + fixture.transitions()[layer][route[layer - 1]][route[layer]] + + excess * excess * 12D / maximumTurn; + } + if (cost < selectedCost || Double.isFinite(cost) && cost == selectedCost && precedes(route, selected)) { + selectedCost = cost; + selected = route; + } + } + return selected; + } + + private static boolean precedes(int[] candidate, int[] selected) { + int last = candidate.length - 1; + if (candidate[last - 1] != selected[last - 1]) { + return candidate[last - 1] < selected[last - 1]; + } + if (candidate[last] != selected[last]) { + return candidate[last] < selected[last]; + } + for (int index = last - 2; index >= 0; index--) { + if (candidate[index] != selected[index]) { + return candidate[index] < selected[index]; + } + } + return false; + } + + private static void enumerate(HydrologyPoint[][] points, int[] route, int layer, List routes) { + if (layer == route.length) { + routes.add(route.clone()); + return; + } + for (int candidate = 0; candidate < points[layer].length; candidate++) { + route[layer] = candidate; + enumerate(points, route, layer + 1, routes); + } + } + + private static void assertCacheGeometry(Fixture fixture, double maximumTurn, double[][][][] cache) { + int populated = 0; + for (int layer = 2; layer < cache.length; layer++) { + for (int previous = 0; previous < cache[layer].length; previous++) { + for (int current = 0; current < cache[layer][previous].length; current++) { + double[] costs = cache[layer][previous][current]; + if (costs == null) { + continue; + } + for (int before = 0; before < costs.length; before++) { + if (Double.isNaN(costs[before])) { + continue; + } + double turn = turn(fixture.points()[layer - 2][before], + fixture.points()[layer - 1][previous], fixture.points()[layer][current]); + double excess = Math.max(0D, turn - 8D); + double expected = turn > maximumTurn ? Double.POSITIVE_INFINITY : excess * excess * 12D / maximumTurn; + assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(costs[before])); + populated++; + } + } + } + } + assertTrue(populated > 0); + } + + private static double turn(HydrologyPoint before, HydrologyPoint previous, HydrologyPoint current) { + double incomingX = previous.x() - before.x(); + double incomingZ = previous.z() - before.z(); + double outgoingX = current.x() - previous.x(); + double outgoingZ = current.z() - previous.z(); + double incomingLength = StrictMath.hypot(incomingX, incomingZ); + double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); + if (incomingLength == 0D || outgoingLength == 0D) { + return 0D; + } + return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, + (incomingX * outgoingX + incomingZ * outgoingZ) / (incomingLength * outgoingLength))))); + } + + private record Fixture(HydrologyPoint[][] points, double[][] scores, double[][] penalties, double[][][] transitions) { + } + + private static final class Solver { + private final HydrologyPlanner planner = mock(HydrologyPlanner.class, CALLS_REAL_METHODS); + private final Constructor candidateConstructor; + private final Object tangent; + private final Method solve; + private final Method indices; + + private Solver() throws Exception { + Class direction = Class.forName(HydrologyPlanner.class.getName() + "$Direction"); + Constructor directionConstructor = direction.getDeclaredConstructor(double.class, double.class); + directionConstructor.setAccessible(true); + tangent = directionConstructor.newInstance(1D, 0D); + Class candidate = Class.forName(HydrologyPlanner.class.getName() + "$RouteCandidate"); + candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class, + double.class, direction, boolean.class); + candidateConstructor.setAccessible(true); + solve = HydrologyPlanner.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class, + double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class); + solve.setAccessible(true); + indices = solve.getReturnType().getDeclaredMethod("indices"); + indices.setAccessible(true); + } + + private int[] solve(Fixture fixture, double maximumTurn, double[][][][] cache) throws Exception { + ArrayList> layers = new ArrayList<>(); + for (int layer = 0; layer < fixture.points().length; layer++) { + ArrayList candidates = new ArrayList<>(); + for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) { + candidates.add(candidateConstructor.newInstance(fixture.points()[layer][candidate], 0D, + fixture.scores()[layer][candidate], 0D, tangent, true)); + } + layers.add(candidates); + } + return (int[]) indices.invoke(solve.invoke(planner, layers, 12D, maximumTurn, 12D, + fixture.penalties(), fixture.transitions(), cache)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index 50df39205..3110efe1a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -1,20 +1,27 @@ package art.arcane.iris.engine.hydrology; import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.volmlib.util.cache.CacheKey; import org.junit.Test; +import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -23,6 +30,8 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -34,6 +43,183 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Test + public void explicitAreaIsFullyQueuedBeforeExecutorCallbacksCanAddOldWork() throws Exception { + for (boolean pregeneration : new boolean[]{false, true}) { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + ArrayList planned = new ArrayList<>(); + AtomicReference cacheReference = new AtomicReference<>(); + AtomicReference monitor = new AtomicReference<>(); + AtomicBoolean injected = new AtomicBoolean(); + doAnswer(invocation -> { + assertFalse(Thread.holdsLock(monitor.get())); + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, task -> { + assertFalse(Thread.holdsLock(monitor.get())); + if (injected.compareAndSet(false, true)) { + cacheReference.get().prefetchArea(0, 0, 1023, 0, 0, 0); + } + task.run(); + }); + cacheReference.set(cache); + Field queue = HydrologyTileCache.class.getDeclaredField("prefetchQueue"); + queue.setAccessible(true); + monitor.set(queue.get(cache)); + + if (pregeneration) { + cache.preparePregeneration(262144, 262144); + } else { + cache.prefetchArea(261120, 261120, 263168, 263168, 262144, 262144); + } + + assertEquals(new HydrologyTileKey(256, 256), planned.getFirst()); + assertEquals(prefetchRectangle(254, 257, 254, 257), new HashSet<>(planned.subList(0, 16))); + assertEquals(List.of(new HydrologyTileKey(255, 255), new HydrologyTileKey(256, 255), + new HydrologyTileKey(257, 255)), planned.subList(1, 4)); + assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size()))); + assertEquals(22, planned.size()); + } + } + + @Test + public void pregenDiscardsStaleQueueWithoutCancelingActivePlansOrDemand() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + List planned = Collections.synchronizedList(new ArrayList<>()); + HydrologyTileKey activeKey = new HydrologyTileKey(0, 0); + HydrologyTileKey demandedKey = new HydrologyTileKey(-1, 0); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + HydrologyTileKey key = invocation.getArgument(0); + planned.add(key); + if (key.equals(activeKey)) { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + cache.prefetchArea(0, 0, 1023, 0, 0, 0); + Runnable initial = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(initial); + Future active = callers.submit(initial); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future demanded = callers.submit(() -> cache.get(demandedKey)); + Runnable demand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(demand); + + cache.preparePregeneration(262144, 262144); + + assertFalse(active.isDone()); + assertFalse(demanded.isDone()); + release.countDown(); + active.get(5, TimeUnit.SECONDS); + assertTrue(queued.isEmpty()); + demand.run(); + assertSame(tile, demanded.get(5, TimeUnit.SECONDS)); + drainPrefetchTasks(queued); + Set expected = new HashSet<>(prefetchRectangle(254, 257, 254, 257)); + expected.add(activeKey); + expected.add(demandedKey); + assertEquals(expected, new HashSet<>(planned)); + assertEquals(18, planned.size()); + assertSame(tile, cache.get(activeKey)); + verify(planner, times(1)).plan(activeKey); + verify(planner, times(1)).plan(demandedKey); + } finally { + release.countDown(); + callers.shutdownNow(); + } + } + + @Test + public void repeatedPregenPreparationRetainsTheFullCurrentLookaheadAndClearsCleanly() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); + cache.prefetchArea(0, 0, 1023, 0, 0, 0); + cache.preparePregeneration(262144, 262144); + cache.preparePregeneration(263168, 262144); + cache.preparePregeneration(263168, 262144); + drainPrefetchTasks(queued); + + Set expected = new HashSet<>(prefetchRectangle(255, 258, 254, 257)); + expected.add(new HydrologyTileKey(0, 0)); + assertEquals(expected, new HashSet<>(planned)); + assertEquals(17, planned.size()); + cache.clear(); + planned.clear(); + cache.preparePregeneration(263168, 262144); + drainPrefetchTasks(queued); + assertEquals(prefetchRectangle(255, 258, 254, 257), new HashSet<>(planned)); + assertEquals(16, planned.size()); + } + + @Test + public void invalidPregenBoundsLeaveQueuedPrefetchIntact() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); + cache.prefetchArea(0, 0, 1023, 0, 0, 0); + assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(Integer.MAX_VALUE, 0)); + assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(0, Integer.MIN_VALUE)); + drainPrefetchTasks(queued); + assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned)); + assertEquals(6, planned.size()); + } + + private static HydrologyPlannerSettings stalePrefetchSettings() { + HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class); + when(settings.routing()).thenReturn(new HydrologyPlannerSettings.Routing( + 1024, 64, 4096, 2048, 0, 0, 0D, 0D, 0D, 0D, 1D, 0)); + when(settings.publicationRadius()).thenReturn(364); + return settings; + } + + private static Set prefetchRectangle(int minX, int maxX, int minZ, int maxZ) { + HashSet keys = new HashSet<>(); + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + keys.add(new HydrologyTileKey(x, z)); + } + } + return keys; + } + + private static void drainPrefetchTasks(LinkedBlockingQueue queued) { + Runnable task; + while ((task = queued.poll()) != null) { + task.run(); + } + } + @Test public void equivalentStudioRuntimesReuseCompletedTiles() { HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class); @@ -108,6 +294,7 @@ public class HydrologyTileCacheTest { closing.close(); allowCompletion.countDown(); assertSame(lateTile, late.get(10L, TimeUnit.SECONDS)); + assertEquals(0, closing.size()); assertSame(plannedTile, succeeding.get(key)); verify(succeedingPlanner, times(1)).plan(key); } finally { @@ -612,6 +799,233 @@ public class HydrologyTileCacheTest { } } + @Test + public void collidingTileHashesDoNotSerializePlanning() throws Exception { + List keys = List.of(new HydrologyTileKey(0, 31), new HydrologyTileKey(1, 0)); + assertEquals(keys.getFirst().hashCode(), keys.getLast().hashCode()); + assertConcurrentTilePlanning(keys); + } + + @Test + public void diagonalColdTilesDoNotSerializePlanning() throws Exception { + assertConcurrentTilePlanning(List.of(new HydrologyTileKey(127, 127), new HydrologyTileKey(128, 128))); + } + + private void assertConcurrentTilePlanning(List keys) throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + CountDownLatch started = new CountDownLatch(keys.size()); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newFixedThreadPool(keys.size()); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, executor); + Future> result = caller.submit(() -> cache.tiles(keys)); + assertTrue("colliding keys must start before either plan completes", started.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertEquals(keys.size(), result.get(5, TimeUnit.SECONDS).size()); + for (HydrologyTileKey key : keys) { + verify(planner, times(1)).plan(key); + } + } finally { + release.countDown(); + caller.shutdownNow(); + executor.shutdownNow(); + } + } + + @Test + public void collidingChunkCacheBinsDoNotSerializeComposition() throws Exception { + int secondChunkX = 207; + int firstHash = Long.hashCode(CacheKey.mix(RiverFootprint.pack(0, 0))); + int secondHash = Long.hashCode(CacheKey.mix(RiverFootprint.pack(secondChunkX, 0))); + assertEquals((firstHash ^ (firstHash >>> 16)) & 127, (secondHash ^ (secondHash >>> 16)) & 127); + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + int blockX = invocation.getArgument(0); + int blockZ = invocation.getArgument(1); + if ((blockX == 0 || blockX == secondChunkX * 16) && blockZ == 0) { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return Optional.empty(); + }).when(tile).columnAt(anyInt(), anyInt()); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64); + Future first = executor.submit(() -> cache.prepareChunkColumns(0, 0)); + Future second = executor.submit(() -> cache.prepareChunkColumns(secondChunkX * 16, 0)); + assertTrue("colliding chunks must compose before either finishes", started.await(5, TimeUnit.SECONDS)); + release.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void concurrentSameChunkRequestsComposeOnlyOnce() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64); + List>> calls = new ArrayList<>(); + for (int index = 0; index < 32; index++) { + calls.add(() -> cache.columnAt(0, 0)); + } + for (Future> result : executor.invokeAll(calls, 5, TimeUnit.SECONDS)) { + assertTrue(result.get(5, TimeUnit.SECONDS).isEmpty()); + } + verify(tile, times(256)).columnAt(anyInt(), anyInt()); + verify(planner, times(1)).plan(new HydrologyTileKey(0, 0)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void inlineWorkerCanClaimItsQueuedPrefetch() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + ForkJoinPool executor = new ForkJoinPool(1); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + HydrologyTile result = executor.submit(() -> { + cache.prefetchArea(0, 0, 0, 0, 0, 0); + return cache.get(key); + }).get(5, TimeUnit.SECONDS); + assertSame(tile, result); + assertTrue(executor.awaitQuiescence(5, TimeUnit.SECONDS)); + verify(planner, times(1)).plan(key); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void clearDoesNotJoinOrPublishAnOldTilePlan() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile oldTile = mock(HydrologyTile.class); + HydrologyTile newTile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return oldTile; + } + return newTile; + }).when(planner).plan(key); + ExecutorService executor = Executors.newFixedThreadPool(2); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + Future oldResult = callers.submit(() -> cache.get(key)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + cache.clear(); + assertSame(newTile, callers.submit(() -> cache.get(key)).get(5, TimeUnit.SECONDS)); + release.countDown(); + assertSame(oldTile, oldResult.get(5, TimeUnit.SECONDS)); + assertSame(newTile, cache.get(key)); + assertEquals(2, calls.get()); + } finally { + release.countDown(); + callers.shutdownNow(); + executor.shutdownNow(); + } + } + + @Test + public void queuedPrefetchCannotPublishAcrossClear() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + List tasks = new ArrayList<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, tasks::add); + + cache.prefetchArea(0, 0, 0, 0, 0, 0); + cache.clear(); + tasks.removeFirst().run(); + + assertEquals(0, cache.size()); + cache.prefetchArea(0, 0, 0, 0, 0, 0); + tasks.removeFirst().run(); + assertEquals(1, cache.size()); + verify(planner, times(2)).plan(new HydrologyTileKey(0, 0)); + } + + @Test + public void clearDoesNotJoinOrPublishOldChunkColumns() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile oldTile = mock(HydrologyTile.class); + HydrologyTile newTile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(oldTile, newTile); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + int blockX = invocation.getArgument(0); + int blockZ = invocation.getArgument(1); + if (blockX == 0 && blockZ == 0) { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return Optional.empty(); + }).when(oldTile).columnAt(anyInt(), anyInt()); + HydrologyColumnSample sample = new HydrologyColumnSample(0, 0, 90, 63, false, "parent", List.of()); + when(newTile.columnAt(anyInt(), anyInt())).thenReturn(Optional.of(sample)); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8); + Future> oldResult = executor.submit(() -> cache.columnAt(0, 0)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + cache.clear(); + assertEquals(Optional.of(sample), executor.submit(() -> cache.columnAt(0, 0)).get(5, TimeUnit.SECONDS)); + release.countDown(); + assertTrue(oldResult.get(5, TimeUnit.SECONDS).isEmpty()); + assertEquals(Optional.of(sample), cache.columnAt(0, 0)); + verify(newTile, times(256)).columnAt(anyInt(), anyInt()); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test(timeout = 5000) + public void recursiveChunkCompositionFailsInsteadOfJoiningItself() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + HydrologyTileCache cache = new HydrologyTileCache(planner, 8); + doAnswer(invocation -> cache.columnAt(0, 0)).when(tile).columnAt(anyInt(), anyInt()); + + org.junit.Assert.assertThrows(IllegalStateException.class, () -> cache.columnAt(0, 0)); + } + @Test public void tileBatchesNeverKeepMoreThanHalfTheCacheBoundInFlight() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -852,6 +1266,199 @@ public class HydrologyTileCacheTest { assertEquals(4, cache.size()); verify(planner, times(4)).plan(any(HydrologyTileKey.class)); } + + @Test + public void speculativeQueueWaitsForEveryDemandAndSharesQueuedPlans() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + int tileSize = emptySettings().routing().tileSize(); + cache.prefetchArea(0, 0, tileSize * 4 - 1, 0, 0, 0); + Runnable initialPrefetch = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(initialPrefetch); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + List keys = List.of( + new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0)); + Future> result = caller.submit(() -> cache.tiles(keys)); + Runnable firstDemand = queued.poll(5, TimeUnit.SECONDS); + Runnable secondDemand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(firstDemand); + assertNotNull(secondDemand); + + initialPrefetch.run(); + assertTrue(queued.isEmpty()); + assertSame(tile, cache.get(new HydrologyTileKey(0, 0))); + assertTrue(queued.isEmpty()); + firstDemand.run(); + assertTrue(queued.isEmpty()); + secondDemand.run(); + assertEquals(3, result.get(5, TimeUnit.SECONDS).size()); + Runnable resumedPrefetch = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(resumedPrefetch); + resumedPrefetch.run(); + + assertTrue(queued.isEmpty()); + for (int index = 0; index < 4; index++) { + verify(planner, times(1)).plan(new HydrologyTileKey(index, 0)); + } + } finally { + caller.shutdownNow(); + } + } + + @Test + public void demandCompletingBeforeActivePrefetchDoesNotLoseResume() throws Exception { + assertDemandPrefetchResume(true, false); + } + + @Test + public void immediateAsyncBatchRegistersAllDemandBeforePrefetchResumes() throws Exception { + assertBatchDemandOrder(false); + } + + @Test + public void inlineBatchRegistersAllDemandBeforePrefetchResumes() throws Exception { + assertBatchDemandOrder(true); + } + + private void assertBatchDemandOrder(boolean inline) throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + ArrayList order = new ArrayList<>(); + AtomicReference cacheReference = new AtomicReference<>(); + int tileSize = emptySettings().routing().tileSize(); + doAnswer(invocation -> { + HydrologyTileKey key = invocation.getArgument(0); + order.add(key); + if (key.tileX() == 1) { + cacheReference.get().prefetchArea(0, 0, tileSize * 4 - 1, 0, 0, 0); + } + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ForkJoinPool pool = new ForkJoinPool(1) { + @Override + public void execute(Runnable task) { + task.run(); + } + }; + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, inline ? pool : Runnable::run); + cacheReference.set(cache); + List keys = List.of(new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0)); + if (inline) { + assertEquals(2, pool.submit(() -> cache.tiles(keys)).get(5, TimeUnit.SECONDS).size()); + } else { + assertEquals(2, cache.tiles(keys).size()); + } + assertEquals(List.of(new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0), + new HydrologyTileKey(0, 0), new HydrologyTileKey(3, 0)), order); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void failedDemandResumesPausedPrefetch() throws Exception { + assertDemandPrefetchResume(false, true); + } + + private void assertDemandPrefetchResume(boolean demandFirst, boolean failDemand) throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + HydrologyTileKey demandKey = new HydrologyTileKey(1, 0); + if (failDemand) { + when(planner.plan(demandKey)).thenThrow(new IllegalStateException("Interrupted", new InterruptedException())); + } + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + int tileSize = emptySettings().routing().tileSize(); + cache.prefetchArea(0, 0, tileSize * 3 - 1, 0, 0, 0); + Runnable initialPrefetch = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(initialPrefetch); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future result = caller.submit(() -> cache.get(demandKey)); + Runnable demand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(demand); + if (demandFirst) { + demand.run(); + assertTrue(queued.isEmpty()); + initialPrefetch.run(); + } else { + initialPrefetch.run(); + assertTrue(queued.isEmpty()); + demand.run(); + } + if (failDemand) { + assertThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + } else { + assertSame(tile, result.get(5, TimeUnit.SECONDS)); + } + Runnable resumed = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(resumed); + resumed.run(); + assertTrue(queued.isEmpty()); + verify(planner, times(1)).plan(new HydrologyTileKey(2, 0)); + } finally { + caller.shutdownNow(); + } + } + + @Test + public void clearDiscardsOldDemandPauseAndSpeculativeQueue() throws Exception { + assertDemandPrefetchInvalidation(false); + } + + @Test + public void closePreventsOldDemandFromResumingPrefetch() throws Exception { + assertDemandPrefetchInvalidation(true); + } + + private void assertDemandPrefetchInvalidation(boolean close) throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + int tileSize = emptySettings().routing().tileSize(); + cache.prefetchArea(0, 0, tileSize * 3 - 1, 0, 0, 0); + Runnable initialPrefetch = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(initialPrefetch); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future result = caller.submit(() -> cache.tiles(List.of(new HydrologyTileKey(1, 0))).getFirst()); + Runnable oldDemand = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(oldDemand); + initialPrefetch.run(); + assertTrue(queued.isEmpty()); + if (close) { + cache.close(); + } else { + cache.clear(); + } + cache.prefetchArea(tileSize * 10, 0, tileSize * 10, 0, tileSize * 10, 0); + if (!close) { + Runnable freshPrefetch = queued.poll(5, TimeUnit.SECONDS); + assertNotNull(freshPrefetch); + freshPrefetch.run(); + } + oldDemand.run(); + assertSame(tile, result.get(5, TimeUnit.SECONDS)); + assertTrue(queued.isEmpty()); + assertEquals(close ? 0 : 1, cache.size()); + verify(planner, never()).plan(new HydrologyTileKey(2, 0)); + } finally { + caller.shutdownNow(); + } + } @Test public void plannedCheckNeverPlansItselfButAsksThePrefetchExecutorForMissingTiles() { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -927,4 +1534,5 @@ public class HydrologyTileCacheTest { verify(planner, org.mockito.Mockito.atLeastOnce()).plan(any(HydrologyTileKey.class)); assertTrue(cache.isPlanned(40, 40)); } + } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveDensityTasksTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveDensityTasksTest.java new file mode 100644 index 000000000..8d57f92f4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveDensityTasksTest.java @@ -0,0 +1,211 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class CaveDensityTasksTest { + @Test + public void saturatedParentsClaimQueuedWorkWithoutHelpingOtherLocks() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(2)) { + CountDownLatch parents = new CountDownLatch(2); + AtomicInteger executions = new AtomicInteger(); + Runnable parent = () -> { + synchronized (new Object()) { + parents.countDown(); + await(parents); + IrisCaveCarver3D.runDensityTasks(List.of( + executions::incrementAndGet, executions::incrementAndGet, + executions::incrementAndGet, executions::incrementAndGet), pool); + } + }; + Future first = pool.submit(parent); + Future second = pool.submit(parent); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(8, executions.get()); + } + } + + @Test + public void failedCallerDrainsStartedWorkerBeforeThrowing() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(2)) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + AtomicInteger completed = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("density failure"); + Future parent = pool.submit(() -> IrisCaveCarver3D.runDensityTasks(List.of(() -> { + await(started); + failed.countDown(); + throw failure; + }, () -> { + started.countDown(); + await(release); + completed.incrementAndGet(); + }), pool)); + try { + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertFalse(parent.isDone()); + } finally { + release.countDown(); + } + try { + parent.get(5, TimeUnit.SECONDS); + fail("Expected density failure"); + } catch (ExecutionException expected) { + Throwable cause = expected.getCause(); + assertTrue(cause == failure || cause.getCause() == failure); + } + assertEquals(1, completed.get()); + } + } + + @Test + public void interruptionDrainsWorkerAndRestoresInterruptStatus() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(2)) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch awaiting = new CountDownLatch(1); + AtomicReference parentThread = new AtomicReference<>(); + AtomicInteger completed = new AtomicInteger(); + Future parent = pool.submit(() -> { + parentThread.set(Thread.currentThread()); + try { + IrisCaveCarver3D.runDensityTasks(List.of(() -> { + await(started); + awaiting.countDown(); + }, () -> { + started.countDown(); + await(release); + completed.incrementAndGet(); + }), pool); + return false; + } catch (IllegalStateException expected) { + return Thread.currentThread().isInterrupted(); + } + }); + try { + assertTrue(awaiting.await(5, TimeUnit.SECONDS)); + parentThread.get().interrupt(); + assertFalse(parent.isDone()); + } finally { + release.countDown(); + } + assertTrue(parent.get(5, TimeUnit.SECONDS)); + assertEquals(1, completed.get()); + } + } + + @Test + public void rejectedSubmissionClaimsAllWorkBeforeFailureReturns() { + ForkJoinPool pool = new ForkJoinPool(2); + pool.shutdown(); + AtomicInteger completed = new AtomicInteger(); + try { + IrisCaveCarver3D.runDensityTasks(List.of(completed::incrementAndGet, completed::incrementAndGet), pool); + fail("Expected rejected submission"); + } catch (RejectedExecutionException expected) { + assertEquals(2, completed.get()); + } + } + + @Test + public void managedDrainCompensatesWithoutRunningUnrelatedWorkOnParent() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(2, ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, false, 2, 3, 2, null, 60, TimeUnit.SECONDS)) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch awaiting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference owner = new AtomicReference<>(); + Object chunkFlag = new Object(); + Future parent = pool.submit(() -> { + synchronized (chunkFlag) { + owner.set(Thread.currentThread()); + IrisCaveCarver3D.runDensityTasks(List.of(() -> { + await(started); + awaiting.countDown(); + }, () -> { + started.countDown(); + await(release); + }), pool); + } + }); + try { + assertTrue(awaiting.await(5, TimeUnit.SECONDS)); + Thread other = pool.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + assertFalse(other == owner.get()); + assertFalse(parent.isDone()); + } finally { + release.countDown(); + } + parent.get(5, TimeUnit.SECONDS); + } + } + + @Test + public void unavailableCompensationStillDrainsStartedWork() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(2, ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, false, 2, 2, 2, null, 60, TimeUnit.SECONDS)) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch awaiting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger completed = new AtomicInteger(); + Future parent = pool.submit(() -> IrisCaveCarver3D.runDensityTasks(List.of(() -> { + await(started); + awaiting.countDown(); + }, () -> { + started.countDown(); + await(release); + completed.incrementAndGet(); + }), pool)); + try { + assertTrue(awaiting.await(5, TimeUnit.SECONDS)); + assertFalse(parent.isDone()); + } finally { + release.countDown(); + } + parent.get(5, TimeUnit.SECONDS); + assertEquals(1, completed.get()); + } + } + + @Test + public void completedTasksStillPropagateAnExistingInterrupt() { + try (ForkJoinPool pool = new ForkJoinPool(1)) { + Thread.currentThread().interrupt(); + try { + IrisCaveCarver3D.runDensityTasks(List.of(() -> {}), pool); + fail("Expected interrupted density batch"); + } catch (IllegalStateException expected) { + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for density task"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 2e6f48593..f52221e2b 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -34,6 +34,9 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -41,6 +44,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -108,6 +113,157 @@ public class IrisCaveCarver3DNearParityTest { carveForcedAirField.setAccessible(true); } + @Test + public void parallelDensityPreservesExactAndAdaptiveCavernsAndLiquids() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(4)) { + for (boolean adaptive : new boolean[]{false, true}) { + for (boolean warp : new boolean[]{false, true}) { + for (boolean modules : new boolean[]{false, true}) { + Engine engine = createEngine(128, 110); + IrisCaveProfile profile = createProfile(warp, modules).setAdaptiveSampling(adaptive) + .setDensityThreshold(new IrisStyledRange(0.08D, 0.08D, new IrisGeneratorStyle(NoiseStyle.FLAT))) + .setFluidRequiresFloor(true); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + WriterCapture serial = createWriterCapture(128); + WriterCapture parallel = createWriterCapture(128); + int[] heights = filledHeights(110); + double[] weights = fullWeights(); + long[] boundaries = new long[256]; + Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary( + SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE)); + for (int column = 0; column < 256; column += 11) { + heights[column] = 80; + weights[column] = column % 3 == 0 ? 0D : 0.6D; + boundaries[column] = SurfaceFluidBoundaryPlan.boundary(56, 64); + } + int expected = carver.carve(serial.writer, -17, 19, weights, 0.1D, 0.15D, + null, heights, boundaries, null, new CaveFluidSupportPlan()); + int actual = pool.submit(() -> carver.carve(parallel.writer, -17, 19, weights, 0.1D, 0.15D, + null, heights, boundaries, null, new CaveFluidSupportPlan())).get(20, TimeUnit.SECONDS); + assertTrue(expected > 0); + assertEquals(expected, actual); + assertEquals(serial.carvedCells, parallel.carvedCells); + assertEquals(serial.carvedLiquids, parallel.carvedLiquids); + } + } + } + } + } + + @Test + public void rejectedParallelVoxelsDoNotSampleAquifers() throws Exception { + Engine engine = createEngine(128, 110); + IrisCaveProfile profile = createProfile(false, false).setAdaptiveSampling(false) + .setBaseDensityStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) + .setDetailWeight(0D) + .setDensityThreshold(new IrisStyledRange(-2D, -2D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + CNG detail = mock(CNG.class); + doAnswer(invocation -> { + throw new AssertionError("Rejected voxel sampled an aquifer"); + }).when(detail).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + Field detailField = IrisCaveCarver3D.class.getDeclaredField("detailDensity"); + detailField.setAccessible(true); + detailField.set(carver, detail); + WriterCapture capture = createWriterCapture(128); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + assertEquals(0, pool.submit(() -> carver.carve(capture.writer, 0, 0, fullWeights(), + 0D, 0D, null, filledHeights(110))).get(20, TimeUnit.SECONDS).intValue()); + } + assertTrue(capture.carvedCells.isEmpty()); + } + + @Test + public void parallelMaterialDecisionsPreserveLavaFluidAndForcedAir() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(4)) { + for (boolean lava : new boolean[]{false, true}) { + Engine engine = createEngine(128, 110); + IrisCaveProfile profile = createProfile(true, true).setAdaptiveSampling(true) + .setAllowLava(lava).setFluidRequiresFloor(false) + .setDensityThreshold(new IrisStyledRange(2D, 2D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + WriterCapture serial = createWriterCapture(128); + WriterCapture parallel = createWriterCapture(128); + int[] heights = filledHeights(110); + int expected = carver.carve(serial.writer, 7, -3, fullWeights(), 0D, 0D, null, heights); + int actual = pool.submit(() -> carver.carve(parallel.writer, 7, -3, fullWeights(), 0D, 0D, + null, heights)).get(20, TimeUnit.SECONDS); + assertEquals(expected, actual); + assertEquals(serial.carvedCells, parallel.carvedCells); + assertEquals(serial.carvedLiquids, parallel.carvedLiquids); + assertTrue(countLiquid(serial, (byte) 0) > 0); + assertTrue(countLiquid(serial, (byte) 1) > 0); + assertTrue(countLiquid(serial, lava ? (byte) 2 : (byte) 3) > 0); + } + } + } + + @Test + public void parallelLatticePreservesCavernsAndResolvedFluidSupport() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(4)) { + for (int step : new int[]{3, 5}) { + Engine engine = createEngine(384, 360); + IrisCaveProfile profile = createProfile(true, true).setSampleStep(step) + .setVerticalRange(new IrisRange(6, 350)) + .setDensityThreshold(new IrisStyledRange(0.08D, 0.08D, new IrisGeneratorStyle(NoiseStyle.FLAT))) + .setFluidRequiresFloor(true); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + WriterCapture serial = createWriterCapture(384); + WriterCapture parallel = createWriterCapture(384); + int[] heights = filledHeights(340); + double[] weights = fullWeights(); + for (int column = 0; column < 256; column += 7) { + heights[column] = 80; + weights[column] = column % 3 == 0 ? 0D : 0.6D; + } + int expected = carver.carve(serial.writer, 9, -7, weights, 0.1D, 0.15D, null, heights); + int actual = pool.submit(() -> carver.carve(parallel.writer, 9, -7, weights, 0.1D, 0.15D, + null, heights)).get(20, TimeUnit.SECONDS); + assertTrue(expected > 0); + assertEquals(expected, actual); + assertEquals(serial.carvedCells, parallel.carvedCells); + assertEquals(serial.carvedLiquids, parallel.carvedLiquids); + } + } + } + + @Test + public void expressionsInDensityFracturesDisableParallelClassification() throws Exception { + Method fixedStyles = IrisCaveCarver3D.class.getDeclaredMethod("fixedDensityStyles", IrisCaveProfile.class); + fixedStyles.setAccessible(true); + IrisCaveProfile profile = createProfile(true, true); + assertEquals(true, fixedStyles.invoke(null, profile)); + profile.getWarpStyle().setFracture(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setExpression("live-height")); + assertEquals(false, fixedStyles.invoke(null, profile)); + profile = createProfile(true, true); + profile.getModules().get(0).getStyle().setExpression("live-height"); + assertEquals(false, fixedStyles.invoke(null, profile)); + } + + @Test + public void nestedCarvingPreservesOuterScratch() { + Engine engine = createEngine(128, 110); + doReturn(110).when(engine).getHeight(anyInt(), anyInt(), anyBoolean()); + IrisCaveProfile profile = createProfile(true, true).setAdaptiveSampling(false); + WriterCapture expected = createWriterCapture(128); + new IrisCaveCarver3D(engine, profile).carve(expected.writer, 2, 3); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + WriterCapture nested = createWriterCapture(128); + AtomicBoolean entered = new AtomicBoolean(); + doAnswer(invocation -> { + if (entered.compareAndSet(false, true)) { + carver.carve(nested.writer, -9, -11, fullWeights(), 0D, 0D, null, filledHeights(80)); + } + return 110; + }).when(engine).getHeight(anyInt(), anyInt(), anyBoolean()); + WriterCapture actual = createWriterCapture(128); + carver.carve(actual.writer, 2, 3); + assertTrue(entered.get()); + assertFalse(nested.carvedCells.isEmpty()); + assertEquals(expected.carvedCells, actual.carvedCells); + assertEquals(expected.carvedLiquids, actual.carvedLiquids); + } + @Test public void genericFluidContractKeepsOverworldDefaults() { IrisCaveProfile profile = new IrisCaveProfile(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java index 3585da9f2..4d12c0e3c 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionTypeKeyTest.java @@ -2,6 +2,9 @@ package art.arcane.iris.engine.object; import org.junit.Test; +import java.util.Locale; +import java.util.Random; + import static org.junit.Assert.assertEquals; public class IrisDimensionTypeKeyTest { @@ -36,4 +39,65 @@ public class IrisDimensionTypeKeyTest { assertEquals("layers:sky/aurora", dimension.getCustomBiomeKey("Aurora")); } + + @Test + public void customBiomeKeysPreserveSanitizerBoundaryBehavior() { + String[] values = {null, "", " ", "\t\n", "a", "0", "_", "-", ".", "..", "...", "....", + "/", "//", "/a/", "a//b", "a/./b", "a/../b", "a..b", "a...b", "a\\b", " a/b ", + "A_Z-9.ext/path", "İ", "ΣΟΣ", "K", "ß", "é", "a\u2003b", "\u2003", "\u00a0", + "\u0000a\u001f", "a\u0000b", "\ud83d\ude00", "\ud800", "\udc00"}; + for (String dimension : values) { + for (String biome : values) { + assertEquals(legacyCustomBiomeKey(dimension, biome), IrisDimension.customBiomeKey(dimension, biome)); + } + } + } + + @Test + public void customBiomeKeysMatchPriorSanitizerForMixedUnicodeInputs() { + Random random = new Random(7331L); + String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-.//\\ ABCXYZ\t\nİΣKé\u2003\u0000\ud83d\ude00"; + for (int sample = 0; sample < 3000; sample++) { + String dimension = randomValue(random, alphabet); + String biome = randomValue(random, alphabet); + assertEquals(legacyCustomBiomeKey(dimension, biome), IrisDimension.customBiomeKey(dimension, biome)); + } + } + + private static String randomValue(Random random, String alphabet) { + int length = random.nextInt(65); + StringBuilder value = new StringBuilder(length); + for (int i = 0; i < length; i++) { + value.append(random.nextInt(8) == 0 ? (char) random.nextInt(65536) + : alphabet.charAt(random.nextInt(alphabet.length()))); + } + return value.toString(); + } + + private static String legacyCustomBiomeKey(String dimension, String biome) { + String dimensionPath = legacyRegistryPath(dimension, "dimension"); + int separator = dimensionPath.indexOf('/'); + String namespace = separator < 0 ? dimensionPath : dimensionPath.substring(0, separator); + namespace = namespace.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_"); + if (namespace.isBlank()) { + namespace = "dimension"; + } + return namespace + ":" + (separator < 0 ? "" : dimensionPath.substring(separator + 1) + "/") + + legacyRegistryPath(biome, "biome"); + } + + private static String legacyRegistryPath(String value, String fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + String sanitized = value.trim().toLowerCase(Locale.ROOT).replace("\\", "/"); + sanitized = sanitized.replaceAll("[^a-z0-9/._-]", "_"); + sanitized = sanitized.replaceAll("/+", "/"); + sanitized = sanitized.replaceAll("^/+", ""); + sanitized = sanitized.replaceAll("/+$", ""); + while (sanitized.contains("..")) { + sanitized = sanitized.replace("..", "_"); + } + return sanitized.isBlank() ? fallback : sanitized; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java index 5c785901f..e4d28f0db 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java @@ -1,19 +1,119 @@ package art.arcane.iris.engine.platform; import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import org.bukkit.World; +import art.arcane.iris.engine.object.StudioMode; +import art.arcane.iris.engine.object.IrisDimension; import org.junit.Test; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +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; +import static org.mockito.ArgumentMatchers.anyInt; public class BukkitChunkGeneratorInitializationModeTest { + @Test + public void normalStudioDemandsItsActualEntryAndAuthoringDoesNotPlanTerrain() throws Exception { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + Field studio = BukkitChunkGenerator.class.getDeclaredField("studio"); + studio.setAccessible(true); + studio.setBoolean(generator, true); + doReturn(false).when(generator).usesFlatStudioTerrain(); + World world = mock(World.class); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + IrisEngine engine = mock(IrisEngine.class); + IrisDimension dimension = mock(IrisDimension.class); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); + Method prefetch = BukkitChunkGenerator.class.getDeclaredMethod( + "prefetchSpawnHydrology", Engine.class, World.class); + prefetch.setAccessible(true); + + prefetch.invoke(generator, engine, world); + + verify(engine).startStudioEntryHydrology(0, 0); + verifyNoInteractions(hydrology); + assertEquals(0.5D, generator.getInitialSpawnLocation(world).getX(), 0D); + assertEquals(96D, generator.getInitialSpawnLocation(world).getY(), 0D); + assertEquals(0.5D, generator.getInitialSpawnLocation(world).getZ(), 0D); + + when(dimension.getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_1x1); + prefetch.invoke(generator, engine, world); + verify(engine, times(1)).startStudioEntryHydrology(0, 0); + verify(hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); + when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); + + IrisEngine authoring = mock(IrisEngine.class); + doReturn(true).when(generator).usesFlatStudioTerrain(); + prefetch.invoke(generator, authoring, world); + verifyNoInteractions(authoring); + + IrisEngine generated = mock(IrisEngine.class); + when(generated.getComplex()).thenReturn(complex); + doReturn(false).when(generator).usesFlatStudioTerrain(); + when(world.isChunkGenerated(0, 0)).thenReturn(true); + prefetch.invoke(generator, generated, world); + verify(generated, never()).startStudioEntryHydrology(anyInt(), anyInt()); + } + + @Test + public void standardStudioTerrainModeTracksHotloadedDimension() { + assertFalse(BukkitChunkGenerator.usesFlatStudioTerrain(true, false, StudioMode.NORMAL)); + assertTrue(BukkitChunkGenerator.usesFlatStudioTerrain(true, false, StudioMode.OBJECT_BUFFET)); + assertFalse(BukkitChunkGenerator.usesFlatStudioTerrain(true, false, StudioMode.NORMAL)); + assertTrue(BukkitChunkGenerator.usesFlatStudioTerrain(true, true, StudioMode.NORMAL)); + assertFalse(BukkitChunkGenerator.usesFlatStudioTerrain(false, false, StudioMode.OBJECT_BUFFET)); + } + + @Test + public void authoringBaseHeightMatchesTheFloorAndEmptyShortWorlds() { + assertEquals(65, BukkitChunkGenerator.authoringBaseHeight(-64, 320)); + assertEquals(129, BukkitChunkGenerator.authoringBaseHeight(128, 256)); + assertEquals(-64, BukkitChunkGenerator.authoringBaseHeight(-64, 64)); + assertEquals(-64, BukkitChunkGenerator.authoringBaseHeight(-64, 0)); + } + + @Test + public void authoringStartupDoesNotPrefetchTerrainHydrology() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java")); + int start = source.indexOf("private void prefetchSpawnHydrology("); + int end = source.indexOf("private int hydrologyTileSize()", start); + String prefetch = source.substring(start, end); + + assertTrue(prefetch.indexOf("if (usesFlatStudioTerrain())") >= 0); + assertTrue(prefetch.indexOf("if (usesFlatStudioTerrain())") + < prefetch.indexOf("engine.getComplex()")); + } + @Test public void runtimeAndOrdinaryStudioWarmGenerationCaches() { IrisEngine.InitializationMode runtime = - BukkitChunkGenerator.selectInitializationMode(false, false); + BukkitChunkGenerator.selectInitializationMode(false, false, false); IrisEngine.InitializationMode studio = - BukkitChunkGenerator.selectInitializationMode(true, false); + BukkitChunkGenerator.selectInitializationMode(true, false, false); assertEquals(IrisEngine.InitializationMode.RUNTIME, runtime); assertFalse(runtime.studio()); @@ -26,13 +126,25 @@ public class BukkitChunkGeneratorInitializationModeTest { @Test public void activeJigsawStudioSkipsGenerationCacheWarm() { IrisEngine.InitializationMode mode = - BukkitChunkGenerator.selectInitializationMode(true, true); + BukkitChunkGenerator.selectInitializationMode(true, true, false); assertEquals(IrisEngine.InitializationMode.JIGSAW_STUDIO, mode); assertTrue(mode.studio()); assertFalse(mode.warmGenerationCaches()); } + @Test + public void activeObjectStudioSkipsGenerationCacheWarm() { + IrisEngine.InitializationMode mode = + BukkitChunkGenerator.selectInitializationMode(true, false, true); + + assertEquals(IrisEngine.InitializationMode.OBJECT_STUDIO, mode); + assertTrue(mode.studio()); + assertFalse(mode.warmGenerationCaches()); + assertEquals(IrisEngine.InitializationMode.RUNTIME, + BukkitChunkGenerator.selectInitializationMode(false, false, true)); + } + @Test public void jigsawBootstrapAndInitializationFailureSkipNativeStructureGeneration() { assertFalse(BukkitChunkGenerator.shouldGenerateNativeStructures(true, false, false)); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java index 2a440d5d5..177fcec7a 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java @@ -58,6 +58,15 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class JigsawStudioGeneratorTest { + @Test + public void checkerboardFloorUsesTheSameFourBlockTilesAtNegativeCoordinates() { + assertTrue(JigsawStudioGenerator.isLightFloor(0, 0)); + assertTrue(JigsawStudioGenerator.isLightFloor(3, 3)); + assertFalse(JigsawStudioGenerator.isLightFloor(4, 0)); + assertFalse(JigsawStudioGenerator.isLightFloor(-1, 0)); + assertTrue(JigsawStudioGenerator.isLightFloor(-1, -1)); + } + @Test @SuppressWarnings("unchecked") public void connectorBlocksAreHiddenByDefaultAndCanBeShownPerWorkcell() { diff --git a/core/src/test/java/art/arcane/iris/util/project/interpolation/IrisInterpolationBoundsTest.java b/core/src/test/java/art/arcane/iris/util/project/interpolation/IrisInterpolationBoundsTest.java new file mode 100644 index 000000000..03ce2d73d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/interpolation/IrisInterpolationBoundsTest.java @@ -0,0 +1,121 @@ +package art.arcane.iris.util.project.interpolation; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class IrisInterpolationBoundsTest { + @Test + public void everyMethodPreservesSeparatePassValuesAndSamplesEachCoordinateOnce() { + Random random = new Random(19053); + for (InterpolationMethod method : InterpolationMethod.values()) { + for (double scale : new double[]{1D, 7D, 12D, 23D, 200D, 7.75D, 8192D}) { + for (int index = 0; index < 24; index++) { + int x = random.nextInt(); + int z = random.nextInt(); + Map expectedSamples = new HashMap<>(); + NoiseBounds expected = separatePasses(method, x, z, scale, + IrisInterpolationBoundsTest::asymmetricBounds, expectedSamples); + Map actualSamples = new HashMap<>(); + NoiseBounds actual = IrisInterpolation.getNoiseBounds(method, x, z, scale, (sx, sz) -> { + actualSamples.merge(new Coordinate(sx, sz), 1, Integer::sum); + return asymmetricBounds(sx, sz); + }); + assertBounds(expected, actual); + assertEquals(expectedSamples.keySet(), actualSamples.keySet()); + for (int calls : actualSamples.values()) { + assertEquals(1, calls); + } + } + } + } + } + + @Test + public void nestedProvidersKeepTheirOwnSamplingSequences() { + for (InterpolationMethod method : InterpolationMethod.values()) { + NoiseBoundsProvider expectedProvider = (x, z) -> separatePasses( + InterpolationMethod.HERMITE_STARCAST_12, (int) x + 103, (int) z - 79, 13.5D, + IrisInterpolationBoundsTest::asymmetricBounds, new HashMap<>()); + NoiseBoundsProvider actualProvider = (x, z) -> IrisInterpolation.getNoiseBounds( + InterpolationMethod.HERMITE_STARCAST_12, (int) x + 103, (int) z - 79, 13.5D, + IrisInterpolationBoundsTest::asymmetricBounds); + NoiseBounds expected = separatePasses(method, -93, 201, 23D, expectedProvider, new HashMap<>()); + NoiseBounds actual = IrisInterpolation.getNoiseBounds(method, -93, 201, 23D, actualProvider); + assertBounds(expected, actual); + } + } + + @Test + public void nestedScalarProvidersPreserveTheirOriginalCallbackOrder() { + for (InterpolationMethod method : InterpolationMethod.values()) { + AtomicInteger expectedCalls = new AtomicInteger(); + AtomicInteger actualCalls = new AtomicInteger(); + NoiseBoundsProvider expectedProvider = (x, z) -> scalarBounds(x, z, expectedCalls); + NoiseBoundsProvider actualProvider = (x, z) -> scalarBounds(x, z, actualCalls); + NoiseBounds expected = separatePasses(method, -93, 201, 23D, expectedProvider, new HashMap<>()); + NoiseBounds actual = IrisInterpolation.getNoiseBounds(method, -93, 201, 23D, actualProvider); + assertBounds(expected, actual); + assertEquals(expectedCalls.get(), actualCalls.get()); + } + } + + private static NoiseBounds scalarBounds(double x, double z, AtomicInteger calls) { + double value = IrisInterpolation.getNoise(InterpolationMethod.HERMITE_STARCAST_12, + (int) x + 103, (int) z - 79, 13.5D, (sx, sz) -> calls.incrementAndGet()); + return new NoiseBounds(value, value + 17D); + } + + @Test + public void failedProviderDoesNotLeavePartialSamplesForTheNextPass() { + AtomicInteger calls = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("provider failure"); + try { + IrisInterpolation.getNoiseBounds(InterpolationMethod.HERMITE_STARCAST_12, 71, -209, 7D, + (x, z) -> { + if (calls.incrementAndGet() == 5) { + throw failure; + } + return asymmetricBounds(x, z); + }); + fail("Expected provider failure"); + } catch (IllegalStateException expected) { + assertEquals(failure, expected); + } + NoiseBounds expected = separatePasses(InterpolationMethod.HERMITE_STARCAST_12, 71, -209, 7D, + IrisInterpolationBoundsTest::asymmetricBounds, new HashMap<>()); + NoiseBounds actual = IrisInterpolation.getNoiseBounds(InterpolationMethod.HERMITE_STARCAST_12, + 71, -209, 7D, IrisInterpolationBoundsTest::asymmetricBounds); + assertBounds(expected, actual); + } + + private static NoiseBounds separatePasses(InterpolationMethod method, int x, int z, double scale, + NoiseBoundsProvider provider, Map samples) { + double minimum = IrisInterpolation.getNoise(method, x, z, scale, + (sx, sz) -> samples.computeIfAbsent(new Coordinate(sx, sz), + coordinate -> provider.noise(sx, sz)).min()); + double maximum = IrisInterpolation.getNoise(method, x, z, scale, + (sx, sz) -> samples.computeIfAbsent(new Coordinate(sx, sz), + coordinate -> provider.noise(sx, sz)).max()); + return new NoiseBounds(minimum, maximum); + } + + private static NoiseBounds asymmetricBounds(double x, double z) { + double minimum = StrictMath.sin(x * 0.0031D) * 35D - StrictMath.cos(z * 0.0087D) * 7D; + return new NoiseBounds(minimum, minimum + 17D + StrictMath.cos(x * 0.0123D - z * 0.019D) * 11D); + } + + private static void assertBounds(NoiseBounds expected, NoiseBounds actual) { + assertEquals(Double.doubleToLongBits(expected.min()), Double.doubleToLongBits(actual.min())); + assertEquals(Double.doubleToLongBits(expected.max()), Double.doubleToLongBits(actual.max())); + } + + private record Coordinate(double x, double z) { + } +}