diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 5cbca3872..4de16d223 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -37,6 +37,7 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.io.Durability; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.IO; @@ -5396,17 +5397,25 @@ public final class DatapackIngestService { } private static void forceFile(Path file) throws IOException { + if (!Durability.enabled()) { + return; + } + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { - channel.force(true); + Durability.force(channel); } } private static void forceDirectoryIfSupported(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java b/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java index 8d645c115..fca7c312d 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.io.Durability; import java.io.File; import java.io.IOException; import java.nio.channels.FileChannel; @@ -19,11 +20,15 @@ final class DirectoryDurability { } static void forceDirectoryRequired(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + if (File.separatorChar == '\\') { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } catch (UnsupportedOperationException failure) { throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java index 4541b20cb..5b649c9a9 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java @@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; +import art.arcane.iris.util.common.io.Durability; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -345,7 +346,7 @@ public final class WorldReplacementJournal { while (buffer.hasRemaining()) { channel.write(buffer); } - channel.force(true); + Durability.force(channel); } forceDirectoryRequired(parent); try { diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioHistoryStore.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioHistoryStore.java index 14a05b3be..d59243cdf 100644 --- a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioHistoryStore.java +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioHistoryStore.java @@ -12,6 +12,7 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteOptions; import art.arcane.iris.core.structure.authoring.StructureWriteResult; import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.util.common.io.Durability; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -303,7 +304,7 @@ final class JigsawStudioHistoryStore { while (buffer.hasRemaining()) { channel.write(buffer); } - channel.force(true); + Durability.force(channel); } try { Files.move( @@ -374,6 +375,10 @@ final class JigsawStudioHistoryStore { } private static void forceDirectory(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + // A directory can only be opened and fsynced on a POSIX filesystem; Windows rejects the // open outright. Matches DirectoryDurability and DatapackIngestService, which already // skip the barrier there. @@ -381,7 +386,7 @@ final class JigsawStudioHistoryStore { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java index da695e5c1..4a16fa414 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.structure.authoring; +import art.arcane.iris.util.common.io.Durability; import java.io.IOException; import java.io.InputStream; import java.nio.channels.FileChannel; @@ -70,17 +71,25 @@ interface StructureFileOperations { } default void forceFile(Path path) throws IOException { + if (!Durability.enabled()) { + return; + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { - channel.force(true); + Durability.force(channel); } } default void forceDirectory(Path path) throws IOException { + if (!Durability.enabled()) { + return; + } + if (!Files.getFileStore(path).supportsFileAttributeView("posix")) { return; } try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java index fbe29df1a..4335f78dc 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.history; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.util.common.io.Durability; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.io.File; @@ -1153,11 +1154,15 @@ public final class GenerationHistory { } private static void forceDirectory(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + if (File.separatorChar == '\\') { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } catch (UnsupportedOperationException exception) { throw new IOException("Generation history directory cannot be durability-synced: " + directory, exception); diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java index cf194565e..530ac14d5 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.util.common.io.Durability; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; @@ -282,7 +283,7 @@ public final class GenerationHistoryStore { while (buffer.hasRemaining()) { channel.write(buffer); } - channel.force(true); + Durability.force(channel); } try { if (replaceExisting) { @@ -352,7 +353,7 @@ public final class GenerationHistoryStore { while (buffer.hasRemaining()) { channel.write(buffer); } - channel.force(true); + Durability.force(channel); } try { Files.move(temporary, metadata, StandardCopyOption.ATOMIC_MOVE); @@ -439,11 +440,15 @@ public final class GenerationHistoryStore { } private static void forceDirectory(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + if (File.separatorChar == '\\') { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } catch (UnsupportedOperationException exception) { throw new IOException("Generation history directory cannot be durability-synced: " + directory, exception); } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java index 891e8f620..74c903135 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.history; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.util.common.io.Durability; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; @@ -1949,14 +1950,14 @@ public final class GenerationSemanticIndex { channel.write(entry); } } - channel.force(true); + Durability.force(channel); if (created) { RegionShard.forceDirectory(directory); } } catch (IOException failure) { try { channel.truncate(size); - channel.force(true); + Durability.force(channel); if (created) { RegionShard.forceDirectory(directory); } @@ -2079,7 +2080,7 @@ public final class GenerationSemanticIndex { private static void truncateTail(RandomAccessFile file, long length) throws IOException { file.setLength(length); - file.getFD().sync(); + Durability.force(file.getFD()); } private static IOException invalid(Path file, String reason) { @@ -2951,16 +2952,20 @@ public final class GenerationSemanticIndex { while (buffer.hasRemaining()) { channel.write(buffer); } - channel.force(true); + Durability.force(channel); } } private static void forceDirectory(Path directory) throws IOException { + if (!Durability.enabled()) { + return; + } + if (File.separatorChar == '\\') { return; } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } catch (UnsupportedOperationException error) { throw new IOException("Generation semantic directory cannot be durability-synced", error); } diff --git a/core/src/main/java/art/arcane/iris/util/common/io/Durability.java b/core/src/main/java/art/arcane/iris/util/common/io/Durability.java new file mode 100644 index 000000000..a37b6866b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/io/Durability.java @@ -0,0 +1,33 @@ +package art.arcane.iris.util.common.io; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.channels.FileChannel; + +public final class Durability { + public static final String MODE_PROPERTY = "iris.durability"; + private static final String RELAXED = "relaxed"; + + private Durability() { + } + + public static boolean enabled() { + return !RELAXED.equalsIgnoreCase(System.getProperty(MODE_PROPERTY)); + } + + public static void force(FileChannel channel) throws IOException { + if (!enabled()) { + return; + } + + channel.force(true); + } + + public static void force(FileDescriptor descriptor) throws IOException { + if (!enabled()) { + return; + } + + descriptor.sync(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java index 48eb068c2..b06f9962d 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java @@ -6,7 +6,9 @@ import art.arcane.iris.engine.history.GenerationEpochContractFactory; import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.engine.history.GenerationPackFingerprint; import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.testsupport.DurabilityMode; import org.junit.Assume; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -26,6 +28,9 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class IrisDatapackCompilerInputFingerprintTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public TemporaryFolder tmp = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index 381cf1d1f..fd00e1485 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -17,8 +17,10 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -42,6 +44,9 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class IrisDatapackCompilerTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index de8447788..7db24b0dc 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -10,6 +10,7 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteMode; import art.arcane.iris.core.structure.authoring.StructureWriteResult; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.IO; import com.google.gson.Gson; @@ -18,6 +19,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.sun.net.httpserver.HttpServer; import org.bukkit.Server; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -54,6 +56,9 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class DatapackIngestServiceTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private interface PaperLikeServer extends Server { String getMinecraftVersion(); } diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 430d86d82..32f62c0a8 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -11,6 +11,7 @@ import art.arcane.iris.engine.history.GenerationEpochContractFactory; import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.engine.history.GenerationPackFingerprint; import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.testsupport.DurabilityMode; import org.junit.Before; import org.junit.BeforeClass; import org.junit.ClassRule; @@ -32,6 +33,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class WorldReplacementBootstrapTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final WorldSlotKey WORLD_KEY = WorldSlotKey.minecraft("the_nether"); private static final long SEED = 4242424242L; diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index 622874288..d0a7b2864 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.history.GenerationEpochContractFactory; import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.engine.history.GenerationPackFingerprint; import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.testsupport.DurabilityMode; import org.junit.Assume; import org.junit.BeforeClass; import org.junit.ClassRule; @@ -30,6 +31,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class WorldReplacementFilesystemTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final UUID TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); private static final UUID OTHER_TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index c4525b85e..d72c65d91 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -3,9 +3,11 @@ package art.arcane.iris.core.pack; import art.arcane.iris.core.lifecycle.BukkitStartupPaths; import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.testsupport.DurabilityMode; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import org.junit.Assume; +import org.junit.ClassRule; import org.junit.Test; import java.io.ByteArrayOutputStream; @@ -39,6 +41,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class DefaultPackBootstrapProvisionerTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Test public void startupDoesNotRequireOrDownloadDefaultPacks() { assertTrue(DefaultPackBootstrapProvisioner.defaultPacks().isEmpty()); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java index 795f7b581..9c8c1ca04 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java @@ -6,6 +6,7 @@ import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.iris.util.common.math.IrisBlockVector; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -36,6 +37,9 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class JigsawStudioGraphEditorResizeTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final String STRUCTURE_KEY = "resize/planar"; @ClassRule diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java index 0d774b7d8..d0d0549fc 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java @@ -10,6 +10,7 @@ import art.arcane.iris.core.structure.authoring.StructureWriteMode; import art.arcane.iris.core.structure.authoring.StructureWriteResult; import art.arcane.iris.engine.object.IrisJigsawThemeSet; import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.iris.util.common.math.IrisBlockVector; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class JigsawStudioPersistenceEditorsTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final String PLANAR_KEY = "studio/planar"; private static final String SPATIAL_KEY = "studio/spatial"; diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java index 7fd5c4ba9..c21360586 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java @@ -9,6 +9,7 @@ import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.volmlib.util.math.RNG; import com.google.gson.JsonObject; @@ -35,6 +36,9 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class JigsawStudioProjectCreatorTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final JigsawStudioProjectCreator.Options DEFAULT_PLANAR_OPTIONS = new JigsawStudioProjectCreator.Options( "settlement/test", diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioHistoryStoreTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioHistoryStoreTest.java index 9a7fe370f..c5a766535 100644 --- a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioHistoryStoreTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioHistoryStoreTest.java @@ -11,6 +11,8 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteOptions; import art.arcane.iris.core.structure.authoring.StructureWriteResult; import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.testsupport.DurabilityMode; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -29,6 +31,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class JigsawStudioHistoryStoreTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java index dda5ee08c..7b95615f4 100644 --- a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java @@ -17,6 +17,7 @@ import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.testsupport.DurabilityMode; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; @@ -46,6 +47,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class JigsawStudioResourceBundleAssemblerTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final Gson PRETTY_GSON = new GsonBuilder().setPrettyPrinting().create(); private static final String STRUCTURE_KEY = "fort"; diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java index 52e695213..e4621cc07 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java @@ -18,6 +18,8 @@ package art.arcane.iris.core.structure.authoring; +import art.arcane.iris.testsupport.DurabilityMode; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -40,6 +42,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class StructureTransactionWriterTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final StructureKey TARGET_KEY = StructureKey.parse("iris_test:temple"); private static final StructureKey SOURCE_KEY = StructureKey.parse("minecraft:trial_chambers"); diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java index 4b0208384..42faebe7d 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java @@ -26,8 +26,10 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.DurabilityMode; import org.junit.AfterClass; import org.junit.BeforeClass; +import org.junit.ClassRule; import org.junit.Test; import org.junit.Rule; import org.junit.rules.TemporaryFolder; @@ -69,6 +71,9 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class EngineShutdownDrainTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static IrisPlatform previousPlatform; @Rule diff --git a/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java index c9df042df..d6b7f574c 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.testsupport.DurabilityMode; import org.junit.Assume; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -19,6 +21,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public final class ChunkGenerationOwnershipTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryLegacyAdoptionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryLegacyAdoptionTest.java new file mode 100644 index 000000000..b354f6088 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryLegacyAdoptionTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.engine.history; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationHistoryLegacyAdoptionTest extends GenerationHistorySupport { + @Test + public void legacyAdoptionIsDurableExplicitAndIdempotent() throws Exception { + Path world = temporaryFolder.newFolder("legacy-world").toPath(); + Path legacy = Files.createDirectories(world.resolve("iris/pack/dimensions")); + Files.writeString(legacy.resolve("main.json"), "legacy"); + Path legacyRoot = world.resolve("iris/pack"); + Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); + Files.writeString(legacyMantle.resolve("r.0.0.lz4b"), "mantle"); + String fingerprint = fingerprint(legacyRoot); + + GenerationHistory adopted = GenerationHistory.adoptLegacyPack( + world, + fingerprint, + 42L, + contract(), + GenerationRegistryContract.empty() + ); + + assertFalse(Files.exists(legacyRoot)); + assertFalse(Files.exists(legacyMantle)); + assertTrue(Files.isDirectory(adopted.activePackRoot())); + Path activationMantle = adopted.paths().activationMantleRoot(1L); + assertEquals("mantle", Files.readString(activationMantle.resolve("r.0.0.lz4b"))); + assertEquals(1L, adopted.activeActivation().activationId()); + GenerationHistory repeated = GenerationHistory.adoptLegacyPack( + world, + fingerprint, + 42L, + contract(), + GenerationRegistryContract.empty() + ); + assertEquals(adopted.activeEpoch(), repeated.activeEpoch()); + assertEquals("mantle", Files.readString(repeated.paths().activationMantleRoot(1L) + .resolve("r.0.0.lz4b"))); + } + + @Test + public void legacyMantleAdoptionFailsClosedOnDualRoots() throws Exception { + Path world = temporaryFolder.newFolder("legacy-dual-mantle-world").toPath(); + Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); + Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); + Files.writeString(legacyMantle.resolve("legacy.bin"), "legacy"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); + Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); + Files.writeString(activationMantle.resolve("activation.bin"), "activation"); + + IOException failure = assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + world, + fingerprint(legacyPack), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + + assertTrue(failure.getMessage().contains("mantle roots conflict")); + assertEquals("legacy", Files.readString(legacyMantle.resolve("legacy.bin"))); + assertEquals("activation", Files.readString(activationMantle.resolve("activation.bin"))); + } + + @Test + public void legacyMantleAdoptionRejectsRootAliases() throws Exception { + Path world = temporaryFolder.newFolder("legacy-aliased-mantle-world").toPath(); + Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); + Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); + Path alias = paths.legacyMantleRoot(); + try { + Files.createSymbolicLink(alias, activationMantle); + } catch (UnsupportedOperationException exception) { + return; + } + + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + world, + fingerprint(legacyPack), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isSymbolicLink(alias)); + assertTrue(Files.isDirectory(activationMantle)); + } + + @Test + public void legacyAdoptionNeverDeletesMismatchedOrUnsafeInput() throws Exception { + Path mismatchWorld = temporaryFolder.newFolder("legacy-mismatch-world").toPath(); + Path mismatchRoot = Files.createDirectories(mismatchWorld.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(mismatchRoot.resolve("dimensions/main.json"), "legacy"); + + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + mismatchWorld, + "f".repeat(64), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isDirectory(mismatchRoot)); + assertFalse(Files.exists(GenerationHistoryPaths.forDimension(mismatchWorld).manifest())); + + Path linkedWorld = temporaryFolder.newFolder("legacy-linked-world").toPath(); + Files.createDirectories(linkedWorld.resolve("iris")); + Path outside = temporaryFolder.newFolder("legacy-outside").toPath(); + Path link = linkedWorld.resolve("iris/pack"); + try { + Files.createSymbolicLink(link, outside); + } catch (UnsupportedOperationException exception) { + return; + } + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + linkedWorld, + "f".repeat(64), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isDirectory(outside)); + assertTrue(Files.isSymbolicLink(link)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java new file mode 100644 index 000000000..1f13bfba8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java @@ -0,0 +1,345 @@ +package art.arcane.iris.engine.history; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.Test; + +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; + +public class GenerationHistoryRecoveryTest extends GenerationHistorySupport { + @Test + public void openFailsClosedOnDanglingOwnershipAndPartialHistory() throws Exception { + Path danglingWorld = temporaryFolder.newFolder("dangling-world").toPath(); + Path pack = createPack("dangling-pack", "alpha"); + GenerationHistory history = createHistory(danglingWorld, pack); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + ownership.assign(1, 2, 99L); + ownership.persist(); + + assertThrows(IOException.class, () -> GenerationHistory.open(danglingWorld)); + + Path absentWorld = temporaryFolder.newFolder("absent-world").toPath(); + Optional absent = GenerationHistory.openIfPresent(absentWorld); + assertTrue(absent.isEmpty()); + + Path partialWorld = temporaryFolder.newFolder("partial-world").toPath(); + Files.createDirectories(GenerationHistoryPaths.forDimension(partialWorld).generationRoot()); + assertThrows(NoSuchFileException.class, () -> GenerationHistory.openIfPresent(partialWorld)); + } + + @Test + public void savedHistoryOpensWithoutRetainingItsExecutableKernel() throws Exception { + Path world = temporaryFolder.newFolder("retained-abi-world").toPath(); + Path packA = createPack("retained-abi-pack-a", "alpha"); + Path packB = createPack("retained-abi-pack-b", "beta"); + GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); + GenerationKernelRegistry kernels = new GenerationKernelRegistry( + versionTwo, + List.of( + new GenerationKernelRegistry.Kernel( + 1, + "1".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("History-only kernel factory was invoked."); + } + ) + ), + new GenerationKernelRegistry.Kernel( + 2, + "2".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("History-only kernel factory was invoked."); + } + ) + ) + ) + ); + GenerationHistory history = GenerationHistory.create( + world, + packA, + fingerprint(packA), + 42L, + contract(), + GenerationRegistryContract.empty(), + versionOne, + kernels + ); + + history.stageUpdate( + packB, + fingerprint(packB), + contract(), + GenerationRegistryContract.empty(), + 256, + versionTwo + ); + history.promotePending(List.of()); + + assertEquals(2, GenerationHistory.open(world).activeEpoch().generatorAbi()); + GenerationHistory reopened = GenerationHistory.open(world, kernels); + assertEquals(2, reopened.activeEpoch().generatorAbi()); + assertEquals(1, reopened.manifest().activation(1L).orElseThrow().activationId()); + } + + @Test + public void changedBuildExpandsFromSavedTerrainWithoutTheOldFactory() throws Exception { + Path world = temporaryFolder.newFolder("build-upgrade-world").toPath(); + Path pack = createPack("build-upgrade-pack", "alpha"); + GenerationKernelRegistry.Version oldVersion = new GenerationKernelRegistry.Version(101, 1, 1); + GenerationKernelRegistry.Version newVersion = new GenerationKernelRegistry.Version(102, 1, 1); + GenerationKernelRegistry firstBuild = singleKernel(oldVersion, "a"); + GenerationKernelRegistry secondBuild = singleKernel(newVersion, "b"); + GenerationHistory.create(world, pack, fingerprint(pack), 42L, + contract(), GenerationRegistryContract.empty(), oldVersion, firstBuild); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + SavedTerrainTestRegion.write(region, new int[][]{{0, 0}}); + byte[] savedBlocks = Files.readAllBytes(region); + + GenerationHistory upgraded = GenerationHistory.open(world, secondBuild); + assertFalse(upgraded.usesCurrentGenerator()); + upgraded.prepareCurrentGenerator(32); + assertTrue(upgraded.usesCurrentGenerator()); + assertEquals(2L, upgraded.activeActivation().activationId()); + assertEquals(oldVersion, upgraded.resolveEpoch(0, 0).kernelVersion()); + try (GenerationHistory.GenerationStage generation = upgraded.openStage(1, 0)) { + assertEquals(newVersion, generation.epoch().kernelVersion()); + } + assertTrue(upgraded.terrainSignatures(2L).size() > 0); + assertArrayEquals(savedBlocks, Files.readAllBytes(region)); + GenerationHistory reopened = GenerationHistory.open(world, secondBuild); + assertTrue(reopened.usesCurrentGenerator()); + assertEquals(2L, reopened.activeActivation().activationId()); + } + + @Test + public void openFailsClosedWhenAnOwnershipShardDisappears() throws Exception { + Path world = temporaryFolder.newFolder("missing-ownership-world").toPath(); + Path packA = createPack("missing-ownership-pack-a", "alpha"); + Path packB = createPack("missing-ownership-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{1, 2}}); + stage(history, packB); + history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); + Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void pendingCutoverResumesAfterPartialOutgoingOwnershipPublication() throws Exception { + Path world = temporaryFolder.newFolder("partial-cutover-world").toPath(); + Path packA = createPack("partial-cutover-pack-a", "alpha"); + Path packB = createPack("partial-cutover-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + int[][] chunks = {{1, 2}, {3, 4}}; + writeRegion(region, chunks); + stage(history, packB); + + ChunkGenerationOwnership partial = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + assertTrue(partial.assign(1, 2, 1L)); + partial.persist(); + + GenerationHistory recovered = GenerationHistory.open(world); + assertEquals(1, recovered.explicitChunkCount()); + assertEquals(2L, recovered.promotePending(signaturesForChunks(chunks)).activationId()); + assertEquals(2, GenerationHistory.open(world).explicitChunkCount()); + } + + @Test + public void pendingCutoverStillRejectsMissingOlderOwnership() throws Exception { + Path world = temporaryFolder.newFolder("pending-missing-old-world").toPath(); + Path packA = createPack("pending-missing-old-pack-a", "alpha"); + Path packB = createPack("pending-missing-old-pack-b", "beta"); + Path packC = createPack("pending-missing-old-pack-c", "gamma"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{1, 2}}); + stage(history, packB); + history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); + stage(history, packC); + Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void pendingCutoverDiscardsOutgoingOwnershipForUnstoredChunks() throws Exception { + Path world = temporaryFolder.newFolder("pending-forged-world").toPath(); + Path packA = createPack("pending-forged-pack-a", "alpha"); + Path packB = createPack("pending-forged-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + stage(history, packB); + ChunkGenerationOwnership forged = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + assertTrue(forged.assign(9, 9, 1L)); + forged.persist(); + + GenerationHistory recovered = GenerationHistory.open(world); + assertEquals(1, recovered.explicitChunkCount()); + recovered.promotePending(List.of()); + assertEquals(0, recovered.explicitChunkCount()); + assertEquals(2L, recovered.resolveActivation(9, 9).activationId()); + assertEquals(0, GenerationHistory.open(world).explicitChunkCount()); + } + + @Test + public void sealedGenerationClaimWithoutStoredTerrainIsDiscardedAfterCrash() throws Exception { + Path world = temporaryFolder.newFolder("semantic-claim-cutover-world").toPath(); + Path packA = createPack("semantic-claim-cutover-pack-a", "alpha"); + Path packB = createPack("semantic-claim-cutover-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + GenerationHistory.GenerationStage generation = history.openStage(9, 9); + ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(9, 9, 1L) + .addSurfaceBiome("iris:forest") + .seal() + .build(); + + assertTrue(history.claimGeneratedSemantics(generation, claim)); + assertFalse(history.claimGeneratedSemantics(generation, claim)); + generation.close(); + assertThrows(IllegalStateException.class, () -> history.claimGeneratedSemantics(generation, claim)); + assertFalse(Files.exists(world.resolve("region"))); + stage(history, packB); + + Path recoveredWorld = temporaryFolder.newFolder("restored-semantic-claim-world").toPath(); + try (Stream entries = Files.walk(world)) { + for (Path source : entries.toList()) { + Path target = recoveredWorld.resolve(world.relativize(source)); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.copy(source, target); + } + } + } + GenerationHistory recovered = GenerationHistory.open(recoveredWorld); + recovered.promotePending(List.of()); + + assertEquals(2L, recovered.resolveActivation(9, 9).activationId()); + assertEquals(2L, recovered.resolveActivation(10, 9).activationId()); + assertEquals(0, recovered.explicitChunkCount()); + GenerationHistory reopened = GenerationHistory.open(recoveredWorld); + assertTrue(reopened.semantics(9, 9).isEmpty()); + assertEquals(2L, reopened.resolveActivation(9, 9).activationId()); + try (GenerationHistory.GenerationStage replacement = reopened.openStage(9, 9)) { + assertTrue(reopened.claimGeneratedSemantics(replacement, + ChunkGenerationSemantics.builder(9, 9, 2L).addObject("replacement").seal().build())); + } + } + + @Test + public void coldRecoveryUsesNativeNoiseStatusAsTheMinimumStoredTerrain() throws Exception { + Path world = temporaryFolder.newFolder("native-status-recovery-world").toPath(); + createHistory(world, createPack("native-status-recovery-pack", "alpha")); + Path regions = Files.createDirectories(world.resolve("region")); + SavedTerrainTestRegion.write(regions.resolve("r.0.0.mca"), new int[][]{{0, 0}}, "minecraft:noise"); + SavedTerrainTestRegion.write(regions.resolve("r.1.0.mca"), new int[][]{{32, 0}}, "minecraft:biomes"); + GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); + index.claimAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addObject("saved-noise").seal().build()); + index.claimAndPersist(ChunkGenerationSemantics.builder(32, 0, 1L).addObject("unsaved-terrain").seal().build()); + + GenerationHistory recovered = GenerationHistory.open(world); + recovered.prepareCurrentGenerator(64); + + assertTrue(recovered.semantics(0, 0).isPresent()); + assertTrue(recovered.semantics(32, 0).isEmpty()); + assertEquals("minecraft:biomes", SavedTerrainChunk.readStatus(world, 32, 0)); + assertEquals(0, recovered.explicitChunkCount()); + GenerationHistory reopened = GenerationHistory.open(world); + assertTrue(reopened.semantics(0, 0).isPresent()); + assertTrue(reopened.semantics(32, 0).isEmpty()); + } + + @Test + public void coldRecoveryKeepsStoredClaimsHistoricalMetadataAndFrozenPacks() throws Exception { + Path world = temporaryFolder.newFolder("cold-recovery-world").toPath(); + Path packA = createPack("cold-recovery-a", "alpha"); + Path packB = createPack("cold-recovery-b", "beta"); + GenerationHistory initial = createHistory(world, packA); + String oldEpoch = initial.activeEpoch().epochId(); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{0, 0}}); + GenerationSemanticIndex initialIndex = GenerationSemanticIndex.loadRequired(world); + initialIndex.claimAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addObject("historical").seal().build()); + GenerationHistory first = GenerationHistory.open(world); + stage(first, packB); + first.promotePending(signaturesForChunks(new int[][]{{0, 0}})); + GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); + index.claimAndPersist(ChunkGenerationSemantics.builder(1, 0, 2L).addObject("saved").seal().build()); + index.claimAndPersist(ChunkGenerationSemantics.builder(2, 0, 2L).addObject("orphan").seal().build()); + writeRegion(region, new int[][]{{1, 0}}); + + GenerationHistory recovered = GenerationHistory.open(world); + recovered.prepareCurrentGenerator(64); + + assertEquals(2L, recovered.activeActivation().activationId()); + assertEquals(1, recovered.explicitChunkCount()); + assertTrue(recovered.semantics(0, 0).isPresent()); + assertTrue(recovered.semantics(1, 0).isPresent()); + assertTrue(recovered.semantics(2, 0).isEmpty()); + assertTrue(Files.isDirectory(recovered.paths().packRoot(oldEpoch))); + assertEquals(GenerationPackFingerprint.compute(packA, GenerationPackFingerprint.CURRENT_VERSION), + GenerationPackFingerprint.compute(recovered.paths().packRoot(oldEpoch), GenerationPackFingerprint.CURRENT_VERSION)); + assertTrue(Files.isDirectory(recovered.activePackRoot())); + assertTrue(Files.isDirectory(packA)); + GenerationHistory reopened = GenerationHistory.open(world); + assertEquals(1L, reopened.resolveActivation(0, 0).activationId()); + assertTrue(reopened.semantics(2, 0).isEmpty()); + } + + @Test + public void openFailsClosedOnSemanticActivationMismatch() throws Exception { + Path world = temporaryFolder.newFolder("semantic-mismatch-world").toPath(); + Path packA = createPack("semantic-mismatch-pack-a", "alpha"); + Path packB = createPack("semantic-mismatch-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + stage(history, packB); + history.promotePending(List.of()); + GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); + index.recordAndPersist(ChunkGenerationSemantics.builder(7, 8, 1L) + .addSurfaceBiome("iris:old") + .seal() + .build()); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void openFailsClosedOnDeletedOrCorruptTransitionSnapshots() throws Exception { + Path deletedWorld = temporaryFolder.newFolder("deleted-snapshot-world").toPath(); + Path deletedPackA = createPack("deleted-snapshot-pack-a", "alpha"); + Path deletedPackB = createPack("deleted-snapshot-pack-b", "beta"); + GenerationHistory deletedHistory = createHistory(deletedWorld, deletedPackA); + stage(deletedHistory, deletedPackB); + deletedHistory.promotePending(List.of()); + Files.delete(new TerrainBoundarySignatureStore(deletedWorld).snapshotPath(2L)); + + assertThrows(IOException.class, () -> GenerationHistory.open(deletedWorld)); + + Path corruptWorld = temporaryFolder.newFolder("corrupt-snapshot-world").toPath(); + Path corruptPackA = createPack("corrupt-snapshot-pack-a", "alpha"); + Path corruptPackB = createPack("corrupt-snapshot-pack-b", "beta"); + GenerationHistory corruptHistory = createHistory(corruptWorld, corruptPackA); + stage(corruptHistory, corruptPackB); + corruptHistory.promotePending(List.of()); + Files.write(new GenerationBoundaryStore(corruptWorld).snapshotPath(2L), new byte[]{1, 2, 3}); + + assertThrows(IOException.class, () -> GenerationHistory.open(corruptWorld)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java index 93e4e3e4b..56b43f93c 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -9,10 +9,12 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.framework.GenerationSessionManager; import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.iris.util.common.data.B; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.matter.Matter; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.BeforeClass; import org.junit.Test; @@ -57,6 +59,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public final class GenerationHistoryRuntimeRouterTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java index 98f0286d3..47a5a0673 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.testsupport.DurabilityMode; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.junit.Assume; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -31,6 +33,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class GenerationHistoryStoreTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(123456789L), ZoneOffset.UTC); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java new file mode 100644 index 000000000..56bfa8d2a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java @@ -0,0 +1,145 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.testsupport.DurabilityMode; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; +import java.util.stream.Stream; + +public abstract class GenerationHistorySupport { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + static GenerationKernelRegistry singleKernel(GenerationKernelRegistry.Version version, String fingerprint) { + return new GenerationKernelRegistry(version, List.of(new GenerationKernelRegistry.Kernel( + version.generatorAbi(), fingerprint.repeat(64), + Map.of(new GenerationKernelRegistry.AlgorithmVersion(version.rngVersion(), version.seedDerivationVersion()), + (engine, transition) -> { + throw new AssertionError("Saved terrain capture invoked a generator factory"); + })))); + } + void initializeRawHistory( + Path world, + Path pack, + int generatorAbi, + int rngVersion + ) throws IOException { + String fingerprint = fingerprint(pack); + GenerationEpoch epoch = GenerationEpoch.create(new GenerationEpoch.Spec( + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + 42L, + GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION, + generatorAbi, + rngVersion, + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, + contract(), + GenerationRegistryContract.empty() + )); + GenerationPackRepository repository = new GenerationPackRepository(world); + repository.publish( + epoch.epochId(), + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + pack + ); + GenerationSemanticIndex.initialize(world); + GenerationHistoryStore.initialize(repository.generationRoot(), epoch); + } + Path createPack(String name, String content) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), content); + return pack; + } + static String fingerprint(Path pack) throws IOException { + return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + } + static GenerationEpoch.DimensionContract upperContract(String packFingerprint) { + GenerationEpoch.DimensionContract base = contract(); + return new GenerationEpoch.DimensionContract( + base.dimensionKey(), base.dimensionTypeKey(), base.environment(), base.generationMode(), + base.internalFluidHeight(), base.minHeight(), base.height(), base.logicalHeight(), + base.coordinateScale(), true, "ceiling", 32, packFingerprint, + base.dimensionTypeFingerprintSchema(), base.dimensionTypeFingerprint()); + } + static GenerationEpoch.DimensionContract contract() { + return new GenerationEpoch.DimensionContract( + "overworld", + "iris:overworld_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + } + static GenerationHistory createHistory(Path world, Path pack) throws IOException { + return GenerationHistory.create( + world, + pack, + fingerprint(pack), + 42L, + contract(), + GenerationRegistryContract.empty() + ); + } + static GenerationActivation stage(GenerationHistory history, Path pack) throws IOException { + return history.stageUpdate( + pack, + fingerprint(pack), + contract(), + GenerationRegistryContract.empty(), + 256 + ); + } + static List signaturesForChunks(int[][] chunks) { + ArrayList coordinates = new ArrayList<>(chunks.length); + for (int[] chunk : chunks) { + coordinates.add(new GenerationBoundary.ChunkCoordinate(chunk[0], chunk[1])); + } + GenerationBoundary boundary = GenerationBoundary.freeze("test-boundary", coordinates); + ArrayList signatures = new ArrayList<>(boundary.exposedBlockColumns().size()); + for (GenerationBoundary.BlockColumn column : boundary.exposedBlockColumns()) { + signatures.add(signature(column.blockX(), column.blockZ())); + } + return List.copyOf(signatures); + } + static TerrainBoundarySignature signature(int blockX, int blockZ) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 64, 2), + new TerrainBoundarySignature.BiomeEncoding( + List.of("iris:test"), + new short[]{0, 0} + ) + ) + , BoundaryColumnGeometry.empty()); + } + static void writeRegion(Path file, int[][] chunks) throws IOException { + SavedTerrainTestRegion.write(file, chunks); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java index c39aec7aa..6e70d4723 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java @@ -1,31 +1,17 @@ package art.arcane.iris.engine.history; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - import java.io.IOException; import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.Set; -import java.util.stream.Stream; +import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -public final class GenerationHistoryTest { - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - +public class GenerationHistoryTest extends GenerationHistorySupport { @Test public void upperContentUpdatesPreserveLayoutAndDistinctActivationsAcrossRestarts() throws Exception { Path world = temporaryFolder.newFolder("upper-update-world").toPath(); @@ -235,159 +221,6 @@ public final class GenerationHistoryTest { ); } - @Test - public void legacyAdoptionIsDurableExplicitAndIdempotent() throws Exception { - Path world = temporaryFolder.newFolder("legacy-world").toPath(); - Path legacy = Files.createDirectories(world.resolve("iris/pack/dimensions")); - Files.writeString(legacy.resolve("main.json"), "legacy"); - Path legacyRoot = world.resolve("iris/pack"); - Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); - Files.writeString(legacyMantle.resolve("r.0.0.lz4b"), "mantle"); - String fingerprint = fingerprint(legacyRoot); - - GenerationHistory adopted = GenerationHistory.adoptLegacyPack( - world, - fingerprint, - 42L, - contract(), - GenerationRegistryContract.empty() - ); - - assertFalse(Files.exists(legacyRoot)); - assertFalse(Files.exists(legacyMantle)); - assertTrue(Files.isDirectory(adopted.activePackRoot())); - Path activationMantle = adopted.paths().activationMantleRoot(1L); - assertEquals("mantle", Files.readString(activationMantle.resolve("r.0.0.lz4b"))); - assertEquals(1L, adopted.activeActivation().activationId()); - GenerationHistory repeated = GenerationHistory.adoptLegacyPack( - world, - fingerprint, - 42L, - contract(), - GenerationRegistryContract.empty() - ); - assertEquals(adopted.activeEpoch(), repeated.activeEpoch()); - assertEquals("mantle", Files.readString(repeated.paths().activationMantleRoot(1L) - .resolve("r.0.0.lz4b"))); - } - - @Test - public void legacyMantleAdoptionFailsClosedOnDualRoots() throws Exception { - Path world = temporaryFolder.newFolder("legacy-dual-mantle-world").toPath(); - Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); - Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); - Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); - Files.writeString(legacyMantle.resolve("legacy.bin"), "legacy"); - GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); - Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); - Files.writeString(activationMantle.resolve("activation.bin"), "activation"); - - IOException failure = assertThrows( - IOException.class, - () -> GenerationHistory.adoptLegacyPack( - world, - fingerprint(legacyPack), - 42L, - contract(), - GenerationRegistryContract.empty() - ) - ); - - assertTrue(failure.getMessage().contains("mantle roots conflict")); - assertEquals("legacy", Files.readString(legacyMantle.resolve("legacy.bin"))); - assertEquals("activation", Files.readString(activationMantle.resolve("activation.bin"))); - } - - @Test - public void legacyMantleAdoptionRejectsRootAliases() throws Exception { - Path world = temporaryFolder.newFolder("legacy-aliased-mantle-world").toPath(); - Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); - Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); - GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); - Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); - Path alias = paths.legacyMantleRoot(); - try { - Files.createSymbolicLink(alias, activationMantle); - } catch (UnsupportedOperationException exception) { - return; - } - - assertThrows( - IOException.class, - () -> GenerationHistory.adoptLegacyPack( - world, - fingerprint(legacyPack), - 42L, - contract(), - GenerationRegistryContract.empty() - ) - ); - assertTrue(Files.isSymbolicLink(alias)); - assertTrue(Files.isDirectory(activationMantle)); - } - - @Test - public void legacyAdoptionNeverDeletesMismatchedOrUnsafeInput() throws Exception { - Path mismatchWorld = temporaryFolder.newFolder("legacy-mismatch-world").toPath(); - Path mismatchRoot = Files.createDirectories(mismatchWorld.resolve("iris/pack/dimensions")).getParent(); - Files.writeString(mismatchRoot.resolve("dimensions/main.json"), "legacy"); - - assertThrows( - IOException.class, - () -> GenerationHistory.adoptLegacyPack( - mismatchWorld, - "f".repeat(64), - 42L, - contract(), - GenerationRegistryContract.empty() - ) - ); - assertTrue(Files.isDirectory(mismatchRoot)); - assertFalse(Files.exists(GenerationHistoryPaths.forDimension(mismatchWorld).manifest())); - - Path linkedWorld = temporaryFolder.newFolder("legacy-linked-world").toPath(); - Files.createDirectories(linkedWorld.resolve("iris")); - Path outside = temporaryFolder.newFolder("legacy-outside").toPath(); - Path link = linkedWorld.resolve("iris/pack"); - try { - Files.createSymbolicLink(link, outside); - } catch (UnsupportedOperationException exception) { - return; - } - assertThrows( - IOException.class, - () -> GenerationHistory.adoptLegacyPack( - linkedWorld, - "f".repeat(64), - 42L, - contract(), - GenerationRegistryContract.empty() - ) - ); - assertTrue(Files.isDirectory(outside)); - assertTrue(Files.isSymbolicLink(link)); - } - - @Test - public void openFailsClosedOnDanglingOwnershipAndPartialHistory() throws Exception { - Path danglingWorld = temporaryFolder.newFolder("dangling-world").toPath(); - Path pack = createPack("dangling-pack", "alpha"); - GenerationHistory history = createHistory(danglingWorld, pack); - ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); - ownership.assign(1, 2, 99L); - ownership.persist(); - - assertThrows(IOException.class, () -> GenerationHistory.open(danglingWorld)); - - Path absentWorld = temporaryFolder.newFolder("absent-world").toPath(); - Optional absent = GenerationHistory.openIfPresent(absentWorld); - assertTrue(absent.isEmpty()); - - Path partialWorld = temporaryFolder.newFolder("partial-world").toPath(); - Files.createDirectories(GenerationHistoryPaths.forDimension(partialWorld).generationRoot()); - assertThrows(NoSuchFileException.class, () -> GenerationHistory.openIfPresent(partialWorld)); - } - @Test public void stagingTheActiveEpochIsANoOp() throws Exception { Path world = temporaryFolder.newFolder("same-epoch-world").toPath(); @@ -420,104 +253,6 @@ public final class GenerationHistoryTest { assertEquals(3, history.manifest().activations().size()); } - @Test - public void savedHistoryOpensWithoutRetainingItsExecutableKernel() throws Exception { - Path world = temporaryFolder.newFolder("retained-abi-world").toPath(); - Path packA = createPack("retained-abi-pack-a", "alpha"); - Path packB = createPack("retained-abi-pack-b", "beta"); - GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); - GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); - GenerationKernelRegistry kernels = new GenerationKernelRegistry( - versionTwo, - List.of( - new GenerationKernelRegistry.Kernel( - 1, - "1".repeat(64), - Map.of( - new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { - throw new AssertionError("History-only kernel factory was invoked."); - } - ) - ), - new GenerationKernelRegistry.Kernel( - 2, - "2".repeat(64), - Map.of( - new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { - throw new AssertionError("History-only kernel factory was invoked."); - } - ) - ) - ) - ); - GenerationHistory history = GenerationHistory.create( - world, - packA, - fingerprint(packA), - 42L, - contract(), - GenerationRegistryContract.empty(), - versionOne, - kernels - ); - - history.stageUpdate( - packB, - fingerprint(packB), - contract(), - GenerationRegistryContract.empty(), - 256, - versionTwo - ); - history.promotePending(List.of()); - - assertEquals(2, GenerationHistory.open(world).activeEpoch().generatorAbi()); - GenerationHistory reopened = GenerationHistory.open(world, kernels); - assertEquals(2, reopened.activeEpoch().generatorAbi()); - assertEquals(1, reopened.manifest().activation(1L).orElseThrow().activationId()); - } - - @Test - public void changedBuildExpandsFromSavedTerrainWithoutTheOldFactory() throws Exception { - Path world = temporaryFolder.newFolder("build-upgrade-world").toPath(); - Path pack = createPack("build-upgrade-pack", "alpha"); - GenerationKernelRegistry.Version oldVersion = new GenerationKernelRegistry.Version(101, 1, 1); - GenerationKernelRegistry.Version newVersion = new GenerationKernelRegistry.Version(102, 1, 1); - GenerationKernelRegistry firstBuild = singleKernel(oldVersion, "a"); - GenerationKernelRegistry secondBuild = singleKernel(newVersion, "b"); - GenerationHistory.create(world, pack, fingerprint(pack), 42L, - contract(), GenerationRegistryContract.empty(), oldVersion, firstBuild); - Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); - SavedTerrainTestRegion.write(region, new int[][]{{0, 0}}); - byte[] savedBlocks = Files.readAllBytes(region); - - GenerationHistory upgraded = GenerationHistory.open(world, secondBuild); - assertFalse(upgraded.usesCurrentGenerator()); - upgraded.prepareCurrentGenerator(32); - assertTrue(upgraded.usesCurrentGenerator()); - assertEquals(2L, upgraded.activeActivation().activationId()); - assertEquals(oldVersion, upgraded.resolveEpoch(0, 0).kernelVersion()); - try (GenerationHistory.GenerationStage generation = upgraded.openStage(1, 0)) { - assertEquals(newVersion, generation.epoch().kernelVersion()); - } - assertTrue(upgraded.terrainSignatures(2L).size() > 0); - assertArrayEquals(savedBlocks, Files.readAllBytes(region)); - GenerationHistory reopened = GenerationHistory.open(world, secondBuild); - assertTrue(reopened.usesCurrentGenerator()); - assertEquals(2L, reopened.activeActivation().activationId()); - } - - private static GenerationKernelRegistry singleKernel(GenerationKernelRegistry.Version version, String fingerprint) { - return new GenerationKernelRegistry(version, List.of(new GenerationKernelRegistry.Kernel( - version.generatorAbi(), fingerprint.repeat(64), - Map.of(new GenerationKernelRegistry.AlgorithmVersion(version.rngVersion(), version.seedDerivationVersion()), - (engine, transition) -> { - throw new AssertionError("Saved terrain capture invoked a generator factory"); - })))); - } - @Test public void openRejectsWorldSeedMismatch() throws Exception { Path world = temporaryFolder.newFolder("seed-world").toPath(); @@ -528,200 +263,6 @@ public final class GenerationHistoryTest { assertEquals(42L, GenerationHistory.open(world, 42L).activeEpoch().worldSeed()); } - @Test - public void openFailsClosedWhenAnOwnershipShardDisappears() throws Exception { - Path world = temporaryFolder.newFolder("missing-ownership-world").toPath(); - Path packA = createPack("missing-ownership-pack-a", "alpha"); - Path packB = createPack("missing-ownership-pack-b", "beta"); - GenerationHistory history = createHistory(world, packA); - Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); - writeRegion(region, new int[][]{{1, 2}}); - stage(history, packB); - history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); - Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); - - assertThrows(IOException.class, () -> GenerationHistory.open(world)); - } - - @Test - public void pendingCutoverResumesAfterPartialOutgoingOwnershipPublication() throws Exception { - Path world = temporaryFolder.newFolder("partial-cutover-world").toPath(); - Path packA = createPack("partial-cutover-pack-a", "alpha"); - Path packB = createPack("partial-cutover-pack-b", "beta"); - GenerationHistory history = createHistory(world, packA); - Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); - int[][] chunks = {{1, 2}, {3, 4}}; - writeRegion(region, chunks); - stage(history, packB); - - ChunkGenerationOwnership partial = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); - assertTrue(partial.assign(1, 2, 1L)); - partial.persist(); - - GenerationHistory recovered = GenerationHistory.open(world); - assertEquals(1, recovered.explicitChunkCount()); - assertEquals(2L, recovered.promotePending(signaturesForChunks(chunks)).activationId()); - assertEquals(2, GenerationHistory.open(world).explicitChunkCount()); - } - - @Test - public void pendingCutoverStillRejectsMissingOlderOwnership() throws Exception { - Path world = temporaryFolder.newFolder("pending-missing-old-world").toPath(); - Path packA = createPack("pending-missing-old-pack-a", "alpha"); - Path packB = createPack("pending-missing-old-pack-b", "beta"); - Path packC = createPack("pending-missing-old-pack-c", "gamma"); - GenerationHistory history = createHistory(world, packA); - Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); - writeRegion(region, new int[][]{{1, 2}}); - stage(history, packB); - history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); - stage(history, packC); - Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); - - assertThrows(IOException.class, () -> GenerationHistory.open(world)); - } - - @Test - public void pendingCutoverDiscardsOutgoingOwnershipForUnstoredChunks() throws Exception { - Path world = temporaryFolder.newFolder("pending-forged-world").toPath(); - Path packA = createPack("pending-forged-pack-a", "alpha"); - Path packB = createPack("pending-forged-pack-b", "beta"); - GenerationHistory history = createHistory(world, packA); - stage(history, packB); - ChunkGenerationOwnership forged = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); - assertTrue(forged.assign(9, 9, 1L)); - forged.persist(); - - GenerationHistory recovered = GenerationHistory.open(world); - assertEquals(1, recovered.explicitChunkCount()); - recovered.promotePending(List.of()); - assertEquals(0, recovered.explicitChunkCount()); - assertEquals(2L, recovered.resolveActivation(9, 9).activationId()); - assertEquals(0, GenerationHistory.open(world).explicitChunkCount()); - } - - @Test - public void sealedGenerationClaimWithoutStoredTerrainIsDiscardedAfterCrash() throws Exception { - Path world = temporaryFolder.newFolder("semantic-claim-cutover-world").toPath(); - Path packA = createPack("semantic-claim-cutover-pack-a", "alpha"); - Path packB = createPack("semantic-claim-cutover-pack-b", "beta"); - GenerationHistory history = createHistory(world, packA); - GenerationHistory.GenerationStage generation = history.openStage(9, 9); - ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(9, 9, 1L) - .addSurfaceBiome("iris:forest") - .seal() - .build(); - - assertTrue(history.claimGeneratedSemantics(generation, claim)); - assertFalse(history.claimGeneratedSemantics(generation, claim)); - generation.close(); - assertThrows(IllegalStateException.class, () -> history.claimGeneratedSemantics(generation, claim)); - assertFalse(Files.exists(world.resolve("region"))); - stage(history, packB); - - Path recoveredWorld = temporaryFolder.newFolder("restored-semantic-claim-world").toPath(); - try (Stream entries = Files.walk(world)) { - for (Path source : entries.toList()) { - Path target = recoveredWorld.resolve(world.relativize(source)); - if (Files.isDirectory(source)) { - Files.createDirectories(target); - } else { - Files.copy(source, target); - } - } - } - GenerationHistory recovered = GenerationHistory.open(recoveredWorld); - recovered.promotePending(List.of()); - - assertEquals(2L, recovered.resolveActivation(9, 9).activationId()); - assertEquals(2L, recovered.resolveActivation(10, 9).activationId()); - assertEquals(0, recovered.explicitChunkCount()); - GenerationHistory reopened = GenerationHistory.open(recoveredWorld); - assertTrue(reopened.semantics(9, 9).isEmpty()); - assertEquals(2L, reopened.resolveActivation(9, 9).activationId()); - try (GenerationHistory.GenerationStage replacement = reopened.openStage(9, 9)) { - assertTrue(reopened.claimGeneratedSemantics(replacement, - ChunkGenerationSemantics.builder(9, 9, 2L).addObject("replacement").seal().build())); - } - } - - @Test - public void coldRecoveryUsesNativeNoiseStatusAsTheMinimumStoredTerrain() throws Exception { - Path world = temporaryFolder.newFolder("native-status-recovery-world").toPath(); - createHistory(world, createPack("native-status-recovery-pack", "alpha")); - Path regions = Files.createDirectories(world.resolve("region")); - SavedTerrainTestRegion.write(regions.resolve("r.0.0.mca"), new int[][]{{0, 0}}, "minecraft:noise"); - SavedTerrainTestRegion.write(regions.resolve("r.1.0.mca"), new int[][]{{32, 0}}, "minecraft:biomes"); - GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); - index.claimAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addObject("saved-noise").seal().build()); - index.claimAndPersist(ChunkGenerationSemantics.builder(32, 0, 1L).addObject("unsaved-terrain").seal().build()); - - GenerationHistory recovered = GenerationHistory.open(world); - recovered.prepareCurrentGenerator(64); - - assertTrue(recovered.semantics(0, 0).isPresent()); - assertTrue(recovered.semantics(32, 0).isEmpty()); - assertEquals("minecraft:biomes", SavedTerrainChunk.readStatus(world, 32, 0)); - assertEquals(0, recovered.explicitChunkCount()); - GenerationHistory reopened = GenerationHistory.open(world); - assertTrue(reopened.semantics(0, 0).isPresent()); - assertTrue(reopened.semantics(32, 0).isEmpty()); - } - - @Test - public void coldRecoveryKeepsStoredClaimsHistoricalMetadataAndFrozenPacks() throws Exception { - Path world = temporaryFolder.newFolder("cold-recovery-world").toPath(); - Path packA = createPack("cold-recovery-a", "alpha"); - Path packB = createPack("cold-recovery-b", "beta"); - GenerationHistory initial = createHistory(world, packA); - String oldEpoch = initial.activeEpoch().epochId(); - Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); - writeRegion(region, new int[][]{{0, 0}}); - GenerationSemanticIndex initialIndex = GenerationSemanticIndex.loadRequired(world); - initialIndex.claimAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addObject("historical").seal().build()); - GenerationHistory first = GenerationHistory.open(world); - stage(first, packB); - first.promotePending(signaturesForChunks(new int[][]{{0, 0}})); - GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); - index.claimAndPersist(ChunkGenerationSemantics.builder(1, 0, 2L).addObject("saved").seal().build()); - index.claimAndPersist(ChunkGenerationSemantics.builder(2, 0, 2L).addObject("orphan").seal().build()); - writeRegion(region, new int[][]{{1, 0}}); - - GenerationHistory recovered = GenerationHistory.open(world); - recovered.prepareCurrentGenerator(64); - - assertEquals(2L, recovered.activeActivation().activationId()); - assertEquals(1, recovered.explicitChunkCount()); - assertTrue(recovered.semantics(0, 0).isPresent()); - assertTrue(recovered.semantics(1, 0).isPresent()); - assertTrue(recovered.semantics(2, 0).isEmpty()); - assertTrue(Files.isDirectory(recovered.paths().packRoot(oldEpoch))); - assertEquals(GenerationPackFingerprint.compute(packA, GenerationPackFingerprint.CURRENT_VERSION), - GenerationPackFingerprint.compute(recovered.paths().packRoot(oldEpoch), GenerationPackFingerprint.CURRENT_VERSION)); - assertTrue(Files.isDirectory(recovered.activePackRoot())); - assertTrue(Files.isDirectory(packA)); - GenerationHistory reopened = GenerationHistory.open(world); - assertEquals(1L, reopened.resolveActivation(0, 0).activationId()); - assertTrue(reopened.semantics(2, 0).isEmpty()); - } - - @Test - public void openFailsClosedOnSemanticActivationMismatch() throws Exception { - Path world = temporaryFolder.newFolder("semantic-mismatch-world").toPath(); - Path packA = createPack("semantic-mismatch-pack-a", "alpha"); - Path packB = createPack("semantic-mismatch-pack-b", "beta"); - GenerationHistory history = createHistory(world, packA); - stage(history, packB); - history.promotePending(List.of()); - GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); - index.recordAndPersist(ChunkGenerationSemantics.builder(7, 8, 1L) - .addSurfaceBiome("iris:old") - .seal() - .build()); - - assertThrows(IOException.class, () -> GenerationHistory.open(world)); - } - @Test public void promotionIsRejectedAfterGenerationAdmissionOpens() throws Exception { Path world = temporaryFolder.newFolder("admission-world").toPath(); @@ -739,147 +280,4 @@ public final class GenerationHistoryTest { generationStage.close(); assertThrows(IllegalStateException.class, () -> history.promotePending(List.of())); } - - @Test - public void openFailsClosedOnDeletedOrCorruptTransitionSnapshots() throws Exception { - Path deletedWorld = temporaryFolder.newFolder("deleted-snapshot-world").toPath(); - Path deletedPackA = createPack("deleted-snapshot-pack-a", "alpha"); - Path deletedPackB = createPack("deleted-snapshot-pack-b", "beta"); - GenerationHistory deletedHistory = createHistory(deletedWorld, deletedPackA); - stage(deletedHistory, deletedPackB); - deletedHistory.promotePending(List.of()); - Files.delete(new TerrainBoundarySignatureStore(deletedWorld).snapshotPath(2L)); - - assertThrows(IOException.class, () -> GenerationHistory.open(deletedWorld)); - - Path corruptWorld = temporaryFolder.newFolder("corrupt-snapshot-world").toPath(); - Path corruptPackA = createPack("corrupt-snapshot-pack-a", "alpha"); - Path corruptPackB = createPack("corrupt-snapshot-pack-b", "beta"); - GenerationHistory corruptHistory = createHistory(corruptWorld, corruptPackA); - stage(corruptHistory, corruptPackB); - corruptHistory.promotePending(List.of()); - Files.write(new GenerationBoundaryStore(corruptWorld).snapshotPath(2L), new byte[]{1, 2, 3}); - - assertThrows(IOException.class, () -> GenerationHistory.open(corruptWorld)); - } - - private void initializeRawHistory( - Path world, - Path pack, - int generatorAbi, - int rngVersion - ) throws IOException { - String fingerprint = fingerprint(pack); - GenerationEpoch epoch = GenerationEpoch.create(new GenerationEpoch.Spec( - fingerprint, - GenerationPackFingerprint.CURRENT_VERSION, - 42L, - GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION, - generatorAbi, - rngVersion, - GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, - contract(), - GenerationRegistryContract.empty() - )); - GenerationPackRepository repository = new GenerationPackRepository(world); - repository.publish( - epoch.epochId(), - fingerprint, - GenerationPackFingerprint.CURRENT_VERSION, - pack - ); - GenerationSemanticIndex.initialize(world); - GenerationHistoryStore.initialize(repository.generationRoot(), epoch); - } - - private Path createPack(String name, String content) throws IOException { - Path pack = temporaryFolder.newFolder(name).toPath(); - Files.createDirectories(pack.resolve("dimensions")); - Files.writeString(pack.resolve("dimensions/main.json"), content); - return pack; - } - - private static String fingerprint(Path pack) throws IOException { - return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); - } - - private static GenerationEpoch.DimensionContract upperContract(String packFingerprint) { - GenerationEpoch.DimensionContract base = contract(); - return new GenerationEpoch.DimensionContract( - base.dimensionKey(), base.dimensionTypeKey(), base.environment(), base.generationMode(), - base.internalFluidHeight(), base.minHeight(), base.height(), base.logicalHeight(), - base.coordinateScale(), true, "ceiling", 32, packFingerprint, - base.dimensionTypeFingerprintSchema(), base.dimensionTypeFingerprint()); - } - - private static GenerationEpoch.DimensionContract contract() { - return new GenerationEpoch.DimensionContract( - "overworld", - "iris:overworld_type", - "NORMAL", - "OVERWORLD", - 127, - -64, - 384, - 384, - 1D, - false, - "none", - 0, - "0".repeat(64), - GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - ); - } - - private static GenerationHistory createHistory(Path world, Path pack) throws IOException { - return GenerationHistory.create( - world, - pack, - fingerprint(pack), - 42L, - contract(), - GenerationRegistryContract.empty() - ); - } - - private static GenerationActivation stage(GenerationHistory history, Path pack) throws IOException { - return history.stageUpdate( - pack, - fingerprint(pack), - contract(), - GenerationRegistryContract.empty(), - 256 - ); - } - - private static List signaturesForChunks(int[][] chunks) { - ArrayList coordinates = new ArrayList<>(chunks.length); - for (int[] chunk : chunks) { - coordinates.add(new GenerationBoundary.ChunkCoordinate(chunk[0], chunk[1])); - } - GenerationBoundary boundary = GenerationBoundary.freeze("test-boundary", coordinates); - ArrayList signatures = new ArrayList<>(boundary.exposedBlockColumns().size()); - for (GenerationBoundary.BlockColumn column : boundary.exposedBlockColumns()) { - signatures.add(signature(column.blockX(), column.blockZ())); - } - return List.copyOf(signatures); - } - - private static TerrainBoundarySignature signature(int blockX, int blockZ) { - return new TerrainBoundarySignature( - new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), - new TerrainBoundarySignature.Samples( - new TerrainBoundarySignature.VerticalLayout(-64, 64, 2), - new TerrainBoundarySignature.BiomeEncoding( - List.of("iris:test"), - new short[]{0, 0} - ) - ) - , BoundaryColumnGeometry.empty()); - } - - private static void writeRegion(Path file, int[][] chunks) throws IOException { - SavedTerrainTestRegion.write(file, chunks); - } } diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java index d7a8bea96..b78e1b66f 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java @@ -1,7 +1,9 @@ package art.arcane.iris.engine.history; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.testsupport.DurabilityMode; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -26,6 +28,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public final class GenerationSemanticIndexTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java b/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java index 17c98daaa..fbb04b251 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.testsupport.DurabilityMode; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -20,6 +22,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class TransitionGenerationPlanTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java index 7c396a761..1246cb52d 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.testsupport.DurabilityMode; import art.arcane.iris.util.nbt.common.mca.MCAFile; +import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -22,6 +24,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public final class WorldChunkInventoryTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + private static final int SECTOR_BYTES = 4_096; @Rule diff --git a/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java b/core/src/test/java/art/arcane/iris/engine/object/PackCompatFixtures.java similarity index 100% rename from core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java rename to core/src/test/java/art/arcane/iris/engine/object/PackCompatFixtures.java diff --git a/core/src/test/java/art/arcane/iris/testsupport/DurabilityMode.java b/core/src/test/java/art/arcane/iris/testsupport/DurabilityMode.java new file mode 100644 index 000000000..249f7a2b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/testsupport/DurabilityMode.java @@ -0,0 +1,41 @@ +package art.arcane.iris.testsupport; + +import art.arcane.iris.util.common.io.Durability; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +public final class DurabilityMode implements TestRule { + private static final String RELAXED = "relaxed"; + + private final String mode; + + private DurabilityMode(String mode) { + this.mode = mode; + } + + public static DurabilityMode relaxed() { + return new DurabilityMode(RELAXED); + } + + @Override + public Statement apply(Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + String previous = System.getProperty(Durability.MODE_PROPERTY); + System.setProperty(Durability.MODE_PROPERTY, mode); + + try { + base.evaluate(); + } finally { + if (previous == null) { + System.clearProperty(Durability.MODE_PROPERTY); + } else { + System.setProperty(Durability.MODE_PROPERTY, previous); + } + } + } + }; + } +}