mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Improved cold generation
- Flat authoring studios skip terrain and native structure preparation - Normal Studio entry prepares the exact hydrology column asynchronously - Hydrology prefetch admits the complete frontier before stale queued work - Concurrent hydrology loads avoid cache monitor contention - Cave density tasks drain failures before releasing chunk state - Terrain and interpolation queries reuse exact bounded samples - Pregeneration counts requested chunks directly from its bounds
This commit is contained in:
+14
@@ -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<Biome> biomeCustomRegistry;
|
||||
private final Registry<Biome> biomeRegistry;
|
||||
private final AtomicCache<RegistryAccess> 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<Biome> 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) {
|
||||
|
||||
+48
-8
@@ -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<Heightmap.Types> 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<ChunkGenerator, BiomeSource> BIOME_SOURCE;
|
||||
private static final WrappedReturningMethod<Heightmap, Object> 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");
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
+25
@@ -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<Biome> 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<Biome> 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<ChunkAccess> 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")));
|
||||
|
||||
+43
@@ -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")))
|
||||
|
||||
+3
-3
@@ -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();");
|
||||
|
||||
+15
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -952,7 +952,7 @@ public final class StudioOpenCoordinator {
|
||||
}
|
||||
|
||||
public boolean prepareGeneratorState() {
|
||||
return this != JIGSAW;
|
||||
return this == STANDARD || this == FORCED_STANDARD;
|
||||
}
|
||||
|
||||
public IrisCreator.DatapackPreparation datapackPreparation() {
|
||||
|
||||
@@ -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<IrisBiome> seaBiomeStream;
|
||||
private ProceduralStream<IrisBiome> shoreBiomeStream;
|
||||
private ProceduralStream<IrisBiome> baseBiomeStream;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient boolean reuseNaturalBaseBiome;
|
||||
private ProceduralStream<UUID> baseBiomeIDStream;
|
||||
private ProceduralStream<IrisBiome> naturalTrueBiomeStream;
|
||||
private ProceduralStream<IrisBiome> 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<IrisBiome> 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.
|
||||
* <p>
|
||||
* 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<ProceduralStream<IrisBiome>> sampleSource = new WeakReference<>(null);
|
||||
private final IdentityHashMap<IrisBiome, GeneratorBounds> 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <T> List<T> invokeAll(List<Callable<T>> 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 <T> List<T> runInline(List<Callable<T>> tasks) {
|
||||
ArrayList<T> results = new ArrayList<>(tasks.size());
|
||||
for (Callable<T> task : tasks) {
|
||||
results.add(call(task));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static <T> List<T> forkJoin(List<Callable<T>> tasks) {
|
||||
ArrayList<ForkJoinTask<T>> forked = new ArrayList<>(tasks.size());
|
||||
for (Callable<T> task : tasks) {
|
||||
forked.add(ForkJoinTask.adapt(task));
|
||||
}
|
||||
ForkJoinTask.invokeAll(forked);
|
||||
ArrayList<T> results = new ArrayList<>(tasks.size());
|
||||
for (ForkJoinTask<T> task : forked) {
|
||||
results.add(task.join());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static <T> List<T> runOn(List<Callable<T>> tasks, Executor executor) {
|
||||
ArrayList<CompletableFuture<T>> futures = new ArrayList<>(tasks.size());
|
||||
for (Callable<T> task : tasks) {
|
||||
futures.add(CompletableFuture.supplyAsync(() -> call(task), executor));
|
||||
}
|
||||
ArrayList<T> results = new ArrayList<>(tasks.size());
|
||||
for (CompletableFuture<T> 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<T> results = new ArrayList<>(tasks.size());
|
||||
for (Callable<T> task : tasks) {
|
||||
results.add(call(task));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
Executor executor = callerRuns ? ((ForkJoinWorkerThread) Thread.currentThread()).getPool() : fallback;
|
||||
ArrayList<Task<T>> pending = new ArrayList<>(tasks.size());
|
||||
for (Callable<T> 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<T> 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<T> implements Runnable, ForkJoinPool.ManagedBlocker {
|
||||
private final Callable<T> work;
|
||||
private final AtomicBoolean claimed = new AtomicBoolean();
|
||||
private final CountDownLatch complete = new CountDownLatch(1);
|
||||
private T result;
|
||||
private Throwable failure;
|
||||
|
||||
Task(Callable<T> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HydrologyTileKey, SourceRoutingContext> routingContexts;
|
||||
private final ConcurrentHashMap<HydrologyTileKey, RoutingContextLoad> resolvingRoutingContexts;
|
||||
private final Cache<HydrologyTileKey, CrossTileResolvedOwner> resolvedOwners;
|
||||
private final Cache<RefinedEdgeKey, List<HydrologyPoint>> refinedEdgeCache;
|
||||
private final ConcurrentHashMap<HydrologyTileKey, CompletableFuture<CrossTileResolvedOwner>> 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<HydrologyTileKey, HydrologyForkJoin.Task<CrossTileResolvedOwner>> preparedOwners
|
||||
) {
|
||||
ArrayList<HydrologyCrossTileCaveAdmission.RankedClaim> blockers = new ArrayList<>();
|
||||
ArrayList<HydrologyCrossTileSurfaceAdmission.RankedClaim> surfaceBlockers = new ArrayList<>();
|
||||
@@ -822,7 +840,7 @@ public final class HydrologyPlanner {
|
||||
candidateKeys.add(candidateKey);
|
||||
}
|
||||
long dependenciesStarted = System.nanoTime();
|
||||
List<CrossTileResolvedOwner> resolvedCandidates = resolveLowerRankOwners(candidateKeys, context);
|
||||
List<CrossTileResolvedOwner> 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<CrossTileResolvedOwner> resolveLowerRankOwners(
|
||||
List<HydrologyTileKey> candidateKeys,
|
||||
CrossTileResolutionContext context
|
||||
CrossTileResolutionContext context,
|
||||
Map<HydrologyTileKey, HydrologyForkJoin.Task<CrossTileResolvedOwner>> preparedOwners
|
||||
) {
|
||||
if (candidateKeys.size() < 2 || !IrisPlatforms.isBound()) {
|
||||
ArrayList<CrossTileResolvedOwner> resolved = new ArrayList<>(candidateKeys.size());
|
||||
for (HydrologyTileKey candidateKey : candidateKeys) {
|
||||
resolved.add(resolveCrossTileOwner(candidateKey, context));
|
||||
HydrologyForkJoin.Task<CrossTileResolvedOwner> 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<Callable<CrossTileResolvedOwner>> tasks = new ArrayList<>(candidateKeys.size());
|
||||
for (HydrologyTileKey candidateKey : candidateKeys) {
|
||||
tasks.add(() -> resolveIndependentOwner(candidateKey));
|
||||
HydrologyForkJoin.Task<CrossTileResolvedOwner> prepared = preparedOwners.get(candidateKey);
|
||||
tasks.add(prepared == null ? () -> resolveIndependentOwner(candidateKey) : prepared::await);
|
||||
}
|
||||
List<CrossTileResolvedOwner> owners = HydrologyForkJoin.invokeAll(tasks, MultiBurst.burst);
|
||||
ArrayList<CrossTileResolvedOwner> 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<HydrologyPoint> 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<RouteCandidate> 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<HydrologyPoint> 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<HydrologyPoint> points;
|
||||
if (samples == null) {
|
||||
profile.routeSolves++;
|
||||
long solveStarted = System.nanoTime();
|
||||
points = surfaceCourseCenterline(grid, surfaceGuide, geometryId);
|
||||
profile.routeSolveNanos += System.nanoTime() - solveStarted;
|
||||
} else {
|
||||
HashMap<SurfaceRouteKey, List<HydrologyPoint>> 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<HydrologyPoint> 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<Integer, List<GridOffset>> radialOffsets;
|
||||
private final HashMap<UndergroundSegmentCapKey, Integer> undergroundSegmentCaps;
|
||||
private final HashMap<Long, HydrologyPoint> routeAnchors;
|
||||
private final IdentityHashMap<SampledGrid, HashMap<SurfaceRouteKey, List<HydrologyPoint>>> 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<HydrologyPoint> guide, long geometryId) {
|
||||
}
|
||||
|
||||
private record RouteCandidate(
|
||||
HydrologyPoint point,
|
||||
double offset,
|
||||
@@ -8307,6 +8413,9 @@ public final class HydrologyPlanner {
|
||||
) {
|
||||
}
|
||||
|
||||
private record RoutingContextLoad(Thread owner, CompletableFuture<SourceRoutingContext> 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<Long> rejectedCourseIds;
|
||||
private final Map<HydrologyTileKey, HydrologyForkJoin.Task<CrossTileResolvedOwner>> preparedOwners = new LinkedHashMap<>();
|
||||
private final Set<HydrologyTileKey> demandedOwners = new HashSet<>();
|
||||
private Throwable primaryFailure;
|
||||
private final LinkedHashMap<CrossTileRejectionKey, CrossTileRejectedCourse> 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<HydrologyTileKey> 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<CrossTileResolvedOwner> 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<HydrologyTileKey, RuntimeException> unusedFailures = new LinkedHashMap<>();
|
||||
for (Map.Entry<HydrologyTileKey, HydrologyForkJoin.Task<CrossTileResolvedOwner>> 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<HydrologyTileKey, RuntimeException> 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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HydrologyTileKey> prefetchQueue;
|
||||
private final Set<HydrologyTileKey> queuedPrefetches;
|
||||
private final Set<CacheLoadKey<HydrologyTileKey>> demandedPlans;
|
||||
private final AtomicBoolean prefetchActive;
|
||||
private final ConcurrentHashMap<HydrologyTileKey, CompletableFuture<HydrologyTile>> planning;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, CompletableFuture<HydrologyTile>> planning;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, PendingLoad<HydrologyTile>> loading;
|
||||
private final ConcurrentHashMap<CacheLoadKey<Long>, PendingLoad<ChunkColumns>> 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<HydrologyTileKey> loadKey = new CacheLoadKey<>(epoch, key);
|
||||
PendingLoad<HydrologyTile> owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>());
|
||||
PendingLoad<HydrologyTile> 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<Long> loadKey, int chunkX, int chunkZ) {
|
||||
long epoch = loadKey.epoch();
|
||||
long packedChunk = loadKey.key();
|
||||
ChunkColumns present = composedChunks.getIfPresent(packedChunk);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
PendingLoad<ChunkColumns> owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>());
|
||||
PendingLoad<ChunkColumns> 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<HydrologyTileKey> 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<HydrologyTileKey> 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<HydrologyTileKey> keys, boolean discardOutsideArea) {
|
||||
Set<HydrologyTileKey> 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<HydrologyTileKey> 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<HydrologyTile> tiles(List<HydrologyTileKey> 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<CompletableFuture<HydrologyTile>> 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<CompletableFuture<HydrologyTile>> 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<HydrologyTile> planAsync(HydrologyTileKey key) {
|
||||
private CompletableFuture<HydrologyTile> planAsync(CacheLoadKey<HydrologyTileKey> loadKey) {
|
||||
HydrologyTileKey key = loadKey.key();
|
||||
HydrologyTile present = tiles.getIfPresent(key);
|
||||
if (present != null) {
|
||||
return CompletableFuture.completedFuture(present);
|
||||
}
|
||||
CompletableFuture<HydrologyTile> future = new CompletableFuture<>();
|
||||
CompletableFuture<HydrologyTile> existing = planning.putIfAbsent(key, future);
|
||||
CompletableFuture<HydrologyTile> 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<HydrologyTile> planDemandAsync(HydrologyTileKey key) {
|
||||
queuedPrefetches.remove(key);
|
||||
return planAsync(key);
|
||||
HydrologyTile present = tiles.getIfPresent(key);
|
||||
if (present != null) {
|
||||
return CompletableFuture.completedFuture(present);
|
||||
}
|
||||
CacheLoadKey<HydrologyTileKey> loadKey = new CacheLoadKey<>(cacheEpoch.get(), key);
|
||||
boolean tracked = beginDemand(loadKey);
|
||||
CompletableFuture<HydrologyTile> future = planAsync(loadKey);
|
||||
if (tracked) {
|
||||
future.whenComplete((tile, failure) -> finishDemand(loadKey));
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
|
||||
private HydrologyTile planDemandInline(HydrologyTileKey key) {
|
||||
HydrologyTile present = tiles.getIfPresent(key);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
CacheLoadKey<HydrologyTileKey> 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<HydrologyTileKey> loadKey) {
|
||||
synchronized (prefetchQueue) {
|
||||
if (loadKey.epoch() != cacheEpoch.get()) {
|
||||
return false;
|
||||
}
|
||||
queuedPrefetches.remove(loadKey.key());
|
||||
return demandedPlans.add(loadKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishDemand(CacheLoadKey<HydrologyTileKey> loadKey) {
|
||||
synchronized (prefetchQueue) {
|
||||
demandedPlans.remove(loadKey);
|
||||
}
|
||||
pumpPrefetch();
|
||||
}
|
||||
|
||||
private static <T> T awaitLoad(PendingLoad<T> load) {
|
||||
if (load.owner() == Thread.currentThread()) {
|
||||
throw new IllegalStateException("Recursive hydrology cache load.");
|
||||
}
|
||||
return awaitPlan(load.future());
|
||||
}
|
||||
|
||||
private static <T> T awaitPlan(CompletableFuture<T> 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<K>(long epoch, K key) {
|
||||
}
|
||||
|
||||
private record PendingLoad<T>(Thread owner, CompletableFuture<T> future) {
|
||||
}
|
||||
|
||||
public record SharedCacheScope(
|
||||
String runtimeIdentity,
|
||||
long worldSeed,
|
||||
|
||||
+348
-18
@@ -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<Boolean> carving = ThreadLocal.withInitial(() -> false);
|
||||
private final ThreadLocal<CaveCarveScratch> 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<Runnable> 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<Runnable> 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<Runnable> work, ForkJoinPool pool) {
|
||||
ArrayList<DensityTask> 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<Matter> 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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-3
@@ -62,6 +62,12 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
|
||||
private final ConcurrentMap<RenderKey, RenderedBay> renderedBays = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, PlatformBlockState> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+10
-2
@@ -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();
|
||||
}
|
||||
|
||||
+31
-36
@@ -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.
|
||||
* <p>
|
||||
* 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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ public class StudioOpenCoordinatorOpenKindTest {
|
||||
|
||||
assertTrue(kind.openWorkspace());
|
||||
assertFalse(kind.teleportThroughStandardEntry());
|
||||
assertTrue(kind.prepareGeneratorState());
|
||||
assertFalse(kind.prepareGeneratorState());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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<IrisBiome> 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<IrisBiome> 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<IrisInterpolator, IdentityHashMap<IrisBiome, Object>> bounds = new HashMap<>();
|
||||
private final Object cache;
|
||||
private final Method sample;
|
||||
private final Constructor<?> boundsConstructor;
|
||||
|
||||
private Fixture(ProceduralStream<IrisBiome> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IrisBiome> counted(IrisBiome biome, AtomicInteger calls) {
|
||||
return ProceduralStream.of((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return biome;
|
||||
}, Interpolated.of(value -> 0D, value -> biome));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IrisBiome, IrisComplex.ChildSelectionPlan> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Callable<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> 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<Callable<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> scheduler = scheduler(queued);
|
||||
MockedStatic<IrisLogging> 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<J> 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<Callable<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> scheduler = scheduler(queued);
|
||||
MockedStatic<IrisLogging> 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<Callable<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> scheduler = scheduler(queued);
|
||||
MockedStatic<IrisLogging> 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<FutureTask<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
|
||||
FutureTask<Void> future = new FutureTask<>(invocation.<Callable<Void>>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<Callable<Void>> queued = new AtomicReference<>();
|
||||
try (MockedStatic<J> scheduler = scheduler(queued);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
engine.startStudioEntryHydrology(0, 0);
|
||||
Future<Void> 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<J> scheduler(AtomicReference<Callable<Void>> queued) {
|
||||
MockedStatic<J> scheduler = mockStatic(J.class);
|
||||
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
|
||||
queued.set(invocation.getArgument(0));
|
||||
return new CompletableFuture<Void>();
|
||||
});
|
||||
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<HydrologyTileKey> 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;
|
||||
}
|
||||
}
|
||||
+95
@@ -67,6 +67,51 @@ public class HydrologyFootprintCompilerTest {
|
||||
return maximum;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void slopeFreeBasisPreservesCompleteSurfaceAndCaveFootprints() {
|
||||
List<HydrologyFeatureType> 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;
|
||||
|
||||
@@ -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<List<Integer>> 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<String> callbackThread = new AtomicReference<>();
|
||||
RejectedExecutionException expected = new RejectedExecutionException("closed executor");
|
||||
try {
|
||||
Future<RejectedExecutionException> 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<Integer> 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<Thread> waiter = new AtomicReference<>();
|
||||
HydrologyForkJoin.Task<Integer> task = new HydrologyForkJoin.Task<>(() -> {
|
||||
started.countDown();
|
||||
await(release);
|
||||
return 1;
|
||||
});
|
||||
try {
|
||||
pool.execute(task);
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
Future<Boolean> 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);
|
||||
|
||||
@@ -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<Object> 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<Object> first = callers.submit(() -> routingContext(planner, new HydrologyTileKey(0, 0)));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
Future<Object> 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<Object> first = callers.submit(() -> routingContext(planner, TILE));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
Future<Object> 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<HydrologyPlanner> 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<HydrologyRoutingTerrainSampler.GridRequest> 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<GridRequest> beforeGrid;
|
||||
|
||||
private ContextRoutingSampler(HydrologyTerrainSample sample, Consumer<GridRequest> 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<HydrologyTileKey, HydrologyForkJoin.Task<?>> tasks = earlyTasks(admission);
|
||||
Map<HydrologyTileKey, CompletableFuture<?>> 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<Runnable> submitted = new ArrayList<>();
|
||||
|
||||
private CapturingHydrologyPool() {
|
||||
super(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable task) {
|
||||
submitted.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<HydrologyTileKey, CompletableFuture<?>> runningOwners(HydrologyPlanner planner) throws Exception {
|
||||
Field field = HydrologyPlanner.class.getDeclaredField("resolvingOwners");
|
||||
field.setAccessible(true);
|
||||
return (Map<HydrologyTileKey, CompletableFuture<?>>) field.get(planner);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void earlyNeighborOwnersPreserveCompleteColdOwnerOutputs() throws Exception {
|
||||
HydrologyTileKey key = new HydrologyTileKey(-1, -1);
|
||||
Set<Thread> 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<Future<HydrologyTile>> 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<HydrologyTile> 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<Long, HydrologyColumnSample> 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<HydrologyTileKey, HydrologyForkJoin.Task<?>> 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<HydrologyTileKey, HydrologyForkJoin.Task<?>> 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<HydrologyTileKey, HydrologyForkJoin.Task<?>> earlyTasks(AutoCloseable admission) throws Exception {
|
||||
Field prepared = admission.getClass().getDeclaredField("preparedOwners");
|
||||
prepared.setAccessible(true);
|
||||
return (Map<HydrologyTileKey, HydrologyForkJoin.Task<?>>) 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<Object> local = (ThreadLocal<Object>) scope.get(planner);
|
||||
local.set(samples);
|
||||
try {
|
||||
return planner.plan(TILE);
|
||||
} finally {
|
||||
local.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SurfaceRouteMemos extends IdentityHashMap<Object, HashMap<Object, List<HydrologyPoint>>> {
|
||||
private final boolean retain;
|
||||
private int hits;
|
||||
private int computations;
|
||||
|
||||
private SurfaceRouteMemos(boolean retain) {
|
||||
this.retain = retain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HashMap<Object, List<HydrologyPoint>> computeIfAbsent(Object key,
|
||||
Function<? super Object, ? extends HashMap<Object, List<HydrologyPoint>>> ignored) {
|
||||
return super.computeIfAbsent(key, unused -> new HashMap<>() {
|
||||
@Override
|
||||
public List<HydrologyPoint> computeIfAbsent(Object route,
|
||||
Function<? super Object, ? extends List<HydrologyPoint>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int[]> 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<int[]> 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<List<Object>> layers = new ArrayList<>();
|
||||
for (int layer = 0; layer < fixture.points().length; layer++) {
|
||||
ArrayList<Object> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HydrologyTileKey> planned = new ArrayList<>();
|
||||
AtomicReference<HydrologyTileCache> cacheReference = new AtomicReference<>();
|
||||
AtomicReference<Object> 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<HydrologyTileKey> 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<Runnable> 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<HydrologyTile> 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<HydrologyTileKey> 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<HydrologyTileKey> planned = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
planned.add(invocation.getArgument(0));
|
||||
return tile;
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
LinkedBlockingQueue<Runnable> 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<HydrologyTileKey> 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<HydrologyTileKey> planned = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
planned.add(invocation.getArgument(0));
|
||||
return tile;
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
LinkedBlockingQueue<Runnable> 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<HydrologyTileKey> prefetchRectangle(int minX, int maxX, int minZ, int maxZ) {
|
||||
HashSet<HydrologyTileKey> 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<Runnable> 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<HydrologyTileKey> 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<HydrologyTileKey> 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<List<HydrologyTile>> 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<Callable<Optional<HydrologyColumnSample>>> calls = new ArrayList<>();
|
||||
for (int index = 0; index < 32; index++) {
|
||||
calls.add(() -> cache.columnAt(0, 0));
|
||||
}
|
||||
for (Future<Optional<HydrologyColumnSample>> 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<HydrologyTile> 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<Runnable> 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<Optional<HydrologyColumnSample>> 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<Runnable> 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<HydrologyTileKey> keys = List.of(
|
||||
new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0));
|
||||
Future<List<HydrologyTile>> 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<HydrologyTileKey> order = new ArrayList<>();
|
||||
AtomicReference<HydrologyTileCache> 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<HydrologyTileKey> 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<Runnable> 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<HydrologyTile> 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<Runnable> 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<HydrologyTile> 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+211
@@ -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<Thread> parentThread = new AtomicReference<>();
|
||||
AtomicInteger completed = new AtomicInteger();
|
||||
Future<Boolean> 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<Thread> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+115
-3
@@ -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));
|
||||
|
||||
+9
@@ -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() {
|
||||
|
||||
+121
@@ -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<Coordinate, NoiseBounds> expectedSamples = new HashMap<>();
|
||||
NoiseBounds expected = separatePasses(method, x, z, scale,
|
||||
IrisInterpolationBoundsTest::asymmetricBounds, expectedSamples);
|
||||
Map<Coordinate, Integer> 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<Coordinate, NoiseBounds> 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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user