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 index 90bb44db9..59e4d1374 100644 --- 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 @@ -37,6 +37,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, private final PlatformBiomeWriter biomeWriter; private final PlatformGenerationRegistry generationRegistry; private final NativeBlockResolver blocks = new NativeBlockResolver(this); + private final NativeBlockState air = blocks.getAir(); private final ConcurrentHashMap states = new ConcurrentHashMap<>(); HeadlessNativePlatform(File root, Supplier registries) { @@ -101,7 +102,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, @Override public NativeBlockState air() { - return blocks.getAir(); + return air; } @Override 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 index dcc06dd79..2608e4f3f 100644 --- 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 @@ -22,16 +22,26 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStateMerger; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.file.Path; 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 final Path stagingRoot; private NativeRegionTerrainWriter writer; private HeadlessNativeRegistries nativeRegistries; private HolderLookup.Provider registries; + HeadlessNativeRuntime() { + stagingRoot = null; + } + + HeadlessNativeRuntime(Path stagingRoot) throws IOException { + this.stagingRoot = stagingRoot.toRealPath(); + } + @Override public IrisPlatform create(File root) { HeadlessNativeBootstrap.initialize(); @@ -68,6 +78,12 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding @Override public GenerationHistory createHistory(RealPackProbeSupport.HistoryRequest request) throws IOException { + if (stagingRoot != null) { + if (!request.worldRoot().getParent().toRealPath().startsWith(stagingRoot)) { + throw new IOException("Unpublished native generation must remain inside its private staging directory"); + } + return HeadlessGenerationHistorySession.createUnpublished(request, new DataFixerV263()); + } return HeadlessGenerationHistorySession.create(request, new DataFixerV263()); } 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 index a6d07d1f9..4ed8fc8f8 100644 --- 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 @@ -45,14 +45,15 @@ final class HeadlessRegionTerrain { static final class Session { private final Map> chunks = new HashMap<>(); - private final RegionGenerationWindow.Policy workerPolicy; + private final RegionGenerationWindow workers; private HeadlessTerrainContext context; - Session(RegionGenerationWindow.Policy workerPolicy) { - this.workerPolicy = Objects.requireNonNull(workerPolicy, "workerPolicy"); + Session(RegionGenerationWindow workers) { + this.workers = Objects.requireNonNull(workers, "workers"); } Result generate(Request request, Consumer progress) throws Exception { + workers.requireOpen(); if (context != null && context != request.context()) { throw new IllegalArgumentException("Rolling regions require one native terrain context"); } @@ -85,7 +86,7 @@ final class HeadlessRegionTerrain { } long started = System.nanoTime(); AtomicInteger completed = new AtomicInteger(); - RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(eligible.size(), request.parallelism(), + workers.process(new RegionGenerationWindow.Request<>(eligible.size(), request.parallelism(), index -> apply(request.context(), eligible.get(index), status, chunks), (index, result) -> { if (status == ChunkStatus.STRUCTURE_STARTS) { @@ -96,7 +97,7 @@ final class HeadlessRegionTerrain { 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()); 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 index 0885526d8..488ddb781 100644 --- 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 @@ -82,7 +82,7 @@ public final class OfflineRegionGenerator { 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(); + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(staged); long started = System.nanoTime(); long writes = 0; long terrainWrites = 0; @@ -102,12 +102,14 @@ public final class OfflineRegionGenerator { : 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); + configuration.noiseSettings(), spigot)); + RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(), + RegionGenerationWindow.Policy.STANDALONE)) { + HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers); 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)); + context, regionDirectory, workers)); 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; @@ -142,9 +144,7 @@ public final class OfflineRegionGenerator { 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()); + publish(staged, configuration.output().resolve("native-terrain-checkpoint")); 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); @@ -207,6 +207,12 @@ public final class OfflineRegionGenerator { }); } + static void publish(Path staged, Path destination) throws IOException { + forceTree(staged); + Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE); + force(destination.getParent()); + } + private static void force(Path path) throws IOException { try (FileChannel channel = FileChannel.open(path, Files.isDirectory(path) ? StandardOpenOption.READ : StandardOpenOption.WRITE)) { 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 index 737418373..c32159d6a 100644 --- 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 @@ -38,6 +38,7 @@ final class OfflineRegionWriter { } Result write(HeadlessRegionTerrain.Result generated) throws Exception { + options.workers().requireOpen(); try (OpenRegions regions = new OpenRegions(options)) { List pending = new ArrayList<>(generated.updated().size()); for (ProtoChunk chunk : generated.updated()) { @@ -62,8 +63,8 @@ final class OfflineRegionWriter { return new Result(0, 0); } int[] counts = new int[2]; - RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(pending.size(), - Math.min(options.parallelism(), MAXIMUM_PREPARATION_WORKERS), + options.workers().process(new RegionGenerationWindow.Request<>(pending.size(), + Math.min(options.workers().parallelism(), MAXIMUM_PREPARATION_WORKERS), index -> prepare(pending.get(index)), (index, prepared) -> { ChunkWrite chunk = prepared.chunk(); @@ -73,7 +74,7 @@ final class OfflineRegionWriter { if (chunk.chunk().getPersistedStatus().isOrAfter(ChunkStatus.TERRAIN)) { counts[1]++; } - }), options.policy()); + })); return new Result(counts[0], counts[1]); } } @@ -100,14 +101,11 @@ final class OfflineRegionWriter { } } - record Options(HeadlessTerrainContext context, Path directory, int parallelism, RegionGenerationWindow.Policy policy) { + record Options(HeadlessTerrainContext context, Path directory, RegionGenerationWindow workers) { 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"); - } + Objects.requireNonNull(workers, "workers"); } } 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 index 80a0568ae..00805e0a5 100644 --- 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 @@ -11,20 +11,55 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -final class RegionGenerationWindow { +final class RegionGenerationWindow implements AutoCloseable { private static final int UNRESPONSIVE_WORKER_EXIT = 70; - private RegionGenerationWindow() { + private final int parallelism; + private final Policy policy; + private final ExecutorService workers; + private boolean closed; + private boolean processing; + + RegionGenerationWindow(int parallelism, Policy policy) { + if (parallelism < 1 || parallelism > 32) { + throw new IllegalArgumentException("Generation workers must be 1..32"); + } + this.parallelism = parallelism; + this.policy = Objects.requireNonNull(policy, "policy"); + workers = Executors.newFixedThreadPool(parallelism); } - static void process(Request request) throws Exception { - process(request, Policy.EMBEDDED); + @Override + public synchronized void close() { + if (processing) { + throw new IllegalStateException("Cannot close generation workers from an active batch"); + } + if (closed) { + return; + } + closed = true; + workers.shutdown(); + drain(workers, policy, null); } - static void process(Request request, Policy policy) throws Exception { - ExecutorService workers = Executors.newFixedThreadPool(request.parallelism()); + int parallelism() { + return parallelism; + } + + synchronized void requireOpen() { + if (closed) { + throw new IllegalStateException("Generation workers are closed"); + } + } + + synchronized void process(Request request) throws Exception { + requireOpen(); + if (processing || request.parallelism() > parallelism) { + throw new IllegalArgumentException("Generation batch requires idle workers within the session limit"); + } ExecutorCompletionService> completions = new ExecutorCompletionService<>(workers); Set>> active = new HashSet<>(request.parallelism()); + processing = true; try { int submitted = 0; for (; submitted < Math.min(request.count(), request.parallelism()); submitted++) { @@ -43,15 +78,16 @@ final class RegionGenerationWindow { request.sink().accept(result.index(), result.value()); } } catch (Exception | Error failure) { + closed = true; for (Future> future : active) { future.cancel(true); } workers.shutdownNow(); drain(workers, policy, failure); throw failure; + } finally { + processing = false; } - workers.shutdown(); - drain(workers, policy, null); } private static void drain(ExecutorService workers, Policy policy, Throwable failure) { 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 index a7af94987..575499792 100644 --- 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 @@ -55,15 +55,17 @@ final class DetachedNativeTerrain { request.level(), request.level().palettedContainerFactory(), null))); } long started = System.nanoTime(); - for (ChunkStatus status : ChunkStatus.getStatusList()) { - if (status == ChunkStatus.EMPTY) { - continue; + try (RegionGenerationWindow workers = new RegionGenerationWindow(request.parallelism(), RegionGenerationWindow.Policy.EMBEDDED)) { + 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()), workers, progress); } - 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); @@ -72,7 +74,7 @@ final class DetachedNativeTerrain { return new Result(request.width() * request.width(), plan.size(), seconds); } - private static void executeStage(Stage stage, Consumer progress) throws Exception { + private static void executeStage(Stage stage, RegionGenerationWindow workers, Consumer progress) throws Exception { long started = System.nanoTime(); List eligible = new ArrayList<>(); for (PlannedChunk planned : stage.plan()) { @@ -82,7 +84,7 @@ final class DetachedNativeTerrain { } AtomicInteger completed = new AtomicInteger(); AtomicInteger targetCompleted = new AtomicInteger(); - RegionGenerationWindow.process(new RegionGenerationWindow.Request<>(eligible.size(), stage.parallelism(), + workers.process(new RegionGenerationWindow.Request<>(eligible.size(), stage.parallelism(), index -> { PlannedChunk planned = eligible.get(index); apply(stage, planned); 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 index 6c998b554..c0e5cf100 100644 --- 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 @@ -5,14 +5,22 @@ 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.Blocks; import net.minecraft.world.level.block.state.BlockState; import org.junit.BeforeClass; import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.File; 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.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; public final class HeadlessNativeStateTest { @BeforeClass @@ -20,6 +28,21 @@ public final class HeadlessNativeStateTest { HeadlessNativeBootstrap.initialize(); } + @Test + public void platformAirResolvesOnceAndRetainsCanonicalNativeState() { + BlockState air = Blocks.AIR.defaultBlockState(); + try (MockedStatic states = mockStatic(ModdedBlockState.class, CALLS_REAL_METHODS)) { + HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"), + HeadlessNativeTestRegistries::get); + NativeBlockState wrapped = platform.air(); + for (int iteration = 0; iteration < 1024; iteration++) { + assertSame(wrapped, platform.air()); + assertSame(air, ((ModdedBlockState) platform.air()).handle()); + } + states.verify(() -> ModdedBlockState.of(air, null), times(1)); + } + } + @Test public void nativeMaterialsDistinguishDecorationsFluidsGlassAndBlockEntities() { assertFalse(NativeBlockResolver.strictParse("minecraft:short_grass").isSolid()); 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 index 5ef4f7939..421b91373 100644 --- 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 @@ -56,8 +56,9 @@ public final class HeadlessRegionTerrainTest { 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); + NoiseGeneratorSettings.OVERWORLD, spigot)); + RegionGenerationWindow workers = new RegionGenerationWindow(4, RegionGenerationWindow.Policy.EMBEDDED)) { + HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers); HeadlessRegionTerrain.Result result = rolling.generate( new HeadlessRegionTerrain.Request(context, -3, 2, 1, 4), System.out::println); OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration( @@ -100,14 +101,14 @@ public final class HeadlessRegionTerrainTest { 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)); + context, regionDirectory, workers)); 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)); + context, regionDirectory, workers)); assertEquals(0, reopened.write(result).chunks()); target.setPersistedStatus(ChunkStatus.STRUCTURE_STARTS); try { @@ -138,7 +139,7 @@ public final class HeadlessRegionTerrainTest { 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)); + context, oversizedDirectory, workers)); NamespacedKey payloadKey = new NamespacedKey("iris", "serialization_test_payload"); byte[] payload = new byte[2 * 1024 * 1024]; new Random(69420L).nextBytes(payload); @@ -151,14 +152,19 @@ public final class HeadlessRegionTerrainTest { regenerated.persistentDataContainer.remove(payloadKey); Path failedDirectory = Files.createDirectory(root.resolve("failed")); OfflineRegionWriter failedWriter = new OfflineRegionWriter(new OfflineRegionWriter.Options( - context, failedDirectory, 4, RegionGenerationWindow.Policy.EMBEDDED)); + context, failedDirectory, workers)); regenerated.setPersistedStatus(ChunkStatus.FULL); try { assertThrows(Exception.class, () -> failedWriter.write(singleton)); } finally { regenerated.setPersistedStatus(ChunkStatus.TERRAIN); } - assertEquals(1, failedWriter.write(singleton).chunks()); + assertThrows(IllegalStateException.class, () -> failedWriter.write(singleton)); + try (RegionGenerationWindow replacement = new RegionGenerationWindow(4, RegionGenerationWindow.Policy.EMBEDDED)) { + OfflineRegionWriter retried = new OfflineRegionWriter(new OfflineRegionWriter.Options( + context, failedDirectory, replacement)); + assertEquals(1, retried.write(singleton).chunks()); + } assertEquals("minecraft:terrain", read(failedDirectory, regenerated.getPos()).getStringOr("Status", "")); } } diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineRegionPublicationTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineRegionPublicationTest.java new file mode 100644 index 000000000..f6f4bb09f --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/OfflineRegionPublicationTest.java @@ -0,0 +1,83 @@ +package art.arcane.iris.probe; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +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; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public final class OfflineRegionPublicationTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void unpublishedRuntimeRejectsWorldRootsOutsideItsStagingDirectory() throws Exception { + Path staging = temporary.newFolder("staging").toPath(); + Path outside = temporary.getRoot().toPath().resolve("outside"); + try (HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(staging)) { + assertThrows(IOException.class, () -> runtime.createHistory( + new RealPackProbeSupport.HistoryRequest(outside, null, null, 1L))); + } + assertFalse(Files.exists(outside)); + } + + @Test + public void publishesOnlyAfterEveryFileAndDirectoryIsForced() throws Exception { + for (boolean fail : List.of(true, false)) { + Path output = temporary.newFolder().toPath(); + Path staged = Files.createDirectory(output.resolve("incomplete")); + Path nested = Files.createDirectory(staged.resolve("world")); + Path data = Files.writeString(nested.resolve("data"), "checkpoint"); + Path target = output.resolve("native-terrain-checkpoint"); + List forced = new ArrayList<>(); + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + if (invocation.getMethod().getParameterCount() != 2) { + return invocation.callRealMethod(); + } + FileChannel actual = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + FileChannel channel = mock(FileChannel.class, delegatesTo(actual)); + doAnswer(force -> { + if (path.equals(output)) { + assertTrue(Files.isDirectory(target)); + assertFalse(Files.exists(staged)); + } else { + assertFalse(Files.exists(target)); + } + if (fail && path.equals(data)) { + throw new IOException("Expected publication sync failure"); + } + actual.force(true); + forced.add(path); + return null; + }).when(channel).force(true); + return channel; + })) { + if (fail) { + assertThrows(IOException.class, () -> OfflineRegionGenerator.publish(staged, target)); + assertTrue(Files.isRegularFile(data)); + assertFalse(Files.exists(target)); + } else { + OfflineRegionGenerator.publish(staged, target); + assertEquals(List.of(data, nested, staged, output), forced); + assertEquals("checkpoint", Files.readString(target.resolve("world/data"))); + } + } + } + } +} 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 index fa040420d..2c1cf242f 100644 --- 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 @@ -9,6 +9,8 @@ import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -20,27 +22,96 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public final class RegionGenerationWindowTest { + @Test + public void retainsWorkersAndThreadLocalScratchAcrossBoundedBatches() throws Exception { + Set firstWorkers = ConcurrentHashMap.newKeySet(); + AtomicInteger allocations = new AtomicInteger(); + ThreadLocal scratch = ThreadLocal.withInitial(allocations::incrementAndGet); + CountDownLatch bothStarted = new CountDownLatch(2); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + RegionGenerationWindow workers = new RegionGenerationWindow(2, RegionGenerationWindow.Policy.EMBEDDED); + try (workers) { + workers.process(new RegionGenerationWindow.Request<>(2, 2, index -> { + firstWorkers.add(Thread.currentThread()); + scratch.get(); + bothStarted.countDown(); + assertTrue(bothStarted.await(5, TimeUnit.SECONDS)); + return index; + }, (index, value) -> { })); + assertEquals(2, allocations.get()); + workers.process(new RegionGenerationWindow.Request<>(16, 1, index -> { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + assertTrue(firstWorkers.contains(Thread.currentThread())); + return scratch.get(); + } finally { + active.decrementAndGet(); + } + }, (index, value) -> assertTrue(value > 0))); + assertEquals(2, allocations.get()); + assertEquals(1, maximum.get()); + } + for (Thread worker : firstWorkers) { + worker.join(1000L); + assertFalse(worker.isAlive()); + } + workers.close(); + assertThrows(IllegalStateException.class, () -> workers.process( + new RegionGenerationWindow.Request<>(1, 1, index -> index, (index, value) -> { }))); + } + + @Test(timeout = 5000) + public void sinkFailureDrainsWorkersAndPermanentlyClosesSession() { + CountDownLatch entered = new CountDownLatch(1); + AtomicInteger exited = new AtomicInteger(); + try (RegionGenerationWindow workers = new RegionGenerationWindow(2, RegionGenerationWindow.Policy.EMBEDDED)) { + assertThrows(IllegalStateException.class, () -> workers.process( + new RegionGenerationWindow.Request<>(2, 2, index -> { + if (index == 0) { + assertTrue(entered.await(2, TimeUnit.SECONDS)); + return index; + } + entered.countDown(); + try { + new CountDownLatch(1).await(); + } finally { + exited.incrementAndGet(); + } + return index; + }, (index, value) -> { + throw new IllegalStateException("Expected sink failure"); + }))); + assertEquals(1, exited.get()); + assertThrows(IllegalStateException.class, workers::requireOpen); + assertThrows(IllegalStateException.class, () -> workers.process( + new RegionGenerationWindow.Request<>(1, 1, index -> index, (index, value) -> { }))); + } + } + @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"); + try (RegionGenerationWindow workers = new RegionGenerationWindow(2, RegionGenerationWindow.Policy.EMBEDDED)) { + workers.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(); } - if (index == 2) { - replacementStarted.countDown(); - } - return index; - } finally { - active.decrementAndGet(); - } - }, (index, value) -> completed.add(value))); + }, (index, value) -> completed.add(value))); + } assertEquals(16, completed.size()); assertTrue(completed.indexOf(1) < completed.indexOf(0)); assertTrue(maximum.get() <= 2); @@ -53,18 +124,20 @@ public final class RegionGenerationWindowTest { 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)); + try (RegionGenerationWindow workers = new RegionGenerationWindow(32, RegionGenerationWindow.Policy.EMBEDDED)) { + workers.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(); } - return index; - } finally { - active.decrementAndGet(); - } - }, (index, value) -> completed.incrementAndGet())); + }, (index, value) -> completed.incrementAndGet())); + } assertEquals(32, maximum.get()); assertEquals(97, completed.get()); assertEquals(0, active.get()); @@ -76,21 +149,23 @@ public final class RegionGenerationWindowTest { 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) -> { }))); + try (RegionGenerationWindow workers = new RegionGenerationWindow(2, RegionGenerationWindow.Policy.EMBEDDED)) { + assertThrows(Exception.class, () -> workers.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()); } @@ -98,16 +173,18 @@ public final class RegionGenerationWindowTest { 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) -> { }))); + try (RegionGenerationWindow workers = new RegionGenerationWindow(2, RegionGenerationWindow.Policy.EMBEDDED)) { + assertThrows(Exception.class, () -> workers.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); } @@ -116,15 +193,17 @@ public final class RegionGenerationWindowTest { 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)); + try (RegionGenerationWindow workers = new RegionGenerationWindow(1, policy)) { + assertThrows(TimeoutException.class, () -> workers.process( + new RegionGenerationWindow.Request<>(1, 1, index -> { + try { + new CountDownLatch(1).await(); + } finally { + completed.incrementAndGet(); + } + return index; + }, (index, value) -> { }))); + } assertEquals(1, completed.get()); } @@ -160,8 +239,8 @@ public final class RegionGenerationWindowTest { 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 -> { + try (RegionGenerationWindow workers = new RegionGenerationWindow(2, policy)) { + workers.process(new RegionGenerationWindow.Request<>(2, 2, index -> { if (index == 0) { if (!entered.await(1, TimeUnit.SECONDS)) { throw new IllegalStateException("Worker did not start"); @@ -177,7 +256,7 @@ public final class RegionGenerationWindowTest { } return index; } - }, (index, value) -> { }), policy); + }, (index, value) -> { })); } 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 index 0e78faada..6e20bbfec 100644 --- 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 @@ -125,8 +125,8 @@ public final class RegionNativeFixture extends JavaPlugin { 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(), + try (RegionGenerationWindow workers = new RegionGenerationWindow(request.parallelism(), RegionGenerationWindow.Policy.EMBEDDED)) { + workers.process(new RegionGenerationWindow.Request<>(chunks, request.parallelism(), index -> { int x = request.chunkX() + index % request.width(); int z = request.chunkZ() + index / request.width(); 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 661bec054..f463252b8 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 @@ -4,6 +4,8 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.world.storage.matter.PreObjectMatterCell; import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.hunk.bits.DataContainer; +import art.arcane.volmlib.util.hunk.storage.PaletteOrHunk; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.matter.Matter; @@ -11,6 +13,7 @@ import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; import java.util.Objects; +import java.util.Arrays; import java.util.ArrayList; import java.util.List; @@ -67,6 +70,82 @@ public final class TerrainMatterView { } } + public static MatterCavern[] getComposedFace(MantleChunk chunk, Face face, int height) { + Objects.requireNonNull(face, "Terrain face"); + if (height < 0) { + throw new IllegalArgumentException("Terrain face height must be nonnegative"); + } + MatterCavern[] result = new MatterCavern[Math.multiplyExact(height, 16)]; + if (chunk == null || height == 0) { + return result; + } + PreObjectMatterCell[] journal = new PreObjectMatterCell[256]; + HydrologyCaveCell[] hydrology = new HydrologyCaveCell[256]; + MatterCavern[] caverns = new MatterCavern[256]; + synchronized (chunk) { + for (int baseY = 0; baseY < height; baseY += 16) { + int section = baseY >> 4; + if (!chunk.exists(section)) { + continue; + } + Matter matter = chunk.get(section); + if (matter == null) { + continue; + } + copyFace(matter.getSlice(PreObjectMatterCell.class), face, journal); + copyFace(matter.getSlice(HydrologyCaveCell.class), face, hydrology); + copyFace(matter.getSlice(MatterCavern.class), face, caverns); + int count = Math.min(16, height - baseY) * 16; + for (int index = 0; index < count; index++) { + PreObjectMatterCell cell = journal[index]; + HydrologyCaveCell effectiveHydrology = cell != null && cell.hydrologyCaptured() + ? cell.hydrology() : hydrology[index]; + result[baseY * 16 + index] = effectiveHydrology != null + ? effectiveHydrology.asCavern() + : cell != null && cell.cavernCaptured() ? cell.cavern() : caverns[index]; + } + } + } + return result; + } + + @SuppressWarnings("unchecked") + private static void copyFace(MatterSlice slice, Face face, T[] destination) { + if (slice == null) { + Arrays.fill(destination, null); + return; + } + if (slice instanceof PaletteOrHunk storage && storage.isPalette() + && slice.getWidth() == 16 && slice.getHeight() == 16 && slice.getDepth() == 16) { + DataContainer palette = (DataContainer) storage.palette(); + palette.copyTo(face.positions, destination); + return; + } + for (int index = 0; index < destination.length; index++) { + int position = face.positions[index]; + destination[index] = slice.get(position & 15, (position >> 4) & 15, position >> 8); + } + } + + public enum Face { + WEST(true, 0), + EAST(true, 15), + NORTH(false, 0), + SOUTH(false, 15); + + private final int[] positions = new int[256]; + + Face(boolean fixedX, int coordinate) { + for (int y = 0; y < 16; y++) { + for (int offset = 0; offset < 16; offset++) { + int x = fixedX ? coordinate : offset; + int z = fixedX ? offset : coordinate; + positions[y * 16 + offset] = (z << 8) | (y << 4) | x; + } + } + } + } + public static void iterate(MantleChunk chunk, Class type, Consumer4 consumer) { Objects.requireNonNull(chunk, "Terrain mantle chunk"); 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 99d90c30f..fe845b704 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 @@ -532,44 +532,46 @@ public class IrisCarveModifier extends EngineAssignedModifier return; } + int height = maxY + 1; + MatterCavern[] ownWest = west == null ? null : TerrainMatterView.getComposedFace(mc, TerrainMatterView.Face.WEST, height); + MatterCavern[] ownEast = east == null ? null : TerrainMatterView.getComposedFace(mc, TerrainMatterView.Face.EAST, height); + MatterCavern[] ownNorth = north == null ? null : TerrainMatterView.getComposedFace(mc, TerrainMatterView.Face.NORTH, height); + MatterCavern[] ownSouth = south == null ? null : TerrainMatterView.getComposedFace(mc, TerrainMatterView.Face.SOUTH, height); + MatterCavern[] fromWest = west == null ? null : TerrainMatterView.getComposedFace(west, TerrainMatterView.Face.EAST, height); + MatterCavern[] fromEast = east == null ? null : TerrainMatterView.getComposedFace(east, TerrainMatterView.Face.WEST, height); + MatterCavern[] fromNorth = north == null ? null : TerrainMatterView.getComposedFace(north, TerrainMatterView.Face.SOUTH, height); + MatterCavern[] fromSouth = south == null ? null : TerrainMatterView.getComposedFace(south, TerrainMatterView.Face.NORTH, height); for (int yy = 1; yy <= maxY; yy++) { for (int offset = 0; offset < 16; offset++) { + int index = yy * 16 + offset; if (west != null) { - tryAddBoundaryWall(mc, west, walls, boundaryMasks, 0, yy, offset, 15, offset); + tryAddBoundaryWall(ownWest[index], fromWest[index], walls, boundaryMasks, 0, yy, offset); } if (east != null) { - tryAddBoundaryWall(mc, east, walls, boundaryMasks, 15, yy, offset, 0, offset); + tryAddBoundaryWall(ownEast[index], fromEast[index], walls, boundaryMasks, 15, yy, offset); } if (north != null) { - tryAddBoundaryWall(mc, north, walls, boundaryMasks, offset, yy, 0, offset, 15); + tryAddBoundaryWall(ownNorth[index], fromNorth[index], walls, boundaryMasks, offset, yy, 0); } if (south != null) { - tryAddBoundaryWall(mc, south, walls, boundaryMasks, offset, yy, 15, offset, 0); + tryAddBoundaryWall(ownSouth[index], fromSouth[index], walls, boundaryMasks, offset, yy, 15); } } } } private void tryAddBoundaryWall( - MantleChunk mc, - MantleChunk neighborChunk, + MatterCavern current, + MatterCavern neighbor, CarveWallBuffer walls, CarveColumnMask[] boundaryMasks, int localX, int yy, - int localZ, - int neighborX, - int neighborZ + int localZ ) { - if (composedCavernAt(mc, localX, yy, localZ) != null) { + if (current != null || neighbor == null) { return; } - - MatterCavern neighbor = composedCavernAt(neighborChunk, neighborX, yy, neighborZ); - if (neighbor == null) { - return; - } - walls.put(localX, yy, localZ, neighbor); int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); boundaryMasks[columnIndex].add(yy); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisPerfectionModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisPerfectionModifier.java index b5e260607..73ff947e4 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisPerfectionModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisPerfectionModifier.java @@ -30,11 +30,9 @@ import art.arcane.iris.generation.concurrent.BurstExecutor; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; +import it.unimi.dsi.fastutil.ints.IntArrayList; public class IrisPerfectionModifier extends EngineAssignedModifier { private static final BoundBlockState AIR = BoundBlockState.of("AIR"); @@ -86,84 +84,67 @@ public class IrisPerfectionModifier extends EngineAssignedModifier { - List surfaces = new ArrayList<>(); - List ceilings = new ArrayList<>(); - for (int j = 0; j < 16; j++) { - surfaces.clear(); - ceilings.clear(); - int top = getHeight(output, finalI, j); - boolean inside = true; - surfaces.add(top); - - for (int k = top; k >= 0; k--) { - NativeBlockState b = output.get(finalI, k, j); - if (IrisSpeleothems.isSpike(b)) { - b = normalizeSpike(b, output, finalI, j, k, AIR.get(), WATER.get()); - } - boolean now = b != null && !(B.isAir(b) || B.isFluid(b)); - - if (now != inside) { - inside = now; - - if (inside) { - surfaces.add(k); - } else { - ceilings.add(k + 1); - } - } - } - - for (int k : surfaces) { - NativeBlockState tip = output.get(finalI, k, j); - - if (tip == null) { - continue; - } - - boolean remove = false; - boolean remove2 = false; - - if (B.isDecorant(tip)) { - NativeBlockState bel = output.get(finalI, k - 1, j); - - if (bel == null) { - remove = true; - } else if (!B.canPlaceOnto(tip, bel)) { - remove = true; - } else if (IrisProceduralBlocks.hasProperty(bel, "half")) { - NativeBlockState bb = output.get(finalI, k - 2, j); - if (bb == null || !B.canPlaceOnto(bel, bb)) { - remove = true; - remove2 = true; - } - } - - if (remove) { - changed.set(true); - output.set(finalI, k, j, AIR.get()); - - if (remove2) { - output.set(finalI, k - 1, j, AIR.get()); - } - } - } - } - } - }); - } - burst.complete(); + for (int index = 0; index < 16; index++) { + int columnX = index; + burst.queue(() -> perfectSlice(output, columnX)); } + burst.complete(); getEngine().getMetrics().getPerfection().put(p.getMilliseconds()); } + private void perfectSlice(Hunk output, int x) { + IntArrayList surfaces = new IntArrayList(); + for (int z = 0; z < 16; z++) { + boolean changed; + do { + surfaces.clear(); + int top = getHeight(output, x, z); + boolean inside = true; + surfaces.add(top); + for (int y = top; y >= 0; y--) { + NativeBlockState block = output.get(x, y, z); + if (IrisSpeleothems.isSpike(block)) { + block = normalizeSpike(block, output, x, z, y, AIR.get(), WATER.get()); + } + boolean now = block != null && !(B.isAir(block) || B.isFluid(block)); + if (now != inside) { + inside = now; + if (inside) { + surfaces.add(y); + } + } + } + changed = false; + for (int index = 0; index < surfaces.size(); index++) { + int y = surfaces.getInt(index); + NativeBlockState tip = output.get(x, y, z); + if (!B.isDecorant(tip)) { + continue; + } + NativeBlockState below = output.get(x, y - 1, z); + boolean remove = below == null || !B.canPlaceOnto(tip, below); + boolean removeBelow = false; + if (!remove && IrisProceduralBlocks.hasProperty(below, "half")) { + NativeBlockState support = output.get(x, y - 2, z); + if (support == null || !B.canPlaceOnto(below, support)) { + remove = true; + removeBelow = true; + } + } + if (remove) { + changed = true; + output.set(x, y, z, AIR.get()); + if (removeBelow) { + output.set(x, y - 1, z, AIR.get()); + } + } + } + } while (changed); + } + } + static NativeBlockState normalizeSpike(NativeBlockState state, Hunk output, int x, int z, int y, NativeBlockState air, NativeBlockState water) { if (IrisSpeleothems.isSupported(state, output, x, z, y)) { diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java index 7fef0061d..5304dffb7 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java @@ -53,7 +53,8 @@ public final class GenerationHistory { GenerationHistoryStore store, ChunkGenerationOwnership ownership, GenerationKernelRegistry kernels, - Optional publication + Optional publication, + boolean unpublished ) throws IOException { this.paths = Objects.requireNonNull(paths, "paths"); this.packs = Objects.requireNonNull(packs, "packs"); @@ -62,8 +63,8 @@ public final class GenerationHistory { this.kernels = Objects.requireNonNull(kernels, "kernels"); this.boundaries = new GenerationBoundaryStore(paths.dimensionRoot()); this.terrainSignatures = new TerrainBoundarySignatureStore(paths.dimensionRoot()); - this.semantics = GenerationSemanticIndex.loadRequired(paths.dimensionRoot()); - this.savedBiomes = SavedBiomeStore.open(paths.dimensionRoot()); + this.semantics = GenerationSemanticIndex.loadRequired(paths.dimensionRoot(), unpublished); + this.savedBiomes = SavedBiomeStore.open(paths.dimensionRoot(), unpublished); this.admission = new GenerationAdmission(paths.dimensionRoot()); this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES); this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES); @@ -108,6 +109,25 @@ public final class GenerationHistory { GenerationKernelRegistry.Version version, GenerationKernelRegistry kernels ) throws IOException { + return create(new FreshCreation(dimensionRoot, packSource, packFingerprint, worldSeed, dimensionContract, registryContract), + version, kernels, false); + } + + public static GenerationHistory createUnpublished(FreshCreation creation) throws IOException { + Objects.requireNonNull(creation, "creation"); + Files.createDirectory(creation.dimensionRoot()); + GenerationKernelRegistry kernels = GenerationKernelRegistry.standard(); + return create(creation, kernels.current(), kernels, true); + } + + private static GenerationHistory create(FreshCreation creation, GenerationKernelRegistry.Version version, + GenerationKernelRegistry kernels, boolean unpublished) throws IOException { + Path dimensionRoot = creation.dimensionRoot(); + Path packSource = creation.packSource(); + String packFingerprint = creation.packFingerprint(); + long worldSeed = creation.worldSeed(); + GenerationEpoch.DimensionContract dimensionContract = creation.dimensionContract(); + GenerationRegistryContract registryContract = creation.registryContract(); GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); requireSafeStatePaths(paths); if (Files.exists(paths.manifest(), LinkOption.NOFOLLOW_LINKS) @@ -141,7 +161,7 @@ public final class GenerationHistory { GenerationSemanticIndex.initialize(paths.dimensionRoot()); GenerationHistoryStore store = GenerationHistoryStore.initialize(paths.generationRoot(), epoch); return new GenerationHistory(paths, packs, store, ownership, kernels, - Optional.of(new FreshPublication(publishedPack, epoch))); + Optional.of(new FreshPublication(publishedPack, epoch)), unpublished); } public static GenerationHistory open(Path dimensionRoot) throws IOException { @@ -183,7 +203,7 @@ public final class GenerationHistory { GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot()); GenerationHistoryStore store = GenerationHistoryStore.open(paths.generationRoot()); ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(paths.ownershipRoot()); - return new GenerationHistory(paths, packs, store, ownership, kernels, Optional.empty()); + return new GenerationHistory(paths, packs, store, ownership, kernels, Optional.empty(), false); } public static Optional openIfPresent(Path dimensionRoot) throws IOException { @@ -1344,4 +1364,15 @@ public final class GenerationHistory { public interface BoundarySignatureCapture { TerrainBoundarySignatureStore.SignatureSampler capture(GenerationBoundary boundary) throws IOException; } + public record FreshCreation(Path dimensionRoot, Path packSource, String packFingerprint, long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, GenerationRegistryContract registryContract) { + public FreshCreation { + Objects.requireNonNull(dimensionRoot, "dimensionRoot"); + Objects.requireNonNull(packSource, "packSource"); + Objects.requireNonNull(packFingerprint, "packFingerprint"); + Objects.requireNonNull(dimensionContract, "dimensionContract"); + Objects.requireNonNull(registryContract, "registryContract"); + } + } + } diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticIndex.java index 13ec5d5dd..5bf84ddd2 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticIndex.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticIndex.java @@ -92,6 +92,7 @@ public final class GenerationSemanticIndex { private final ShardPublisher publisher; private final PointerPublisher pointerPublisher; private final CatalogPublisher catalogPublisher; + private final boolean unpublished; private long regionDecodeCount; private volatile IOException journalFailure; private long appendingRegionKey; @@ -101,7 +102,8 @@ public final class GenerationSemanticIndex { Path dimensionRoot, ShardPublisher publisher, PointerPublisher pointerPublisher, - CatalogPublisher catalogPublisher + CatalogPublisher catalogPublisher, + boolean unpublished ) { this.dimensionRoot = dimensionRoot.toAbsolutePath().normalize(); directory = this.dimensionRoot.resolve("iris").resolve("generation").resolve("semantics"); @@ -115,6 +117,7 @@ public final class GenerationSemanticIndex { this.publisher = publisher; this.pointerPublisher = pointerPublisher; this.catalogPublisher = catalogPublisher; + this.unpublished = unpublished; } public static GenerationSemanticIndex load(Path dimensionRoot) throws IOException { @@ -126,7 +129,8 @@ public final class GenerationSemanticIndex { Objects.requireNonNull(dimensionRoot, "dimensionRoot"), RegionShard::publish, ShardPointer::publish, - Catalog::publish + Catalog::publish, + false ); index.ensureStorageDirectory(); Path catalog = index.directory.resolve(CATALOG_FILE_NAME); @@ -138,7 +142,13 @@ public final class GenerationSemanticIndex { } public static GenerationSemanticIndex loadRequired(Path dimensionRoot) throws IOException { - GenerationSemanticIndex index = load(dimensionRoot); + return loadRequired(dimensionRoot, false); + } + + static GenerationSemanticIndex loadRequired(Path dimensionRoot, boolean unpublished) throws IOException { + GenerationSemanticIndex index = new GenerationSemanticIndex(Objects.requireNonNull(dimensionRoot, "dimensionRoot"), + RegionShard::publish, ShardPointer::publish, Catalog::publish, unpublished); + index.loadShards(); Path catalog = index.directory.resolve(CATALOG_FILE_NAME); if (!Files.isRegularFile(catalog, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Generation semantic shard catalog is missing: " + catalog); @@ -167,7 +177,8 @@ public final class GenerationSemanticIndex { Objects.requireNonNull(dimensionRoot, "dimensionRoot"), Objects.requireNonNull(publisher, "publisher"), Objects.requireNonNull(pointerPublisher, "pointerPublisher"), - Objects.requireNonNull(catalogPublisher, "catalogPublisher") + Objects.requireNonNull(catalogPublisher, "catalogPublisher"), + false ); index.loadShards(); return index; @@ -254,7 +265,7 @@ public final class GenerationSemanticIndex { appendingBase = baseRegion; publicationLock.unlock(); try { - SemanticJournal.append(directory, regionX, regionZ, updates); + SemanticJournal.append(directory, regionX, regionZ, updates, unpublished); } catch (JournalAppendFailure failure) { journalFailure = failure; throw failure; @@ -1928,7 +1939,8 @@ public final class GenerationSemanticIndex { Path directory, int regionX, int regionZ, - List claims + List claims, + boolean unpublished ) throws IOException { Path file = directory.resolve(journalFileName(regionX, regionZ)); boolean created = !Files.exists(file, LinkOption.NOFOLLOW_LINKS); @@ -1961,14 +1973,18 @@ public final class GenerationSemanticIndex { channel.write(entry); } } - Durability.force(channel); + if (!unpublished) { + Durability.force(channel); + } if (created) { RegionShard.forceDirectory(directory); } } catch (IOException failure) { try { channel.truncate(size); - Durability.force(channel); + if (!unpublished) { + Durability.force(channel); + } if (created) { RegionShard.forceDirectory(directory); } 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 e6713f6a6..8e452be5c 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 @@ -49,14 +49,16 @@ public final class SavedBiomeStore { private final Path dimensionRoot; private final Path directory; + private final boolean unpublished; private final WriteStripe[] regionLocks = new WriteStripe[64]; private final LinkedHashMap regions = new LinkedHashMap<>(64, 0.75F, true); private final LinkedHashMap> chunks = new LinkedHashMap<>(128, 0.75F, true); private long cachedBytes; private volatile IOException writeFailure; - private SavedBiomeStore(Path dimensionRoot) throws IOException { + private SavedBiomeStore(Path dimensionRoot, boolean unpublished) throws IOException { this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").toAbsolutePath().normalize(); + this.unpublished = unpublished; directory = this.dimensionRoot.resolve("iris/generation/biomes"); requireSafeAncestors(); for (int index = 0; index < regionLocks.length; index++) { @@ -65,7 +67,11 @@ public final class SavedBiomeStore { } public static SavedBiomeStore open(Path dimensionRoot) throws IOException { - return new SavedBiomeStore(dimensionRoot); + return open(dimensionRoot, false); + } + + static SavedBiomeStore open(Path dimensionRoot, boolean unpublished) throws IOException { + return new SavedBiomeStore(dimensionRoot, unpublished); } public Path storageDirectory() { @@ -195,11 +201,14 @@ public final class SavedBiomeStore { RegionIndex region = region(first.chunkX(), first.chunkZ()); ensureRegionFile(region); long offset = 0L; - boolean durable = false; + boolean appendCompleted = false; try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) { offset = output.length(); long length = 0L; + ByteBuffer[] records = new ByteBuffer[claimed.size()]; + int recordIndex = 0; for (PendingWrite pending : claimed.values()) { + records[recordIndex++] = ByteBuffer.wrap(pending.record); length += pending.record.length; } if (offset + length > MAXIMUM_REGION_BYTES) { @@ -207,15 +216,24 @@ public final class SavedBiomeStore { } try { output.seek(offset); - for (PendingWrite pending : claimed.values()) { - output.write(pending.record); + FileChannel channel = output.getChannel(); + int firstBuffer = 0; + while (firstBuffer < records.length) { + channel.write(records, firstBuffer, records.length - firstBuffer); + while (firstBuffer < records.length && !records[firstBuffer].hasRemaining()) { + firstBuffer++; + } } - output.getChannel().force(true); - durable = true; + if (!unpublished) { + output.getChannel().force(true); + } + appendCompleted = true; } catch (IOException failure) { try { output.setLength(offset); - output.getChannel().force(true); + if (!unpublished) { + output.getChannel().force(true); + } } catch (IOException rollback) { failure.addSuppressed(rollback); writeFailure = failure; @@ -223,7 +241,7 @@ public final class SavedBiomeStore { throw failure; } } catch (IOException failure) { - if (durable) { + if (appendCompleted) { writeFailure = failure; } throw failure; diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/TerrainComposedFaceTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainComposedFaceTest.java new file mode 100644 index 000000000..b13a1e374 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainComposedFaceTest.java @@ -0,0 +1,153 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.PreObjectMatterCell; +import art.arcane.volmlib.util.hunk.bits.DataContainer; +import art.arcane.volmlib.util.hunk.storage.PaletteOrHunk; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.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; +import static org.mockito.Mockito.when; + +public class TerrainComposedFaceTest { + @BeforeClass + public static void registerMatter() { + IrisMatterSupport.ensureRegistered(); + } + + @Test + @SuppressWarnings("unchecked") + public void allFacesMatchScalarCompositionAcrossSectionsAndCapturedNulls() { + Matter[] sections = {new IrisMatter(16, 16, 16), null, new IrisMatter(16, 16, 16)}; + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.exists(anyInt())).thenAnswer(call -> (int) call.getArgument(0) != 1); + when(chunk.get(anyInt())).thenAnswer(call -> { + assertTrue(Thread.holdsLock(chunk)); + return sections[(int) call.getArgument(0)]; + }); + MatterCavern natural = new MatterCavern(true, "natural", (byte) 0); + MatterCavern original = new MatterCavern(false, "original", (byte) 2); + HydrologyCaveCell[] hydrology = new HydrologyCaveCell[HydrologyCaveAction.values().length + 1]; + for (int index = 1; index < hydrology.length; index++) { + hydrology[index] = new HydrologyCaveCell(HydrologyCaveAction.values()[index - 1], "river", "flooded"); + } + PreObjectMatterCell[] journal = {null, PreObjectMatterCell.string(null), + PreObjectMatterCell.cavern(null), PreObjectMatterCell.cavern(original), + PreObjectMatterCell.hydrology(null), PreObjectMatterCell.hydrology(hydrology[1]), + PreObjectMatterCell.cavern(null).captureHydrology(null), + PreObjectMatterCell.cavern(original).captureHydrology(null)}; + for (Matter matter : sections) { + if (matter == null) { + continue; + } + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + int index = x + y * 17 + z * 31; + matter.slice(MatterCavern.class).set(x, y, z, index % 3 == 0 ? null : natural); + matter.slice(HydrologyCaveCell.class).set(x, y, z, hydrology[index % hydrology.length]); + matter.slice(PreObjectMatterCell.class).set(x, y, z, journal[index % journal.length]); + } + } + } + } + for (TerrainMatterView.Face face : TerrainMatterView.Face.values()) { + MatterCavern[] copied = TerrainMatterView.getComposedFace(chunk, face, 43); + assertEquals(43 * 16, copied.length); + for (int y = 0; y < 43; y++) { + for (int offset = 0; offset < 16; offset++) { + int x = face == TerrainMatterView.Face.WEST ? 0 : face == TerrainMatterView.Face.EAST ? 15 : offset; + int z = face == TerrainMatterView.Face.NORTH ? 0 : face == TerrainMatterView.Face.SOUTH ? 15 : offset; + assertSame(TerrainMatterView.getComposedCavern(chunk, x, y, z), copied[y * 16 + offset]); + } + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void paletteFacesUseOneBulkReadAndDoNotLeakAcrossAbsentSlices() { + Matter first = new IrisMatter(16, 16, 16); + Matter second = new IrisMatter(16, 16, 16); + MatterCavern cavern = new MatterCavern(true, "cave", (byte) 0); + PaletteOrHunk slice = (PaletteOrHunk) first.slice(MatterCavern.class); + slice.setRaw(15, 4, 7, cavern); + DataContainer palette = spy(slice.palette()); + slice.setPalette(palette); + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.exists(anyInt())).thenReturn(true); + when(chunk.get(0)).thenReturn(first); + when(chunk.get(1)).thenReturn(second); + doAnswer(call -> { + assertTrue(Thread.holdsLock(chunk)); + return call.callRealMethod(); + }).when(palette).copyTo(any(int[].class), any(MatterCavern[].class)); + MatterCavern[] copied = TerrainMatterView.getComposedFace(chunk, TerrainMatterView.Face.EAST, 32); + assertSame(cavern, copied[4 * 16 + 7]); + for (int index = 16 * 16; index < copied.length; index++) { + assertNull(copied[index]); + } + verify(palette, times(1)).copyTo(any(int[].class), any(MatterCavern[].class)); + verify(palette, never()).get(anyInt()); + } + + @Test + @SuppressWarnings("unchecked") + public void nonstandardPaletteDimensionsRetainScalarCoordinatesAndBounds() { + for (int[] dimensions : new int[][]{{8, 16, 16}, {16, 8, 16}, {16, 16, 8}}) { + Matter matter = new IrisMatter(dimensions[0], dimensions[1], dimensions[2]); + MatterCavern cavern = new MatterCavern(true, "small", (byte) 0); + for (int x = 0; x < dimensions[0]; x++) { + for (int y = 0; y < dimensions[1]; y++) { + for (int z = 0; z < dimensions[2]; z++) { + if ((x + y + z) % 3 == 0) { + matter.slice(MatterCavern.class).set(x, y, z, cavern); + } + } + } + } + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.exists(0)).thenReturn(true); + when(chunk.get(0)).thenReturn(matter); + for (TerrainMatterView.Face face : TerrainMatterView.Face.values()) { + MatterCavern[] copied = TerrainMatterView.getComposedFace(chunk, face, 16); + for (int y = 0; y < 16; y++) { + for (int offset = 0; offset < 16; offset++) { + int x = face == TerrainMatterView.Face.WEST ? 0 : face == TerrainMatterView.Face.EAST ? 15 : offset; + int z = face == TerrainMatterView.Face.NORTH ? 0 : face == TerrainMatterView.Face.SOUTH ? 15 : offset; + assertSame(TerrainMatterView.getComposedCavern(chunk, x, y, z), copied[y * 16 + offset]); + } + } + } + } + } + + @Test + public void absentFacesAndInvalidHeightsAreBounded() { + assertEquals(0, TerrainMatterView.getComposedFace(null, TerrainMatterView.Face.WEST, 0).length); + for (MatterCavern cavern : TerrainMatterView.getComposedFace(null, TerrainMatterView.Face.WEST, 17)) { + assertNull(cavern); + } + assertThrows(IllegalArgumentException.class, () -> TerrainMatterView.getComposedFace(null, TerrainMatterView.Face.WEST, -1)); + assertThrows(ArithmeticException.class, () -> TerrainMatterView.getComposedFace(null, TerrainMatterView.Face.WEST, Integer.MAX_VALUE)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveBoundaryFacesTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveBoundaryFacesTest.java new file mode 100644 index 000000000..85c9eca15 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveBoundaryFacesTest.java @@ -0,0 +1,125 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.mantle.TerrainMatterView; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.PreObjectMatterCell; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +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.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisCarveBoundaryFacesTest { + @BeforeClass + public static void registerMatter() { + IrisMatterSupport.ensureRegistered(); + } + + @Test + @SuppressWarnings("unchecked") + public void borderSnapshotsPreserveCornerOrderAndNeverNestChunkLocks() throws Exception { + MantleChunk[] chunks = new MantleChunk[5]; + Matter[][] matter = new Matter[5][2]; + MatterCavern[] values = new MatterCavern[5]; + for (int side = 0; side < chunks.length; side++) { + int selected = side; + chunks[side] = mock(MantleChunk.class); + matter[side][0] = new IrisMatter(16, 16, 16); + matter[side][1] = new IrisMatter(16, 16, 16); + values[side] = new MatterCavern(true, "side-" + side, (byte) 0); + when(chunks[side].exists(anyInt())).thenAnswer(call -> { + assertTrue(Thread.holdsLock(chunks[selected])); + for (int other = 0; other < chunks.length; other++) { + if (other != selected && chunks[other] != null) { + assertFalse(Thread.holdsLock(chunks[other])); + } + } + return true; + }); + when(chunks[side].get(anyInt())).thenAnswer(call -> matter[selected][(int) call.getArgument(0)]); + if (side == 0) { + continue; + } + for (int y = 1; y < 32; y++) { + for (int offset = 0; offset < 16; offset++) { + int x = side == 1 ? 15 : side == 2 ? 0 : offset; + int z = side == 3 ? 15 : side == 4 ? 0 : offset; + matter[side][y >> 4].slice(MatterCavern.class).set(x, y & 15, z, values[side]); + } + } + } + matter[0][0].slice(MatterCavern.class).set(0, 7, 0, values[0]); + matter[0][0].slice(MatterCavern.class).set(0, 5, 0, values[0]); + matter[0][0].slice(PreObjectMatterCell.class).set(0, 5, 0, PreObjectMatterCell.cavern(null)); + Mantle mantle = mock(Mantle.class, RETURNS_DEEP_STUBS); + TectonicPlate plate = mock(TectonicPlate.class); + when(mantle.getLoadedRegions().get(anyLong())).thenReturn(plate); + when(plate.get(31, 0)).thenReturn(chunks[1]); + when(plate.get(1, 0)).thenReturn(chunks[2]); + when(plate.get(0, 31)).thenReturn(chunks[3]); + when(plate.get(0, 1)).thenReturn(chunks[4]); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(0).maxHeight(48).build()); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + CarveWallBuffer walls = new CarveWallBuffer(64); + CarveColumnMask[] masks = new CarveColumnMask[256]; + Arrays.setAll(masks, index -> new CarveColumnMask()); + int[] surfaces = new int[256]; + Arrays.fill(surfaces, 30); + Method method = IrisCarveModifier.class.getDeclaredMethod("addCrossChunkBoundaryWalls", + Mantle.class, MantleChunk.class, CarveWallBuffer.class, CarveColumnMask[].class, + int.class, int.class, int[].class); + method.setAccessible(true); + method.invoke(modifier, mantle, chunks[0], walls, masks, 0, 0, surfaces); + CarveWallBuffer expectedWalls = new CarveWallBuffer(64); + for (int y = 1; y < 32; y++) { + for (int offset = 0; offset < 16; offset++) { + addScalarWall(expectedWalls, chunks[0], chunks[1], 0, y, offset, 15, offset); + addScalarWall(expectedWalls, chunks[0], chunks[2], 15, y, offset, 0, offset); + addScalarWall(expectedWalls, chunks[0], chunks[3], offset, y, 0, offset, 15); + addScalarWall(expectedWalls, chunks[0], chunks[4], offset, y, 15, offset, 0); + } + } + for (int y = 1; y < 32; y++) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + MatterCavern expected = expectedWalls.get(x, y, z); + assertSame("voxel " + x + "," + y + "," + z, expected, walls.get(x, y, z)); + assertTrue(masks[x * 16 + z].contains(y) == (expected != null)); + } + } + } + } + + private static void addScalarWall(CarveWallBuffer walls, MantleChunk chunk, + MantleChunk neighbor, int x, int y, int z, + int neighborX, int neighborZ) { + if (TerrainMatterView.getComposedCavern(chunk, x, y, z) != null) { + return; + } + MatterCavern value = TerrainMatterView.getComposedCavern(neighbor, neighborX, y, neighborZ); + if (value != null) { + walls.put(x, y, z, value); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java new file mode 100644 index 000000000..b4c13a95b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPerfectionModifierColumnTest.java @@ -0,0 +1,239 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.concurrent.BurstExecutor; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.iris.generation.decoration.IrisSpeleothems; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.hunk.storage.ArrayHunk; +import org.bukkit.block.data.BlockData; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ExecutorService; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisPerfectionModifierColumnTest { + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + + @Test + public void randomColumnsMatchOriginalGlobalPasses() { + Fixture fixture = new Fixture(); + Random random = new Random(82714L); + NativeBlockState[] palette = {null, fixture.air, fixture.water, fixture.stone, + fixture.decorant, fixture.half, fixture.spike(true, false), fixture.spike(false, true)}; + for (int trial = 0; trial < 8; trial++) { + Hunk source = Hunk.newArrayHunk(16, 32, 16); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < 32; y++) { + source.setRaw(x, y, z, y == 0 ? fixture.stone : palette[random.nextInt(palette.length)]); + } + } + } + assertParity(fixture, source); + } + } + + @Test + public void unrelatedTallColumnsAreScannedOnceDuringRepeatedCleanup() { + Fixture fixture = new Fixture(); + CountingHunk output = new CountingHunk(768); + for (int y = 0; y < 768; y++) { + output.setRaw(1, y, 0, fixture.stone); + } + for (int y = 1; y <= 24; y++) { + output.setRaw(0, y, 0, fixture.decorant); + } + fixture.modifier.onModify(0, 0, output, false, null); + assertTrue("clean column must not be scanned for each removed decorant", output.cleanColumnReads < 780); + for (int y = 1; y <= 24; y++) { + assertTrue(output.getRaw(0, y, 0).isAir()); + } + } + + @Test + public void pairedHalvesMergedSpikesAndWaterloggedSupportsMatchOriginal() { + Fixture fixture = new Fixture(); + Hunk source = Hunk.newArrayHunk(16, 16, 16); + for (int x = 0; x < 16; x++) { + source.setRaw(x, 1, 0, x % 2 == 0 ? fixture.stone : fixture.water); + source.setRaw(x, 8, 0, x % 3 == 0 ? fixture.stone : fixture.decorant); + for (int y = 2; y <= 4; y++) { + source.setRaw(x, y, 0, fixture.spike(true, x % 2 == 0)); + } + for (int y = 5; y <= 7; y++) { + source.setRaw(x, y, 0, fixture.spike(false, x % 2 != 0)); + } + source.setRaw(x, 10, 0, fixture.half); + source.setRaw(x, 11, 0, fixture.decorant); + } + assertParity(fixture, source); + } + + private void assertParity(Fixture fixture, Hunk source) { + Hunk expected = Hunk.newArrayHunk(16, source.getHeight(), 16); + Hunk actual = Hunk.newArrayHunk(16, source.getHeight(), 16); + source.iterateSync((x, y, z, state) -> { + expected.setRaw(x, y, z, state); + actual.setRaw(x, y, z, state); + }); + original(expected, fixture.air, fixture.water); + fixture.modifier.onModify(0, 0, actual, false, null); + expected.iterateSync((x, y, z, state) -> assertEquals("voxel " + x + "," + y + "," + z, + state == null ? null : state.key(), actual.getRaw(x, y, z) == null ? null : actual.getRaw(x, y, z).key())); + } + + private static void original(Hunk output, NativeBlockState air, NativeBlockState water) { + boolean changed; + do { + changed = false; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + List surfaces = new ArrayList<>(); + int top = 0; + for (int y = output.getHeight() - 1; y >= 0; y--) { + NativeBlockState block = output.get(x, y, z); + if (block != null && !B.isAir(block) && !B.isFluid(block)) { + top = y; + break; + } + } + boolean inside = true; + surfaces.add(top); + for (int y = top; y >= 0; y--) { + NativeBlockState block = output.get(x, y, z); + if (IrisSpeleothems.isSpike(block)) { + block = IrisPerfectionModifier.normalizeSpike(block, output, x, z, y, air, water); + } + boolean now = block != null && !(B.isAir(block) || B.isFluid(block)); + if (now != inside) { + inside = now; + if (inside) { + surfaces.add(y); + } + } + } + for (int y : surfaces) { + NativeBlockState tip = output.get(x, y, z); + if (tip == null) { + continue; + } + boolean remove = false; + boolean removeBelow = false; + if (B.isDecorant(tip)) { + NativeBlockState below = output.get(x, y - 1, z); + if (below == null || !B.canPlaceOnto(tip, below)) { + remove = true; + } else if (IrisProceduralBlocks.hasProperty(below, "half")) { + NativeBlockState support = output.get(x, y - 2, z); + if (support == null || !B.canPlaceOnto(below, support)) { + remove = true; + removeBelow = true; + } + } + if (remove) { + changed = true; + output.set(x, y, z, air); + if (removeBelow) { + output.set(x, y - 1, z, air); + } + } + } + } + } + } + } while (changed); + } + + private final class Fixture { + private final Map states = new HashMap<>(); + private final NativeBlockState air = state("minecraft:air"); + private final NativeBlockState water = state("minecraft:water"); + private final NativeBlockState stone = state("minecraft:stone"); + private final NativeBlockState decorant = state("minecraft:poppy"); + private final NativeBlockState half = state("minecraft:tall_grass[half=lower]"); + private final IrisPerfectionModifier modifier; + + private Fixture() { + when(platform.registries().block("AIR")).thenReturn(air); + when(platform.registries().block("WATER")).thenReturn(water); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getMetrics()).thenReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)); + MultiBurst pool = mock(MultiBurst.class); + when(engine.burst()).thenReturn(pool); + when(pool.burst(false)).thenAnswer(call -> { + BurstExecutor burst = new BurstExecutor(mock(ExecutorService.class), 16); + burst.setMulticore(false); + return burst; + }); + modifier = new IrisPerfectionModifier(engine); + } + + private NativeBlockState spike(boolean upward, boolean waterlogged) { + return state("minecraft:pointed_dripstone[thickness=tip,vertical_direction=" + + (upward ? "up" : "down") + ",waterlogged=" + waterlogged + "]"); + } + + private NativeBlockState state(String key) { + NativeBlockState existing = states.get(key); + if (existing != null) { + return existing; + } + NativeBlockState block = mock(NativeBlockState.class); + states.put(key, block); + when(block.key()).thenReturn(key); + when(block.isAir()).thenReturn(key.equals("minecraft:air")); + when(block.isFluid()).thenReturn(key.equals("minecraft:water")); + when(block.isWater()).thenReturn(key.equals("minecraft:water")); + when(block.isWaterLogged()).thenReturn(key.contains("waterlogged=true")); + when(block.isDecorant()).thenReturn(key.equals("minecraft:poppy") || key.startsWith("minecraft:tall_grass")); + when(block.canPlaceOnto(any())).thenAnswer(call -> "minecraft:tall_grass[half=lower]".equals(call.getArgument(0).key())); + BlockData handle = mock(BlockData.class); + when(block.nativeHandle()).thenReturn(handle); + when(handle.isFaceSturdy(any(), any())).thenReturn(key.equals("minecraft:stone")); + when(block.withProperty(anyString(), anyString())).thenAnswer(call -> { + String property = call.getArgument(0); + String value = call.getArgument(1); + return state(key.replaceAll(property + "=[^,\\]]+", property + "=" + value)); + }); + return block; + } + } + + private static final class CountingHunk extends ArrayHunk { + private int cleanColumnReads; + + private CountingHunk(int height) { + super(16, height, 16); + } + + @Override + public NativeBlockState getRaw(int x, int y, int z) { + if (x == 1 && z == 0) { + cleanColumnReads++; + } + return super.getRaw(x, y, z); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java new file mode 100644 index 000000000..a78ecf46b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.world.history; + +import art.arcane.iris.world.storage.Durability; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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.AdditionalAnswers.delegatesTo; +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.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.withSettings; + +public final class GenerationUnpublishedHistoryTest extends GenerationHistorySupport { + @Test + public void unpublishedHistoryRequiresNewRootAndReopeningRestoresImmediateBiomeWrites() throws Exception { + Path root = temporaryFolder.getRoot().toPath().resolve("unpublished"); + Path pack = createPack("unpublished-pack", "alpha"); + GenerationHistory.FreshCreation creation = new GenerationHistory.FreshCreation(root, pack, fingerprint(pack), + 42L, contract(), GenerationRegistryContract.empty()); + GenerationHistory history = GenerationHistory.createUnpublished(creation); + assertThrows(FileAlreadyExistsException.class, () -> GenerationHistory.createUnpublished(creation)); + history.savedBiomes().claimAndPersist(biomes(0)); + assertBiomeAppendForces(history.savedBiomes(), root, 1, 0); + GenerationHistory reopened = GenerationHistory.open(root); + assertEquals(biomes(1), reopened.savedBiomes().get(1, 0).orElseThrow()); + assertBiomeAppendForces(reopened.savedBiomes(), root, 2, 1); + } + + @Test + public void semanticAppendDeferralDoesNotChangeReplayOrNormalOpenDurability() throws Exception { + Path root = temporaryFolder.newFolder("semantics").toPath(); + GenerationSemanticIndex.initialize(root); + GenerationSemanticIndex staged = GenerationSemanticIndex.loadRequired(root, true); + staged.claimAndPersist(semantics(0)); + try (MockedStatic forces = mockStatic(Durability.class, CALLS_REAL_METHODS)) { + assertTrue(staged.claimAndPersist(semantics(1))); + forces.verify(() -> Durability.force(any(FileChannel.class)), times(0)); + } + GenerationSemanticIndex reopened = GenerationSemanticIndex.loadRequired(root); + assertEquals(semantics(1), reopened.get(1, 0).orElseThrow()); + try (MockedStatic forces = mockStatic(Durability.class, CALLS_REAL_METHODS)) { + assertTrue(reopened.claimAndPersist(semantics(2))); + forces.verify(() -> Durability.force(any(FileChannel.class)), times(1)); + } + reopened.compactJournals(); + assertEquals(3, GenerationSemanticIndex.loadRequired(root).recordCount()); + } + + @Test + public void unpublishedBiomeWriteFailureStillRollsBackAndDoesNotPublishClaim() throws Exception { + Path root = temporaryFolder.newFolder("failed-biomes").toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root, true); + store.claimAndPersist(biomes(0)); + Path region = region(root); + byte[] before = Files.readAllBytes(region); + try (RandomAccessFile actual = new RandomAccessFile(region.toFile(), "rw")) { + FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); + doThrow(new IOException("Expected staged write failure")).when(channel).write(any(ByteBuffer[].class), anyInt(), anyInt()); + try (MockedConstruction ignored = mockConstruction(RandomAccessFile.class, + withSettings().defaultAnswer(delegatesTo(actual)), + (file, context) -> doReturn(channel).when(file).getChannel())) { + assertThrows(IOException.class, () -> store.claimAndPersist(biomes(1))); + } + verify(channel, times(0)).force(true); + } + assertArrayEquals(before, Files.readAllBytes(region)); + assertTrue(store.get(1, 0).isEmpty()); + assertFalse(SavedBiomeStore.open(root).get(1, 0).isPresent()); + assertTrue(store.claimAndPersist(biomes(1))); + } + + private static void assertBiomeAppendForces(SavedBiomeStore store, Path root, int x, int count) throws Exception { + try (RandomAccessFile actual = new RandomAccessFile(region(root).toFile(), "rw")) { + FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); + try (MockedConstruction ignored = mockConstruction(RandomAccessFile.class, + withSettings().defaultAnswer(delegatesTo(actual)), + (file, context) -> doReturn(channel).when(file).getChannel())) { + assertTrue(store.claimAndPersist(biomes(x))); + } + verify(channel, times(count)).force(true); + } + } + + private static Path region(Path root) { + return root.resolve("iris/generation/biomes/r.0.0.ibio"); + } + + private static ChunkGenerationSemantics semantics(int x) { + return ChunkGenerationSemantics.builder(x, 0, 1L).addSurfaceBiome("biome/main").seal().build(); + } + + private static SavedBiomeChunk biomes(int x) { + SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(1L, "biome/main", "region/main"); + SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, + List.of(new SavedBiomeChunk.Span(-64, 320, cell))); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, 1L, -64, 384)); + for (int z = 0; z < 16; z++) { + for (int localX = 0; localX < 16; localX++) { + builder.column(localX, z, column); + } + } + return builder.build(); + } +} 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 12bcf985c..3128fc873 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 @@ -11,6 +11,7 @@ import java.io.RandomAccessFile; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.channels.FileChannel; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -35,6 +36,8 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -45,6 +48,74 @@ public class SavedBiomeStoreBatchTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void gatheringWritesHandleZeroAndPartialProgressAcrossQueuedFrames() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + BatchControl control = new BatchControl(); + control.maximumWriteBytes = 11; + control.zeroFirstWrite = true; + ExecutorService workers = Executors.newFixedThreadPool(4); + List claims = List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L)); + try { + List> results = enqueue(store, root, control, workers, claims); + assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + assertEquals(3, control.maximumGathered.get()); + assertTrue(control.writes.get() > claims.size()); + for (SavedBiomeChunk claim : claims) { + assertTrue(store.cached(claim.chunkX(), claim.chunkZ()).isEmpty()); + } + control.release.countDown(); + for (Future result : results) { + ClaimResult completed = result.get(5, TimeUnit.SECONDS); + assertTrue(completed.persisted()); + assertNull(completed.failure()); + } + assertEquals(1, control.forces.get()); + SavedBiomeStore reopened = SavedBiomeStore.open(root); + for (SavedBiomeChunk claim : claims) { + assertEquals(claim, reopened.get(claim.chunkX(), claim.chunkZ()).orElseThrow()); + } + } finally { + control.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + public void partialGatherFailureRollsBackWholeBatchBeforePublication() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + Path region = root.resolve("iris/generation/biomes/r.0.0.ibio"); + byte[] original = Files.readAllBytes(region); + BatchControl control = new BatchControl(); + control.maximumWriteBytes = 11; + control.writeFailure = new IOException("Partial gathered append failed"); + control.release.countDown(); + ExecutorService workers = Executors.newFixedThreadPool(4); + List claims = List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L)); + try { + for (Future result : enqueue(store, root, control, workers, claims)) { + ClaimResult completed = result.get(5, TimeUnit.SECONDS); + assertFalse(completed.persisted()); + assertSame(control.writeFailure, completed.failure()); + } + assertEquals(2, control.writes.get()); + assertEquals(1, control.forces.get()); + assertArrayEquals(original, Files.readAllBytes(region)); + for (SavedBiomeChunk claim : claims) { + assertTrue(store.cached(claim.chunkX(), claim.chunkZ()).isEmpty()); + assertTrue(store.claimAndPersist(claim)); + } + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + @Test public void independentOriginRegionsPersistWhileOneRegionRemainsLocked() throws Exception { Path root = temporaryFolder.newFolder().toPath(); @@ -277,6 +348,33 @@ public class SavedBiomeStoreBatchTest { try (RandomAccessFile actual = new RandomAccessFile( root.resolve("iris/generation/biomes/r.0.0.ibio").toFile(), "rw")) { FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); + if (control.maximumWriteBytes > 0) { + doAnswer(invocation -> { + ByteBuffer[] records = invocation.getArgument(0); + int offset = invocation.getArgument(1); + int count = invocation.getArgument(2); + control.maximumGathered.accumulateAndGet(count, Math::max); + int write = control.writes.incrementAndGet(); + if (control.zeroFirstWrite && write == 1) { + return 0L; + } + if (control.writeFailure != null && write == 2) { + throw control.writeFailure; + } + long written = 0L; + for (int index = offset; index < offset + count && written < control.maximumWriteBytes; index++) { + ByteBuffer record = records[index]; + int previousLimit = record.limit(); + record.limit(record.position() + Math.min(record.remaining(), control.maximumWriteBytes - (int) written)); + try { + written += actual.getChannel().write(record); + } finally { + record.limit(previousLimit); + } + } + return written; + }).when(channel).write(any(ByteBuffer[].class), anyInt(), anyInt()); + } doAnswer(invocation -> { int force = control.forces.incrementAndGet(); if (force == 1) { @@ -331,6 +429,11 @@ public class SavedBiomeStoreBatchTest { private final CountDownLatch forcing = new CountDownLatch(1); private final CountDownLatch release = new CountDownLatch(1); private final AtomicInteger forces = new AtomicInteger(); + private int maximumWriteBytes; + private boolean zeroFirstWrite; + private IOException writeFailure; + private final AtomicInteger writes = new AtomicInteger(); + private final AtomicInteger maximumGathered = new AtomicInteger(); private IOException failure; private RuntimeException uncheckedFailure; } diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java index 1360c4580..ea17a71cd 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java @@ -10,6 +10,7 @@ import java.io.DataOutputStream; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.channels.FileChannel; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -103,7 +104,8 @@ public class SavedBiomeStoreWriteTest { (file, context) -> doReturn(channel).when(file).getChannel())) { boolean result = store.claimAndPersist(chunk(1, 3L)); assertEquals(1, files.constructed().size()); - verify(files.constructed().getFirst()).write(any(byte[].class)); + verify(channel).write(any(ByteBuffer[].class), anyInt(), anyInt()); + verify(files.constructed().getFirst(), never()).write(any(byte[].class)); verify(files.constructed().getFirst(), never()).writeInt(anyInt()); return result; } diff --git a/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java index f9fd5cf36..7c1f4722f 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java +++ b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java @@ -22,6 +22,17 @@ final class HeadlessGenerationHistorySession { } static GenerationHistory create(RealPackProbeSupport.HistoryRequest request, IDataFixer fixer) throws IOException { + GenerationHistory.FreshCreation creation = capture(request, fixer); + Files.createDirectories(creation.dimensionRoot()); + return GenerationHistory.create(creation.dimensionRoot(), creation.packSource(), creation.packFingerprint(), + creation.worldSeed(), creation.dimensionContract(), creation.registryContract()); + } + + static GenerationHistory createUnpublished(RealPackProbeSupport.HistoryRequest request, IDataFixer fixer) throws IOException { + return GenerationHistory.createUnpublished(capture(request, fixer)); + } + + private static GenerationHistory.FreshCreation capture(RealPackProbeSupport.HistoryRequest request, IDataFixer fixer) throws IOException { IrisData data = request.data(); IrisDimension dimension = request.dimension(); PlatformRegistries registries = IrisPlatforms.get().registries(); @@ -34,8 +45,7 @@ final class HeadlessGenerationHistorySession { 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(), + return new GenerationHistory.FreshCreation(request.worldRoot(), pack, fingerprint, request.seed(), dimensionContract, registryContract); } }