diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java index 40b8d8c4a..535db2eb4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java @@ -103,6 +103,21 @@ public class NativeStructureVolumeIndexTest { assertEquals(coldResolutions, resolver.resolutions()); } + @Test + public void completedOriginsRemainBoundedAndPendingBuildsAreReleased() throws Exception { + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting( + (engine, chunkX, chunkZ) -> NativeStructureVolume.NONE); + for (int chunkX = -8_192; chunkX <= 8_192; chunkX++) { + index.originVolumes(null, chunkX, -17); + } + assertEquals(16_384, cache(index, "originCache").size()); + assertTrue(cache(index, "originBuilds").isEmpty()); + index.resolve(null, -16, -16, -1, -1); + assertEquals(16_384, cache(index, "originCache").size()); + assertTrue(cache(index, "originBuilds").isEmpty()); + assertTrue(cache(index, "queryBuilds").isEmpty()); + } + @Test public void runtimeBucketsStayIsolatedAndRetirementEvictsOnlyTheirEntries() { AtomicInteger runtimeId = new AtomicInteger(1); @@ -282,7 +297,7 @@ public class NativeStructureVolumeIndexTest { } @Test - public void overlappingColdQueriesCoordinateBeforeResolvingOrigins() throws Exception { + public void overlappingColdQueriesResolveIndependentOriginsConcurrently() throws Exception { BlockingResolver resolver = new BlockingResolver(); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); ExecutorService executor = Executors.newFixedThreadPool(2); @@ -291,7 +306,8 @@ public class NativeStructureVolumeIndexTest { assertTrue(resolver.awaitFirstEntry()); Future> adjacent = executor.submit(() -> index.resolve(null, 16, 0, 31, 15)); - assertFalse(resolver.awaitSecondEntry(250)); + assertTrue(resolver.awaitSecondEntry(5_000)); + assertTrue(adjacent.get(5, TimeUnit.SECONDS).isEmpty()); resolver.release(); assertTrue(first.get(5, TimeUnit.SECONDS).isEmpty()); assertTrue(adjacent.get(5, TimeUnit.SECONDS).isEmpty()); @@ -303,12 +319,42 @@ public class NativeStructureVolumeIndexTest { } @Test - public void contendedNativeWindowsAllowQueuedTerrainWorkToRun() throws Exception { + public void overlappingNegativeQueriesPreserveEveryVolumeAndOriginOrder() throws Exception { + BlockingResolver coordination = new BlockingResolver(); + NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> + new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, + -256, 0, -256, 256, 255, 256)); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { + coordination.volumesAt(engine, chunkX, chunkZ); + return volumes.volumesAt(engine, chunkX, chunkZ); + }); + NativeStructureVolumeIndex sequential = NativeStructureVolumeIndex.forTesting(volumes); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> negative = executor.submit( + () -> index.resolve(null, -16, 0, -1, 15)); + assertTrue(coordination.awaitFirstEntry()); + Future> positive = executor.submit( + () -> index.resolve(null, 0, 0, 15, 15)); + assertTrue(coordination.awaitSecondEntry(5_000)); + assertEquals(sequential.resolve(null, 0, 0, 15, 15), positive.get(5, TimeUnit.SECONDS)); + coordination.release(); + assertEquals(sequential.resolve(null, -16, 0, -1, 15), negative.get(5, TimeUnit.SECONDS)); + assertEquals(306, coordination.resolutions()); + } finally { + coordination.release(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void sharedOriginWaitAllowsQueuedTerrainWorkToRun() throws Exception { assertQueuedTerrainCanComplete(false); } @Test - public void interruptedNativeWindowWaitStillAcquiresAndReleasesOnce() throws Exception { + public void interruptedOriginWaitPreservesInterruptAndResult() throws Exception { assertQueuedTerrainCanComplete(true); } @@ -331,7 +377,7 @@ public class NativeStructureVolumeIndexTest { AtomicReference secondWaiter = new AtomicReference<>(); try { Future> original = owner.submit( - () -> index.resolve(null, 0, 0, 15, 15)); + () -> index.originVolumes(null, -6, -8)); assertTrue(ownerEntered.await(5, TimeUnit.SECONDS)); Future> adjacent = workers.submit(() -> { firstWaiter.set(Thread.currentThread()); @@ -343,15 +389,15 @@ public class NativeStructureVolumeIndexTest { secondWaiter.set(Thread.currentThread()); return index.resolve(null, 32, 0, 47, 15); }); - awaitOriginWindowWaiter(firstWaiter); - awaitOriginWindowWaiter(secondWaiter); + awaitOriginWaiter(firstWaiter); + awaitOriginWaiter(secondWaiter); if (interruptWaiter) { firstWaiter.get().interrupt(); } Future terrainTask = workers.submit(() -> terrain.complete(null)); terrainTask.get(3, TimeUnit.SECONDS); - assertEquals(289, original.get(3, TimeUnit.SECONDS).size()); + assertEquals(1, original.get(3, TimeUnit.SECONDS).size()); KList adjacentVolumes = adjacent.get(3, TimeUnit.SECONDS); assertEquals(289, adjacentVolumes.size()); assertEquals(adjacentVolumes, index.resolve(null, 16, 0, 31, 15)); @@ -366,28 +412,28 @@ public class NativeStructureVolumeIndexTest { } } - private static void awaitOriginWindowWaiter(AtomicReference reference) { + private static void awaitOriginWaiter(AtomicReference reference) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); while (System.nanoTime() < deadline) { Thread thread = reference.get(); if (thread != null && thread.getState() == Thread.State.WAITING) { for (StackTraceElement frame : thread.getStackTrace()) { - if (frame.getClassName().equals(NativeStructureVolumeIndex.class.getName()) - && frame.getMethodName().equals("lockOriginWindow")) { + if (frame.getClassName().equals("art.arcane.volmlib.nativelib.terrain.NativeBuildFutures") + && frame.getMethodName().equals("awaitBuild")) { return; } } } LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } - throw new AssertionError("Native query did not wait for the occupied origin window"); + throw new AssertionError("Native query did not wait for the shared origin"); } @Test public void distantColdQueriesCanResolveOriginsConcurrently() throws Exception { BlockingResolver resolver = new BlockingResolver(); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); - int distantChunkX = findDisjointWindow(0, 0); + int distantChunkX = 64; ExecutorService executor = Executors.newFixedThreadPool(2); try { Future> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15)); @@ -478,16 +524,6 @@ public class NativeStructureVolumeIndexTest { ); } - private static int findDisjointWindow(int chunkX, int chunkZ) { - long sourceMask = NativeStructureVolumeIndex.originWindowStripeMask(chunkX, chunkZ); - for (int candidate = 64; candidate < 16_384; candidate += 17) { - if ((sourceMask & NativeStructureVolumeIndex.originWindowStripeMask(candidate, chunkZ)) == 0L) { - return candidate; - } - } - throw new IllegalStateException("Unable to find a disjoint native structure window stripe"); - } - private static final class CountingResolver implements NativeStructureVolumeIndex.OriginResolver { private final AtomicInteger resolutions = new AtomicInteger(); private final int originChunkX; diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkGenerator.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkGenerator.java new file mode 100644 index 000000000..e675a4968 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkGenerator.java @@ -0,0 +1,111 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.platform.bukkit.nms.BukkitBiomePolicy; +import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeBiomeRegistryImpl; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeBiomeSourceImpl; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +final class HeadlessChunkGenerator extends ChunkGenerator { + private final NativeTerrainColumns columns; + private final int minimumY; + private final int height; + private final int seaLevel; + + HeadlessChunkGenerator(Options options) { + super(biomes(options)); + Engine engine = options.engine(); + columns = new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)); + minimumY = engine.getMinHeight(); + height = engine.getHeight(); + seaLevel = minimumY + engine.getDimension().getFluidHeight(); + if (engine instanceof IrisEngine irisEngine) { + irisEngine.addGenerationRuntimeRetirementListener(columns::evictRuntime); + } + } + + @Override + protected MapCodec codec() { + return MapCodec.unit(this); + } + + @Override + public int getGenDepth() { + return height; + } + + @Override + public int getSeaLevel() { + return seaLevel; + } + + @Override + public int getMinY() { + return minimumY; + } + + @Override + public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor accessor, RandomState random) { + return columns.baseHeight(x, z, type, accessor); + } + + @Override + public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor accessor, RandomState random) { + return columns.baseColumn(x, z, accessor); + } + + @Override + public CompletableFuture buildTerrain(ChunkAccess chunk, Blender blender, RandomState random, + StructureManager structures, BiomeManager biomes, WorldGenRegion region, Set> possibleBiomes) { + throw new UnsupportedOperationException("Headless terrain must be generated through the Iris region pipeline"); + } + + @Override + public void spawnOriginalMobs(WorldGenRegion region) { + throw new UnsupportedOperationException("Headless native population is not installed"); + } + + @Override + public void addDebugScreenInfo(List result, RandomState random, BlockPos position, SamplerContext context) { + result.add("Iris native structure terrain"); + } + + private static NativeBiomeSourceImpl biomes(Options options) { + NativeBiomeRegistryImpl registry = new NativeBiomeRegistryImpl(options.registries().lookupOrThrow(Registries.BIOME)); + BukkitBiomePolicy, Biome> policy = new BukkitBiomePolicy<>( + new BukkitBiomePolicy.RuntimeOptions<>(options.engine().getSeedManager().getSeed(), options.engine(), + null, () -> registry), registry); + return new NativeBiomeSourceImpl(policy); + } + + record Options(Engine engine, RegistryAccess registries) { + Options { + Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(registries, "registries"); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkSerialization.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkSerialization.java new file mode 100644 index 000000000..38478f882 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessChunkSerialization.java @@ -0,0 +1,126 @@ +package art.arcane.iris.probe; + +import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk; +import ca.spottedleaf.moonrise.patches.starlight.light.SWMRNibbleArray; +import ca.spottedleaf.moonrise.patches.starlight.storage.StarlightSectionData; +import it.unimi.dsi.fastutil.longs.LongSet; +import it.unimi.dsi.fastutil.shorts.ShortArrayList; +import it.unimi.dsi.fastutil.shorts.ShortList; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkType; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class HeadlessChunkSerialization { + private HeadlessChunkSerialization() { + } + + static SerializableChunkData copyOf(ProtoChunk chunk, Context context) { + if (!chunk.canBeSerialized() || chunk.getPersistedStatus().getChunkType() != ChunkType.PROTOCHUNK) { + throw new IllegalArgumentException("Headless serialization requires a serializable proto chunk"); + } + List blockEntities = new ArrayList<>(chunk.getBlockEntitiesPos().size()); + for (BlockPos position : chunk.getBlockEntitiesPos()) { + CompoundTag tag = chunk.getBlockEntityNbtForSaving(position, context.structures().registryAccess()); + if (tag != null) { + blockEntities.add(tag.copy()); + } + } + List entities = new ArrayList<>(chunk.getEntities().size()); + for (CompoundTag entity : chunk.getEntities()) { + entities.add(entity.copy()); + } + Map heightmaps = new EnumMap<>(Heightmap.Types.class); + for (Map.Entry entry : chunk.getHeightmaps()) { + heightmaps.put(entry.getKey(), entry.getValue().getRawData().clone()); + } + ShortList[] postProcessing = new ShortList[chunk.getPostProcessing().length]; + for (int i = 0; i < postProcessing.length; i++) { + ShortList source = chunk.getPostProcessing()[i]; + if (source != null && !source.isEmpty()) { + postProcessing[i] = new ShortArrayList(source); + } + } + CompoundTag persistent = chunk.persistentDataContainer.isEmpty() + ? null : chunk.persistentDataContainer.toTagCompound().copy(); + return new SerializableChunkData(context.containers(), chunk.getPos(), chunk.getMinSectionY(), + context.gameTime(), chunk.getInhabitedTime(), chunk.getPersistedStatus(), + chunk.getBlendingData() == null ? null : chunk.getBlendingData().pack(), + chunk.getBelowZeroRetrogen(), chunk.getUpgradeData().copy(), heightmaps, + chunk.getTicksForSerialization(context.gameTime()), postProcessing, chunk.isLightCorrect(), + sections(chunk), entities, blockEntities, structures(chunk, context.structures()), persistent); + } + + private static List sections(ProtoChunk chunk) { + LevelChunkSection[] blocks = chunk.getSections(); + StarlightChunk light = (StarlightChunk) chunk; + SWMRNibbleArray[] blockLight = light.starlight$getBlockNibbles(); + SWMRNibbleArray[] skyLight = light.starlight$getSkyNibbles(); + List sections = new ArrayList<>(blocks.length + 2); + for (int i = 0; i < blocks.length + 2; i++) { + LevelChunkSection blocksCopy = i > 0 && i <= blocks.length ? blocks[i - 1].copy() : null; + SWMRNibbleArray.SaveState blockState = blockLight[i].getSaveState(); + SWMRNibbleArray.SaveState skyState = skyLight[i].getSaveState(); + if (blocksCopy == null && blockState == null && skyState == null) { + continue; + } + SerializableChunkData.SectionData section = new SerializableChunkData.SectionData( + chunk.getMinSectionY() + i - 1, blocksCopy, layer(blockState), layer(skyState)); + if (blockState != null) { + ((StarlightSectionData) (Object) section).starlight$setBlockLightState(blockState.state); + } + if (skyState != null) { + ((StarlightSectionData) (Object) section).starlight$setSkyLightState(skyState.state); + } + sections.add(section); + } + return sections; + } + + private static DataLayer layer(SWMRNibbleArray.SaveState state) { + return state == null || state.data == null ? null : new DataLayer(state.data); + } + + private static CompoundTag structures(ProtoChunk chunk, StructurePieceSerializationContext context) { + Registry registry = context.registryAccess().lookupOrThrow(Registries.STRUCTURE); + CompoundTag starts = new CompoundTag(); + for (Map.Entry entry : chunk.getAllStarts().entrySet()) { + starts.put(Objects.requireNonNull(registry.getKey(entry.getKey()), "structure key").toString(), + entry.getValue().createTag(context, chunk.getPos())); + } + CompoundTag references = new CompoundTag(); + for (Map.Entry entry : chunk.getAllReferences().entrySet()) { + if (!entry.getValue().isEmpty()) { + references.putLongArray(Objects.requireNonNull(registry.getKey(entry.getKey()), "structure key").toString(), + entry.getValue().toLongArray()); + } + } + CompoundTag result = new CompoundTag(); + result.put("starts", starts); + result.put("References", references); + return result; + } + + record Context(StructurePieceSerializationContext structures, PalettedContainerFactory containers, long gameTime) { + Context { + Objects.requireNonNull(structures, "structures"); + Objects.requireNonNull(containers, "containers"); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBiomeWriter.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBiomeWriter.java new file mode 100644 index 000000000..caffd10d2 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBiomeWriter.java @@ -0,0 +1,33 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; + +import java.util.List; +import java.util.function.Supplier; + +record HeadlessNativeBiomeWriter(Supplier registries) implements PlatformBiomeWriter { + @Override + public int biomeIdFor(String key) { + Registry registry = registry(); + Biome biome = registry.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(key))).value(); + return registry.getId(biome); + } + + @Override + public List allBiomes() { + return registry().listElements().map(holder -> (NativeBiome) ModdedBiome.of( + holder.value(), holder.key().identifier().toString())).toList(); + } + + private Registry registry() { + return (Registry) registries.get().lookupOrThrow(Registries.BIOME); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBootstrap.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBootstrap.java new file mode 100644 index 000000000..39cd2dca5 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeBootstrap.java @@ -0,0 +1,47 @@ +package art.arcane.iris.probe; + +import net.minecraft.SharedConstants; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.component.DataComponentInitializers; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.tags.TagLoader; + +import java.util.List; + +final class HeadlessNativeBootstrap { + private static boolean initialized; + + private HeadlessNativeBootstrap() { + } + + static synchronized void bindComponents(HolderLookup.Provider registries) { + List> pending = + BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(registries); + for (DataComponentInitializers.PendingComponents components : pending) { + components.apply(); + } + } + + static synchronized void initialize() { + if (initialized) { + return; + } + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + try (MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource().fullResources()))) { + List> pending = TagLoader.loadTagsForExistingRegistries(resources, + RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY)); + for (Registry.PendingTags tags : pending) { + tags.apply(); + } + } + initialized = true; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java new file mode 100644 index 000000000..90bb44db9 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java @@ -0,0 +1,273 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.bukkit.nms.BukkitGenerationRegistry; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.spi.LogLevel; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.volmlib.nativelib.entity.NativeEntityType; +import art.arcane.volmlib.nativelib.item.NativeItem; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, NativeBlockResolver.Policy { + private final StubPlatform services; + private final NativeRegistryAccess nativeAccess; + private final PlatformBiomeWriter biomeWriter; + private final PlatformGenerationRegistry generationRegistry; + private final NativeBlockResolver blocks = new NativeBlockResolver(this); + private final ConcurrentHashMap states = new ConcurrentHashMap<>(); + + HeadlessNativePlatform(File root, Supplier registries) { + services = new StubPlatform(root); + biomeWriter = new HeadlessNativeBiomeWriter(registries); + generationRegistry = new BukkitGenerationRegistry(new NativeGenerationRegistryImpl(() -> { + HolderLookup.Provider lookup = registries.get(); + if (lookup instanceof RegistryAccess access) { + return access; + } + throw new IllegalStateException("Generation registry contract requires loaded native registries"); + }), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity()); + nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries, + () -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); }, + message -> { throw new IllegalStateException("Unavailable native registry: " + message); })); + } + + NativeBlockResolver blocks() { + return blocks; + } + + @Override + public PlatformRegistries registries() { + return this; + } + + @Override + public PlatformGenerationRegistry generationRegistry() { + return generationRegistry; + } + + @Override + public String platformName() { + return "headless-native-core"; + } + + @Override + public String minecraftVersion() { + return SharedConstants.getCurrentVersion().name(); + } + + @Override + public NativeBlockState decodeBlockState(String key) { + return states.computeIfAbsent(key, NativeBlockResolver::strictParse); + } + + @Override + public NativeBlockState block(String key) { + return states.computeIfAbsent(key, candidate -> Objects.requireNonNull(blocks.getOrNull(candidate, true), + "Unresolved native block: " + candidate)); + } + + @Override + public NativeBlockState blockOrNull(String key) { + return blocks.getOrNull(key); + } + + @Override + public NativeBlockState blockOrNull(String key, boolean warn) { + return blocks.getOrNull(key, warn); + } + + @Override + public NativeBlockState air() { + return blocks.getAir(); + } + + @Override + public ModdedBlockState resolveCustomBlock(String key) { + return null; + } + + @Override + public boolean preventLeafDecay() { + return IrisSettings.get().getGenerator().isPreventLeafDecay(); + } + + @Override + public void reportError(String key, Throwable failure) { + services.reportError(failure); + failure.printStackTrace(System.err); + } + + @Override + public void warnUnresolved(String key, String message) { + throw new IllegalArgumentException(message + ": " + key); + } + + @Override + public void debug(String message) { + services.log(LogLevel.DEBUG, message); + } + + @Override + public NativeBlockState deepSlateOre(NativeBlockState block, NativeBlockState ore) { + return nativeAccess.deepSlateOre(block, ore); + } + + @Override + public NativeBiome biome(String key) { + return nativeAccess.biome(key); + } + + @Override + public NativeItem item(String key) { + return nativeAccess.item(key); + } + + @Override + public NativeEntityType entity(String key) { + return nativeAccess.entity(key); + } + + @Override + public List blockKeys() { + return nativeAccess.blockKeys(); + } + + @Override + public List biomeKeys() { + return nativeAccess.biomeKeys(); + } + + @Override + public List structureKeys() { + return nativeAccess.structureKeys(); + } + + @Override + public List itemKeys() { + return nativeAccess.itemKeys(); + } + + @Override + public List entityKeys() { + return nativeAccess.entityKeys(); + } + + @Override + public List enchantmentKeys() { + return nativeAccess.enchantmentKeys(); + } + + @Override + public List potionEffectKeys() { + return nativeAccess.potionEffectKeys(); + } + + @Override + public List lootTableKeys() { + return nativeAccess.lootTableKeys(); + } + + @Override + public Map> blockStateProperties() { + return nativeAccess.blockStateProperties(); + } + + @Override + public List blockTypeKeys() { + return blockKeys(); + } + + @Override + public PlatformScheduler scheduler() { + return services.scheduler(); + } + + @Override + public PlatformStructureHooks structureHooks() { + return services.structureHooks(); + } + + @Override + public PlatformBiomeWriter biomeWriter() { + return biomeWriter; + } + + @Override + public File dataFolder() { + return services.dataFolder(); + } + + @Override + public File dataFile(String... path) { + return services.dataFile(path); + } + + @Override + public File pluginJar() { + return services.pluginJar(); + } + + @Override + public int irisVersionNumber() { + return services.irisVersionNumber(); + } + + @Override + public int minecraftVersionNumber() { + return Integer.parseInt(minecraftVersion().replace(".", "")); + } + + @Override + public void callEvent(Object event) { + services.callEvent(event); + } + + @Override + public void dispatchConsoleCommand(String command) { + services.dispatchConsoleCommand(command); + } + + @Override + public boolean spawnEntity(NativeWorld world, String entityKey, double x, double y, double z) { + return services.spawnEntity(world, entityKey, x, y, z); + } + + @Override + public void log(LogLevel level, String message) { + services.log(level, message); + } + + @Override + public void msg(String message) { + services.msg(message); + } + + @Override + public void reportError(Throwable error) { + services.reportError(error); + } + +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java new file mode 100644 index 000000000..13f3c3cd3 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java @@ -0,0 +1,116 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.IrisDatapackCompiler; +import art.arcane.iris.pack.datapack.v263.DataFixerV263; +import art.arcane.volmlib.util.collection.KList; + +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.packs.PackLocationInfo; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.PathPackResources; +import net.minecraft.server.packs.repository.PackSource; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; + +import java.io.IOException; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +final class HeadlessNativeRegistries implements AutoCloseable { + private final Path directory; + private final MultiPackResourceManager resources; + private final RegistryAccess.Frozen registries; + + private HeadlessNativeRegistries(Path directory, MultiPackResourceManager resources, RegistryAccess.Frozen registries) { + this.directory = directory; + this.resources = resources; + this.registries = registries; + } + + static HeadlessNativeRegistries load(Map definitions) throws IOException { + HeadlessNativeBootstrap.initialize(); + Path directory = Files.createTempDirectory("iris-native-registry-"); + try { + for (Map.Entry definition : definitions.entrySet()) { + Path file = directory.resolve(definition.getKey()).normalize(); + if (!file.startsWith(directory.resolve("data")) || !file.toString().endsWith(".json")) { + throw new IllegalArgumentException("Invalid generated registry resource " + definition.getKey()); + } + Files.createDirectories(file.getParent()); + Files.writeString(file, definition.getValue()); + } + return load(directory); + } catch (IOException | RuntimeException failure) { + delete(directory); + throw failure; + } + } + + static HeadlessNativeRegistries compile(File pack) throws IOException { + HeadlessNativeBootstrap.initialize(); + Path directory = Files.createTempDirectory("iris-native-pack-"); + try { + IrisDatapackCompiler.compile(List.of(pack), new KList<>(directory.toFile()), List.of(), + new DataFixerV263(), false); + return load(directory); + } catch (IOException | RuntimeException failure) { + delete(directory); + throw failure; + } + } + + private static HeadlessNativeRegistries load(Path directory) { + PackLocationInfo location = new PackLocationInfo("iris-headless", Component.literal("Iris"), + PackSource.WORLD, Optional.empty()); + MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of( + ServerPacksSource.createVanillaPackSource().fullResources(), new PathPackResources(location, directory))); + try { + RegistryAccess.Frozen builtins = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + RegistryAccess.Frozen loaded; + try (ExecutorService executor = Executors.newFixedThreadPool(4)) { + loaded = RegistryDataLoader.load(resources, builtins.listRegistries().toList(), + RegistryDataLoader.WORLD_REGISTRIES, executor).join(); + } + RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess( + Stream.concat(builtins.registries(), loaded.registries())).freeze(); + HeadlessNativeBootstrap.bindComponents(registries); + return new HeadlessNativeRegistries(directory, resources, registries); + } catch (RuntimeException failure) { + resources.close(); + throw failure; + } + } + + RegistryAccess.Frozen registries() { + return registries; + } + + MultiPackResourceManager resources() { + return resources; + } + + @Override + public void close() throws IOException { + resources.close(); + delete(directory); + } + + private static void delete(Path directory) throws IOException { + try (Stream paths = Files.walk(directory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java new file mode 100644 index 000000000..dcc06dd79 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java @@ -0,0 +1,93 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.block.BlockDataMergeSupport; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.DecoratorPlatformHooks; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.modded.ModdedDecoratorHooks; +import art.arcane.iris.modded.ModdedStateRotator; +import art.arcane.iris.modded.ModdedTileData; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader; +import art.arcane.iris.pack.datapack.v263.DataFixerV263; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateMerger; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.data.registries.VanillaRegistries; +import net.minecraft.server.packs.resources.ResourceManager; + +final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBindings { + private NativeRegionTerrainWriter writer; + private HeadlessNativeRegistries nativeRegistries; + private HolderLookup.Provider registries; + + @Override + public IrisPlatform create(File root) { + HeadlessNativeBootstrap.initialize(); + registries = VanillaRegistries.createWorldLookup(); + HeadlessNativePlatform platform = new HeadlessNativePlatform(root, () -> registries); + ModdedDecoratorHooks decorators = new ModdedDecoratorHooks(); + DecoratorPlatformHooks.bind(decorators, decorators); + IrisObjectRotation.bindPlatformRotator(new ModdedStateRotator()); + BlockDataMergeSupport.bindPlatformMerger(new NativeStateMerger(platform.blocks())::merge); + NativeTileReader tiles = new NativeTileReader(() -> registries); + TileData.bindPlatformReader(input -> ModdedTileData.wrap(tiles.read(input))); + TileData.bindPlatformFactory(ModdedTileData::fromProperties); + return platform; + } + + @Override + public void prepare(IrisData data, IrisDimension dimension) { + try { + if (nativeRegistries != null) { + throw new IllegalStateException("Headless native runtime supports one immutable pack per process"); + } + nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder()); + registries = nativeRegistries.registries(); + writer = new NativeRegionTerrainWriter(nativeRegistries.registries()); + } catch (IOException failure) { + throw new UncheckedIOException("Cannot load native generation registries", failure); + } + } + + @Override + public KList nativeStructureVolumes(Engine engine, int minX, int minZ, int maxX, int maxZ) { + return NativeStructureVolumeIndex.volumes(engine, minX, minZ, maxX, maxZ); + } + + @Override + public GenerationHistory createHistory(RealPackProbeSupport.HistoryRequest request) throws IOException { + return HeadlessGenerationHistorySession.create(request, new DataFixerV263()); + } + + @Override + public void close() throws IOException { + if (nativeRegistries != null) { + nativeRegistries.close(); + nativeRegistries = null; + } + } + + RegistryAccess.Frozen registries() { + return nativeRegistries.registries(); + } + + ResourceManager resources() { + return nativeRegistries.resources(); + } + + NativeRegionTerrainWriter writer() { + return writer; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessRegionTerrain.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessRegionTerrain.java new file mode 100644 index 000000000..a6d07d1f9 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessRegionTerrain.java @@ -0,0 +1,210 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.NativeTerrainReceipt; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourcePolicy; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainPipeline; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.WorldgenTerrainHeightmaps; +import net.minecraft.core.Holder; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.Heightmap; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +final class HeadlessRegionTerrain { + private static final NamespacedKey STRUCTURE_ACTIVATION = new NamespacedKey("iris", "structure_activation"); + private static final NamespacedKey NATURAL_TERRAIN = new NamespacedKey("iris", "natural_terrain"); + + private HeadlessRegionTerrain() { + } + + static final class Session { + private final Map> chunks = new HashMap<>(); + private final RegionGenerationWindow.Policy workerPolicy; + private HeadlessTerrainContext context; + + Session(RegionGenerationWindow.Policy workerPolicy) { + this.workerPolicy = Objects.requireNonNull(workerPolicy, "workerPolicy"); + } + + Result generate(Request request, Consumer progress) throws Exception { + if (context != null && context != request.context()) { + throw new IllegalArgumentException("Rolling regions require one native terrain context"); + } + context = request.context(); + List plan = NativeRegionPlan.plan( + request.chunkX(), request.chunkZ(), request.width()); + Set retained = new HashSet<>(plan.size()); + for (NativeRegionPlan.PlannedChunk planned : plan) { + retained.add(ChunkPos.pack(planned.x(), planned.z())); + } + chunks.keySet().retainAll(retained); + Map previous = new HashMap<>(chunks.size()); + for (Map.Entry> entry : chunks.entrySet()) { + previous.put(entry.getKey(), entry.getValue().chunk().getPersistedStatus()); + } + for (ChunkStatus status : List.of(ChunkStatus.STRUCTURE_STARTS, ChunkStatus.STRUCTURE_REFERENCES, + ChunkStatus.BIOMES, ChunkStatus.TERRAIN)) { + List eligible = new ArrayList<>(); + for (NativeRegionPlan.PlannedChunk planned : plan) { + HeadlessStructurePlanner.PlannedChunk existing = + chunks.get(ChunkPos.pack(planned.x(), planned.z())); + if (planned.status().isOrAfter(status) + && (status != ChunkStatus.BIOMES || planned.status() == ChunkStatus.BIOMES) + && (existing == null || existing.chunk().getPersistedStatus().isBefore(status))) { + eligible.add(planned); + } + } + if (eligible.isEmpty()) { + continue; + } + long started = System.nanoTime(); + AtomicInteger completed = new AtomicInteger(); + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(eligible.size(), request.parallelism(), + index -> apply(request.context(), eligible.get(index), status, chunks), + (index, result) -> { + if (status == ChunkStatus.STRUCTURE_STARTS) { + chunks.put(result.chunk().getPos().pack(), result); + } + int count = completed.incrementAndGet(); + if (count % 128 == 0 || count == eligible.size()) { + progress.accept("stage=" + status.getName() + " chunks=" + count + "/" + eligible.size() + + " seconds=" + (System.nanoTime() - started) / 1_000_000_000.0); + } + }), workerPolicy); + failReported(); + } + List ordered = new ArrayList<>(plan.size()); + List updated = new ArrayList<>(); + for (NativeRegionPlan.PlannedChunk planned : plan) { + ProtoChunk chunk = chunks.get(ChunkPos.pack(planned.x(), planned.z())).chunk(); + if (chunk.getPersistedStatus().isBefore(planned.status())) { + throw new IllegalStateException("Incomplete native stage at " + chunk.getPos()); + } + ordered.add(chunk); + ChunkStatus prior = previous.get(chunk.getPos().pack()); + if (prior == null || chunk.getPersistedStatus().isAfter(prior)) { + updated.add(chunk); + } + } + return new Result(List.copyOf(ordered), List.copyOf(updated), request.width() * request.width()); + } + } + + private static HeadlessStructurePlanner.PlannedChunk apply( + HeadlessTerrainContext context, NativeRegionPlan.PlannedChunk planned, ChunkStatus status, + Map> chunks) throws Exception { + Engine engine = context.engine(); + GenerationHistoryRuntimeRouter router = ((IrisEngine) engine).getGenerationHistoryRuntimeRouter().orElseThrow(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(planned.x(), planned.z()); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope scope = route.openRuntimeScope(); + GenerationSessionLease lease = engine.acquireGenerationLease("headless_native_terrain"); + IrisContext.Scope generation = IrisContext.open(engine, lease.sessionId(), null)) { + if (status == ChunkStatus.STRUCTURE_STARTS) { + HeadlessStructurePlanner.PlannedChunk result = HeadlessStructurePlanner.plan( + context.structures(), context.policies(), new ChunkPos(planned.x(), planned.z())); + result.chunk().persistentDataContainer.set(STRUCTURE_ACTIVATION, + PersistentDataType.LONG, route.activation().activationId()); + return result; + } + HeadlessStructurePlanner.PlannedChunk result = Objects.requireNonNull( + chunks.get(ChunkPos.pack(planned.x(), planned.z())), "planned structure chunk"); + if (status == ChunkStatus.STRUCTURE_REFERENCES) { + HeadlessStructurePlanner.references(context.policies(), result, chunks); + } else if (status == ChunkStatus.BIOMES) { + biomes(context, result.chunk()); + } else if (status == ChunkStatus.TERRAIN) { + if (result.chunk().getPersistedStatus().isBefore(ChunkStatus.BIOMES)) { + biomes(context, result.chunk()); + } + terrain(context, result.chunk(), route); + } else { + throw new IllegalArgumentException("Unsupported headless native stage " + status); + } + return result; + } + } + + private static void biomes(HeadlessTerrainContext context, ProtoChunk chunk) { + context.biomes().prepareVisibleBiomeBatch(); + NativeBiomeSourcePolicy.VisibleResolver> resolver = context.biomes().visibleResolver(); + chunk.fillBiomesFromNoise(resolver::biome); + chunk.setPersistedStatus(ChunkStatus.BIOMES); + } + + private static void terrain(HeadlessTerrainContext context, ProtoChunk target, + GenerationHistoryRuntimeRouter.RuntimeRoute route) throws Exception { + Engine engine = context.engine(); + ChunkPos position = target.getPos(); + Hunk blocks = Hunk.newArrayHunk(16, engine.getHeight(), 16); + Hunk biomes = Hunk.newArrayHunk(16, engine.getHeight(), 16); + engine.generate(position.x() << 4, position.z() << 4, blocks, biomes, true); + context.writer().fill(target, new NativeRegionTerrainWriter.TerrainInput(position.x(), position.z(), + engine.getMinHeight(), engine.getHeight(), + (x, y, z) -> blockState(blocks.getRaw(x, y, z)), + (x, y, z) -> Objects.requireNonNull(biomes.getRaw(x, y, z), "Generated native biome").key(), List.of())); + NativeTerrainPipeline.claimGeneratedSemantics(route, target, engine.getMinHeight()); + target.persistentDataContainer.set(NATURAL_TERRAIN, PersistentDataType.BYTE_ARRAY, + NativeTerrainReceipt.encode(route.naturalTerrain().orElseThrow(), + route.activation().activationId(), route.epoch().epochId())); + WorldgenTerrainHeightmaps.primeTerrain(target, + (x, z) -> engine.getHeight(x, z, false) + engine.getMinHeight() + 1, + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight() + 1); + Heightmap.primeHeightmaps(target, EnumSet.of(Heightmap.Types.MOTION_BLOCKING, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, Heightmap.Types.OCEAN_FLOOR, Heightmap.Types.WORLD_SURFACE)); + target.setPersistedStatus(ChunkStatus.TERRAIN); + target.markUnsaved(); + } + + private static BlockState blockState(NativeBlockState state) { + return state == null ? Blocks.AIR.defaultBlockState() : ((ModdedBlockState) state).handle(); + } + + private static void failReported() { + List reports = RealPackProbeSupport.drainReported(); + if (!reports.isEmpty()) { + IllegalStateException failure = new IllegalStateException("Native terrain generation reported errors"); + for (Throwable report : reports) { + failure.addSuppressed(report); + } + throw failure; + } + } + + record Request(HeadlessTerrainContext context, int chunkX, int chunkZ, int width, int parallelism) { + Request { + Objects.requireNonNull(context, "context"); + if (parallelism < 1 || parallelism > 32) { + throw new IllegalArgumentException("Headless native parallelism must be 1..32"); + } + } + } + + record Result(List chunks, List updated, int targetChunks) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructureConfiguration.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructureConfiguration.java new file mode 100644 index 000000000..4d6b0cb46 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructureConfiguration.java @@ -0,0 +1,104 @@ +package art.arcane.iris.probe; + +import net.kyori.adventure.key.Key; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.spigotmc.SpigotConfig; +import org.spigotmc.SpigotWorldConfig; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + +final class HeadlessStructureConfiguration { + private static final int CONFIG_VERSION = 13; + private static final Field CONFIG_FILE = nativeField("CONFIG_FILE"); + private static final Field VERSION = nativeField("version"); + + private HeadlessStructureConfiguration() { + } + + static SpigotWorldConfig load(Options options) throws IOException { + if (Bukkit.getServer() != null) { + throw new IllegalStateException("Headless structure configuration cannot initialize inside a Bukkit server"); + } + Files.createDirectories(options.stagingDirectory()); + Path staged = Files.createTempFile(options.stagingDirectory(), "spigot-structures-", ".yml"); + try { + Files.copy(options.source(), staged, StandardCopyOption.REPLACE_EXISTING); + YamlConfiguration configuration = new YamlConfiguration(); + configuration.load(staged.toFile()); + configuration.options().copyDefaults(true); + int version = configuration.getInt("config-version", CONFIG_VERSION); + String worldName = options.levelKey().identifier().toString(); + prepareEnvironment(configuration, options.legacyWorldName(), worldName, version); + configuration.set("config-version", CONFIG_VERSION); + return initialize(options, staged, configuration); + } catch (InvalidConfigurationException | ReflectiveOperationException | RuntimeException failure) { + throw new IOException("Could not initialize native structure configuration from " + options.source(), failure); + } finally { + Files.deleteIfExists(staged); + } + } + + private static void prepareEnvironment(YamlConfiguration configuration, String legacyWorldName, + String worldName, int version) { + if (version <= 12) { + ConfigurationSection legacy = configuration.getConfigurationSection("world-settings." + legacyWorldName); + if (legacy != null) { + configuration.set("world-settings." + legacyWorldName, null); + configuration.set("world-settings." + worldName, legacy); + } + } + String worldPath = "world-settings." + worldName + "."; + configuration.set(worldPath + "verbose", false); + configuration.set(worldPath + "view-distance", 3); + configuration.set(worldPath + "simulation-distance", 3); + } + + private static SpigotWorldConfig initialize(Options options, Path staged, YamlConfiguration configuration) + throws ReflectiveOperationException { + synchronized (SpigotConfig.class) { + YamlConfiguration previousConfiguration = SpigotConfig.config; + File previousFile = (File) CONFIG_FILE.get(null); + int previousVersion = VERSION.getInt(null); + try { + SpigotConfig.config = configuration; + CONFIG_FILE.set(null, staged.toFile()); + VERSION.setInt(null, CONFIG_VERSION); + return new SpigotWorldConfig(options.legacyWorldName(), Key.key(options.levelKey().identifier().toString())); + } finally { + SpigotConfig.config = previousConfiguration; + CONFIG_FILE.set(null, previousFile); + VERSION.setInt(null, previousVersion); + } + } + } + + private static Field nativeField(String name) { + try { + Field field = SpigotConfig.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + + record Options(Path source, String legacyWorldName, ResourceKey levelKey, Path stagingDirectory) { + Options { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(legacyWorldName, "legacyWorldName"); + Objects.requireNonNull(levelKey, "levelKey"); + Objects.requireNonNull(stagingDirectory, "stagingDirectory"); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructurePlanner.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructurePlanner.java new file mode 100644 index 000000000..3acd47f86 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessStructurePlanner.java @@ -0,0 +1,333 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; +import art.arcane.volmlib.nativelib.terrain.structure.StructureInjectionPolicy; +import art.arcane.volmlib.nativelib.terrain.structure.StructureOwnershipRecordView; +import art.arcane.volmlib.nativelib.terrain.structure.StructurePlacementDecision; +import art.arcane.volmlib.nativelib.terrain.structure.StructureReferencePolicy; +import art.arcane.volmlib.nativelib.terrain.structure.StructureStartPlan; +import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureFactory; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureOwnershipFingerprint; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureReferenceEnvelope; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureReferenceRepair; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureVegetationClearer; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureVerticalPlacer; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Climate; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +final class HeadlessStructurePlanner { + private HeadlessStructurePlanner() { + } + + static

> PlannedChunk plan( + NativeContext context, Policies policies, ChunkPos position) { + ProtoChunk chunk = new ProtoChunk(position, UpgradeData.EMPTY, + LevelHeightAccessor.create(context.generator().getMinY(), context.generator().getGenDepth()), + PalettedContainerFactory.create(context.registries()), null); + if (!context.enabled()) { + chunk.setPersistedStatus(ChunkStatus.STRUCTURE_STARTS); + return new PlannedChunk(context, chunk, Map.of(), Map.of()); + } + try (NativeGenerationScope scope = policies.references().openOriginScope(chunk.getPos().x(), chunk.getPos().z())) { + generateNaturalStarts(context, chunk); + Map ownership = inject(context, policies, chunk); + Map terrain = adjust(context, policies, chunk, ownership); + chunk.setPersistedStatus(ChunkStatus.STRUCTURE_STARTS); + return new PlannedChunk<>(context, chunk, Map.copyOf(ownership), Map.copyOf(terrain)); + } + } + + static

> void references( + Policies policies, PlannedChunk target, Map> grid) { + ChunkPos position = target.chunk().getPos(); + List> origins = new ArrayList<>(289); + if (target.chunk().getPersistedStatus() != ChunkStatus.STRUCTURE_STARTS) { + throw new IllegalArgumentException("Structure references require an unreferenced planned chunk: " + position); + } + if (!target.context().adapterEnabled()) { + target.chunk().setPersistedStatus(ChunkStatus.STRUCTURE_REFERENCES); + return; + } + for (int x = position.x() - 8; x <= position.x() + 8; x++) { + for (int z = position.z() - 8; z <= position.z() + 8; z++) { + PlannedChunk origin = grid.get(ChunkPos.pack(x, z)); + if (origin == null || origin.context() != target.context() + || origin.chunk().getPos().x() != x || origin.chunk().getPos().z() != z + || !origin.chunk().getPersistedStatus().isOrAfter(ChunkStatus.STRUCTURE_STARTS)) { + throw new IllegalArgumentException("Missing matching structure origin at " + x + "," + z); + } + origins.add(origin); + } + } + Map references = new HashMap<>(); + Registry registry = target.context().registries().lookupOrThrow(Registries.STRUCTURE); + for (PlannedChunk origin : origins) { + ChunkPos source = origin.chunk().getPos(); + try (NativeGenerationScope scope = policies.references().openOriginScope(source.x(), source.z())) { + for (Map.Entry entry : origin.chunk().getAllStarts().entrySet()) { + StructureStart start = entry.getValue(); + if (!start.isValid()) { + continue; + } + String key = Objects.requireNonNull(registry.getKey(entry.getKey()), "structure key").toString(); + if (!NativeStructureReferenceEnvelope.contentFitsReferenceRange(start)) { + throw new IllegalStateException("Planned structure exceeds its reference range: " + key); + } + O ownership = origin.ownership().get(entry.getKey()); + boolean relevant = ownership == null + ? NativeStructureReferenceRepair.requiresNaturalReference(policies.references(), position, + key, entry.getKey(), start) + : NativeStructureReferenceRepair.requiresReference(position, key, start, ownership); + if (relevant) { + references.computeIfAbsent(entry.getKey(), ignored -> new LongOpenHashSet()).add(source.pack()); + } + } + } + } + target.chunk().setAllReferences(references); + target.chunk().setPersistedStatus(ChunkStatus.STRUCTURE_REFERENCES); + } + + private static

> Map inject( + NativeContext context, Policies policies, ProtoChunk chunk) { + Registry registry = context.registries().lookupOrThrow(Registries.STRUCTURE); + Map ownership = new LinkedHashMap<>(); + for (P plan : policies.injection().plansAt(chunk.getPos().x(), chunk.getPos().z())) { + Holder.Reference holder = registry.getOrThrow(ResourceKey.create(Registries.STRUCTURE, + Identifier.parse(plan.structureKey()))); + Structure structure = holder.value(); + if (ownership.containsKey(structure)) { + policies.injection().duplicate(plan.structureKey()); + continue; + } + StructureStart existing = chunk.getStartForStructure(structure); + boolean replace = plan.replacesSource() || existing != null && existing.isValid() + && policies.injection().sourceReplaced(plan.structureKey(), + NativeStructureVegetationClearer.isUndergroundStep(structure.step())); + if (!replace && existing != null && existing.isValid()) { + continue; + } + int references = existing != null && existing.isValid() ? existing.getReferences() : 0; + StructureStart generated = NativeStructureFactory.generate(new NativeStructureFactory.GenerationContext( + context.registries(), context.generator(), context.generator().getBiomeSource(), + context.structureState().randomState(), context.templates(), context.structureState().getLevelSeed(), + context.levelKey(), chunk, biome -> true, context.generator().getSeaLevel(), + policies.injection()::surfaceHeight), holder, plan, references); + if (!generated.isValid()) { + if (replace) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + } + policies.injection().discard(plan.structureKey(), chunk.getPos().x(), chunk.getPos().z()); + continue; + } + BoundingBox envelope = NativeStructureReferenceEnvelope.referenceBounds(generated, structure, + plan.terrain(), plan.structureKey()); + policies.injection().record(plan, NativeStructureOwnershipFingerprint.capture(plan.structureKey(), generated, envelope)); + O recorded; + try { + recorded = policies.references().findPersisted(plan.structureKey(), chunk.getPos().x(), chunk.getPos().z()); + if (!NativeStructureOwnershipFingerprint.matches(recorded, generated)) { + throw new IllegalStateException("Configured structure ownership was not retained: " + plan.structureKey()); + } + chunk.setStartForStructure(structure, generated); + } catch (RuntimeException | Error failure) { + try { + policies.injection().discard(plan.structureKey(), chunk.getPos().x(), chunk.getPos().z()); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + ownership.put(structure, recorded); + } + return ownership; + } + + private static

> Map adjust( + NativeContext context, Policies policies, ProtoChunk chunk, Map ownership) { + Registry registry = context.registries().lookupOrThrow(Registries.STRUCTURE); + Map terrain = new HashMap<>(); + for (Map.Entry entry : chunk.getAllStarts().entrySet()) { + Structure structure = entry.getKey(); + StructureStart start = entry.getValue(); + if (!start.isValid()) { + continue; + } + String key = Objects.requireNonNull(registry.getKey(structure), "structure key").toString(); + if (!policies.allowsFootprint().test(start.getBoundingBox())) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + if (ownership.remove(structure) != null) { + policies.references().discard(key, chunk.getPos().x(), chunk.getPos().z()); + } + continue; + } + O recorded = ownership.get(structure); + if (recorded != null) { + terrain.put(structure, NativeStructureTerrainIntegrator.resolveNativeTerrain(start, recorded.terrain())); + continue; + } + boolean underground = NativeStructureVegetationClearer.isUndergroundStep(structure.step()); + StructurePlacementDecision decision = policies.references().resolve(key, underground); + if (!decision.generate()) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + continue; + } + NativeStructureVerticalPlacer.applyVerticalPlacement(start, key, decision.yShift(), + context.generator().getSeaLevel(), chunk.getMinY(), chunk.getMinY() + chunk.getHeight(), + underground, decision.preserveSourceY(), decision.yBand(), policies.injection()::surfaceHeight); + StructureTerrainSettings resolved = NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication(start, structure, + start.getReferences(), resolved, key); + chunk.setStartForStructure(structure, wrapped); + if (wrapped.isValid()) { + terrain.put(structure, resolved); + } + } + return terrain; + } + + static void generateNaturalStarts(NativeContext context, ChunkAccess chunk) { + if (!context.enabled() || SharedConstants.DEBUG_DISABLE_STRUCTURES) { + return; + } + RandomState random = context.structureState().randomState(); + Climate.Sampler climate = random.createClimateSampler(SamplerContext.builder().enableCaches().build()); + for (Holder holder : context.structureState().possibleStructureSets()) { + generateSet(context, chunk, holder.value(), climate); + } + } + + private static void generateSet(NativeContext context, ChunkAccess chunk, StructureSet set, Climate.Sampler climate) { + List entries = set.structures(); + for (StructureSet.StructureSelectionEntry entry : entries) { + StructureStart existing = chunk.getStartForStructure(entry.structure().value()); + if (existing != null && existing.isValid()) { + return; + } + } + StructurePlacement placement = set.placement(); + ChunkPos position = chunk.getPos(); + ResourceKey key = placement instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyed + ? keyed.key : null; + if (!placement.isStructureChunk(context.structureState(), position.x(), position.z(), key)) { + return; + } + if (entries.size() == 1) { + generate(context, chunk, entries.getFirst(), climate); + return; + } + ArrayList remaining = new ArrayList<>(entries); + WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L)); + random.setLargeFeatureSeed(context.structureState().getLevelSeed(), position.x(), position.z()); + int weight = 0; + for (StructureSet.StructureSelectionEntry entry : remaining) { + weight += entry.weight(); + } + while (!remaining.isEmpty()) { + int selection = random.nextInt(weight); + int index = 0; + for (StructureSet.StructureSelectionEntry entry : remaining) { + selection -= entry.weight(); + if (selection < 0) { + break; + } + index++; + } + StructureSet.StructureSelectionEntry selected = remaining.get(index); + if (generate(context, chunk, selected, climate)) { + return; + } + remaining.remove(index); + weight -= selected.weight(); + } + } + + private static boolean generate(NativeContext context, ChunkAccess chunk, + StructureSet.StructureSelectionEntry entry, Climate.Sampler climate) { + Structure structure = entry.structure().value(); + StructureStart existing = chunk.getStartForStructure(structure); + int references = existing == null ? 0 : existing.getReferences(); + StructureStart generated = structure.generate(entry.structure(), context.levelKey(), context.registries(), + context.generator(), context.generator().getBiomeSource(), climate, context.structureState().randomState(), + context.templates(), context.structureState().getLevelSeed(), chunk.getPos(), references, chunk, + structure.biomes()::contains); + if (!generated.isValid()) { + return false; + } + chunk.setStartForStructure(structure, generated); + return true; + } + + record NativeContext(RegistryAccess registries, ChunkGenerator generator, + ChunkGeneratorStructureState structureState, StructureTemplateManager templates, + ResourceKey levelKey, WorldOptions worldOptions, boolean adapterEnabled) { + NativeContext { + Objects.requireNonNull(registries, "registries"); + Objects.requireNonNull(generator, "generator"); + Objects.requireNonNull(structureState, "structureState"); + Objects.requireNonNull(templates, "templates"); + Objects.requireNonNull(levelKey, "levelKey"); + Objects.requireNonNull(worldOptions, "worldOptions"); + if (worldOptions.seed() != structureState.getLevelSeed()) { + throw new IllegalArgumentException("Structure state seed differs from world options"); + } + } + + boolean enabled() { + return worldOptions.generateStructures() && adapterEnabled; + } + } + + record Policies

>( + StructureInjectionPolicy

injection, StructureReferencePolicy references, + Predicate allowsFootprint) { + Policies { + Objects.requireNonNull(injection, "injection"); + Objects.requireNonNull(references, "references"); + Objects.requireNonNull(allowsFootprint, "allowsFootprint"); + } + } + + record PlannedChunk>(NativeContext context, ProtoChunk chunk, + Map ownership, Map terrain) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessTerrainContext.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessTerrainContext.java new file mode 100644 index 000000000..19c39b75a --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessTerrainContext.java @@ -0,0 +1,159 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.platform.bukkit.nms.BukkitBiomePolicy; +import art.arcane.iris.structure.nativegen.IrisStructurePolicy; +import art.arcane.iris.structure.nativegen.IrisStructureVolumePolicy; +import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord; +import art.arcane.iris.structure.nativegen.NativeStructureStartPlan; +import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeBiomeRegistryImpl; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureVolumeSource; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.spigotmc.SpigotWorldConfig; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; + +final class HeadlessTerrainContext implements AutoCloseable { + private final Engine engine; + private final LevelStorageSource.LevelStorageAccess storage; + private final HeadlessStructurePlanner.NativeContext structures; + private final HeadlessStructurePlanner.Policies policies; + private final BukkitBiomePolicy, Biome> biomes; + private final HeadlessChunkSerialization.Context serialization; + private final NativeRegionTerrainWriter writer; + + private HeadlessTerrainContext(Options options, LevelStorageSource.LevelStorageAccess storage) { + engine = options.engine(); + this.storage = storage; + HeadlessNativeRuntime runtime = options.runtime(); + writer = runtime.writer(); + HeadlessChunkGenerator generator = new HeadlessChunkGenerator(new HeadlessChunkGenerator.Options( + engine, runtime.registries())); + long seed = engine.getSeedManager().getSeed(); + NoiseGeneratorSettings settings = runtime.registries().lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(options.noiseSettings()).value(); + RandomState random = RandomState.create(runtime.registries().lookupOrThrow(Registries.NOISE), seed, settings); + ChunkGeneratorStructureState state = generator.createState( + runtime.registries().lookupOrThrow(Registries.STRUCTURE_SET), random, seed, options.configuration()); + initializeStructureState(state); + StructureTemplateManager templates = new StructureTemplateManager(runtime.resources(), storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + structures = new HeadlessStructurePlanner.NativeContext(runtime.registries(), generator, state, + templates, options.levelKey(), new WorldOptions(seed, true, false), true); + IrisStructurePolicy policy = new IrisStructurePolicy(engine); + policies = new HeadlessStructurePlanner.Policies<>(policy, policy, + bounds -> engine.getComplex().allowsNewGenerationFootprint( + bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ())); + NativeBiomeRegistryImpl registry = new NativeBiomeRegistryImpl(runtime.registries().lookupOrThrow(Registries.BIOME)); + biomes = new BukkitBiomePolicy<>(new BukkitBiomePolicy.RuntimeOptions<>(seed, engine, null, () -> registry), registry); + serialization = new HeadlessChunkSerialization.Context(new StructurePieceSerializationContext( + runtime.resources(), runtime.registries(), templates), PalettedContainerFactory.create(runtime.registries()), 0L); + NativeStructureVolumeSource volumes = new NativeStructureVolumeSource<>( + new NativeStructureVolumeSource.Context(runtime.registries(), templates, options.levelKey(), + LevelHeightAccessor.create(engine.getMinHeight(), engine.getHeight()), + () -> generator, generator::getBiomeSource, () -> state), IrisStructureVolumePolicy.INSTANCE); + NativeStructureVolumeIndex.install(engine, volumes::volumesAt); + } + + static HeadlessTerrainContext create(Options options) throws IOException { + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(options.templateStorage()) + .createAccess("native-templates"); + try { + return new HeadlessTerrainContext(options, storage); + } catch (RuntimeException | Error failure) { + try { + storage.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + private static void initializeStructureState(ChunkGeneratorStructureState state) { + state.ensureStructuresGenerated(); + Throwable failure = null; + for (Holder structureSet : state.possibleStructureSets()) { + if (!(structureSet.value().placement() instanceof ConcentricRingsStructurePlacement rings)) { + continue; + } + try { + state.getRingPositionsFor(rings); + } catch (RuntimeException | Error problem) { + if (failure == null) { + failure = problem; + } else { + failure.addSuppressed(problem); + } + } + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + } + + @Override + public void close() throws IOException { + NativeStructureVolumeIndex.uninstall(engine); + storage.close(); + } + + Engine engine() { + return engine; + } + + HeadlessStructurePlanner.NativeContext structures() { + return structures; + } + + HeadlessStructurePlanner.Policies policies() { + return policies; + } + + BukkitBiomePolicy, Biome> biomes() { + return biomes; + } + + HeadlessChunkSerialization.Context serialization() { + return serialization; + } + + NativeRegionTerrainWriter writer() { + return writer; + } + + record Options(Engine engine, HeadlessNativeRuntime runtime, Path templateStorage, ResourceKey levelKey, + ResourceKey noiseSettings, SpigotWorldConfig configuration) { + Options { + Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(templateStorage, "templateStorage"); + Objects.requireNonNull(levelKey, "levelKey"); + Objects.requireNonNull(noiseSettings, "noiseSettings"); + Objects.requireNonNull(configuration, "configuration"); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionPlan.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionPlan.java new file mode 100644 index 000000000..b8d3e3e64 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionPlan.java @@ -0,0 +1,56 @@ +package art.arcane.iris.probe; + +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.status.ChunkPyramid; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.status.ChunkStep; + +import java.util.ArrayList; +import java.util.List; + +final class NativeRegionPlan { + private NativeRegionPlan() { + } + + static List plan(int chunkX, int chunkZ, int width) { + if (width < 1 || width > 32) { + throw new IllegalArgumentException("Native terrain tile width must be 1..32 chunks"); + } + ChunkStep target = ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FULL); + int halo = target.accumulatedDependencies().size() - 1; + int maximumX = Math.addExact(chunkX, width - 1); + int maximumZ = Math.addExact(chunkZ, width - 1); + if (!new ChunkPos(Math.subtractExact(chunkX, halo), Math.subtractExact(chunkZ, halo)).isValid() + || !new ChunkPos(Math.addExact(maximumX, halo), Math.addExact(maximumZ, halo)).isValid()) { + throw new IllegalArgumentException("Native terrain tile exceeds supported chunk bounds"); + } + List result = new ArrayList<>((width + halo * 2) * (width + halo * 2)); + for (int z = Math.subtractExact(chunkZ, halo); z <= Math.addExact(maximumZ, halo); z++) { + for (int x = Math.subtractExact(chunkX, halo); x <= Math.addExact(maximumX, halo); x++) { + int distance = Math.max(distance(x, chunkX, maximumX), distance(z, chunkZ, maximumZ)); + ChunkStatus required = distance == 0 ? ChunkStatus.FULL : target.accumulatedDependencies().get(distance); + ChunkStatus status = required.isAfter(ChunkStatus.TERRAIN) ? ChunkStatus.TERRAIN : required; + result.add(new PlannedChunk(x, z, status, distance == 0)); + } + } + return List.copyOf(result); + } + + static int terrainHalo() { + ChunkStep target = ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FULL); + int radius = 0; + for (int distance = 1; distance < target.accumulatedDependencies().size(); distance++) { + if (target.accumulatedDependencies().get(distance).isOrAfter(ChunkStatus.TERRAIN)) { + radius = distance; + } + } + return radius; + } + + private static int distance(int value, int minimum, int maximum) { + return Math.max(0, Math.max(minimum - value, value - maximum)); + } + + record PlannedChunk(int x, int z, ChunkStatus status, boolean target) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionTerrainWriter.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionTerrainWriter.java new file mode 100644 index 000000000..6382c9de5 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/NativeRegionTerrainWriter.java @@ -0,0 +1,223 @@ +package art.arcane.iris.probe; + +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import it.unimi.dsi.fastutil.shorts.ShortList; +import net.minecraft.commands.arguments.blocks.BlockStateParser; +import net.minecraft.core.Holder; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +final class NativeRegionTerrainWriter { + private final Registry biomes; + private final RegistryAccess registries; + private final PalettedContainerFactory containers; + private final Map tileStates = new ConcurrentHashMap<>(); + private final Map> biomeHolders = new ConcurrentHashMap<>(); + + NativeRegionTerrainWriter(RegistryAccess registries) { + this.registries = Objects.requireNonNull(registries, "registries"); + biomes = registries.lookupOrThrow(Registries.BIOME); + containers = PalettedContainerFactory.create(registries); + } + + RegistryAccess registries() { + return registries; + } + + CompoundTag encode(TerrainInput input) { + TerrainData terrain = terrain(input, null); + SerializableChunkData data = new SerializableChunkData(containers, + new ChunkPos(input.chunkX(), input.chunkZ()), input.minimumY() >> 4, 0L, 0L, + ChunkStatus.EMPTY, null, null, UpgradeData.EMPTY, Map.of(), + new ChunkAccess.PackedTicks(List.of(), List.of()), new ShortList[input.height() >> 4], false, + terrain.sections(), List.of(), terrain.blockEntities(), new CompoundTag(), null); + CompoundTag tag = data.write(); + tag.putString("iris:checkpoint", "iris:terrain_checkpoint"); + return tag; + } + + + void fill(ProtoChunk chunk, TerrainInput input) { + if (chunk.getPos().x() != input.chunkX() || chunk.getPos().z() != input.chunkZ() + || chunk.getMinY() != input.minimumY() || chunk.getHeight() != input.height()) { + throw new IllegalArgumentException("Generated terrain does not match its planned chunk"); + } + TerrainData terrain = terrain(input, chunk.getSections()); + for (int i = 0; i < terrain.sections().size(); i++) { + chunk.getSections()[i] = terrain.sections().get(i).chunkSection(); + } + for (CompoundTag entity : terrain.blockEntities()) { + chunk.setBlockEntityNbt(entity); + } + chunk.markUnsaved(); + } + + private TerrainData terrain(TerrainInput input, LevelChunkSection[] existingBiomes) { + Int2ObjectOpenHashMap tiles = new Int2ObjectOpenHashMap<>(input.tiles().size()); + for (TileInput tile : input.tiles()) { + if (tile.y() >= input.height() || tiles.put(tile.x() | tile.z() << 4 | tile.y() << 8, tile) != null) { + throw new IllegalArgumentException("Duplicate or out-of-bounds tile data"); + } + } + List blockEntities = new ArrayList<>(); + List sections = new ArrayList<>(input.height() >> 4); + for (int sectionY = 0; sectionY < input.height(); sectionY += 16) { + PalettedContainer blocks = containers.createForBlockStates(); + PalettedContainer> sectionBiomes = existingBiomes == null + ? containers.createForBiomes() : existingBiomes[sectionY >> 4].getBiomes().copy(); + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + BlockState state = Objects.requireNonNull(input.blocks().state(x, sectionY + y, z), "block state"); + TileInput tile = tiles.isEmpty() ? null : tiles.get(x | z << 4 | (sectionY + y) << 8); + if (tile == null && state == Blocks.AIR.defaultBlockState()) { + continue; + } + if (tile != null) { + state = tileStates.computeIfAbsent(tile.stateKey(), NativeRegionTerrainWriter::resolveState); + } + blocks.set(x, y, z, state); + if (state.hasBlockEntity()) { + BlockPos position = new BlockPos((input.chunkX() << 4) + x, + input.minimumY() + sectionY + y, (input.chunkZ() << 4) + z); + CompoundTag entity = encodeBlockEntity(state, position, tile); + if (entity != null) { + blockEntities.add(entity); + } + } else if (tile != null) { + throw new IllegalArgumentException("Tile data has no native block entity at " + x + "," + y + "," + z); + } + } + } + } + if (existingBiomes == null) { + for (int y = 0; y < 4; y++) { + for (int z = 0; z < 4; z++) { + for (int x = 0; x < 4; x++) { + String key = Objects.requireNonNull(input.biomes().key(x << 2, sectionY + (y << 2), z << 2), "biome key"); + sectionBiomes.set(x, y, z, biomeHolders.computeIfAbsent(key, this::resolveBiome)); + } + } + } + } + sections.add(new SerializableChunkData.SectionData((input.minimumY() + sectionY) >> 4, + new LevelChunkSection(blocks, sectionBiomes), null, null)); + } + return new TerrainData(sections, blockEntities); + } + + private CompoundTag encodeBlockEntity(BlockState state, BlockPos position, TileInput tile) { + BlockEntity entity = ((EntityBlock) state.getBlock()).newBlockEntity(position, state); + if (entity == null) { + if (tile != null) { + throw new IllegalStateException("Native block did not create a tile entity at " + position); + } + return null; + } + if (tile != null) { + CompoundTag merged = entity.saveWithoutMetadata(registries); + merged.merge(tile.payload()); + entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, merged)); + } + return entity.saveWithFullMetadata(registries); + } + + BlockState readBlock(CompoundTag tag, int x, int y, int z) { + for (CompoundTag section : tag.getListOrEmpty("sections").compoundStream().toList()) { + if (section.getByteOr("Y", (byte) 0) == y >> 4) { + return containers.blockStatesContainerCodec().parse(NbtOps.INSTANCE, + section.getCompoundOrEmpty("block_states")).getOrThrow().get(x & 15, y & 15, z & 15); + } + } + throw new IllegalArgumentException("Missing section at " + y); + } + + static void write(RegionFile region, CompoundTag tag) throws IOException { + ChunkPos position = new ChunkPos(tag.getIntOr("xPos", 0), tag.getIntOr("zPos", 0)); + try (DataOutputStream output = region.getChunkDataOutputStream(position)) { + NbtIo.write(tag, output); + } + } + + private static BlockState resolveState(String key) { + try { + return BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, key, false).blockState(); + } catch (CommandSyntaxException failure) { + throw new IllegalArgumentException("Unsupported native block state: " + key, failure); + } + } + + private Holder resolveBiome(String key) { + return biomes.get(ResourceKey.create(Registries.BIOME, Identifier.parse(key))) + .orElseThrow(() -> new IllegalArgumentException("Unsupported native biome: " + key)); + } + + record TerrainInput(int chunkX, int chunkZ, int minimumY, int height, BlockLookup blocks, KeyLookup biomes, List tiles) { + TerrainInput { + if ((minimumY & 15) != 0 || (height & 15) != 0 || height < 16 + || minimumY < -2032 || (long) minimumY + height > 2032) { + throw new IllegalArgumentException("Terrain bounds must be section aligned within the native height range"); + } + Objects.requireNonNull(blocks, "blocks"); + Objects.requireNonNull(biomes, "biomes"); + tiles = List.copyOf(tiles); + } + } + + record TileInput(int x, int y, int z, String stateKey, CompoundTag payload) { + TileInput { + if (x < 0 || x > 15 || z < 0 || z > 15 || y < 0) { + throw new IllegalArgumentException("Tile position is outside its chunk"); + } + Objects.requireNonNull(stateKey, "stateKey"); + payload = Objects.requireNonNull(payload, "payload").copy(); + } + } + + private record TerrainData(List sections, List blockEntities) { + } + + @FunctionalInterface + interface BlockLookup { + BlockState state(int x, int y, int z); + } + + @FunctionalInterface + interface KeyLookup { + String key(int x, int y, int z); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionGenerator.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionGenerator.java new file mode 100644 index 000000000..0885526d8 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionGenerator.java @@ -0,0 +1,258 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.world.pregen.PregenPerformanceProfile; + +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import org.spigotmc.SpigotWorldConfig; +import org.bukkit.NamespacedKey; +import org.bukkit.Bukkit; +import org.bukkit.persistence.PersistentDataType; + +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.FileVisitResult; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +public final class OfflineRegionGenerator { + private OfflineRegionGenerator() { + } + + public static void main(String[] arguments) { + PrintStream diagnostics = System.err; + try { + execute(arguments); + } catch (Throwable failure) { + failure.printStackTrace(diagnostics); + diagnostics.flush(); + System.exit(1); + } + } + + private static void execute(String[] arguments) throws Exception { + if (arguments.length == 1 && arguments[0].equals("--help")) { + System.out.println("Usage: iris-offline pack dimension seed chunkX chunkZ width regionsX regionsZ parallelism spigot worldName levelKey noiseSettings output"); + System.out.println("Java 25; width 1..32 chunks; parallelism 1..32. Output must be a new absolute directory."); + System.out.println("Generates fresh TERRAIN checkpoints and immutable Iris history. Native server completion is required for features, population and lighting."); + return; + } + if (Bukkit.getServer() != null) { + throw new IllegalStateException("Offline generation requires a separate process without a running Bukkit server"); + } + long processStarted = System.nanoTime(); + HeadlessNativeBootstrap.initialize(); + Configuration configuration = Configuration.parse(arguments); + Files.createDirectory(configuration.output()); + force(configuration.output()); + try (FileChannel channel = FileChannel.open(configuration.output().resolve("checkpoint.lock"), + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + FileLock lock = channel.lock()) { + if (!lock.isValid()) { + throw new IllegalStateException("Headless checkpoint lock is invalid"); + } + run(configuration); + System.out.println("processSeconds=" + (System.nanoTime() - processStarted) / 1_000_000_000.0); + } + } + + private static void run(Configuration configuration) throws Exception { + Path staged = Files.createDirectory(configuration.output().resolve("incomplete")); + byte[] configurationBytes = Files.readAllBytes(configuration.spigot()); + Path capturedConfiguration = staged.resolve("spigot.yml"); + Files.write(capturedConfiguration, configurationBytes, StandardOpenOption.CREATE_NEW); + String configurationHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(configurationBytes)); + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(); + long started = System.nanoTime(); + long writes = 0; + long terrainWrites = 0; + double generationSeconds = 0; + double serializationSeconds = 0; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(configuration.pack(), + configuration.dimension(), "[headless-native-region]", runtime, staged)); + RealPackProbeSupport.EngineSession session = workspace.openEngine(configuration.seed(), false, "native-region")) { + session.retainWorld(staged.resolve("world")); + SpigotWorldConfig spigot = HeadlessStructureConfiguration.load(new HeadlessStructureConfiguration.Options( + capturedConfiguration, configuration.worldName(), configuration.levelKey(), staged.resolve("configuration"))); + PregenPerformanceProfile.apply(session.engine()); + System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + + " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true"); + IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime(); + try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null + : hydrology.preparePregeneration(configuration.hydrologyArea()); + HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options( + session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(), + configuration.noiseSettings(), spigot))) { + HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(RegionGenerationWindow.Policy.STANDALONE); + Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region"); + Files.createDirectory(regionDirectory); + OfflineRegionWriter writer = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, regionDirectory, configuration.parallelism(), RegionGenerationWindow.Policy.STANDALONE)); + for (int row = 0; row < configuration.regionsZ(); row++) { + for (int column = 0; column < configuration.regionsX(); column++) { + int regionX = row % 2 == 0 ? column : configuration.regionsX() - column - 1; + int chunkX = Math.addExact(configuration.chunkX(), Math.multiplyExact(regionX, configuration.width())); + int chunkZ = Math.addExact(configuration.chunkZ(), Math.multiplyExact(row, configuration.width())); + RegionResult generated = generateRegion(new RegionRequest(context, rolling, writer, chunkX, chunkZ, + configuration.width(), configuration.parallelism(), + staged.resolve("receipts.properties"))); + writes += generated.written().chunks(); + terrainWrites += generated.written().terrainChunks(); + generationSeconds += generated.generationSeconds(); + serializationSeconds += generated.serializationSeconds(); + System.out.println("region=" + regionX + "," + row + " residentChunks=" + generated.residentChunks() + + " upgradedChunks=" + generated.upgradedChunks() + " writtenChunks=" + generated.written().chunks()); + } + } + } finally { + PregenPerformanceProfile.restore(); + } + } + double wallSeconds = (System.nanoTime() - started) / 1_000_000_000.0; + String report = String.format(Locale.ROOT, + "format=iris-native-terrain-checkpoint\nplatform=headless-native\nstatus=minecraft:terrain\ncomplete=false\n" + + "targetChunks=%d\nchunkWrites=%d\nterrainWrites=%d\nparallelism=%d\nseed=%d\nchunkX=%d\nchunkZ=%d\nwidth=%d\nregionsX=%d\nregionsZ=%d\n" + + "levelKey=%s\nworldName=%s\nnoiseSettings=%s\nspigotSha256=%s\npipelineWallSeconds=%.3f\ngenerationSeconds=%.3f\nserializationSeconds=%.3f\n" + + "limitations=Fresh offline world checkpoint only. Native features, population, lighting, POI, entities and scheduled ticks require native completion. No live plugin events are dispatched. Authored tile payloads remain in retained mantle for native completion. No transitions or existing chunks are imported.\n", + Math.multiplyExact(Math.multiplyExact(configuration.width(), configuration.width()), + Math.multiplyExact(configuration.regionsX(), configuration.regionsZ())), writes, terrainWrites, + configuration.parallelism(), configuration.seed(), configuration.chunkX(), configuration.chunkZ(), + configuration.width(), configuration.regionsX(), configuration.regionsZ(), configuration.levelKey().identifier(), + configuration.worldName(), configuration.noiseSettings().identifier(), configurationHash, + wallSeconds, generationSeconds, serializationSeconds); + Files.writeString(staged.resolve("checkpoint.properties"), report, StandardOpenOption.CREATE_NEW); + long publicationStarted = System.nanoTime(); + forceTree(staged); + Files.move(staged, configuration.output().resolve("native-terrain-checkpoint"), StandardCopyOption.ATOMIC_MOVE); + force(configuration.output()); + System.out.println(report); + System.out.println("publicationSeconds=" + (System.nanoTime() - publicationStarted) / 1_000_000_000.0); + System.out.println("totalSeconds=" + (System.nanoTime() - started) / 1_000_000_000.0); + } + + private static RegionResult generateRegion(RegionRequest request) throws Exception { + long generationStarted = System.nanoTime(); + HeadlessRegionTerrain.Result generated = request.rolling().generate(new HeadlessRegionTerrain.Request( + request.context(), request.chunkX(), request.chunkZ(), request.width(), request.parallelism()), System.out::println); + double generationSeconds = (System.nanoTime() - generationStarted) / 1_000_000_000.0; + long serializationStarted = System.nanoTime(); + OfflineRegionWriter.Result written = request.writer().write(generated); + writeReceipts(generated, request.receipts()); + return new RegionResult(written, generated.chunks().size(), generated.updated().size(), generationSeconds, + (System.nanoTime() - serializationStarted) / 1_000_000_000.0); + } + + private record RegionRequest(HeadlessTerrainContext context, HeadlessRegionTerrain.Session rolling, OfflineRegionWriter writer, + int chunkX, int chunkZ, int width, int parallelism, Path receipts) { + } + + private record RegionResult(OfflineRegionWriter.Result written, int residentChunks, int upgradedChunks, + double generationSeconds, double serializationSeconds) { + } + + private static void writeReceipts(HeadlessRegionTerrain.Result generated, Path path) throws Exception { + NamespacedKey key = new NamespacedKey("iris", "natural_terrain"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + StringBuilder receipts = new StringBuilder(); + for (ProtoChunk chunk : generated.updated()) { + if (!chunk.getPersistedStatus().isOrAfter(ChunkStatus.TERRAIN)) { + continue; + } + byte[] receipt = chunk.persistentDataContainer.get(key, PersistentDataType.BYTE_ARRAY); + if (receipt == null) { + throw new IllegalStateException("Native TERRAIN checkpoint has no receipt at " + chunk.getPos()); + } + receipts.append(chunk.getPos().x()).append(',').append(chunk.getPos().z()).append('=') + .append(HexFormat.of().formatHex(digest.digest(receipt))).append('\n'); + } + Files.writeString(path, receipts, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } + + private static void forceTree(Path directory) throws IOException { + Files.walkFileTree(directory, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + force(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path path, IOException failure) throws IOException { + if (failure != null) { + throw failure; + } + force(path); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void force(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, Files.isDirectory(path) + ? StandardOpenOption.READ : StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + record Configuration(File pack, String dimension, long seed, int chunkX, int chunkZ, int width, + int regionsX, int regionsZ, int parallelism, Path spigot, String worldName, ResourceKey levelKey, + ResourceKey noiseSettings, Path output) { + Configuration { + if (regionsX < 1 || regionsZ < 1) { + throw new IllegalArgumentException("Region dimensions must be positive"); + } + Math.multiplyExact(Math.multiplyExact(width, width), Math.multiplyExact(regionsX, regionsZ)); + NativeRegionPlan.plan(chunkX, chunkZ, width); + NativeRegionPlan.plan(Math.addExact(chunkX, Math.multiplyExact(regionsX - 1, width)), + Math.addExact(chunkZ, Math.multiplyExact(regionsZ - 1, width)), width); + if (parallelism < 1 || parallelism > 32 || !Files.isRegularFile(spigot)) { + throw new IllegalArgumentException("Parallelism must be 1..32 and Spigot configuration must exist"); + } + if (!output.isAbsolute() || Files.exists(output) || !Files.isDirectory(output.getParent())) { + throw new IllegalArgumentException("Output must be a new absolute directory under an existing parent"); + } + } + + HydrologyTileCache.PregenerationArea hydrologyArea() { + int halo = NativeRegionPlan.terrainHalo(); + long minimumX = ((long) chunkX - halo) * 16L; + long minimumZ = ((long) chunkZ - halo) * 16L; + long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L; + long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L; + return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L), + Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ); + } + + static Configuration parse(String[] arguments) throws IOException { + if (arguments.length != 14) { + throw new IllegalArgumentException("Expected pack dimension seed chunkX chunkZ width regionsX regionsZ parallelism spigot worldName levelKey noiseSettings output"); + } + return new Configuration(new File(arguments[0]).getCanonicalFile(), arguments[1], Long.parseLong(arguments[2]), + Integer.parseInt(arguments[3]), Integer.parseInt(arguments[4]), Integer.parseInt(arguments[5]), + Integer.parseInt(arguments[6]), Integer.parseInt(arguments[7]), Integer.parseInt(arguments[8]), + Path.of(arguments[9]), arguments[10], + ResourceKey.create(Registries.DIMENSION, Identifier.parse(arguments[11])), + ResourceKey.create(Registries.NOISE_SETTINGS, Identifier.parse(arguments[12])), Path.of(arguments[13])); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionWriter.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionWriter.java new file mode 100644 index 000000000..737418373 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/OfflineRegionWriter.java @@ -0,0 +1,163 @@ +package art.arcane.iris.probe; + +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.IORunnable; +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.WriteData; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class OfflineRegionWriter { + private static final int MAXIMUM_PREPARATION_WORKERS = 8; + private static final int MAXIMUM_STATUS_REGIONS = 64; + + private final Options options; + private final Map published = new LinkedHashMap<>(MAXIMUM_STATUS_REGIONS, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAXIMUM_STATUS_REGIONS; + } + }; + + OfflineRegionWriter(Options options) { + this.options = Objects.requireNonNull(options, "options"); + } + + Result write(HeadlessRegionTerrain.Result generated) throws Exception { + try (OpenRegions regions = new OpenRegions(options)) { + List pending = new ArrayList<>(generated.updated().size()); + for (ProtoChunk chunk : generated.updated()) { + ChunkPos position = chunk.getPos(); + long regionKey = ChunkPos.pack(position.x() >> 5, position.z() >> 5); + int[] statuses = published.computeIfAbsent(regionKey, ignored -> new int[1024]); + int index = (position.x() & 31) + ((position.z() & 31) << 5); + int status = chunk.getPersistedStatus().getIndex() + 2; + if (statuses[index] >= status) { + continue; + } + RegionFile region = regions.get(position); + if (statuses[index] == 0) { + statuses[index] = readStatus(region, position); + if (statuses[index] >= status) { + continue; + } + } + pending.add(new ChunkWrite(chunk, region, statuses, index, status)); + } + if (pending.isEmpty()) { + return new Result(0, 0); + } + int[] counts = new int[2]; + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(pending.size(), + Math.min(options.parallelism(), MAXIMUM_PREPARATION_WORKERS), + index -> prepare(pending.get(index)), + (index, prepared) -> { + ChunkWrite chunk = prepared.chunk(); + prepared.commit().run(chunk.region()); + chunk.statuses()[chunk.index()] = chunk.status(); + counts[0]++; + if (chunk.chunk().getPersistedStatus().isOrAfter(ChunkStatus.TERRAIN)) { + counts[1]++; + } + }), options.policy()); + return new Result(counts[0], counts[1]); + } + } + + private PreparedWrite prepare(ChunkWrite chunk) throws IOException { + CompoundTag tag = HeadlessChunkSerialization.copyOf(chunk.chunk(), options.context().serialization()).write(); + WriteData prepared = chunk.region().moonrise$startWrite(tag, chunk.chunk().getPos()); + try (DataOutputStream output = prepared.output()) { + NbtIo.write(tag, output); + } + return new PreparedWrite(chunk, prepared.write()); + } + + private static int readStatus(RegionFile region, ChunkPos position) throws IOException { + try (DataInputStream input = region.getChunkDataInputStream(position)) { + if (input == null) { + return 1; + } + ChunkStatus status = ChunkStatus.byName(NbtIo.read(input).getStringOr("Status", "")); + if (status == null) { + throw new IOException("Invalid native chunk status at " + position); + } + return status.getIndex() + 2; + } + } + + record Options(HeadlessTerrainContext context, Path directory, int parallelism, RegionGenerationWindow.Policy policy) { + Options { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(policy, "policy"); + if (parallelism < 1 || parallelism > 32) { + throw new IllegalArgumentException("Offline writer parallelism must be 1..32"); + } + } + } + + record Result(int chunks, int terrainChunks) { + } + + private record ChunkWrite(ProtoChunk chunk, RegionFile region, int[] statuses, int index, int status) { + } + + private record PreparedWrite(ChunkWrite chunk, IORunnable commit) { + } + + private static final class OpenRegions implements AutoCloseable { + private final Options options; + private final Map regions = new HashMap<>(); + + private OpenRegions(Options options) { + this.options = options; + } + + private RegionFile get(ChunkPos position) throws IOException { + long key = ChunkPos.pack(position.x() >> 5, position.z() >> 5); + RegionFile region = regions.get(key); + if (region != null) { + return region; + } + Path path = options.directory().resolve("r." + (position.x() >> 5) + "." + (position.z() >> 5) + ".mca"); + region = new RegionFile(new RegionStorageInfo("headless-native-terrain", + options.context().structures().levelKey(), "chunk"), path, options.directory(), false); + regions.put(key, region); + return region; + } + + @Override + public void close() throws IOException { + IOException failure = null; + for (RegionFile region : regions.values()) { + try { + region.close(); + } catch (IOException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } + } + if (failure != null) { + throw failure; + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/RegionGenerationWindow.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/RegionGenerationWindow.java new file mode 100644 index 000000000..80a0568ae --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/RegionGenerationWindow.java @@ -0,0 +1,140 @@ +package art.arcane.iris.probe; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +final class RegionGenerationWindow { + private static final int UNRESPONSIVE_WORKER_EXIT = 70; + + private RegionGenerationWindow() { + } + + static void process(Request request) throws Exception { + process(request, Policy.EMBEDDED); + } + + static void process(Request request, Policy policy) throws Exception { + ExecutorService workers = Executors.newFixedThreadPool(request.parallelism()); + ExecutorCompletionService> completions = new ExecutorCompletionService<>(workers); + Set>> active = new HashSet<>(request.parallelism()); + try { + int submitted = 0; + for (; submitted < Math.min(request.count(), request.parallelism()); submitted++) { + active.add(submit(completions, request.generator(), submitted)); + } + for (int completed = 0; completed < request.count(); completed++) { + Future> pending = completions.poll(policy.completionTimeout().toNanos(), TimeUnit.NANOSECONDS); + if (pending == null) { + throw new TimeoutException("No generation worker completed within " + policy.completionTimeout()); + } + active.remove(pending); + Completed result = pending.get(); + if (submitted < request.count()) { + active.add(submit(completions, request.generator(), submitted++)); + } + request.sink().accept(result.index(), result.value()); + } + } catch (Exception | Error failure) { + for (Future> future : active) { + future.cancel(true); + } + workers.shutdownNow(); + drain(workers, policy, failure); + throw failure; + } + workers.shutdown(); + drain(workers, policy, null); + } + + private static void drain(ExecutorService workers, Policy policy, Throwable failure) { + boolean interrupted = Thread.interrupted() || failure instanceof InterruptedException; + long deadline = System.nanoTime() + policy.drainTimeout().toNanos(); + try { + while (!workers.isTerminated()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0L) { + break; + } + try { + workers.awaitTermination(remaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException interruption) { + interrupted = true; + } + } + if (workers.isTerminated()) { + return; + } + IllegalStateException stalled = new IllegalStateException( + "Generation workers did not stop within " + policy.drainTimeout() + + "; generation resources remain open and no checkpoint will be published", failure); + stalled.printStackTrace(System.err); + if (policy.haltOnUnresponsiveWorkers()) { + System.err.println("Terminating offline generation; incomplete output is retained for inspection"); + System.err.flush(); + Runtime.getRuntime().halt(UNRESPONSIVE_WORKER_EXIT); + } + System.err.println("Waiting for embedded generation workers to stop before releasing their resources"); + while (!workers.isTerminated()) { + try { + workers.awaitTermination(1L, TimeUnit.DAYS); + } catch (InterruptedException interruption) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private static Future> submit(ExecutorCompletionService> completions, + Generator generator, int index) { + return completions.submit(() -> new Completed<>(index, generator.generate(index))); + } + + record Policy(Duration completionTimeout, Duration drainTimeout, boolean haltOnUnresponsiveWorkers) { + static final Policy EMBEDDED = new Policy(Duration.ofMinutes(5), Duration.ofSeconds(30), false); + static final Policy STANDALONE = new Policy(Duration.ofMinutes(5), Duration.ofSeconds(30), true); + + Policy { + Objects.requireNonNull(completionTimeout, "completionTimeout"); + Objects.requireNonNull(drainTimeout, "drainTimeout"); + if (completionTimeout.isNegative() || completionTimeout.isZero() + || drainTimeout.isNegative() || drainTimeout.isZero()) { + throw new IllegalArgumentException("Worker completion and drain timeouts must be positive"); + } + completionTimeout.toNanos(); + drainTimeout.toNanos(); + } + } + + record Request(int count, int parallelism, Generator generator, Sink sink) { + Request { + if (count < 1 || count > 4096 || parallelism < 1 || parallelism > 32) { + throw new IllegalArgumentException("Generation window requires 1..4096 tasks and 1..32 workers"); + } + } + } + + private record Completed(int index, T value) { + } + + @FunctionalInterface + interface Generator { + T generate(int index) throws Exception; + } + + @FunctionalInterface + interface Sink { + void accept(int index, T value) throws Exception; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrain.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrain.java new file mode 100644 index 000000000..a7af94987 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrain.java @@ -0,0 +1,233 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.probe.NativeRegionPlan.PlannedChunk; + +import net.minecraft.nbt.CompoundTag; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.GenerationChunkHolder; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.StaticCache2D; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkPyramid; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.status.ChunkStep; +import net.minecraft.world.level.chunk.status.WorldGenContext; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicInteger; + +final class DetachedNativeTerrain { + private static final NamespacedKey NATURAL_TERRAIN = new NamespacedKey("iris", "natural_terrain"); + + private DetachedNativeTerrain() { + } + + static Result generate(Request request, Consumer progress) throws Exception { + List plan = NativeRegionPlan.plan(request.chunkX(), request.chunkZ(), request.width()); + Path output = Files.createDirectory(request.output()); + WorldGenContext context = context(request.level().getChunkSource().chunkMap); + Map holders = new HashMap<>(plan.size()); + for (PlannedChunk planned : plan) { + ChunkPos position = new ChunkPos(planned.x(), planned.z()); + holders.put(position.pack(), new DetachedHolder(new ProtoChunk(position, UpgradeData.EMPTY, + request.level(), request.level().palettedContainerFactory(), null))); + } + long started = System.nanoTime(); + for (ChunkStatus status : ChunkStatus.getStatusList()) { + if (status == ChunkStatus.EMPTY) { + continue; + } + if (status.isAfter(ChunkStatus.TERRAIN)) { + break; + } + ChunkStep step = ChunkPyramid.GENERATION_PYRAMID.getStepTo(status); + executeStage(new Stage(context, step, plan, holders, request.parallelism()), progress); + } + long serializationStarted = System.nanoTime(); + write(request.level(), output, plan, holders); + progress.accept("stage=native_serialization seconds=" + (System.nanoTime() - serializationStarted) / 1_000_000_000.0); + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + return new Result(request.width() * request.width(), plan.size(), seconds); + } + + private static void executeStage(Stage stage, Consumer progress) throws Exception { + long started = System.nanoTime(); + List eligible = new ArrayList<>(); + for (PlannedChunk planned : stage.plan()) { + if (planned.status().isOrAfter(stage.step().targetStatus())) { + eligible.add(planned); + } + } + AtomicInteger completed = new AtomicInteger(); + AtomicInteger targetCompleted = new AtomicInteger(); + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(eligible.size(), stage.parallelism(), + index -> { + PlannedChunk planned = eligible.get(index); + apply(stage, planned); + return planned; + }, (index, planned) -> { + int count = completed.incrementAndGet(); + if (planned.target()) { + targetCompleted.incrementAndGet(); + } + if (count % 128 == 0) { + progress.accept("stage=" + stage.step().targetStatus().getName() + " completed=" + count + + " elapsedSeconds=" + (System.nanoTime() - started) / 1_000_000_000.0); + } + })); + progress.accept("stage=" + stage.step().targetStatus().getName() + " chunks=" + completed.get() + + " targetChunks=" + targetCompleted.get() + " haloChunks=" + (completed.get() - targetCompleted.get()) + + " seconds=" + (System.nanoTime() - started) / 1_000_000_000.0); + } + + private static void apply(Stage stage, PlannedChunk planned) { + DetachedHolder center = requireHolder(stage.holders(), planned.x(), planned.z()); + StaticCache2D cache = StaticCache2D.create(planned.x(), planned.z(), + stage.step().directDependencies().size() - 1, (x, z) -> requireHolder(stage.holders(), x, z)); + ChunkAccess result = stage.step().apply(stage.context(), cache, center.chunk).join(); + if (result != center.chunk || result.getPersistedStatus() != stage.step().targetStatus()) { + throw new IllegalStateException("Native stage did not retain its detached chunk: " + stage.step().targetStatus()); + } + } + + private static DetachedHolder requireHolder(Map holders, int x, int z) { + DetachedHolder holder = holders.get(ChunkPos.pack(x, z)); + if (holder == null) { + throw new IllegalStateException("Native dependency escaped the terrain halo at " + x + "," + z); + } + return holder; + } + + private static WorldGenContext context(ChunkMap map) throws ReflectiveOperationException { + for (Field field : ChunkMap.class.getDeclaredFields()) { + if (field.getType() == WorldGenContext.class) { + field.setAccessible(true); + return (WorldGenContext) field.get(map); + } + } + throw new IllegalStateException("Native world generation context is unavailable"); + } + + private static void write(ServerLevel level, Path output, List plan, + Map holders) throws IOException { + List ordered = new ArrayList<>(plan); + ordered.sort(Comparator.comparingInt((PlannedChunk chunk) -> chunk.z() >> 5) + .thenComparingInt(chunk -> chunk.x() >> 5).thenComparingInt(PlannedChunk::z).thenComparingInt(PlannedChunk::x)); + StringBuilder receipts = new StringBuilder(); + RegionFile region = null; + long currentRegion = Long.MIN_VALUE; + try { + for (PlannedChunk planned : ordered) { + if (planned.status() == ChunkStatus.EMPTY) { + continue; + } + long key = ChunkPos.pack(planned.x() >> 5, planned.z() >> 5); + if (key != currentRegion) { + if (region != null) { + region.close(); + } + Path file = output.resolve("r." + (planned.x() >> 5) + "." + (planned.z() >> 5) + ".mca"); + region = new RegionFile(new RegionStorageInfo(level.serverLevelData.getLevelName(), + level.dimension(), "chunk"), file, output, false); + currentRegion = key; + } + ChunkAccess chunk = holders.get(ChunkPos.pack(planned.x(), planned.z())).chunk; + if (planned.status() == ChunkStatus.TERRAIN) { + receipts.append(planned.x()).append(",").append(planned.z()).append("=") + .append(receiptHash(chunk)).append("\n"); + } + CompoundTag tag = SerializableChunkData.copyOf(level, chunk).write(); + NativeRegionTerrainWriter.write(region, tag); + } + Files.writeString(output.resolve("receipts.properties"), receipts.toString(), StandardCharsets.UTF_8); + } finally { + if (region != null) { + region.close(); + } + } + } + + + static String receiptHash(ChunkAccess chunk) { + byte[] receipt = chunk.persistentDataContainer.get(NATURAL_TERRAIN, + PersistentDataType.BYTE_ARRAY); + if (receipt == null || receipt.length == 0) { + throw new IllegalStateException("Missing Iris natural terrain receipt at " + chunk.getPos()); + } + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(receipt)); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException(failure); + } + } + + record Request(ServerLevel level, int chunkX, int chunkZ, int width, int parallelism, Path output) { + Request { + if (parallelism < 1 || parallelism > 16) { + throw new IllegalArgumentException("Native terrain worker count must be 1..16"); + } + } + } + + private record Stage(WorldGenContext context, ChunkStep step, List plan, + Map holders, int parallelism) { + } + + record Result(int terrainChunks, int dependencyChunks, double seconds) { + } + + + private static final class DetachedHolder extends GenerationChunkHolder { + private final ProtoChunk chunk; + + private DetachedHolder(ProtoChunk chunk) { + super(chunk.getPos()); + this.chunk = chunk; + } + + @Override + public ChunkAccess getChunkIfPresentUnchecked(ChunkStatus status) { + if (chunk.getPersistedStatus().isBefore(status)) { + throw new IllegalStateException("Native dependency is not ready: " + getPos() + " needs " + status); + } + return chunk; + } + + @Override + protected void addSaveDependency(CompletableFuture dependency) { + throw new UnsupportedOperationException("Detached chunks have no live save queue"); + } + + @Override + public int getTicketLevel() { + throw new UnsupportedOperationException("Detached chunks have no live tickets"); + } + + @Override + public int getQueueLevel() { + throw new UnsupportedOperationException("Detached chunks have no live queue"); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrainTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrainTest.java new file mode 100644 index 000000000..88cc5d753 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DetachedNativeTerrainTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.probe; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.status.ChunkPyramid; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.status.ChunkStep; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class DetachedNativeTerrainTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void fullRegionPlanSatisfiesEveryNativeStageDependency() { + List plan = NativeRegionPlan.plan(-32, 64, 32); + Map statuses = new HashMap<>(); + int terrain = 0; + for (NativeRegionPlan.PlannedChunk chunk : plan) { + statuses.put(ChunkPos.pack(chunk.x(), chunk.z()), chunk.status()); + if (chunk.status() == ChunkStatus.TERRAIN) { + terrain++; + } + } + assertEquals(1296, terrain); + assertEquals(2916, plan.size()); + assertTrue(plan.size() < 4096); + for (NativeRegionPlan.PlannedChunk chunk : plan) { + for (ChunkStatus status : ChunkStatus.getStatusList()) { + if (status == ChunkStatus.EMPTY || status.isAfter(chunk.status())) { + continue; + } + ChunkStep step = ChunkPyramid.GENERATION_PYRAMID.getStepTo(status); + int radius = step.directDependencies().size() - 1; + for (int z = -radius; z <= radius; z++) { + for (int x = -radius; x <= radius; x++) { + ChunkStatus required = step.directDependencies().get(Math.max(Math.abs(x), Math.abs(z))); + ChunkStatus available = statuses.get(ChunkPos.pack(chunk.x() + x, chunk.z() + z)); + assertTrue("Missing native stage dependency", available != null && available.isOrAfter(required)); + } + } + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkGeneratorTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkGeneratorTest.java new file mode 100644 index 000000000..092b0fe9c --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkGeneratorTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeBiomeSourceImpl; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.bukkit.craftbukkit.generator.CustomChunkGenerator; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +public class HeadlessChunkGeneratorTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void nativeStructureStateAndOriginMatchThePaperGeneratorDefaults() throws Exception { + int checked = 0; + for (Method method : CustomChunkGenerator.class.getMethods()) { + if (!method.getName().equals("getOrigin") && !method.getName().equals("createState")) { + continue; + } + assertEquals(ChunkGenerator.class, method.getDeclaringClass()); + assertEquals(method, HeadlessChunkGenerator.class.getMethod(method.getName(), method.getParameterTypes())); + checked++; + } + assertEquals(2, checked); + } + + @Test + public void nativeSwampHutQueriesRealPackHeightsAndBiomesWithoutAServer() throws Exception { + long seed = 69420L; + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + pack().toFile(), "main", "[native-column-test]", runtime, temporary.getRoot().toPath())); + RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "columns"); + MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource().fullResources())); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault( + temporary.newFolder().toPath()).createAccess("native")) { + Engine engine = session.engine(); + HeadlessChunkGenerator generator = spy(new HeadlessChunkGenerator( + new HeadlessChunkGenerator.Options(engine, runtime.registries()))); + assertTrue(generator.getBiomeSource() instanceof NativeBiomeSourceImpl); + assertEquals(engine.getMinHeight(), generator.getMinY()); + assertEquals(engine.getHeight(), generator.getGenDepth()); + assertEquals(engine.getMinHeight() + engine.getDimension().getFluidHeight(), generator.getSeaLevel()); + Holder settings = runtime.registries().lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(NoiseGeneratorSettings.OVERWORLD); + RandomState random = RandomState.create(runtime.registries().lookupOrThrow(Registries.NOISE), seed, settings.value()); + LevelHeightAccessor height = LevelHeightAccessor.create(generator.getMinY(), generator.getGenDepth()); + Holder swamp = runtime.registries().lookupOrThrow(Registries.BIOME).getOrThrow( + ResourceKey.create(Registries.BIOME, Identifier.parse("minecraft:swamp"))); + assertTrue(generator.getBiomeSource().possibleBiomes().contains(swamp)); + for (int x : new int[]{-33, 0, 49}) { + int z = -x - 7; + int expected = engine.getMinHeight() + engine.getHeight(x, z, true) + 1; + assertTrue(expected > generator.getSeaLevel()); + assertEquals(expected, generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, height, random)); + NoiseColumn column = generator.getBaseColumn(x, z, height, random); + assertFalse(column.getBlock(expected - 1).isAir()); + assertTrue(column.getBlock(expected).isAir()); + assertEquals(swamp, generator.getBiomeSource().createResolver( + random.createClimateSampler(SamplerContext.builder().enableCaches().build())) + .getNoiseBiome(x >> 2, expected >> 2, z >> 2)); + } + Holder.Reference hut = runtime.registries().lookupOrThrow(Registries.STRUCTURE).getOrThrow( + ResourceKey.create(Registries.STRUCTURE, Identifier.parse("minecraft:swamp_hut"))); + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + ChunkPos position = new ChunkPos(-3, 2); + ProtoChunk chunk = new ProtoChunk(position, UpgradeData.EMPTY, height, + PalettedContainerFactory.create(runtime.registries()), null); + clearInvocations(generator); + StructureStart start = hut.value().generate(hut, Level.OVERWORLD, runtime.registries(), generator, + generator.getBiomeSource(), random.createClimateSampler(SamplerContext.builder().enableCaches().build()), + random, templates, seed, position, 0, chunk, hut.value().biomes()::contains); + assertTrue(start.isValid()); + assertFalse(start.getPieces().isEmpty()); + verify(generator, atLeastOnce()).getBaseHeight(anyInt(), anyInt(), any(Heightmap.Types.class), + any(LevelHeightAccessor.class), any(RandomState.class)); + StructureStart excluded = hut.value().generate(hut, Level.OVERWORLD, runtime.registries(), generator, + generator.getBiomeSource(), random.createClimateSampler(SamplerContext.builder().enableCaches().build()), + random, templates, seed, position, 0, chunk, selected -> !selected.equals(swamp)); + assertFalse(excluded.isValid()); + assertTrue(RealPackProbeSupport.drainReported().isEmpty()); + } + } + + private Path pack() throws Exception { + Path pack = temporary.newFolder().toPath(); + for (String directory : List.of("dimensions", "regions", "biomes", "generators")) { + Files.createDirectories(pack.resolve(directory)); + } + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256, + "landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/swamp.json"), """ + {"derivative":"minecraft:swamp","generators":[{"generator":"flat","min":35,"max":35}]} + """); + return pack; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkSerializationTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkSerializationTest.java new file mode 100644 index 000000000..ecf33662b --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessChunkSerializationTest.java @@ -0,0 +1,157 @@ +package art.arcane.iris.probe; + +import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.shorts.ShortArrayList; +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.util.RandomSource; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.level.storage.LevelStorageSource; +import net.minecraft.world.ticks.ScheduledTick; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HeadlessChunkSerializationTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void snapshotMatchesNativeCopyOfIncludingStructuresLightAndReceipts() throws Exception { + RegistryAccess registries = HeadlessNativeTestRegistries.get(); + PalettedContainerFactory containers = PalettedContainerFactory.create(registries); + LevelHeightAccessor height = LevelHeightAccessor.create(-64, 384); + ProtoChunk chunk = new ProtoChunk(new ChunkPos(-33, 17), UpgradeData.EMPTY, height, containers, null); + BlockPos block = new BlockPos(-528, -40, 272); + chunk.setBlockState(block, Blocks.CHEST.defaultBlockState(), 0); + CompoundTag chest = new CompoundTag(); + chest.putString("id", "minecraft:chest"); + chest.putInt("x", block.getX()); + chest.putInt("y", block.getY()); + chest.putInt("z", block.getZ()); + chest.putString("LootTable", "minecraft:chests/simple_dungeon"); + chunk.setBlockEntityNbt(chest); + CompoundTag entity = new CompoundTag(); + entity.putString("id", "minecraft:pig"); + chunk.addEntity(entity); + chunk.getBlockTicks().schedule(new ScheduledTick<>(Blocks.CHEST, block, 37L, 2L)); + chunk.getFluidTicks().schedule(new ScheduledTick<>(Fluids.WATER, block.above(), 41L, 3L)); + chunk.addPackedPostProcess(new ShortArrayList(new short[]{17, 2049}), 1); + chunk.setInhabitedTime(192L); + Heightmap.primeHeightmaps(chunk, EnumSet.allOf(Heightmap.Types.class)); + chunk.setPersistedStatus(ChunkStatus.TERRAIN); + chunk.persistentDataContainer.set(new NamespacedKey("iris", "natural_terrain"), + PersistentDataType.BYTE_ARRAY, new byte[]{1, -8, 92, 0}); + StarlightChunk light = (StarlightChunk) chunk; + light.starlight$getBlockNibbles()[1].setFull(); + light.starlight$getBlockNibbles()[1].updateVisible(); + light.starlight$getSkyNibbles()[0].setFull(); + light.starlight$getSkyNibbles()[0].updateVisible(); + chunk.setLightCorrect(true); + Structure hut = registries.lookupOrThrow(Registries.STRUCTURE).getOrThrow( + ResourceKey.create(Registries.STRUCTURE, Identifier.parse("minecraft:swamp_hut"))).value(); + StructureStart start = new StructureStart(hut, chunk.getPos(), 2, + new PiecesContainer(List.of(new SwampHutPiece(RandomSource.create(69420L), block.getX(), block.getZ())))); + start.persistentDataContainer.set(new NamespacedKey("iris", "structure_activation"), PersistentDataType.LONG, 73L); + chunk.setAllStarts(Map.of(hut, start)); + chunk.setAllReferences(Map.of(hut, new LongOpenHashSet(new long[]{ChunkPos.pack(-32, 17)}))); + try (MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource().fullResources())); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault( + temporary.newFolder().toPath()).createAccess("serialization")) { + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + StructurePieceSerializationContext structures = new StructurePieceSerializationContext(resources, registries, templates); + HeadlessChunkSerialization.Context context = new HeadlessChunkSerialization.Context(structures, containers, 29L); + MinecraftServer server = mock(MinecraftServer.class); + when(server.getResourceManager()).thenReturn(resources); + when(server.registryAccess()).thenReturn(registries.freeze()); + when(server.getStructureTemplateManager()).thenReturn(templates); + ServerLevel level = mock(ServerLevel.class); + when(level.getServer()).thenReturn(server); + when(level.registryAccess()).thenReturn(registries); + when(level.palettedContainerFactory()).thenReturn(containers); + when(level.getGameTime()).thenReturn(29L); + when(level.getMinSectionY()).thenReturn(height.getMinSectionY()); + when(level.getMaxSectionY()).thenReturn(height.getMaxSectionY()); + CompoundTag nativeTag = SerializableChunkData.copyOf(level, chunk).write(); + SerializableChunkData snapshot = HeadlessChunkSerialization.copyOf(chunk, context); + CompoundTag headless = snapshot.write(); + assertEquals(nativeTag, headless); + assertEquals("minecraft:terrain", headless.getStringOr("Status", "")); + assertEquals(29L, headless.getLongOr("LastUpdate", -1L)); + assertEquals(192L, headless.getLongOr("InhabitedTime", -1L)); + assertArrayEquals(new byte[]{1, -8, 92, 0}, headless.getCompoundOrEmpty("ChunkBukkitValues") + .getByteArray("iris:natural_terrain").orElseThrow()); + assertFalse(headless.getListOrEmpty("block_entities").isEmpty()); + assertFalse(headless.getListOrEmpty("block_ticks").isEmpty()); + assertFalse(headless.getListOrEmpty("fluid_ticks").isEmpty()); + assertTrue(headless.getCompoundOrEmpty("structures").getCompoundOrEmpty("starts").contains("minecraft:swamp_hut")); + CompoundTag serializedStart = headless.getCompoundOrEmpty("structures").getCompoundOrEmpty("starts") + .getCompoundOrEmpty("minecraft:swamp_hut"); + StructureStart restored = StructureStart.loadStaticStart(structures, serializedStart, 69420L); + assertTrue(restored.isValid()); + assertEquals(start.getBoundingBox(), restored.getBoundingBox()); + assertEquals(73L, serializedStart.getCompoundOrEmpty("StructureBukkitValues") + .getLongOr("iris:structure_activation", -1L)); + chunk.getSection(1).setBlockState(0, 8, 0, Blocks.AIR.defaultBlockState()); + chunk.getPostProcessing()[1].clear(); + chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE).getRawData()[0] = 0L; + assertEquals(headless, snapshot.write()); + for (ChunkStatus status : List.of(ChunkStatus.EMPTY, ChunkStatus.STRUCTURE_STARTS, + ChunkStatus.STRUCTURE_REFERENCES, ChunkStatus.BIOMES, ChunkStatus.TERRAIN)) { + ProtoChunk fresh = new ProtoChunk(new ChunkPos(0, -1), UpgradeData.EMPTY, height, containers, null); + fresh.setPersistedStatus(status); + CompoundTag freshTag = HeadlessChunkSerialization.copyOf(fresh, context).write(); + assertEquals(SerializableChunkData.copyOf(level, fresh).write(), freshTag); + assertEquals(status, SerializableChunkData.getChunkStatusFromTag(freshTag)); + assertFalse(freshTag.contains("ChunkBukkitValues")); + assertTrue(freshTag.getListOrEmpty("entities").isEmpty()); + assertTrue(freshTag.getListOrEmpty("block_entities").isEmpty()); + } + chunk.setPersistedStatus(ChunkStatus.FULL); + assertThrows(IllegalArgumentException.class, () -> HeadlessChunkSerialization.copyOf(chunk, context)); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java new file mode 100644 index 000000000..5ee4ddab4 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl; +import net.minecraft.data.registries.VanillaRegistries; +import org.bukkit.Bukkit; +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public final class HeadlessGenerationRegistryTest { + @Test + public void capturesGeneratedDefinitionsThroughNativeCodecs() throws Exception { + HeadlessNativeBootstrap.initialize(); + String biome = HeadlessNativeTestRegistries.generatedBiome(); + String dimension = HeadlessNativeTestRegistries.generatedDimension(); + HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"), + HeadlessNativeTestRegistries::get); + PlatformGenerationRegistry registry = platform.generationRegistry(); + assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity()); + assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(), + registry.generatedDefinitionRendererIdentity()); + assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome), + registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test")); + assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension), + registry.generatedDefinition("minecraft:dimension_type", "iris:native_test")); + assertEquals(PlatformGenerationRegistry.Definition.resourceIdentity("minecraft:block", "minecraft:stone"), + registry.definition("minecraft:block", "minecraft:stone")); + assertThrows(IllegalStateException.class, + () -> registry.generatedDefinition("minecraft:worldgen/biome", "iris:absent")); + assertThrows(IllegalArgumentException.class, + () -> registry.canonicalDefinition("minecraft:worldgen/biome", "iris:invalid", "{}")); + assertNull(Bukkit.getServer()); + } + + @Test + public void rejectsContractCaptureBeforeCoherentRegistriesLoad() { + HeadlessNativeBootstrap.initialize(); + HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"), + VanillaRegistries::createWorldLookup); + assertThrows(IllegalStateException.class, + () -> platform.generationRegistry().definition("minecraft:block", "minecraft:stone")); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessMantleInspection.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessMantleInspection.java new file mode 100644 index 000000000..ddd88a5e3 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessMantleInspection.java @@ -0,0 +1,90 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngineMantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.mantle.runtime.Mantle; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +public final class HeadlessMantleInspection { + private HeadlessMantleInspection() { + } + + public static void main(String[] arguments) throws Exception { + if (arguments.length != 6) { + throw new IllegalArgumentException("Expected pack, dimension, seed, world, regionX, regionZ"); + } + Path temporary = Files.createTempDirectory("iris-mantle-inspection-"); + Path copied = temporary.resolve("mantle"); + try { + Process copy = new ProcessBuilder("cp", "-Rc", Path.of(arguments[3]).resolve("mantle-hydrology").toString(), + copied.toString()).inheritIO().start(); + if (copy.waitFor() != 0) { + throw new IllegalStateException("Could not clone retained mantle for read-only inspection"); + } + inspect(arguments, copied); + } finally { + try (Stream paths = Files.walk(temporary)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + } + + private static void inspect(String[] arguments, Path copied) throws Exception { + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + new File(arguments[0]), arguments[1], "[mantle-inspection]", runtime, + Path.of(System.getProperty("java.io.tmpdir")))); + RealPackProbeSupport.EngineSession session = workspace.openEngine(Long.parseLong(arguments[2]), false, "inspect")) { + Engine engine = session.engine(); + Mantle mantle = IrisEngineMantle.createMantle(engine, copied, engine::getData); + try { + AtomicInteger tiles = new AtomicInteger(); + Map materials = new TreeMap<>(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + int minimumX = Integer.parseInt(arguments[4]) << 5; + int minimumZ = Integer.parseInt(arguments[5]) << 5; + for (int z = minimumZ; z < minimumZ + 32; z++) { + for (int x = minimumX; x < minimumX + 32; x++) { + mantle.iterateChunk(x, z, TileWrapper.class, (localX, y, localZ, wrapper) -> { + TileData tile = wrapper.getData(); + tiles.incrementAndGet(); + materials.merge(tile.getMaterialKey(), 1, Integer::sum); + digest.update(encoded(tile)); + }); + } + } + System.out.println("retainedMantleTiles=" + tiles.get() + " materials=" + materials + + " sha256=" + HexFormat.of().formatHex(digest.digest())); + } finally { + mantle.close(); + } + } + } + + private static byte[] encoded(TileData tile) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes)) { + tile.toBinary(output); + return bytes.toByteArray(); + } catch (Exception failure) { + throw new IllegalStateException("Cannot serialize retained tile " + tile.getMaterialKey(), failure); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBlockPredicateTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBlockPredicateTest.java new file mode 100644 index 000000000..bc572d0d4 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBlockPredicateTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockProperties; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +public final class HeadlessNativeBlockPredicateTest { + @BeforeClass + public static void bootstrap() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void memoizedPredicatesMatchEveryNativeBlockStateIncludingStateProperties() { + for (Block block : BuiltInRegistries.BLOCK) { + for (BlockState state : block.getStateDefinition().getPossibleStates()) { + ModdedBlockState wrapped = ModdedBlockState.deferred(state, Map.of(), "iris:test"); + for (int iteration = 0; iteration < 2; iteration++) { + assertEquals(NativeBlockProperties.isStorage(state), wrapped.isStorage()); + assertEquals(NativeBlockProperties.isUpdatable(state), wrapped.isUpdatable()); + } + } + } + } + + @Test + public void repeatedWritesClassifyImmutableStateOnlyOnce() { + BlockState state = Blocks.CHEST.defaultBlockState(); + ModdedBlockState wrapped = ModdedBlockState.deferred(state, Map.of(), "iris:chest"); + boolean storage = NativeBlockProperties.isStorage(state); + boolean updatable = NativeBlockProperties.isUpdatable(state); + try (MockedStatic properties = mockStatic(NativeBlockProperties.class, CALLS_REAL_METHODS)) { + for (int iteration = 0; iteration < 512; iteration++) { + assertEquals(storage, wrapped.isStorage()); + assertEquals(updatable, wrapped.isUpdatable()); + } + properties.verify(() -> NativeBlockProperties.isUpdatable(state), times(1)); + properties.verify(() -> NativeBlockProperties.isStorage(state), times(2)); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBootstrapTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBootstrapTest.java new file mode 100644 index 000000000..5eddc8968 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeBootstrapTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.probe; + +import net.minecraft.tags.BlockTags; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import java.util.Map; +import net.minecraft.world.level.block.Blocks; +import org.junit.Test; +import org.bukkit.Bukkit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class HeadlessNativeBootstrapTest { + @Test + public void bindsNativeItemComponentsWithoutStartingServer() throws Exception { + HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get()); + for (Item item : BuiltInRegistries.ITEM) { + ItemStack stack = new ItemStack(item); + if (item != Items.AIR) { + assertTrue(stack.getMaxStackSize() > 0); + } + } + assertNotNull(new ItemStack(Items.APPLE).get(DataComponents.FOOD)); + assertEquals(64, new ItemStack(Items.TRIAL_KEY).getMaxStackSize()); + assertTrue(Bukkit.getServer() == null); + } + + @Test + public void loadsActualNativeTagsWithoutStartingServer() throws Exception { + HeadlessNativeBootstrap.initialize(); + assertTrue(Blocks.OAK_LOG.defaultBlockState().is(BlockTags.LOGS)); + assertTrue(Blocks.OAK_LEAVES.defaultBlockState().is(BlockTags.LEAVES)); + assertTrue(Blocks.STONE.defaultBlockState().is(BlockTags.BLOCKS_MOTION)); + assertFalse(Blocks.WATER.defaultBlockState().is(BlockTags.BLOCKS_MOTION)); + assertFalse(Blocks.SHORT_GRASS.defaultBlockState().is(BlockTags.BLOCKS_MOTION)); + assertTrue(Bukkit.getServer() == null); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistriesTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistriesTest.java new file mode 100644 index 000000000..0f5b1bf57 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistriesTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.Holder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.structure.Structure; +import org.junit.Test; + + +import static org.junit.Assert.assertTrue; + +public final class HeadlessNativeRegistriesTest { + @Test + public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception { + RegistryAccess registries = HeadlessNativeTestRegistries.get(); + Structure swampHut = registries.lookupOrThrow(Registries.STRUCTURE) + .getValue(Identifier.parse("minecraft:swamp_hut")); + Holder swamp = registries.lookupOrThrow(Registries.BIOME) + .getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse("minecraft:swamp"))); + assertTrue(swampHut.biomes().contains(swamp)); + Holder custom = registries.lookupOrThrow(Registries.BIOME) + .getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse("iris:coherent_swamp"))); + assertTrue(swampHut.biomes().contains(custom)); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistryTest.java new file mode 100644 index 000000000..6ecc541d9 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeRegistryTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess; +import net.minecraft.core.registries.Registries; +import org.bukkit.Bukkit; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Map; +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +public final class HeadlessNativeRegistryTest { + @BeforeClass + public static void bootstrap() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void corePlatformUsesNativeResolutionWithoutBiomeFallback() throws Exception { + NativeRegionTerrainWriter writer = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"), writer::registries); + assertEquals("headless-native-core", platform.platformName()); + assertTrue(platform.block("minecraft:stone").isSolid()); + assertFalse(platform.block("minecraft:short_grass").isSolid()); + assertTrue(platform.block("minecraft:oak_log").isTreeBlock()); + assertTrue(platform.biomeWriter().biomeIdFor("minecraft:plains") >= 0); + assertThrows(IllegalStateException.class, () -> platform.biomeWriter().biomeIdFor("iris:unknown")); + assertThrows(UnsupportedOperationException.class, platform::lootTableKeys); + } + + @Test + public void resolvesNativeDynamicAndStaticRegistriesWithoutServer() throws Exception { + NativeRegionTerrainWriter writer = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration( + writer::registries, writer::registries, message -> { + throw new AssertionError("Unavailable registry: " + message); + })); + assertNull(Bukkit.getServer()); + assertEquals("iris:headless/test", access.biome("iris:headless/test").key()); + assertNotNull(access.biome("minecraft:plains")); + assertNull(access.biome("iris:unknown")); + assertTrue(access.structureKeys().contains("minecraft:mineshaft")); + assertTrue(access.enchantmentKeys().contains("minecraft:fortune")); + assertEquals("minecraft:diamond_sword", access.item("DIAMOND SWORD").key()); + assertEquals("creature", access.entity("minecraft:cow").spawnCategory()); + assertTrue(access.blockStateProperties().containsKey("minecraft:oak_log")); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeStateTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeStateTest.java new file mode 100644 index 000000000..6c998b554 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeStateTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.BeforeClass; +import org.junit.Test; + +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 final class HeadlessNativeStateTest { + @BeforeClass + public static void bootstrap() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void nativeMaterialsDistinguishDecorationsFluidsGlassAndBlockEntities() { + assertFalse(NativeBlockResolver.strictParse("minecraft:short_grass").isSolid()); + assertTrue(NativeBlockResolver.strictParse("minecraft:glass").isSolid()); + assertFalse(NativeBlockResolver.strictParse("minecraft:glass").isOccluding()); + assertTrue(NativeBlockResolver.strictParse("minecraft:stone").isOccluding()); + assertTrue(NativeBlockResolver.strictParse("minecraft:oak_log").isTreeBlock()); + assertTrue(NativeBlockResolver.strictParse("minecraft:oak_leaves").isTreeBlock()); + assertTrue(NativeBlockResolver.strictParse("minecraft:chest").hasTileEntity()); + NativeBlockState slab = NativeBlockResolver.strictParse("minecraft:oak_slab[waterlogged=true]"); + assertTrue(slab.isWaterLogged()); + assertFalse(slab.isFluid()); + assertTrue(NativeBlockResolver.strictParse("minecraft:water").isFluid()); + } + + @Test + public void canonicalStateKeysRoundTripEveryNativeDefaultState() { + for (Block block : BuiltInRegistries.BLOCK) { + BlockState state = block.defaultBlockState(); + ModdedBlockState decoded = NativeBlockResolver.strictParse(ModdedBlockState.serialize(state)); + assertEquals(state, decoded.handle()); + } + assertThrows(IllegalArgumentException.class, + () -> NativeBlockResolver.strictParse("minecraft:oak_log[axis=invalid]")); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTestRegistries.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTestRegistries.java new file mode 100644 index 000000000..73af3026f --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTestRegistries.java @@ -0,0 +1,66 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.terrain.IrisDimensionType; +import art.arcane.iris.generation.terrain.IrisDimensionTypeOptions; +import art.arcane.iris.pack.datapack.IDataFixer; +import art.arcane.iris.pack.datapack.v263.DataFixerV263; +import com.mojang.serialization.JsonOps; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.registries.VanillaRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; + +final class HeadlessNativeTestRegistries { + private static RegistryAccess registries; + + private HeadlessNativeTestRegistries() { + } + + static synchronized RegistryAccess get() { + if (registries != null) { + return registries; + } + HeadlessNativeBootstrap.initialize(); + HolderLookup.Provider vanilla = VanillaRegistries.createWorldLookup(); + String plains = biome(vanilla, "minecraft:plains"); + String swamp = biome(vanilla, "minecraft:swamp"); + Map definitions = Map.of( + "data/iris/worldgen/biome/native_test.json", generatedBiome(), + "data/iris/dimension_type/native_test.json", generatedDimension(), + "data/iris/worldgen/biome/biomes/test.json", plains, + "data/iris/worldgen/biome/headless/test.json", plains, + "data/iris/worldgen/biome/coherent_swamp.json", swamp, + "data/minecraft/tags/worldgen/biome/has_structure/swamp_hut.json", + "{\"replace\":false,\"values\":[\"iris:coherent_swamp\"]}"); + try (HeadlessNativeRegistries loaded = HeadlessNativeRegistries.load(definitions)) { + registries = loaded.registries(); + return registries; + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } + + static String generatedBiome() { + return new IrisBiomeCustom().setId("native_test").generateJson(new DataFixerV263()); + } + + static String generatedDimension() { + return new IrisDimensionType(IDataFixer.Dimension.OVERWORLD, + new IrisDimensionTypeOptions(), 768, 768, -256).toJson(new DataFixerV263()); + } + + private static String biome(HolderLookup.Provider vanilla, String key) { + Biome biome = vanilla.lookupOrThrow(Registries.BIOME).getOrThrow( + ResourceKey.create(Registries.BIOME, Identifier.parse(key))).value(); + return Biome.DIRECT_CODEC.encodeStart(vanilla.createSerializationContext(JsonOps.INSTANCE), biome) + .getOrThrow().toString(); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTileTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTileTest.java new file mode 100644 index 000000000..78eb64c05 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTileTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileData; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedTileData; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import java.util.Map; +import java.util.List; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.data.registries.VanillaRegistries; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public final class HeadlessNativeTileTest { + @BeforeClass + public static void bootstrap() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void nativeTileBinaryRetainsTypedPayloadWithoutWorld() throws Exception { + NativeTileData captured = NativeTileData.capture("minecraft:player_head", + "{id:\"minecraft:skull\",profile:{id:[I;1,2,3,4]},note:[B;5b,6b]}", + message -> { throw new AssertionError(message); }); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + captured.toBinary(output); + } + NativeTileReader reader = new NativeTileReader(VanillaRegistries::createWorldLookup); + NativeTileData decoded; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + decoded = reader.read(input); + } + CompoundTag payload = decoded.payload(); + assertEquals(captured.payload(), payload); + assertArrayEquals(new int[]{1, 2, 3, 4}, payload.getCompoundOrEmpty("profile").getIntArray("id").orElseThrow()); + assertArrayEquals(new byte[]{5, 6}, payload.getByteArray("note").orElseThrow()); + assertEquals(captured, decoded); + } + + @Test + public void writesNativeTilePayloadIntoChunkAndRejectsMismatchedBlocks() throws Exception { + ModdedTileData tile = ModdedTileData.capture("minecraft:chest", + "{LootTable:'minecraft:chests/simple_dungeon',LootTableSeed:17L}"); + HeadlessNativeTiles.Placement placement = new HeadlessNativeTiles.Placement(2, 4, 6, + new BlockPos(-32, -64, 48), ModdedBlockState.of(Blocks.CHEST.defaultBlockState(), Map.of()), tile); + NativeRegionTerrainWriter.TileInput input = HeadlessNativeTiles.prepare(placement); + NativeRegionTerrainWriter writer = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + CompoundTag chunk = writer.encode(new NativeRegionTerrainWriter.TerrainInput(-2, 3, -64, 16, + (x, y, z) -> x == 2 && y == 4 && z == 6 ? Blocks.CHEST.defaultBlockState() : Blocks.AIR.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of(input))); + CompoundTag entity = chunk.getListOrEmpty("block_entities").getCompoundOrEmpty(0); + assertEquals("minecraft:chest", entity.getStringOr("id", "")); + assertEquals(17L, entity.getLongOr("LootTableSeed", 0)); + assertEquals(-60, entity.getIntOr("y", 0)); + assertThrows(IllegalStateException.class, () -> HeadlessNativeTiles.prepare(new HeadlessNativeTiles.Placement( + 2, 4, 6, placement.origin(), ModdedBlockState.of(Blocks.BARREL.defaultBlockState(), Map.of()), tile))); + } + +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTiles.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTiles.java new file mode 100644 index 000000000..1ae1430ae --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessNativeTiles.java @@ -0,0 +1,62 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.modded.ModdedTileData; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.ArrayList; +import java.util.List; + +final class HeadlessNativeTiles { + private HeadlessNativeTiles() { + } + + static List capture(Engine engine, + RealPackProbeSupport.GeneratedChunk chunk, + BlockPos origin) { + List tiles = new ArrayList<>(); + engine.getMantle().getMantle().iterateChunk(origin.getX() >> 4, origin.getZ() >> 4, + TileWrapper.class, (x, y, z, wrapper) -> tiles.add(prepare(new Placement(x, y, z, origin, + chunk.blocks().get(x, y, z), wrapper.getData())))); + return List.copyOf(tiles); + } + + static NativeRegionTerrainWriter.TileInput prepare(Placement placement) { + if (!(placement.tile() instanceof ModdedTileData tile) + || !(placement.state() instanceof ModdedBlockState original)) { + throw new IllegalStateException("Native tile export requires native block and tile data"); + } + NativeTileData data = tile.nativeData(); + BlockState state = data.adjustBlockState(original.handle()); + BlockPos position = placement.origin().offset(placement.x(), placement.y(), placement.z()); + if (!(state.getBlock() instanceof EntityBlock block)) { + throw new IllegalStateException("Tile data has no native block entity at " + position); + } + BlockEntity entity = block.newBlockEntity(position, state); + if (entity == null || !data.isApplicable(state, entity)) { + throw new IllegalStateException("Tile data does not match native block at " + position); + } + try { + CompoundTag payload = data.payload(); + if (payload == null) { + throw new IllegalStateException("Tile data has no native payload at " + position); + } + return new NativeRegionTerrainWriter.TileInput(placement.x(), placement.y(), placement.z(), + ModdedBlockState.serialize(state), payload); + } catch (Exception failure) { + throw new IllegalStateException("Cannot export native tile payload at " + position, failure); + } + } + + record Placement(int x, int y, int z, BlockPos origin, NativeBlockState state, TileData tile) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessRegionTerrainTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessRegionTerrainTest.java new file mode 100644 index 000000000..5ef4f7939 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessRegionTerrainTest.java @@ -0,0 +1,191 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.spigotmc.SpigotWorldConfig; + +import java.io.DataInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public final class HeadlessRegionTerrainTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void producesNativeTerrainAndFullCompletionHaloWithoutAServer() throws Exception { + Path root = temporary.newFolder().toPath(); + Path configuration = root.resolve("spigot.yml"); + Files.writeString(configuration, "config-version: 13\nworld-settings:\n default:\n verbose: false\n"); + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + pack().toFile(), "main", "[headless-native-grid-test]", runtime, temporary.getRoot().toPath())); + RealPackProbeSupport.EngineSession session = workspace.openEngine(69420L, false, "native-grid")) { + NativeStructureVolume volume = NativeStructureVolume.of("minecraft:swamp_hut", 0, 20, 0, 7, 30, 7); + NativeStructureVolumeIndex.install(session.engine(), (engine, x, z) -> + x == 0 && z == 0 ? new KList<>(List.of(volume)) : NativeStructureVolume.NONE); + assertEquals(List.of(volume), session.engine().getNativeStructureVolumes(0, 0, 15, 15)); + SpigotWorldConfig spigot = HeadlessStructureConfiguration.load(new HeadlessStructureConfiguration.Options( + configuration, "probe", Level.OVERWORLD, root.resolve("configuration"))); + try (HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options( + session.engine(), runtime, root.resolve("templates"), Level.OVERWORLD, + NoiseGeneratorSettings.OVERWORLD, spigot))) { + HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(RegionGenerationWindow.Policy.EMBEDDED); + HeadlessRegionTerrain.Result result = rolling.generate( + new HeadlessRegionTerrain.Request(context, -3, 2, 1, 4), System.out::println); + OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration( + workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration, + "probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export")); + HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea(); + assertEquals(-80L, bounds.minimumBlockX()); + assertEquals(0L, bounds.minimumBlockZ()); + assertEquals(15L, bounds.maximumBlockX()); + assertEquals(111L, bounds.maximumBlockZ()); + assertEquals(1, result.targetChunks()); + assertEquals(529, result.chunks().size()); + long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count(); + long biomes = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.BIOMES).count(); + assertEquals(25L, terrain); + assertEquals(24L, biomes); + ProtoChunk target = result.chunks().stream().filter(chunk -> chunk.getPos().equals(new ChunkPos(-3, 2))) + .findFirst().orElseThrow(); + CompoundTag tag = HeadlessChunkSerialization.copyOf(target, context.serialization()).write(); + assertEquals("minecraft:terrain", tag.getStringOr("Status", "")); + assertFalse(tag.getBooleanOr("isLightOn", false)); + assertTrue(tag.getCompoundOrEmpty("Heightmaps").contains("WORLD_SURFACE_WG")); + assertTrue(tag.getCompoundOrEmpty("Heightmaps").contains("OCEAN_FLOOR_WG")); + assertEquals(6, tag.getCompoundOrEmpty("Heightmaps").size()); + assertTrue(tag.getCompoundOrEmpty("ChunkBukkitValues").contains("iris:natural_terrain")); + assertTrue(tag.getCompoundOrEmpty("ChunkBukkitValues").contains("iris:structure_activation")); + assertFalse(tag.getListOrEmpty("sections").isEmpty()); + assertTrue(RealPackProbeSupport.drainReported().isEmpty()); + assertNull(Bukkit.getServer()); + HeadlessRegionTerrain.Result repeated = rolling.generate( + new HeadlessRegionTerrain.Request(context, -3, 2, 1, 4), System.out::println); + assertEquals(0, repeated.updated().size()); + HeadlessRegionTerrain.Result adjacent = rolling.generate( + new HeadlessRegionTerrain.Request(context, -2, 2, 1, 4), System.out::println); + assertEquals(529, adjacent.chunks().size()); + assertTrue(adjacent.updated().size() < result.updated().size()); + ProtoChunk retained = adjacent.chunks().stream().filter(chunk -> chunk.getPos().equals(target.getPos())) + .findFirst().orElseThrow(); + assertSame(target, retained); + assertEquals(ChunkStatus.TERRAIN, retained.getPersistedStatus()); + Path regionDirectory = Files.createDirectory(root.resolve("region")); + OfflineRegionWriter writer = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, regionDirectory, 4, RegionGenerationWindow.Policy.EMBEDDED)); + OfflineRegionWriter.Result firstWrite = writer.write(result); + assertEquals(result.updated().size(), firstWrite.chunks()); + assertEquals(tag, read(regionDirectory, target.getPos())); + OfflineRegionWriter.Result duplicateWrite = writer.write(result); + assertEquals(0, duplicateWrite.chunks()); + OfflineRegionWriter reopened = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, regionDirectory, 4, RegionGenerationWindow.Policy.EMBEDDED)); + assertEquals(0, reopened.write(result).chunks()); + target.setPersistedStatus(ChunkStatus.STRUCTURE_STARTS); + try { + assertEquals(0, writer.write( + new HeadlessRegionTerrain.Result(List.of(target), List.of(target), 1)).chunks()); + } finally { + target.setPersistedStatus(ChunkStatus.TERRAIN); + } + HeadlessRegionTerrain.Result nextRow = rolling.generate( + new HeadlessRegionTerrain.Request(context, -2, 3, 1, 4), System.out::println); + assertEquals(529, nextRow.chunks().size()); + assertSame(target, nextRow.chunks().stream().filter(chunk -> chunk.getPos().equals(target.getPos())) + .findFirst().orElseThrow()); + assertTrue(nextRow.updated().size() < result.updated().size()); + HeadlessRegionTerrain.Result distant = rolling.generate( + new HeadlessRegionTerrain.Request(context, 20, 2, 1, 4), System.out::println); + assertEquals(529, distant.chunks().size()); + ProtoChunk previousHalo = distant.chunks().getFirst(); + HeadlessRegionTerrain.Result back = rolling.generate( + new HeadlessRegionTerrain.Request(context, -3, 2, 1, 4), System.out::println); + assertFalse(back.chunks().contains(previousHalo)); + ProtoChunk regenerated = back.chunks().stream().filter(chunk -> chunk.getPos().equals(target.getPos())) + .findFirst().orElseThrow(); + CompoundTag regeneratedTag = HeadlessChunkSerialization.copyOf(regenerated, context.serialization()).write(); + for (String key : List.of("sections", "Heightmaps", "structures", "ChunkBukkitValues")) { + assertEquals(key, tag.get(key), regeneratedTag.get(key)); + } + assertEquals(0, writer.write(back).chunks()); + Path oversizedDirectory = Files.createDirectory(root.resolve("oversized")); + OfflineRegionWriter oversizedWriter = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, oversizedDirectory, 4, RegionGenerationWindow.Policy.EMBEDDED)); + NamespacedKey payloadKey = new NamespacedKey("iris", "serialization_test_payload"); + byte[] payload = new byte[2 * 1024 * 1024]; + new Random(69420L).nextBytes(payload); + regenerated.persistentDataContainer.set(payloadKey, PersistentDataType.BYTE_ARRAY, payload); + HeadlessRegionTerrain.Result singleton = new HeadlessRegionTerrain.Result(List.of(regenerated), List.of(regenerated), 1); + assertEquals(1, oversizedWriter.write(singleton).chunks()); + assertTrue(Files.isRegularFile(oversizedDirectory.resolve("c.-3.2.mcc"))); + assertArrayEquals(payload, read(oversizedDirectory, regenerated.getPos()) + .getCompoundOrEmpty("ChunkBukkitValues").getByteArray("iris:serialization_test_payload").orElseThrow()); + regenerated.persistentDataContainer.remove(payloadKey); + Path failedDirectory = Files.createDirectory(root.resolve("failed")); + OfflineRegionWriter failedWriter = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, failedDirectory, 4, RegionGenerationWindow.Policy.EMBEDDED)); + regenerated.setPersistedStatus(ChunkStatus.FULL); + try { + assertThrows(Exception.class, () -> failedWriter.write(singleton)); + } finally { + regenerated.setPersistedStatus(ChunkStatus.TERRAIN); + } + assertEquals(1, failedWriter.write(singleton).chunks()); + assertEquals("minecraft:terrain", read(failedDirectory, regenerated.getPos()).getStringOr("Status", "")); + } + } + } + + private static CompoundTag read(Path directory, ChunkPos position) throws Exception { + Path path = directory.resolve("r." + (position.x() >> 5) + "." + (position.z() >> 5) + ".mca"); + try (RegionFile region = new RegionFile(new RegionStorageInfo("test", Level.OVERWORLD, "chunk"), path, directory, false); + DataInputStream input = region.getChunkDataInputStream(position)) { + return NbtIo.read(input); + } + } + + private Path pack() throws Exception { + Path pack = temporary.newFolder().toPath(); + for (String directory : List.of("dimensions", "regions", "biomes", "generators")) { + Files.createDirectories(pack.resolve(directory)); + } + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256, + "landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/swamp.json"), """ + {"derivative":"minecraft:swamp","generators":[{"generator":"flat","min":35,"max":35}]} + """); + return pack; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructureConfigurationTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructureConfigurationTest.java new file mode 100644 index 000000000..c85141efe --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructureConfigurationTest.java @@ -0,0 +1,163 @@ +package art.arcane.iris.probe; + +import net.minecraft.world.level.Level; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.Rule; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.spigotmc.SpigotConfig; +import org.spigotmc.SpigotWorldConfig; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Comparator; +import java.util.Locale; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +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 HeadlessStructureConfigurationTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @BeforeClass + public static void initializeNative() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void nativeConstructorInitializesEveryDefaultWithoutChangingTheSourceOrGlobals() throws Exception { + Path source = temporary.newFile("spigot.yml").toPath(); + Path staging = temporary.newFolder().toPath(); + byte[] original = Files.readAllBytes(source); + YamlConfiguration previous = SpigotConfig.config; + Object previousFile = nativeField("CONFIG_FILE").get(null); + int previousVersion = nativeField("version").getInt(null); + SpigotWorldConfig configuration = HeadlessStructureConfiguration.load(options(source, staging)); + assertEquals(SpigotWorldConfig.class, configuration.getClass()); + int checked = 0; + for (Field field : seedFields()) { + if (field.getType() == int.class) { + assertTrue(field.getName(), field.getInt(configuration) > 0); + } else { + assertNull(field.getName(), field.get(configuration)); + } + checked++; + } + assertTrue(checked >= 22); + assertEquals(100, configuration.cactusModifier); + assertArrayEquals(original, Files.readAllBytes(source)); + assertSame(previous, SpigotConfig.config); + assertEquals(previousFile, nativeField("CONFIG_FILE").get(null)); + assertEquals(previousVersion, nativeField("version").getInt(null)); + assertEmpty(staging); + } + + @Test + public void everyNativeSeedHonorsDefaultAndNamespacedOverrides() throws Exception { + Path source = temporary.newFile("overrides.yml").toPath(); + Path staging = temporary.newFolder().toPath(); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("config-version", 13); + int ordinal = 0; + for (Field field : seedFields()) { + String path = seedPath(field); + yaml.set("world-settings.default." + path, 12000 + ordinal); + yaml.set("world-settings.minecraft:overworld." + path, + field.getType() == Long.class ? 9000000000L + ordinal : 24000L + ordinal); + ordinal++; + } + yaml.set("world-settings.default.verbose", true); + yaml.set("world-settings.legacy.seed-swamp", 9); + yaml.save(source.toFile()); + byte[] original = Files.readAllBytes(source); + SpigotWorldConfig configured = HeadlessStructureConfiguration.load(options(source, staging)); + ordinal = 0; + for (Field field : seedFields()) { + assertEquals(field.getName(), field.getType() == Long.class ? 9000000000L + ordinal : 24000L + ordinal, + ((Number) field.get(configured)).longValue()); + ordinal++; + } + assertArrayEquals(original, Files.readAllBytes(source)); + yaml.set("world-settings.minecraft:overworld", null); + yaml.save(source.toFile()); + SpigotWorldConfig defaults = HeadlessStructureConfiguration.load(options(source, staging)); + ordinal = 0; + for (Field field : seedFields()) { + assertEquals(field.getName(), 12000L + ordinal++, ((Number) field.get(defaults)).longValue()); + } + assertEmpty(staging); + } + + @Test + public void legacyMigrationRetainsNativePrecedenceAndFailuresRestoreState() throws Exception { + Path source = temporary.newFile("legacy.yml").toPath(); + Path staging = temporary.newFolder().toPath(); + Files.writeString(source, """ + config-version: 12 + world-settings: + default: + seed-swamp: 11 + legacy: + seed-swamp: 73 + verbose: true + minecraft:overworld: + seed-swamp: 99 + """); + byte[] original = Files.readAllBytes(source); + assertEquals(73, HeadlessStructureConfiguration.load(options(source, staging)).swampSeed); + assertArrayEquals(original, Files.readAllBytes(source)); + assertEquals(99, HeadlessStructureConfiguration.load(new HeadlessStructureConfiguration.Options( + source, "minecraft:overworld", Level.OVERWORLD, staging)).swampSeed); + assertArrayEquals(original, Files.readAllBytes(source)); + YamlConfiguration previous = SpigotConfig.config; + Object previousFile = nativeField("CONFIG_FILE").get(null); + int previousVersion = nativeField("version").getInt(null); + for (String invalid : List.of("world-settings: [broken", "world-settings:\n default:\n seed-stronghold: '2.5'\n")) { + Files.writeString(source, invalid); + IOException failure = assertThrows(IOException.class, + () -> HeadlessStructureConfiguration.load(options(source, staging))); + assertNotNull(failure.getCause()); + assertEquals(invalid, Files.readString(source)); + assertSame(previous, SpigotConfig.config); + assertEquals(previousFile, nativeField("CONFIG_FILE").get(null)); + assertEquals(previousVersion, nativeField("version").getInt(null)); + assertEmpty(staging); + } + } + + private static HeadlessStructureConfiguration.Options options(Path source, Path staging) { + return new HeadlessStructureConfiguration.Options(source, "legacy", Level.OVERWORLD, staging); + } + + private static List seedFields() { + return Stream.of(SpigotWorldConfig.class.getFields()).filter(field -> field.getName().endsWith("Seed")) + .sorted(Comparator.comparing(Field::getName)).toList(); + } + + private static String seedPath(Field field) { + return "seed-" + field.getName().substring(0, field.getName().length() - 4).toLowerCase(Locale.ROOT); + } + + private static Field nativeField(String name) throws Exception { + Field field = SpigotConfig.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + private static void assertEmpty(Path directory) throws IOException { + try (Stream entries = Files.list(directory)) { + assertEquals(0L, entries.count()); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructurePlannerTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructurePlannerTest.java new file mode 100644 index 000000000..f13ba15f1 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessStructurePlannerTest.java @@ -0,0 +1,366 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.structure.nativegen.IrisNativeStructure; +import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision; +import art.arcane.iris.structure.nativegen.NativeStructureGenerationStatus; +import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord; +import art.arcane.iris.structure.nativegen.NativeStructureStartPlan; +import art.arcane.iris.structure.placement.IrisStructurePlacement; +import art.arcane.iris.structure.placement.IrisStructureTerrain; +import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; +import art.arcane.volmlib.nativelib.terrain.structure.StructureFingerprint; +import art.arcane.volmlib.nativelib.terrain.structure.StructureInjectionPolicy; +import art.arcane.volmlib.nativelib.terrain.structure.StructurePlacementDecision; +import art.arcane.volmlib.nativelib.terrain.structure.StructureReferencePolicy; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureFactory; +import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureOwnershipFingerprint; +import art.arcane.volmlib.util.structure.StructureTerrainMode; +import com.mojang.serialization.Lifecycle; +import net.kyori.adventure.key.Key; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.HolderSet; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.registries.VanillaRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.FixedBiomeSource; +import net.minecraft.world.level.biome.MultiNoiseBiomeSource; +import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.spigotmc.SpigotWorldConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +public class HeadlessStructurePlannerTest { + private static final long SEED = 69420L; + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + private HolderLookup.Provider lookup; + private MappedRegistry biomes; + private Holder.Reference biome; + private RandomState random; + private ChunkGenerator generator; + + @Before + public void initialize() { + HeadlessNativeBootstrap.initialize(); + lookup = VanillaRegistries.createWorldLookup(); + biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + Biome value = lookup.lookupOrThrow(Registries.BIOME).getOrThrow( + ResourceKey.create(Registries.BIOME, Identifier.parse("minecraft:plains"))).value(); + biome = biomes.register(ResourceKey.create(Registries.BIOME, Identifier.parse("minecraft:plains")), + value, RegistrationInfo.BUILT_IN); + biomes.freeze(); + Holder.Reference settings = lookup.lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(NoiseGeneratorSettings.OVERWORLD); + random = RandomState.create(lookup.lookupOrThrow(Registries.NOISE), SEED, settings.value()); + generator = new NoiseBasedChunkGenerator(new FixedBiomeSource(biome), settings); + } + + @Test + public void weightedFailedTrialsMatchNativeSelectionAndClimateAtNegativeCoordinates() throws Exception { + generator = new NoiseBasedChunkGenerator(MultiNoiseBiomeSource.createFromPreset( + lookup.lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST) + .getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD)), + lookup.lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow(NoiseGeneratorSettings.OVERWORLD)); + List attempts = new ArrayList<>(); + Set climates = new HashSet<>(); + MappedRegistry structures = new MappedRegistry<>(Registries.STRUCTURE, Lifecycle.stable()); + List entries = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + String key = "probe:rejected_" + index; + Holder.Reference holder = structures.register(ResourceKey.create(Registries.STRUCTURE, + Identifier.parse(key)), new RejectedStructure( + HolderSet.direct(List.copyOf(generator.getBiomeSource().possibleBiomes())), key, attempts), RegistrationInfo.BUILT_IN); + entries.add(new StructureSet.StructureSelectionEntry(holder, index + 1)); + } + structures.freeze(); + RegistryAccess registries = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, structures)); + ChunkGeneratorStructureState state = state(entries, "probe:weighted", 18373); + try (MultiPackResourceManager resources = resources(); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(temporary.newFolder().toPath()).createAccess("world")) { + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + HeadlessStructurePlanner.NativeContext context = new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, new WorldOptions(SEED, true, false), true); + for (ChunkPos position : List.of(new ChunkPos(-17, 31), new ChunkPos(0, 0), new ChunkPos(17, -31))) { + attempts.clear(); + generator.createStructures(registries, state, + new StructureManager(null, new WorldOptions(SEED, true, false), null), chunk(position, registries), + templates, Level.OVERWORLD); + List nativeAttempts = List.copyOf(attempts); + attempts.clear(); + HeadlessStructurePlanner.generateNaturalStarts(context, chunk(position, registries)); + assertEquals(nativeAttempts, attempts); + assertEquals(4, attempts.size()); + climates.add(attempts.getFirst().split("\\|", 3)[1]); + } + assertTrue(climates.size() > 1); + } + } + + @Test + public void nativePiecesAndConfiguredPlacementSaltArePreserved() throws Exception { + MappedRegistry structures = new MappedRegistry<>(Registries.STRUCTURE, Lifecycle.stable()); + Holder.Reference hut = structures.register(ResourceKey.create(Registries.STRUCTURE, + Identifier.parse("probe:hut")), new SwampHutStructure(new Structure.StructureSettings(HolderSet.direct(biome))), + RegistrationInfo.BUILT_IN); + structures.freeze(); + RegistryAccess registries = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, structures)); + ChunkGeneratorStructureState state = state(List.of(new StructureSet.StructureSelectionEntry(hut, 1)), + "minecraft:villages", 18373); + RandomSpreadStructurePlacement placement = (RandomSpreadStructurePlacement) state.possibleStructureSets().getFirst().value().placement(); + try (MultiPackResourceManager resources = resources(); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(temporary.newFolder().toPath()).createAccess("world")) { + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + HeadlessStructurePlanner.NativeContext context = new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, new WorldOptions(SEED, true, false), true); + ChunkPos position = placement.getPotentialStructureChunk(SEED, -33, 17); + ProtoChunk chunk = chunk(position, registries); + HeadlessStructurePlanner.generateNaturalStarts(context, chunk); + StructureStart expected = hut.value().generate(hut, Level.OVERWORLD, registries, generator, + generator.getBiomeSource(), random.createClimateSampler(SamplerContext.builder().enableCaches().build()), + random, templates, SEED, position, 0, chunk, hut.value().biomes()::contains); + assertTrue(expected.isValid()); + StructurePieceSerializationContext serialization = new StructurePieceSerializationContext(resources, registries, templates); + CompoundTag expectedTag = expected.createTag(serialization, position); + assertEquals(expectedTag, chunk.getStartForStructure(hut.value()).createTag(serialization, position)); + RandomSpreadStructurePlacement configured = new RandomSpreadStructurePlacement(7, 1, RandomSpreadType.LINEAR, 17); + assertEquals(configured.getPotentialStructureChunk(SEED, -33, 17), position); + HeadlessStructurePlanner.NativeContext disabled = new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, new WorldOptions(SEED, false, false), true); + ProtoChunk empty = chunk(position, registries); + HeadlessStructurePlanner.generateNaturalStarts(disabled, empty); + assertTrue(empty.getAllStarts().isEmpty()); + } + } + + private ChunkGeneratorStructureState state(List entries, String key, int salt) { + MappedRegistry sets = new MappedRegistry<>(Registries.STRUCTURE_SET, Lifecycle.stable()); + sets.register(ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), new StructureSet(entries, + new RandomSpreadStructurePlacement(key.equals("minecraft:villages") ? 7 : 1, + key.equals("minecraft:villages") ? 1 : 0, RandomSpreadType.LINEAR, salt)), RegistrationInfo.BUILT_IN); + sets.freeze(); + return ChunkGeneratorStructureState.createForNormal(random, SEED, generator.getOrigin(random), + generator.getBiomeSource(), sets, configuration()); + } + + @Test + public void configuredStartsKeepOwnershipTerrainAndReferencesBeyondPieceBounds() throws Exception { + MappedRegistry structures = new MappedRegistry<>(Registries.STRUCTURE, Lifecycle.stable()); + Holder.Reference hut = structures.register(ResourceKey.create(Registries.STRUCTURE, + Identifier.parse("probe:hut")), new SwampHutStructure(new Structure.StructureSettings(HolderSet.direct(biome))), + RegistrationInfo.BUILT_IN); + structures.freeze(); + RegistryAccess registries = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, structures)); + MappedRegistry sets = new MappedRegistry<>(Registries.STRUCTURE_SET, Lifecycle.stable()); + sets.freeze(); + ChunkGeneratorStructureState state = ChunkGeneratorStructureState.createForNormal(random, SEED, + generator.getOrigin(random), generator.getBiomeSource(), sets, configuration()); + IrisStructureTerrain terrain = new IrisStructureTerrain().setMode(StructureTerrainMode.BORE).setHorizontalPadding(24); + NativeStructureStartPlan plan = new NativeStructureStartPlan( + new IrisStructurePlacement().setUnderground(true).setTerrain(terrain), + new IrisNativeStructure().setStructure("probe:hut"), -1, 0, 30); + TestPolicy policy = new TestPolicy(plan); + HeadlessStructurePlanner.Policies policies = + new HeadlessStructurePlanner.Policies<>(policy, policy, footprint -> true); + try (MultiPackResourceManager resources = resources(); + LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(temporary.newFolder().toPath()).createAccess("world")) { + StructureTemplateManager templates = new StructureTemplateManager(resources, storage, + DataFixers.getDataFixer(), BuiltInRegistries.BLOCK); + HeadlessStructurePlanner.NativeContext context = new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, new WorldOptions(SEED, true, false), true); + Map> grid = new HashMap<>(); + for (int x = -10; x <= 6; x++) { + for (int z = -8; z <= 8; z++) { + ChunkPos position = new ChunkPos(x, z); + grid.put(position.pack(), HeadlessStructurePlanner.plan(context, policies, position)); + } + } + HeadlessStructurePlanner.PlannedChunk origin = grid.get(new ChunkPos(-1, 0).pack()); + StructureStart actual = origin.chunk().getStartForStructure(hut.value()); + StructureStart expected = NativeStructureFactory.generate(new NativeStructureFactory.GenerationContext( + registries, generator, generator.getBiomeSource(), random, templates, SEED, Level.OVERWORLD, + origin.chunk(), selected -> true, generator.getSeaLevel(), policy::surfaceHeight), hut, plan, 0); + StructurePieceSerializationContext serialization = new StructurePieceSerializationContext(resources, registries, templates); + assertEquals(expected.createTag(serialization, origin.chunk().getPos()), actual.createTag(serialization, origin.chunk().getPos())); + assertEquals(30, actual.getBoundingBox().minY()); + assertTrue(NativeStructureOwnershipFingerprint.matches(origin.ownership().get(hut.value()), actual)); + assertEquals(StructureTerrainMode.BORE, origin.terrain().get(hut.value()).resolvedMode()); + HeadlessStructurePlanner.PlannedChunk target = grid.get(new ChunkPos(-2, 0).pack()); + assertFalse(actual.getBoundingBox().intersects(-32, 0, -17, 15)); + Map> incomplete = new HashMap<>(grid); + incomplete.remove(new ChunkPos(-10, -8).pack()); + assertThrows(IllegalArgumentException.class, () -> HeadlessStructurePlanner.references(policies, target, incomplete)); + assertTrue(target.chunk().getAllReferences().isEmpty()); + HeadlessStructurePlanner.references(policies, target, grid); + assertTrue(target.chunk().getAllReferences().get(hut.value()).contains(new ChunkPos(-1, 0).pack())); + assertEquals(ChunkStatus.STRUCTURE_REFERENCES, target.chunk().getPersistedStatus()); + HeadlessStructurePlanner.Policies excluded = + new HeadlessStructurePlanner.Policies<>(policy, policy, footprint -> false); + HeadlessStructurePlanner.PlannedChunk rejected = HeadlessStructurePlanner.plan( + context, excluded, new ChunkPos(-1, 0)); + assertFalse(rejected.chunk().getStartForStructure(hut.value()).isValid()); + assertTrue(rejected.ownership().isEmpty()); + assertTrue(rejected.terrain().isEmpty()); + assertEquals(1, policy.discards); + policy.hideOwnership = true; + assertThrows(IllegalStateException.class, () -> HeadlessStructurePlanner.plan(context, policies, new ChunkPos(-1, 0))); + assertEquals(2, policy.discards); + for (boolean adapterEnabled : new boolean[]{true, false}) { + HeadlessStructurePlanner.NativeContext disabled = new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, + new WorldOptions(SEED, !adapterEnabled, false), adapterEnabled); + HeadlessStructurePlanner.PlannedChunk empty = HeadlessStructurePlanner.plan( + disabled, policies, new ChunkPos(-1, 0)); + assertTrue(empty.chunk().getAllStarts().isEmpty()); + assertTrue(empty.ownership().isEmpty()); + if (!adapterEnabled) { + int scopes = policy.scopes; + HeadlessStructurePlanner.references(policies, empty, Map.of()); + assertEquals(scopes, policy.scopes); + assertTrue(empty.chunk().getAllReferences().isEmpty()); + assertEquals(ChunkStatus.STRUCTURE_REFERENCES, empty.chunk().getPersistedStatus()); + } + } + assertEquals(2, policy.discards); + assertThrows(IllegalArgumentException.class, () -> new HeadlessStructurePlanner.NativeContext( + registries, generator, state, templates, Level.OVERWORLD, + new WorldOptions(SEED + 1, true, false), true)); + } + } + + private static SpigotWorldConfig configuration() { + return new SpigotWorldConfig("probe", Key.key("probe:world")) { + @Override + public void init() { + villageSeed = 17; + } + }; + } + + private static final class TestPolicy implements StructureInjectionPolicy, + StructureReferencePolicy { + private final NativeStructureStartPlan plan; + private NativeStructureOwnershipRecord ownership; + private boolean hideOwnership; + private int discards; + private int scopes; + + private TestPolicy(NativeStructureStartPlan plan) { this.plan = plan; } + @Override public List plansAt(int x, int z) { + return x == plan.chunkX() && z == plan.chunkZ() ? List.of(plan) : List.of(); + } + @Override public boolean sourceReplaced(String key, boolean underground) { return false; } + @Override public int surfaceHeight(int x, int z) { return 80; } + @Override public void record(NativeStructureStartPlan selected, StructureFingerprint fingerprint) { + ownership = NativeStructureOwnershipRecord.capture(fingerprint, selected); + } + @Override public void discard(String key, int x, int z) { ownership = null; discards++; } + @Override public void duplicate(String key) { throw new AssertionError(key); } + @Override public NativeStructureStartPlan matchingPlan(String key, int x, int z) { + return plansAt(x, z).stream().findFirst().orElse(null); + } + @Override public NativeStructureOwnershipRecord findPersisted(String key, int x, int z) { + return !hideOwnership && ownership != null && ownership.structureKey().equals(key) && ownership.originChunkX() == x + && ownership.originChunkZ() == z ? ownership : null; + } + @Override public NativeStructureOwnershipRecord capture(StructureFingerprint fingerprint, NativeStructureStartPlan selected) { + return NativeStructureOwnershipRecord.capture(fingerprint, selected); + } + @Override public void record(NativeStructureOwnershipRecord record) { ownership = record; } + @Override public NativeGenerationScope openOriginScope(int x, int z) { scopes++; return () -> {}; } + @Override public StructurePlacementDecision resolve(String key, boolean underground) { + return new IrisNativeStructureDecision(NativeStructureGenerationStatus.GENERATE_NATIVE, 0, null, + true, null, new IrisStructureTerrain()); + } + @Override public void invalidated(String key) { throw new AssertionError(key); } + } + + private static ProtoChunk chunk(ChunkPos position, RegistryAccess registries) { + return new ProtoChunk(position, UpgradeData.EMPTY, LevelHeightAccessor.create(-64, 384), + PalettedContainerFactory.create(registries), null); + } + + private static MultiPackResourceManager resources() { + return new MultiPackResourceManager(PackType.SERVER_DATA, + List.of(ServerPacksSource.createVanillaPackSource().fullResources())); + } + + private static final class RejectedStructure extends Structure { + private final String key; + private final List attempts; + + private RejectedStructure(HolderSet biomes, String key, List attempts) { + super(new StructureSettings(biomes)); + this.key = key; + this.attempts = attempts; + } + + @Override + protected Optional findGenerationPoint(GenerationContext context) { + int x = context.chunkPos().getMinBlockX() >> 2; + int z = context.chunkPos().getMinBlockZ() >> 2; + attempts.add(key + "|" + context.climateSampler().sample(x, 20, z) + + "|" + context.biomeResolver().getNoiseBiome(x, 20, z).unwrapKey().orElseThrow()); + return Optional.empty(); + } + + @Override + public StructureType type() { + return StructureType.SWAMP_HUT; + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/NativeRegionTerrainWriterTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/NativeRegionTerrainWriterTest.java new file mode 100644 index 000000000..36d905729 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/NativeRegionTerrainWriterTest.java @@ -0,0 +1,183 @@ +package art.arcane.iris.probe; + +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.server.Bootstrap; +import net.minecraft.core.registries.Registries; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.storage.RegionFile; +import net.minecraft.world.level.chunk.storage.RegionStorageInfo; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.DataInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class NativeRegionTerrainWriterTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void nativeRoundTripPreservesNegativeCoordinatesAndPartialStatus() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + CompoundTag tag = codec.encode(new NativeRegionTerrainWriter.TerrainInput(-33, -1, -64, 32, + (x, y, z) -> y < 17 ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of())); + assertEquals("minecraft:empty", tag.getStringOr("Status", "")); + assertEquals(SharedConstants.getCurrentVersion().dataVersion().version(), tag.getIntOr("DataVersion", -1)); + assertFalse(tag.contains("Level")); + assertFalse(tag.getBooleanOr("isLightOn", false)); + assertEquals("iris:terrain_checkpoint", tag.getStringOr("iris:checkpoint", "")); + assertEquals(Blocks.STONE.defaultBlockState(), codec.readBlock(tag, 0, -48, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), codec.readBlock(tag, 0, -47, 0)); + Path directory = Files.createTempDirectory("iris-native-region-test-"); + Path file = directory.resolve("r.-2.-1.mca"); + try { + try (RegionFile region = new RegionFile(new RegionStorageInfo("checkpoint", Level.OVERWORLD, "chunk"), file, directory, false)) { + NativeRegionTerrainWriter.write(region, tag); + } + try (RegionFile region = new RegionFile(new RegionStorageInfo("checkpoint", Level.OVERWORLD, "chunk"), file, directory, false); + DataInputStream input = region.getChunkDataInputStream(new ChunkPos(-33, -1))) { + assertTrue(input != null); + assertEquals(tag, NbtIo.read(input)); + } + } finally { + Files.deleteIfExists(file); + Files.delete(directory); + } + } + + @Test + public void preservesDistinctAirStatesInInitiallyEmptyContainers() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + assertEquals(Blocks.AIR.defaultBlockState(), + PalettedContainerFactory.create(codec.registries()).createForBlockStates().get(0, 0, 0)); + CompoundTag tag = codec.encode(new NativeRegionTerrainWriter.TerrainInput(0, 0, 0, 16, + (x, y, z) -> x == 1 ? Blocks.CAVE_AIR.defaultBlockState() + : x == 2 ? Blocks.VOID_AIR.defaultBlockState() : Blocks.AIR.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of())); + assertEquals(Blocks.AIR.defaultBlockState(), codec.readBlock(tag, 0, 0, 0)); + assertEquals(Blocks.CAVE_AIR.defaultBlockState(), codec.readBlock(tag, 1, 0, 0)); + assertEquals(Blocks.VOID_AIR.defaultBlockState(), codec.readBlock(tag, 2, 0, 0)); + assertTrue(tag.getListOrEmpty("block_entities").isEmpty()); + } + + @Test + public void serializesNativeBlockEntityDefaultsAndCustomPayload() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + CompoundTag payload = new CompoundTag(); + payload.putString("LootTable", "minecraft:chests/simple_dungeon"); + payload.putLong("LootTableSeed", 91L); + NativeRegionTerrainWriter.TileInput tile = new NativeRegionTerrainWriter.TileInput(3, 5, 7, + "minecraft:chest[facing=north,type=single,waterlogged=false]", payload); + CompoundTag tag = codec.encode(new NativeRegionTerrainWriter.TerrainInput(-2, 3, -64, 16, + (x, y, z) -> x == 3 && y == 5 && z == 7 || x == 1 && y == 2 && z == 4 + ? Blocks.CHEST.defaultBlockState() : Blocks.AIR.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of(tile))); + List entities = tag.getListOrEmpty("block_entities").compoundStream().toList(); + assertEquals(2, entities.size()); + CompoundTag custom = entities.stream().filter(entity -> entity.getIntOr("x", 0) == -29) + .findFirst().orElseThrow(); + assertEquals("minecraft:chest", custom.getStringOr("id", "")); + assertEquals(-59, custom.getIntOr("y", 0)); + assertEquals(55, custom.getIntOr("z", 0)); + assertEquals("minecraft:chests/simple_dungeon", custom.getStringOr("LootTable", "")); + assertEquals(91L, custom.getLongOr("LootTableSeed", 0)); + assertEquals("minecraft:empty", tag.getStringOr("Status", "")); + } + + @Test + public void fillsPlannedChunkWithoutDiscardingStatusOrBlockEntities() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + ProtoChunk chunk = new ProtoChunk(new ChunkPos(-2, 3), UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 32), PalettedContainerFactory.create(codec.registries()), null); + chunk.setPersistedStatus(ChunkStatus.BIOMES); + chunk.fillBiomesFromNoise((x, y, z) -> codec.registries().lookupOrThrow(Registries.BIOME) + .getOrThrow(Biomes.SWAMP)); + BlockState chest = Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING, Direction.EAST); + NativeRegionTerrainWriter.TerrainInput input = new NativeRegionTerrainWriter.TerrainInput(-2, 3, -64, 32, + (x, y, z) -> x == 3 && y == 5 && z == 7 ? chest : Blocks.STONE.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of()); + codec.fill(chunk, input); + assertEquals(ChunkStatus.BIOMES, chunk.getPersistedStatus()); + assertEquals(Biomes.SWAMP, + chunk.getNoiseBiome(-8, -16, 12).unwrapKey().orElseThrow()); + assertEquals(Blocks.STONE.defaultBlockState(), chunk.getBlockState(new BlockPos(-32, -33, 48))); + assertEquals(chest, chunk.getBlockState(new BlockPos(-29, -59, 55))); + assertEquals("minecraft:chest", chunk.getBlockEntityNbt(new BlockPos(-29, -59, 55)) + .getStringOr("id", "")); + assertThrows(IllegalArgumentException.class, () -> codec.fill(chunk, + new NativeRegionTerrainWriter.TerrainInput(0, 0, -64, 32, + input.blocks(), input.biomes(), List.of()))); + } + + @Test + public void serializesEveryNativeDefaultBlockEntityWithoutWorld() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + int tested = 0; + for (Block block : BuiltInRegistries.BLOCK) { + if (!block.defaultBlockState().hasBlockEntity()) { + continue; + } + String key = BuiltInRegistries.BLOCK.getKey(block).toString(); + CompoundTag tag = codec.encode(new NativeRegionTerrainWriter.TerrainInput(0, 0, 0, 16, + (x, y, z) -> x == 0 && y == 0 && z == 0 ? block.defaultBlockState() : Blocks.AIR.defaultBlockState(), + (x, y, z) -> "minecraft:plains", List.of())); + int expected = ((EntityBlock) block).newBlockEntity(BlockPos.ZERO, block.defaultBlockState()) == null ? 0 : 1; + assertEquals(key, expected, tag.getListOrEmpty("block_entities").size()); + tested++; + } + assertTrue(tested > 50); + } + + @Test + public void rejectsMissingNativeStatesAndUnregisteredBiomes() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + assertThrows(NullPointerException.class, () -> codec.encode(new NativeRegionTerrainWriter.TerrainInput( + 0, 0, 0, 16, (x, y, z) -> null, (x, y, z) -> "minecraft:plains", List.of()))); + assertThrows(IllegalArgumentException.class, () -> codec.encode(new NativeRegionTerrainWriter.TerrainInput( + 0, 0, 0, 16, (x, y, z) -> Blocks.STONE.defaultBlockState(), (x, y, z) -> "iris:custom_biome", List.of()))); + } + + @Test + public void validatesSectionBounds() throws Exception { + assertThrows(IllegalArgumentException.class, () -> new NativeRegionTerrainWriter.TerrainInput( + 0, 0, -63, 16, (x, y, z) -> Blocks.AIR.defaultBlockState(), (x, y, z) -> "minecraft:plains", List.of())); + } + + @Test + public void preservesDeclaredCustomBiomeKeys() throws Exception { + NativeRegionTerrainWriter codec = new NativeRegionTerrainWriter(HeadlessNativeTestRegistries.get()); + CompoundTag tag = codec.encode(new NativeRegionTerrainWriter.TerrainInput(0, 0, 0, 16, + (x, y, z) -> Blocks.STONE.defaultBlockState(), (x, y, z) -> "iris:biomes/test", List.of())); + assertEquals("iris:biomes/test", tag.getListOrEmpty("sections").getCompoundOrEmpty(0) + .getCompoundOrEmpty("biomes").getListOrEmpty("palette").getStringOr(0, "")); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionGenerationWindowTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionGenerationWindowTest.java new file mode 100644 index 000000000..fa040420d --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionGenerationWindowTest.java @@ -0,0 +1,186 @@ +package art.arcane.iris.probe; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +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 final class RegionGenerationWindowTest { + @Test + public void replenishesWorkersWhileEarlierChunksRemainBlocked() throws Exception { + CountDownLatch replacementStarted = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + List completed = new ArrayList<>(); + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(16, 2, index -> { + int running = active.incrementAndGet(); + maximum.accumulateAndGet(running, Math::max); + try { + if (index == 0 && !replacementStarted.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("A completed worker was not replenished"); + } + if (index == 2) { + replacementStarted.countDown(); + } + return index; + } finally { + active.decrementAndGet(); + } + }, (index, value) -> completed.add(value))); + assertEquals(16, completed.size()); + assertTrue(completed.indexOf(1) < completed.indexOf(0)); + assertTrue(maximum.get() <= 2); + assertEquals(0, active.get()); + } + + @Test + public void thirtyTwoWorkersRemainBoundedWhileReplenishing() throws Exception { + CountDownLatch initialWorkers = new CountDownLatch(32); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(97, 32, index -> { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + if (index < 32) { + initialWorkers.countDown(); + assertTrue(initialWorkers.await(5, TimeUnit.SECONDS)); + } + return index; + } finally { + active.decrementAndGet(); + } + }, (index, value) -> completed.incrementAndGet())); + assertEquals(32, maximum.get()); + assertEquals(97, completed.get()); + assertEquals(0, active.get()); + assertThrows(IllegalArgumentException.class, + () -> new RegionGenerationWindow.Request<>(97, 33, index -> index, (index, value) -> { })); + } + + @Test(timeout = 5000) + public void interruptsAndDrainsOtherWorkersAfterFailure() { + CountDownLatch blocked = new CountDownLatch(1); + AtomicInteger interrupted = new AtomicInteger(); + assertThrows(Exception.class, () -> RegionGenerationWindow.process( + new RegionGenerationWindow.Request<>(2, 2, index -> { + if (index == 0) { + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + throw new IllegalStateException("Expected fixture failure"); + } + blocked.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException failure) { + interrupted.incrementAndGet(); + Thread.currentThread().interrupt(); + } + return index; + }, (index, value) -> { }))); + assertEquals(1, interrupted.get()); + } + + @Test + public void drainsStartedWorkBeforeReportingFailure() { + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicInteger finished = new AtomicInteger(); + assertThrows(Exception.class, () -> RegionGenerationWindow.process( + new RegionGenerationWindow.Request<>(4, 2, index -> { + if (index == 0) { + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + throw new IllegalStateException("Expected fixture failure"); + } + secondStarted.countDown(); + finished.incrementAndGet(); + return index; + }, (index, value) -> { }))); + assertTrue(finished.get() >= 1); + } + + @Test(timeout = 5000) + public void completionTimeoutInterruptsAndDrainsActiveWork() { + AtomicInteger completed = new AtomicInteger(); + RegionGenerationWindow.Policy policy = new RegionGenerationWindow.Policy( + Duration.ofMillis(50), Duration.ofSeconds(1), false); + assertThrows(TimeoutException.class, () -> RegionGenerationWindow.process( + new RegionGenerationWindow.Request<>(1, 1, index -> { + try { + new CountDownLatch(1).await(); + } finally { + completed.incrementAndGet(); + } + return index; + }, (index, value) -> { }), policy)); + assertEquals(1, completed.get()); + } + + @Test(timeout = 10000) + public void standaloneStalledWorkerStopsProcessBeforeResourceCleanup() throws Exception { + Path directory = Files.createTempDirectory("iris-worker-shutdown-"); + Path cleanup = directory.resolve("cleanup"); + String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + String classpath = Path.of(RegionGenerationWindowTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + + File.pathSeparator + + Path.of(RegionGenerationWindow.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", executable).toString(), + "-cp", classpath, UnresponsiveProcess.class.getName(), cleanup.toString()) + .redirectErrorStream(true).start(); + try { + assertTrue("Offline failure did not terminate", process.waitFor(5, TimeUnit.SECONDS)); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(output, 70, process.exitValue()); + assertTrue(output, output.contains("Generation workers did not stop")); + assertTrue(output, output.contains("Expected process fixture failure")); + assertFalse("Resources were closed while generation was active", Files.exists(cleanup)); + } finally { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + Files.deleteIfExists(cleanup); + Files.delete(directory); + } + } + + public static final class UnresponsiveProcess { + public static void main(String[] arguments) throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch never = new CountDownLatch(1); + RegionGenerationWindow.Policy policy = new RegionGenerationWindow.Policy( + Duration.ofSeconds(1), Duration.ofMillis(25), true); + try { + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(2, 2, index -> { + if (index == 0) { + if (!entered.await(1, TimeUnit.SECONDS)) { + throw new IllegalStateException("Worker did not start"); + } + throw new IllegalStateException("Expected process fixture failure"); + } + entered.countDown(); + while (true) { + try { + never.await(); + } catch (InterruptedException ignored) { + continue; + } + return index; + } + }, (index, value) -> { }), policy); + } finally { + Files.writeString(Path.of(arguments[0]), "closed"); + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixture.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixture.java new file mode 100644 index 000000000..0e78faada --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixture.java @@ -0,0 +1,260 @@ +package art.arcane.iris.probe; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.craftbukkit.CraftChunk; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +public final class RegionNativeFixture extends JavaPlugin { + private final AtomicBoolean running = new AtomicBoolean(); + private final LoadCompletions pendingLoads = new LoadCompletions(); + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + + @Override + public void onEnable() { + getLogger().info("Native TERRAIN export fixture ready; console: mcaterrain "); + } + + @Override + public void onDisable() { + pendingLoads.stop(); + worker.shutdown(); + try { + if (!worker.awaitTermination(60, TimeUnit.SECONDS)) { + getLogger().severe("Native terrain export worker did not stop before shutdown"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + getLogger().log(Level.SEVERE, "Interrupted while stopping native terrain export", failure); + } + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { + boolean loading = command.getName().equals("mcaload"); + int expectedArguments = 5; + if (!(sender instanceof ConsoleCommandSender) || arguments.length != expectedArguments) { + sender.sendMessage("Console only: " + command.getName() + " " + + " "); + return true; + } + try { + if (!Bukkit.getWorldContainer().getCanonicalFile().getName().startsWith("mca-")) { + throw new IllegalStateException("The fixture requires a disposable mca-* test instance"); + } + if (!Bukkit.getOnlinePlayers().isEmpty() || Bukkit.getName().toLowerCase(Locale.ROOT).contains("folia")) { + throw new IllegalStateException("The fixture requires an empty Paper test server"); + } + World world = Bukkit.getWorld(arguments[0]); + if (world == null || world.getGenerator() == null) { + throw new IllegalArgumentException("A loaded Iris world is required"); + } + int chunkX = Integer.parseInt(arguments[1]); + int chunkZ = Integer.parseInt(arguments[2]); + int width = Integer.parseInt(arguments[3]); + List plan = NativeRegionPlan.plan(chunkX, chunkZ, width); + if (loading) { + load(new LoadRequest(world, chunkX, chunkZ, width, Integer.parseInt(arguments[4]))); + return true; + } + Path worldRoot = world.getWorldFolder().toPath(); + for (NativeRegionPlan.PlannedChunk chunk : plan) { + if (world.isChunkLoaded(chunk.x(), chunk.z()) || Files.exists(worldRoot.resolve("region") + .resolve("r." + (chunk.x() >> 5) + "." + (chunk.z() >> 5) + ".mca"))) { + throw new IllegalStateException("Native export requires untouched chunk and halo regions"); + } + } + Files.createDirectories(getDataFolder().toPath()); + Path output = getDataFolder().toPath().resolve("terrain-" + chunkX + "-" + chunkZ + "-" + width); + ServerLevel level = ((CraftWorld) world).getHandle(); + DetachedNativeTerrain.Request request = new DetachedNativeTerrain.Request(level, chunkX, chunkZ, width, + Integer.parseInt(arguments[4]), output); + if (Files.exists(output) || !running.compareAndSet(false, true)) { + throw new IllegalStateException("An export is active or the output already exists"); + } + worker.execute(() -> export(request)); + sender.sendMessage("Native TERRAIN export started: " + output); + } catch (Exception failure) { + getLogger().log(Level.SEVERE, "Native TERRAIN export could not start", failure); + } + return true; + } + + private void load(LoadRequest request) throws Exception { + Path completed = getDataFolder().toPath().resolve("terrain-" + request.chunkX() + "-" + request.chunkZ() + + "-" + request.width()).resolve("terrain.properties"); + if (!Files.isRegularFile(completed)) { + throw new IllegalStateException("No completed native TERRAIN export exists for these coordinates"); + } + Properties receipts = new Properties(); + try (Reader reader = Files.newBufferedReader(completed.getParent().resolve("receipts.properties"))) { + receipts.load(reader); + } + if (!running.compareAndSet(false, true)) { + throw new IllegalStateException("A native fixture operation is active"); + } + worker.execute(() -> loadRegion(request, receipts)); + } + + private void loadRegion(LoadRequest request, Properties receipts) { + long started = System.nanoTime(); + int chunks = request.width() * request.width(); + getLogger().info("NATIVE_LOAD_STARTED chunks=" + chunks + " chunkX=" + request.chunkX() + + " chunkZ=" + request.chunkZ() + " workers=" + request.parallelism()); + try { + RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(chunks, request.parallelism(), + index -> { + int x = request.chunkX() + index % request.width(); + int z = request.chunkZ() + index / request.width(); + requestChunk(new ChunkLoad(request.world(), x, z, receipts.getProperty(x + "," + z))).join(); + return index; + }, (index, completed) -> {})); + getLogger().info("NATIVE_LOAD_COMPLETE chunks=" + chunks + " workers=" + request.parallelism() + + " status=full light=true receiptSha256Matches=true seconds=" + + (System.nanoTime() - started) / 1_000_000_000.0); + } catch (Exception failure) { + getLogger().log(Level.SEVERE, "Native terrain reload failed after draining started requests", failure); + } finally { + running.set(false); + } + } + + private CompletableFuture requestChunk(ChunkLoad request) { + CompletableFuture completion = pendingLoads.register(); + if (completion.isDone()) { + return completion; + } + try { + Bukkit.getScheduler().runTask(this, () -> { + if (completion.isDone()) { + return; + } + try { + request.world().getChunkAtAsync(request.x(), request.z(), true) + .whenComplete((chunk, failure) -> completeLoad(new LoadedChunk(request, chunk, failure), completion)); + } catch (RuntimeException failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException failure) { + completion.completeExceptionally(failure); + } + return completion; + } + + private void completeLoad(LoadedChunk loaded, CompletableFuture completion) { + if (completion.isDone()) { + return; + } + if (loaded.failure() != null) { + completion.completeExceptionally(loaded.failure()); + return; + } + if (!Bukkit.isPrimaryThread()) { + try { + Bukkit.getScheduler().runTask(this, () -> completeLoad(loaded, completion)); + } catch (RuntimeException failure) { + completion.completeExceptionally(failure); + } + return; + } + try { + ChunkAccess nativeChunk = ((CraftChunk) loaded.chunk()).getHandle(ChunkStatus.FULL); + if (nativeChunk.getPersistedStatus() != ChunkStatus.FULL || !nativeChunk.isLightCorrect() + || !DetachedNativeTerrain.receiptHash(nativeChunk).equals(loaded.request().receipt())) { + throw new IllegalStateException("Native load did not finish status, light and receipt at " + + loaded.request().x() + "," + loaded.request().z()); + } + completion.complete(null); + } catch (RuntimeException failure) { + completion.completeExceptionally(failure); + } + } + + private void export(DetachedNativeTerrain.Request request) { + try { + DetachedNativeTerrain.Result result = DetachedNativeTerrain.generate(request, getLogger()::info); + String report = String.format(Locale.ROOT, + "stage=minecraft:terrain%ncomplete=false%nterrainChunks=%d%ndependencyChunks=%d%nseconds=%.3f%nworkers=%d%n" + + "remaining=features,lighting,spawn,full%n" + + "world=%s%nseed=%d%n" + + "installation=Stop this test server, retain its world history and mantle, recheck that EVERY destination MCA file is absent, then copy all MCA files without replacement into this same untouched world region directory. Never copy while running.%n", + result.terrainChunks(), result.dependencyChunks(), result.seconds(), request.parallelism(), + request.level().getWorld().getName(), request.level().getSeed()); + Files.writeString(request.output().resolve("terrain.properties"), report); + getLogger().info("NATIVE_TERRAIN_COMPLETE " + report.replace('\n', ' ')); + } catch (Exception failure) { + getLogger().log(Level.SEVERE, "Native TERRAIN export failed; output is incomplete", failure); + } finally { + running.set(false); + } + } + static final class LoadCompletions { + private final Set> pending = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean stopping = new AtomicBoolean(); + + CompletableFuture register() { + CompletableFuture completion = new CompletableFuture<>(); + pending.add(completion); + completion.whenComplete((result, failure) -> pending.remove(completion)); + if (stopping.get()) { + cancel(completion); + } + return completion; + } + + void stop() { + stopping.set(true); + for (CompletableFuture completion : pending) { + cancel(completion); + } + } + + int pendingCount() { + return pending.size(); + } + + private static void cancel(CompletableFuture completion) { + completion.completeExceptionally(new CancellationException("Native fixture is stopping")); + } + } + + private record LoadRequest(World world, int chunkX, int chunkZ, int width, int parallelism) { + private LoadRequest { + if (parallelism < 1 || parallelism > 16) { + throw new IllegalArgumentException("Native load worker count must be 1..16"); + } + } + } + + private record ChunkLoad(World world, int x, int z, String receipt) { + } + + private record LoadedChunk(ChunkLoad request, Chunk chunk, Throwable failure) { + } + +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixtureTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixtureTest.java new file mode 100644 index 000000000..c3e339d56 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/RegionNativeFixtureTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.probe; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class RegionNativeFixtureTest { + @Test + public void stoppingReleasesPendingAndRejectsLaterAdmissions() { + RegionNativeFixture.LoadCompletions requests = new RegionNativeFixture.LoadCompletions(); + CompletableFuture pending = requests.register(); + CompletableFuture completed = requests.register(); + completed.complete(null); + assertEquals(1, requests.pendingCount()); + requests.stop(); + assertTrue(pending.isCompletedExceptionally()); + assertFalse(completed.isCompletedExceptionally()); + assertTrue(requests.register().isCompletedExceptionally()); + assertEquals(0, requests.pendingCount()); + } + + @Test + public void concurrentShutdownLeavesNoUncompletedAdmissions() throws Exception { + for (int round = 0; round < 32; round++) { + RegionNativeFixture.LoadCompletions requests = new RegionNativeFixture.LoadCompletions(); + CountDownLatch start = new CountDownLatch(1); + List>> admissions = new ArrayList<>(); + try (ExecutorService workers = Executors.newFixedThreadPool(8)) { + for (int index = 0; index < 32; index++) { + admissions.add(workers.submit(() -> { + start.await(); + return requests.register(); + })); + } + start.countDown(); + requests.stop(); + for (Future> admission : admissions) { + assertTrue(admission.get(5, TimeUnit.SECONDS).isCompletedExceptionally()); + } + } + assertEquals(0, requests.pendingCount()); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java index d7b2bac8e..bb504508d 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java @@ -103,6 +103,21 @@ public class NativeStructureVolumeIndexTest { assertEquals(coldResolutions, resolver.resolutions()); } + @Test + public void completedOriginsRemainBoundedAndPendingBuildsAreReleased() throws Exception { + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting( + (engine, chunkX, chunkZ) -> NativeStructureVolume.NONE); + for (int chunkX = -8_192; chunkX <= 8_192; chunkX++) { + index.originVolumes(null, chunkX, -17); + } + assertEquals(16_384, cache(index, "originCache").size()); + assertTrue(cache(index, "originBuilds").isEmpty()); + index.resolve(null, -16, -16, -1, -1); + assertEquals(16_384, cache(index, "originCache").size()); + assertTrue(cache(index, "originBuilds").isEmpty()); + assertTrue(cache(index, "queryBuilds").isEmpty()); + } + @Test public void runtimeBucketsStayIsolatedAndRetirementEvictsOnlyTheirEntries() { AtomicInteger runtimeId = new AtomicInteger(1); @@ -282,7 +297,7 @@ public class NativeStructureVolumeIndexTest { } @Test - public void overlappingColdQueriesCoordinateBeforeResolvingOrigins() throws Exception { + public void overlappingColdQueriesResolveIndependentOriginsConcurrently() throws Exception { BlockingResolver resolver = new BlockingResolver(); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); ExecutorService executor = Executors.newFixedThreadPool(2); @@ -291,7 +306,8 @@ public class NativeStructureVolumeIndexTest { assertTrue(resolver.awaitFirstEntry()); Future> adjacent = executor.submit(() -> index.resolve(null, 16, 0, 31, 15)); - assertFalse(resolver.awaitSecondEntry(250)); + assertTrue(resolver.awaitSecondEntry(5_000)); + assertTrue(adjacent.get(5, TimeUnit.SECONDS).isEmpty()); resolver.release(); assertTrue(first.get(5, TimeUnit.SECONDS).isEmpty()); assertTrue(adjacent.get(5, TimeUnit.SECONDS).isEmpty()); @@ -303,12 +319,42 @@ public class NativeStructureVolumeIndexTest { } @Test - public void contendedNativeWindowsAllowQueuedTerrainWorkToRun() throws Exception { + public void overlappingNegativeQueriesPreserveEveryVolumeAndOriginOrder() throws Exception { + BlockingResolver coordination = new BlockingResolver(); + NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> + new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, + -256, 0, -256, 256, 255, 256)); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { + coordination.volumesAt(engine, chunkX, chunkZ); + return volumes.volumesAt(engine, chunkX, chunkZ); + }); + NativeStructureVolumeIndex sequential = NativeStructureVolumeIndex.forTesting(volumes); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> negative = executor.submit( + () -> index.resolve(null, -16, 0, -1, 15)); + assertTrue(coordination.awaitFirstEntry()); + Future> positive = executor.submit( + () -> index.resolve(null, 0, 0, 15, 15)); + assertTrue(coordination.awaitSecondEntry(5_000)); + assertEquals(sequential.resolve(null, 0, 0, 15, 15), positive.get(5, TimeUnit.SECONDS)); + coordination.release(); + assertEquals(sequential.resolve(null, -16, 0, -1, 15), negative.get(5, TimeUnit.SECONDS)); + assertEquals(306, coordination.resolutions()); + } finally { + coordination.release(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void sharedOriginWaitAllowsQueuedTerrainWorkToRun() throws Exception { assertQueuedTerrainCanComplete(false); } @Test - public void interruptedNativeWindowWaitStillAcquiresAndReleasesOnce() throws Exception { + public void interruptedOriginWaitPreservesInterruptAndResult() throws Exception { assertQueuedTerrainCanComplete(true); } @@ -331,7 +377,7 @@ public class NativeStructureVolumeIndexTest { AtomicReference secondWaiter = new AtomicReference<>(); try { Future> original = owner.submit( - () -> index.resolve(null, 0, 0, 15, 15)); + () -> index.originVolumes(null, -6, -8)); assertTrue(ownerEntered.await(5, TimeUnit.SECONDS)); Future> adjacent = workers.submit(() -> { firstWaiter.set(Thread.currentThread()); @@ -343,15 +389,15 @@ public class NativeStructureVolumeIndexTest { secondWaiter.set(Thread.currentThread()); return index.resolve(null, 32, 0, 47, 15); }); - awaitOriginWindowWaiter(firstWaiter); - awaitOriginWindowWaiter(secondWaiter); + awaitOriginWaiter(firstWaiter); + awaitOriginWaiter(secondWaiter); if (interruptWaiter) { firstWaiter.get().interrupt(); } Future terrainTask = workers.submit(() -> terrain.complete(null)); terrainTask.get(3, TimeUnit.SECONDS); - assertEquals(289, original.get(3, TimeUnit.SECONDS).size()); + assertEquals(1, original.get(3, TimeUnit.SECONDS).size()); KList adjacentVolumes = adjacent.get(3, TimeUnit.SECONDS); assertEquals(289, adjacentVolumes.size()); assertEquals(adjacentVolumes, index.resolve(null, 16, 0, 31, 15)); @@ -366,28 +412,28 @@ public class NativeStructureVolumeIndexTest { } } - private static void awaitOriginWindowWaiter(AtomicReference reference) { + private static void awaitOriginWaiter(AtomicReference reference) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); while (System.nanoTime() < deadline) { Thread thread = reference.get(); if (thread != null && thread.getState() == Thread.State.WAITING) { for (StackTraceElement frame : thread.getStackTrace()) { - if (frame.getClassName().equals(NativeStructureVolumeIndex.class.getName()) - && frame.getMethodName().equals("lockOriginWindow")) { + if (frame.getClassName().equals("art.arcane.volmlib.nativelib.terrain.NativeBuildFutures") + && frame.getMethodName().equals("awaitBuild")) { return; } } } LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } - throw new AssertionError("Native query did not wait for the occupied origin window"); + throw new AssertionError("Native query did not wait for the shared origin"); } @Test public void distantColdQueriesCanResolveOriginsConcurrently() throws Exception { BlockingResolver resolver = new BlockingResolver(); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); - int distantChunkX = findDisjointWindow(0, 0); + int distantChunkX = 64; ExecutorService executor = Executors.newFixedThreadPool(2); try { Future> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15)); @@ -478,16 +524,6 @@ public class NativeStructureVolumeIndexTest { ); } - private static int findDisjointWindow(int chunkX, int chunkZ) { - long sourceMask = NativeStructureVolumeIndex.originWindowStripeMask(chunkX, chunkZ); - for (int candidate = 64; candidate < 16_384; candidate += 17) { - if ((sourceMask & NativeStructureVolumeIndex.originWindowStripeMask(candidate, chunkZ)) == 0L) { - return candidate; - } - } - throw new IllegalStateException("Unable to find a disjoint native structure window stripe"); - } - private static final class CountingResolver implements NativeStructureVolumeIndex.OriginResolver { private final AtomicInteger resolutions = new AtomicInteger(); private final int originChunkX; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 831993b86..517c8da0a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -391,7 +391,10 @@ public final class ModdedEngineBootstrap { BlockDataMergeSupport.StateMerger previousMerger = BlockDataMergeSupport.bindPlatformMerger(new NativeStateMerger(ModdedBlockResolution.BLOCKS)::merge); rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(previousMerger)); - NativeTileReader nativeTileReader = new NativeTileReader(boundLoader::currentServer); + NativeTileReader nativeTileReader = new NativeTileReader(() -> { + NativeModdedServer server = boundLoader.currentServer(); + return server == null ? null : server.registryAccess(); + }); TileData.TileReader previousTileReader = TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in))); rollback.add(() -> TileData.restorePlatformReader(previousTileReader)); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 0874bc97f..ba3f55d9d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -38,6 +38,8 @@ import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.core.HolderLookup; + import java.io.File; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -61,13 +63,25 @@ public final class ModdedPlatform implements IrisPlatform { public ModdedPlatform(NativeModdedLoader loader) { this.loader = loader; this.registries = new ModdedRegistries( - new NativeRegistryAccess(ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady), + new NativeRegistryAccess(new NativeRegistryAccess.Configuration( + ModdedPlatform::nativeRegistries, ModdedPlatform::reloadableRegistries, + ModdedRegistries::warnNotReady)), ModdedPlatform::generationRegistry); this.scheduler = new ModdedScheduler(); this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer); this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains")); } + private static HolderLookup.Provider nativeRegistries() { + NativeModdedServer server = ModdedEngineBootstrap.currentServer(); + return server == null ? null : server.registryAccess(); + } + + private static HolderLookup.Provider reloadableRegistries() { + NativeModdedServer server = ModdedEngineBootstrap.currentServer(); + return server == null ? null : server.reloadableRegistries(); + } + public static void errorSink(Consumer sink) { ERROR_SINK = sink; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java index 6beac113e..5c9ebd510 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeRegistryAccessTest.java @@ -25,7 +25,7 @@ public class NativeRegistryAccessTest { @Test public void statePropertiesPreserveTypesDefaultsAndSharedGroups() { - NativeRegistryAccess access = new NativeRegistryAccess(() -> null, message -> {}); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, message -> {})); Map> properties = access.blockStateProperties(); assertSame(properties.get("minecraft:stone"), properties.get("minecraft:dirt")); @@ -41,7 +41,7 @@ public class NativeRegistryAccessTest { @Test public void staticRegistriesResolveWithoutAnActiveServerAndDynamicReadsStayEmpty() { List warnings = new ArrayList<>(); - NativeRegistryAccess access = new NativeRegistryAccess(() -> null, warnings::add); + NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, warnings::add)); assertEquals("minecraft:diamond_sword", access.item("DIAMOND SWORD").key()); assertEquals("creature", access.entity("minecraft:cow").spawnCategory()); diff --git a/build.gradle b/build.gradle index 376c00ed0..1bb89c41a 100644 --- a/build.gradle +++ b/build.gradle @@ -163,6 +163,9 @@ nmsBindings.each { String key -> compileOnly(rootProject.libs.byteBuddy.core) testImplementation('junit:junit:4.13.2') testImplementation('org.mockito:mockito-core:5.23.0') + if (key == 'v26_3_R1') { + testImplementation(project(':probe')) + } testImplementation(project(':core')) testImplementation(testFixtures(project(':core'))) testImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") { @@ -955,3 +958,157 @@ pluginPackaging { } } } + +project(':adapters:bukkit:nms:v26_3_R1') { + Configuration headlessNativeSources = configurations.create('headlessNativeSources') { + canBeConsumed = false + canBeResolved = true + } + dependencies.add(headlessNativeSources.name, + "com.github.VolmitSoftware.VolmLib:native-minecraft26_2-sources:${volmLibCoordinate.tokenize(':').last()}") + List headlessNativeTypes = ['ModdedBlockState', 'ModdedBiome', 'NativeBlockProperties', 'NativeBlockResolver', + 'NativeBlockFaces', 'NativeStateRotator', 'NativeStateMerger', + 'NativeRegistryAccess', 'ModdedItem', 'ModdedEntityType', 'NativeTileReader', 'NativeTileData'] + TaskProvider unpackHeadlessNativeSources = tasks.register('unpackHeadlessNativeSources', Sync) { + dependsOn(headlessNativeSources) + inputs.property('nativeTypes', headlessNativeTypes) + inputs.property('nativeVersion', '26.3') + from(provider { headlessNativeSources.collect { File artifact -> zipTree(artifact) } }) { + headlessNativeTypes.each { String type -> include("art/arcane/volmlib/nativelib/minecraft26_2/modded/${type}.java") } + include('versions/26.3/**') + eachFile { FileCopyDetails source -> + if (source.path.startsWith('versions/26.3/')) { + source.path = source.path.substring('versions/26.3/'.length()) + } + } + } + from(rootProject.file('adapters/modded-common/src/main/java')) { + include('art/arcane/iris/modded/ModdedDecoratorHooks.java', + 'art/arcane/iris/modded/ModdedStateRotator.java', 'art/arcane/iris/modded/ModdedTileData.java') + } + into(layout.buildDirectory.dir('generated/sources/headless-native')) + } + sourceSets { + offline { + java.srcDir(unpackHeadlessNativeSources) + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.compileClasspath + sourceSets.main.output + } + } + dependencies { + offlineImplementation(project(':probe')) + offlineImplementation(project(':core')) + offlineImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") { + transitive = false + } + offlineImplementation(volmLibCoordinate) { + transitive = false + } + } + sourceSets.test.compileClasspath += sourceSets.offline.output + sourceSets.test.runtimeClasspath += sourceSets.offline.output + tasks.named('test', Test).configure { + forkEvery = 1 + } + + TaskProvider offlineJar = tasks.register('offlineJar', Jar) { + dependsOn(offlineClasses) + archiveClassifier = 'offline' + from(sourceSets.offline.runtimeClasspath.filter { File file -> file.isDirectory() }) + } + FileCollection offlineLibraries = sourceSets.offline.runtimeClasspath.filter { File file -> file.isFile() } + TaskProvider offlineStartScripts = tasks.register('offlineStartScripts', CreateStartScripts) { + dependsOn(offlineJar) + mainClass = 'art.arcane.iris.probe.OfflineRegionGenerator' + applicationName = 'iris-offline' + outputDir = layout.buildDirectory.dir('offline-scripts').get().asFile + classpath = files(offlineJar) + offlineLibraries + defaultJvmOpts = ['-Xmx8G', '--add-modules=jdk.incubator.vector', '-Djava.awt.headless=true'] + } + tasks.register('installOffline', Sync) { + group = 'distribution' + description = 'Installs the standalone native region generator and its runtime libraries.' + dependsOn(offlineJar, offlineStartScripts) + into(layout.buildDirectory.dir('install/iris-offline')) + into('lib') { from(offlineJar, offlineLibraries) } + into('bin') { + from(offlineStartScripts) + filePermissions { unix('rwxr-xr-x') } + } + } + + tasks.register('regionMantleInspection', JavaExec) { + group = 'verification' + dependsOn(testClasses) + mainClass = 'art.arcane.iris.probe.HeadlessMantleInspection' + classpath = sourceSets.test.runtimeClasspath + maxHeapSize = '4G' + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + doFirst { + args(['probePack', 'probeDimension', 'probeSeed', 'probeWorld', 'probeRegionX', 'probeRegionZ'] + .collect { String name -> providers.gradleProperty(name).get() }) + } + } + + tasks.register('generateOfflineRegions', JavaExec) { + group = 'verification' + description = 'Writes fresh native TERRAIN checkpoints with exact prerequisites for subsequent native FULL completion.' + dependsOn(offlineClasses) + mainClass = 'art.arcane.iris.probe.OfflineRegionGenerator' + classpath = sourceSets.offline.runtimeClasspath + maxHeapSize = '8G' + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + doFirst { + List names = ['probePack', 'probeDimension', 'probeSeed', 'probeChunkX', 'probeChunkZ', + 'probeWidth', 'probeRegionsX', 'probeRegionsZ', 'probeParallelism', 'probeSpigot', 'probeWorldName', 'probeLevelKey', + 'probeNoiseSettings', 'probeOutput'] + args(names.collect { String name -> providers.gradleProperty(name).get() }) + } + } + + +} + +project(':adapters:bukkit:nms:v26_3_R1') { + Provider fixtureResources = layout.buildDirectory.dir('generated/region-native-fixture') + TaskProvider fixtureDescriptor = tasks.register('regionNativeFixtureDescriptor') { + outputs.dir(fixtureResources) + doLast { + File descriptor = fixtureResources.get().file('plugin.yml').asFile + descriptor.parentFile.mkdirs() + descriptor.text = '''name: RegionNativeFixture +version: 1.0 +main: art.arcane.iris.probe.RegionNativeFixture +api-version: '26.3' +depend: [Iris] +commands: + mcaterrain: + description: Export native terrain checkpoints on an isolated test server + mcaload: + description: Verify native terrain checkpoints finish generation on reload +''' + } + } + TaskProvider compileRegionNativeFixture = tasks.register('compileRegionNativeFixture', JavaCompile) { + source(fileTree('src/test/java') { + include('art/arcane/iris/probe/RegionNativeFixture.java', + 'art/arcane/iris/probe/DetachedNativeTerrain.java') + }) + source(fileTree('src/offline/java') { + include('art/arcane/iris/probe/RegionGenerationWindow.java', + 'art/arcane/iris/probe/NativeRegionTerrainWriter.java', + 'art/arcane/iris/probe/NativeRegionPlan.java') + }) + classpath = sourceSets.test.compileClasspath + destinationDirectory = layout.buildDirectory.dir('classes/region-native-fixture') + javaCompiler = javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(25) } + options.release = 25 + options.compilerArgs.add('-parameters') + } + tasks.register('regionNativeFixtureJar', Jar) { + dependsOn(compileRegionNativeFixture, fixtureDescriptor) + archiveFileName = 'mca-native-fixture.jar' + from(compileRegionNativeFixture.flatMap { JavaCompile task -> task.destinationDirectory }) + from(fixtureResources) + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java index 6bdbb7c6f..266f8d3b9 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java @@ -115,9 +115,9 @@ public class IrisBiome extends IrisRegistrant implements Rarity { private final transient AtomicCache maxWithObjectHeight = new AtomicCache<>(); private final transient AtomicCache realCarveBiome = new AtomicCache<>(); private final transient AtomicCache> realChildren = new AtomicCache<>(); - private final transient AtomicCache> layerHeightGenerators = new AtomicCache<>(); - private final transient AtomicCache> layerCeilingHeightGenerators = new AtomicCache<>(); - private final transient AtomicCache> layerSeaHeightGenerators = new AtomicCache<>(); + private final transient IrisBiomeLayerHeightCache layerHeightGenerators = new IrisBiomeLayerHeightCache(); + private final transient IrisBiomeLayerHeightCache layerCeilingHeightGenerators = new IrisBiomeLayerHeightCache(); + private final transient IrisBiomeLayerHeightCache layerSeaHeightGenerators = new IrisBiomeLayerHeightCache(); private final transient AtomicCache> surfaceOreCache = new AtomicCache<>(); private final transient AtomicCache> undergroundOreCache = new AtomicCache<>(); private final transient AtomicCache surfaceOreBoundsCache = new AtomicCache<>(); diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index 4feb7c38e..afc475c96 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -25,7 +25,6 @@ import art.arcane.iris.structure.object.IrisObjectPlacement; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.IrisComplex; -import art.arcane.volmlib.util.cache.AtomicCache; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -316,27 +315,10 @@ final class IrisBiomeLayerGenerator { return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data); } - private static KList getHeightGenerators(AtomicCache> cache, + private static KList getHeightGenerators(IrisBiomeLayerHeightCache cache, KList layers, int seedOffset, RNG rng, IrisData data) { - KList cached = cache.getIfPresent(); - - if (cached != null) { - return cached; - } - - return cache.aquire(() -> - { - KList layerHeightGenerators = new KList<>(); - - int m = seedOffset; - - for (IrisBiomePaletteLayer layer : layers) { - layerHeightGenerators.add(layer.getHeightGenerator(rng.nextParallelRNG((m++) * m * m * m), data)); - } - - return layerHeightGenerators; - }); + return cache.get(layers, seedOffset, rng, data); } private static void appendLayer(KList blocks, IrisBiomePaletteLayer layer, diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCache.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCache.java new file mode 100644 index 000000000..d62865fbd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCache.java @@ -0,0 +1,68 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.cache.LazyBoundedCache; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +final class IrisBiomeLayerHeightCache { + private final LazyBoundedCache> generators = new LazyBoundedCache<>(32); + private volatile CachedGenerators recent; + + KList get(KList layers, int seedOffset, RNG rng, IrisData data) { + Engine engine = data == null ? null : data.getEngine(); + long seed = rng.getSeed(); + CachedGenerators cached = recent; + if (cached != null && cached.key.matches(data, engine, seed)) { + return cached.generators; + } + + GeneratorKey key = new GeneratorKey(data, engine, seed); + KList result = generators.computeIfAbsent(key, ignored -> create(layers, seedOffset, rng, data)); + recent = new CachedGenerators(key, result); + return result; + } + + private static KList create(KList layers, int seedOffset, RNG rng, IrisData data) { + KList result = new KList<>(); + int offset = seedOffset; + for (IrisBiomePaletteLayer layer : layers) { + result.add(layer.getHeightGenerator(rng.nextParallelRNG((offset++) * offset * offset * offset), data)); + } + return result; + } + + private record CachedGenerators(GeneratorKey key, KList generators) { + } + + private static final class GeneratorKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private GeneratorKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + private boolean matches(IrisData data, Engine engine, long seed) { + return this.data == data && this.engine == engine && this.seed == seed; + } + + @Override + public boolean equals(Object object) { + return this == object || object instanceof GeneratorKey other + && matches(other.data, other.engine, other.seed); + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + return 31 * result + Long.hashCode(seed); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomePaletteLayer.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomePaletteLayer.java index 20237f2fc..f3abda3f7 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomePaletteLayer.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomePaletteLayer.java @@ -65,7 +65,13 @@ public class IrisBiomePaletteLayer { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private final transient AtomicReference recentLayerGenerator = new AtomicReference<>(); - private final transient AtomicCache heightGenerator = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache heightGenerators = + new LazyBoundedCache<>(LAYER_GENERATOR_CACHE_SIZE); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient AtomicReference recentHeightGenerator = new AtomicReference<>(); @Description("The style of noise") private IrisGeneratorStyle style = NoiseStyle.STATIC.style(); @DependsOn({"minHeight", "maxHeight"}) @@ -91,13 +97,18 @@ public class IrisBiomePaletteLayer { private KList palette = new KList().qadd(new IrisBlockData("GRASS_BLOCK")); public CNG getHeightGenerator(RNG rng, IrisData data) { - CNG cached = heightGenerator.getIfPresent(); - - if (cached != null) { - return cached; + Engine engine = data == null ? null : data.getEngine(); + long generatorSeed = rng.getSeed() + minHeight * maxHeight + getBlockData(data).size(); + CachedLayerGenerator recent = recentHeightGenerator.get(); + if (recent != null && recent.key.matches(data, engine, generatorSeed)) { + return recent.generator; } - return heightGenerator.aquire(() -> CNG.signature(rng.nextParallelRNG(minHeight * maxHeight + getBlockData(data).size()))); + LayerGeneratorKey key = new LayerGeneratorKey(data, engine, generatorSeed); + CNG generator = heightGenerators.computeIfAbsent(key, + ignored -> CNG.signature(new RNG(generatorSeed))); + recentHeightGenerator.set(new CachedLayerGenerator(key, generator)); + return generator; } public NativeBlockState get(RNG rng, double x, double y, double z, IrisData data) { diff --git a/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java b/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java index c9809f866..8451bdcfe 100644 --- a/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java +++ b/core/src/main/java/art/arcane/iris/generation/chunk/LinkedTerrainChunk.java @@ -35,7 +35,8 @@ public class LinkedTerrainChunk implements TerrainChunk { private final int minHeight; private final int maxHeight; private final int biomeHeight; - private final Biome[] biomes; + private final NativeBiome[] biomes; + private volatile NativeBiome defaultBiome; public LinkedTerrainChunk(World world) { this(Bukkit.createChunkData(world)); @@ -46,19 +47,27 @@ public class LinkedTerrainChunk implements TerrainChunk { minHeight = data.getMinHeight(); maxHeight = data.getMaxHeight(); biomeHeight = Math.max(1, maxHeight - minHeight); - biomes = new Biome[CHUNK_SIZE * biomeHeight * CHUNK_SIZE]; + biomes = new NativeBiome[CHUNK_SIZE * biomeHeight * CHUNK_SIZE]; } @Override public NativeBiome getBiome(int x, int y, int z) { int index = biomeIndex(x, y, z); - Biome biome = biomes[index]; - return BukkitBiome.of(biome == null ? Biome.PLAINS : biome); + NativeBiome biome = biomes[index]; + if (biome != null) { + return biome; + } + NativeBiome fallback = defaultBiome; + if (fallback == null) { + fallback = BukkitBiome.of(Biome.PLAINS); + defaultBiome = fallback; + } + return fallback; } @Override public void setBiome(int x, int y, int z, NativeBiome bio) { - biomes[biomeIndex(x, y, z)] = (Biome) bio.nativeHandle(); + biomes[biomeIndex(x, y, z)] = canonicalBiome(bio); } /** @@ -66,7 +75,7 @@ public class LinkedTerrainChunk implements TerrainChunk { * calling setBiome for every y in [minHeight, maxHeight). */ public void fillBiomeColumn(int x, int z, NativeBiome bio) { - Biome handle = (Biome) bio.nativeHandle(); + NativeBiome handle = canonicalBiome(bio); int stride = CHUNK_SIZE * CHUNK_SIZE; int index = (z & (CHUNK_SIZE - 1)) * CHUNK_SIZE + (x & (CHUNK_SIZE - 1)); @@ -120,4 +129,12 @@ public class LinkedTerrainChunk implements TerrainChunk { int clampedY = Math.max(minHeight, Math.min(maxHeight - 1, y)) - minHeight; return (clampedY * CHUNK_SIZE + clampedZ) * CHUNK_SIZE + clampedX; } + + private static NativeBiome canonicalBiome(NativeBiome biome) { + if (biome instanceof BukkitBiome) { + return biome; + } + Biome handle = (Biome) biome.nativeHandle(); + return handle == null ? null : BukkitBiome.of(handle); + } } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterial.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterial.java index 2498809fc..8ab695e5c 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterial.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterial.java @@ -13,7 +13,9 @@ public record TreeBlockMaterial(String materialKey) { } public static TreeBlockMaterial of(NativeBlockState state) { - return of(Objects.requireNonNull(state, "state").key()); + NativeBlockState resolved = Objects.requireNonNull(state, "state"); + String materialKey = resolved.materialKey(); + return materialKey == null ? of(resolved.key()) : new TreeBlockMaterial(materialKey); } public static TreeBlockMaterial of(String blockStateKey) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java new file mode 100644 index 000000000..5cbc7e2aa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyBasisCache.java @@ -0,0 +1,108 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.volmlib.util.cache.CacheKey; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; + +final class HydrologyBasisCache { + private static final int MAXIMUM_ENTRIES = 32_768; + private static final int MAXIMUM_STRIPES = 64; + private final Stripe[] full; + private final Stripe[] withoutSlope; + + HydrologyBasisCache() { + this(MAXIMUM_ENTRIES); + } + + HydrologyBasisCache(int maximumEntries) { + if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) { + throw new IllegalArgumentException("Terrain basis cache size must be a positive power of two."); + } + int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries); + full = new Stripe[stripeCount]; + withoutSlope = new Stripe[stripeCount]; + for (int index = 0; index < stripeCount; index++) { + full[index] = new Stripe(maximumEntries / stripeCount); + withoutSlope[index] = new Stripe(maximumEntries / stripeCount); + } + } + + HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler) { + return sample(x, z, sampler, false); + } + + HydrologyTerrainSample sampleWithoutSlope(int x, int z, HydrologyNaturalTerrainSampler sampler) { + return sample(x, z, sampler, true); + } + + void seed(Long2ObjectMap samples) { + seed(samples, full); + } + + boolean findWithoutSlope(long packed, HydrologyTerrainSample[] samples, int index) { + Stripe stripe = stripe(withoutSlope, packed); + synchronized (stripe) { + samples[index] = stripe.samples.getAndMoveToFirst(packed); + return samples[index] != null || stripe.samples.containsKey(packed); + } + } + + HydrologyTerrainSample rememberWithoutSlope(long packed, HydrologyTerrainSample sample) { + Stripe stripe = stripe(withoutSlope, packed); + synchronized (stripe) { + if (stripe.samples.containsKey(packed)) { + return stripe.samples.getAndMoveToFirst(packed); + } + stripe.put(packed, sample); + return sample; + } + } + + void seedWithoutSlope(Long2ObjectMap samples) { + seed(samples, withoutSlope); + } + + private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) { + long packed = RiverFootprint.pack(x, z); + Stripe stripe = stripe(omitSlope ? withoutSlope : full, packed); + synchronized (stripe) { + HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed); + if (cached != null || stripe.samples.containsKey(packed)) { + return cached; + } + HydrologyTerrainSample sampled = omitSlope ? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z); + stripe.put(packed, sampled); + return sampled; + } + } + + private void seed(Long2ObjectMap samples, Stripe[] stripes) { + for (Long2ObjectMap.Entry entry : samples.long2ObjectEntrySet()) { + Stripe stripe = stripe(stripes, entry.getLongKey()); + synchronized (stripe) { + stripe.put(entry.getLongKey(), entry.getValue()); + } + } + } + + private Stripe stripe(Stripe[] stripes, long packed) { + return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)]; + } + + private static final class Stripe { + private final int maximumEntries; + private final Long2ObjectLinkedOpenHashMap samples; + + private Stripe(int maximumEntries) { + this.maximumEntries = maximumEntries; + samples = new Long2ObjectLinkedOpenHashMap<>(Math.min(16, maximumEntries)); + } + + private void put(long packed, HydrologyTerrainSample value) { + if (samples.size() >= maximumEntries && !samples.containsKey(packed)) { + samples.removeLast(); + } + samples.putAndMoveToFirst(packed, value); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java index 7badfa9a5..5868d6651 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java @@ -269,6 +269,7 @@ final class HydrologyCrossTileResolver { } HydrologyCaveCourseFilter.Result result = publication.result(); List uniqueDiagnostics = planner.sourcePlanner.uniqueDiagnostics(diagnostics); + footprintCompiler.sharedBasis = null; HydrologyOwnerDraft draft = new HydrologyOwnerDraft( key, result, @@ -295,9 +296,15 @@ final class HydrologyCrossTileResolver { HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get(); HydrologyLandHeightCache sharedLandHeights = planner.naturalSampler == null ? null : ownerSamples == null ? new HydrologyLandHeightCache() : ownerSamples.fallbackLandHeights(); + HydrologyBasisCache sharedBasis = null; + if (planner.naturalSampler != null) { + sharedBasis = ownerSamples == null ? new HydrologyBasisCache() + : ownerSamples.fallbackBasis(context.footprintCompiler().terrainBases); + } + FallbackSamples shared = new FallbackSamples(sharedLandHeights, sharedBasis); for (int index = start; index < end; index++) { OutletCandidate outlet = outlets.get(index); - tasks.add(() -> compileFallbackTrial(context, outlet, sharedLandHeights)); + tasks.add(() -> compileFallbackTrial(context, outlet, shared)); } List trials = HydrologyForkJoin.invokeAll(tasks, IrisPlatforms.isBound() ? MultiBurst.hydrology : null); @@ -318,7 +325,7 @@ final class HydrologyCrossTileResolver { private FallbackTrial compileFallbackTrial( FallbackContext context, OutletCandidate outlet, - HydrologyLandHeightCache sharedLandHeights + FallbackSamples shared ) { HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get(); DraftProfile previousProfile = planner.draftProfiles.get(); @@ -328,12 +335,13 @@ final class HydrologyCrossTileResolver { HashMap sourceCompilations = new HashMap<>(context.sourceCompilations()); HydrologyFootprintCompiler footprints = null; try { - planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(sharedLandHeights)); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared.landHeights(), shared.basis())); planner.draftProfiles.set(profile); footprints = new HydrologyFootprintCompiler( planner.settings, new HydrologyFootprintCompiler.Sampling(planner.sampler, planner.geometrySampler, planner.naturalSampler) ); + footprints.sharedBasis = shared.basis(); footprints.regionalNetwork = context.footprintCompiler().regionalNetwork; footprints.regionalSurfaceFootprints.putAll(context.footprintCompiler().regionalSurfaceFootprints); footprints.regionalDropRasters.putAll(context.footprintCompiler().regionalDropRasters); @@ -368,6 +376,9 @@ final class HydrologyCrossTileResolver { } } + private record FallbackSamples(HydrologyLandHeightCache landHeights, HydrologyBasisCache basis) { + } + private record FallbackContext( HydrologyTileKey key, HydrologySampledGrid grid, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java index 9733eacfa..e3ef3caec 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java @@ -46,6 +46,7 @@ final class HydrologyFootprintCompiler { final Map regionalSurfaceFootprints; final Map regionalDropRasters; HydrologyRegionalNetwork regionalNetwork; + HydrologyBasisCache sharedBasis; int fullMaterializationCount; final HydrologyFootprintRasterizer rasterizer; @@ -86,7 +87,17 @@ final class HydrologyFootprintCompiler { this.courseFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true); this.validationCourseRasters = new LinkedHashMap<>(VALIDATION_RASTER_CACHE_SIZE, 1F, true); this.rasterStencils = new HashMap<>(); - this.surfaceCompiler = new SurfaceFootprintCompiler(settings, this::sampleTerrainBasis, this.geometrySampler); + this.surfaceCompiler = new SurfaceFootprintCompiler(settings, new HydrologyTerrainSampler() { + @Override + public HydrologyTerrainSample sample(int x, int z) { + return sampleTerrainBasis(x, z); + } + + @Override + public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) { + return sampleTerrainBases(coordinates, count); + } + }, this.geometrySampler); this.surfaceFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true); this.regionalSurfaceFootprints = new LinkedHashMap<>(); this.regionalDropRasters = new LinkedHashMap<>(); @@ -101,6 +112,14 @@ final class HydrologyFootprintCompiler { } SurfaceFootprint surfaceFootprint(RiverCourse course) { + return surfaceFootprint(course, false); + } + + SurfaceFootprint surfaceFootprintForPublication(RiverCourse course) { + return surfaceFootprint(course, true); + } + + private SurfaceFootprint surfaceFootprint(RiverCourse course, boolean publication) { SurfaceFootprint regional = regionalSurfaceFootprints.get(course.id()); if (regional != null) { return regional; @@ -115,7 +134,10 @@ final class HydrologyFootprintCompiler { if (cached != null) { return cached; } - SurfaceFootprint compiled = surfaceCompiler.compile(course); + SurfaceFootprint compiled = publication ? surfaceCompiler.compileForPublication(course) : surfaceCompiler.compile(course); + if (publication && !compiled.accepted()) { + return compiled; + } surfaceFootprints.put(rasterKey, compiled); if (surfaceFootprints.size() > COURSE_FOOTPRINT_CACHE_SIZE) { surfaceFootprints.remove(surfaceFootprints.sequencedKeySet().getFirst()); @@ -906,13 +928,48 @@ final class HydrologyFootprintCompiler { if (cached != null) { return cached; } - HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(x, z); + HydrologyTerrainSample sampled = sharedBasis == null ? naturalSampler.sampleBasisWithoutSlope(x, z) + : sharedBasis.sampleWithoutSlope(x, z, naturalSampler); if (sampled != null) { terrainBases.put(packed, sampled); } return sampled; } + private HydrologyTerrainSample[] sampleTerrainBases(long[] coordinates, int count) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + long[] missing = new long[count]; + int[] positions = new int[count]; + int missingCount = 0; + for (int index = 0; index < count; index++) { + long key = coordinates[index]; + HydrologyTerrainSample cached = terrainBases.get(key); + if (cached != null) { + samples[index] = cached; + } else if (sharedBasis != null && sharedBasis.findWithoutSlope(key, samples, index)) { + continue; + } else if (naturalSampler == null) { + samples[index] = sampleTerrainBasis(RiverFootprint.unpackX(key), RiverFootprint.unpackZ(key)); + } else { + missing[missingCount] = key; + positions[missingCount++] = index; + } + } + if (missingCount > 0) { + HydrologyTerrainSample[] loaded = naturalSampler.sampleBasisWithoutSlopeBatch(missing, missingCount); + for (int index = 0; index < missingCount; index++) { + samples[positions[index]] = sharedBasis == null ? loaded[index] + : sharedBasis.rememberWithoutSlope(missing[index], loaded[index]); + } + } + for (int index = 0; index < count; index++) { + if (samples[index] != null) { + terrainBases.put(coordinates[index], samples[index]); + } + } + return samples; + } + HydrologyRoutingTerrainSampler.NaturalClassification classifyNatural(int x, int z) { if (naturalSampler == null) { return HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java index 68535859d..e8f715dfa 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyNaturalTerrainSampler.java @@ -11,4 +11,12 @@ public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainS HydrologyTerrainSample terrain = sampleBasisWithoutSlope(blockX, blockZ); return terrain == null || terrain.ocean() ? Double.NaN : terrain.naturalHeight(); } + + default HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + for (int index = 0; index < count; index++) { + samples[index] = sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index])); + } + return samples; + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOceanReceiver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOceanReceiver.java index e82285e98..76045ced3 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOceanReceiver.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOceanReceiver.java @@ -73,6 +73,11 @@ public final class HydrologyOceanReceiver implements HydrologyTerrainSampler { return source.sample(blockX, blockZ); } + @Override + public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) { + return source.sampleBatch(coordinates, count); + } + @Override public boolean receivingWater(int blockX, int blockZ, int queriedSeaLevel) { return queriedSeaLevel == seaLevel && receiving.contains(RiverFootprint.pack(blockX, blockZ)) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java index f500cc9da..5f7392bd0 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java @@ -11,6 +11,7 @@ import art.arcane.volmlib.util.noise.SimplexNoise; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap; import java.util.ArrayList; @@ -549,7 +550,8 @@ public final class HydrologyPlanner { } HydrologyTerrainSample sampled = naturalSampler == null ? sampler.sample(blockX, blockZ) - : naturalSampler.sampleBasis(blockX, blockZ); + : samples.sharedBasis == null ? naturalSampler.sampleBasis(blockX, blockZ) + : samples.sharedBasis.sample(blockX, blockZ, naturalSampler); samples.basis.put(packed, sampled); return sampled; } @@ -576,7 +578,9 @@ public final class HydrologyPlanner { if (cached != null || samples.basisWithoutSlope.containsKey(packed)) { return cached; } - HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(blockX, blockZ); + HydrologyTerrainSample sampled = samples.sharedBasis == null + ? naturalSampler.sampleBasisWithoutSlope(blockX, blockZ) + : samples.sharedBasis.sampleWithoutSlope(blockX, blockZ, naturalSampler); samples.basisWithoutSlope.put(packed, sampled); return sampled; } @@ -692,6 +696,7 @@ public final class HydrologyPlanner { private final Long2ObjectOpenHashMap basisWithoutSlope; private final Long2DoubleOpenHashMap landHeights; private HydrologyLandHeightCache sharedLandHeights; + private HydrologyBasisCache sharedBasis; final Long2ObjectOpenHashMap detailed; private final Long2ObjectOpenHashMap classifications; private final HashMap geometry; @@ -704,11 +709,12 @@ public final class HydrologyPlanner { final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; PlanningSamples() { - this(null); + this(null, null); } - PlanningSamples(HydrologyLandHeightCache sharedLandHeights) { + PlanningSamples(HydrologyLandHeightCache sharedLandHeights, HydrologyBasisCache sharedBasis) { this.sharedLandHeights = sharedLandHeights; + this.sharedBasis = sharedBasis; this.basis = new Long2ObjectOpenHashMap<>(); this.basisWithoutSlope = new Long2ObjectOpenHashMap<>(); this.landHeights = new Long2DoubleOpenHashMap(); @@ -725,6 +731,16 @@ public final class HydrologyPlanner { this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); } + HydrologyBasisCache fallbackBasis(Long2ObjectMap footprintBasis) { + if (sharedBasis == null) { + sharedBasis = new HydrologyBasisCache(); + sharedBasis.seed(basis); + sharedBasis.seedWithoutSlope(basisWithoutSlope); + sharedBasis.seedWithoutSlope(footprintBasis); + } + return sharedBasis; + } + HydrologyLandHeightCache fallbackLandHeights() { if (sharedLandHeights == null) { sharedLandHeights = new HydrologyLandHeightCache(); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributaries.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributaries.java index ca2239cf4..7121d9058 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributaries.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributaries.java @@ -89,7 +89,7 @@ final class HydrologyRegionalTributaries { if (tributary == null || !confined(tributary)) { return null; } - SurfaceFootprint footprint = footprints.surfaceFootprint(tributary); + SurfaceFootprint footprint = footprints.surfaceFootprintForPublication(tributary); if (!footprint.accepted() || !joinsWater(tributary, footprint, receiver.course())) { if (!footprint.accepted()) { planner.tributaries.addTributaryDiagnostic(local.id(), local.segments().getFirst().start(), diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceProfiles.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceProfiles.java index 51a4541f7..c5f51620e 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceProfiles.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceProfiles.java @@ -73,7 +73,7 @@ final class HydrologySurfaceProfiles { if (course.type() != RiverCourseType.SURFACE) { continue; } - SurfaceFootprint footprint = compiler.surfaceFootprint(course); + SurfaceFootprint footprint = compiler.surfaceFootprintForPublication(course); if (!footprint.accepted()) { iterator.remove(); rejectedOutlets.add(course.outletId().orElseThrow()); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTerrainSampler.java index cef11e85c..d3dcb1ec6 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTerrainSampler.java @@ -2,8 +2,18 @@ package art.arcane.iris.generation.hydrology; @FunctionalInterface public interface HydrologyTerrainSampler { + int MAXIMUM_BATCH_SIZE = 4096; + HydrologyTerrainSample sample(int blockX, int blockZ); + default HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + for (int index = 0; index < count; index++) { + samples[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index])); + } + return samples; + } + default boolean receivingWater(int blockX, int blockZ, int seaLevel) { HydrologyTerrainSample terrain = sample(blockX, blockZ); return terrain != null && terrain.ocean() && terrain.naturalHeight() < seaLevel; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index 17118edc5..2dce0754e 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -44,6 +44,8 @@ public final class HydrologyTileCache implements AutoCloseable { .build(); private final HydrologyPlanner planner; + private final int tileSize; + private final int publicationRadius; private final int maximumEntries; private final Cache tiles; private final Cache composedChunks; @@ -107,6 +109,8 @@ public final class HydrologyTileCache implements AutoCloseable { SharedCacheScope sharedCacheScope ) { this.planner = Objects.requireNonNull(planner, "planner"); + this.tileSize = planner.settings().routing().tileSize(); + this.publicationRadius = planner.settings().publicationRadius(); this.prefetchExecutor = prefetchExecutor; this.waitingForbidden = waitingForbidden; this.sharedCacheScope = sharedCacheScope; @@ -560,8 +564,6 @@ public final class HydrologyTileCache implements AutoCloseable { public PregenerationScope preparePregeneration(PregenerationArea area) { Objects.requireNonNull(area, "area"); - int tileSize = planner.settings().routing().tileSize(); - int publicationRadius = planner.settings().publicationRadius(); TileBounds bounds = new TileBounds( tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize), tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize), @@ -590,7 +592,7 @@ public final class HydrologyTileCache implements AutoCloseable { sharedCacheScope = Objects.requireNonNull(scope, "scope"); persistentStore = persistentRoot == null ? null - : new PreparedHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize()); + : new PreparedHydrologyTileStore(persistentRoot, scope, tileSize); } @Override @@ -670,7 +672,7 @@ public final class HydrologyTileCache implements AutoCloseable { return tile.key().equals(sharedKey.tileKey()) && tile.worldSeed() == scope.worldSeed() && tile.settingsFingerprint() == scope.settingsFingerprint() - && tile.tileSize() == planner.settings().routing().tileSize(); + && tile.tileSize() == tileSize; } private ChunkColumns chunkColumns(int blockX, int blockZ) { @@ -765,8 +767,6 @@ public final class HydrologyTileCache implements AutoCloseable { private ArrayList relevantKeys(int chunkX, int chunkZ) { int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); - int tileSize = planner.settings().routing().tileSize(); - int publicationRadius = planner.settings().publicationRadius(); int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize); int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize); @@ -854,8 +854,6 @@ public final class HydrologyTileCache implements AutoCloseable { private List prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX, long maximumBlockZ, int centreBlockX, int centreBlockZ) { - int tileSize = planner.settings().routing().tileSize(); - int publicationRadius = planner.settings().publicationRadius(); int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); int maximumTileX = tileCoordinate((long) maximumBlockX + publicationRadius, tileSize); int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize); @@ -1127,23 +1125,42 @@ public final class HydrologyTileCache implements AutoCloseable { return List.of(loaded); } int batch = Math.max(1, Math.min(maximumEntries / 2, HydrologyPlanningAdmission.maximumRoots())); - ArrayList> futures = new ArrayList<>(Math.min(batch, loaded.length)); - for (int start = 0; start < loaded.length; start += batch) { - int end = Math.min(loaded.length, start + batch); - futures.clear(); - for (int index = start; index < end; index++) { - futures.add(planDemandAsync(keys.get(index))); - } - for (int index = start; index < end; index++) { - loaded[index] = awaitPlan(futures.get(index - start)); - } - } + loadDemandTiles(keys, loaded, batch); return List.of(loaded); } finally { finishDemandBatch(demandEpoch); } } + private void loadDemandTiles(List keys, HydrologyTile[] loaded, int maximumPending) { + ArrayList pending = new ArrayList<>(Math.min(maximumPending, loaded.length)); + int next = 0; + while (next < loaded.length || !pending.isEmpty()) { + while (next < loaded.length && pending.size() < maximumPending) { + if (loaded[next] == null) { + pending.add(new PendingTile(next, planDemandAsync(keys.get(next)))); + } + next++; + } + boolean completed = false; + for (int index = pending.size() - 1; index >= 0; index--) { + PendingTile tile = pending.get(index); + if (tile.future().isDone()) { + loaded[tile.index()] = awaitPlan(tile.future()); + pending.remove(index); + completed = true; + } + } + if (!completed && !pending.isEmpty()) { + CompletableFuture[] futures = new CompletableFuture[pending.size()]; + for (int index = 0; index < pending.size(); index++) { + futures[index] = pending.get(index).future(); + } + awaitPlan(CompletableFuture.anyOf(futures)); + } + } + } + private long beginDemandBatch() { synchronized (prefetchQueue) { demandBatches++; @@ -1283,10 +1300,8 @@ public final class HydrologyTileCache implements AutoCloseable { List relevantKeys, List relevantTiles ) { - ArrayList layers = new ArrayList<>(); + ArrayList layers = null; HydrologyColumnSample template = null; - int tileSize = planner.settings().routing().tileSize(); - int publicationRadius = planner.settings().publicationRadius(); int minimumTileX = tileCoordinate((long) blockX - publicationRadius, tileSize); int maximumTileX = tileCoordinate((long) blockX + publicationRadius, tileSize); int minimumTileZ = tileCoordinate((long) blockZ - publicationRadius, tileSize); @@ -1307,19 +1322,28 @@ public final class HydrologyTileCache implements AutoCloseable { template = sample; } else { requireMatchingTerrain(template, sample); + if (layers == null) { + layers = new ArrayList<>(template.layers()); + } + layers.addAll(sample.layers()); } - layers.addAll(sample.layers()); } if (template == null) { return Optional.empty(); } + if (layers == null && template.layers().size() < 2) { + return Optional.of(template); + } LinkedHashMap unique = new LinkedHashMap<>(); - for (HydrologyColumnLayer layer : layers) { + for (HydrologyColumnLayer layer : layers == null ? template.layers() : layers) { HydrologyColumnLayer existing = unique.putIfAbsent(layer.feature().id(), layer); if (existing != null && !existing.equals(layer)) { throw new IllegalStateException("Hydrology feature id collision at " + blockX + "," + blockZ + "."); } } + if (layers == null && unique.size() == template.layers().size()) { + return Optional.of(template); + } return Optional.of(new HydrologyColumnSample( blockX, blockZ, @@ -1384,6 +1408,9 @@ public final class HydrologyTileCache implements AutoCloseable { } } + private record PendingTile(int index, CompletableFuture future) { + } + private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { private boolean contains(HydrologyTileKey key) { return key.tileX() >= minimumX && key.tileX() <= maximumX diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index bafa65b52..e1a6b26c3 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -5,6 +5,9 @@ import art.arcane.volmlib.util.cache.CacheKey; import art.arcane.iris.generation.hydrology.HydrologyNaturalTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.generation.hydrology.RiverFootprint; +import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; import art.arcane.iris.generation.hydrology.HydrologyForkJoin; import art.arcane.iris.generation.concurrent.MultiBurst; import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap; @@ -15,6 +18,8 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.Executor; +import java.util.concurrent.CancellationException; +import java.util.function.Supplier; final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable { private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64; @@ -130,6 +135,44 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return basis(blockX, blockZ).terrain(); } + @Override + public HydrologyTerrainSample[] sampleBasisWithoutSlopeBatch(long[] coordinates, int count) { + Objects.checkFromIndexSize(0, count, coordinates.length); + if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) { + throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit"); + } + int workers = Math.min(Math.min(samplingOptions.maximumWorkers(), Runtime.getRuntime().availableProcessors()), + Math.ceilDiv(count, MINIMUM_PARALLEL_BASIS_SAMPLES)); + Supplier scope = workers > 1 ? samplingOptions.batchScopes().capture() : null; + if (scope == null) { + return HydrologyNaturalTerrainSampler.super.sampleBasisWithoutSlopeBatch(coordinates, count); + } + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + int perWorker = Math.ceilDiv(count, workers); + ArrayList> tasks = new ArrayList<>(workers); + for (int worker = 0; worker < workers; worker++) { + int start = worker * perWorker; + int end = Math.min(count, start + perWorker); + if (start >= end) { + break; + } + tasks.add(() -> { + try (NativeGenerationScope ignored = scope.get()) { + for (int index = start; index < end; index++) { + if (Thread.currentThread().isInterrupted()) { + throw new CancellationException("Terrain basis sampling interrupted"); + } + samples[index] = sampleBasisWithoutSlope(RiverFootprint.unpackX(coordinates[index]), + RiverFootprint.unpackZ(coordinates[index])); + } + } + return null; + }); + } + HydrologyForkJoin.invokeAll(tasks, samplingOptions.executor()); + return samples; + } + @Override public double sampleLandHeight(int blockX, int blockZ) { long packed = pack(blockX, blockZ); @@ -415,8 +458,13 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai record SamplingOptions( int maximumEntries, int maximumWorkers, - Executor executor + Executor executor, + BatchScopes batchScopes ) { + SamplingOptions(int maximumEntries, int maximumWorkers, Executor executor) { + this(maximumEntries, maximumWorkers, executor, () -> () -> () -> {}); + } + SamplingOptions { if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive"); @@ -425,16 +473,18 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai throw new IllegalArgumentException("maximumWorkers must be positive"); } Objects.requireNonNull(executor, "executor"); + Objects.requireNonNull(batchScopes, "batchScopes"); } - static SamplingOptions production(int maximumEntries) { + static SamplingOptions production(int maximumEntries, BatchScopes batchScopes) { int configuredWorkers = IrisSettings.getThreadCount( IrisSettings.get().getConcurrency().getParallelism() ); return new SamplingOptions( maximumEntries, Math.max(1, configuredWorkers), - MultiBurst.burst + MultiBurst.burst, + batchScopes ); } @@ -443,6 +493,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } } + interface BatchScopes { + Supplier capture(); + } + record TerrainBasis(double naturalHeight, HydrologyTerrainSample terrain) { TerrainBasis { if (!Double.isFinite(naturalHeight)) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java index 5a7d35b08..f769d6445 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java @@ -1,6 +1,9 @@ package art.arcane.iris.generation.hydrology.runtime; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; @@ -53,6 +56,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.IntConsumer; import java.util.function.Predicate; +import java.util.function.Supplier; public final class IrisHydrologyRuntime implements AutoCloseable { private static final int MAXIMUM_CACHE_TILES = 64; @@ -102,7 +106,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { ); this.routingTerrainSampler = new IrisHydrologyRoutingTerrainSampler( terrainSources, - IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES) + IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES, this::captureBasisScope) ); HydrologyPlanner planner = new HydrologyPlanner( context.seed(), @@ -532,6 +536,37 @@ public final class IrisHydrologyRuntime implements AutoCloseable { + " (raw=" + rawNaturalHeight + ", sampled=" + sampledNaturalHeight + "; " + breakdown + ")"; } + private Supplier captureBasisScope() { + if (!(context.data().getEngine() instanceof IrisEngine engine)) { + return null; + } + IrisEngine.GenerationRuntimeBinding binding = engine.captureGenerationRuntimeBinding(); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) { + if (engine.getComplex().getHydrologyRuntime() != this) { + return null; + } + } + IrisContext current = IrisContext.get(); + long session = current != null && current.getEngine() == engine ? current.getGenerationSessionId() : 0L; + return () -> { + IrisEngine.GenerationRuntimeScope runtime = engine.openGenerationRuntimeScope(binding); + IrisContext.Scope execution; + try { + execution = IrisContext.open(engine, session, null); + } catch (RuntimeException | Error failure) { + runtime.close(); + throw failure; + } + return () -> { + try { + execution.close(); + } finally { + runtime.close(); + } + }; + }; + } + private IrisHydrologyRoutingTerrainSampler.TerrainBasis createTerrainBasis( int x, int z, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index 6f1818deb..97d32935f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -13,6 +13,7 @@ import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongIterator; import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; @@ -105,9 +106,33 @@ public final class ErosionFieldCompiler { return new PreparedField(count, basin, bendOffsets, blendWidth, radii, inletStart); } + ErosionField compileForPublication(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, + ValleyProfile valley, SurfaceTerminal terminal, int apronLimit, + HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context, + PreparedField prepared) { + RasterGeometry geometry = new RasterGeometry(); + if (context.bounds() == null) { + ErosionField wet = compile(courseSeed, centerline, channel, valley, terminal, apronLimit, + ponds, context, prepared, geometry, true); + if (wet.rejection() != null) { + return wet; + } + } + return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, + ponds, context, prepared, geometry, false); + } + ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, SurfaceTerminal terminal, int apronLimit, HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context, PreparedField prepared) { + return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, + ponds, context, prepared, new RasterGeometry(), false); + } + + private ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, + ValleyProfile valley, SurfaceTerminal terminal, int apronLimit, + HydrologyPlannerSettings.Ponds ponds, SurfaceRasterContext context, + PreparedField prepared, RasterGeometry geometry, boolean wetOnly) { Objects.requireNonNull(context, "context"); SurfaceBounds bounds = context.bounds(); SurfaceBounds samplingBounds = bounds == null ? null : bounds.expand(2); @@ -124,148 +149,191 @@ public final class ErosionFieldCompiler { boolean[] basin = prepared.basin(); double[] bendOffsets = prepared.bendOffsets(); double[][] blendWidth = prepared.blendWidth(); - Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); - nearest.defaultReturnValue(-1); - Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); - for (int station = 0; station < count; station++) { - int radius = prepared.radii()[station]; - int stationX = centerline.x()[station]; - int stationZ = centerline.z()[station]; - if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { - continue; - } - int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ); - int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ); - int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX); - int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX); - for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) { - for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) { - int cellX = stationX + deltaX; - int cellZ = stationZ + deltaZ; - double cellDistance = centerline.distanceToSegment(station, cellX, cellZ); - if (cellDistance > radius) { - continue; - } - long key = RiverFootprint.pack(cellX, cellZ); - int existing = nearest.get(key); - // A cell on a station's cross-section row ties between the two segments meeting - // there; the later station owns it so the row uses the head its own ring bounded. - if (existing < 0 - || cellDistance < distance.get(key) - EPSILON - || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { - nearest.put(key, station); - distance.put(key, cellDistance); + Long2IntOpenHashMap nearest = geometry.nearest; + Long2DoubleOpenHashMap distance = geometry.distance; + if (!geometry.prepared) { + for (int station = 0; station < count; station++) { + int radius = prepared.radii()[station]; + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) { + continue; + } + int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ); + int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ); + int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX); + int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX); + for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) { + for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) { + int cellX = stationX + deltaX; + int cellZ = stationZ + deltaZ; + double cellDistance = centerline.distanceToSegment(station, cellX, cellZ, SurfaceDistanceTable.SHARED); + if (cellDistance > radius) { + continue; + } + long key = RiverFootprint.pack(cellX, cellZ); + int existing = nearest.get(key); + // A cell on a station's cross-section row ties between the two segments meeting + // there; the later station owns it so the row uses the head its own ring bounded. + if (existing < 0 + || cellDistance < distance.get(key) - EPSILON + || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { + nearest.put(key, station); + distance.put(key, cellDistance); + } } } } + geometry.prepared = true; } int oceanStart = oceanStart(centerline, valley, terminal); int inletStart = prepared.inletStart(); - Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(nearest.size()); + Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(wetOnly + ? Math.min(nearest.size(), Math.max(16, count * 8)) : nearest.size()); LongArrayList wetKeys = new LongArrayList(); - for (long key : nearest.keySet()) { - int station = nearest.get(key); - double cellDistance = distance.get(key); - int cellX = RiverFootprint.unpackX(key); - int cellZ = RiverFootprint.unpackZ(key); - int head = valley.head()[station]; - if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) { - continue; - } - double halfWidth = channel.halfWidth()[station]; - double outline = halfWidth * (1D + roughness * SurfaceNoise.signed( - courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); - outline = Math.max( - Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth), - Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline)); - boolean wet = cellDistance <= outline + 0.25D; - double bankDistance = cellDistance - outline; - if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D - && (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled() - || bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) { - continue; - } - HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); - boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel - && SurfaceCellAdmission.mouthLand(terrain, seaLevel); - if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) { - if (sampler.receivingWater(cellX, cellZ, seaLevel) && wet && terminal == SurfaceTerminal.OCEAN_MOUTH - && station >= valley.exposedStations() - 1 - && station - oceanStart < apronLimit - && cellDistance <= apronLimit + 0.25D) { - columns.put(key, new SurfaceColumn( - cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true)); + LongIterator candidates = nearest.keySet().iterator(); + long[] coordinates = new long[Math.min(nearest.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)]; + double[] outlines = new double[coordinates.length]; + while (candidates.hasNext()) { + int pageSize = 0; + while (candidates.hasNext() && pageSize < coordinates.length) { + long key = candidates.nextLong(); + int station = nearest.get(key); + double cellDistance = distance.get(key); + int cellX = RiverFootprint.unpackX(key); + int cellZ = RiverFootprint.unpackZ(key); + int head = valley.head()[station]; + if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) { + continue; } - continue; - } - int natural = terrain.naturalHeight(); - if (wet) { - double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline)); - normalized = SurfaceBendProfile.normalizedDistance(normalized, - side(centerline, station, cellX, cellZ), bendOffsets[station]); - double depth = channel.depth()[station]; - double local = 1D + (depth - 1D) * bedFactor(normalized, erosion) - + erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) - + (basin[station] ? flow.plungeBasinDepth() : 0D); - int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); - int minimumBed = natural - maximumIncision(terrain, station >= inletStart); - if (minimumBed < head) { - bed = Math.max(bed, minimumBed); + double halfWidth = channel.halfWidth()[station]; + double outline = halfWidth * (1D + roughness * SurfaceNoise.signed( + courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); + outline = Math.max( + Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth), + Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline)); + boolean wet = cellDistance <= outline + 0.25D; + double bankDistance = cellDistance - outline; + if (wetOnly && !wet) { + continue; } - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); - wetKeys.add(key); - continue; - } - int bankTop = head + sink; - // The geometric shore is the column's own policy width where it sets one, so a beach can be - // wide on one bank and narrow on the other. - double shore = terrain.shoreWidth(shoreWidth); - // The SHORE role is the shore biome band: the column's policy width, or the geometric shore - // width where no policy sets one. It is independent of the flattened geometric shore and of - // the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the - // geometric shore with the bank biome. - boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D; - SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK; - if (bankDistance > erosion.excavation().maximumWidth()) { - if (shoreBiome) { - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D + && (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled() + || bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) { + continue; } + coordinates[pageSize] = key; + outlines[pageSize++] = outline; + } + if (pageSize == 0) { continue; } - if (cellDistance <= outline + shore + 0.25D) { - int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore)); + HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize); + for (int index = 0; index < pageSize; index++) { + long key = coordinates[index]; + int station = nearest.get(key); + double cellDistance = distance.get(key); + int cellX = RiverFootprint.unpackX(key); + int cellZ = RiverFootprint.unpackZ(key); + int head = valley.head()[station]; + double outline = outlines[index]; + boolean wet = cellDistance <= outline + 0.25D; + double bankDistance = cellDistance - outline; + HydrologyTerrainSample terrain = samples[index]; + boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel + && SurfaceCellAdmission.mouthLand(terrain, seaLevel); + if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) { + if (sampler.receivingWater(cellX, cellZ, seaLevel) && wet && terminal == SurfaceTerminal.OCEAN_MOUTH + && station >= valley.exposedStations() - 1 + && station - oceanStart < apronLimit + && cellDistance <= apronLimit + 0.25D) { + columns.put(key, new SurfaceColumn( + cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true)); + } + continue; + } + int natural = terrain.naturalHeight(); + if (wet) { + double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline)); + normalized = SurfaceBendProfile.normalizedDistance(normalized, + side(centerline, station, cellX, cellZ), bendOffsets[station]); + double depth = channel.depth()[station]; + double local = 1D + (depth - 1D) * bedFactor(normalized, erosion) + + erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + + (basin[station] ? flow.plungeBasinDepth() : 0D); + int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); + int minimumBed = natural - maximumIncision(terrain, station >= inletStart); + if (minimumBed < head) { + bed = Math.max(bed, minimumBed); + } + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); + wetKeys.add(key); + continue; + } + int bankTop = head + sink; + // The geometric shore is the column's own policy width where it sets one, so a beach can be + // wide on one bank and narrow on the other. + double shore = terrain.shoreWidth(shoreWidth); + // The SHORE role is the shore biome band: the column's policy width, or the geometric shore + // width where no policy sets one. It is independent of the flattened geometric shore and of + // the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the + // geometric shore with the bank biome. + boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D; + SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK; + if (bankDistance > erosion.excavation().maximumWidth()) { + if (shoreBiome) { + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + } + continue; + } + if (cellDistance <= outline + shore + 0.25D) { + int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore)); + height = boundedBankHeight(terrain, height, erosion.excavation()); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); + continue; + } + int shoreTop = bankTop + (int) StrictMath.round(shoreRise); + int cut = natural - shoreTop; + if (cut == 0 || !erosion.enabled() || !terrain.erosion()) { + if (shoreBiome) { + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + } + continue; + } + double side = side(centerline, station, cellX, cellZ); + double width = blendWidth[station][side < 0D ? 0 : 1]; + width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); + double progress = (cellDistance - outline - shore) / width; + if (progress >= 1D) { + if (shoreBiome) { + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + } + continue; + } + int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)); height = boundedBankHeight(terrain, height, erosion.excavation()); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); - continue; } - int shoreTop = bankTop + (int) StrictMath.round(shoreRise); - int cut = natural - shoreTop; - if (cut == 0 || !erosion.enabled() || !terrain.erosion()) { - if (shoreBiome) { - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); - } - continue; - } - double side = side(centerline, station, cellX, cellZ); - double width = blendWidth[station][side < 0D ? 0 : 1]; - width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); - double progress = (cellDistance - outline - shore) / width; - if (progress >= 1D) { - if (shoreBiome) { - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); - } - continue; - } - int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)); - height = boundedBankHeight(terrain, height, erosion.excavation()); - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); } if (count > 0) { - pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0], samplingContext); + pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0], samplingContext, wetOnly); if (terminal == SurfaceTerminal.SINKHOLE) { - pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1], samplingContext); + pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1], samplingContext, wetOnly); } } + if (wetOnly) { + connectSteps(columns, wetKeys); + for (SurfaceColumn column : columns.values()) { + if (!column.apron()) { + int cut = unsupportedIncisionCut(column, inletStart); + if (cut > 0) { + return new ErosionField(columns, 0, + HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, 0L); + } + } + } + return new ErosionField(columns, 0, null, 0, 0L); + } int uncontained = contain(columns, wetKeys, sink, bounds, context.drops()); connectSteps(columns, wetKeys); return validate(columns, wetKeys, centerline, terminal, bounds, uncontained, inletStart); @@ -286,7 +354,8 @@ public final class ErosionFieldCompiler { SurfaceCenterline centerline, int station, int head, - SurfaceRasterContext context + SurfaceRasterContext context, + boolean wetOnly ) { SurfaceBounds bounds = context.bounds(); if (!pond.enabled()) { @@ -315,6 +384,8 @@ public final class ErosionFieldCompiler { } int bankTop = head + sink; int shoreTop = bankTop + (int) StrictMath.round(shoreRise); + double maximumBlendWidth = Math.min(banks.maximumBlendWidth(), + Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); int reach = (int) StrictMath.ceil(radius * (1D + roughness) + shore + banks.maximumBlendWidth()) + 1; for (int deltaZ = -reach; deltaZ <= reach; deltaZ++) { for (int deltaX = -reach; deltaX <= reach; deltaX++) { @@ -329,11 +400,6 @@ public final class ErosionFieldCompiler { || context.drops().connects(cellX, cellZ, head)) { continue; } - HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); - if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { - continue; - } - int natural = terrain.naturalHeight(); long key = RiverFootprint.pack(cellX, cellZ); SurfaceColumn existing = columns.get(key); if (existing != null && existing.apron()) { @@ -344,7 +410,22 @@ public final class ErosionFieldCompiler { outline = Math.max( Math.max(0.5D, channelSettings.outlineMinimumRatio() * radius), Math.min(channelSettings.outlineMaximumRatio() * radius, outline)); - if (distance <= outline + 0.25D) { + boolean wet = distance <= outline + 0.25D; + if (wetOnly && !wet) { + continue; + } + if (!wet && (existing != null && existing.role() == SurfaceRole.CHANNEL + || distance - outline > erosion.excavation().maximumWidth() + || distance > outline + shore + 0.25D && (!erosion.enabled() || !erode + || (distance - outline - shore) / maximumBlendWidth >= 1D))) { + continue; + } + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { + continue; + } + int natural = terrain.naturalHeight(); + if (wet) { double normalized = Math.min(1D, distance / Math.max(0.5D, outline)); double local = 1D + (pond.depth() - 1D) * bedFactor(normalized, erosion) + erosion.bedNoise() * roughness * SurfaceNoise.signed(seed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()); @@ -363,12 +444,6 @@ public final class ErosionFieldCompiler { } continue; } - if (existing != null && existing.role() == SurfaceRole.CHANNEL) { - continue; - } - if (distance - outline > erosion.excavation().maximumWidth()) { - continue; - } if (distance <= outline + shore + 0.25D) { int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore)); height = boundedBankHeight(terrain, height, erosion.excavation()); @@ -376,7 +451,7 @@ public final class ErosionFieldCompiler { continue; } int cut = natural - shoreTop; - if (cut == 0 || !erosion.enabled() || !erode) { + if (cut == 0) { continue; } double width = Math.max(banks.minimumBlendWidth(), @@ -624,6 +699,12 @@ public final class ErosionFieldCompiler { return Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); } + private int unsupportedIncisionCut(SurfaceColumn column, int inletStart) { + int incision = maximumIncision(column.terrain(), column.station() >= inletStart); + int cut = column.terrain().naturalHeight() - column.height(); + return cut > incision ? cut : 0; + } + private ErosionField validate(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, SurfaceCenterline centerline, SurfaceTerminal terminal, SurfaceBounds bounds, int uncontained, int inletStart) { @@ -635,10 +716,8 @@ public final class ErosionFieldCompiler { continue; } if (column.role() == SurfaceRole.CHANNEL) { - HydrologyTerrainSample terrain = column.terrain(); - int incision = maximumIncision(terrain, column.station() >= inletStart); - int cut = terrain.naturalHeight() - column.height(); - if (cut > incision) { + int cut = unsupportedIncisionCut(column, inletStart); + if (cut > 0) { return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, excavation); @@ -818,6 +897,16 @@ public final class ErosionFieldCompiler { return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); } + private static final class RasterGeometry { + private final Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); + private final Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); + private boolean prepared; + + private RasterGeometry() { + nearest.defaultReturnValue(-1); + } + } + record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii, int inletStart) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCenterline.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCenterline.java index 7c7e95b8c..fae43b0c0 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCenterline.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCenterline.java @@ -75,10 +75,15 @@ public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] ta } public double distanceToSegment(int station, double pointX, double pointZ) { + return distanceToSegment(station, pointX, pointZ, null); + } + + double distanceToSegment(int station, double pointX, double pointZ, SurfaceDistanceTable distances) { double startX = x[station]; double startZ = z[station]; if (station + 1 >= x.length) { - return StrictMath.hypot(pointX - startX, pointZ - startZ); + return distances == null ? StrictMath.hypot(pointX - startX, pointZ - startZ) + : distances.hypot(pointX - startX, pointZ - startZ); } double endX = x[station + 1]; double endZ = z[station + 1]; @@ -91,7 +96,8 @@ public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] ta progress = Math.max(0D, Math.min(1D, progress)); double closestX = startX + segmentX * progress; double closestZ = startZ + segmentZ * progress; - return StrictMath.hypot(pointX - closestX, pointZ - closestZ); + return distances == null ? StrictMath.hypot(pointX - closestX, pointZ - closestZ) + : distances.hypot(pointX - closestX, pointZ - closestZ); } public SurfaceCenterline truncate(int stations) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTable.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTable.java new file mode 100644 index 000000000..0683aaba8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTable.java @@ -0,0 +1,48 @@ +package art.arcane.iris.generation.hydrology.surface; + +import java.util.Objects; +import java.util.function.DoubleBinaryOperator; + +final class SurfaceDistanceTable { + static final SurfaceDistanceTable SHARED = new SurfaceDistanceTable(256, StrictMath::hypot); + + private final int maximumResidual; + private final int width; + private final double[] integers; + private final double[] diagonals; + private final DoubleBinaryOperator calculator; + + SurfaceDistanceTable(int maximumResidual, DoubleBinaryOperator calculator) { + if (maximumResidual < 0 || maximumResidual > 256) { + throw new IllegalArgumentException("Surface distance table residual must be between zero and 256."); + } + this.maximumResidual = maximumResidual; + this.width = maximumResidual + 1; + this.calculator = Objects.requireNonNull(calculator, "calculator"); + this.integers = new double[width * width]; + this.diagonals = new double[maximumResidual]; + for (int x = 0; x <= maximumResidual; x++) { + for (int z = 0; z <= maximumResidual; z++) { + integers[x * width + z] = calculator.applyAsDouble(x, z); + } + } + for (int offset = 0; offset < maximumResidual; offset++) { + diagonals[offset] = calculator.applyAsDouble(offset + 0.5D, offset + 0.5D); + } + } + + double hypot(double x, double z) { + double positiveX = Math.abs(x); + double positiveZ = Math.abs(z); + int integerX = (int) positiveX; + int integerZ = (int) positiveZ; + if (integerX <= maximumResidual && integerZ <= maximumResidual + && positiveX == integerX && positiveZ == integerZ) { + return integers[integerX * width + integerZ]; + } + if (positiveX == positiveZ && positiveX < maximumResidual && positiveX == integerX + 0.5D) { + return diagonals[integerX]; + } + return calculator.applyAsDouble(x, z); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java index a343b9aa4..7a6aab149 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java @@ -54,11 +54,19 @@ public final class SurfaceFootprintCompiler { return compile(prepare(course), bounds); } + public SurfaceFootprint compileForPublication(RiverCourse course) { + return compile(prepare(course), null, true); + } + public PreparedCourse prepare(RiverCourse course) { return new PreparedCourse(this, Objects.requireNonNull(course, "course")); } public SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds) { + return compile(prepared, bounds, false); + } + + private SurfaceFootprint compile(PreparedCourse prepared, SurfaceBounds bounds, boolean publication) { if (prepared.compiler != this) { throw new IllegalArgumentException("Prepared course belongs to another surface compiler"); } @@ -89,7 +97,7 @@ public final class SurfaceFootprintCompiler { while (after < course.segments().size() && exposedSegment(course.segments().get(after))) { after++; } - SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler); + SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler, publication); columns.addAll(run.columns()); uncontained += run.uncontainedWetCells(); excavation += run.bankExcavation(); @@ -147,7 +155,7 @@ public final class SurfaceFootprintCompiler { private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset, SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool, - HydrologyTerrainSampler receivingSampler) { + HydrologyTerrainSampler receivingSampler, boolean publication) { RiverCourse course = prepared.course; PreparedRun run = prepared.runs[first]; if (run == null) { @@ -159,9 +167,13 @@ public final class SurfaceFootprintCompiler { SurfaceCenterline centerline = run.centerline(); boolean coastalChannel = run.coastalChannel(); String poolBiome = run.poolBiome(); - ErosionField field = run.erosion().compile( - HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, run.channel(), run.valley(), run.terminal(), - settings.outlets().maximumOceanApron(), run.ponds(), new SurfaceRasterContext(bounds, drops, run.boundary()), + SurfaceRasterContext context = new SurfaceRasterContext(bounds, drops, run.boundary()); + long seed = HydrologyHash.mix(course.id(), COURSE_SEED_SALT); + ErosionField field = publication ? run.erosion().compileForPublication( + seed, centerline, run.channel(), run.valley(), run.terminal(), + settings.outlets().maximumOceanApron(), run.ponds(), context, run.field()) : run.erosion().compile( + seed, centerline, run.channel(), run.valley(), run.terminal(), + settings.outlets().maximumOceanApron(), run.ponds(), context, run.field()); ArrayList ordered = new ArrayList<>(field.columns().values()); ordered.sort(Comparator.comparingInt(SurfaceColumn::station) diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java index c081b26df..061c88ac7 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java @@ -40,6 +40,12 @@ final class CaveCarveScratch { final double[] planeThresholdLimit = new double[256]; final boolean[] planeCarve = new boolean[256]; final double[] adaptivePlaneDensity = new double[81]; + double[] verticalDensityLower = new double[9]; + double[] verticalDensityUpper = new double[9]; + int verticalDensityLowerY = Integer.MIN_VALUE; + int verticalDensityUpperY = Integer.MIN_VALUE; + int verticalDensityX; + int verticalDensityZ; final double[] adaptivePlanePrediction = new double[256]; final double[] adaptivePlaneAmbiguity = new double[256]; final int[] adaptivePlaneSampleBounds = new int[4]; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java index 1cf0218c3..d3059fdba 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java @@ -91,6 +91,9 @@ public class IrisCaveCarver3D { private final int fluidHeight; private final int aquiferCeilingY; private final boolean parallelDensity; + private final int verticalDensityStep; + private final int worldMinimumY; + private final int densityMaximumY; private final ThreadLocal carving = ThreadLocal.withInitial(() -> false); private final ThreadLocal scratchCache = ThreadLocal.withInitial(CaveCarveScratch::new); @@ -139,6 +142,9 @@ public class IrisCaveCarver3D { detailMinContribution = -detailWeight; detailMaxContribution = detailWeight; parallelDensity = fixedDensityStyles(profile); + verticalDensityStep = parallelDensity ? Math.max(1, engine.getDimension().getCaveDensitySamplingStep()) : 1; + worldMinimumY = engine.getWorld().minHeight(); + densityMaximumY = engine.getWorld().getHeight() - 1; } public int carve(MantleWriter writer, int chunkX, int chunkZ) { @@ -1354,6 +1360,13 @@ public class IrisCaveCarver3D { return; } + if (verticalDensityStep > 1 && adaptiveSampleStep == ADAPTIVE_DEEP_SAMPLE_STEP) { + prepareVerticalDensityPlane(scratch, x0, z0, y); + classifyDeepAdaptivePlaneFromSamples(scratch, planeColumnIndices, planeThresholdLimit, + planeCount, planeCarve, ADAPTIVE_DEEP_SAMPLE_STEP, scratch.adaptivePlaneDensity, 2, 3); + return; + } + if (!hasWarp) { if (!hasModules) { classifyDensityPlaneAdaptiveNoWarpNoModules(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin); @@ -1372,6 +1385,65 @@ public class IrisCaveCarver3D { classifyDensityPlaneAdaptiveWarpModules(scratch, x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin); } + private void prepareVerticalDensityPlane(CaveCarveScratch scratch, int x0, int z0, int y) { + int lower = (int) (Math.floorDiv((long) y + worldMinimumY, verticalDensityStep) + * verticalDensityStep - worldMinimumY); + int upper = Math.min(densityMaximumY, lower + verticalDensityStep); + lower = Math.max(0, lower); + for (CaveFieldModuleState module : modules) { + if (y < module.minY) { + upper = Math.min(upper, module.minY - 1); + } else if (y > module.maxY) { + lower = Math.max(lower, module.maxY + 1); + } else { + lower = Math.max(lower, module.minY); + upper = Math.min(upper, module.maxY); + } + } + if (scratch.verticalDensityX != x0 || scratch.verticalDensityZ != z0) { + scratch.verticalDensityX = x0; + scratch.verticalDensityZ = z0; + scratch.verticalDensityLowerY = Integer.MIN_VALUE; + scratch.verticalDensityUpperY = Integer.MIN_VALUE; + } + if (scratch.verticalDensityUpperY == lower) { + double[] previousLower = scratch.verticalDensityLower; + scratch.verticalDensityLower = scratch.verticalDensityUpper; + scratch.verticalDensityUpper = previousLower; + scratch.verticalDensityUpperY = scratch.verticalDensityLowerY; + scratch.verticalDensityLowerY = lower; + } + if (scratch.verticalDensityLowerY != lower) { + scratch.verticalDensityLowerY = Integer.MIN_VALUE; + sampleVerticalDensityPlane(scratch, x0, z0, lower, scratch.verticalDensityLower); + scratch.verticalDensityLowerY = lower; + } + if (y == lower) { + System.arraycopy(scratch.verticalDensityLower, 0, scratch.adaptivePlaneDensity, 0, 9); + return; + } + if (scratch.verticalDensityUpperY != upper) { + scratch.verticalDensityUpperY = Integer.MIN_VALUE; + sampleVerticalDensityPlane(scratch, x0, z0, upper, scratch.verticalDensityUpper); + scratch.verticalDensityUpperY = upper; + } + double fraction = (y - lower) / (double) (upper - lower); + for (int index = 0; index < 9; index++) { + double lowerDensity = scratch.verticalDensityLower[index]; + scratch.adaptivePlaneDensity[index] = lowerDensity + + (scratch.verticalDensityUpper[index] - lowerDensity) * fraction; + } + } + + private void sampleVerticalDensityPlane(CaveCarveScratch scratch, int x0, int z0, int y, double[] samples) { + for (int x = 0; x < 3; x++) { + for (int z = 0; z < 3; z++) { + samples[x * 3 + z] = sampleDensityOptimized(scratch, + x0 + x * ADAPTIVE_DEEP_SAMPLE_STEP, y, z0 + z * ADAPTIVE_DEEP_SAMPLE_STEP); + } + } + } + private void classifyDensityPlaneNoWarpNoModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) { for (int planeIndex = 0; planeIndex < planeCount; planeIndex++) { int columnIndex = planeColumnIndices[planeIndex]; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java index 81c77193c..64d0cf4eb 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java @@ -590,29 +590,25 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (matter == null) { continue; } - MatterSlice journalSlice = matter.hasSlice(PreObjectMatterCell.class) - ? matter.getSlice(PreObjectMatterCell.class) - : null; - MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) - ? matter.getSlice(MatterCavern.class) - : null; - MatterSlice hydrologySlice = matter.hasSlice(HydrologyCaveCell.class) - ? matter.getSlice(HydrologyCaveCell.class) - : null; + MatterSlice journalSlice = matter.getSlice(PreObjectMatterCell.class); + MatterSlice cavernSlice = matter.getSlice(MatterCavern.class); + MatterSlice hydrologySlice = matter.getSlice(HydrologyCaveCell.class); int sectionBaseY = section << 4; int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16); for (int y = sectionBaseY; y < sectionMaxY; y++) { int localY = y & 15; - HydrologyCaveCell hydrology = hydrologySlice == null + PreObjectMatterCell cell = journalSlice == null + ? null + : journalSlice.get(x & 15, localY, z & 15); + HydrologyCaveCell hydrology = cell != null && cell.hydrologyCaptured() + ? cell.hydrology() + : hydrologySlice == null ? null : hydrologySlice.get(x & 15, localY, z & 15); if (hydrology != null) { carvedColumn[y] = hydrology.carves() ? (byte) 1 : 0; continue; } - PreObjectMatterCell cell = journalSlice == null - ? null - : journalSlice.get(x & 15, localY, z & 15); MatterCavern cavern = cell != null && cell.cavernCaptured() ? cell.cavern() : cavernSlice == null ? null : cavernSlice.get(x & 15, localY, z & 15); @@ -799,9 +795,11 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } private static T prerequisiteValue(Matter matter, int x, int y, int z, Class type) { - PreObjectMatterCell cell = preObjectCell(matter, x, y, z); - if (cell != null && cell.captures(type)) { - return cell.original(type); + if (isPreObjectType(type) || type == HydrologyCaveCell.class) { + PreObjectMatterCell cell = preObjectCell(matter, x, y, z); + if (cell != null && cell.captures(type)) { + return cell.original(type); + } } MatterSlice slice = matter.getSlice(type); return slice == null ? null : slice.get(x & 15, y & 15, z & 15); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index cb1bd8f8b..7e10c48b4 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.integration.Identifier; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.structure.object.IObjectPlacer; @@ -11,6 +12,9 @@ import art.arcane.iris.generation.block.B; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.util.matter.MatterCavern; import org.jetbrains.annotations.Nullable; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.util.ArrayList; import java.util.HashMap; @@ -26,7 +30,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private final int destinationChunkZ; private final int worldHeight; private final List mutations; - private final Map overlay; + private final Long2ObjectOpenHashMap> overlay; ObjectDestinationTransaction(MantleWriter writer, int destinationChunkX, int destinationChunkZ) { this.writer = writer; @@ -34,7 +38,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { this.destinationChunkZ = destinationChunkZ; this.worldHeight = writer.getMantle().getWorldHeight(); this.mutations = new ArrayList<>(); - this.overlay = new HashMap<>(); + this.overlay = new Long2ObjectOpenHashMap<>(); } void commit() { @@ -53,9 +57,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } void apply(ObjectSourcePlan plan) { - for (Mutation mutation : plan.mutations()) { - if (isDestination(mutation.x(), mutation.z())) { - mutation.apply(this); + for (Mutation mutation : plan.mutationsFor(destinationChunkX, destinationChunkZ)) { + switch (mutation) { + case SetMutation set -> setData(set.key().x(), set.key().y(), set.key().z(), set.value(), set); + case CustomBlockMutation custom -> set(custom.key().x(), custom.key().y(), custom.key().z(), custom.state(), custom); } } } @@ -102,6 +107,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { @Override public void set(int x, int y, int z, NativeBlockState state) { + set(x, y, z, state, null); + } + + private void set(int x, int y, int z, NativeBlockState state, CustomBlockMutation replayed) { if (state == null) { return; } @@ -111,11 +120,12 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { if (!canSetBlock(x, y, z)) { return; } - DataKey blockKey = new DataKey(x, y, z, NativeBlockState.class); Identifier identifier = Identifier.fromString(placementKey); - overlay.put(blockKey, baseState); - overlay.put(new DataKey(x, y, z, Identifier.class), identifier); - mutations.add(new CustomBlockMutation(blockKey, state)); + OverlayCell cell = writableCell(x, y, z); + cell.block = baseState; + cell.identifier = identifier; + mutations.add(replayed != null + ? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state)); return; } setData(x, y, z, state); @@ -123,8 +133,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { @Override public NativeBlockState get(int x, int y, int z) { - DataKey key = new DataKey(x, y, z, NativeBlockState.class); - Object value = overlay.get(key); + OverlayCell cell = overlayCell(x, y, z); + Object value = cell == null ? null : cell.block; if (value == CLEARED) { return EngineMantle.AIR.get(); } @@ -140,15 +150,19 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { @Override public boolean isCarved(int x, int y, int z) { - Object block = overlay.get(new DataKey(x, y, z, NativeBlockState.class)); + OverlayCell cell = overlayCell(x, y, z); + if (cell == null) { + return writer.isPrerequisiteCarved(x, y, z); + } + Object block = cell.block; if (block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) { return false; } - Object hydrologyValue = overlay.get(new DataKey(x, y, z, HydrologyCaveCell.class)); + Object hydrologyValue = cell.hydrology; if (hydrologyValue instanceof HydrologyCaveCell hydrology) { return hydrology.carves(); } - Object cavern = overlay.get(new DataKey(x, y, z, MatterCavern.class)); + Object cavern = cell.cavern; return cavern == null ? writer.isPrerequisiteCarved(x, y, z) : cavern != CLEARED; } @@ -186,6 +200,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { @Override public void setData(int x, int y, int z, T data) { + setData(x, y, z, data, null); + } + + private void setData(int x, int y, int z, Object data, SetMutation replayed) { if (data == null || y < 0 || y >= worldHeight) { return; } @@ -196,18 +214,21 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { return; } Class type = data instanceof NativeBlockState ? NativeBlockState.class : data.getClass(); + OverlayCell cell = writableCell(x, y, z); if (data instanceof NativeBlockState) { - overlay.put(new DataKey(x, y, z, Identifier.class), CLEARED); + cell.block = data; + cell.identifier = CLEARED; + } else { + cell.put(type, data); } - DataKey key = new DataKey(x, y, z, type); - overlay.put(key, data); - mutations.add(new SetMutation(key, data)); + mutations.add(replayed != null + ? replayed : new SetMutation(new DataKey(x, y, z, type), data)); } @Override public @Nullable T getDataIfPresent(int x, int y, int z, Class type) { - DataKey key = new DataKey(x, y, z, type); - Object value = overlay.get(key); + OverlayCell cell = overlayCell(x, y, z); + Object value = cell == null ? null : cell.get(type); if (value == CLEARED) { return null; } @@ -228,22 +249,22 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } else { carved = carved.clone(); } - for (int y = 0; y < cappedHeight; y++) { - Object block = overlay.get(new DataKey(x, y, z, NativeBlockState.class)); - if (block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) { + Int2ObjectOpenHashMap column = overlay.get(columnKey(x, z)); + if (column == null) { + return carved; + } + for (Int2ObjectMap.Entry entry : column.int2ObjectEntrySet()) { + int y = entry.getIntKey(); + if (y >= cappedHeight) { + continue; + } + OverlayCell cell = entry.getValue(); + if (cell.block instanceof NativeBlockState state && !state.isAir() && !state.isFluid()) { carved[y] = 0; - continue; - } - DataKey hydrologyKey = new DataKey(x, y, z, HydrologyCaveCell.class); - Object hydrologyValue = overlay.get(hydrologyKey); - if (hydrologyValue instanceof HydrologyCaveCell hydrology) { + } else if (cell.hydrology instanceof HydrologyCaveCell hydrology) { carved[y] = hydrology.carves() ? (byte) 1 : 0; - continue; - } - DataKey cavernKey = new DataKey(x, y, z, MatterCavern.class); - Object cavernValue = overlay.get(cavernKey); - if (cavernValue != null) { - carved[y] = cavernValue == CLEARED ? (byte) 0 : (byte) 1; + } else if (cell.cavern != null) { + carved[y] = cell.cavern == CLEARED ? (byte) 0 : (byte) 1; } } return carved; @@ -254,6 +275,30 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { return writer.getEngine(); } + private static long columnKey(int x, int z) { + return ((long) x << 32) | (z & 0xffffffffL); + } + + private OverlayCell overlayCell(int x, int y, int z) { + Int2ObjectOpenHashMap column = overlay.get(columnKey(x, z)); + return column == null ? null : column.get(y); + } + + private OverlayCell writableCell(int x, int y, int z) { + long coordinate = columnKey(x, z); + Int2ObjectOpenHashMap column = overlay.get(coordinate); + if (column == null) { + column = new Int2ObjectOpenHashMap<>(4); + overlay.put(coordinate, column); + } + OverlayCell cell = column.get(y); + if (cell == null) { + cell = new OverlayCell(); + column.put(y, cell); + } + return cell; + } + private Object prerequisiteOrCleared(int x, int y, int z, Class type) { Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type); return value == null ? CLEARED : value; @@ -317,10 +362,61 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } } + private static final class OverlayCell { + private Object block; + private Object hydrology; + private Object cavern; + private Object identifier; + private Object string; + private Object treeMaterial; + private Map, Object> other; + + private Object get(Class type) { + if (type == NativeBlockState.class) { + return block; + } + if (type == HydrologyCaveCell.class) { + return hydrology; + } + if (type == MatterCavern.class) { + return cavern; + } + if (type == Identifier.class) { + return identifier; + } + if (type == String.class) { + return string; + } + if (type == TreeBlockMaterial.class) { + return treeMaterial; + } + return other == null ? null : other.get(type); + } + + private void put(Class type, Object value) { + if (type == HydrologyCaveCell.class) { + hydrology = value; + } else if (type == MatterCavern.class) { + cavern = value; + } else if (type == Identifier.class) { + identifier = value; + } else if (type == String.class) { + string = value; + } else if (type == TreeBlockMaterial.class) { + treeMaterial = value; + } else { + if (other == null) { + other = new HashMap<>(); + } + other.put(type, value); + } + } + } + record DataKey(int x, int y, int z, Class type) { } - interface Mutation { + sealed interface Mutation permits SetMutation, CustomBlockMutation { int x(); int z(); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java index 44357fa10..ef409292d 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java @@ -1,25 +1,47 @@ package art.arcane.iris.generation.mantle; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.ArrayList; import java.util.List; final class ObjectSourcePlan { - private final List mutations; + private static final int DESTINATION_INDEX_WEIGHT = 8; + private final Long2ObjectOpenHashMap> destinations; private final int mutationWeight; ObjectSourcePlan(List mutations) { - this.mutations = List.copyOf(mutations); + Long2ObjectOpenHashMap> grouped = new Long2ObjectOpenHashMap<>(2); long weight = 1L; - for (ObjectDestinationTransaction.Mutation mutation : this.mutations) { + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + long key = destinationKey(mutation.x() >> 4, mutation.z() >> 4); + ArrayList local = grouped.get(key); + if (local == null) { + local = new ArrayList<>(); + grouped.put(key, local); + weight += DESTINATION_INDEX_WEIGHT; + } + local.add(mutation); weight += mutation.weight(); } + destinations = new Long2ObjectOpenHashMap<>(grouped.size()); + for (Long2ObjectMap.Entry> entry : grouped.long2ObjectEntrySet()) { + destinations.put(entry.getLongKey(), List.copyOf(entry.getValue())); + } this.mutationWeight = weight >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) weight; } - List mutations() { - return mutations; + List mutationsFor(int chunkX, int chunkZ) { + List mutations = destinations.get(destinationKey(chunkX, chunkZ)); + return mutations == null ? List.of() : mutations; } int mutationWeight() { return mutationWeight; } + + private static long destinationKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); + } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java index b0a0d38ac..800dec793 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCache.java @@ -2,16 +2,21 @@ package art.arcane.iris.generation.mantle; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import art.arcane.volmlib.util.cache.CacheKey; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import static art.arcane.iris.generation.cache.Cache.key; final class ObjectSourcePlanCache { - static final long MAXIMUM_MUTATION_WEIGHT = 1_048_576L; + static final long MAXIMUM_MUTATION_WEIGHT = 4_194_304L; - private final Cache plans; + private final long maximumMutationWeight; + private volatile State state; ObjectSourcePlanCache() { this(MAXIMUM_MUTATION_WEIGHT); @@ -21,23 +26,88 @@ final class ObjectSourcePlanCache { if (maximumMutationWeight <= 0L) { throw new IllegalArgumentException("Maximum mutation weight must be positive"); } - this.plans = Caffeine.newBuilder() - .maximumWeight(maximumMutationWeight) - .weigher((Long key, ObjectSourcePlan plan) -> plan.mutationWeight()) - .build(); + this.maximumMutationWeight = maximumMutationWeight; + state = new State(maximumMutationWeight); } ObjectSourcePlan get(int sourceChunkX, int sourceChunkZ, Supplier builder) { Objects.requireNonNull(builder, "Source plan builder"); - return plans.get(key(sourceChunkX, sourceChunkZ), ignored -> builder.get()); + State current = state; + long source = CacheKey.mix(key(sourceChunkX, sourceChunkZ)); + ObjectSourcePlan cached = current.plans.getIfPresent(source); + if (cached != null) { + return cached; + } + Pending created = new Pending(Thread.currentThread()); + Pending pending = current.pending.putIfAbsent(source, created); + if (pending != null) { + return pending.await(); + } + try { + ObjectSourcePlan plan = current.plans.getIfPresent(source); + if (plan == null) { + plan = builder.get(); + if (plan != null) { + current.plans.put(source, plan); + } + } + created.result.complete(plan); + return plan; + } catch (RuntimeException | Error failure) { + created.failure = failure; + created.result.completeExceptionally(failure); + throw failure; + } finally { + current.pending.remove(source, created); + } } void clear() { - plans.invalidateAll(); + state = new State(maximumMutationWeight); } long estimatedSize() { - plans.cleanUp(); - return plans.estimatedSize(); + State current = state; + current.plans.cleanUp(); + return current.plans.estimatedSize(); + } + + private static final class State { + private final Cache plans; + private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); + + private State(long maximumMutationWeight) { + plans = Caffeine.newBuilder() + .maximumWeight(maximumMutationWeight) + .weigher((Long key, ObjectSourcePlan plan) -> plan.mutationWeight()) + .build(); + } + } + + private static final class Pending { + private final Thread owner; + private final CompletableFuture result = new CompletableFuture<>(); + private Throwable failure; + + private Pending(Thread owner) { + this.owner = owner; + } + + private ObjectSourcePlan await() { + if (owner == Thread.currentThread()) { + throw new IllegalStateException("Recursive source plan construction"); + } + try { + return result.join(); + } catch (CompletionException completion) { + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + throw completion; + } + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java b/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java index 7a5d2b416..661bec054 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java @@ -8,6 +8,7 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; import java.util.Objects; import java.util.ArrayList; @@ -117,6 +118,7 @@ public final class TerrainMatterView { } private static T raw(Matter matter, int x, int y, int z, Class type) { - return !matter.hasSlice(type) ? null : matter.getSlice(type).get(x & 15, y & 15, z & 15); + MatterSlice slice = matter.getSlice(type); + return slice == null ? null : slice.get(x & 15, y & 15, z & 15); } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java index 64c44e2ca..76300e75d 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java @@ -35,6 +35,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Iterator; import java.util.Set; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** @@ -52,6 +54,7 @@ final class EngineShutdownSequence { Collections.newSetFromMap(new IdentityHashMap<>())); private final Set incompleteTargets = Collections.synchronizedSet( Collections.newSetFromMap(new IdentityHashMap<>())); + private final Map> detachedRuntimeCloses = new IdentityHashMap<>(); private boolean runtimeReleased; private boolean worldManagerStopped; private boolean targetReleased; @@ -66,7 +69,7 @@ final class EngineShutdownSequence { generationAdmissions.add(history.retainRuntime()); } - void close() { + synchronized void close() { if (!engine.beginShutdown()) { return; } @@ -91,91 +94,93 @@ final class EngineShutdownSequence { } engine.lifecycleState = LifecycleState.CLOSING; engine.getClosing().set(true); - engine.backgroundTasks.closeBackgroundTaskAdmission(); - EngineTickRegistry.unregisterTicking(engine); - // Best-effort like every other step: a pregen join timeout must not escape the - // synchronized block before anything is saved or released. On failure, save what - // can be saved and leave the close incomplete-but-retryable (a live pregen writer - // may still hold the mantle, so the releases are skipped). - Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine)); - if (pregenFailure != null) { - engine.lifecycleState = LifecycleState.FAILED; - failure = pregenFailure; - failure = runCleanup(failure, engine::savePrefetchOnce); - failure = runCleanup(failure, engine::saveEngineData); - failure = runCleanup(failure, () -> engine.getMantle().saveAllNow()); - reportIncompleteClose(failure); - return; - } - Throwable drainFailure = null; + } + engine.backgroundTasks.closeBackgroundTaskAdmission(); + EngineTickRegistry.unregisterTicking(engine); + // Best-effort like every other step: a pregen join timeout must not escape the + // shutdown before anything is saved or released. On failure, save what + // can be saved and leave the close incomplete-but-retryable (a live pregen writer + // may still hold the mantle, so the releases are skipped). + Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine)); + if (pregenFailure != null) { + engine.lifecycleState = LifecycleState.FAILED; + failure = pregenFailure; + failure = runCleanup(failure, engine::savePrefetchOnce); + failure = runCleanup(failure, engine::saveEngineData); + failure = runCleanup(failure, () -> engine.getMantle().saveAllNow()); + reportIncompleteClose(failure); + return; + } + Throwable drainFailure = null; + try { + engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); + } catch (GenerationSessionException e) { + drainFailure = e; + } + if (drainFailure != null) { + // A drain timeout must not abandon teardown: give remaining admitted work + // one final bounded drain before deciding whether resources can be released. + IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name() + + "; waiting briefly for remaining work."); try { - engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); + engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true); + drainFailure = null; } catch (GenerationSessionException e) { - drainFailure = e; + drainFailure = appendFailure(drainFailure, e); } - if (drainFailure != null) { - // A drain timeout must not abandon teardown: give remaining admitted work - // one final bounded drain before deciding whether resources can be released. - IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name() - + "; waiting briefly for remaining work."); - try { - engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true); - drainFailure = null; - } catch (GenerationSessionException e) { - drainFailure = appendFailure(drainFailure, e); + } + if (drainFailure != null) { + // A live lease may be mid-write, so the mantle must not be closed at it — but + // dirty plates can still be flushed (saveAll is synchronized) so terrain since + // the last periodic save is not lost. The close stays incomplete and retryable. + engine.lifecycleState = LifecycleState.FAILED; + failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow()); + reportIncompleteClose(failure); + return; + } + + BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close"); + failure = backgroundDrain.failure(); + if (!backgroundDrain.allowsResourceRelease() && failure == null) { + failure = new IllegalStateException("Iris background tasks remain active during close."); + } + + if (backgroundDrain.allowsResourceRelease()) { + Throwable ownershipFailure = runCleanup(null, + () -> NativeStructureOwnershipStore.close(engine)); + failure = appendFailure(failure, ownershipFailure); + if (ownershipFailure == null) { + Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce); + Throwable engineDataFailure = runCleanup(null, engine::saveEngineData); + failure = appendFailure(failure, prefetchFailure); + failure = appendFailure(failure, engineDataFailure); + failure = releaseRuntime(failure); + if (runtimeReleased) { + failure = releaseTarget(failure); + } + if (prefetchFailure == null + && engineDataFailure == null + && runtimeReleased + && targetReleased) { + failure = releaseEngineDataForShutdown(failure); + } + if (engineDataReleased) { + failure = releasePreservation(failure); } } - if (drainFailure != null) { - // A live lease may be mid-write, so the mantle must not be closed at it — but - // dirty plates can still be flushed (saveAll is synchronized) so terrain since - // the last periodic save is not lost. The close stays incomplete and retryable. - engine.lifecycleState = LifecycleState.FAILED; - failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow()); - reportIncompleteClose(failure); - return; - } - - BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close"); - failure = backgroundDrain.failure(); - if (!backgroundDrain.allowsResourceRelease() && failure == null) { - failure = new IllegalStateException("Iris background tasks remain active during close."); - } - - if (backgroundDrain.allowsResourceRelease()) { - Throwable ownershipFailure = runCleanup(null, - () -> NativeStructureOwnershipStore.close(engine)); - failure = appendFailure(failure, ownershipFailure); - if (ownershipFailure == null) { - Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce); - Throwable engineDataFailure = runCleanup(null, engine::saveEngineData); - failure = appendFailure(failure, prefetchFailure); - failure = appendFailure(failure, engineDataFailure); - failure = releaseRuntime(failure); - if (runtimeReleased) { - failure = releaseTarget(failure); - } - if (prefetchFailure == null - && engineDataFailure == null - && runtimeReleased - && targetReleased) { - failure = releaseEngineDataForShutdown(failure); - } - if (engineDataReleased) { - failure = releasePreservation(failure); - } - } - } - if (failure == null - && runtimeReleased - && targetReleased - && engineDataReleased - && preservationReleased) { - failure = releaseGenerationAdmissions(failure); - if (failure == null) { + } + if (failure == null + && runtimeReleased + && targetReleased + && engineDataReleased + && preservationReleased) { + failure = releaseGenerationAdmissions(failure); + if (failure == null) { + synchronized (engine.lifecycleLock) { engine.closed = true; engine.lifecycleState = LifecycleState.CLOSED; - IrisLogging.debug("Engine Fully Shutdown!"); } + IrisLogging.debug("Engine Fully Shutdown!"); } } if (failure != null) { @@ -359,6 +364,38 @@ final class EngineShutdownSequence { return failure; } + DetachedRuntimeClose claimDetachedRuntimeClose(GenerationRuntime runtime, boolean rejectConcurrent) { + synchronized (engine.lifecycleLock) { + if (!engine.detachedGenerationRuntimes.contains(runtime)) { + return new DetachedRuntimeClose(CompletableFuture.completedFuture(null), false); + } + CompletableFuture existing = detachedRuntimeCloses.get(runtime); + if (existing != null) { + if (rejectConcurrent) { + throw new IllegalStateException("Detached Iris generation runtime is already retiring."); + } + return new DetachedRuntimeClose(existing, false); + } + CompletableFuture completion = new CompletableFuture<>(); + detachedRuntimeCloses.put(runtime, completion); + if (rejectConcurrent) { + engine.retiringGenerationRuntimes.add(runtime); + } + return new DetachedRuntimeClose(completion, true); + } + } + + void finishDetachedRuntimeClose(GenerationRuntime runtime, DetachedRuntimeClose close, Throwable failure) { + synchronized (engine.lifecycleLock) { + if (failure == null) { + engine.detachedGenerationRuntimes.remove(runtime); + } + engine.retiringGenerationRuntimes.remove(runtime); + close.completion().complete(failure); + detachedRuntimeCloses.remove(runtime); + } + } + Throwable closeDetachedGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) { IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding( engine, @@ -509,15 +546,28 @@ final class EngineShutdownSequence { private Throwable closeDetachedGenerationRuntimes(Throwable failure) { GenerationRuntime[] detachedRuntimes = engine.detachedGenerationRuntimes.toArray(new GenerationRuntime[0]); for (GenerationRuntime detached : detachedRuntimes) { - Throwable detachedFailure = closeDetachedGenerationRuntime(detached, null); - failure = appendFailure(failure, detachedFailure); - if (detachedFailure == null) { - engine.detachedGenerationRuntimes.remove(detached); + DetachedRuntimeClose close = claimDetachedRuntimeClose(detached, false); + Throwable detachedFailure; + if (close.owner()) { + detachedFailure = null; + try { + detachedFailure = closeDetachedGenerationRuntime(detached, null); + } catch (Throwable closeFailure) { + detachedFailure = closeFailure; + } finally { + finishDetachedRuntimeClose(detached, close, detachedFailure); + } + } else { + detachedFailure = close.completion().join(); } + failure = appendFailure(failure, detachedFailure); } return failure; } + record DetachedRuntimeClose(CompletableFuture completion, boolean owner) { + } + private Throwable releaseTarget(Throwable failure) { if (targetReleased) { return failure; diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 98aaf66a4..7e0c49ac6 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -63,6 +63,7 @@ import art.arcane.iris.generation.hydrology.IrisRiverProfile; import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle; import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.terrain.Terrain3DRuntime; +import art.arcane.iris.generation.terrain.ProceduralTerrainHeightSampler; import art.arcane.iris.generation.terrain.HydrologyBankTerrainRuntime; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; @@ -113,9 +114,9 @@ public class IrisComplex implements DataProvider { private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int STUDIO_NOISE_CACHE_SIZE = 32_768; + private static final int TERRAIN_COLUMN_CACHE_SIZE = 65_536; /** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20; - private static final int HEIGHT_BOUNDS_GRID = 4; /** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */ private static final double SLOPE_RUN = 3D; /** A sheer coast would let shoreMinimumWidth buy unbounded height, so the shore band stops climbing here. */ @@ -135,6 +136,8 @@ public class IrisComplex implements DataProvider { }; @Getter(AccessLevel.NONE) private final transient ThreadLocal gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); + @Getter(AccessLevel.NONE) + private final int biomeBoundsSamplingStep; private transient volatile SharedCornerBounds sharedCornerBounds = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); @Getter(AccessLevel.NONE) private final transient IrisInterpolator[] frozenInterpolators; @@ -178,6 +181,8 @@ public class IrisComplex implements DataProvider { private ProceduralStream naturalHeightStream; private ProceduralStream baseTerrainHeightStream; @Getter(AccessLevel.NONE) + private final ProceduralTerrainHeightSampler proceduralTerrainHeight; + @Getter(AccessLevel.NONE) private final transient Engine terrainEngine; @Getter(AccessLevel.NONE) private transient Terrain3DRuntime terrain3D; @@ -240,6 +245,7 @@ public class IrisComplex implements DataProvider { IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) { terrainEngine = engine; + biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep(); this.detached = detached; this.transitionGenerationPlan = transitionGenerationPlan; this.resolvedTerrain = new ResolvedTerrainProvider(engine); @@ -379,8 +385,11 @@ public class IrisComplex implements DataProvider { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null ? biome : mapped; }), "imageMappedBaseBiomeStream", engine, cacheSize); + proceduralTerrainHeight = new ProceduralTerrainHeightSampler( + (x, z) -> getHeight(engine, x, z, engine.getSeedManager().getHeight()), + engine.getDimension().getTerrainSamplingStep()); baseTerrainHeightStream = GenerationStreams.cache2DDouble(ProceduralStream.of( - (x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z), + this::sampleUnblendedNaturalTerrainHeight, Interpolated.DOUBLE ), "baseTerrainHeightStream", engine, cacheSize); boolean terrain3DEnabled = false; @@ -395,7 +404,7 @@ public class IrisComplex implements DataProvider { (x, z) -> baseTerrainHeightStream.getDouble(x, z), this::sampleTerrain3DBiome), new Terrain3DRuntime.Options(engine.getSeedManager().getTerrain(), engine.getHeight(), - fluidHeight, data, terrain3DEnabled, Math.max(4_096, cacheSize))); + fluidHeight, data, terrain3DEnabled, TERRAIN_COLUMN_CACHE_SIZE)); unblendedNaturalHeightStream = terrain3DEnabled ? GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::sampleTerrain3DHeight), "unblendedNaturalHeightStream", engine, cacheSize) : baseTerrainHeightStream; @@ -634,12 +643,11 @@ public class IrisComplex implements DataProvider { private double sampleNaturalTerrainHeight(Engine engine, double x, double z) { return terrain3D != null && terrain3D.active() ? sampleTerrain3DHeight(x, z) - : sampleUnblendedNaturalTerrainHeight(engine, x, z); + : sampleUnblendedNaturalTerrainHeight(x, z); } private double sampleTerrain3DHeight(double x, double z) { - Terrain3DColumn column = terrain3D.column(blockCoordinate(x), blockCoordinate(z)); - return column.shaped() ? column.topY() : column.baseHeight(); + return terrain3D.height(blockCoordinate(x), blockCoordinate(z)); } private IrisBiome focusedBiomeAt(double x, double z) { @@ -735,8 +743,8 @@ public class IrisComplex implements DataProvider { return column == null ? getPlacementHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY); } - private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) { - double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight()); + private double sampleUnblendedNaturalTerrainHeight(double x, double z) { + double proceduralHeight = proceduralTerrainHeight.sample(x, z); return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); } @@ -1279,11 +1287,8 @@ public class IrisComplex implements DataProvider { return null; } - public double sampleProceduralTerrainHeight(Engine engine, double worldX, double worldZ) { - if (engine == null) { - throw new IllegalArgumentException("Engine is required to sample procedural terrain height"); - } - return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight()); + public double sampleProceduralTerrainHeight(double worldX, double worldZ) { + return proceduralTerrainHeight.sample(worldX, worldZ); } private IrisDecorator decorateFor(IrisBiome b, double x, double z, IrisDecorationPart part) { @@ -1439,7 +1444,7 @@ public class IrisComplex implements DataProvider { if (biomeBuffet != null && !biomeBuffet.cells().isEmpty()) { return generatorBounds.get(interpolator).get(focusedBiomeAt(x, z)).noiseBounds; } - int grid = HEIGHT_BOUNDS_GRID; + int grid = biomeBoundsSamplingStep; GridBoundsCache cache = gridBoundsCache.get(); if (grid <= 1) { return sampleBoundsRaw(cache, engine, interpolator, generators, x, z); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 189bbc5a0..841e5f0f9 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -1166,6 +1166,7 @@ public class IrisEngine implements Engine { public void closeDetachedGenerationRuntime(GenerationRuntimeBinding binding) { GenerationRuntimeBinding required = Objects.requireNonNull(binding, "detached generation runtime binding"); GenerationRuntime retired; + EngineShutdownSequence.DetachedRuntimeClose retirement; synchronized (lifecycleLock) { if (required.engine != this) { throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine."); @@ -1177,19 +1178,17 @@ public class IrisEngine implements Engine { if (!detachedGenerationRuntimes.contains(required.runtime)) { return; } - if (!retiringGenerationRuntimes.add(required.runtime)) { - throw new IllegalStateException("Detached Iris generation runtime is already retiring."); - } retired = required.runtime; + retirement = shutdownSequence.claimDetachedRuntimeClose(retired, true); } - Throwable failure = retireGenerationRuntimeCaches(retired.cacheId(), null); + Throwable failure = null; try { + failure = retireGenerationRuntimeCaches(retired.cacheId(), null); failure = shutdownSequence.closeDetachedGenerationRuntime(retired, failure); + } catch (Throwable closeFailure) { + failure = EngineShutdownSequence.appendFailure(failure, closeFailure); } finally { - synchronized (lifecycleLock) { - detachedGenerationRuntimes.remove(retired); - retiringGenerationRuntimes.remove(retired); - } + shutdownSequence.finishDetachedRuntimeClose(retired, retirement, failure); } if (failure != null) { throw new IllegalStateException("Failed to close a detached Iris generation runtime.", failure); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index 5b05d8f63..99d90c30f 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -24,6 +24,10 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout; import art.arcane.iris.generation.runtime.UpperDimensionContext; import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.iris.generation.runtime.EngineAssignedModifier; import art.arcane.iris.generation.mantle.TerrainMatterView; import art.arcane.iris.generation.terrain.InferredType; @@ -37,6 +41,7 @@ import art.arcane.iris.generation.hydrology.IrisRiverMaterialConfig; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.block.B; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.hunk.Hunk; @@ -83,7 +88,7 @@ public class IrisCarveModifier extends EngineAssignedModifier public void onModify(int x, int z, Hunk output, boolean multicore, ChunkContext context) { PrecisionStopwatch caveStopwatch = PrecisionStopwatch.start(); Mantle mantle = getEngine().getMantle().getMantle(); - IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); + CaveInputs resolverState = new CaveInputs(getEngine()); Long2ObjectOpenHashMap caveBiomeCache = new Long2ObjectOpenHashMap<>(2048); IrisCarveScratch scratch = SCRATCH.get(); scratch.reset(); @@ -97,7 +102,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int[] upperSurfaceHeights = protectUpper ? scratch.getOrCreateUpperSurfaceHeights() : null; DimensionStackContext stackContext = getEngine().getDimensionStackContext(); DimensionStackLayout[] stackLayouts = stackContext == null ? null : new DimensionStackLayout[256]; - Terrain3DColumn[] terrainColumns = getComplex().hasTerrain3D() ? new Terrain3DColumn[256] : null; + Terrain3DColumn[] terrainColumns = resolverState.complex.hasTerrain3D() ? new Terrain3DColumn[256] : null; int chunkBlockX = PowerOfTwoCoordinates.chunkToBlock(x); int chunkBlockZ = PowerOfTwoCoordinates.chunkToBlock(z); for (int columnIndex = 0; columnIndex < 256; columnIndex++) { @@ -105,7 +110,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int localZ = columnIndex & 15; surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ); if (terrainColumns != null) { - terrainColumns[columnIndex] = getComplex().terrainColumn(chunkBlockX + localX, chunkBlockZ + localZ); + terrainColumns[columnIndex] = resolverState.complex.terrainColumn(chunkBlockX + localX, chunkBlockZ + localZ); } if (protectUpper) { int worldX = localX + chunkBlockX; @@ -594,7 +599,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int columnIndex, int chunkX, int chunkZ, - IrisDimensionCarvingResolver.State resolverState, + CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache ) { @@ -617,12 +622,12 @@ public class IrisCarveModifier extends EngineAssignedModifier int y = firstHeight; while (y >= 0) { - if (y <= getEngine().getHeight()) { + if (y <= resolverState.height) { if (y == buf + 1) { buf = y; zone.ceiling = buf; } else { - if (zone.isValid(getEngine())) { + if (zone.isValid(resolverState.height)) { processZone(output, mc, mantle, zone, rx, rz, worldX, worldZ, resolverState, caveBiomeCache, customBiomeCache); } @@ -635,7 +640,7 @@ public class IrisCarveModifier extends EngineAssignedModifier y = columnMask.nextSetBit(y + 1); } - if (zone.isValid(getEngine())) { + if (zone.isValid(resolverState.height)) { processZone(output, mc, mantle, zone, rx, rz, worldX, worldZ, resolverState, caveBiomeCache, customBiomeCache); } @@ -649,7 +654,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int columnIndex, int chunkX, int chunkZ, - IrisDimensionCarvingResolver.State resolverState, + CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache ) { @@ -692,7 +697,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int worldZ, int zoneFloor, int zoneCeiling, - IrisDimensionCarvingResolver.State resolverState, + CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache ) { @@ -711,7 +716,7 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, rx, zoneFloor, rz, HydrologyCaveCell.class); IrisRiverMaterialConfig bedMaterial = undergroundBedMaterial(); KList floorLayers = floorBiome.generateLayers( - getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex()); + resolverState.dimension, worldX, worldZ, rng, 3, zoneFloor, resolverState.data, resolverState.complex); for (int i = 0; i < zoneFloor - 1; i++) { if (!floorLayers.hasIndex(i)) { break; @@ -732,7 +737,7 @@ public class IrisCarveModifier extends EngineAssignedModifier output, rx, floorY, rz, paintUndergroundBedMaterial( floorLayers.get(i), bedMaterial, floorHydrology, i, - rng, worldX, floorY, worldZ, getData()), + rng, worldX, floorY, worldZ, resolverState.data), floorHydrology); if (!B.isSolid(existing) || !canReplaceHydrologyGuard(hydrology, layer, false) @@ -750,7 +755,7 @@ public class IrisCarveModifier extends EngineAssignedModifier if (ceilingBiome != null) { int worldMaxY = getEngine().getWorld().maxHeight() - getEngine().getWorld().minHeight(); KList ceilingLayers = ceilingBiome.generateCeilingLayers( - getDimension(), worldX, worldZ, rng, 3, zoneCeiling, getData(), getComplex()); + resolverState.dimension, worldX, worldZ, rng, 3, zoneCeiling, resolverState.data, resolverState.complex); for (int i = 0; i < ceilingLayers.size(); i++) { int ceilingY = zoneCeiling + i + 1; if (ceilingY >= worldMaxY) { @@ -794,7 +799,7 @@ public class IrisCarveModifier extends EngineAssignedModifier private void processZone(Hunk output, MantleChunk mc, Mantle mantle, CaveZone zone, int rx, int rz, int xx, int zz, - IrisDimensionCarvingResolver.State resolverState, + CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { int maxY = output.getHeight(); @@ -818,7 +823,7 @@ public class IrisCarveModifier extends EngineAssignedModifier HydrologyCaveCell floorHydrology = dataIfPresent( mc, rx, zone.floor, rz, HydrologyCaveCell.class); IrisRiverMaterialConfig bedMaterial = undergroundBedMaterial(); - KList floorBlocks = floorBiome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex()); + KList floorBlocks = floorBiome.generateLayers(resolverState.dimension, xx, zz, rng, 3, zone.floor, resolverState.data, resolverState.complex); for (int i = 0; i < zone.floor - 1; i++) { if (!floorBlocks.hasIndex(i)) { break; @@ -834,7 +839,7 @@ public class IrisCarveModifier extends EngineAssignedModifier output, rx, y, rz, paintUndergroundBedMaterial( floorBlocks.get(i), bedMaterial, floorHydrology, i, - rng, xx, y, zz, getData()), + rng, xx, y, zz, resolverState.data), floorHydrology); NativeBlockState existing = output.getRaw(rx, y, rz); if (!B.isSolid(existing) @@ -851,7 +856,7 @@ public class IrisCarveModifier extends EngineAssignedModifier } if (ceilingBiome != null) { - KList ceilingBlocks = ceilingBiome.generateCeilingLayers(getDimension(), xx, zz, rng, 3, zone.ceiling, getData(), getComplex()); + KList ceilingBlocks = ceilingBiome.generateCeilingLayers(resolverState.dimension, xx, zz, rng, 3, zone.ceiling, resolverState.data, resolverState.complex); for (int i = 0; i < ceilingBlocks.size(); i++) { int cy = zone.ceiling + i + 1; if (cy >= maxY) { @@ -882,7 +887,7 @@ public class IrisCarveModifier extends EngineAssignedModifier public void decorateNaturalCaves(int blockX, int blockZ, Hunk output) { Mantle mantle = getEngine().getMantle().getMantle(); MantleChunk chunk = mantle.getChunk(blockX >> 4, blockZ >> 4).use(); - IrisDimensionCarvingResolver.State resolver = new IrisDimensionCarvingResolver.State(); + CaveInputs resolver = new CaveInputs(getEngine()); Long2ObjectOpenHashMap caveBiomes = new Long2ObjectOpenHashMap<>(256); Map customBiomes = new HashMap<>(); try { @@ -901,7 +906,7 @@ public class IrisCarveModifier extends EngineAssignedModifier CaveZone zone = new CaveZone(); zone.setFloor(floor); zone.setCeiling(y - 1); - if (zone.isValid(getEngine()) + if (zone.isValid(resolver.height) && !getComplex().isTerrain3DOpening(worldX, floor, worldZ)) { if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) { mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING); @@ -985,15 +990,15 @@ public class IrisCarveModifier extends EngineAssignedModifier } } - IrisBiome resolveCaveBoundaryBiome(MantleChunk mantleChunk, int x, int y, int z, int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { + IrisBiome resolveCaveBoundaryBiome(MantleChunk mantleChunk, int x, int y, int z, int worldX, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { MatterCavern cavern = composedCavernAt(mantleChunk, x, y, z); return resolveCaveBoundaryBiome( cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache); } - IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { - if (getComplex().isTerrain3DOpening(worldX, y, worldZ)) { - return getComplex().getTrueBiomeStream().get(worldX, worldZ); + IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { + if (resolverState.complex.isTerrain3DOpening(worldX, y, worldZ)) { + return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ); } if (cavern != null && !cavern.getCustomBiome().isEmpty()) { return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome()); @@ -1105,7 +1110,7 @@ public class IrisCarveModifier extends EngineAssignedModifier return IrisProceduralBlocks.isGravityAffected(state); } - private IrisBiome resolveCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) { + private IrisBiome resolveCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, CaveInputs resolverState) { IrisBiome center = sampleCaveBiome(caveBiomeCache, x, y, z, resolverState); if (center == null) { return null; @@ -1125,14 +1130,14 @@ public class IrisCarveModifier extends EngineAssignedModifier return neighbor != null ? neighbor : center; } - private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) { + private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, CaveInputs resolverState) { long key = BlockPosition.toLong(x, y, z); IrisBiome cachedBiome = caveBiomeCache.get(key); if (cachedBiome != null) { return cachedBiome; } - IrisBiome resolvedBiome = getEngine().getCaveBiome(x, y, z, resolverState); + IrisBiome resolvedBiome = resolverState.resolve(x, y, z); if (resolvedBiome != null) { caveBiomeCache.put(key, resolvedBiome); } @@ -1149,6 +1154,79 @@ public class IrisCarveModifier extends EngineAssignedModifier return loaded; } + static final class CaveInputs { + private final Engine engine; + private final IrisComplex complex; + private final IrisDimension dimension; + private final IrisData data; + private final int height; + private final IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + private final IrisDimensionCarvingResolver.Snapshot snapshot; + private final int minimumY; + private final DimensionStackContext stack; + private final ProceduralStream surfaceBiomes; + private final ProceduralStream caveBiomes; + private final ProceduralStream heights; + private final CaveColumn[] columns; + + CaveInputs(Engine engine) { + this.engine = engine; + complex = engine.getComplex(); + dimension = engine.getDimension(); + data = engine.getData(); + height = engine.getHeight(); + IrisContext context = IrisContext.get(); + boolean scoped = engine instanceof IrisEngine irisEngine + && irisEngine.hasGenerationRuntimeScope() && !engine.getPlatformHooks().isMainThread() + && context != null && context.getChunkContext() != null + && context.getChunkContext().getComplex() == complex + && context.getChunkContext().isNaturalTerrain(); + snapshot = scoped ? IrisDimensionCarvingResolver.snapshot(engine) : null; + minimumY = scoped ? engine.getWorld().minHeight() : 0; + stack = scoped ? engine.getDimensionStackContext() : null; + surfaceBiomes = scoped ? complex.getTrueBiomeStream() : null; + caveBiomes = scoped ? complex.getCaveBiomeStream() : null; + heights = scoped ? complex.getHeightStream() : null; + columns = scoped ? new CaveColumn[1024] : null; + } + + IrisBiome resolve(int x, int y, int z) { + if (snapshot == null) { + return engine.getCaveBiome(x, y, z, state); + } + IrisBiome configured = snapshot.resolveBiome(x, y + minimumY, z); + if (configured != null) { + return configured; + } + long key = ((long) x << 32) | (z & 0xffffffffL); + int index = (x * 31 + z) & (columns.length - 1); + CaveColumn column = columns[index]; + if (column == null || column.key != key) { + IrisBiome surface = surfaceBiomes.get(x, z); + IrisBiome cave = caveBiomes.get(x, z); + int surfaceY = heights.get(x, z).intValue(); + if (cave == null || cave.getLoadKey() == null) { + cave = surface; + if (stack != null) { + DimensionStackLayout.Layer layer = stack.getLayout(x, z).surfaceLayer(); + if (layer != null && layer.biome() != null) { + cave = layer.biome(); + } + } + } + column = new CaveColumn(key, surface, cave, surfaceY, + cave == null ? 0 : Math.max(0, cave.getCaveMinDepthBelowSurface())); + columns[index] = column; + } + int depth = column.surfaceY - y; + return column.cave == null || depth <= 0 || depth < column.minimumDepth + ? column.surface : column.cave; + } + } + + private record CaveColumn(long key, IrisBiome surface, IrisBiome cave, int surfaceY, int minimumDepth) { + } + @Data public static class CaveZone { private int ceiling = -1; @@ -1158,8 +1236,8 @@ public class IrisCarveModifier extends EngineAssignedModifier return (ceiling - floor) - 1; } - public boolean isValid(Engine engine) { - return floor < ceiling && ceiling - floor >= 1 && floor >= 0 && ceiling <= engine.getHeight() && airThickness() > 0; + public boolean isValid(int height) { + return floor < ceiling && ceiling - floor >= 1 && floor >= 0 && ceiling <= height && airThickness() > 0; } public String toString() { diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java index 41c648e0f..e38ed4560 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java @@ -176,10 +176,9 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex); if (topBlocks == null || topBlocks.isEmpty()) { topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex); @@ -188,6 +187,11 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier topBlocks; private final KList bottomBlocks; @@ -230,7 +234,6 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier paletteContexts = new IdentityHashMap<>(); IdentityHashMap topDepthByEntry = new IdentityHashMap<>(); @@ -246,7 +249,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier entries = dimension.getCarving(); if (entries == null || entries.isEmpty()) { resolvedState.rootEntriesByWorldY.put(worldY, null); @@ -115,10 +123,19 @@ public final class IrisDimensionCarvingResolver { return rootEntry; } + ColumnResolutions columns = resolvedState.columns; + int slot = ((worldX & 31) << 5) | (worldZ & 31); + long coordinate = ((long) worldX << 32) | (worldZ & 0xffffffffL); + if (columns != null && columns.roots[slot] == rootEntry && columns.coordinates[slot] == coordinate) { + return columns.entries[slot]; + } + Map entryIndex = resolveEntryIndex(engine, resolvedState); IrisDimensionCarvingEntry current = rootEntry; int depth = remainingDepth; + boolean cacheable = true; while (depth > 0) { + cacheable &= fixedChildStyle(current); IrisDimensionCarvingEntry selected = selectChild(engine, data, current, worldX, worldZ, entryIndex, resolvedState); if (selected == null || selected == current) { break; @@ -132,9 +149,27 @@ public final class IrisDimensionCarvingResolver { current = selected; } + if (cacheable) { + if (columns == null) { + columns = new ColumnResolutions(); + resolvedState.columns = columns; + } + columns.coordinates[slot] = coordinate; + columns.entries[slot] = current; + columns.roots[slot] = rootEntry; + } return current; } + private static boolean fixedChildStyle(IrisDimensionCarvingEntry entry) { + for (IrisGeneratorStyle style = entry.getChildStyle(); style != null; style = style.getFracture()) { + if (style.getExpression() != null) { + return false; + } + } + return true; + } + private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) { if (state.biomeCache.containsKey(entry)) { return state.biomeCache.get(entry); @@ -275,6 +310,31 @@ public final class IrisDimensionCarvingResolver { return replacement; } + public static final class Snapshot { + private final Engine engine; + private final State state = new State(); + private final IrisDimension dimension; + private final IrisData data; + + private Snapshot(Engine engine) { + this.engine = engine; + this.dimension = engine.getDimension(); + this.data = engine.getData(); + state.entryIndex = dimension.getCarvingEntryIndex(); + } + + public IrisBiome resolveBiome(int worldX, int worldY, int worldZ) { + IrisDimensionCarvingEntry root = state.rootEntriesByWorldY.containsKey(worldY) + ? state.rootEntriesByWorldY.get(worldY) + : selectBoundRootEntry(dimension, worldY, data, state); + if (root == null) { + return null; + } + IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, state); + return resolved == null ? null : resolveBoundEntryBiome(data, resolved, state); + } + } + public static final class State { private final Map rootEntriesByWorldY = new HashMap<>(); private final Map> selectionPlans = new IdentityHashMap<>(); @@ -284,6 +344,7 @@ public final class IrisDimensionCarvingResolver { private WeakReference dataIdentity; private Map entryIndex; private Long childSeed; + private ColumnResolutions columns; private IrisData bind(Engine engine) { IrisDimension dimension = engine.getDimension(); @@ -301,6 +362,7 @@ public final class IrisDimensionCarvingResolver { biomeCache.clear(); entryIndex = null; childSeed = null; + columns = null; return data; } @@ -309,6 +371,12 @@ public final class IrisDimensionCarvingResolver { } } + private static final class ColumnResolutions { + private final long[] coordinates = new long[1024]; + private final IrisDimensionCarvingEntry[] roots = new IrisDimensionCarvingEntry[1024]; + private final IrisDimensionCarvingEntry[] entries = new IrisDimensionCarvingEntry[1024]; + } + private static final class CarvingChoice implements Rarity { private final IrisDimensionCarvingEntry entry; private final int rarity; diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSampler.java b/core/src/main/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSampler.java new file mode 100644 index 000000000..b0b7f6451 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSampler.java @@ -0,0 +1,76 @@ +package art.arcane.iris.generation.terrain; + +import art.arcane.volmlib.util.cache.CacheKey; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.function.DoubleBinaryOperator; + +public final class ProceduralTerrainHeightSampler { + private static final int MAXIMUM_NODES = 262_144; + + private final DoubleBinaryOperator source; + private final int step; + private final AtomicReferenceArray nodes; + + public ProceduralTerrainHeightSampler(DoubleBinaryOperator source, int step) { + if (step != 1 && step != 2 && step != 4 && step != 8) { + throw new IllegalArgumentException("Terrain sampling step must be 1, 2, 4, or 8"); + } + this.source = Objects.requireNonNull(source, "Procedural terrain height source"); + this.step = step; + nodes = step == 1 ? null : new AtomicReferenceArray<>(MAXIMUM_NODES); + } + + public double sample(double x, double z) { + if (step == 1) { + return source.applyAsDouble(x, z); + } + double lowerX = Math.floor(x / step) * step; + double lowerZ = Math.floor(z / step) * step; + double dx = (x - lowerX) / step; + double dz = (z - lowerZ) / step; + double northWest = node(lowerX, lowerZ); + double north = dx == 0D ? northWest + : northWest + (node(lowerX + step, lowerZ) - northWest) * dx; + if (dz == 0D) { + return north; + } + double southWest = node(lowerX, lowerZ + step); + double south = dx == 0D ? southWest + : southWest + (node(lowerX + step, lowerZ + step) - southWest) * dx; + return north + (south - north) * dz; + } + + int cachedNodeCount() { + if (nodes == null) { + return 0; + } + int count = 0; + for (int index = 0; index < nodes.length(); index++) { + if (nodes.get(index) != null) { + count++; + } + } + return count; + } + + private double node(double x, double z) { + long xBits = Double.doubleToLongBits(x); + long zBits = Double.doubleToLongBits(z); + int index = (int) CacheKey.mix(xBits ^ Long.rotateLeft(zBits, 32)) & (MAXIMUM_NODES - 1); + Node cached = nodes.get(index); + if (cached != null && cached.xBits() == xBits && cached.zBits() == zBits) { + return cached.height(); + } + double height = source.applyAsDouble(x, z); + if (!Double.isFinite(height)) { + throw new IllegalStateException("Nonfinite procedural terrain height at " + x + "," + z); + } + nodes.set(index, new Node(xBits, zBits, height)); + return height; + } + + private record Node(long xBits, long zBits, double height) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilter.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilter.java index c7e2831dc..4a9f75a7e 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilter.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilter.java @@ -23,6 +23,27 @@ final class Terrain3DFragmentFilter { this.source = Objects.requireNonNull(source, "Raw density columns"); } + double height(int x, int z) { + DensityColumn density = source.column(x, z); + Terrain3DColumn raw = density.raw; + if (!raw.shaped()) { + return raw.baseHeight(); + } + Terrain3DColumn resolved = density.resolved; + if (resolved != null) { + return resolved.topY(); + } + for (int span = raw.spanCount() - 1; span > 0; span--) { + if (density.state(span) == UNKNOWN) { + TRAVERSALS.get().classify(this, x, z, density, span); + } + if (density.state(span) == KEEP) { + return raw.floor(span); + } + } + return raw.floor(0); + } + Terrain3DColumn column(int x, int z) { DensityColumn density = source.column(x, z); Terrain3DColumn resolved = density.resolved; diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java index 8f61cc77c..ac32ee03b 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java @@ -4,6 +4,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.cache.CacheKey; import art.arcane.volmlib.util.math.RNG; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; @@ -16,6 +17,8 @@ import java.util.concurrent.atomic.AtomicReferenceArray; public final class Terrain3DRuntime { private static final int STEP = 4; private static final int CACHE_STRIPES = 16; + private static final int MAXIMUM_ANCHORS = 32_768; + private static final long ANCHOR_CACHE_BYTES = 64L * 1024 * 1024; private static final long DENSITY_SALT = 0x536E4A11C924B3D7L; private static final long CRACK_SALT = 0x7839A16DC4052EFBL; private static final NodeSample EMPTY_SAMPLE = new NodeSample(0D, 0D, 0D); @@ -44,7 +47,7 @@ public final class Terrain3DRuntime { this.options = Objects.requireNonNull(options, "Terrain options"); this.noiseFactory = Objects.requireNonNull(noiseFactory, "Terrain noise factory"); columns = new BoundedCache<>(options.maximumColumns()); - anchors = new BoundedCache<>(Math.max(CACHE_STRIPES, options.maximumColumns() / 4)); + anchors = new BoundedCache<>(anchorCacheCapacity(options.height(), options.maximumColumns())); fragments = new Terrain3DFragmentFilter(this::rawColumn); } @@ -52,6 +55,24 @@ public final class Terrain3DRuntime { return options.enabled(); } + static int anchorCacheCapacity(int height, int maximumColumns) { + long bytesPerAnchor = 160L + 48L * (Math.ceilDiv((long) height, STEP) + 1); + long maximumEntries = Math.min(MAXIMUM_ANCHORS, + Math.min((long) maximumColumns * 8, ANCHOR_CACHE_BYTES / bytesPerAnchor)); + return Math.max(CACHE_STRIPES, (int) (maximumEntries / CACHE_STRIPES) * CACHE_STRIPES); + } + + public double height(int x, int z) { + if (!active()) { + return baseHeight(x, z); + } + ColumnMemo memo = localColumn.get(); + if (memo.generation == cacheGeneration && memo.key == pack(x, z)) { + return memo.column.shaped() ? memo.column.topY() : memo.column.baseHeight(); + } + return fragments.height(x, z); + } + public Terrain3DColumn column(int x, int z) { if (!active()) { return Terrain3DColumn.unshaped(baseHeight(x, z), options.height()); @@ -80,6 +101,10 @@ public final class Terrain3DRuntime { localColumn.remove(); } + int cachedColumnCount() { + return columns.size(); + } + private Terrain3DFragmentFilter.DensityColumn rawColumn(int x, int z) { long key = pack(x, z); Terrain3DFragmentFilter.DensityColumn cached = columns.get(key); @@ -408,11 +433,16 @@ public final class Terrain3DRuntime { } } + private int size() { + int size = 0; + for (CacheStripe stripe : stripes) { + size += stripe.size(); + } + return size; + } + private CacheStripe stripe(long key) { - long mixed = key ^ (key >>> 33); - mixed *= 0xff51afd7ed558ccdL; - mixed ^= mixed >>> 33; - return stripes[(int) mixed & (CACHE_STRIPES - 1)]; + return stripes[(int) CacheKey.mix(key) & (CACHE_STRIPES - 1)]; } } @@ -443,5 +473,9 @@ public final class Terrain3DRuntime { private synchronized void clear() { entries.clear(); } + + private synchronized int size() { + return entries.size(); + } } } diff --git a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java index d014a6a33..be0d7200f 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java @@ -56,6 +56,9 @@ final class PackDimensionValidator { validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); + validateSamplingStep(dimensionKey, dimJson, "terrainSamplingStep", 1, blockingErrors); + validateSamplingStep(dimensionKey, dimJson, "caveDensitySamplingStep", 1, blockingErrors); + validateSamplingStep(dimensionKey, dimJson, "biomeBoundsSamplingStep", 4, blockingErrors); validateWorldBoundary(dimensionKey, dimJson, blockingErrors); validateStaticObjects(packFolder, dimensionKey, dimJson, blockingErrors); validateObjectScaleFactor(dimensionKey, dimJson, blockingErrors); @@ -109,6 +112,21 @@ final class PackDimensionValidator { IrisObjectScale.MAXIMUM_FACTOR, "Dimension '" + dimensionKey + "'", errors); } + private static void validateSamplingStep(String dimensionKey, JSONObject dimension, String field, int minimum, List errors) { + if (!dimension.has(field)) { + return; + } + Object value = dimension.opt(field); + if (value instanceof Number number) { + double step = number.doubleValue(); + if (step == minimum || step == minimum * 2D || step == minimum * 4D || step == minimum * 8D) { + return; + } + } + errors.add("Dimension '" + dimensionKey + "' " + field + " must be one of " + minimum + ", " + + minimum * 2 + ", " + minimum * 4 + ", or " + minimum * 8 + "."); + } + static void validateDimensionStack(File packFolder, String dimensionKey, JSONObject dimension, Set dimensionKeys, List blockingErrors) { if (!dimension.has("dimensionStack")) { diff --git a/core/src/main/java/art/arcane/iris/pack/schema/SchemaPropertyBuilder.java b/core/src/main/java/art/arcane/iris/pack/schema/SchemaPropertyBuilder.java index 04f7b2572..0f997ac9d 100644 --- a/core/src/main/java/art/arcane/iris/pack/schema/SchemaPropertyBuilder.java +++ b/core/src/main/java/art/arcane/iris/pack/schema/SchemaPropertyBuilder.java @@ -22,11 +22,13 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.iris.pack.schema.annotation.MaxNumber; import art.arcane.iris.pack.schema.annotation.MinNumber; +import art.arcane.iris.pack.schema.annotation.IntegerOptions; import art.arcane.iris.pack.schema.annotation.RegistryMapBlockState; import art.arcane.iris.pack.schema.annotation.Required; import art.arcane.iris.pack.schema.annotation.Snippet; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.volmlib.util.json.JSONArray; import java.lang.reflect.Field; import java.util.List; @@ -67,6 +69,13 @@ final class SchemaPropertyBuilder { } private String integerProperty(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(IntegerOptions.class)) { + JSONArray options = new JSONArray(); + for (int value : k.getDeclaredAnnotation(IntegerOptions.class).value()) { + options.put(value); + } + prop.put("enum", options); + } if (k.isAnnotationPresent(MinNumber.class)) { int min = (int) k.getDeclaredAnnotation(MinNumber.class).value(); prop.put("minimum", min); diff --git a/core/src/main/java/art/arcane/iris/pack/schema/annotation/IntegerOptions.java b/core/src/main/java/art/arcane/iris/pack/schema/annotation/IntegerOptions.java new file mode 100644 index 000000000..485c7b89f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/schema/annotation/IntegerOptions.java @@ -0,0 +1,12 @@ +package art.arcane.iris.pack.schema.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface IntegerOptions { + int[] value(); +} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java index 314455cc3..9d9f53205 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java @@ -4,7 +4,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiomeRegistry; import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourcePolicy; import java.util.function.IntUnaryOperator; -import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.generation.runtime.DimensionStackContext; import art.arcane.iris.generation.runtime.DimensionStackLayout; @@ -22,7 +21,6 @@ import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.generation.context.IrisContext; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; -import org.bukkit.World; import org.bukkit.block.Biome; import java.io.IOException; @@ -37,6 +35,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.Objects; public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy { private static final int NOISE_BIOME_CACHE_MAX = 262144; @@ -49,6 +49,7 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy private final BukkitChunkGenerator platformGenerator; private final NativeBiomeRegistry biomeCustomRegistry; private final NativeBiomeRegistry biomeRegistry; + private final Supplier> possibleBiomeRegistry; private final H fallbackBiome; private final ConcurrentHashMap noiseBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap structureBiomeCache = new ConcurrentHashMap<>(); @@ -56,13 +57,11 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy private final ConcurrentHashMap naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> runtimeBiomeStates = new ConcurrentHashMap<>(); - public BukkitBiomePolicy(RuntimeOptions options, NativeBiomeRegistry registry) { + public BukkitBiomePolicy(RuntimeOptions options, NativeBiomeRegistry registry) { Engine engine = options.engine(); - World world = options.world(); this.engine = engine; - this.platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator generator - ? generator - : null; + this.platformGenerator = options.platformGenerator(); + this.possibleBiomeRegistry = options.possibleBiomeRegistry(); this.seed = options.seed(); this.biomeCustomRegistry = registry; this.biomeRegistry = biomeCustomRegistry; @@ -179,12 +178,8 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy throw new IllegalStateException("Iris possible biome lookup has no active engine runtime"); } runtimeBiomeState(); - World world = BukkitWorldBinding.world(engine.getWorld()); - if (world == null) { - throw new IllegalStateException("Iris biome source has no bound Bukkit world"); - } NativeBiomeRegistry customRegistry = biomeCustomRegistry; - NativeBiomeRegistry worldRegistry = biomeRegistry.forWorld(world); + NativeBiomeRegistry worldRegistry = Objects.requireNonNull(possibleBiomeRegistry.get(), "possible biome registry"); return Set.copyOf(getAllBiomes( customRegistry, worldRegistry, engine, includeDimensionStack)); } @@ -1105,7 +1100,12 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy Map vanillaSpawnBiomes ) { } - public record RuntimeOptions(long seed, Engine engine, World world) { + public record RuntimeOptions(long seed, Engine engine, BukkitChunkGenerator platformGenerator, + Supplier> possibleBiomeRegistry) { + public RuntimeOptions { + Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(possibleBiomeRegistry, "possible biome registry"); + } } @Override public VisibleResolver visibleResolver() { diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGeneratorContext.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGeneratorContext.java index 57b14a426..845fd0dc5 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGeneratorContext.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGeneratorContext.java @@ -5,6 +5,7 @@ import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.EngineLifecycleTasks; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.platform.generation.BukkitChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.structure.nativegen.IrisStructurePolicy; import art.arcane.iris.structure.nativegen.ImportedFeaturePolicy; import art.arcane.iris.structure.nativegen.IrisStructureVolumePolicy; @@ -50,7 +51,16 @@ public final class BukkitGeneratorContext extends BukkitGenerationPolicy impleme @Override public NativeBiomeSourcePolicy biomes(NativeBiomeRegistry registry) { - return new BukkitBiomePolicy<>(new BukkitBiomePolicy.RuntimeOptions(options.seed(), current(), options.world()), registry); + return new BukkitBiomePolicy<>(new BukkitBiomePolicy.RuntimeOptions<>(options.seed(), current(), generator, + () -> worldBiomeRegistry(registry)), registry); + } + + private NativeBiomeRegistry worldBiomeRegistry(NativeBiomeRegistry registry) { + World world = BukkitWorldBinding.world(current().getWorld()); + if (world == null) { + throw new IllegalStateException("Iris biome source has no bound Bukkit world"); + } + return registry.forWorld(world); } @Override diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java index 9837eb2bf..a87750555 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java @@ -18,10 +18,6 @@ import java.util.Objects; import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.ForkJoinTask; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.locks.ReentrantLock; import java.util.function.IntConsumer; import java.util.function.Supplier; @@ -35,8 +31,6 @@ import java.util.function.Supplier; */ public final class NativeStructureVolumeIndex { private static final int ORIGIN_REACH_CHUNKS = StructureOwnershipRecordView.MAX_REFERENCE_DISTANCE_CHUNKS; - private static final int ORIGIN_WINDOW_DIAMETER = ORIGIN_REACH_CHUNKS * 2 + 1; - private static final int ORIGIN_WINDOW_STRIPE_COUNT = Long.SIZE; private static final int MAX_CACHED_ORIGIN_CHUNKS = 16_384; private static final int MAX_CACHED_QUERY_CHUNKS = 4_096; private static final Map INDEXES = @@ -49,7 +43,6 @@ public final class NativeStructureVolumeIndex { new ConcurrentHashMap<>(); private final ConcurrentHashMap>> queryBuilds = new ConcurrentHashMap<>(); - private final ReentrantLock[] originWindowStripes = createOriginWindowStripes(); private final IntConsumer retirementListener = this::evictRuntime; private NativeStructureVolumeIndex(OriginResolver origins) { @@ -147,16 +140,6 @@ public final class NativeStructureVolumeIndex { } private KList buildChunkVolumes(Engine engine, int chunkX, int chunkZ) { - long stripeMask = originWindowStripeMask(chunkX, chunkZ); - lockOriginWindow(stripeMask); - try { - return buildChunkVolumesCoordinated(engine, chunkX, chunkZ); - } finally { - unlockOriginWindow(stripeMask); - } - } - - private KList buildChunkVolumesCoordinated(Engine engine, int chunkX, int chunkZ) { int minX = chunkX << 4; int minZ = chunkZ << 4; int maxX = minX + 15; @@ -207,20 +190,6 @@ public final class NativeStructureVolumeIndex { return originVolumes(engine, chunkX, chunkZ); } - static long originWindowStripeMask(int chunkX, int chunkZ) { - int minCellX = Math.floorDiv(chunkX - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); - int maxCellX = Math.floorDiv(chunkX + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); - int minCellZ = Math.floorDiv(chunkZ - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); - int maxCellZ = Math.floorDiv(chunkZ + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); - long mask = 0L; - for (int cellX = minCellX; cellX <= maxCellX; cellX++) { - for (int cellZ = minCellZ; cellZ <= maxCellZ; cellZ++) { - mask |= 1L << originWindowStripe(cellX, cellZ); - } - } - return mask; - } - KList originVolumes(Engine engine, int chunkX, int chunkZ) { try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = openHistoryCoordinateScope(engine, chunkX, chunkZ)) { @@ -301,87 +270,6 @@ public final class NativeStructureVolumeIndex { return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); } - private static int originWindowStripe(int cellX, int cellZ) { - long mixed = chunkKey(cellX, cellZ); - mixed ^= mixed >>> 33; - mixed *= 0xff51afd7ed558ccdL; - mixed ^= mixed >>> 33; - mixed *= 0xc4ceb9fe1a85ec53L; - mixed ^= mixed >>> 33; - return (int) mixed & (ORIGIN_WINDOW_STRIPE_COUNT - 1); - } - - private static ReentrantLock[] createOriginWindowStripes() { - ReentrantLock[] stripes = new ReentrantLock[ORIGIN_WINDOW_STRIPE_COUNT]; - for (int i = 0; i < stripes.length; i++) { - stripes[i] = new ReentrantLock(); - } - return stripes; - } - - private void lockOriginWindow(long stripeMask) { - long pending = stripeMask; - while (pending != 0L) { - int stripe = Long.numberOfTrailingZeros(pending); - lockOriginStripe(originWindowStripes[stripe]); - pending &= pending - 1L; - } - } - - private static void lockOriginStripe(ReentrantLock lock) { - if (lock.tryLock()) { - return; - } - if (!ForkJoinTask.inForkJoinPool()) { - lock.lock(); - return; - } - OriginLockBlocker blocker = new OriginLockBlocker(lock); - try { - ForkJoinPool.managedBlock(blocker); - } catch (InterruptedException interruption) { - Thread.currentThread().interrupt(); - blocker.block(); - } catch (RejectedExecutionException exhaustedPool) { - blocker.block(); - } - } - - private void unlockOriginWindow(long stripeMask) { - long pending = stripeMask; - while (pending != 0L) { - int stripe = Long.SIZE - 1 - Long.numberOfLeadingZeros(pending); - originWindowStripes[stripe].unlock(); - pending &= ~(1L << stripe); - } - } - - private static final class OriginLockBlocker implements ForkJoinPool.ManagedBlocker { - private final ReentrantLock lock; - private boolean acquired; - - private OriginLockBlocker(ReentrantLock lock) { - this.lock = lock; - } - - @Override - public boolean block() { - if (!acquired) { - lock.lock(); - acquired = true; - } - return true; - } - - @Override - public boolean isReleasable() { - if (!acquired) { - acquired = lock.tryLock(); - } - return acquired; - } - } - private static GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( Engine engine, int chunkX, diff --git a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java b/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java index d07a0d481..2eb2648df 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java @@ -583,7 +583,7 @@ public final class ImageMapStudioGUI { && !engine.isClosed() && engine.getComplex() != null) { proceduralHeightSampler = (worldX, worldZ) -> engine.getComplex() - .sampleProceduralTerrainHeight(engine, worldX, worldZ); + .sampleProceduralTerrainHeight(worldX, worldZ); } DoubleBinaryOperator currentProceduralHeightSampler = proceduralHeightSampler; new SwingWorker() { diff --git a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java index eadd195ca..bce08f4d5 100644 --- a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java +++ b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java @@ -163,13 +163,14 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { } int nearest = -1; double nearestDistance = Double.POSITIVE_INFINITY; - for (int offset = 0; offset + 1 < height(); offset++) { - Voxel current = voxelAt(minimumY + offset); - Voxel above = voxelAt(minimumY + offset + 1); + for (int run = 0; run + 1 < runEnds.length; run++) { + Voxel current = palette.get(paletteIndices[run]); + Voxel above = palette.get(paletteIndices[run + 1]); if (current.phase() != Phase.SOLID || current.protectedContent() || above.phase() == Phase.SOLID && !above.protectedContent()) { continue; } + int offset = runEnds[run] - 1; double distance = Math.abs(offset - expectedOffset); if (distance < nearestDistance) { nearest = offset; @@ -179,10 +180,10 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { if (nearest >= 0) { return nearest; } - for (int offset = height() - 1; offset >= 0; offset--) { - Voxel current = voxelAt(minimumY + offset); + for (int run = runEnds.length - 1; run >= 0; run--) { + Voxel current = palette.get(paletteIndices[run]); if (current.phase() == Phase.SOLID && !current.protectedContent()) { - return offset; + return runEnds[run] - 1; } } return 0; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java index 84d1fb949..affa4ce2c 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java @@ -1415,7 +1415,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { || router.history.resolveActivation(chunkX(), chunkZ()).activationId() != stage.activation().activationId()) { return false; } - ChunkGenerationSemantics semantics = GenerationSemanticCapture.capture( + ChunkGenerationSemantics semantics = GenerationSemanticCapture.captureScoped( router.engine, stage, caveSpace diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java index 5849bc0ad..6476fd032 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java @@ -3,6 +3,10 @@ package art.arcane.iris.world.history; import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.DimensionStackContext; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.StructurePlacementMarker; @@ -12,6 +16,7 @@ import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.structure.placement.IrisStructurePlacement; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -28,6 +33,14 @@ public final class GenerationSemanticCapture { private GenerationSemanticCapture() { } + static ChunkGenerationSemantics captureScoped( + Engine engine, + GenerationHistory.GenerationStage stage, + NativeBlockPositionPredicate caveSpace + ) { + return capture(engine, stage.chunkX(), stage.chunkZ(), stage.activation().activationId(), caveSpace, true); + } + public static ChunkGenerationSemantics capture( Engine engine, GenerationHistory.GenerationStage stage @@ -47,7 +60,8 @@ public final class GenerationSemanticCapture { requiredStage.chunkX(), requiredStage.chunkZ(), requiredStage.activation().activationId(), - caveSpace + caveSpace, + false ); } @@ -57,7 +71,7 @@ public final class GenerationSemanticCapture { int chunkZ, long activationId ) { - return capture(engine, chunkX, chunkZ, activationId, (x, y, z) -> true); + return capture(engine, chunkX, chunkZ, activationId, (x, y, z) -> true, false); } private static ChunkGenerationSemantics capture( @@ -65,7 +79,8 @@ public final class GenerationSemanticCapture { int chunkX, int chunkZ, long activationId, - NativeBlockPositionPredicate caveSpace + NativeBlockPositionPredicate caveSpace, + boolean scoped ) { Objects.requireNonNull(caveSpace, "cave space"); Engine requiredEngine = Objects.requireNonNull(engine, "engine"); @@ -75,7 +90,7 @@ public final class GenerationSemanticCapture { activationId ); captureColumns(requiredEngine, chunkX, chunkZ, semantics); - captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics, caveSpace); + captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics, caveSpace, scoped); captureStructures(requiredEngine, chunkX, chunkZ, semantics); return semantics.seal().build(); } @@ -133,18 +148,20 @@ public final class GenerationSemanticCapture { int chunkX, int chunkZ, ChunkGenerationSemantics.Builder semantics, - NativeBlockPositionPredicate caveSpace + NativeBlockPositionPredicate caveSpace, + boolean scoped ) { Mantle mantle = engine.getMantle().getMantle(); int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); BitSet resolvedCaves = new BitSet(); + CaveBiomes caveBiomes = new CaveBiomes(engine, scoped); mantle.iterateChunk(chunkX, chunkZ, MatterCavern.class, (localX, y, localZ, cavern) -> { if (!caveSpace.test(localX, y, localZ)) { return; } captureCave( - engine, + caveBiomes, semantics, resolvedCaves, minimumX + localX, @@ -163,7 +180,7 @@ public final class GenerationSemanticCapture { return; } captureCave( - engine, + caveBiomes, semantics, resolvedCaves, blockX, @@ -196,7 +213,7 @@ public final class GenerationSemanticCapture { } private static void captureCave( - Engine engine, + CaveBiomes caveBiomes, ChunkGenerationSemantics.Builder semantics, BitSet resolvedCaves, int blockX, @@ -213,7 +230,7 @@ public final class GenerationSemanticCapture { semantics.addCaveBiome(explicitBiomeKey); return; } - IrisBiome caveBiome = engine.getCaveBiome(blockX, y, blockZ); + IrisBiome caveBiome = caveBiomes.resolve(blockX, y, blockZ); if (caveBiome != null && caveBiome.getLoadKey() != null) { semantics.addCaveBiome(caveBiome.getLoadKey()); } @@ -256,4 +273,80 @@ public final class GenerationSemanticCapture { } } + private static final class CaveBiomes { + private final Engine engine; + private final boolean scoped; + private final CaveFallback fallback; + private final IrisDimensionCarvingResolver.State fallbackState = new IrisDimensionCarvingResolver.State(); + private IrisDimensionCarvingResolver.Snapshot snapshot; + private int minimumY; + + private CaveBiomes(Engine engine, boolean scoped) { + this.engine = engine; + this.scoped = scoped; + this.fallback = scoped && engine instanceof IrisEngine irisEngine + && irisEngine.hasGenerationRuntimeScope() && !engine.getPlatformHooks().isMainThread() + ? new CaveFallback(engine.getComplex(), engine.getDimensionStackContext()) + : null; + } + + private IrisBiome resolve(int x, int y, int z) { + if (!scoped) { + return engine.getCaveBiome(x, y, z); + } + if (snapshot == null) { + snapshot = IrisDimensionCarvingResolver.snapshot(engine); + minimumY = engine.getWorld().minHeight(); + } + IrisBiome configured = snapshot.resolveBiome(x, y + minimumY, z); + if (configured != null) { + return configured; + } + return fallback == null ? engine.getCaveBiome(x, y, z, fallbackState) : fallback.resolve(x, y, z); + } + } + + private static final class CaveFallback { + private final ProceduralStream surfaceBiomes; + private final ProceduralStream caveBiomes; + private final ProceduralStream heights; + private final DimensionStackContext stack; + private final CaveColumn[] columns = new CaveColumn[CHUNK_SIZE * CHUNK_SIZE]; + + private CaveFallback(IrisComplex complex, DimensionStackContext stack) { + this.surfaceBiomes = complex.getTrueBiomeStream(); + this.caveBiomes = complex.getCaveBiomeStream(); + this.heights = complex.getHeightStream(); + this.stack = stack; + } + + private IrisBiome resolve(int x, int y, int z) { + int index = ((x & 15) << 4) | (z & 15); + CaveColumn column = columns[index]; + if (column == null) { + IrisBiome surfaceBiome = surfaceBiomes.get(x, z); + int surfaceY = heights.get(x, z).intValue(); + IrisBiome caveBiome = caveBiomes.get(x, z); + if (caveBiome == null || caveBiome.getLoadKey() == null) { + caveBiome = surfaceBiome; + if (stack != null) { + DimensionStackLayout.Layer layer = stack.getLayout(x, z).surfaceLayer(); + if (layer != null && layer.biome() != null) { + caveBiome = layer.biome(); + } + } + } + column = new CaveColumn(surfaceBiome, caveBiome, surfaceY, + caveBiome == null ? 0 : Math.max(0, caveBiome.getCaveMinDepthBelowSurface())); + columns[index] = column; + } + int depth = column.surfaceY() - y; + return column.caveBiome() == null || depth <= 0 || depth < column.minimumDepth() + ? column.surfaceBiome() : column.caveBiome(); + } + } + + private record CaveColumn(IrisBiome surfaceBiome, IrisBiome caveBiome, int surfaceY, int minimumDepth) { + } + } diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java index ad4a1357a..e6713f6a6 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java @@ -106,7 +106,8 @@ public final class SavedBiomeStore { public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException { SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk"); WriteStripe lock = regionLock(required.chunkX(), required.chunkZ()); - if (!lock.writing) { + requireWritable(); + if (cached(required.chunkX(), required.chunkZ()).isPresent()) { synchronized (lock) { if (alreadyClaimed(required)) { return false; @@ -119,15 +120,8 @@ public final class SavedBiomeStore { PendingWrite pending = new PendingWrite(required, record); lock.pending.add(pending); synchronized (lock) { - if (!pending.completed) { - lock.writing = true; - try { - while (!pending.completed) { - persistPending(lock); - } - } finally { - lock.writing = false; - } + while (!pending.completed) { + persistPending(lock); } if (pending.failure != null) { throw pending.failure; @@ -788,7 +782,6 @@ public final class SavedBiomeStore { private static final class WriteStripe { private final ConcurrentLinkedQueue pending = new ConcurrentLinkedQueue<>(); - private volatile boolean writing; } private static final class PendingWrite { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index 7c9fd87d4..02d21c486 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -79,6 +79,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final int workerPoolThreads; private final int runtimeCpuThreads; private final int effectiveWorkerThreads; + private final int pregenWorkerThreads; private final int recommendedRuntimeConcurrencyCap; private final Method directChunkAtAsyncUrgentMethod; private final Method directChunkAtAsyncMethod; @@ -141,10 +142,11 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.chunkAccessMode = chunkAsyncMethodSelection.mode(); int detectedWorkerPoolThreads = resolveWorkerPoolThreads(); int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors()); - int configuredWorldGenThreads = Math.max(1, IrisSettings.get().getConcurrency().getWorldGenThreads()); + this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, Runtime.getRuntime().maxMemory(), + !foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0); int workerThreadsForCap = foliaRuntime - ? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, configuredWorldGenThreads) - : resolvePaperLikeConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, configuredWorldGenThreads); + ? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads) + : resolvePaperLikeConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads); if (foliaRuntime) { this.paperLikeBackendMode = IrisPaperLikeBackendMode.AUTO; this.backendMode = "folia-region"; @@ -650,14 +652,22 @@ public class AsyncPregenMethod implements PregeneratorMethod { } static int computeInitialInFlightLimit(int concurrencyCap, int workerThreads) { - int initial = Math.max(4, Math.max(1, workerThreads)); - return Math.min(Math.max(1, concurrencyCap), initial); + long initial = Math.max(1L, workerThreads) * 8L; + return (int) Math.min(Math.max(1, concurrencyCap), initial); } static int nextAdaptiveInFlightLimit(int current, int concurrencyCap) { return Math.min(Math.max(1, concurrencyCap), Math.max(1, current) + 1); } + static int pregenWorkerThreadTarget(int availableProcessors, long maximumHeapBytes, boolean expanded) { + int baseline = Math.max(2, availableProcessors); + if (!expanded || maximumHeapBytes < (16L << 30)) { + return baseline; + } + return Math.max(baseline, (int) Math.min(32L, (long) baseline * 2L)); + } + static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) { int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); if (detectedWorkerPoolThreads > 0) { @@ -804,8 +814,10 @@ public class AsyncPregenMethod implements PregeneratorMethod { + ", recommendedCap=" + recommendedRuntimeConcurrencyCap + ", urgent=" + urgent + ", slowWarning=" + slowRequestWarningSeconds + "s"); - if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) { - acquireWorkerThreadBoost(); + synchronized (this) { + if (!closing.get() && workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) { + acquireWorkerThreadBoost(pregenWorkerThreads); + } } } @@ -837,17 +849,25 @@ public class AsyncPregenMethod implements PregeneratorMethod { flushAllRemainingChunks(); chunkFlush.close(CHUNK_FLUSH_DRAIN_SECONDS, TimeUnit.SECONDS); executor.shutdown(); - if (holdsWorkerBoost.compareAndSet(true, false)) { - releaseWorkerThreadBoost(); - } } finally { - HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope; - if (scope != null) { - scope.close(); - hydrologyPrefetchScope = null; - } - if (interrupted) { - Thread.currentThread().interrupt(); + try { + HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope; + if (scope != null) { + scope.close(); + hydrologyPrefetchScope = null; + } + } finally { + try { + synchronized (this) { + if (holdsWorkerBoost.compareAndSet(true, false)) { + releaseWorkerThreadBoost(); + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } } } } @@ -1007,62 +1027,51 @@ public class AsyncPregenMethod implements PregeneratorMethod { return null; } - public static void acquireWorkerThreadBoost() { - if (BOOST_HOLDERS.getAndIncrement() > 0) { - return; + public static synchronized void acquireWorkerThreadBoost(int targetThreads) { + if (targetThreads < 1) { + throw new IllegalArgumentException("Worker thread target must be positive"); } - increaseWorkerThreads(); + BOOST_HOLDERS.incrementAndGet(); + increaseWorkerThreads(targetThreads); } - public static void releaseWorkerThreadBoost() { - if (BOOST_HOLDERS.decrementAndGet() > 0) { + public static synchronized void releaseWorkerThreadBoost() { + if (BOOST_HOLDERS.get() == 0 || BOOST_HOLDERS.decrementAndGet() > 0) { return; } resetWorkerThreads(); } - private static void increaseWorkerThreads() { - THREAD_COUNT.updateAndGet(i -> { - if (i > 0) { - return i; + private static void increaseWorkerThreads(int targetThreads) { + try { + NativeWorkerPool pool = workerPool(); + int threads = pool.threadCount(); + if (threads >= targetThreads) { + return; } - - int adjusted = IrisSettings.get().getConcurrency().getWorldGenThreads(); - try { - NativeWorkerPool pool = workerPool(); - int threads = pool.threadCount(); - if (threads >= adjusted) { - return 0; - } - - pool.adjustThreadCount(adjusted); - return threads; - } catch (Throwable e) { - IrisLogging.warn("Failed to increase worker threads, if you are on paper or a fork of it please increase it manually to " + adjusted); - IrisLogging.warn("For more information see https://docs.papermc.io/paper/reference/global-configuration#chunk_system_worker_threads"); - if (e instanceof InvocationTargetException) { - IrisLogging.reportError(e); - } + if (THREAD_COUNT.get() == 0) { + THREAD_COUNT.set(threads); } - return 0; - }); + pool.adjustThreadCount(targetThreads); + } catch (Throwable e) { + IrisLogging.reportError(e); + IrisLogging.warn("Failed to increase worker threads, if you are on paper or a fork of it please increase it manually to " + targetThreads); + IrisLogging.warn("For more information see https://docs.papermc.io/paper/reference/global-configuration#chunk_system_worker_threads"); + } } private static void resetWorkerThreads() { - THREAD_COUNT.updateAndGet(i -> { - if (i == 0) { - return 0; - } - - try { - workerPool().adjustThreadCount(i); - return 0; - } catch (Throwable e) { - IrisLogging.reportError(e); - IrisLogging.error("Failed to reset worker threads"); - } - return i; - }); + int original = THREAD_COUNT.get(); + if (original == 0) { + return; + } + try { + workerPool().adjustThreadCount(original); + THREAD_COUNT.set(0); + } catch (Throwable e) { + IrisLogging.reportError(e); + IrisLogging.error("Failed to reset worker threads"); + } } private static NativeWorkerPool workerPool() { diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeCeilingLayerTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeCeilingLayerTest.java index 2999f1c88..171b39279 100644 --- a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeCeilingLayerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeCeilingLayerTest.java @@ -11,7 +11,6 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -28,7 +27,6 @@ public class IrisBiomeCeilingLayerTest { new IrisDimension(), 13, -27, new RNG(1), 8, 64, null, null); assertEquals(List.of(first, first, second, third, third), result); - assertNull(biome.getLayerHeightGenerators().getIfPresent()); } @Test @@ -45,8 +43,8 @@ public class IrisBiomeCeilingLayerTest { assertEquals(firstCeiling.size(), secondCeiling.size()); } first.getLayerHeightGenerators(rng, null); - assertNotSame(first.getLayerHeightGenerators().getIfPresent().get(0), - first.getLayerCeilingHeightGenerators().getIfPresent().get(0)); + assertNotSame(first.getLayerHeightGenerators(rng, null).get(0), + first.getLayerCeilingHeightGenerators().get(first.getCaveCeilingLayers(), 7235, rng, null).get(0)); } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCacheTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCacheTest.java new file mode 100644 index 000000000..431ef092f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerHeightCacheTest.java @@ -0,0 +1,179 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeLayerHeightCacheTest { + private static final long TERRAIN_SEED = 7191L; + private static final long CARVE_SEED = -872315L; + private static final long FLOATING_SEED = (TERRAIN_SEED ^ 0x7EB0A73F1DCE514DL) + + (0x7A4E ^ "magnetics/glass-shard".hashCode()); + + @Test + public void layerHeightUsesEachCallerSeedInEitherOrder() { + IrisBiomePaletteLayer layer = layer(); + assertNoise(layer().getHeightGenerator(new RNG(CARVE_SEED), null), + layer.getHeightGenerator(new RNG(CARVE_SEED), null)); + assertNoise(layer().getHeightGenerator(new RNG(FLOATING_SEED), null), + layer.getHeightGenerator(new RNG(FLOATING_SEED), null)); + IrisBiomePaletteLayer reversed = layer(); + assertNoise(layer.getHeightGenerator(new RNG(FLOATING_SEED), null), + reversed.getHeightGenerator(new RNG(FLOATING_SEED), null)); + assertNoise(layer.getHeightGenerator(new RNG(CARVE_SEED), null), + reversed.getHeightGenerator(new RNG(CARVE_SEED), null)); + } + + @Test + public void sharedSurfaceCarveAndFloatingBiomeIgnoresWarmOrder() { + IrisBiome biome = biome(); + long[] seeds = {FLOATING_SEED, CARVE_SEED, TERRAIN_SEED, FLOATING_SEED + 733L}; + for (long seed : seeds) { + assertGenerators(biome().getLayerHeightGenerators(new RNG(seed), null), + biome.getLayerHeightGenerators(new RNG(seed), null)); + } + IrisBiome reversed = biome(); + for (int i = seeds.length - 1; i >= 0; i--) { + assertGenerators(biome.getLayerHeightGenerators(new RNG(seeds[i]), null), + reversed.getLayerHeightGenerators(new RNG(seeds[i]), null)); + } + assertNotEquals(biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null).get(0).noise(13, -29), + biome.getLayerHeightGenerators(new RNG(FLOATING_SEED), null).get(0).noise(13, -29), 0D); + } + + @Test + public void concurrentSurfaceCarveAndFloatingCallersMatchIsolatedBiomes() throws Exception { + IrisBiome shared = biome(); + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + List> tasks = new ArrayList<>(); + long[] seeds = {TERRAIN_SEED, CARVE_SEED, FLOATING_SEED, FLOATING_SEED + 733L}; + try { + for (int worker = 0; worker < 8; worker++) { + long seed = seeds[worker % seeds.length]; + KList expected = biome().getLayerHeightGenerators(new RNG(seed), null); + tasks.add(executor.submit(() -> { + assertTrue(start.await(5, TimeUnit.SECONDS)); + for (int sample = 0; sample < 30; sample++) { + assertGenerators(expected, shared.getLayerHeightGenerators(new RNG(seed), null)); + } + return null; + })); + } + start.countDown(); + for (Future task : tasks) { + task.get(10, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + public void runtimeAndDataIdentitiesDoNotReuseHeightGenerators() { + IrisBiome biome = biome(); + IrisData data = mock(IrisData.class); + IrisData otherData = mock(IrisData.class); + Engine first = mock(Engine.class); + Engine second = mock(Engine.class); + when(data.getEngine()).thenReturn(first); + when(otherData.getEngine()).thenReturn(first); + RNG rng = new RNG(TERRAIN_SEED); + KList original = biome.getLayerHeightGenerators(rng, data); + assertSame(original, biome.getLayerHeightGenerators(rng, data)); + KList other = biome.getLayerHeightGenerators(rng, otherData); + assertNotSame(original, other); + assertNotSame(original.get(0), other.get(0)); + assertGenerators(original, other); + when(data.getEngine()).thenReturn(second); + KList replacement = biome.getLayerHeightGenerators(rng, data); + assertNotSame(original, replacement); + assertNotSame(original.get(0), replacement.get(0)); + assertGenerators(original, replacement); + when(data.getEngine()).thenReturn(first); + assertSame(original, biome.getLayerHeightGenerators(rng, data)); + } + + @Test + public void materialBandsMatchIsolatedGenerationAcrossSurfaceSeaAndCeiling() { + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + IrisBiome shared = materialBiome(first, second); + IrisDimension dimension = new IrisDimension(); + long[] seeds = {FLOATING_SEED, TERRAIN_SEED, CARVE_SEED, FLOATING_SEED + 733L}; + for (long seed : seeds) { + IrisBiome isolated = materialBiome(first, second); + RNG rng = new RNG(seed); + for (int x = -65; x <= 65; x += 7) { + assertEquals(isolated.generateLayers(dimension, x, -x, rng, 64, 120, null, null), + shared.generateLayers(dimension, x, -x, rng, 64, 120, null, null)); + assertEquals(isolated.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null), + shared.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null)); + assertEquals(isolated.generateSeaLayers(x, -x, rng, 64, null), + shared.generateSeaLayers(x, -x, rng, 64, null)); + } + } + } + + @Test + public void evictionRecomputesTheSameNoise() { + IrisBiome biome = biome(); + KList original = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null); + for (int seed = 0; seed < 96; seed++) { + biome.getLayerHeightGenerators(new RNG(seed), null); + } + KList recomputed = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null); + assertNotSame(original, recomputed); + assertNotSame(original.get(0), recomputed.get(0)); + assertGenerators(original, recomputed); + } + + private static IrisBiomePaletteLayer layer() { + return new IrisBiomePaletteLayer().zero().setMinHeight(1).setMaxHeight(12); + } + + private static IrisBiome biome() { + return new IrisBiome().setLayers(new KList<>(layer(), layer().setMinHeight(3).setMaxHeight(5))); + } + + private static IrisBiome materialBiome(NativeBlockState first, NativeBlockState second) { + IrisBiomePaletteLayer upper = IrisBiomeCeilingLayerTest.layer(first, 1).setMaxHeight(5); + IrisBiomePaletteLayer lower = IrisBiomeCeilingLayerTest.layer(second, 3).setMaxHeight(12); + KList layers = new KList<>(upper, lower); + return new IrisBiome().setLayers(layers).setCaveCeilingLayers(layers).setSeaLayers(layers); + } + + private static void assertGenerators(KList expected, KList actual) { + assertEquals(expected.size(), actual.size()); + for (int index = 0; index < expected.size(); index++) { + assertNoise(expected.get(index), actual.get(index)); + } + } + + private static void assertNoise(CNG expected, CNG actual) { + for (int x = -97; x <= 97; x += 13) { + assertEquals(expected.noise(x, x * -3.25), actual.noise(x, x * -3.25), 0D); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java new file mode 100644 index 000000000..54c386389 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.generation.chunk; + +import art.arcane.iris.platform.bukkit.BukkitBiome; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.world.history.BoundaryColumnGeometry; +import art.arcane.iris.world.history.NativeTerrainReceipt; +import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import io.papermc.paper.InternalAPIBridge; +import io.papermc.paper.registry.RegistryAccess; +import net.kyori.adventure.key.Key; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.block.Biome; +import org.bukkit.generator.ChunkGenerator.ChunkData; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEFAULTS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class LinkedTerrainChunkTest { + @BeforeClass + public static void initializeBiomes() throws Exception { + BukkitTestServer.install(); + AtomicReference registered = new AtomicReference<>(); + RegistryAccess access = mock(RegistryAccess.class, invocation -> { + if (invocation.getMethod().getName().equals("getRegistry")) { + if (registered.get() == null) { + registered.set(Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class[]{Registry.class}, + (proxy, method, arguments) -> method.getName().equals("getOrThrow") + ? biome((Key) arguments[0]) : null)); + } + return registered.get(); + } + return RETURNS_DEFAULTS.answer(invocation); + }); + InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> + invocation.getMethod().getName().equals("constructLegacyCustomBiome") + ? biome(Key.key("minecraft:custom")) : RETURNS_DEFAULTS.answer(invocation)); + try (MockedStatic registries = mockStatic(RegistryAccess.class); + MockedStatic internals = mockStatic(InternalAPIBridge.class)) { + registries.when(RegistryAccess::registryAccess).thenReturn(access); + internals.when(InternalAPIBridge::get).thenReturn(bridge); + Class.forName(Registry.class.getName()); + Class.forName(Biome.class.getName()); + } + } + + @Test + public void biomeReadsReuseCanonicalHandlesAndPreserveReceiptBytes() throws Exception { + LinkedTerrainChunk chunk = chunk(); + NativeBiome plains = BukkitBiome.of(Biome.PLAINS); + NativeBiome desert = BukkitBiome.of(Biome.DESERT); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + chunk.fillBiomeColumn(x, z, plains); + } + } + NativeBiome alternate = mock(NativeBiome.class); + when(alternate.nativeHandle()).thenReturn(Biome.DESERT); + when(alternate.key()).thenReturn("ignored:alias"); + chunk.setBiome(0, -12, 0, alternate); + chunk.setBiome(17, 100, -1, desert); + AtomicInteger conversions = new AtomicInteger(); + try (MockedStatic wrappers = mockStatic(BukkitBiome.class, invocation -> { + conversions.incrementAndGet(); + return CALLS_REAL_METHODS.answer(invocation); + })) { + assertSame(desert, chunk.getBiome(0, -12, 0)); + assertSame(desert, chunk.getBiome(1, -1, 15)); + assertSame(plains, chunk.getBiome(0, -100, 0)); + for (boolean boundary : new boolean[]{false, true}) { + SavedTerrainChunk.VoxelSource actual = source((x, y, z) -> chunk.getBiome(x, y, z).key()); + SavedTerrainChunk.VoxelSource expected = source((x, y, z) -> + x == 0 && y == -12 && z == 0 || x == 1 && y == -1 && z == 15 + ? "minecraft:desert" : "minecraft:plains"); + SavedTerrainChunk captured = capture(actual, boundary); + assertArrayEquals(NativeTerrainReceipt.encode(capture(expected, boundary), 7L, "epoch"), + NativeTerrainReceipt.encode(captured, 7L, "epoch")); + } + assertEquals(0, conversions.get()); + } + } + + @Test + public void emptyColumnsResolveTheDefaultBiomeOnlyOnce() { + LinkedTerrainChunk chunk = chunk(); + NativeBiome plains = BukkitBiome.of(Biome.PLAINS); + AtomicInteger conversions = new AtomicInteger(); + try (MockedStatic wrappers = mockStatic(BukkitBiome.class, invocation -> { + conversions.incrementAndGet(); + return CALLS_REAL_METHODS.answer(invocation); + })) { + for (int y = -32; y < 16; y++) { + assertSame(plains, chunk.getBiome(0, y, 0)); + } + assertEquals(1, conversions.get()); + } + } + + private static LinkedTerrainChunk chunk() { + ChunkData data = mock(ChunkData.class); + when(data.getMinHeight()).thenReturn(-16); + when(data.getMaxHeight()).thenReturn(0); + return new LinkedTerrainChunk(data); + } + + private static SavedTerrainChunk capture(SavedTerrainChunk.VoxelSource source, boolean boundary) throws Exception { + return boundary ? SavedTerrainChunk.captureBoundary(-1, 2, -16, 16, "minecraft:noise", source) + : SavedTerrainChunk.capture(-1, 2, -16, 16, "minecraft:noise", source); + } + + private static SavedTerrainChunk.VoxelSource source(BiomeKey source) { + BoundaryColumnGeometry.Voxel stone = new BoundaryColumnGeometry.Voxel( + "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); + return new SavedTerrainChunk.VoxelSource() { + @Override + public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { + return stone; + } + + @Override + public String biome(int x, int y, int z) { + return source.get(x, y, z); + } + }; + } + + private static Biome biome(Key key) { + NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value()); + return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class[]{Biome.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getKey" -> namespaced; + case "hashCode" -> namespaced.hashCode(); + case "equals" -> proxy == arguments[0]; + case "toString", "name" -> key.value(); + default -> null; + }); + } + + private interface BiomeKey { + String get(int x, int y, int z); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterialTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterialTest.java index 8a2532768..cb0578140 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterialTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeBlockMaterialTest.java @@ -1,11 +1,42 @@ package art.arcane.iris.generation.decoration.tree; import org.junit.Test; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.junit.Assert.assertTrue; public class TreeBlockMaterialTest { + @Test + public void nativeMaterialUsesTheMemoizedStringWithoutReadingTheStateKey() { + NativeBlockState state = mock(NativeBlockState.class); + String material = new String("minecraft:oak_log"); + when(state.materialKey()).thenReturn(material); + + TreeBlockMaterial first = TreeBlockMaterial.of(state); + TreeBlockMaterial second = TreeBlockMaterial.of(state); + + assertSame(material, first.materialKey()); + assertSame(first.materialKey(), second.materialKey()); + verify(state, never()).key(); + assertEquals(TreeBlockMaterial.of("minecraft:oak_log[axis=x]"), first); + } + + @Test + public void nativeMaterialFallsBackToTheStateKeyWhenNotMemoized() { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn("minecraft:oak_log[axis=z]"); + + assertEquals(TreeBlockMaterial.of("minecraft:oak_log"), TreeBlockMaterial.of(state)); + verify(state).key(); + } + @Test public void materialComparisonIgnoresBlockStateProperties() { TreeBlockMaterial expected = TreeBlockMaterial.of("minecraft:oak_log[axis=y]"); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java new file mode 100644 index 000000000..1d0c01696 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyBasisCacheTest.java @@ -0,0 +1,158 @@ +package art.arcane.iris.generation.hydrology; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyBasisCacheTest { + @Test + public void batchLookupPreservesNullAndFirstPublishedSample() { + HydrologyBasisCache cache = new HydrologyBasisCache(1); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[1]; + long key = RiverFootprint.pack(-17, Integer.MAX_VALUE); + assertTrue(!cache.findWithoutSlope(key, samples, 0)); + assertNull(cache.rememberWithoutSlope(key, null)); + assertTrue(cache.findWithoutSlope(key, samples, 0)); + assertNull(samples[0]); + HydrologyTerrainSample later = HydrologyTerrainSample.openLand(73, 0D, "land"); + assertNull(cache.rememberWithoutSlope(key, later)); + cache.rememberWithoutSlope(key + 1, later); + assertTrue(!cache.findWithoutSlope(key, samples, 0)); + assertEquals(later, cache.rememberWithoutSlope(key + 1, null)); + } + + @Test + public void channelsNullsFailuresAndEvictionPreserveSamples() { + CountingSampler sampler = new CountingSampler(); + HydrologyBasisCache cache = new HydrologyBasisCache(1); + assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D); + assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D); + assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D); + assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D); + assertEquals(1, sampler.fullCalls.get()); + assertEquals(1, sampler.slopeFreeCalls.get()); + assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler)); + assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler)); + assertEquals(2, sampler.slopeFreeCalls.get()); + assertThrows(IllegalStateException.class, () -> cache.sampleWithoutSlope(99, 0, sampler)); + assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight()); + assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight()); + assertEquals(4, sampler.slopeFreeCalls.get()); + assertEquals(87, cache.sampleWithoutSlope(1, -2, sampler).naturalHeight()); + assertEquals(5, sampler.slopeFreeCalls.get()); + } + + @Test + public void plannerTrialsPreserveLocalSlopePrecedenceAndOwnerIsolation() { + CountingSampler natural = new CountingSampler(); + HydrologyTerrainSampler detailed = (x, z) -> { + throw new AssertionError("Basis caching must not sample caves"); + }; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner(23L, settings, detailed, natural, + HydrologyGeometrySampler.deterministic(detailed), -4096, + footprint -> new HydrologyTerrainCaveVoxelView(detailed, settings.seaLevel(), -4096, 4096)); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + try { + assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D); + assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D); + HydrologyBasisCache shared = planner.planningSamples.get() + .fallbackBasis(new Long2ObjectOpenHashMap<>()); + for (int trial = 0; trial < 4; trial++) { + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(null, shared)); + assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D); + assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D); + assertEquals(3D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D); + assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D); + } + assertEquals(1, natural.fullCalls.get()); + assertEquals(2, natural.slopeFreeCalls.get()); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D); + assertEquals(3, natural.slopeFreeCalls.get()); + } finally { + planner.planningSamples.remove(); + } + } + + @Test + public void parallelTrialsComputeEachCoordinateOnce() throws Exception { + CountingSampler sampler = new CountingSampler(); + HydrologyBasisCache cache = new HydrologyBasisCache(); + CountDownLatch ready = new CountDownLatch(4); + CountDownLatch start = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + List>> results = new ArrayList<>(); + for (int trial = 0; trial < 4; trial++) { + results.add(workers.submit(() -> { + ready.countDown(); + assertTrue(start.await(5, TimeUnit.SECONDS)); + List values = new ArrayList<>(); + for (int x = -128; x < 0; x++) { + values.add(cache.sampleWithoutSlope(x, -17, sampler)); + } + return values; + })); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + List expected = results.getFirst().get(5, TimeUnit.SECONDS); + for (Future> result : results) { + assertEquals(expected, result.get(5, TimeUnit.SECONDS)); + } + assertEquals(128, sampler.slopeFreeCalls.get()); + } finally { + start.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static final class CountingSampler implements HydrologyNaturalTerrainSampler { + private final AtomicInteger fullCalls = new AtomicInteger(); + private final AtomicInteger slopeFreeCalls = new AtomicInteger(); + private final AtomicInteger failures = new AtomicInteger(); + + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + fullCalls.incrementAndGet(); + return HydrologyTerrainSample.openLand(87, 3D, "land"); + } + + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) { + slopeFreeCalls.incrementAndGet(); + if (x == Integer.MIN_VALUE) { + return null; + } + if (x == 99 && failures.getAndIncrement() == 0) { + throw new IllegalStateException("retry"); + } + return HydrologyTerrainSample.openLand(87, 0D, "land"); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new AssertionError("Basis caching must not sample grids"); + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + throw new AssertionError("Basis caching must not classify separately"); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java index 84bcc02ec..6f5ff054b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java @@ -34,6 +34,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class); @@ -59,6 +60,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); AtomicBoolean forbidden = new AtomicBoolean(true); @@ -77,6 +79,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); List expected = List.of(mock(HydrologyDiagnosticCandidate.class)); @@ -127,6 +130,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); List old = List.of(mock(HydrologyDiagnosticCandidate.class)); @@ -166,6 +170,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey firstKey = new HydrologyTileKey(0, 0); HydrologyTileKey secondKey = new HydrologyTileKey(1, 0); @@ -213,6 +218,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void aDiagnosticLoadCannotCloseItsOwnCache() { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); when(planner.plan(key)).thenReturn(tile); @@ -229,6 +235,7 @@ public class HydrologyDiagnosticCacheTest { @Test public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTile tile = mock(HydrologyTile.class); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java index 8dc530c5a..9332dc059 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java @@ -113,6 +113,34 @@ public class HydrologyFootprintCompilerTest { } } + @Test + public void fallbackCompilersShareBasisWithoutChangingValidationOrFootprints() { + HydraulicSegment segment = new HydraulicSegment( + 9001L, 9000L, HydrologyFeatureType.SURFACE_POOL, 70, 70, 6, 3, false, false, + List.of(new HydrologyPoint(-12, 70, -3), new HydrologyPoint(32, 70, 5)), + HydraulicChannelProfile.uniform(6, 3)); + RiverCourse course = course(9000L, RiverCourseType.SURFACE, segment); + SlopeCountingSampler sampler = new SlopeCountingSampler(true); + HydrologyFootprintCompiler owner = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler)); + HydrologyFootprintCompiler.ValidationRaster expectedValidation = owner.compileValidation(List.of(course)); + RiverFootprint expected = owner.compile(List.of(course)); + int ownerCalls = sampler.slopeFreeCalls; + assertTrue(ownerCalls > 0); + HydrologyBasisCache shared = new HydrologyBasisCache(); + shared.seedWithoutSlope(owner.terrainBases); + for (int trial = 0; trial < 3; trial++) { + HydrologyFootprintCompiler alternative = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler)); + alternative.sharedBasis = shared; + assertEquals(expectedValidation.columns(), alternative.compileValidation(List.of(course)).columns()); + assertEquals(expected, alternative.compile(List.of(course))); + } + assertEquals(ownerCalls, sampler.slopeFreeCalls); + } + @Test public void surfaceChannelNeverRaisesTerrainOrPublishesUnsupportedFluid() { HydraulicSegment segment = new HydraulicSegment( @@ -536,11 +564,14 @@ public class HydrologyFootprintCompilerTest { ); RiverCourse course = course(42L, RiverCourseType.SURFACE, segment); HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent"); - RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler( HydrologyPlannerSettings.defaults(), terrain, request -> request.minimum() - ).compile(List.of(course)); + ); + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, + compiler.surfaceFootprintForPublication(course).rejection()); + RiverFootprint footprint = compiler.compile(List.of(course)); int channelEdge = 0; while (layerAt(footprint, channelEdge + 1, 0).channel()) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java index e4777c8df..bd7e36b8a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerRoutingSamplerTest.java @@ -143,7 +143,7 @@ public class HydrologyPlannerRoutingSamplerTest { assertEquals(6, heightCalls.get()); HydrologyLandHeightCache shared = planner.planningSamples.get().fallbackLandHeights(); for (int trial = 0; trial < 3; trial++) { - planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared)); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared, null)); assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D); assertEquals(0D, planner.sampleLandHeight(1, -17), 0D); assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java index 5ab3042b7..094175c68 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java @@ -2118,13 +2118,15 @@ public class HydrologyPlannerTest { } @Test - public void fallbackAlternatesCoastalAndInlandTrials() { + public void fallbackAlternatesCoastalAndInlandTrials() throws Exception { // An island whose coast is walled by a berm between the lattice columns: every mouth course // needs a cut deeper than the channel may make, so the surface routing falls back. The coast // offers more fallback mouths than the trial budget, so a sinkhole only gets its trial when // the fallback alternates coastal and inland candidates. HydrologyPlannerSettings settings = standardSettings(1D, 0D, true, true, List.of()); + AtomicInteger terrainSamples = new AtomicInteger(); HydrologyTerrainSampler island = (int x, int z) -> { + terrainSamples.incrementAndGet(); if (x < -16 || x > 144 || z < -16 || z > 144) { return oceanTerrain(); } @@ -2135,6 +2137,7 @@ public class HydrologyPlannerTest { }; HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE); + assertTrue("Fallback terrain samples: " + terrainSamples.get(), terrainSamples.get() < 37_500); assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty()); assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) -> @@ -2144,6 +2147,46 @@ public class HydrologyPlannerTest { assertFalse("outlet=" + outlet, outlet.directOcean()); assertTrue(course.surfaceSinkholeContinuation()); } + + HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() { + @Override + public HydrologyTerrainSample sampleBasis(int x, int z) { + return island.sample(x, z); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()]; + for (int z = 0; z < request.width(); z++) { + for (int x = 0; x < request.width(); x++) { + samples[z * request.width() + x] = island.sample( + request.minimumX() + x * request.spacing(), request.minimumZ() + z * request.spacing()); + } + } + return samples; + } + + @Override + public NaturalClassification classifyNatural(int x, int z) { + return island.sample(x, z).ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + + @Override + public boolean supportsSharedGridSamples() { + return true; + } + }; + HydrologyPlanner sharedPlanner = new HydrologyPlanner(885L, settings, island, natural, + HydrologyGeometrySampler.deterministic(island), Integer.MIN_VALUE, footprint -> solidCaveView()); + ForkJoinPool workers = new ForkJoinPool(4); + try { + HydrologyTile shared = workers.submit(() -> sharedPlanner.plan(TILE)).get(30, TimeUnit.SECONDS); + assertTileContentsEqual(tile, shared); + assertEquals(tile.courses(), shared.courses()); + assertEquals(tile.localDiagnosticCandidates(), shared.localDiagnosticCandidates()); + } finally { + workers.shutdownNow(); + } } private HydrologyPlannerSettings mixedCoastSettings(int maximumCoastalPerTile) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java index cf6dc5d09..f5f8cc352 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanningAdmissionIntegrationTest.java @@ -40,7 +40,9 @@ public class HydrologyPlanningAdmissionIntegrationTest { AtomicInteger peak = new AtomicInteger(); HydrologyTile tile = mock(HydrologyTile.class); HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class); + when(firstPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class); + when(secondPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTileKey key = new HydrologyTileKey(0, 0); when(firstPlanner.plan(key)).thenAnswer(invocation -> { peak.accumulateAndGet(active.incrementAndGet(), Math::max); @@ -86,6 +88,7 @@ public class HydrologyPlanningAdmissionIntegrationTest { ExecutorService workers = Executors.newSingleThreadExecutor(); ExecutorService callers = Executors.newFixedThreadPool(2); HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); HydrologyTileKey key = new HydrologyTileKey(0, 0); try { @@ -101,6 +104,7 @@ public class HydrologyPlanningAdmissionIntegrationTest { releaseRoots(held); HydrologyTile tile = mock(HydrologyTile.class); HydrologyPlanner nextPlanner = mock(HydrologyPlanner.class); + when(nextPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); when(nextPlanner.plan(key)).thenReturn(tile); try (HydrologyTileCache next = new HydrologyTileCache(nextPlanner, 4, workers)) { assertSame(tile, callers.submit(() -> next.get(key)).get(5L, TimeUnit.SECONDS)); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index a8d7fbeb8..87f9233a4 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -280,6 +280,7 @@ public class HydrologyTileCacheTest { @Test public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); CountDownLatch started = new CountDownLatch(1); @@ -331,6 +332,7 @@ public class HydrologyTileCacheTest { @Test public void closeDrainsBackgroundPlanningAndRejectsNewDemand() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); CountDownLatch started = new CountDownLatch(1); @@ -402,6 +404,7 @@ public class HydrologyTileCacheTest { @Test public void closingFromAnOwnedLoadFailsWithoutDeadlockingOrClosingTheCache() { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileCache cache = new HydrologyTileCache(planner, 4); HydrologyTileKey key = new HydrologyTileKey(0, 0); @@ -707,6 +710,7 @@ public class HydrologyTileCacheTest { @Test public void equivalentStudioRuntimesReuseCompletedTiles() { HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class); + when(firstPlanner.settings()).thenReturn(emptySettings()); HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class); HydrologyTile tile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(2, -3); @@ -739,7 +743,9 @@ public class HydrologyTileCacheTest { @Test public void failedPlansDoNotEnterTheSharedStudioCache() { HydrologyPlanner failingPlanner = mock(HydrologyPlanner.class); + when(failingPlanner.settings()).thenReturn(emptySettings()); HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class); + when(succeedingPlanner.settings()).thenReturn(emptySettings()); HydrologyTile emptyTile = mock(HydrologyTile.class); HydrologyTile plannedTile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(-4, 5); @@ -759,6 +765,7 @@ public class HydrologyTileCacheTest { @Test public void closeWaitsForActivePlansWithoutPublishingTheirSharedTiles() throws Exception { HydrologyPlanner closingPlanner = mock(HydrologyPlanner.class); + when(closingPlanner.settings()).thenReturn(emptySettings()); HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class); HydrologyTile lateTile = mock(HydrologyTile.class); HydrologyTile plannedTile = mock(HydrologyTile.class); @@ -956,6 +963,106 @@ public class HydrologyTileCacheTest { } } + @Test + public void singleColumnContributionReusesItsImmutableSample() { + HydrologyColumnSample sample = compositionSample(90, List.of(compositionLayer(11L, 80))); + try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) { + assertSame(sample, cache.columnAt(-1, 0).orElseThrow()); + assertTrue(cache.columnAt(-2, 0).isEmpty()); + } + } + + @Test + public void singleColumnContributionPreservesCanonicalLayerOrder() { + HydrologyColumnSample sample = compositionSample(90, + List.of(compositionLayer(11L, 80), compositionLayer(12L, 78))); + try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) { + assertSame(sample, cache.columnAt(-1, 0).orElseThrow()); + } + } + + @Test + public void singleColumnContributionDeduplicatesIdenticalFeatureIds() { + HydrologyColumnLayer layer = compositionLayer(11L, 80); + HydrologyColumnSample sample = compositionSample(90, List.of(layer, layer)); + try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) { + assertEquals(compositionSample(90, List.of(layer)), cache.columnAt(-1, 0).orElseThrow()); + } + } + + @Test + public void singleColumnContributionRejectsConflictingFeatureIds() { + HydrologyColumnSample sample = compositionSample(90, + List.of(compositionLayer(11L, 80), compositionLayer(11L, 79))); + try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0)); + assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage()); + } + } + + @Test + public void overlappingColumnContributionsMergeAndDeduplicateLayers() { + HydrologyColumnLayer shared = compositionLayer(11L, 80); + HydrologyColumnLayer additional = compositionLayer(12L, 78); + HydrologyColumnSample first = compositionSample(90, List.of(shared)); + HydrologyColumnSample second = compositionSample(90, List.of(additional, shared)); + try (HydrologyTileCache cache = compositionCache(Map.of( + new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) { + assertEquals(compositionSample(90, List.of(shared, additional)), cache.columnAt(-1, 0).orElseThrow()); + } + } + + @Test + public void overlappingColumnContributionsRejectConflictingFeatureIds() { + HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80))); + HydrologyColumnSample second = compositionSample(90, List.of(compositionLayer(11L, 79))); + try (HydrologyTileCache cache = compositionCache(Map.of( + new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0)); + assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage()); + } + } + + @Test + public void overlappingColumnContributionsRejectDifferentNaturalTerrain() { + HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80))); + HydrologyColumnSample second = compositionSample(91, List.of(compositionLayer(12L, 78))); + try (HydrologyTileCache cache = compositionCache(Map.of( + new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0)); + assertEquals("Hydrology plans disagree on terrain metadata at -1,0.", failure.getMessage()); + } + } + + private HydrologyTileCache compositionCache(Map samples) { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(featureSettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> { + HydrologyTileKey key = invocation.getArgument(0); + HydrologyColumnSample sample = samples.get(key); + RiverFootprint footprint = sample == null ? RiverFootprint.empty() + : new RiverFootprint(Map.of(RiverFootprint.pack(sample.x(), sample.z()), sample)); + HydrologyTile tile = mock(HydrologyTile.class); + when(tile.footprint()).thenReturn(footprint); + when(tile.columnAt(anyInt(), anyInt())).thenAnswer(query -> + footprint.sample(query.getArgument(0), query.getArgument(1))); + return tile; + }); + return new HydrologyTileCache(planner, 64); + } + + private static HydrologyColumnSample compositionSample(int naturalHeight, List layers) { + return new HydrologyColumnSample(-1, 0, naturalHeight, 63, false, "parent", layers); + } + + private static HydrologyColumnLayer compositionLayer(long featureId, int bedY) { + HydrologyFeatureRef feature = new HydrologyFeatureRef(featureId, HydrologyFeatureType.SURFACE_POOL, + 1L, 1L, -1, 82, 0, 1, 0, false); + return new HydrologyColumnLayer(feature, bedY, 82, 82, + true, false, false, true, false, false, true, true, false, + "default", "river", "mouth", "shore", "bank", "cave"); + } + @Test public void concurrentDifferentTileAndCompositeQueriesRemainDeterministic() throws Exception { HydrologyPlanner planner = new HydrologyPlanner(1211L, featureSettings(), this::featureTerrain); @@ -1338,6 +1445,7 @@ public class HydrologyTileCacheTest { private void assertConcurrentTilePlanning(List keys) throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); HydrologyTile tile = mock(HydrologyTile.class); CountDownLatch started = new CountDownLatch(Math.min(keys.size(), HydrologyPlanningAdmission.maximumRoots())); CountDownLatch release = new CountDownLatch(1); @@ -1449,6 +1557,7 @@ public class HydrologyTileCacheTest { @Test public void clearDoesNotJoinOrPublishAnOldTilePlan() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); HydrologyTile oldTile = mock(HydrologyTile.class); HydrologyTile newTile = mock(HydrologyTile.class); HydrologyTileKey key = new HydrologyTileKey(0, 0); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileDemandWindowTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileDemandWindowTest.java new file mode 100644 index 000000000..5476fc7a2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileDemandWindowTest.java @@ -0,0 +1,151 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +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.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HydrologyTileDemandWindowTest { + @Test + public void completedSlotsRefillBeforeTheSlowestTileFinishes() throws Exception { + assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2); + HydrologyPlanner planner = planner(); + HydrologyTile first = mock(HydrologyTile.class); + HydrologyTile second = mock(HydrologyTile.class); + HydrologyTile third = mock(HydrologyTile.class); + List expected = List.of(first, second, third); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch thirdStarted = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + doAnswer(invocation -> { + HydrologyTileKey key = invocation.getArgument(0); + peak.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + if (key.tileX() == 0) { + firstStarted.countDown(); + assertTrue(releaseFirst.await(5, TimeUnit.SECONDS)); + } else { + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)); + if (key.tileX() == 2) { + thirdStarted.countDown(); + } + } + return expected.get(key.tileX()); + } finally { + active.decrementAndGet(); + } + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService workers = Executors.newFixedThreadPool(3); + ExecutorService caller = Executors.newSingleThreadExecutor(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + try { + Future> result = caller.submit(() -> cache.tiles(keys())); + assertTrue("a completed slot must admit the third tile while the first is blocked", + thirdStarted.await(5, TimeUnit.SECONDS)); + assertFalse(result.isDone()); + releaseFirst.countDown(); + assertEquals(expected, result.get(5, TimeUnit.SECONDS)); + assertEquals(2, peak.get()); + } finally { + releaseFirst.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void cancellationStopsRefillWithoutCancellingSharedSiblingWork() throws Exception { + assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2); + HydrologyPlanner planner = planner(); + HydrologyTile tile = mock(HydrologyTile.class); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + AtomicInteger planned = new AtomicInteger(); + doAnswer(invocation -> { + HydrologyTileKey key = invocation.getArgument(0); + planned.incrementAndGet(); + if (key.tileX() == 0) { + firstStarted.countDown(); + assertTrue(releaseFirst.await(5, TimeUnit.SECONDS)); + return tile; + } + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)); + throw new CancellationException("Cancelled demand tile"); + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService workers = Executors.newFixedThreadPool(3); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + try { + Future> result = callers.submit(() -> cache.tiles(keys())); + ExecutionException failure = assertThrows(ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException); + assertEquals(2, planned.get()); + Future shared = callers.submit(() -> cache.get(keys().getFirst())); + releaseFirst.countDown(); + assertSame(tile, shared.get(5, TimeUnit.SECONDS)); + assertEquals(2, planned.get()); + } finally { + releaseFirst.countDown(); + callers.shutdownNow(); + workers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void capturedCachedTilesSurviveInvalidationDuringAnotherPlan() { + HydrologyPlanner planner = planner(); + HydrologyTile cached = mock(HydrologyTile.class); + HydrologyTile generated = mock(HydrologyTile.class); + HydrologyTileKey cachedKey = new HydrologyTileKey(2, 0); + when(planner.plan(cachedKey)).thenReturn(cached); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, Runnable::run); + try { + assertSame(cached, cache.get(cachedKey)); + doAnswer(invocation -> { + cache.clear(); + return generated; + }).when(planner).plan(keys().getFirst()); + when(planner.plan(cachedKey)).thenThrow(new AssertionError("Captured tile was replanned")); + assertEquals(List.of(generated, cached), cache.tiles(List.of(keys().getFirst(), cachedKey))); + } finally { + cache.close(); + } + } + + private static HydrologyPlanner planner() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); + return planner; + } + + private static List keys() { + return List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index 1f9eb05f8..c093fb46a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.runtime; import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.RiverFootprint; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.runtime.Engine; @@ -26,6 +27,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ExecutionException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; @@ -36,6 +40,75 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisHydrologyRoutingTerrainSamplerTest { + @Test + public void boundedBasisBatchUsesScopedWorkersAndDrainsNestedPool() throws Exception { + ThreadLocal scope = new ThreadLocal<>(); + AtomicInteger opened = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + AtomicInteger samples = new AtomicInteger(); + CountDownLatch simultaneous = new CountDownLatch(2); + ForkJoinPool pool = new ForkJoinPool(2); + try { + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> { + assertEquals(Integer.valueOf(7), scope.get()); + if (x >= 10_000) { + throw new IllegalStateException("terrain failure"); + } + simultaneous.countDown(); + try { + assertTrue(simultaneous.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + samples.incrementAndGet(); + return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand((int) height, 0D, "land")); + }, (x, z) -> 100 + Math.floorMod(x + z, 17), (x, z) -> false, 60), + new IrisHydrologyRoutingTerrainSampler.SamplingOptions(8192, 2, pool, () -> () -> { + Integer previous = scope.get(); + scope.set(7); + opened.incrementAndGet(); + return () -> { + if (previous == null) { + scope.remove(); + } else { + scope.set(previous); + } + closed.incrementAndGet(); + }; + })); + long[] coordinates = new long[4096]; + for (int index = 0; index < coordinates.length; index++) { + coordinates[index] = RiverFootprint.pack(index - 2048, -index); + } + HydrologyTerrainSample[] result = pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch( + coordinates, coordinates.length)).get(10, TimeUnit.SECONDS); + for (int index = 0; index < result.length; index++) { + assertEquals(100 + Math.floorMod(-2048, 17), result[index].naturalHeight()); + } + assertEquals(coordinates.length, samples.get()); + assertEquals(2, opened.get()); + assertEquals(opened.get(), closed.get()); + assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS)); + assertThrows(IllegalArgumentException.class, + () -> sampler.sampleBasisWithoutSlopeBatch(new long[4097], 4097)); + long[] failureCoordinates = new long[4096]; + for (int index = 0; index < failureCoordinates.length; index++) { + failureCoordinates[index] = RiverFootprint.pack(10_000 + index, -index); + } + assertThrows(ExecutionException.class, () -> pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch( + failureCoordinates, failureCoordinates.length)).get(10, TimeUnit.SECONDS)); + assertEquals(4, opened.get()); + assertEquals(opened.get(), closed.get()); + assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test public void bankHeightsAtSeaLevelSkipOceanIntentSampling() { AtomicInteger classifierCalls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java index 1aa73922f..0b6364574 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java @@ -2,6 +2,12 @@ package art.arcane.iris.generation.hydrology.surface; import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.generation.hydrology.HydraulicChannelProfile; +import art.arcane.iris.generation.hydrology.HydraulicSegment; +import art.arcane.iris.generation.hydrology.HydrologyFeatureType; +import art.arcane.iris.generation.hydrology.HydrologySurfaceDropRaster; +import art.arcane.iris.generation.hydrology.RiverCourse; +import art.arcane.iris.generation.hydrology.RiverCourseType; import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler; import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; @@ -13,10 +19,18 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile; import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle; import org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; +import java.util.HexFormat; import java.util.HashSet; import java.util.List; +import java.util.HashMap; +import java.util.Map; import java.util.Set; +import java.util.OptionalLong; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntBinaryOperator; @@ -33,6 +47,223 @@ public class ErosionFieldCompilerTest { default -> request.minimum(); }; + @Test + public void wetIncisionPreflightMatchesFullVerdictsAcrossPondsCrossingsStepsAndPolicies() { + int accepted = 0; + int unsupported = 0; + for (int scenario = 0; scenario < 24; scenario++) { + int fixture = scenario; + boolean crossing = scenario % 3 == 1; + boolean coastal = scenario % 6 == 5; + HydrologyPlannerSettings.Ponds ponds = scenario % 4 == 0 ? noPonds() + : new HydrologyPlannerSettings.Ponds(new HydrologyPlannerSettings.Pond(true, 3, 8, 3), + new HydrologyPlannerSettings.Pond(true, 3, 7, 2)); + HydrologyPlannerSettings.Erosion erosion = shapedErosion( + IrisRiverBlendStyle.values()[scenario % IrisRiverBlendStyle.values().length], 4, 0.5D, + IrisRiverBedProfile.values()[scenario % IrisRiverBedProfile.values().length], 1D, 2D); + HydrologyPlannerSettings.Surface surface = surfaceWith(scenario % 2 == 0 ? 0D : 0.6D, + 0, 1.5D, erosion, ponds, HydrologyPlannerSettings.Channel.defaults(), + HydrologyPlannerSettings.Flow.defaults()); + HydrologyTerrainSampler terrain = (x, z) -> { + if (coastal && x > 12) { + return HydrologyTerrainSample.ocean(50, "ocean"); + } + int natural = coastal ? SEA_LEVEL : 90; + if (fixture >= 12 && x >= -2 && x <= 2) { + natural += 30; + } + if (fixture % 4 == 2 && z > 4) { + natural += 12; + } + HydrologyTerrainSample sample = HydrologyTerrainSample.openLand(natural, 0D, "land") + .withErosion(fixture % 7 != 0).withShoreWidth(z < 0 ? 0.5D : 3D); + return fixture % 3 == 2 ? sample.withSurfacePolicy(new SurfaceRiverPolicy( + "local", null, null, null, null, null, null, x < 0 ? 3 : 16)) : sample; + }; + List path = crossing + ? List.of(new HydrologyPoint(-18, 90, -8), new HydrologyPoint(18, 90, 8), + new HydrologyPoint(-18, 90, 8), new HydrologyPoint(18, 90, -8)) + : List.of(new HydrologyPoint(-18, 90, 0), new HydrologyPoint(18, 90, 0)); + SurfaceCenterline centerline = SurfaceCenterline.densify(path); + double[] widths = new double[centerline.size()]; + double[] depths = new double[centerline.size()]; + double[] multipliers = new double[centerline.size()]; + int[] heads = new int[centerline.size()]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 3D); + Arrays.fill(multipliers, 1D); + for (int station = 0; station < heads.length; station++) { + heads[station] = coastal ? SEA_LEVEL : 89 - (scenario % 4 == 3 ? station / 8 : 0); + } + ChannelProfile channel = new ChannelProfile(widths, depths, multipliers); + ValleyProfile valley = ValleyProfile.fromHeads(heads, heads.length); + SurfaceTerminal terminal = coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE; + HydrologyPlannerSettings settings = rasterSettings(surface); + HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.empty(); + if (scenario % 4 == 3 && !coastal) { + HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL, + 89, 81, 6, 3, true, true, + List.of(new HydrologyPoint(-5, 89, 0), new HydrologyPoint(-4, 81, 0)), + HydraulicChannelProfile.uniform(6, 3)); + RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L), + OptionalLong.of(8L), "water", 1, List.of(), List.of(fall)); + drops = HydrologySurfaceDropRaster.compile(settings, terrain, CONSTANT_GEOMETRY, course); + assertTrue("Drop raster scenario " + scenario, !drops.columns().isEmpty()); + } + SurfaceRasterContext context = new SurfaceRasterContext(null, drops, + scenario % 4 == 1 ? new SurfaceRunBoundary( + new SurfaceRunBoundary.Plane(-18, 0, 1, 0, 10D), + new SurfaceRunBoundary.Plane(18, 0, 1, 0, 10D)) : SurfaceRunBoundary.unbounded()); + ErosionFieldCompiler compiler = new ErosionFieldCompiler(settings, terrain); + ErosionFieldCompiler.PreparedField prepared = compiler.prepare(centerline, channel, valley, + terminal, coastal ? Math.max(0, centerline.size() - 12) : centerline.size()); + for (long seed = 0; seed < 4; seed++) { + ErosionField full = compiler.compile(seed, centerline, channel, valley, terminal, 8, + ponds, context, prepared); + ErosionField publication = compiler.compileForPublication(seed, centerline, channel, valley, + terminal, 8, ponds, context, prepared); + assertEquals("scenario=" + scenario + " seed=" + seed, full.rejection(), publication.rejection()); + if (full.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED) { + unsupported++; + for (SurfaceColumn wet : publication.columns().values()) { + if (wet.role() == SurfaceRole.CHANNEL) { + assertEquals(full.column(wet.x(), wet.z()), wet); + } + } + } else { + assertEqualFields(full, publication); + if (full.rejection() == null) { + accepted++; + } + } + } + } + assertTrue("Accepted oracle cases: " + accepted, accepted >= 8); + assertTrue("Unsupported oracle cases: " + unsupported, unsupported >= 8); + } + + @Test + public void rasterBatchesPreserveExactDemandAndColumnOrder() { + Map serialCalls = new HashMap<>(); + Map batchCalls = new HashMap<>(); + AtomicInteger batches = new AtomicInteger(); + HydrologyTerrainSampler serial = (x, z) -> { + serialCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum); + return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land"); + }; + HydrologyTerrainSampler batched = new HydrologyTerrainSampler() { + @Override + public HydrologyTerrainSample sample(int x, int z) { + batchCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum); + return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land"); + } + + public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) { + assertTrue(count > 0 && count <= 4096); + batches.incrementAndGet(); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count]; + for (int index = 0; index < count; index++) { + samples[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index])); + } + return samples; + } + }; + ErosionField expected = compile(serial, 300, SurfaceTerminal.SINKHOLE, 40).field(); + ErosionField actual = compile(batched, 300, SurfaceTerminal.SINKHOLE, 40).field(); + assertEquals(new ArrayList<>(expected.columns().values()), new ArrayList<>(actual.columns().values())); + assertEquals(expected.rejection(), actual.rejection()); + assertEquals(expected.rejectionDetail(), actual.rejectionDetail()); + assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells()); + assertEquals(expected.bankExcavation(), actual.bankExcavation()); + assertEquals(serialCalls, batchCalls); + assertTrue(batches.get() > 1); + } + + @Test + public void pondExclusionsPreserveCompleteAndClippedFieldsWithFewerTerrainSamples() throws Exception { + List fingerprints = new ArrayList<>(); + List sampleCounts = new ArrayList<>(); + for (int scenario = 0; scenario < 8; scenario++) { + double shore = switch (scenario) { + case 0 -> Math.nextDown(1.75D); + case 1 -> 1.75D; + case 2 -> Math.nextUp(1.75D); + default -> 1.5D; + }; + boolean enabled = scenario != 4; + boolean erode = scenario != 5; + boolean coastal = scenario == 7; + HydrologyPlannerSettings.Erosion defaults = HydrologyPlannerSettings.Erosion.defaults(); + HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion(enabled, + defaults.smoothingRadius(), defaults.thalwegFraction(), defaults.blendCurve(), defaults.bedNoise(), + defaults.style(), defaults.terraceSteps(), defaults.cliffFraction(), defaults.bedProfile(), + defaults.shoreRise(), defaults.blendBaseWidth(), + new HydrologyPlannerSettings.Excavation(16, scenario == 6 ? 5 : 48, 256)); + HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(true, 6, 6, 3), + new HydrologyPlannerSettings.Pond(true, 5, 5, 2)); + HydrologyPlannerSettings.Surface surface = surfaceWith(scenario == 3 ? 0.65D : 0D, 0, shore, + erosion, ponds, HydrologyPlannerSettings.Channel.defaults(), HydrologyPlannerSettings.Flow.defaults()); + HydrologyTerrainSampler terrain = (x, z) -> coastal && x >= 4 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(coastal ? SEA_LEVEL : 80 + Math.max(0, z) / 3, 0D, "land") + .withErosion(erode); + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(-12, 80, 0), new HydrologyPoint(12, 80, 0))); + double[] widths = new double[centerline.size()]; + double[] depths = new double[centerline.size()]; + double[] multipliers = new double[centerline.size()]; + int[] heads = new int[centerline.size()]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(multipliers, 1D); + Arrays.fill(heads, coastal ? SEA_LEVEL : 80); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + AtomicInteger samples = new AtomicInteger(); + ErosionField complete = null; + for (SurfaceBounds bounds : new SurfaceBounds[]{null, new SurfaceBounds(-20, -12, -1, 12), + new SurfaceBounds(0, -12, 20, 12)}) { + ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), (x, z) -> { + samples.incrementAndGet(); + return terrain.sample(x, z); + }).compile(1234L, centerline, new ChannelProfile(widths, depths, multipliers), + ValleyProfile.fromHeads(heads, coastal ? 17 : heads.length), + coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE, 8, ponds, + SurfaceRasterContext.bounded(bounds)); + List ordered = new ArrayList<>(field.columns().values()); + ordered.sort(Comparator.comparingLong(column -> RiverFootprint.pack(column.x(), column.z()))); + for (SurfaceColumn column : ordered) { + digest.update(column.toString().getBytes(StandardCharsets.UTF_8)); + if (bounds != null && bounds.contains(column.x(), column.z())) { + assertEquals(complete.column(column.x(), column.z()), column); + } + } + digest.update((field.rejection() + ":" + field.rejectionDetail() + ":" + + field.uncontainedWetCells() + ":" + field.bankExcavation()).getBytes(StandardCharsets.UTF_8)); + if (bounds == null) { + complete = field; + } + } + fingerprints.add(HexFormat.of().formatHex(digest.digest())); + sampleCounts.add(samples.get()); + } + assertEquals(List.of( + "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", + "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", + "66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a", + "744b15e684ba996f9c730ab1409ada2643985c902a6da1aadb5cc2b4882d640b", + "2e7e4af1e433ccd0a7392f0d2e93bb85ac122088cd2cf8d55107f7dcfb4ec283", + "7beee0b78c37b1287510fa0719375e14e0f0013eb8af81df8e20a3b5628612b7", + "ad35016194318efa42dc862df4e2bb3d9ec398d3bacec79588576d74bfd1723a", + "a2fe6d20c87be6b6ebf1f4503da75ee767e65d5c8e8c43d5c344957f48f3085a"), fingerprints); + int[] previousSamples = {16724, 16724, 16724, 19594, 15164, 15464, 16724, 7367}; + int[] maximumSamples = {15500, 15500, 15500, 17000, 3000, 3300, 5000, 7367}; + for (int scenario = 0; scenario < previousSamples.length; scenario++) { + assertTrue("scenario " + scenario + " samples " + sampleCounts.get(scenario) + + "/" + previousSamples[scenario], sampleCounts.get(scenario) <= maximumSamples[scenario]); + } + } + @Test public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() { HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTableTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTableTest.java new file mode 100644 index 000000000..b481b90ea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceDistanceTableTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.generation.hydrology.surface; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; + +public class SurfaceDistanceTableTest { + @Test + public void latticeResidualsReuseExactDistancesIncludingSignedZero() { + SurfaceDistanceTable table = new SurfaceDistanceTable(32, StrictMath::hypot); + for (int x = -66; x <= 66; x++) { + for (int z = -66; z <= 66; z++) { + equalBits(StrictMath.hypot(x / 2D, z / 2D), table.hypot(x / 2D, z / 2D)); + } + } + double[] special = {-0D, 0D, Double.MIN_VALUE, Double.MAX_VALUE, Double.NaN, + Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, 0.25D, 31.75D, 32.5D}; + for (double x : special) { + for (double z : special) { + equalBits(StrictMath.hypot(x, z), table.hypot(x, z)); + } + } + } + + @Test + public void scalarProjectionMatchesAcrossDirectionsAndExtremeCoordinates() { + SurfaceDistanceTable table = new SurfaceDistanceTable(64, StrictMath::hypot); + int[] origins = {Integer.MIN_VALUE, Integer.MIN_VALUE + 64, -30000000, -1, 0, + 30000000, Integer.MAX_VALUE - 64, Integer.MAX_VALUE}; + for (int origin : origins) { + for (int directionX = -1; directionX <= 1; directionX++) { + for (int directionZ = -1; directionZ <= 1; directionZ++) { + SurfaceCenterline centerline = segment(origin, -origin, origin + directionX, -origin + directionZ); + for (int station = 0; station < 2; station++) { + for (int x = -34; x <= 34; x++) { + for (int z = -34; z <= 34; z++) { + double pointX = origin + x; + double pointZ = -origin + z; + equalBits(centerline.distanceToSegment(station, pointX, pointZ), + centerline.distanceToSegment(station, pointX, pointZ, table)); + } + } + } + } + } + } + SurfaceCenterline arbitrary = segment(-1700000000, 1500000000, 1900000000, -1600000000); + for (int x = -80; x <= 80; x++) { + equalBits(arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D), + arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D, table)); + } + } + + @Test + public void repeatedStationSquaresOnlyCalculateTheBoundedTable() { + AtomicInteger calculations = new AtomicInteger(); + SurfaceDistanceTable table = new SurfaceDistanceTable(33, (x, z) -> { + calculations.incrementAndGet(); + return StrictMath.hypot(x, z); + }); + int initialization = calculations.get(); + assertEquals(34 * 34 + 33, initialization); + long samples = 0; + for (int station = 0; station < 1000; station++) { + SurfaceCenterline centerline = segment(station, -station, station + 1, -station + 1); + for (int x = -32; x <= 32; x++) { + for (int z = -32; z <= 32; z++) { + centerline.distanceToSegment(0, station + x, -station + z, table); + samples++; + } + } + } + assertEquals(4_225_000L, samples); + assertEquals(initialization, calculations.get()); + table.hypot(34D, 0D); + assertEquals(initialization + 1, calculations.get()); + } + + private static SurfaceCenterline segment(int x, int z, int endX, int endZ) { + return new SurfaceCenterline(new int[]{x, endX}, new int[]{z, endZ}, + new double[2], new double[2], new int[2]); + } + + private static void equalBits(double expected, double actual) { + assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java index 6aebc75dc..a3b74ce7d 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -37,6 +37,27 @@ public class SurfaceFootprintCompilerTest { default -> request.minimum(); }; + @Test + public void impossibleWetIncisionRejectsBeforeSamplingWideBanks() { + AtomicInteger bankSamples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (x, z) -> { + if (Math.abs(z) > 8) { + bankSamples.incrementAndGet(); + } + return HydrologyTerrainSample.openLand(x >= 60 && x <= 68 ? 170 : 90, 0D, "land"); + }; + RiverCourse course = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL, + 89, 89, points(0, 200, 89)))); + + SurfaceFootprint footprint = compiler(sampler).compileForPublication(course); + + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, footprint.rejection()); + assertTrue("Wide-bank terrain samples: " + bankSamples.get(), bankSamples.get() < 1_000); + SurfaceFootprint complete = compiler(sampler).compile(course); + assertEquals(complete.rejection(), footprint.rejection()); + assertTrue(complete.columns().size() > footprint.columns().size()); + } + @Test public void mouthUsesConnectedFloodedLandWithoutChangingTerrainMetadataOrOwningWater() { int seaLevel = HydrologyPlannerSettings.defaults().seaLevel(); diff --git a/core/src/test/java/art/arcane/iris/generation/image/IrisImageMapRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/image/IrisImageMapRuntimeTest.java index d2594acac..4a0b22313 100644 --- a/core/src/test/java/art/arcane/iris/generation/image/IrisImageMapRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/image/IrisImageMapRuntimeTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.image; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.ProceduralTerrainHeightSampler; import art.arcane.volmlib.util.collection.KList; import com.google.gson.Gson; import org.junit.Rule; @@ -48,6 +49,33 @@ public class IrisImageMapRuntimeTest { assertEquals(first.hash(), second.hash()); } + @Test + public void heightImagesRetainPixelDetailAfterProceduralLatticeSampling() throws Exception { + File pack = temporaryFolder.newFolder("sampled-height-pack"); + writeMap(pack, "terrain", "terrain", grayscale(0, 255, 0, 255), new IrisImageMap() + .setSource("terrain") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setMinimumHeight(-64D) + .setMaximumHeight(320D) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP)); + IrisDimension dimension = new IrisDimension(); + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey("terrain") + .setMap("terrain") + .setApplication(IrisImageMapApplication.TERRAIN_HEIGHT)); + ProceduralTerrainHeightSampler terrain = new ProceduralTerrainHeightSampler((x, z) -> x + 100D, 4); + IrisData data = IrisData.openDatapackCompiler(pack); + try { + IrisImageMapRuntime runtime = IrisImageMapRuntime.compile(data, dimension, -64); + for (int x = 0; x < 4; x++) { + assertEquals(x % 2 == 0 ? 0D : 384D, + runtime.sampleTerrainHeight(x, 0D, terrain.sample(x, 0D)), EPSILON); + } + } finally { + data.close(); + } + } + @Test public void composesNamedMasksInDeclarationOrder() throws Exception { File pack = temporaryFolder.newFolder("mask-pack"); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java index 1544576e0..21c277cb0 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java @@ -126,6 +126,176 @@ public class IrisCaveCarver3DNearParityTest { carveForcedAirField.setAccessible(true); } + @Test + public void verticalDensityPlanesUseGlobalAnchorsAndReduceSamplesAcrossChunkEdges() throws Exception { + Engine engine = createEngine(128, 110); + IrisDimension dimension = engine.getDimension(); + doReturn(4).when(dimension).getCaveDensitySamplingStep(); + doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld(); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, + createProfile(false, false).setDetailWeight(0D).setAllowFluid(false)); + AtomicInteger calls = new AtomicInteger(); + Set sampledY = new HashSet<>(); + CNG density = mock(CNG.class); + doAnswer(invocation -> { + calls.incrementAndGet(); + double x = invocation.getArgument(0); + double y = invocation.getArgument(1); + double z = invocation.getArgument(2); + sampledY.add((int) y); + return x * 0.0001D + y * 0.01D + z * 0.0002D; + }).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + baseDensityField.set(carver, density); + Method prepare = IrisCaveCarver3D.class.getDeclaredMethod("prepareVerticalDensityPlane", + CaveCarveScratch.class, int.class, int.class, int.class); + prepare.setAccessible(true); + CaveCarveScratch scratch = new CaveCarveScratch(); + for (int y = 0; y < 64; y++) { + prepare.invoke(carver, scratch, -16, 32, y); + for (int x = 0; x < 3; x++) { + for (int z = 0; z < 3; z++) { + assertEquals((-16 + x * 8) * 0.0001D + y * 0.01D + (32 + z * 8) * 0.0002D, + scratch.adaptivePlaneDensity[x * 3 + z], 1.0E-14D); + } + } + } + assertEquals(153, calls.get()); + assertFalse(sampledY.contains(1)); + int interpolatedCalls = calls.get(); + doReturn(1).when(dimension).getCaveDensitySamplingStep(); + IrisCaveCarver3D scalar = new IrisCaveCarver3D(engine, + createProfile(false, false).setDetailWeight(0D).setAllowFluid(false)); + baseDensityField.set(scalar, density); + CaveCarveScratch scalarScratch = new CaveCarveScratch(); + for (int index = 0; index < 256; index++) { + scalarScratch.planeColumnIndices[index] = index; + } + Method classify = IrisCaveCarver3D.class.getDeclaredMethod("classifyDensityPlaneAdaptive", + CaveCarveScratch.class, int.class, int.class, int.class, int[].class, double[].class, + int.class, boolean[].class, int.class, double.class); + classify.setAccessible(true); + calls.set(0); + for (int y = 0; y < 64; y++) { + classify.invoke(scalar, scalarScratch, -16, 32, y, scalarScratch.planeColumnIndices, + scalarScratch.planeThresholdLimit, 256, scalarScratch.planeCarve, 8, 0.04D); + } + assertEquals(576, calls.get()); + assertTrue(interpolatedCalls < calls.get() * 0.3D); + assertTrue(sampledY.containsAll(List.of(0, 3, 7, 63))); + CaveCarveScratch neighboring = new CaveCarveScratch(); + prepare.invoke(carver, neighboring, 0, 32, 63); + for (int z = 0; z < 3; z++) { + assertEquals(scratch.adaptivePlaneDensity[6 + z], neighboring.adaptivePlaneDensity[z], 0D); + } + prepare.invoke(carver, scratch, 48, -32, 9); + assertEquals(48 * 0.0001D + 0.09D - 32 * 0.0002D, scratch.adaptivePlaneDensity[0], 1.0E-14D); + } + + @Test + public void expressionStylesKeepTheOriginalSamplingAndCarvingBehavior() throws Exception { + CNG density = mock(CNG.class); + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + calls.incrementAndGet(); + double y = invocation.getArgument(1); + return Math.sin(y * 0.2D); + }).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + doReturn("density_expression").when(style).getExpression(); + doReturn(density).when(style).create(any(RNG.class), any(IrisData.class)); + IrisCaveProfile profile = createProfile(false, false).setBaseDensityStyle(style) + .setDetailWeight(0D).setAllowFluid(false) + .setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + WriterCapture expected = null; + int expectedSamples = 0; + for (int step : new int[]{1, 4}) { + Engine engine = createEngine(128, 110); + IrisDimension dimension = engine.getDimension(); + doReturn(step).when(dimension).getCaveDensitySamplingStep(); + WriterCapture capture = createWriterCapture(128); + calls.set(0); + new IrisCaveCarver3D(engine, profile).carve(capture.writer, 0, 0, + fullWeights(), 0D, 0D, null, filledHeights(110)); + assertFalse(capture.carvedCells.isEmpty()); + if (expected == null) { + expected = capture; + expectedSamples = calls.get(); + } else { + assertEquals(expectedSamples, calls.get()); + assertEquals(expected.carvedCells, capture.carvedCells); + assertEquals(expected.carvedLiquids, capture.carvedLiquids); + } + } + } + + @Test + public void verticalDensityPreservesTwoBlockModuleTunnelsWithoutBleedingAcrossBands() throws Exception { + for (int step : new int[]{2, 4, 8}) { + Engine engine = createEngine(128, 110); + IrisDimension dimension = engine.getDimension(); + doReturn(step).when(dimension).getCaveDensitySamplingStep(); + IrisCaveProfile profile = createProfile(false, false).setDetailWeight(0D).setAllowFluid(false) + .setVerticalEdgeFade(0).setVerticalEdgeFadeStrength(0D).setAllowSurfaceBreak(false) + .setDensityThreshold(new IrisStyledRange(0.25D, 0.25D, new IrisGeneratorStyle(NoiseStyle.FLAT))) + .setModules(new KList<>(new IrisCaveFieldModule(new IrisGeneratorStyle(NoiseStyle.FLAT), + 1D, 0D, new IrisRange(5D, 6D), false))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + CNG base = mock(CNG.class); + doReturn(1D).when(base).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + baseDensityField.set(carver, base); + CNG module = mock(CNG.class); + doReturn(-1D).when(module).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + moduleDensityField.set(((CaveFieldModuleState[]) modulesField.get(carver))[0], module); + WriterCapture capture = createWriterCapture(128); + carver.carve(capture.writer, -1, 2, fullWeights(), 0D, 0D, null, filledHeights(110)); + assertEquals(512, capture.carvedCells.size()); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertTrue(capture.carvedCells.contains(cellKey(x, 5, z))); + assertTrue(capture.carvedCells.contains(cellKey(x, 6, z))); + assertFalse(capture.carvedCells.contains(cellKey(x, 4, z))); + assertFalse(capture.carvedCells.contains(cellKey(x, 7, z))); + } + } + } + } + + @Test + public void verticalDensityMatchesParallelDecisionsAndKeepsSurfaceFluidBarriers() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(4)) { + for (boolean warp : new boolean[]{false, true}) { + Engine engine = createEngine(128, 110); + IrisDimension dimension = engine.getDimension(); + doReturn(4).when(dimension).getCaveDensitySamplingStep(); + doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld(); + IrisCaveProfile profile = createProfile(warp, true).setFluidRequiresFloor(false) + .setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + long[] boundaries = new long[256]; + Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary( + SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE)); + boundaries[16] = SurfaceFluidBoundaryPlan.boundary(61, 64); + WriterCapture serial = createWriterCapture(128); + WriterCapture parallel = createWriterCapture(128); + int[] heights = filledHeights(110); + int expected = carver.carve(serial.writer, -17, 19, fullWeights(), 0D, 0D, + null, heights, boundaries, null, new CaveFluidSupportPlan()); + int actual = pool.submit(() -> carver.carve(parallel.writer, -17, 19, fullWeights(), 0D, 0D, + 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); + for (int y = 61; y <= 64; y++) { + assertFalse(serial.carvedCells.contains(cellKey(1, y, 0))); + } + assertTrue(countLiquid(serial, (byte) 2) > 0); + assertTrue(countLiquid(serial, (byte) 0) > 0); + assertTrue(maxY(serial.carvedCells) <= 110); + } + } + } + @Test public void carvingReleasesSectionReferencesAfterSuccessAndFailure() throws Exception { for (boolean fail : new boolean[]{false, true}) { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java index fea89db36..eb784c8df 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java @@ -37,6 +37,10 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class MantleWriterPreObjectJournalTest { @@ -72,7 +76,7 @@ public class MantleWriterPreObjectJournalTest { when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class)); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); - matter = new IrisMatter(16, 16, 16); + matter = spy(new IrisMatter(16, 16, 16)); when(mantle.getWorldHeight()).thenReturn(32); when(mantle.getChunk(0, 0)).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); @@ -90,6 +94,88 @@ public class MantleWriterPreObjectJournalTest { IrisPlatforms.unbind(); } + @Test + public void concurrentTransactionsKeepOverlaysPrivateDuringObjectPublication() throws Exception { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState published = mock(NativeBlockState.class); + NativeBlockState firstOverlay = mock(NativeBlockState.class); + NativeBlockState secondOverlay = mock(NativeBlockState.class); + matter.slice(NativeBlockState.class).set(X, Y, Z, original); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = executor.submit(() -> verifyTransactionReads(original, firstOverlay, ready, start)); + Future second = executor.submit(() -> verifyTransactionReads(original, secondOverlay, ready, start)); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + writer.withComponentPriority(2, () -> { + for (int pass = 0; pass < 100; pass++) { + writer.setData(X, Y, Z, published); + } + }); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertSame(published, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertSame(original, writer.getPrerequisiteBlock(X, Y, Z)); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void integerPrerequisitesDoNotReadTheObjectJournal() { + matter.slice(Integer.class).set(X, Y, Z, 17); + clearInvocations(matter); + + assertEquals(Integer.valueOf(17), writer.getPrerequisiteDataIfPresent(X, Y, Z, Integer.class)); + + verify(matter, never()).getSlice(PreObjectMatterCell.class); + } + + @Test + public void hydrologyPrerequisitesHonorCapturedOriginalsAndNulls() { + HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); + HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); + matter.slice(HydrologyCaveCell.class).set(X, Y, Z, live); + matter.slice(HydrologyCaveCell.class).set(X, Y + 1, Z, live); + matter.slice(PreObjectMatterCell.class) + .set(X, Y, Z, PreObjectMatterCell.hydrology(original)); + matter.slice(PreObjectMatterCell.class) + .set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null)); + + assertSame(original, writer.getPrerequisiteDataIfPresent(X, Y, Z, HydrologyCaveCell.class)); + assertNull(writer.getPrerequisiteDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class)); + assertSame(live, writer.getDataIfPresent(X, Y, Z, HydrologyCaveCell.class)); + assertSame(live, writer.getDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class)); + } + + @Test + public void carvedColumnsHonorCapturedHydrologyBeforeLiveCells() { + HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); + HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); + MatterSlice hydrology = matter.slice(HydrologyCaveCell.class); + MatterSlice journal = matter.slice(PreObjectMatterCell.class); + hydrology.set(X, Y, Z, live); + hydrology.set(X, Y + 1, Z, live); + hydrology.set(X, Y + 2, Z, original); + journal.set(X, Y, Z, PreObjectMatterCell.hydrology(original)); + journal.set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null)); + journal.set(X, Y + 2, Z, PreObjectMatterCell.hydrology(live)); + matter.slice(MatterCavern.class) + .set(X, Y + 1, Z, new MatterCavern(true, "cave", (byte) 0)); + + byte[] carved = writer.getPrerequisiteCarvedColumn(X, Z, 32); + + assertEquals(1, carved[Y]); + assertEquals(1, carved[Y + 1]); + assertEquals(0, carved[Y + 2]); + for (int y = 0; y < carved.length; y++) { + assertEquals(writer.isPrerequisiteCarved(X, y, Z), carved[y] != 0); + } + } + @Test public void firstMutationCapturesEachSupportedOriginalIndependently() { NativeBlockState originalBlock = mock(NativeBlockState.class); @@ -207,6 +293,23 @@ public class MantleWriterPreObjectJournalTest { assertFalse(writer.restorePrerequisiteCell(X + 1, Y, Z)); } + private void verifyTransactionReads( + NativeBlockState original, + NativeBlockState overlay, + CountDownLatch ready, + CountDownLatch start + ) { + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + ready.countDown(); + await(start); + for (int pass = 0; pass < 100; pass++) { + assertSame(original, transaction.get(X, Y, Z)); + } + transaction.setData(X, Y, Z, overlay); + assertSame(overlay, transaction.get(X, Y, Z)); + assertSame(original, writer.getPrerequisiteBlock(X, Y, Z)); + } + private void await(CountDownLatch latch) { try { assertTrue(latch.await(5, TimeUnit.SECONDS)); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index af5d109e4..2a905b8f1 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.integration.Identifier; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.world.history.BoundaryColumnGeometry; @@ -27,6 +28,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -47,6 +51,219 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ObjectDestinationTransactionTest { + @Test + public void commonAndGenericMetadataSurviveBlockReplacementAndSourceReplay() { + MantleWriter writer = writer(); + NativeBlockState custom = mock(NativeBlockState.class); + NativeBlockState base = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + Identifier identifier = Identifier.fromString("iris:tree_block"); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn(identifier.toString()); + when(custom.placementBaseState()).thenReturn(base); + TreeBlockMaterial firstMaterial = new TreeBlockMaterial("minecraft:oak_log"); + TreeBlockMaterial secondMaterial = new TreeBlockMaterial("minecraft:birch_log"); + Marker generic = new Marker("generic"); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + int[][] positions = {{-17, 31}, {Integer.MIN_VALUE, Integer.MAX_VALUE}}; + for (int[] position : positions) { + int x = position[0]; + int z = position[1]; + source.setData(x, 7, z, "first"); + source.setData(x, 7, z, firstMaterial); + source.setData(x, 7, z, generic); + source.set(x, 7, z, custom); + source.setData(x, 7, z, "second"); + source.setData(x, 7, z, secondMaterial); + source.setData(x, 7, z, Integer.valueOf(42)); + source.setData(x, 7, z, replacement); + source.setData(x, 7, z, null); + assertNull(source.getDataIfPresent(x, 7, z, Identifier.class)); + } + ObjectSourcePlan plan = source.sourcePlanSince(0); + assertEquals(16, source.mutationCheckpoint()); + assertEquals(35, plan.mutationWeight()); + for (int[] position : positions) { + int x = position[0]; + int z = position[1]; + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, x >> 4, z >> 4); + destination.apply(plan); + List sourceMutations = plan.mutationsFor(x >> 4, z >> 4); + List replayed = destination.sourcePlanSince(0).mutationsFor(x >> 4, z >> 4); + assertEquals(sourceMutations.size(), replayed.size()); + for (int index = 0; index < sourceMutations.size(); index++) { + assertSame(sourceMutations.get(index), replayed.get(index)); + } + assertEquals(8, destination.mutationCheckpoint()); + assertSame(replacement, destination.get(x, 7, z)); + assertNull(destination.getDataIfPresent(x, 7, z, Identifier.class)); + assertEquals("second", destination.getDataIfPresent(x, 7, z, String.class)); + assertSame(secondMaterial, destination.getDataIfPresent(x, 7, z, TreeBlockMaterial.class)); + assertSame(generic, destination.getDataIfPresent(x, 7, z, Marker.class)); + assertEquals(Integer.valueOf(42), destination.getDataIfPresent(x, 7, z, Integer.class)); + destination.commit(); + InOrder order = inOrder(writer); + order.verify(writer).setData(x, 7, z, "first"); + order.verify(writer).setData(x, 7, z, firstMaterial); + order.verify(writer).setData(x, 7, z, generic); + order.verify(writer).set(x, 7, z, custom); + order.verify(writer).setData(x, 7, z, "second"); + order.verify(writer).setData(x, 7, z, secondMaterial); + order.verify(writer).setData(x, 7, z, Integer.valueOf(42)); + order.verify(writer).setData(x, 7, z, replacement); + } + } + + @Test + public void replayRechecksDestinationProtectionAndHeightBeforeRetainingMutations() { + MantleWriter sourceWriter = writer(); + when(sourceWriter.getMantle().getWorldHeight()).thenReturn(128); + NativeBlockState block = mock(NativeBlockState.class); + NativeBlockState custom = mock(NativeBlockState.class); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("iris:tree_block"); + when(custom.placementBaseState()).thenReturn(block); + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + TreeBlockMaterial material = new TreeBlockMaterial("minecraft:oak_log"); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(sourceWriter, 0, 0); + source.setData(0, 4, 0, block); + source.setData(1, 4, 0, cavern); + source.set(2, 4, 0, custom); + source.setData(3, 0, 0, block); + source.setData(4, 80, 0, block); + source.setData(0, 4, 0, "tree"); + source.setData(0, 4, 0, material); + ObjectSourcePlan plan = source.sourcePlanSince(0); + assertEquals(7, plan.mutationsFor(0, 0).size()); + MantleWriter destinationWriter = writer(); + when(destinationWriter.getEngine().getDimension().isBedrock()).thenReturn(true); + for (int x = 0; x < 3; x++) { + when(destinationWriter.getPrerequisiteDataIfPresent(x, 4, 0, HydrologyCaveCell.class)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + } + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(destinationWriter, 0, 0); + + destination.apply(plan); + + List replayed = destination.sourcePlanSince(0).mutationsFor(0, 0); + assertEquals(2, replayed.size()); + assertSame(plan.mutationsFor(0, 0).get(5), replayed.get(0)); + assertSame(plan.mutationsFor(0, 0).get(6), replayed.get(1)); + assertNull(destination.getDataIfPresent(2, 4, 0, Identifier.class)); + assertNull(destination.getDataIfPresent(1, 4, 0, MatterCavern.class)); + assertEquals("tree", destination.getDataIfPresent(0, 4, 0, String.class)); + assertSame(material, destination.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class)); + destination.commit(); + verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class)); + verify(destinationWriter, never()).set(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class)); + verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(MatterCavern.class)); + } + + @Test + public void failedCommonMetadataPublicationRestoresOriginalValuesAndMissingTypes() { + MantleWriter writer = writer(); + TreeBlockMaterial originalMaterial = new TreeBlockMaterial("minecraft:oak_log"); + TreeBlockMaterial replacementMaterial = new TreeBlockMaterial("minecraft:birch_log"); + Marker generic = new Marker("generic"); + Marker failure = new Marker("failure"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, String.class)).thenReturn("original"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, TreeBlockMaterial.class)).thenReturn(originalMaterial); + doThrow(new IllegalStateException("publication failed")) + .when(writer).setData(1, 4, 0, failure); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + assertEquals("original", transaction.getDataIfPresent(0, 4, 0, String.class)); + assertSame(originalMaterial, transaction.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class)); + transaction.setData(0, 4, 0, "replacement"); + transaction.setData(0, 4, 0, replacementMaterial); + transaction.setData(0, 4, 0, generic); + transaction.setData(1, 4, 0, failure); + + assertThrows(IllegalStateException.class, transaction::commit); + + verify(writer).clearData(0, 4, 0, String.class); + verify(writer).setData(0, 4, 0, "original"); + verify(writer).clearData(0, 4, 0, TreeBlockMaterial.class); + verify(writer).setData(0, 4, 0, originalMaterial); + verify(writer).clearData(0, 4, 0, Marker.class); + verify(writer).setData(0, 4, 0, generic); + } + + @Test + public void sparseOverlayMatchesScalarAndColumnReadsAcrossSignedCoordinates() { + MantleWriter writer = writer(); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + int[][] columns = {{0, 0}, {-1, -1}, {1, -1}, {-1, 1}, + {Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE}, + {0, 1 << 26}, {1 << 26, 0}}; + NativeBlockState solid = mock(NativeBlockState.class); + NativeBlockState air = mock(NativeBlockState.class); + NativeBlockState fluid = mock(NativeBlockState.class); + when(air.isAir()).thenReturn(true); + when(fluid.isFluid()).thenReturn(true); + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + Object[] values = {solid, air, fluid, cavern, + HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD), + HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)}; + Map expected = new HashMap<>(); + Random random = new Random(918241L); + for (int pass = 0; pass < 12; pass++) { + for (int write = 0; write < 80; write++) { + int[] column = columns[random.nextInt(columns.length)]; + int y = random.nextInt(64); + Object value = values[random.nextInt(values.length)]; + transaction.setData(column[0], y, column[1], value); + HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get( + new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class)); + if (!(value instanceof HydrologyCaveCell) + && hydrology != null && hydrology.protectsPlacement()) { + continue; + } + Class type = value instanceof NativeBlockState ? NativeBlockState.class : value.getClass(); + expected.put(new ObjectDestinationTransaction.DataKey(column[0], y, column[1], type), value); + } + for (int[] column : columns) { + byte[] carved = new byte[64]; + for (int y = 0; y < 64; y++) { + NativeBlockState block = (NativeBlockState) expected.get( + new ObjectDestinationTransaction.DataKey(column[0], y, column[1], NativeBlockState.class)); + HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get( + new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class)); + MatterCavern expectedCavern = (MatterCavern) expected.get( + new ObjectDestinationTransaction.DataKey(column[0], y, column[1], MatterCavern.class)); + boolean isCarved = block != solid && (hydrology == null + ? expectedCavern != null : hydrology.carves()); + carved[y] = isCarved ? (byte) 1 : 0; + assertSame(block, transaction.get(column[0], y, column[1])); + assertSame(hydrology, transaction.getDataIfPresent(column[0], y, column[1], HydrologyCaveCell.class)); + assertSame(expectedCavern, transaction.getDataIfPresent(column[0], y, column[1], MatterCavern.class)); + assertEquals(isCarved, transaction.isCarved(column[0], y, column[1])); + } + assertArrayEquals(carved, transaction.getCarvedColumn(column[0], column[1], 64)); + } + } + } + + @Test + public void overlayReadsLeavePrerequisiteUpdatesVisibleAndReturnedColumnsIndependent() { + MantleWriter writer = writer(); + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + when(writer.getPrerequisiteBlock(-1, 5, -1)).thenReturn(first, second); + byte[] prerequisite = new byte[64]; + prerequisite[5] = 1; + when(writer.getPrerequisiteCarvedColumn(-1, -1, 64)).thenReturn(prerequisite); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, -1, -1); + + assertSame(first, transaction.get(-1, 5, -1)); + assertSame(second, transaction.get(-1, 5, -1)); + byte[] column = transaction.getCarvedColumn(-1, -1, 64); + column[5] = 0; + assertEquals(1, transaction.getCarvedColumn(-1, -1, 64)[5]); + transaction.setData(-1, 5, -1, second); + assertEquals(0, transaction.getCarvedColumn(-1, -1, 64)[5]); + assertEquals(1, prerequisite[5]); + } + @Test public void overlayReadsEarlierWritesAndCommitsOnlyDestinationChunk() { MantleWriter writer = writer(); @@ -122,6 +339,46 @@ public class ObjectDestinationTransactionTest { verify(writer, never()).setData(-1, 4, 16, foreign); } + @Test + public void indexedSourceReplayPreservesCustomIdentityAndWriteOrderAtNegativeChunks() { + MantleWriter writer = writer(); + NativeBlockState custom = mock(NativeBlockState.class); + NativeBlockState base = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + Identifier identifier = Identifier.fromString("iris:custom_block"); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn(identifier.toString()); + when(custom.placementBaseState()).thenReturn(base); + Marker first = new Marker("first"); + Marker second = new Marker("second"); + Marker foreign = new Marker("foreign"); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + source.setData(-17, 4, 31, first); + source.set(-17, 4, 31, custom); + source.setData(-16, 4, 31, foreign); + source.setData(-17, 4, 31, replacement); + source.setData(-17, 4, 32, foreign); + source.set(-17, 4, 31, custom); + source.setData(-17, 4, 31, second); + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -2, 1); + + destination.apply(source.sourcePlanSince(0)); + + assertEquals(5, destination.mutationCheckpoint()); + assertSame(base, destination.get(-17, 4, 31)); + assertEquals(identifier, destination.getDataIfPresent(-17, 4, 31, Identifier.class)); + assertSame(second, destination.getDataIfPresent(-17, 4, 31, Marker.class)); + destination.commit(); + InOrder order = inOrder(writer); + order.verify(writer).setData(-17, 4, 31, first); + order.verify(writer).set(-17, 4, 31, custom); + order.verify(writer).setData(-17, 4, 31, replacement); + order.verify(writer).set(-17, 4, 31, custom); + order.verify(writer).setData(-17, 4, 31, second); + verify(writer, never()).setData(-16, 4, 31, foreign); + verify(writer, never()).setData(-17, 4, 32, foreign); + } + @Test public void sourcePlanRejectsAnInvalidCheckpoint() { ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0); @@ -340,8 +597,13 @@ public class ObjectDestinationTransactionTest { transaction.setData(0, 4, 0, block); assertNull(transaction.getDataIfPresent(0, 4, 0, Identifier.class)); transaction.setData(1, 4, 0, failure); + ObjectSourcePlan plan = transaction.sourcePlanSince(0); + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 0, 0); + destination.apply(plan); + assertNull(destination.getDataIfPresent(0, 4, 0, Identifier.class)); + assertSame(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst()); - assertThrows(IllegalStateException.class, transaction::commit); + assertThrows(IllegalStateException.class, destination::commit); verify(writer).clearData(0, 4, 0, Identifier.class); verify(writer).setData(0, 4, 0, originalIdentifier); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java index ab70b7375..ec516e2bf 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanCacheTest.java @@ -1,22 +1,91 @@ package art.arcane.iris.generation.mantle; +import art.arcane.volmlib.util.cache.CacheKey; import org.junit.Test; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ObjectSourcePlanCacheTest { + @Test + public void defaultCapacityRetainsNeighboringSourceWorkingSetWithoutRebuilding() { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(); + AtomicInteger builds = new AtomicInteger(); + ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey( + 0, 4, 0, String.class); + ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation(key, "marker"); + ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2048, mutation)); + assertTrue(expected.mutationWeight() * 1024L > 1_048_576L); + for (int sweep = 0; sweep < 3; sweep++) { + for (int x = -16; x < 16; x++) { + for (int z = -16; z < 16; z++) { + assertSame(expected, cache.get(x, z, () -> { + builds.incrementAndGet(); + return expected; + })); + } + } + assertEquals(1024L, cache.estimatedSize()); + assertEquals(1024, builds.get()); + } + cache.clear(); + assertEquals(0L, cache.estimatedSize()); + assertSame(expected, cache.get(-16, -16, () -> { + builds.incrementAndGet(); + return expected; + })); + assertEquals(1025, builds.get()); + } + + @Test + public void unrelatedCoordinatesBuildWhileAnotherPlanIsPending() throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); + ObjectSourcePlan first = planAt(1); + ObjectSourcePlan second = planAt(2); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + assertEquals(Long.hashCode(CacheKey.mix(CacheKey.key(32113, 37))), + Long.hashCode(CacheKey.mix(CacheKey.key(32667, 37)))); + Future firstResult = executor.submit(() -> cache.get(32113, 37, () -> { + firstEntered.countDown(); + try { + assertTrue(releaseFirst.await(10L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + return first; + })); + assertTrue(firstEntered.await(5L, TimeUnit.SECONDS)); + Future secondResult = executor.submit(() -> cache.get(32667, 37, () -> second)); + + assertSame(second, secondResult.get(3L, TimeUnit.SECONDS)); + releaseFirst.countDown(); + assertSame(first, firstResult.get(5L, TimeUnit.SECONDS)); + assertSame(first, cache.get(32113, 37, () -> second)); + assertSame(second, cache.get(32667, 37, () -> first)); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + } + } + @Test public void concurrentRequestsBuildOneSourcePlan() throws Exception { ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); @@ -51,9 +120,9 @@ public class ObjectSourcePlanCacheTest { @Test public void cacheEvictsByMutationWeightAndCanBeCleared() { - ObjectSourcePlanCache cache = new ObjectSourcePlanCache(3L); ObjectSourcePlan first = planAt(0); ObjectSourcePlan second = planAt(1); + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.mutationWeight()); cache.get(0, 0, () -> first); cache.get(1, 0, () -> second); @@ -68,6 +137,83 @@ public class ObjectSourcePlanCacheTest { assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlanCache(0L)); } + @Test + public void clearingPendingWorkKeepsNewGenerationIsolated() throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); + ObjectSourcePlan old = planAt(1); + ObjectSourcePlan replacement = planAt(2); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future first = executor.submit(() -> cache.get(7, 8, () -> { + entered.countDown(); + await(release); + return old; + })); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + cache.clear(); + assertSame(replacement, cache.get(7, 8, () -> replacement)); + } finally { + release.countDown(); + } + assertSame(old, first.get(5L, TimeUnit.SECONDS)); + assertSame(replacement, cache.get(7, 8, () -> old)); + } + } + + @Test + public void waitingForkJoinWorkerAllowsQueuedDependencyToRun() throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); + ObjectSourcePlan expected = planAt(1); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ExecutorService owner = Executors.newSingleThreadExecutor(); + ForkJoinPool pool = new ForkJoinPool(1)) { + Future first = owner.submit(() -> cache.get(7, 8, () -> { + entered.countDown(); + await(release); + return expected; + })); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + Future second = pool.submit(() -> { + waiting.countDown(); + return cache.get(7, 8, () -> { throw new AssertionError("Duplicate build"); }); + }); + assertTrue(waiting.await(5L, TimeUnit.SECONDS)); + pool.submit(release::countDown).get(5L, TimeUnit.SECONDS); + assertSame(expected, first.get(5L, TimeUnit.SECONDS)); + assertSame(expected, second.get(5L, TimeUnit.SECONDS)); + } finally { + release.countDown(); + } + } + } + + @Test + public void failedAndNullBuildsAreRetryableAndRecursionFailsImmediately() { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); + IllegalArgumentException failure = new IllegalArgumentException("Build failed"); + assertSame(failure, assertThrows(IllegalArgumentException.class, + () -> cache.get(7, 8, () -> { throw failure; }))); + assertNull(cache.get(7, 8, () -> null)); + assertThrows(IllegalStateException.class, () -> cache.get(7, 8, + () -> cache.get(7, 8, () -> planAt(1)))); + ObjectSourcePlan expected = planAt(1); + assertSame(expected, cache.get(7, 8, () -> expected)); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(10L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + private static ObjectSourcePlan planAt(int x) { ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey( x, diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java new file mode 100644 index 000000000..353ff7daf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectSourcePlanTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.generation.mantle; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ObjectSourcePlanTest { + @Test + public void destinationLookupRetainsImmutableIndexedOrder() { + List mutations = new ArrayList<>(); + int[][] positions = {{-1, -1}, {0, 0}, {-16, -16}, {-17, -1}, + {Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE}}; + for (int pass = 0; pass < 3; pass++) { + for (int[] position : positions) { + mutations.add(mutationAt(position[0], position[1])); + } + } + ObjectSourcePlan plan = new ObjectSourcePlan(mutations); + List expected = List.of( + mutations.get(0), mutations.get(2), mutations.get(6), mutations.get(8), mutations.get(12), mutations.get(14)); + mutations.clear(); + + List local = plan.mutationsFor(-1, -1); + assertEquals(expected, local); + assertSame(local, plan.mutationsFor(-1, -1)); + assertEquals(3, plan.mutationsFor(Integer.MIN_VALUE >> 4, Integer.MAX_VALUE >> 4).size()); + assertEquals(3, plan.mutationsFor(Integer.MAX_VALUE >> 4, Integer.MIN_VALUE >> 4).size()); + assertTrue(plan.mutationsFor(1, -1).isEmpty()); + assertTrue(plan.mutationsFor(-1, 1).isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> local.clear()); + } + + @Test + public void cacheWeightIncludesEachDestinationIndex() { + ObjectDestinationTransaction.Mutation first = mutationAt(0, 0); + ObjectDestinationTransaction.Mutation nearby = mutationAt(1, 0); + ObjectDestinationTransaction.Mutation remote = mutationAt(16, 0); + ObjectSourcePlan compact = new ObjectSourcePlan(List.of(first, nearby)); + ObjectSourcePlan scattered = new ObjectSourcePlan(List.of(first, remote)); + + assertTrue(compact.mutationWeight() > 1 + first.weight() + nearby.weight()); + assertTrue(scattered.mutationWeight() > compact.mutationWeight()); + assertThrows(NullPointerException.class, () -> new ObjectSourcePlan(Arrays.asList(first, null))); + } + + private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) { + return new ObjectDestinationTransaction.SetMutation( + new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker"); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java index b2907aad4..75defd7d6 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java @@ -34,6 +34,11 @@ import java.nio.file.Path; import java.util.Optional; 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.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertFalse; @@ -97,6 +102,128 @@ public class EngineShutdownDrainTest { } } + @Test + public void closingRuntimeAllowsItsBackgroundWorkersToOpenCapturedScopes() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + doCallRealMethod().when(fixture.engine).openGenerationRuntimeScope(any()); + IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding( + fixture.engine, fixture.generation); + ExecutorService worker = Executors.newSingleThreadExecutor(); + doAnswer(invocation -> { + Future continuation = worker.submit(() -> { + try (IrisEngine.GenerationRuntimeScope ignored = fixture.engine.openGenerationRuntimeScope(binding)) { + assertSame(binding, fixture.engine.generationRuntimeScopes.current()); + assertSame(fixture.runtime, fixture.engine.runtime); + assertTrue(fixture.engine.getClosing().get()); + assertThrows(IllegalStateException.class, () -> fixture.engine.requireRunning("start generation")); + } + }); + continuation.get(5, TimeUnit.SECONDS); + return null; + }).when(fixture.complex).close(); + + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + assertTrue(fixture.engine.closed); + assertThrows(IllegalStateException.class, () -> fixture.engine.openGenerationRuntimeScope(binding)); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void concurrentCloseWaitsForTheRunningShutdown() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + ExecutorService closer = Executors.newSingleThreadExecutor(); + CompletableFuture> concurrentClose = new CompletableFuture<>(); + CountDownLatch entered = new CountDownLatch(1); + doAnswer(invocation -> { + concurrentClose.complete(closer.submit(() -> { + entered.countDown(); + fixture.shutdown.close(); + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + return null; + }).when(fixture.complex).close(); + + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + concurrentClose.get(5, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS); + verify(fixture.complex).close(); + verify(fixture.mantle).close(); + assertTrue(fixture.engine.closed); + } finally { + closer.shutdownNow(); + assertTrue(closer.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void shutdownWaitsForConcurrentDetachedRetirementWithoutClosingItTwice() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + GenerationRuntime detached = generation(target(), mock(EngineMantle.class)); + IrisComplex detachedComplex = detached.complex(); + fixture.engine.detachedGenerationRuntimes.add(detached); + CountDownLatch retirementStarted = new CountDownLatch(1); + CountDownLatch allowRetirement = new CountDownLatch(1); + CountDownLatch backgroundDrained = new CountDownLatch(1); + doAnswer(invocation -> { + retirementStarted.countDown(); + assertTrue(allowRetirement.await(5, TimeUnit.SECONDS)); + return null; + }).when(detachedComplex).close(); + when(fixture.engine.backgroundTasks.drainBackgroundTasks("close")).thenAnswer(invocation -> { + backgroundDrained.countDown(); + return new EngineBackgroundTasks.BackgroundTaskDrain(null, true); + }); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future retiring = workers.submit(() -> fixture.engine.closeDetachedGenerationRuntime( + new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached))); + assertTrue(retirementStarted.await(5, TimeUnit.SECONDS)); + Future shutdown = workers.submit(() -> { + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + }); + assertTrue(backgroundDrained.await(5, TimeUnit.SECONDS)); + assertFalse(shutdown.isDone()); + verify(fixture.target, never()).close(); + allowRetirement.countDown(); + retiring.get(5, TimeUnit.SECONDS); + shutdown.get(5, TimeUnit.SECONDS); + verify(detached.complex()).close(); + verify(detached.target()).close(); + assertTrue(fixture.engine.closed); + } finally { + allowRetirement.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void failedDetachedRetirementRetainsOwnershipForShutdownRetry() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + GenerationRuntime detached = generation(target(), mock(EngineMantle.class)); + IrisComplex detachedComplex = detached.complex(); + fixture.engine.detachedGenerationRuntimes.add(detached); + doThrow(new IllegalStateException("Background work is still active")).doNothing() + .when(detachedComplex).close(); + + assertThrows(IllegalStateException.class, () -> fixture.engine.closeDetachedGenerationRuntime( + new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached))); + assertTrue(fixture.engine.detachedGenerationRuntimes.contains(detached)); + verify(detached.target(), never()).close(); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + verify(detached.complex(), times(2)).close(); + verify(detached.target()).close(); + assertTrue(fixture.engine.closed); + } + @Test public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception { ShutdownFixture fixture = new ShutdownFixture(); @@ -726,14 +853,21 @@ public class EngineShutdownDrainTest { setField(engine, "lifecycleLock", new Object()); setField(engine, "runtimeAssembly", new ThreadLocal()); setField(engine, "detachedGenerationRuntimes", ConcurrentHashMap.newKeySet()); - when(engine.getClosing()).thenReturn(new AtomicBoolean()); + setField(engine, "retiringGenerationRuntimes", ConcurrentHashMap.newKeySet()); + setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState()); + AtomicBoolean closing = new AtomicBoolean(); + setField(engine, "closing", closing); + when(engine.getClosing()).thenReturn(closing); when(engine.getGenerationSessions()).thenReturn(sessions); when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class)); when(engine.getMantle()).thenReturn(mantle); IrisWorld world = target.getWorld(); when(engine.getWorld()).thenReturn(world); - when(engine.beginShutdown()).thenReturn(true); + doCallRealMethod().when(engine).beginShutdown(); + doCallRealMethod().when(engine).requireRunning(anyString()); + doCallRealMethod().when(engine).closeDetachedGenerationRuntime(any()); engine.runtime = runtime; + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; engine.publishedTarget = target; } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisCarveWallPaletteScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisCarveWallPaletteScopeTest.java index 26827c91f..3102a557e 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisCarveWallPaletteScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisCarveWallPaletteScopeTest.java @@ -19,6 +19,8 @@ import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; @@ -208,6 +210,7 @@ public class IrisCarveWallPaletteScopeTest { engine.runtime = published; doReturn(IrisWorld.builder().minHeight(-64).maxHeight(0).build()).when(engine).getWorld(); doReturn(64).when(engine).getHeight(); + doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks(); doReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)).when(engine).getMetrics(); doReturn(48).when(context).getRoundedHeight(anyInt(), anyInt()); IrisComplex activeComplex = active.complex(); @@ -230,13 +233,15 @@ public class IrisCarveWallPaletteScopeTest { paletteCalls.incrementAndGet(); return wall; }).when(palette).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class)); - doAnswer(call -> { - try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope( - new IrisEngine.GenerationRuntimeBinding(engine, active))) { - assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData()); - } - return biome; - }).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + for (IrisComplex complex : new IrisComplex[]{activeComplex, detachedComplex, assemblyComplex}) { + doReturn(ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> null))) + .when(complex).getTrueBiomeStream(); + doReturn(ProceduralStream.of((x, z) -> scopedBiome(biome), Interpolated.of(value -> 0D, value -> null))) + .when(complex).getCaveBiomeStream(); + doReturn(ProceduralStream.of((x, z) -> 48D, Interpolated.DOUBLE)) + .when(complex).getHeightStream(); + } + doAnswer(call -> scopedBiome(biome)).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); doReturn(engine).when(modifier).getEngine(); doAnswer(call -> { dataLookups.incrementAndGet(); @@ -246,5 +251,14 @@ public class IrisCarveWallPaletteScopeTest { field(modifier, IrisCarveModifier.class, "AIR", air); field(modifier, IrisCarveModifier.class, "LAVA", air); } + + private IrisBiome scopedBiome(IrisBiome biome) { + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope( + new IrisEngine.GenerationRuntimeBinding(engine, active))) { + assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData()); + } + return biome; + } + } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java index 2417ac2ab..d061bf79e 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java @@ -84,6 +84,72 @@ public class IrisComplexGridBoundsCacheTest { } } + public static class CoarseBounds { + @Test + public void coarseBoundsUseGlobalCornersAcrossNegativeChunkSeams() throws Exception { + for (int step : new int[]{8, 16, 32}) { + IrisComplex complex = createComplex(); + setStep(complex, step); + CoordinateInterpolator interpolator = new CoordinateInterpolator(); + for (double x : new double[]{-33D, -32D, -17D, -16D, -1D, 0D, 15D, 16D, 31D, 32D}) { + for (double z : new double[]{-17D, -0.25D, 0D, 15.5D, 16D}) { + NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z); + assertBoundsBitsEqual(expectedGridSampleBounds(x, z, step), actual); + } + } + } + } + + @Test + public void coarseGridReducesDistinctEnvelopeEvaluationsForDenseTerrainNodes() throws Exception { + for (int step : new int[]{4, 8, 16, 32}) { + IrisComplex complex = createComplex(); + setStep(complex, step); + CountingInterpolator interpolator = new CountingInterpolator(1D, 9D); + for (int x = 0; x < 32; x += 4) { + for (int z = 0; z < 32; z += 4) { + NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z); + assertEquals(1D, actual.min(), 0D); + assertEquals(9D, actual.max(), 0D); + } + } + int side = step == 4 ? 8 : 32 / step + 1; + assertEquals(side * side, interpolator.getInvocations()); + } + } + + @Test + public void everyInterpolatorGroupKeepsItsOwnEnvelopeAndGenerators() throws Exception { + IrisComplex complex = createComplex(); + setStep(complex, 16); + CountingInterpolator first = new CountingInterpolator(1D, 2D); + CountingInterpolator second = new CountingInterpolator(10D, 20D); + SampleGenerator firstGenerator = new SampleGenerator(0.25D); + SampleGenerator secondGenerator = new SampleGenerator(0.75D); + setField(complex, "frozenInterpolators", new IrisInterpolator[]{first, second}); + setField(complex, "frozenGenerators", new IrisGenerator[][]{{firstGenerator}, {secondGenerator}}); + setField(complex, "biomeBuffetGenerators", new HashMap<>()); + Method method = IrisComplex.class.getDeclaredMethod("getInterpolatedHeight", Engine.class, + double.class, double.class, long.class); + method.setAccessible(true); + assertEquals(18.75D, (double) method.invoke(complex, null, -1D, 17D, 31L), 0D); + assertEquals(4, first.getInvocations()); + assertEquals(4, second.getInvocations()); + assertEquals(1, firstGenerator.invocations); + assertEquals(1, secondGenerator.invocations); + } + + private static void setStep(IrisComplex complex, int step) throws Exception { + setField(complex, "biomeBoundsSamplingStep", step); + } + + private static void setField(IrisComplex complex, String name, Object value) throws Exception { + Field field = IrisComplex.class.getDeclaredField(name); + field.setAccessible(true); + field.set(complex, value); + } + } + @RunWith(Parameterized.class) public static class LegacyBilerpParity { @Parameters(name = "x={0} z={1}") @@ -121,6 +187,9 @@ public class IrisComplexGridBoundsCacheTest { private static IrisComplex createComplex() throws Exception { IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + Field biomeBoundsSamplingStep = IrisComplex.class.getDeclaredField("biomeBoundsSamplingStep"); + biomeBoundsSamplingStep.setAccessible(true); + biomeBoundsSamplingStep.setInt(complex, 4); Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds"); generatorBounds.setAccessible(true); @@ -192,7 +261,10 @@ public class IrisComplexGridBoundsCacheTest { } private static NoiseBounds legacyGridSampleBounds(double x, double z) { - int grid = 4; + return expectedGridSampleBounds(x, z, 4); + } + + private static NoiseBounds expectedGridSampleBounds(double x, double z, int grid) { int xi = (int) Math.floor(x); int zi = (int) Math.floor(z); int mask = grid - 1; @@ -262,6 +334,21 @@ public class IrisComplexGridBoundsCacheTest { } } + private static final class SampleGenerator extends IrisGenerator { + private final double value; + private int invocations; + + private SampleGenerator(double value) { + this.value = value; + } + + @Override + public double getHeight(double x, double z, long seed) { + invocations++; + return value; + } + } + private static final class CoordinateInterpolator extends IrisInterpolator { private final AtomicInteger invocations = new AtomicInteger(); @@ -272,11 +359,11 @@ public class IrisComplexGridBoundsCacheTest { } private static double low(double x, double z) { - return x * 0.125D - z * 0.0625D - 7.75D; + return x * 0.125D - z * 0.0625D - 7.75D + x * x * 0.000125D; } private static double high(double x, double z) { - return x * -0.03125D + z * 0.1875D + 12.5D; + return x * -0.03125D + z * 0.1875D + 12.5D + z * z * 0.00015625D; } private int getInvocations() { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java index d26052ff9..3e6fb15cf 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.world.pregen.PregenPerformanceProfile; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.hydrology.HydrologyPlanner; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; import art.arcane.iris.generation.hydrology.HydrologyTile; import art.arcane.iris.generation.hydrology.HydrologyTileCache; import art.arcane.iris.generation.hydrology.HydrologyTileKey; @@ -335,6 +336,7 @@ public class IrisComplexHydrologyNoiseCacheTest { private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) { HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class)); HydrologyTileCache cache = new HydrologyTileCache(planner, 4); cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget)); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index e684bf951..3faf566bd 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -1,6 +1,9 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext; +import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope; import art.arcane.iris.generation.runtime.GenerationRuntime.BiomeMaxes; import art.arcane.iris.world.history.SavedBiomeRuntime; import art.arcane.iris.world.history.SavedBiomeUnavailableException; @@ -32,6 +35,7 @@ import org.junit.Test; import java.io.IOException; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.file.Path; import java.time.Duration; import java.util.Collections; @@ -47,6 +51,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntConsumer; +import java.util.function.Supplier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; @@ -70,6 +77,52 @@ public class IrisEngineGenerationRuntimeScopeTest { @ClassRule public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform(); + @Test + @SuppressWarnings("unchecked") + public void hydrologyBasisWorkersRetainHistoricalBindingAndRestoreAmbientContext() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture historical = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + setField(engine, "runtimeBuilder", new EngineRuntimeBuilder(engine)); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class, CALLS_REAL_METHODS); + when(historical.data.getEngine()).thenReturn(engine); + when(historical.complex.getHydrologyRuntime()).thenReturn(hydrology); + IrisHydrologyRuntimeContext context = new IrisHydrologyRuntimeContext(17L, 384, historical.dimension, + historical.data, (x, z, height) -> null, (x, z) -> 80D, (x, z) -> "constant", + (x, z) -> false, footprint -> null, () -> false, () -> {}); + Field runtimeContext = IrisHydrologyRuntime.class.getDeclaredField("context"); + runtimeContext.setAccessible(true); + runtimeContext.set(hydrology, context); + Method capture = IrisHydrologyRuntime.class.getDeclaredMethod("captureBasisScope"); + capture.setAccessible(true); + assertNull(capture.invoke(hydrology)); + Supplier workerScope; + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(detachedBinding(engine, historical.runtime)); + IrisContext.Scope execution = IrisContext.open(engine, 73L, mock(ChunkContext.class))) { + workerScope = (Supplier) capture.invoke(hydrology); + } + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + worker.submit(() -> { + assertSame(active.complex, engine.getComplex()); + try (IrisContext.Scope ambient = IrisContext.open(engine, 99L, null)) { + try (NativeGenerationScope selected = workerScope.get()) { + assertSame(historical.complex, engine.getComplex()); + assertSame(historical.data, IrisContext.require().getData()); + assertEquals(73L, IrisContext.require().getGenerationSessionId()); + assertNull(IrisContext.require().getChunkContext()); + } + assertSame(active.complex, engine.getComplex()); + assertEquals(99L, IrisContext.require().getGenerationSessionId()); + } + assertNull(IrisContext.get()); + }).get(5, TimeUnit.SECONDS); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test public void carvingResolverRestoresNestedRuntimeDefinitions() throws Exception { RuntimeFixture active = runtime(1, 1D, 1D, 1D); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java index 41d0d703e..3a12db6f7 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java @@ -128,7 +128,7 @@ public class IrisCarveModifierBiomeBlendTest { private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException { IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); doReturn(engine).when(modifier).getEngine(); - doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); + doReturn(mock(IrisComplex.class)).when(engine).getComplex(); Field field = IrisCarveModifier.class.getDeclaredField("rng"); field.setAccessible(true); field.set(modifier, rng); @@ -138,7 +138,7 @@ public class IrisCarveModifierBiomeBlendTest { private static IrisBiome resolve(IrisCarveModifier modifier, int[] coordinate, Long2ObjectOpenHashMap cache) { return modifier.resolveCaveBoundaryBiome(null, coordinate[0], coordinate[1], coordinate[2], - new IrisDimensionCarvingResolver.State(), cache, new HashMap<>()); + new IrisCarveModifier.CaveInputs(modifier.getEngine()), cache, new HashMap<>()); } private static int[] coordinate(int roll, int sign) { diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java index 560e3f27f..31cbad1d9 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java @@ -50,7 +50,7 @@ public class IrisCarveModifierBoundarySupportTest { IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); doReturn(engine).when(modifier).getEngine(); - doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); + doReturn(mock(IrisComplex.class)).when(engine).getComplex(); Field rng = IrisCarveModifier.class.getDeclaredField("rng"); rng.setAccessible(true); rng.set(modifier, new RNG(71L)); @@ -73,7 +73,7 @@ public class IrisCarveModifierBoundarySupportTest { Long2ObjectOpenHashMap caveBiomeCache = new Long2ObjectOpenHashMap<>(); Map customBiomeCache = new HashMap<>(); - IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); + IrisCarveModifier.CaveInputs resolverState = new IrisCarveModifier.CaveInputs(engine); IrisBiome floor = modifier.resolveCaveBoundaryBiome( mantleChunk, 1, 6, 2, 40, 44, resolverState, caveBiomeCache, customBiomeCache); @@ -94,11 +94,13 @@ public class IrisCarveModifierBoundarySupportTest { doReturn(biomes).when(complex).getTrueBiomeStream(); doReturn(surface).when(biomes).get(40, 44); IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); - doReturn(complex).when(modifier).getComplex(); + Engine engine = mock(Engine.class); + doReturn(engine).when(modifier).getEngine(); + doReturn(complex).when(engine).getComplex(); IrisBiome biome = modifier.resolveCaveBoundaryBiome( new MatterCavern(true, "carving/deep", (byte) 0), 40, 42, 44, - new IrisDimensionCarvingResolver.State(), new Long2ObjectOpenHashMap<>(), new HashMap<>()); + new IrisCarveModifier.CaveInputs(engine), new Long2ObjectOpenHashMap<>(), new HashMap<>()); assertSame(surface, biome); } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveInputsTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveInputsTest.java new file mode 100644 index 000000000..d07611c8e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveInputsTest.java @@ -0,0 +1,177 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.DimensionStackContext; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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.when; + +public class IrisCarveModifierCaveInputsTest { + @Test + public void capturedColumnsMatchScalarAcrossDepthStackAndCoordinateCollisions() { + try (Fixture fixture = new Fixture()) { + DimensionStackContext stack = mock(DimensionStackContext.class); + when(fixture.engine.getDimensionStackContext()).thenReturn(stack); + when(fixture.scalar.getDimensionStackContext()).thenReturn(stack); + when(stack.getLayout(anyInt(), anyInt())).thenAnswer(call -> { + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + when(layer.biome()).thenReturn((int) call.getArgument(0) % 2 == 0 ? fixture.stacked : null); + when(layout.surfaceLayer()).thenReturn(layer); + return layout; + }); + IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + for (int x : new int[]{-35, -32, -17, -16, 0, 16, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) { + for (int z : new int[]{-35, -16, 0, 3, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) { + for (int y : new int[]{0, 30, 32, 33, 34, 39, 40, 41}) { + assertSame(fixture.scalar.getCaveBiome(x, y, z, state), inputs.resolve(x, y, z)); + } + } + } + } + } + + @Test + public void repeatedHeightsReuseCapturedColumnAndIgnoreAmbientRuntimeReplacement() { + try (Fixture fixture = new Fixture()) { + IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine); + IrisComplex replacement = mock(IrisComplex.class); + when(fixture.engine.getComplex()).thenReturn(replacement); + when(fixture.engine.getDimension()).thenReturn(new IrisDimension()); + when(fixture.engine.getData()).thenReturn(mock(IrisData.class)); + for (int y = 0; y < 40; y++) { + assertSame(y <= 33 ? fixture.cave : fixture.surface, inputs.resolve(-31, y, 6)); + } + assertEquals(1, fixture.samples.get()); + } + } + + @Test + @SuppressWarnings("unchecked") + public void configuredWorldHeightRangeRetainsCapturedPackAndPrecedesFallback() { + try (Fixture fixture = new Fixture()) { + IrisBiome configured = biome("configured", 0); + ResourceLoader loader = mock(ResourceLoader.class); + when(fixture.data.getBiomeLoader()).thenReturn(loader); + when(loader.load("configured")).thenReturn(configured); + IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry(); + root.setBiome("configured"); + root.setChildRecursionDepth(0); + root.setWorldYRange(new IrisRange(-64, -48)); + fixture.dimension.setCarving(new KList<>(List.of(root))); + IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine); + when(fixture.engine.getDimension()).thenReturn(new IrisDimension()); + when(fixture.engine.getData()).thenReturn(mock(IrisData.class)); + assertSame(configured, inputs.resolve(-31, 0, 6)); + assertSame(configured, inputs.resolve(-31, 16, 6)); + assertEquals(0, fixture.samples.get()); + assertSame(fixture.cave, inputs.resolve(-31, 17, 6)); + } + } + + @Test + public void genericAndMainThreadEnginesRetainCustomBiomeDispatch() { + IrisBiome custom = biome("custom", 0); + Engine generic = mock(Engine.class); + when(generic.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom); + assertSame(custom, new IrisCarveModifier.CaveInputs(generic).resolve(1, 2, 3)); + try (Fixture fixture = new Fixture()) { + when(fixture.hooks.isMainThread()).thenReturn(true); + when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom); + assertSame(custom, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(1, 2, 3)); + assertEquals(0, fixture.samples.get()); + } + } + + @Test + public void contentStageKeepsCoordinateHistoryDispatch() { + try (Fixture fixture = new Fixture()) { + when(fixture.context.isNaturalTerrain()).thenReturn(false); + IrisBiome historical = biome("historical", 0); + when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(historical); + assertSame(historical, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(-17, 20, 16)); + assertEquals(0, fixture.samples.get()); + } + } + + private static IrisBiome biome(String key, int depth) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + biome.setCaveMinDepthBelowSurface(depth); + return biome; + } + + private static final class Fixture implements AutoCloseable { + private final IrisContext.Scope scope; + private final ChunkContext context = mock(ChunkContext.class); + private final IrisEngine engine = mock(IrisEngine.class); + private final Engine scalar = mock(Engine.class, CALLS_REAL_METHODS); + private final IrisComplex complex = mock(IrisComplex.class); + private final IrisDimension dimension = new IrisDimension(); + private final IrisData data = mock(IrisData.class); + private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + private final IrisBiome surface = biome("surface", 3); + private final IrisBiome cave = biome("cave", 7); + private final IrisBiome unkeyed = biome(null, 2); + private final IrisBiome stacked = biome("stacked", 5); + private final AtomicInteger samples = new AtomicInteger(); + + private Fixture() { + when(context.getComplex()).thenReturn(complex); + when(context.isNaturalTerrain()).thenReturn(true); + scope = IrisContext.open(engine, 1L, context); + when(engine.hasGenerationRuntimeScope()).thenReturn(true); + for (Engine target : new Engine[]{engine, scalar}) { + doReturn(complex).when(target).getComplex(); + doReturn(dimension).when(target).getDimension(); + doReturn(data).when(target).getData(); + doReturn(hooks).when(target).getPlatformHooks(); + doReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()).when(target).getWorld(); + } + when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surface, Interpolated.of(value -> 0D, value -> null))); + when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> switch (Math.floorMod(x.intValue(), 3)) { + case 0 -> null; + case 1 -> unkeyed; + default -> cave; + }, Interpolated.of(value -> 0D, value -> null))); + when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> { + samples.incrementAndGet(); + return 40D; + }, Interpolated.DOUBLE)); + } + + @Override + public void close() { + scope.close(); + } + + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierInferenceIsolationTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierInferenceIsolationTest.java index afb8e59a8..433a55dd5 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierInferenceIsolationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierInferenceIsolationTest.java @@ -64,7 +64,7 @@ public class IrisCarveModifierInferenceIsolationTest { int.class, int.class, int.class, - IrisDimensionCarvingResolver.State.class, + IrisCarveModifier.CaveInputs.class, Long2ObjectOpenHashMap.class, Map.class ); @@ -80,7 +80,7 @@ public class IrisCarveModifierInferenceIsolationTest { 0, 1, 1, - new IrisDimensionCarvingResolver.State(), + new IrisCarveModifier.CaveInputs(engine), new Long2ObjectOpenHashMap(), customBiomes ); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifierStackTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifierStackTest.java index 6b82a0788..49a9f6b8f 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifierStackTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifierStackTest.java @@ -1,14 +1,33 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.biome.IrisFloatingChildBiomes; +import art.arcane.volmlib.util.math.RNG; import org.junit.Test; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class IrisFloatingChildBiomeModifierStackTest { + @Test + public void floatingPaletteSeedDependsOnWorldAndBiomeInsteadOfColumnVisitOrder() { + IrisFloatingChildBiomes first = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard"); + IrisFloatingChildBiomes repeated = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard"); + IrisFloatingChildBiomes other = new IrisFloatingChildBiomes().setBiome("other/floating"); + RNG world = new RNG(7191L ^ 0x7EB0A73F1DCE514DL); + long expected = IrisFloatingChildBiomeModifier.paletteRng(world, first).getSeed(); + for (int column = -256; column < 256; column++) { + world.nextLong(); + assertEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, repeated).getSeed()); + } + assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, other).getSeed()); + assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(new RNG(8191L), first).getSeed()); + } + @Test public void floatingBiomeMarkersSkipStackProtectedCells() { DimensionStackLayout layout = mock(DimensionStackLayout.class); diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java index 7509acab1..3ee98ec8b 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.terrain; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.world.IrisWorld; @@ -48,6 +49,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; public class IrisDimensionCarvingResolverParityTest { @@ -59,6 +61,67 @@ public class IrisDimensionCarvingResolverParityTest { BukkitTestServer.install(); } + @Test + public void scopedSnapshotMatchesScalarRootsAndReadsRuntimeIdentityOnce() { + Fixture fixture = createFixture(); + Map expected = new HashMap<>(); + for (int y = -80; y <= 340; y += 13) { + for (int x = -17; x <= 17; x += 17) { + expected.put(x + ":" + y, IrisDimensionCarvingResolver.resolveBiome( + fixture.engine, x, y, -23, new IrisDimensionCarvingResolver.State())); + } + } + clearInvocations(fixture.engine); + IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine); + for (int y = -80; y <= 340; y += 13) { + for (int x = -17; x <= 17; x += 17) { + assertSame(expected.get(x + ":" + y), snapshot.resolveBiome(x, y, -23)); + } + } + verify(fixture.engine).getDimension(); + verify(fixture.engine).getData(); + } + + @Test + public void scopedSnapshotsBindReplacementRuntimeWithoutSharingPriorResults() { + Fixture first = createFixture(); + Fixture replacement = createMixedDepthFixture(); + IrisBiome expectedFirst = IrisDimensionCarvingResolver.resolveBiome(first.engine, -17, 80, 23, null); + IrisBiome expectedReplacement = IrisDimensionCarvingResolver.resolveBiome(replacement.engine, -17, 80, 23, null); + assertSame(expectedFirst, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23)); + doReturn(replacement.engine.getData()).when(first.engine).getData(); + doReturn(replacement.engine.getDimension()).when(first.engine).getDimension(); + doReturn(replacement.engine.getSeedManager()).when(first.engine).getSeedManager(); + assertSame(expectedReplacement, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23)); + } + + @Test + public void scopedSnapshotsKeepExpressionSamplingPerImplicitCell() { + for (boolean fracture : new boolean[]{false, true}) { + Fixture fixture = createFixture(); + IrisDimension dimension = fixture.engine.getDimension(); + IrisDimensionCarvingEntry parent = spy(dimension.getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + if (fracture) { + parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height")); + } else { + parent.getChildStyle().setExpression("live-height"); + } + KList entries = new KList<>(dimension.getCarving()); + entries.set(0, parent); + doReturn(entries).when(dimension).getCarving(); + dimension.getCarvingEntryIndex().put("root-low", parent); + CNG generator = mock(CNG.class); + doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine); + + assertSame(fixture.engine.getData().getBiomeLoader().load("child-a"), snapshot.resolveBiome(-17, 40, 23)); + assertSame(fixture.engine.getData().getBiomeLoader().load("root-low"), snapshot.resolveBiome(-17, 41, 23)); + verify(generator, times(2)).noiseFast2D(-17D, 23D); + } + } + @Test public void extremeChildRarityRemainsReachableWithOneNoiseSample() { Fixture fixture = createFixture(); @@ -84,6 +147,72 @@ public class IrisDimensionCarvingResolverParityTest { verify(generator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble()); } + @Test + public void repeatedColumnResolutionSamplesChildrenOnceAcrossHeights() { + Fixture fixture = createFixture(); + IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + CNG generator = mock(CNG.class); + doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble()); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + + for (int height = 0; height < 16; height++) { + for (int x = -16; x < 0; x++) { + for (int z = 0; z < 16; z++) { + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, x, z, state)); + } + } + } + + verify(generator, times(256)).noiseFast2D(anyDouble(), anyDouble()); + } + + @Test + public void expressionChildStylesKeepSamplingTheCurrentContext() { + for (boolean fracture : new boolean[]{false, true}) { + Fixture fixture = createFixture(); + IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + if (fracture) { + parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height")); + } else { + parent.getChildStyle().setExpression("live-height"); + } + CNG generator = mock(CNG.class); + doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + } + } + + @Test + public void cachedColumnResolutionInvalidatesWithDataAndEngineChanges() { + Fixture fixture = createFixture(); + Fixture replacement = createFixture(); + IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + CNG generator = mock(CNG.class); + doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble()); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + IrisDimensionCarvingEntry firstChild = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); + IrisDimensionCarvingEntry replacementChild = replacement.engine.getDimension().getCarvingEntryIndex().get("child-a"); + + assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + doReturn(replacement.engine.getData()).when(fixture.engine).getData(); + assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state)); + assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state)); + + verify(generator, times(3)).noiseFast2D(-17D, 23D); + } + @Test public void resolverStatefulOverloadsMatchLegacyResolverAcrossSampleGrid() { Fixture fixture = createFixture(); diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSamplerTest.java new file mode 100644 index 000000000..ae3c36986 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/ProceduralTerrainHeightSamplerTest.java @@ -0,0 +1,195 @@ +package art.arcane.iris.generation.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.DoubleBinaryOperator; + +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 ProceduralTerrainHeightSamplerTest { + @Test + public void unitStepPreservesOriginalFractionalSamples() { + AtomicInteger calls = new AtomicInteger(); + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> { + calls.incrementAndGet(); + return terrain(x, z); + }, 1); + for (double x : new double[]{-17.25D, -4D, -0.5D, 0D, 1.75D, 32D}) { + assertEquals(terrain(x, -2.25D), sampler.sample(x, -2.25D), 0D); + } + assertEquals(6, calls.get()); + } + + @Test + public void denseTerrainReusesLatticeNodesInsteadOfSamplingEveryColumn() { + AtomicInteger calls = new AtomicInteger(); + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> { + calls.incrementAndGet(); + return terrain(x, z); + }, 4); + for (int x = -32; x < 32; x++) { + for (int z = -32; z < 32; z++) { + assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4), + sampler.sample(x, z), 0D); + } + } + assertTrue("Procedural sample calls: " + calls.get(), calls.get() < 400); + assertTrue(calls.get() >= 289); + } + + @Test + public void neighboringRegionsRetainHeightsAcrossRepeatedSweeps() { + AtomicInteger calls = new AtomicInteger(); + DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D; + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> { + calls.incrementAndGet(); + return linear.applyAsDouble(x, z); + }, 4); + int uniqueNodes = 261 * 261; + for (int sweep = 0; sweep < 2; sweep++) { + int before = calls.get(); + for (int chunkX = -32; chunkX <= 32; chunkX++) { + for (int chunkZ = -32; chunkZ <= 32; chunkZ++) { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int x = chunkX * 16 + localX; + int z = chunkZ * 16 + localZ; + assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D); + } + } + } + } + int sourceCalls = calls.get() - before; + assertTrue("Procedural sample calls in sweep " + sweep + ": " + sourceCalls, + sourceCalls < (sweep == 0 ? uniqueNodes * 1.05D : uniqueNodes * 0.25D)); + } + } + + @Test + public void allStepsPreserveNodesLinearFieldsAndSignedWorldCoordinates() { + DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D; + for (int step : new int[]{2, 4, 8}) { + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(linear, step); + ProceduralTerrainHeightSampler shaped = new ProceduralTerrainHeightSampler( + ProceduralTerrainHeightSamplerTest::terrain, step); + for (double x : new double[]{Integer.MIN_VALUE, -33.5D, -8D, -0.25D, 0D, 8D, 31.75D, + Integer.MAX_VALUE}) { + for (double z : new double[]{-19.5D, -8D, 0D, 8D, 19.5D}) { + assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D); + assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, step), + shaped.sample(x, z), 0D); + } + } + for (int x = -32; x <= 32; x += step) { + assertEquals(terrain(x, -8), shaped.sample(x, -8), 0D); + } + } + } + + @Test + public void concurrentQueriesRemainDeterministicAcrossCacheReplacement() throws Exception { + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler( + ProceduralTerrainHeightSamplerTest::terrain, 4); + ExecutorService executor = Executors.newFixedThreadPool(4); + List> tasks = new ArrayList<>(); + try { + for (int worker = 0; worker < 4; worker++) { + int offset = worker * 65_537; + tasks.add(executor.submit(() -> { + for (int index = 0; index < 100_000; index++) { + int x = index * 4 - offset; + int z = -index * 8 + offset; + assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4), + sampler.sample(x, z), 0D); + } + })); + } + for (Future task : tasks) { + task.get(10, TimeUnit.SECONDS); + } + assertTrue(sampler.cachedNodeCount() > 200_000); + assertTrue(sampler.cachedNodeCount() <= 262_144); + ProceduralTerrainHeightSampler other = new ProceduralTerrainHeightSampler((x, z) -> 42D, 4); + assertEquals(42D, other.sample(0, 0), 0D); + assertEquals(terrain(0, 0), sampler.sample(0, 0), 0D); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void failedNodesCanBeRetriedAndInvalidStepsAreRejected() { + AtomicInteger calls = new AtomicInteger(); + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> { + if (calls.incrementAndGet() == 1) { + throw new IllegalStateException("height failure"); + } + return 72D; + }, 4); + assertThrows(IllegalStateException.class, () -> sampler.sample(-4D, 8D)); + assertEquals(72D, sampler.sample(-4D, 8D), 0D); + assertEquals(2, calls.get()); + for (int step : new int[]{-1, 0, 3, 16}) { + assertThrows(IllegalArgumentException.class, + () -> new ProceduralTerrainHeightSampler((x, z) -> 0D, step)); + } + } + + @Test + public void sampledBaseHeightsRetainThreeDimensionalLedgesAndAirGaps() { + ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler( + (x, z) -> 96D + (Math.floorMod((int) x, 4) == 2 ? 2D : 0D), 4); + IrisBiome biome = new IrisBiome().setTerrain3D(new IrisTerrain3D() + .setAmplitude(32).setHorizontalScale(64).setVerticalScale(64) + .setMinimumSlope(0).setFluidClearance(0).setFluidFade(1)); + Terrain3DRuntime.Options options = new Terrain3DRuntime.Options(12, 256, 0, null, true, 4096); + Terrain3DRuntime.NoiseFactory noise = (style, seed) -> + (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D); + Terrain3DRuntime sampled = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> sampler.sample(x, z), (x, z) -> biome), options, noise); + Terrain3DRuntime reference = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), options, noise); + for (int x = -9; x <= 9; x++) { + Terrain3DColumn column = sampled.column(x, -2); + assertEquals(reference.column(x, -2), column); + assertTrue(column.shaped()); + assertTrue(column.spanCount() > 1); + assertTrue(column.isSolid(104)); + assertFalse(column.isSolid(84)); + assertTrue(column.isSolid(60)); + } + } + + private static double terrain(double x, double z) { + return 96D + StrictMath.sin(x * 0.13D) * 18D + StrictMath.cos(z * 0.17D) * 12D; + } + + private static double interpolated(DoubleBinaryOperator source, double x, double z, int step) { + double lowerX = Math.floor(x / step) * step; + double lowerZ = Math.floor(z / step) * step; + double dx = (x - lowerX) / step; + double dz = (z - lowerZ) / step; + double northWest = source.applyAsDouble(lowerX, lowerZ); + double north = dx == 0D ? northWest + : northWest + (source.applyAsDouble(lowerX + step, lowerZ) - northWest) * dx; + if (dz == 0D) { + return north; + } + double southWest = source.applyAsDouble(lowerX, lowerZ + step); + double south = dx == 0D ? southWest + : southWest + (source.applyAsDouble(lowerX + step, lowerZ + step) - southWest) * dx; + return north + (south - north) * dz; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilterTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilterTest.java index 218914267..de02c58bd 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DFragmentFilterTest.java @@ -3,15 +3,146 @@ package art.arcane.iris.generation.terrain; import org.junit.Test; import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class Terrain3DFragmentFilterTest { + @Test + public void heightLeavesIrrelevantLowerFragmentsUnclassified() { + Terrain3DColumn center = new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20, 100, 612}); + BiFunction source = (x, z) -> x == 0 && z == 0 + ? center : new Terrain3DColumn(10D, 11, true, new int[]{0, 10}); + Fixture reference = new Fixture(source); + Fixture selective = new Fixture(source); + Terrain3DColumn expected = reference.filter.column(0, 0); + + assertEquals(expected.topY(), selective.filter.height(0, 0), 0D); + assertEquals(1, selective.reads.get()); + assertTrue(reference.reads.get() > selective.reads.get()); + assertEquals(expected, selective.filter.column(0, 0)); + assertEquals(2, expected.spanCount()); + } + + @Test + public void heightRejectsSmallTopFragmentBeforeKeepingGroundConnectedLedge() { + Fixture fixture = new Fixture((x, z) -> x == 0 && z == 0 + ? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 50, 70, 110, 112}) + : new Terrain3DColumn(100D, 101, true, new int[]{0, 100})); + + assertEquals(70D, fixture.filter.height(0, 0), 0D); + assertEquals(70, fixture.filter.column(0, 0).topY()); + assertEquals(2, fixture.filter.column(0, 0).spanCount()); + } + + @Test + public void heightUsesExactConnectedVolumeThresholdAndSignedCoordinateEdges() { + for (int volume : new int[]{512, 513}) { + BiFunction source = (x, z) -> + x >= -volume && x < 0 && z == -17 + ? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20}) + : new Terrain3DColumn(10D, 11, true, new int[]{0, 10}); + Fixture reference = new Fixture(source); + Fixture selective = new Fixture(source); + assertEquals(volume == 512 ? 10D : 20D, selective.filter.height(-volume, -17), 0D); + assertEquals(reference.filter.column(-volume, -17), selective.filter.column(-volume, -17)); + } + for (int x : new int[]{Integer.MIN_VALUE, Integer.MAX_VALUE}) { + Fixture fixture = new Fixture((ignoredX, ignoredZ) -> + new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20})); + assertEquals(20D, fixture.filter.height(x, 7), 0D); + assertEquals(20, fixture.filter.column(x, 7).topY()); + } + } + + @Test + public void concurrentHeightAndFullColumnQueriesMatchIndependentFullClassification() throws Exception { + Random random = new Random(883719L); + Map raw = new HashMap<>(); + List positions = new ArrayList<>(); + for (int x = -8; x <= 8; x++) { + for (int z = -8; z <= 8; z++) { + int ground = random.nextInt(5) == 0 ? 36 : 5 + random.nextInt(6); + int[] boundaries = new int[10]; + boundaries[0] = 0; + boundaries[1] = ground; + int count = 2; + for (int y = 16; y <= 40; y += 8) { + if (y > ground + 1 && random.nextBoolean()) { + boundaries[count++] = y; + boundaries[count++] = y + random.nextInt(3); + } + } + long key = pack(x, z); + raw.put(key, new Terrain3DColumn(ground + 0.25D, ground + 1, true, Arrays.copyOf(boundaries, count))); + positions.add(key); + } + } + Terrain3DColumn outside = new Terrain3DColumn(8D, 9, true, new int[]{0, 8}); + BiFunction source = (x, z) -> raw.getOrDefault(pack(x, z), outside); + Fixture reference = new Fixture(source); + Map expected = new HashMap<>(); + for (long key : positions) { + expected.put(key, reference.filter.column((int) (key >> 32), (int) key)); + } + Fixture selective = new Fixture(source); + try (ExecutorService workers = Executors.newFixedThreadPool(4)) { + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < 4; worker++) { + int shift = worker * 59; + tasks.add(workers.submit(() -> { + for (int index = 0; index < positions.size() * 2; index++) { + long key = positions.get((index + shift) % positions.size()); + int x = (int) (key >> 32); + int z = (int) key; + assertEquals(expected.get(key).topY(), selective.filter.height(x, z), 0D); + if ((index & 1) == 0) { + assertEquals(expected.get(key), selective.filter.column(x, z)); + } + } + })); + } + for (Future task : tasks) { + task.get(10, TimeUnit.SECONDS); + } + } + for (long key : positions) { + assertEquals(expected.get(key), selective.filter.column((int) (key >> 32), (int) key)); + } + } + + private static long pack(int x, int z) { + return (long) x << 32 | z & 0xffffffffL; + } + + private static final class Fixture { + private final AtomicInteger reads = new AtomicInteger(); + private final ConcurrentHashMap columns = new ConcurrentHashMap<>(); + private final Terrain3DFragmentFilter filter; + + private Fixture(BiFunction source) { + filter = new Terrain3DFragmentFilter((x, z) -> { + reads.incrementAndGet(); + return columns.computeIfAbsent(pack(x, z), ignored -> new Terrain3DFragmentFilter.DensityColumn(source.apply(x, z))); + }); + } + } + @Test public void perThreadTraversalScratchDoesNotPinTheFilterAfterItIsDropped() throws Exception { // A hotload retires the runtime that owns the filter; the worker threads that classified diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java index c1face357..f183c9884 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java @@ -21,6 +21,35 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class Terrain3DRuntimeTest { + @Test + public void heightQueriesPreserveCompleteDensityColumnsAndFractionalFallback() { + Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D) + * StrictMath.cos(x * 0.08D) * StrictMath.cos(z * 0.06D); + Terrain3DRuntime reference = runtime(profile(), noise); + Terrain3DRuntime selective = runtime(profile(), noise); + for (int pass = 0; pass < 2; pass++) { + for (int x = -12; x <= 12; x++) { + for (int z = -6; z <= 6; z++) { + Terrain3DColumn expected = reference.column(x, z); + assertEquals(expected.topY(), selective.height(x, z), 0D); + assertEquals(expected, selective.column(x, z)); + assertEquals(expected.topY(), selective.height(x, z), 0D); + } + } + selective.clear(); + } + Terrain3DRuntime unshaped = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 300.25D, (x, z) -> new IrisBiome()), + options(true), (style, seed) -> noise); + assertEquals(300.25D, unshaped.height(-17, 23), 0D); + assertEquals(255, unshaped.column(-17, 23).topY()); + assertEquals(300.25D, unshaped.height(-17, 23), 0D); + Terrain3DRuntime inactive = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> -12.75D, (x, z) -> null), + options(false), (style, seed) -> noise); + assertEquals(-12.75D, inactive.height(0, 0), 0D); + } + @Test public void alignedColumnsOnlySampleContributingAnchors() { for (int x = -8; x <= -7; x++) { @@ -260,6 +289,138 @@ public class Terrain3DRuntimeTest { assertEquals(40, water.column(0, 0).topY()); } + @Test + public void anchorRetentionBoundsFullHeightNoiseStorage() { + int shorter = Terrain3DRuntime.anchorCacheCapacity(256, 4096); + int taller = Terrain3DRuntime.anchorCacheCapacity(1024, 4096); + assertTrue(shorter > taller); + assertTrue(taller > 4096 / 4); + for (int height : new int[]{16, 256, 640, 1024, 4096, 16_384}) { + for (int columns : new int[]{16, 4096, Integer.MAX_VALUE}) { + int capacity = Terrain3DRuntime.anchorCacheCapacity(height, columns); + long fullHeightStorage = 160L + 48L * (Math.ceilDiv((long) height, 4) + 1); + assertEquals(0, capacity % 16); + assertTrue(capacity >= 16); + assertTrue(capacity <= 32_768); + assertTrue(capacity * fullHeightStorage <= 64L * 1024 * 1024); + } + } + } + + @Test + public void sharedColumnWorkingSetReusesTerrainAcrossChunksAndClearsCompletely() { + AtomicInteger sourceCalls = new AtomicInteger(); + AtomicInteger height = new AtomicInteger(96); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> { + sourceCalls.incrementAndGet(); + return height.get(); + }, (x, z) -> biome), + new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536), + (style, seed) -> (x, y, z) -> 0D); + List columns = new ArrayList<>(128 * 128); + for (int x = -64; x < 64; x++) { + for (int z = -64; z < 64; z++) { + Terrain3DColumn column = runtime.column(x, z); + assertTrue(column.shaped()); + assertEquals(96, column.topY()); + columns.add(column); + } + } + int callsBeforeRevisit = sourceCalls.get(); + int index = 0; + for (int x = -64; x < 64; x++) { + for (int z = -64; z < 64; z++) { + assertSame(columns.get(index++), runtime.column(x, z)); + } + } + assertEquals(callsBeforeRevisit, sourceCalls.get()); + assertEquals(columns.size(), runtime.cachedColumnCount()); + height.set(120); + runtime.clear(); + assertEquals(0, runtime.cachedColumnCount()); + assertEquals(120, runtime.column(-64, -64).topY()); + assertTrue(sourceCalls.get() > callsBeforeRevisit); + } + + @Test + public void sharedColumnCacheRemainsBoundedAcrossDistantWorkingSets() { + IrisBiome biome = new IrisBiome(); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), + new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536), + (style, seed) -> (x, y, z) -> 0D); + for (int x = -65_536; x < 65_536; x++) { + assertEquals(96, runtime.column(x, -1).topY()); + } + assertEquals(65_536, runtime.cachedColumnCount()); + assertEquals(96, runtime.column(-65_536, -1).topY()); + assertEquals(65_536, runtime.cachedColumnCount()); + runtime.clear(); + assertEquals(0, runtime.cachedColumnCount()); + } + + @Test + public void evictedAnchorsRecomputeIdenticalColumns() { + AtomicInteger samples = new AtomicInteger(); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), + new Terrain3DRuntime.Options(12, 256, 0, null, true, 16), + (style, seed) -> (x, y, z) -> { + samples.incrementAndGet(); + return 0.2D * StrictMath.sin(x + z); + }); + Terrain3DColumn original = runtime.column(-4, -4); + for (int index = 0; index < 512; index++) { + runtime.column(index * 4, -16); + } + int samplesBeforeRevisit = samples.get(); + + assertEquals(original, runtime.column(-4, -4)); + assertTrue(samples.get() > samplesBeforeRevisit); + } + + @Test + public void anchorNoiseSurvivesColumnCacheTurnover() { + AtomicInteger samples = new AtomicInteger(); + AtomicInteger biomes = new AtomicInteger(); + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> { + biomes.incrementAndGet(); + return biome; + }), + new Terrain3DRuntime.Options(12, 256, 0, null, true, 64), + (style, seed) -> (x, y, z) -> { + samples.incrementAndGet(); + return 0.2D * StrictMath.sin(x + z); + }); + List columns = new ArrayList<>(); + for (int x = -32; x < 32; x += 4) { + for (int z = -32; z < 32; z += 4) { + columns.add(runtime.column(x, z)); + } + } + int initialSamples = samples.get(); + int initialBiomes = biomes.get(); + int index = 0; + for (int x = -32; x < 32; x += 4) { + for (int z = -32; z < 32; z += 4) { + assertEquals(columns.get(index++), runtime.column(x, z)); + } + } + + assertTrue(initialSamples > 0); + assertEquals(initialSamples, samples.get()); + assertEquals(initialBiomes, biomes.get()); + runtime.clear(); + assertEquals(columns.getFirst(), runtime.column(-32, -32)); + assertTrue(samples.get() > initialSamples); + assertTrue(biomes.get() > initialBiomes); + } + @Test public void columnCacheReusesGeometryAndAnchorNoise() { AtomicInteger samples = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/pack/PackValidatorTerrainSamplingTest.java b/core/src/test/java/art/arcane/iris/pack/PackValidatorTerrainSamplingTest.java new file mode 100644 index 000000000..75cbcbfa5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/PackValidatorTerrainSamplingTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorTerrainSamplingTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void acceptsOmittedAndSupportedSamplingSteps() throws Exception { + assertTrue(validate("terrainSamplingStep", null).isLoadable()); + for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) { + for (String value : new String[]{"1", "2", "4", "8"}) { + PackValidationResult result = validate(field, value); + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + } + } + + @Test + public void rejectsInvalidSamplingStepsBeforeWorldCreation() throws Exception { + for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) { + for (String value : new String[]{"0", "-1", "3", "16", "4.5", "\"4\"", "null", "true"}) { + PackValidationResult result = validate(field, value); + assertFalse("Accepted " + field + "=" + value, result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' " + field + " must be one of 1, 2, 4, or 8.")); + } + } + } + + @Test + public void acceptsOnlySupportedBiomeBoundsSamplingSteps() throws Exception { + assertTrue(validate("biomeBoundsSamplingStep", null).isLoadable()); + for (String value : new String[]{"4", "8", "16", "32"}) { + PackValidationResult result = validate("biomeBoundsSamplingStep", value); + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + for (String value : new String[]{"0", "-1", "1", "2", "3", "5", "64", "8.5", "\"8\"", "null", "true"}) { + PackValidationResult result = validate("biomeBoundsSamplingStep", value); + assertFalse("Accepted biomeBoundsSamplingStep=" + value, result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Dimension 'main' biomeBoundsSamplingStep must be one of 4, 8, 16, or 32.")); + } + } + + private PackValidationResult validate(String field, String value) throws Exception { + Path pack = temporary.newFolder().toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("regions")); + Files.createDirectories(pack.resolve("biomes")); + Files.writeString(pack.resolve("dimensions/main.json"), "{\"regions\":[\"main\"]" + + (value == null ? "" : ",\"" + field + "\":" + value) + "}"); + Files.writeString(pack.resolve("regions/main.json"), "{\"landBiomes\":[\"main\"]}"); + Files.writeString(pack.resolve("biomes/main.json"), "{\"name\":\"Main\"}"); + return PackValidator.validate(pack.toFile()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/IrisBiomeBoundsSamplingSchemaTest.java b/core/src/test/java/art/arcane/iris/pack/schema/IrisBiomeBoundsSamplingSchemaTest.java new file mode 100644 index 000000000..65e51e78c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/schema/IrisBiomeBoundsSamplingSchemaTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.pack.schema; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.Gson; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class IrisBiomeBoundsSamplingSchemaTest { + @Test + public void omittedSettingKeepsFourAndExplicitValuesRoundTrip() { + Gson gson = new Gson(); + assertEquals(4, gson.fromJson("{}", IrisDimension.class).getBiomeBoundsSamplingStep()); + for (int step : new int[]{4, 8, 16, 32}) { + IrisDimension dimension = new IrisDimension().setBiomeBoundsSamplingStep(step); + assertEquals(step, gson.fromJson(gson.toJson(dimension), IrisDimension.class).getBiomeBoundsSamplingStep()); + } + for (int step : new int[]{0, 1, 2, 3, 5, 64}) { + assertThrows(IllegalArgumentException.class, + () -> new IrisDimension().setBiomeBoundsSamplingStep(step).getBiomeBoundsSamplingStep()); + } + } + + @Test + public void schemaListsOnlySupportedIntegerSteps() throws Exception { + SchemaBuilder builder = new SchemaBuilder(IrisDimension.class, null); + for (String name : new String[]{"biomeBoundsSamplingStep", "terrainSamplingStep", "caveDensitySamplingStep"}) { + int minimum = name.equals("biomeBoundsSamplingStep") ? 4 : 1; + Field field = IrisDimension.class.getDeclaredField(name); + JSONObject property = new SchemaPropertyBuilder(builder).buildProperty(field, IrisDimension.class); + assertEquals("integer", property.getString("type")); + assertEquals(minimum, property.getInt("minimum")); + assertEquals(minimum * 8, property.getInt("maximum")); + JSONArray options = property.getJSONArray("enum"); + assertEquals(4, options.length()); + for (int index = 0; index < options.length(); index++) { + assertEquals(minimum << index, options.getInt(index)); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 1aa9762c9..0caed6db5 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "415345947f2c96fc468848aefa064854a085ae5631ea0f780c77c99973bff594"; + private static final String EXPECTED_SCHEMA_DIGEST = "9c118c18c2f757f08ffb71d52d8549c53e4dae4939e0e030d36e4e9115794497"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java index 6075f0aa0..7395bb8e6 100644 --- a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java @@ -52,6 +52,59 @@ public class BoundaryColumnGeometryTest { assertEquals(50, geometry.surfaceOffsetNear(70D)); } + @Test + public void nearestSurfaceMatchesDenseColumnsAcrossRunsProtectionAndTies() { + List states = List.of(STONE, AIR, + new BoundaryColumnGeometry.Voxel("minecraft:oak_log[axis=y]", + BoundaryColumnGeometry.Phase.SOLID, "", true), + new BoundaryColumnGeometry.Voxel("minecraft:water[level=0]", + BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false)); + for (int combination = 0; combination < 4096; combination++) { + int encoded = combination; + ArrayList voxels = new ArrayList<>(18); + for (int run = 0; run < 6; run++) { + BoundaryColumnGeometry.Voxel voxel = states.get(encoded & 3); + encoded >>= 2; + for (int offset = 0; offset <= run % 3; offset++) { + voxels.add(voxel); + } + } + BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-64, voxels); + for (double expected : new double[]{-10D, 0D, 1.5D, 3D, 5.5D, 8D, 12D, 100D}) { + assertEquals(denseSurface(voxels, expected), geometry.surfaceOffsetNear(expected)); + } + } + assertThrows(IllegalArgumentException.class, () -> BoundaryColumnGeometry.empty().surfaceOffsetNear(0D)); + BoundaryColumnGeometry solid = BoundaryColumnGeometry.fromVoxels(-64, List.of(STONE)); + assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.POSITIVE_INFINITY)); + } + + private static int denseSurface(List voxels, double expected) { + int selected = -1; + double distance = Double.POSITIVE_INFINITY; + for (int index = 0; index + 1 < voxels.size(); index++) { + BoundaryColumnGeometry.Voxel current = voxels.get(index); + BoundaryColumnGeometry.Voxel above = voxels.get(index + 1); + if (current.phase() == BoundaryColumnGeometry.Phase.SOLID && !current.protectedContent() + && (above.phase() != BoundaryColumnGeometry.Phase.SOLID || above.protectedContent()) + && Math.abs(index - expected) < distance) { + selected = index; + distance = Math.abs(index - expected); + } + } + if (selected >= 0) { + return selected; + } + for (int index = voxels.size() - 1; index >= 0; index--) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(index); + if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) { + return index; + } + } + return 0; + } + @Test public void enclosedOpenQueriesIncludeUpperCavesAndExcludeOpenSky() { BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-4, diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouterRoutingTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouterRoutingTest.java index 11041f806..b68468f74 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouterRoutingTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouterRoutingTest.java @@ -369,6 +369,10 @@ public final class GenerationHistoryRuntimeRouterRoutingTest extends GenerationH GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(4, -3); assertThrows(IllegalStateException.class, route::claimGeneratedSemantics); + try (GenerationHistoryRuntimeRouter.RuntimeRoute other = router.openRoute(5, -3); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = other.openRuntimeScope()) { + assertThrows(IllegalStateException.class, route::claimGeneratedSemantics); + } try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) { assertTrue(route.claimGeneratedSemantics()); assertFalse(route.claimGeneratedSemantics()); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java index add19f54e..e7c6b9776 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java @@ -3,6 +3,19 @@ package art.arcane.iris.world.history; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.runtime.DimensionStackContext; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.structure.placement.StructurePlacementMarker; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.mantle.EngineMantle; @@ -23,13 +36,18 @@ import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -47,6 +65,211 @@ public final class GenerationSemanticCaptureTest { } } + @Test + @SuppressWarnings("unchecked") + public void scopedCaptureKeepsExplicitPrecedenceAndScalarFallback() { + Fixture fixture = new Fixture(); + IrisBiome configured = new IrisBiome(); + configured.setLoadKey("iris:configured"); + IrisBiome fallback = new IrisBiome(); + fallback.setLoadKey("iris:fallback"); + IrisData data = mock(IrisData.class); + ResourceLoader loader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(loader); + when(loader.load("iris:configured")).thenReturn(configured); + IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry(); + root.setBiome("iris:configured"); + root.setWorldYRange(new IrisRange(-64, -48)); + root.setChildRecursionDepth(0); + IrisDimension dimension = new IrisDimension(); + dimension.setCarving(new KList<>(List.of(root))); + when(fixture.engine.getDimension()).thenReturn(dimension); + when(fixture.engine.getData()).thenReturn(data); + when(fixture.engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()); + when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))) + .thenAnswer(invocation -> (int) invocation.getArgument(1) <= 16 ? configured : fallback); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + for (int y = 0; y < 64; y++) { + consumer.accept(1, y, 2, new MatterCavern(true, y == 3 ? "iris:explicit" : null, (byte) 0)); + } + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any()); + HydrologyCaveCell duplicate = mock(HydrologyCaveCell.class); + when(duplicate.floodedBiomeKey()).thenReturn("iris:duplicate"); + when(duplicate.fluidProfileKey()).thenReturn("iris:river"); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + consumer.accept(1, 4, 2, duplicate); + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(HydrologyCaveCell.class), any()); + GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); + GenerationActivation activation = mock(GenerationActivation.class); + when(activation.activationId()).thenReturn(5L); + when(stage.activation()).thenReturn(activation); + when(stage.chunkX()).thenReturn(-2); + when(stage.chunkZ()).thenReturn(3); + + ChunkGenerationSemantics expected = GenerationSemanticCapture.capture(fixture.engine, stage, (x, y, z) -> y != 8); + verify(fixture.engine, times(62)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + clearInvocations(fixture.engine); + ChunkGenerationSemantics actual = GenerationSemanticCapture.captureScoped(fixture.engine, stage, (x, y, z) -> y != 8); + + assertEquals(expected, actual); + assertEquals(Set.of("iris:configured", "iris:fallback", "iris:explicit"), actual.caveBiomeKeys()); + verify(fixture.engine, times(47)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + } + + @Test + public void scopedFallbackMatchesScalarDepthAndStackBiomesWithOneSamplePerColumn() { + IrisEngine engine = mock(IrisEngine.class); + Fixture fixture = new Fixture(engine); + IrisComplex complex = engine.getComplex(); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + when(engine.hasGenerationRuntimeScope()).thenReturn(true); + IrisDimension dimension = new IrisDimension(); + IrisData data = mock(IrisData.class); + IrisWorld world = IrisWorld.builder().minHeight(-64).maxHeight(320).build(); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getData()).thenReturn(data); + when(engine.getWorld()).thenReturn(world); + DimensionStackContext stack = mock(DimensionStackContext.class); + when(engine.getDimensionStackContext()).thenReturn(stack); + IrisBiome[] surfaces = new IrisBiome[256]; + IrisBiome[] caves = new IrisBiome[256]; + for (int index = 0; index < 256; index++) { + surfaces[index] = biome("surface:" + index, 7); + caves[index] = index % 3 == 0 ? null : biome(index % 3 == 1 ? null : "cave:" + index, 5); + } + when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surfaces[column(x, z)], Interpolated.of(value -> 0D, value -> null))); + when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> caves[column(x, z)], Interpolated.of(value -> 0D, value -> null))); + AtomicInteger heightSamples = new AtomicInteger(); + when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> { + heightSamples.incrementAndGet(); + return 40D + (column(x, z) % 8); + }, Interpolated.DOUBLE)); + when(stack.getLayout(anyInt(), anyInt())).thenAnswer(invocation -> { + int index = column((int) invocation.getArgument(0), (int) invocation.getArgument(1)); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + when(layer.biome()).thenReturn(index % 2 == 0 ? biome("stack:" + index, 3) : null); + when(layout.surfaceLayer()).thenReturn(layer); + return layout; + }); + Engine scalar = mock(Engine.class, CALLS_REAL_METHODS); + doReturn(complex).when(scalar).getComplex(); + doReturn(dimension).when(scalar).getDimension(); + doReturn(stack).when(scalar).getDimensionStackContext(); + doReturn(data).when(scalar).getData(); + doReturn(world).when(scalar).getWorld(); + doReturn(hooks).when(scalar).getPlatformHooks(); + Set expected = new HashSet<>(); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int index = (x << 4) | z; + int surfaceY = 40 + index % 8; + int y = surfaceY - index % 10; + expected.add(scalar.getCaveBiome(x - 32, y, z + 48, state).getLoadKey()); + expected.add(scalar.getCaveBiome(x - 32, y + 1, z + 48, state).getLoadKey()); + } + } + heightSamples.set(0); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int index = (x << 4) | z; + int y = 40 + index % 8 - index % 10; + consumer.accept(x, y, z, new MatterCavern(true, null, (byte) 0)); + consumer.accept(x, y + 1, z, new MatterCavern(true, null, (byte) 0)); + } + } + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any()); + + ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true); + + assertEquals(expected, captured.caveBiomeKeys()); + assertEquals(256, heightSamples.get()); + verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + } + + @Test + public void scopedFallbackRetainsCapturedRuntimeWhenAmbientInputsChange() { + IrisEngine engine = mock(IrisEngine.class); + Fixture fixture = new Fixture(engine); + IrisComplex capturedComplex = engine.getComplex(); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + when(engine.hasGenerationRuntimeScope()).thenReturn(true); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getData()).thenReturn(mock(IrisData.class)); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()); + when(capturedComplex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:surface", 0), Interpolated.of(value -> 0D, value -> null))); + when(capturedComplex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:cave", 5), Interpolated.of(value -> 0D, value -> null))); + when(capturedComplex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> 40D, Interpolated.DOUBLE)); + IrisComplex changedComplex = mock(IrisComplex.class); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + consumer.accept(0, 40, 0, new MatterCavern(true, null, (byte) 0)); + when(engine.getComplex()).thenReturn(changedComplex); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getData()).thenReturn(mock(IrisData.class)); + consumer.accept(0, 35, 0, new MatterCavern(true, null, (byte) 0)); + consumer.accept(15, 20, 15, new MatterCavern(true, null, (byte) 0)); + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any()); + + ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true); + + assertEquals(Set.of("captured:surface", "captured:cave"), captured.caveBiomeKeys()); + verify(changedComplex, never()).getCaveBiomeStream(); + verify(changedComplex, never()).getHeightStream(); + verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + } + + private static IrisBiome biome(String key, int minimumDepth) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + biome.setCaveMinDepthBelowSurface(minimumDepth); + return biome; + } + + private static int column(double x, double z) { + return (((int) x & 15) << 4) | ((int) z & 15); + } + + private static GenerationHistory.GenerationStage stage() { + GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); + GenerationActivation activation = mock(GenerationActivation.class); + when(activation.activationId()).thenReturn(5L); + when(stage.activation()).thenReturn(activation); + when(stage.chunkX()).thenReturn(-2); + when(stage.chunkZ()).thenReturn(3); + return stage; + } + + @Test + public void unscopedCapturePreservesCustomThreeArgumentCaveBiomeDispatch() { + Fixture fixture = new Fixture(); + IrisBiome custom = biome("custom:cave", 0); + doReturn(custom).when(fixture.engine).getCaveBiome(-31, 20, 50); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + consumer.accept(1, 20, 2, new MatterCavern(true, null, (byte) 0)); + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any()); + clearInvocations(fixture.engine); + + ChunkGenerationSemantics captured = GenerationSemanticCapture.capture(fixture.engine, -2, 3, 5L); + + assertEquals(Set.of("custom:cave"), captured.caveBiomeKeys()); + verify(fixture.engine).getCaveBiome(-31, 20, 50); + verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + } + @Test public void captureKeepsWorldPoiCoordinatesAndInternalHeight() { Fixture fixture = new Fixture(); @@ -103,7 +326,7 @@ public final class GenerationSemanticCaptureTest { new ChunkGenerationSemantics.BlockPosition(3, 11, 4), new ChunkGenerationSemantics.BlockPosition(5, 12, 6)), checked); verify(filled, never()).fluidProfileKey(); - verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt()); + verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); } @Test @@ -112,7 +335,7 @@ public final class GenerationSemanticCaptureTest { List heights = List.of(0, 15, 16, 255, 256, 767, 4063); Set expected = new HashSet<>(); List resolved = new ArrayList<>(); - when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenAnswer(invocation -> { int x = invocation.getArgument(0); int y = invocation.getArgument(1); int z = invocation.getArgument(2); @@ -181,11 +404,19 @@ public final class GenerationSemanticCaptureTest { } private static final class Fixture { - private final Engine engine = mock(Engine.class); + private final Engine engine; @SuppressWarnings("unchecked") private final Mantle mantle = mock(Mantle.class); private Fixture() { + this(mock(Engine.class)); + } + + private Fixture(Engine engine) { + this.engine = engine; + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + when(engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> engine.getCaveBiome( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), state)); IrisComplex complex = mock(IrisComplex.class); EngineMantle engineMantle = mock(EngineMantle.class); when(engine.getComplex()).thenReturn(complex); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java index 9d80533af..12bcf985c 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java @@ -250,25 +250,25 @@ public class SavedBiomeStoreBatchTest { private static List> enqueue(SavedBiomeStore store, Path root, BatchControl control, ExecutorService workers, List chunks) throws Exception { Object stripe = regionLock(store, 0, 0); - Field writingField = stripe.getClass().getDeclaredField("writing"); Field pendingField = stripe.getClass().getDeclaredField("pending"); - writingField.setAccessible(true); pendingField.setAccessible(true); Queue pending = (Queue) pendingField.get(stripe); ArrayList> results = new ArrayList<>(chunks.size()); - synchronized (stripe) { - writingField.setBoolean(stripe, true); - try { - for (SavedBiomeChunk chunk : chunks) { - results.add(workers.submit(() -> claim(store, root, control, chunk))); - } - Await.reached("every claim to queue behind the region write", Duration.ofSeconds(5L), - () -> pending.size() == chunks.size()); - assertEquals(chunks.size(), pending.size()); - } finally { - writingField.setBoolean(stripe, false); + int uncachedClaims = 0; + for (SavedBiomeChunk chunk : chunks) { + if (store.cached(chunk.chunkX(), chunk.chunkZ()).isEmpty()) { + uncachedClaims++; } } + int expectedQueuedClaims = uncachedClaims; + synchronized (stripe) { + for (SavedBiomeChunk chunk : chunks) { + results.add(workers.submit(() -> claim(store, root, control, chunk))); + } + Await.reached("uncached claims to queue before acquiring the region write lock", Duration.ofSeconds(5L), + () -> pending.size() == expectedQueuedClaims); + assertEquals(expectedQueuedClaims, pending.size()); + } return results; } diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java index d8ea84e14..66d8e4322 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenMethodConcurrencyCapTest.java @@ -16,6 +16,24 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class AsyncPregenMethodConcurrencyCapTest { + @Test + public void pregenWorkerTargetExpandsOnlyOptedInHighHeapJobs() { + assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, (16L << 30) - 1L, true)); + assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true)); + assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(24, 16L << 30, true)); + assertEquals(64, AsyncPregenMethod.pregenWorkerThreadTarget(64, 16L << 30, true)); + assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, 32L << 30, false)); + assertEquals(2, AsyncPregenMethod.pregenWorkerThreadTarget(1, 8L << 30, true)); + assertEquals(Integer.MAX_VALUE, AsyncPregenMethod.pregenWorkerThreadTarget(Integer.MAX_VALUE, Long.MAX_VALUE, true)); + int target = AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true); + int workers = AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, target); + assertEquals(32, workers); + assertEquals(256, AsyncPregenMethod.computePaperLikeRecommendedCap(workers)); + assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, workers)); + assertEquals(48, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(48, 16, target)); + assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(256, true)); + } + @Test public void paperLikeRecommendedCapTracksWorkerThreads() { assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(1)); @@ -57,11 +75,16 @@ public class AsyncPregenMethodConcurrencyCapTest { } @Test - public void coldStartMatchesAvailableWorkersBeforeAdaptiveGrowth() { + public void coldStartFillsChunkStatusPipelineWithinHardCap() { assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(1, 16)); - assertEquals(4, AsyncPregenMethod.computeInitialInFlightLimit(128, 1)); - assertEquals(16, AsyncPregenMethod.computeInitialInFlightLimit(128, 16)); + assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, 1)); + assertEquals(128, AsyncPregenMethod.computeInitialInFlightLimit(128, 16)); assertEquals(6, AsyncPregenMethod.computeInitialInFlightLimit(6, 16)); + assertEquals(64, AsyncPregenMethod.computeInitialInFlightLimit(64, 8)); + assertEquals(192, AsyncPregenMethod.computeInitialInFlightLimit(192, 64)); + assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, Integer.MAX_VALUE)); + assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, Integer.MIN_VALUE)); + assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(0, 16)); } @Test diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java new file mode 100644 index 000000000..4f1f36985 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenWorkerBoostTest.java @@ -0,0 +1,178 @@ +package art.arcane.iris.world.pregen; + +import art.arcane.iris.world.lifecycle.CapabilitySnapshot; +import art.arcane.iris.world.lifecycle.WorldLifecycleService; +import art.arcane.volmlib.nativelib.terrain.NativeWorkerPool; +import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +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.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class AsyncPregenWorkerBoostTest { + @Test + public void nestedTargetsEscalateAndRestoreOnlyAfterLastRelease() throws Exception { + try (Fixture fixture = new Fixture(4)) { + AsyncPregenMethod.acquireWorkerThreadBoost(16); + AsyncPregenMethod.acquireWorkerThreadBoost(32); + AsyncPregenMethod.acquireWorkerThreadBoost(16); + assertEquals(32, fixture.threads.get()); + AsyncPregenMethod.releaseWorkerThreadBoost(); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(32, fixture.threads.get()); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(4, fixture.threads.get()); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(List.of(16, 32, 4), fixture.adjustments); + AsyncPregenMethod.acquireWorkerThreadBoost(32); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(List.of(16, 32, 4, 32, 4), fixture.adjustments); + } + } + + @Test + public void explicitHigherNativeCountIsNeverReduced() throws Exception { + try (Fixture fixture = new Fixture(48)) { + AsyncPregenMethod.acquireWorkerThreadBoost(32); + AsyncPregenMethod.acquireWorkerThreadBoost(16); + AsyncPregenMethod.releaseWorkerThreadBoost(); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(48, fixture.threads.get()); + assertTrue(fixture.adjustments.isEmpty()); + assertThrows(IllegalArgumentException.class, () -> AsyncPregenMethod.acquireWorkerThreadBoost(0)); + } + } + + @Test + public void reacquisitionWaitsForPreviousRestorationAndKeepsItsOwnHold() throws Exception { + try (Fixture fixture = new Fixture(4); ExecutorService executor = Executors.newFixedThreadPool(2)) { + AsyncPregenMethod.acquireWorkerThreadBoost(16); + CountDownLatch restoring = new CountDownLatch(1); + CountDownLatch finishRestore = new CountDownLatch(1); + CountDownLatch acquiring = new CountDownLatch(1); + AtomicBoolean waitOnce = new AtomicBoolean(true); + doAnswer(invocation -> { + int count = invocation.getArgument(0); + if (count == 4 && waitOnce.compareAndSet(true, false)) { + restoring.countDown(); + assertTrue(finishRestore.await(5L, TimeUnit.SECONDS)); + } + fixture.adjustments.add(count); + fixture.threads.set(count); + return null; + }).when(fixture.pool).adjustThreadCount(anyInt()); + Future released = executor.submit(() -> fixture.withBinding(AsyncPregenMethod::releaseWorkerThreadBoost)); + try { + assertTrue(restoring.await(5L, TimeUnit.SECONDS)); + Future acquired = executor.submit(() -> fixture.withBinding(() -> { + acquiring.countDown(); + AsyncPregenMethod.acquireWorkerThreadBoost(32); + })); + assertTrue(acquiring.await(5L, TimeUnit.SECONDS)); + assertFalse(acquired.isDone()); + finishRestore.countDown(); + released.get(5L, TimeUnit.SECONDS); + acquired.get(5L, TimeUnit.SECONDS); + assertEquals(32, fixture.threads.get()); + AsyncPregenMethod.releaseWorkerThreadBoost(); + assertEquals(4, fixture.threads.get()); + } finally { + finishRestore.countDown(); + } + } + } + + @Test + public void closeRestoresWorkersWhenDrainFailsAndPreservesCancellationInterrupt() throws Exception { + try (Fixture fixture = new Fixture(4)) { + AsyncPregenMethod.acquireWorkerThreadBoost(32); + AsyncPregenMethod method = mock(AsyncPregenMethod.class, CALLS_REAL_METHODS); + PregenAdmissionGate admission = mock(PregenAdmissionGate.class); + IllegalStateException failure = new IllegalStateException("drain failed"); + when(admission.awaitDrain(anyLong(), any(TimeUnit.class), any(Runnable.class))).thenThrow(failure); + set(method, "closing", new AtomicBoolean()); + set(method, "holdsWorkerBoost", new AtomicBoolean(true)); + set(method, "admission", admission); + Thread.currentThread().interrupt(); + try { + assertSame(failure, assertThrows(IllegalStateException.class, method::close)); + assertEquals(4, fixture.threads.get()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertSame(failure, assertThrows(IllegalStateException.class, method::close)); + assertEquals(List.of(32, 4), fixture.adjustments); + } + } + + private static void set(AsyncPregenMethod method, String name, Object value) throws Exception { + Field field = AsyncPregenMethod.class.getDeclaredField(name); + field.setAccessible(true); + field.set(method, value); + } + + private static final class Fixture implements AutoCloseable { + private final WorldLifecycleService service = mock(WorldLifecycleService.class); + private final NativeWorkerPool pool = mock(NativeWorkerPool.class); + private final AtomicInteger threads; + private final List adjustments = Collections.synchronizedList(new ArrayList<>()); + private final MockedStatic lifecycle = mockStatic(WorldLifecycleService.class); + + private Fixture(int initialThreads) throws Exception { + threads = new AtomicInteger(initialThreads); + CapabilitySnapshot capabilities = mock(CapabilitySnapshot.class); + NativeWorldRuntime runtime = mock(NativeWorldRuntime.class); + when(service.capabilities()).thenReturn(capabilities); + when(capabilities.nativeRuntime()).thenReturn(runtime); + when(runtime.workers()).thenReturn(pool); + when(pool.threadCount()).thenAnswer(invocation -> threads.get()); + doAnswer(invocation -> { + int count = invocation.getArgument(0); + adjustments.add(count); + threads.set(count); + return null; + }).when(pool).adjustThreadCount(anyInt()); + lifecycle.when(WorldLifecycleService::get).thenReturn(service); + } + + private void withBinding(Runnable action) { + try (MockedStatic binding = mockStatic(WorldLifecycleService.class)) { + binding.when(WorldLifecycleService::get).thenReturn(service); + action.run(); + } + } + + @Override + public void close() { + for (int index = 0; index < 4; index++) { + AsyncPregenMethod.releaseWorkerThreadBoost(); + } + lifecycle.close(); + } + } +} diff --git a/gradle/native-minecraft-sources.gradle b/gradle/native-minecraft-sources.gradle index ea1150361..ad4e85dd6 100644 --- a/gradle/native-minecraft-sources.gradle +++ b/gradle/native-minecraft-sources.gradle @@ -9,6 +9,7 @@ String nativeLibraryCoordinate = providers.gradleProperty('volmLibCoordinate') .getOrElse(nativeLibraryProperties.getProperty('volmLibCoordinate')) String nativeSourceLoader = extensions.extraProperties.get('nativeSourceLoader') String nativeSourceImplementation = 'minecraft26_2' +String nativeSourceMinecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.2') extensions.extraProperties.set('nativeSourceImplementation', nativeSourceImplementation) dependencies.add(nativeMinecraftSources.name, "com.github.VolmitSoftware.VolmLib:native-${nativeSourceImplementation}-sources:${nativeLibraryCoordinate.tokenize(':').last()}") @@ -17,7 +18,13 @@ TaskProvider unpackNativeMinecraftSources = tasks.register('unpackNativeMi dependsOn(nativeMinecraftSources) from(provider { nativeMinecraftSources.collect { File artifact -> zipTree(artifact) } }) { include('art/**', 'modded/**', "${nativeSourceLoader}/**", - 'resources/modded/**', "resources/${nativeSourceLoader}/**") + 'resources/modded/**', "resources/${nativeSourceLoader}/**", "versions/${nativeSourceMinecraftVersion}/**") + eachFile { FileCopyDetails source -> + String versionPrefix = "versions/${nativeSourceMinecraftVersion}/" + if (source.path.startsWith(versionPrefix)) { + source.path = source.path.substring(versionPrefix.length()) + } + } } into(layout.buildDirectory.dir('generated/sources/native-minecraft')) } diff --git a/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java new file mode 100644 index 000000000..f9fd5cf36 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java @@ -0,0 +1,41 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.IDataFixer; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.world.history.GenerationEpoch; +import art.arcane.iris.world.history.GenerationEpochContractFactory; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.history.GenerationRegistryContract; +import art.arcane.iris.world.history.GenerationRegistryContractFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +final class HeadlessGenerationHistorySession { + private HeadlessGenerationHistorySession() { + } + + static GenerationHistory create(RealPackProbeSupport.HistoryRequest request, IDataFixer fixer) throws IOException { + IrisData data = request.data(); + IrisDimension dimension = request.dimension(); + PlatformRegistries registries = IrisPlatforms.get().registries(); + PlatformGenerationRegistry generationRegistry = registries.generationRegistry(); + Path pack = data.getDataFolder().toPath(); + String fingerprint = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create( + data, dimension, fingerprint, fixer, registries, generationRegistry); + String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey( + data.getDataFolder().getName(), dimension.getLoadKey(), dimension.getDimensionTypeKey()); + GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( + dimension, dimension.getLoadKey(), dimensionTypeKey); + Files.createDirectories(request.worldRoot()); + return GenerationHistory.create(request.worldRoot(), pack, fingerprint, request.seed(), + dimensionContract, registryContract); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java index b8c224931..4b5cf9bdb 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java +++ b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java @@ -18,21 +18,33 @@ import art.arcane.iris.generation.mantle.MantleComponent; import art.arcane.iris.generation.mantle.MantleObjectComponent; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.IrisBoundarySignatureSampler; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisServices; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; import java.io.File; +import java.io.IOException; import java.nio.file.Files; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitOption; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.EnumSet; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.stream.Stream; @@ -43,6 +55,15 @@ final class RealPackProbeSupport { } static Workspace openWorkspace(File packSource, String dimensionKey, String logPrefix) throws Exception { + return openWorkspace(new WorkspaceOptions(packSource, dimensionKey, logPrefix, null, + Path.of(System.getProperty("java.io.tmpdir")))); + } + + static Workspace openWorkspace(WorkspaceOptions options) throws Exception { + File packSource = options.packSource(); + String dimensionKey = options.dimensionKey(); + String logPrefix = options.logPrefix(); + RuntimeBindings bindings = options.bindings(); if (packSource == null || !packSource.isDirectory()) { throw new IllegalArgumentException("Pack folder not found: " + (packSource == null ? "null" : packSource.getAbsolutePath())); @@ -51,12 +72,12 @@ final class RealPackProbeSupport { || dimensionKey.chars().anyMatch(Character::isWhitespace)) { throw new IllegalArgumentException("Dimension key must be non-blank and contain no whitespace."); } - File workRoot = Files.createTempDirectory("iris-real-pack-probe-").toFile(); + File workRoot = Files.createTempDirectory(options.parent(), "iris-real-pack-probe-").toFile(); try { File pack = clonePack(packSource, workRoot); - configureRuntime(new File(workRoot, "platform-validation"), false); + configureRuntime(new File(workRoot, "platform-validation"), false, bindings); validatePack(pack, logPrefix); - return new Workspace(workRoot, pack, dimensionKey, logPrefix, false); + return new Workspace(workRoot, pack, dimensionKey, logPrefix, false, bindings); } catch (Throwable failure) { Throwable cleanupFailure = deleteWorkRoot(workRoot); if (cleanupFailure != null) { @@ -79,8 +100,8 @@ final class RealPackProbeSupport { } File workRoot = Files.createTempDirectory("iris-generated-chunk-probe-").toFile(); try { - configureRuntime(new File(workRoot, "platform-validation"), hydrologyGeneratedVerification); - return new Workspace(workRoot, pack, dimensionKey, logPrefix, hydrologyGeneratedVerification); + configureRuntime(new File(workRoot, "platform-validation"), hydrologyGeneratedVerification, null); + return new Workspace(workRoot, pack, dimensionKey, logPrefix, hydrologyGeneratedVerification, null); } catch (Throwable failure) { Throwable cleanupFailure = deleteWorkRoot(workRoot); if (cleanupFailure != null) { @@ -190,12 +211,20 @@ final class RealPackProbeSupport { } } + record WorkspaceOptions(File packSource, String dimensionKey, String logPrefix, + RuntimeBindings bindings, Path parent) { + WorkspaceOptions { + Objects.requireNonNull(parent, "parent"); + } + } + static final class Workspace implements AutoCloseable { private final File workRoot; private final File pack; private final String dimensionKey; private final String logPrefix; private final boolean hydrologyGeneratedVerification; + private final RuntimeBindings bindings; private int sessionSequence; private boolean sessionOpen; private boolean closed; @@ -205,13 +234,15 @@ final class RealPackProbeSupport { File pack, String dimensionKey, String logPrefix, - boolean hydrologyGeneratedVerification + boolean hydrologyGeneratedVerification, + RuntimeBindings bindings ) { this.workRoot = workRoot; this.pack = pack; this.dimensionKey = dimensionKey; this.logPrefix = logPrefix; this.hydrologyGeneratedVerification = hydrologyGeneratedVerification; + this.bindings = bindings; } EngineSession openEngine(long seed, boolean studio, String runLabel) throws Exception { @@ -228,13 +259,28 @@ final class RealPackProbeSupport { IrisData data = null; Engine engine = null; try { - configureRuntime(platformRoot, hydrologyGeneratedVerification); + configureRuntime(platformRoot, hydrologyGeneratedVerification, bindings); data = IrisData.openRuntime(pack); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); if (dimension == null) { throw new IllegalStateException("Dimension '" + dimensionKey + "' did not load from " + pack.getAbsolutePath()); } + if (bindings != null) { + bindings.prepare(data, dimension); + } + GenerationHistory history = bindings == null ? null + : Objects.requireNonNull(bindings.createHistory( + new HistoryRequest(worldRoot.toPath(), data, dimension, seed)), "Native generation history"); + if (history != null) { + data.close(); + data = IrisData.openRuntime(history.activePackRoot().toFile()); + data.bindGenerationRegistryContract(history.activeEpoch().registryContract()); + dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IOException("Immutable generation pack does not contain dimension " + dimensionKey); + } + } IrisWorld world = IrisWorld.builder() .platformIdentity("iris:probe") .name("probe") @@ -244,13 +290,30 @@ final class RealPackProbeSupport { .maxHeight(dimension.getMaxHeight()) .build(); long started = System.nanoTime(); - engine = new IrisEngine( - new EngineTarget(world, dimension, data), - studio ? IrisEngine.InitializationMode.STUDIO : IrisEngine.InitializationMode.RUNTIME - ); + EngineTarget target = new EngineTarget(world, dimension, data); + IrisEngine.InitializationMode mode = studio + ? IrisEngine.InitializationMode.STUDIO : IrisEngine.InitializationMode.RUNTIME; + if (history == null) { + engine = new IrisEngine(target, mode); + } else { + IrisEngine created = new IrisEngine(target, mode, + history.paths().activationMantleRoot(history.activeActivation().activationId()), + history.activeEpoch().kernelVersion(), null); + engine = created; + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + created, history, IrisBoundarySignatureSampler.INSTANCE); + router.preloadActiveRuntimes(); + } long readyNanos = System.nanoTime() - started; List initializationReports = settleAndDrain(); printReports(logPrefix, runLabel + " engine-init reports", initializationReports); + if (bindings != null && !initializationReports.isEmpty()) { + IllegalStateException failure = new IllegalStateException("Native engine initialization reported failures"); + for (Throwable report : initializationReports) { + failure.addSuppressed(report); + } + throw failure; + } return new EngineSession(this, data, engine, readyNanos); } catch (Throwable failure) { Throwable cleanupFailure = closeResources(engine, data); @@ -276,9 +339,20 @@ final class RealPackProbeSupport { throw new IllegalStateException("Cannot close a probe workspace with an open engine session."); } closed = true; + Throwable failure = null; + if (bindings != null) { + try { + bindings.close(); + } catch (Throwable bindingFailure) { + failure = bindingFailure; + } + } IrisPlatforms.unbind(); StubPlatform.errorSink(null); - Throwable failure = deleteWorkRoot(workRoot); + Throwable cleanupFailure = deleteWorkRoot(workRoot); + if (cleanupFailure != null) { + failure = appendFailure(failure, cleanupFailure); + } if (failure != null) { throwAsException(failure); } @@ -294,6 +368,7 @@ final class RealPackProbeSupport { private final IrisData data; private final Engine engine; private final long readyNanos; + private Path retainedWorld; private boolean closed; private EngineSession(Workspace workspace, IrisData data, Engine engine, long readyNanos) { @@ -311,6 +386,13 @@ final class RealPackProbeSupport { return readyNanos; } + void retainWorld(Path destination) { + if (closed || retainedWorld != null || Files.exists(destination)) { + throw new IllegalStateException("World retention requires an open session and unused destination"); + } + retainedWorld = destination; + } + @Override public void close() throws Exception { if (closed) { @@ -331,6 +413,10 @@ final class RealPackProbeSupport { if (failure != null) { throwAsException(failure); } + if (retainedWorld != null) { + Files.move(engine.getTarget().getWorld().worldFolder().toPath(), retainedWorld, + StandardCopyOption.ATOMIC_MOVE); + } } } @@ -408,7 +494,7 @@ final class RealPackProbeSupport { } } - private static void configureRuntime(File platformRoot, boolean hydrologyGeneratedVerification) { + private static void configureRuntime(File platformRoot, boolean hydrologyGeneratedVerification, RuntimeBindings bindings) { REPORTED.clear(); StubPlatform.bindGenerationStateHandlers(); StubPlatform.verbose(false); @@ -417,9 +503,21 @@ final class RealPackProbeSupport { IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) InertEffects::new); - IrisServices.register(EnginePlatformHooks.class, new InertPlatformHooks(hydrologyGeneratedVerification)); + IrisServices.register(EnginePlatformHooks.class, bindings == null + ? new InertPlatformHooks(hydrologyGeneratedVerification) : bindings); IrisPlatforms.unbind(); - IrisPlatforms.bind(new StubPlatform(platformRoot)); + IrisPlatforms.bind(bindings == null ? new StubPlatform(platformRoot) : bindings.create(platformRoot)); + } + + interface RuntimeBindings extends AutoCloseable, EnginePlatformHooks { + IrisPlatform create(File platformRoot); + + void prepare(IrisData data, IrisDimension dimension); + + GenerationHistory createHistory(HistoryRequest request) throws IOException; + } + + record HistoryRequest(Path worldRoot, IrisData data, IrisDimension dimension, long seed) { } private static void validatePack(File pack, String logPrefix) { @@ -438,18 +536,29 @@ final class RealPackProbeSupport { private static File clonePack(File source, File workRoot) throws Exception { File destination = new File(workRoot, "pack"); - Process clone = new ProcessBuilder("cp", "-Rc", source.getAbsolutePath(), destination.getAbsolutePath()) - .inheritIO() - .start(); - if (clone.waitFor() != 0) { - deleteRecursively(destination.toPath()); - Process copy = new ProcessBuilder("cp", "-R", source.getAbsolutePath(), destination.getAbsolutePath()) - .inheritIO() - .start(); - if (copy.waitFor() != 0) { - throw new IllegalStateException("Failed to copy pack to " + destination.getAbsolutePath()); + if (System.getProperty("os.name").startsWith("Mac")) { + Process clone = new ProcessBuilder("/bin/cp", "-Rc", source.getAbsolutePath(), destination.getAbsolutePath()) + .inheritIO().start(); + if (clone.waitFor() == 0) { + return destination; } + deleteRecursively(destination.toPath()); } + Path sourcePath = source.toPath(); + Files.walkFileTree(sourcePath, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { + Files.createDirectory(destination.toPath().resolve(sourcePath.relativize(directory))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.copy(file, destination.toPath().resolve(sourcePath.relativize(file)), StandardCopyOption.COPY_ATTRIBUTES); + return FileVisitResult.CONTINUE; + } + }); return destination; }