mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 21:40:05 +00:00
Improved saved world startup
- Startup holds generation admission through recovery and runtime attachment - Prepared startup state removes the second recovery scan before generator readiness - Recovery batches allocated chunks by region with reusable snapshot buffers - Region decoding uses up to four workers within processor and heap limits - Oversized regions and external chunks use bounded direct reads - Status parsing validates chunk coordinates without constructing the full NBT tree - Region snapshots reject changed files, allocation headers and truncated payloads - Native zlib decompression uses jlibdeflate with a Java inflater fallback - Chunk readers reuse region handles and decompression buffers during recovery and boundary capture - Semantic shards reuse validated summaries with invalidation on mutation - Semantic key validation avoids regular expression allocation - Saved biome indexes use buffered sequential reads with reusable CRC state - Pack inspection reads validated manifest and pack state without opening terrain indexes - Datapack compilation reuses pack inspection within each operation - Kernel staging avoids duplicate pack hashing when no activation is pending - Pack copying and directory synchronization use up to four available processors - Boundary publication batches directory synchronization around durable catalog publication - Configured world reconciliation reuses ready runtimes after identity and saved-state checks - Explicit world loads retain the full validation path - Regression tests cover startup admission, recovery, malformed data, resource limits, publication and reconciliation
This commit is contained in:
1 parent
30c533feaf
commit
98ddb6644d
35 files changed
+3301
-197
No files matched your search
+6
-4
@@ -25,7 +25,6 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -63,7 +62,7 @@ public class BukkitChunkGeneratorHistoryTargetTest {
|
||||
fixture.generator.prepareGenerationHistoryTarget(128);
|
||||
|
||||
assertSame(fixture.target, fixture.generator.getTarget());
|
||||
verify(fixture.history).prepareCurrentGenerator(128);
|
||||
verify(fixture.history).prepareStartup(128);
|
||||
verify(fixture.history).activePackRoot();
|
||||
verify(fixture.data, never()).close();
|
||||
verify(fixture.data, never()).dump();
|
||||
@@ -102,7 +101,8 @@ public class BukkitChunkGeneratorHistoryTargetTest {
|
||||
assertSame(failure, assertThrows(IOException.class,
|
||||
() -> fixture.generator.prepareGenerationHistoryTarget(128)));
|
||||
|
||||
verify(fixture.history).prepareCurrentGenerator(128);
|
||||
verify(fixture.history).prepareStartup(128);
|
||||
verify(fixture.preparation).close();
|
||||
verify(fixture.data, never()).close();
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ public class BukkitChunkGeneratorHistoryTargetTest {
|
||||
public void preparationFailureCannotBeHiddenByAnExistingTarget() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IOException failure = new IOException("Pending activation is invalid");
|
||||
doThrow(failure).when(fixture.history).prepareCurrentGenerator(128);
|
||||
when(fixture.history.prepareStartup(128)).thenThrow(failure);
|
||||
|
||||
assertSame(failure, assertThrows(IOException.class,
|
||||
() -> fixture.generator.prepareGenerationHistoryTarget(128)));
|
||||
@@ -167,12 +167,14 @@ public class BukkitChunkGeneratorHistoryTargetTest {
|
||||
private static final class Fixture {
|
||||
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
private final GenerationHistory history = mock(GenerationHistory.class);
|
||||
private final GenerationHistory.StartupPreparation preparation = mock(GenerationHistory.StartupPreparation.class);
|
||||
private final AtomicCache<EngineTarget> targets = new AtomicCache<>();
|
||||
private final EngineTarget target = mock(EngineTarget.class);
|
||||
private final IrisData data = mock(IrisData.class);
|
||||
|
||||
private Fixture() throws Exception {
|
||||
Path packRoot = Path.of("generation", "packs", "current");
|
||||
when(history.prepareStartup(128)).thenReturn(preparation);
|
||||
when(history.activePackRoot()).thenReturn(packRoot);
|
||||
when(target.getData()).thenReturn(data);
|
||||
when(data.getDataFolder()).thenReturn(packRoot.toFile());
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.runtime.EngineTarget;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.volmlib.util.cache.AtomicCache;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.bukkit.generator.BlockPopulator;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.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.when;
|
||||
|
||||
public final class BukkitStartupRecoveryScanTest extends GenerationHistoryRuntimeRouterSupport {
|
||||
@Test
|
||||
public void unchangedBukkitStartupScansSavedChunksOnce() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("startup-recovery-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("startup-recovery-pack", "alpha"));
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{0, 0}, {1, 0}});
|
||||
byte[] originalRegion = Files.readAllBytes(region);
|
||||
GenerationEpoch epoch = history.activeEpoch();
|
||||
EngineTarget target = mock(EngineTarget.class);
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
IrisWorld irisWorld = mock(IrisWorld.class);
|
||||
when(target.getData()).thenReturn(data);
|
||||
when(target.getDimension()).thenReturn(dimension);
|
||||
when(target.getWorld()).thenReturn(irisWorld);
|
||||
when(data.getDataFolder()).thenReturn(history.activePackRoot().toFile());
|
||||
when(dimension.getLoadKey()).thenReturn(epoch.dimensionContract().dimensionKey());
|
||||
when(irisWorld.getRawWorldSeed()).thenReturn(epoch.worldSeed());
|
||||
IrisEngine.GenerationRuntimeBinding binding = new FakeRuntimeFactory().binding(history, history.activeActivation());
|
||||
when(binding.target()).thenReturn(target);
|
||||
BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
AtomicCache<EngineTarget> targets = new AtomicCache<>();
|
||||
targets.aquireOrThrow(() -> target);
|
||||
setField(generator, "generationHistory", history);
|
||||
setField(generator, "targetCache", targets);
|
||||
setField(generator, "studioEntryBootstrapActive", new AtomicBoolean());
|
||||
setField(generator, "populators", new KList<BlockPopulator>());
|
||||
IrisSettings settings = new IrisSettings();
|
||||
|
||||
try (MockedStatic<IrisSettings> configuration = mockStatic(IrisSettings.class);
|
||||
MockedStatic<GenerationEpochContractFactory> contracts = mockStatic(GenerationEpochContractFactory.class);
|
||||
MockedStatic<WorldChunkInventory> inventory = mockStatic(WorldChunkInventory.class, CALLS_REAL_METHODS);
|
||||
MockedConstruction<IrisEngine> engines = mockConstruction(IrisEngine.class, (engine, context) -> {
|
||||
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(binding);
|
||||
})) {
|
||||
configuration.when(IrisSettings::get).thenReturn(settings);
|
||||
contracts.when(() -> GenerationEpochContractFactory.createForEpoch(
|
||||
dimension, epoch.dimensionContract().dimensionTypeKey(), epoch)).thenReturn(epoch.dimensionContract());
|
||||
Method setup = BukkitChunkGenerator.class.getDeclaredMethod("setupEngine");
|
||||
setup.setAccessible(true);
|
||||
setup.invoke(generator);
|
||||
|
||||
assertEquals(1, engines.constructed().size());
|
||||
IrisEngine engine = engines.constructed().getFirst();
|
||||
ArgumentCaptor<GenerationHistoryRuntimeRouter> attached = ArgumentCaptor.forClass(GenerationHistoryRuntimeRouter.class);
|
||||
verify(engine).attachGenerationHistoryRuntimeRouter(attached.capture());
|
||||
try (GenerationHistoryRuntimeRouter router = attached.getValue()) {
|
||||
assertSame(history, router.history());
|
||||
assertSame(engine, generator.getEngine());
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
inventory.verify(() -> WorldChunkInventory.scan(world), times(1));
|
||||
assertArrayEquals(originalRegion, Files.readAllBytes(region));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception {
|
||||
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(generator, value);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public final class GenerationHistoryStartupValidationTest extends GenerationHistorySupport {
|
||||
@Test
|
||||
public void startupRetainsSavedClaimsWithoutRepeatingReferenceTraversal() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("startup-reference-reuse").toPath();
|
||||
createHistory(world, createPack("startup-reference-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 stored = GenerationSemanticIndex.loadRequired(world);
|
||||
for (int chunkX : new int[]{0, 32, 64}) {
|
||||
stored.recordAndPersist(ChunkGenerationSemantics.builder(chunkX, 0, 1L).seal().build());
|
||||
}
|
||||
GenerationHistory history = GenerationHistory.open(world);
|
||||
GenerationSemanticIndex observed = observeSemantics(history);
|
||||
|
||||
try (GenerationHistory.StartupPreparation ignored = history.prepareStartup(64)) {
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
}
|
||||
|
||||
verify(observed, never()).forEachRecord(any());
|
||||
assertTrue(history.semantics(0, 0).isPresent());
|
||||
assertTrue(history.semantics(32, 0).isEmpty());
|
||||
assertTrue(history.semantics(64, 0).isEmpty());
|
||||
GenerationHistory reopened = GenerationHistory.open(world);
|
||||
assertTrue(reopened.semantics(0, 0).isPresent());
|
||||
assertTrue(reopened.semantics(32, 0).isEmpty());
|
||||
assertTrue(reopened.semantics(64, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupPromotionRecoversOrphansWithoutRepeatingReferenceTraversal() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("startup-promotion-reference-reuse").toPath();
|
||||
createHistory(world, createPack("startup-promotion-pack-a", "alpha"));
|
||||
GenerationSemanticIndex stored = GenerationSemanticIndex.loadRequired(world);
|
||||
stored.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).seal().build());
|
||||
GenerationHistory history = GenerationHistory.open(world);
|
||||
stage(history, createPack("startup-promotion-pack-b", "beta"));
|
||||
GenerationSemanticIndex observed = observeSemantics(history);
|
||||
|
||||
try (GenerationHistory.StartupPreparation ignored = history.prepareStartup(64)) {
|
||||
assertEquals(2L, history.activeActivation().activationId());
|
||||
}
|
||||
|
||||
verify(observed, never()).forEachRecord(any());
|
||||
assertTrue(history.semantics(0, 0).isEmpty());
|
||||
assertEquals(2L, GenerationHistory.open(world).activeActivation().activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void standalonePromotionStillValidatesSemanticReferences() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("standalone-reference-validation").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("standalone-reference-pack", "alpha"));
|
||||
semantics(history).recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 99L).seal().build());
|
||||
|
||||
IOException failure = assertThrows(IOException.class, () -> history.promotePending(List.of()));
|
||||
|
||||
assertTrue(failure.getMessage().contains("missing generation activation 99"));
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void livePromotionStillValidatesSemanticReferences() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("live-reference-validation").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("live-reference-pack", "alpha"));
|
||||
try (GenerationHistory.GenerationStage ignored = history.openStage(0, 0)) {
|
||||
}
|
||||
semantics(history).recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 99L).seal().build());
|
||||
|
||||
try (GenerationHistory.LiveCutover cutover = history.beginLiveCutover()) {
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> cutover.promote(boundary -> (blockX, blockZ) -> null));
|
||||
assertTrue(failure.getMessage().contains("missing generation activation 99"));
|
||||
}
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupValidationCannotBeReusedAfterGenerationAdmission() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("startup-admission-guard").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("startup-admission-pack", "alpha"));
|
||||
try (GenerationHistory.GenerationStage generation = history.openStage(0, 0)) {
|
||||
history.claimGeneratedSemantics(generation,
|
||||
ChunkGenerationSemantics.builder(0, 0, 1L).seal().build());
|
||||
}
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> history.prepareStartup(64));
|
||||
assertFalse(history.semantics(0, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openingHistoryStillRejectsInvalidSemanticReferences() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("open-reference-validation").toPath();
|
||||
createHistory(world, createPack("open-reference-pack", "alpha"));
|
||||
GenerationSemanticIndex stored = GenerationSemanticIndex.loadRequired(world);
|
||||
stored.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 99L).seal().build());
|
||||
|
||||
IOException failure = assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
|
||||
assertTrue(failure.getMessage().contains("missing generation activation 99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void kernelStagingRejectsTamperedActivePackWithOrWithoutPendingActivation() throws Exception {
|
||||
GenerationKernelRegistry.Version oldVersion = new GenerationKernelRegistry.Version(101, 1, 1);
|
||||
GenerationKernelRegistry.Version currentVersion = new GenerationKernelRegistry.Version(102, 1, 1);
|
||||
for (boolean pending : new boolean[]{false, true}) {
|
||||
Path world = temporaryFolder.newFolder("kernel-tamper-" + pending).toPath();
|
||||
Path pack = createPack("kernel-tamper-pack-" + pending, "alpha");
|
||||
GenerationHistory.create(world, pack, fingerprint(pack), 42L,
|
||||
contract(), GenerationRegistryContract.empty(), oldVersion, singleKernel(oldVersion, "a"));
|
||||
GenerationHistory history = GenerationHistory.open(world, singleKernel(currentVersion, "b"));
|
||||
Path activePack = history.activePackRoot();
|
||||
if (pending) {
|
||||
history.stageCurrentKernel(32);
|
||||
}
|
||||
Files.writeString(activePack.resolve("dimensions/main.json"), "changed");
|
||||
|
||||
assertThrows(IOException.class, () -> history.stageCurrentKernel(32));
|
||||
|
||||
assertEquals(pending, history.pendingActivation().isPresent());
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void kernelStagingRejectsSymlinkedActivePackRoots() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("kernel-symlink").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("kernel-symlink-pack", "alpha"));
|
||||
Path activePack = history.activePackRoot();
|
||||
Path moved = temporaryFolder.getRoot().toPath().resolve("moved-kernel-pack");
|
||||
Files.move(activePack, moved);
|
||||
Files.createSymbolicLink(activePack, moved);
|
||||
|
||||
assertThrows(IOException.class, () -> history.stageCurrentKernel(32));
|
||||
assertTrue(history.pendingActivation().isEmpty());
|
||||
}
|
||||
|
||||
private static GenerationSemanticIndex observeSemantics(GenerationHistory history) throws Exception {
|
||||
GenerationSemanticIndex observed = spy(semantics(history));
|
||||
Field field = GenerationHistory.class.getDeclaredField("semantics");
|
||||
field.setAccessible(true);
|
||||
field.set(history, observed);
|
||||
return observed;
|
||||
}
|
||||
|
||||
private static GenerationSemanticIndex semantics(GenerationHistory history) throws Exception {
|
||||
Field field = GenerationHistory.class.getDeclaredField("semantics");
|
||||
field.setAccessible(true);
|
||||
return (GenerationSemanticIndex) field.get(history);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class GenerationPackInspectionTest extends GenerationHistorySupport {
|
||||
@Test
|
||||
public void inspectionVerifiesEachSelectedPackOnce() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
Path pack = createPack("pack", "alpha");
|
||||
Path update = createPack("update", "beta");
|
||||
GenerationHistory history = createHistory(world, pack);
|
||||
GenerationActivation pending = stage(history, update);
|
||||
AtomicInteger hashes = new AtomicInteger();
|
||||
try (MockedStatic<GenerationPackFingerprint> ignored = mockStatic(GenerationPackFingerprint.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("compute")) {
|
||||
hashes.incrementAndGet();
|
||||
}
|
||||
return invocation.callRealMethod();
|
||||
})) {
|
||||
GenerationHistory.PackInspection inspection = GenerationHistory.inspectPacks(world);
|
||||
assertEquals(history.activeEpoch(), inspection.manifest().activeEpoch());
|
||||
assertEquals(history.paths().packRoot(history.activeEpoch().epochId()), inspection.activePackRoot());
|
||||
assertEquals(history.paths().packRoot(pending.epochId()), inspection.pendingPackRoot().orElseThrow());
|
||||
assertEquals(2, hashes.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inspectionDoesNotReplaceRuntimeSemanticValidation() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("pack", "alpha"));
|
||||
Files.delete(history.paths().generationRoot().resolve("semantics/index.isix"));
|
||||
|
||||
GenerationHistory.PackInspection inspection = GenerationHistory.inspectPacks(world);
|
||||
|
||||
assertEquals(history.activeEpoch(), inspection.manifest().activeEpoch());
|
||||
assertFalse(inspection.pendingPackRoot().isPresent());
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void laterInspectionsRejectModifiedActivePacks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("pack", "alpha"));
|
||||
GenerationHistory.PackInspection inspection = GenerationHistory.inspectPacks(world);
|
||||
Files.writeString(inspection.activePackRoot().resolve("dimensions/main.json"), "changed");
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.inspectPacks(world));
|
||||
assertThrows(IOException.class, history::activePackRoot);
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inspectionRejectsModifiedPendingPacks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("pack", "alpha"));
|
||||
GenerationActivation pending = stage(history, createPack("update", "beta"));
|
||||
Files.writeString(history.paths().packRoot(pending.epochId()).resolve("dimensions/main.json"), "changed");
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.inspectPacks(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inspectionRejectsMalformedManifest() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("pack", "alpha"));
|
||||
Files.writeString(history.paths().manifest(), "{}");
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.inspectPacks(world));
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,9 @@ public class GenerationPackRepositoryTest {
|
||||
String fingerprint = fingerprint(source);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
|
||||
assertThrows(IOException.class, () -> repository.requireSafePackRoot(epochId));
|
||||
assertThrows(IOException.class, () -> repository.requireExactPack(
|
||||
epochId, fingerprint, GenerationPackFingerprint.CURRENT_VERSION));
|
||||
assertThrows(IOException.class, () -> repository.publish(
|
||||
epochId,
|
||||
fingerprint,
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
@@ -178,6 +179,33 @@ public final class GenerationSemanticIndexTest {
|
||||
assertEquals(Optional.of(expected), GenerationSemanticIndex.load(dimensionRoot).get(4, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void summariesFollowLoadedShardUpdatesAndOrphanRemoval() throws Exception {
|
||||
Path root = temporaryFolder.newFolder("summary-updates").toPath();
|
||||
GenerationSemanticIndex initial = GenerationSemanticIndex.initialize(root);
|
||||
initial.recordAndPersist(ChunkGenerationSemantics.builder(-33, -1, 1L).addObject("iris:old").build());
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.load(root);
|
||||
assertEquals("iris:old", requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:old").key());
|
||||
|
||||
loaded.claimAndPersist(ChunkGenerationSemantics.builder(-33, -1, 1L).addObject("iris:new").seal().build());
|
||||
loaded.claimAndPersist(ChunkGenerationSemantics.builder(-34, -1, 1L).addObject("iris:orphan").seal().build());
|
||||
assertEquals("iris:new", requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:new").key());
|
||||
assertEquals(2, sealedClaims(loaded, 1L).size());
|
||||
loaded.compactJournals();
|
||||
|
||||
GenerationSemanticIndex reopened = GenerationSemanticIndex.load(root);
|
||||
assertEquals("iris:new", requiredMatch(reopened, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:new").key());
|
||||
assertEquals(1, reopened.discardUnstoredClaims(
|
||||
WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(-33, -1)), Set.of(1L)));
|
||||
GenerationSemanticIndex recovered = GenerationSemanticIndex.load(root);
|
||||
assertEquals(1, sealedClaims(recovered, 1L).size());
|
||||
assertEquals("iris:old", requiredMatch(recovered, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:old").key());
|
||||
assertEquals("iris:new", requiredMatch(recovered, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:new").key());
|
||||
assertTrue(recovered.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.OBJECT, "iris:orphan",
|
||||
new ChunkGenerationSemantics.BlockPosition(-520, 80, -8), 4)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conflictingActivationsAndFactsAfterSealAreRejectedWithoutDiskChanges() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("sealed").toPath();
|
||||
@@ -627,6 +655,22 @@ public final class GenerationSemanticIndexTest {
|
||||
assertTrue(error.getCause().getMessage().contains("unsupported semantic record flags 2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSummaryMismatchEvenWhenTheRecordAndChecksumAreValid() throws Exception {
|
||||
Path root = temporaryFolder.newFolder("summary-mismatch").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).seal().build());
|
||||
Path shard = onlyShard(index.storageDirectory());
|
||||
byte[] encoded = Files.readAllBytes(shard);
|
||||
encoded[firstRecordFlagsOffset(encoded)] = 0;
|
||||
rewriteChecksum(encoded);
|
||||
replaceReferencedShard(root, shard, encoded);
|
||||
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.load(root);
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, () -> loaded.get(0, 0));
|
||||
assertTrue(error.getCause().getMessage().contains("summary does not match semantic records"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinkStorageAncestorsAreRejected() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("symlink-dimension").toPath();
|
||||
|
||||
+37
@@ -2,6 +2,7 @@ package art.arcane.iris.world.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -9,8 +10,44 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class GenerationSemanticKeyValidationTest {
|
||||
@Test
|
||||
public void resourceKeyLimitCountsUtf8BytesAcrossCharacterWidths() {
|
||||
for (String unit : List.of("a", "é", "漢", "\ud801\udc00", "\ud801")) {
|
||||
int unitBytes = unit.getBytes(StandardCharsets.UTF_8).length;
|
||||
int repetitions = ChunkGenerationSemantics.MAX_KEY_BYTES / unitBytes;
|
||||
int remainder = ChunkGenerationSemantics.MAX_KEY_BYTES % unitBytes;
|
||||
String accepted = unit.repeat(repetitions) + "a".repeat(remainder);
|
||||
assertEquals(ChunkGenerationSemantics.MAX_KEY_BYTES, accepted.getBytes(StandardCharsets.UTF_8).length);
|
||||
assertEquals(accepted, ChunkGenerationSemantics.requireResourceKey(accepted));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ChunkGenerationSemantics.requireResourceKey(accepted + "a"));
|
||||
}
|
||||
for (int length : new int[]{ChunkGenerationSemantics.MAX_KEY_BYTES / 3,
|
||||
ChunkGenerationSemantics.MAX_KEY_BYTES / 3 + 1}) {
|
||||
String accepted = "a".repeat(length);
|
||||
assertEquals(accepted, ChunkGenerationSemantics.requireResourceKey(accepted));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyAndPopulatedRecordsRemainImmutableWhenTheirBuilderChanges() {
|
||||
ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(0, 0, 1L);
|
||||
ChunkGenerationSemantics empty = builder.build();
|
||||
builder.addObject("iris:b").addObject("iris:a");
|
||||
ChunkGenerationSemantics populated = builder.build();
|
||||
builder.addObject("iris:c");
|
||||
|
||||
assertTrue(empty.objectKeys().isEmpty());
|
||||
assertEquals(List.of("iris:a", "iris:b"), List.copyOf(populated.objectKeys()));
|
||||
assertThrows(UnsupportedOperationException.class, () -> empty.objectKeys().add("iris:a"));
|
||||
assertThrows(UnsupportedOperationException.class, () -> populated.objectKeys().add("iris:c"));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addObject(" iris:d"));
|
||||
assertEquals(List.of("iris:a", "iris:b", "iris:c"), List.copyOf(builder.build().objectKeys()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateKeysRetainValidationAndCanonicalOrdering() {
|
||||
ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(-2, 3, 5L);
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.testsupport.Await;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class GenerationStartupPreparationTest extends GenerationHistoryRuntimeRouterSupport {
|
||||
@Test
|
||||
public void preparationAttachesThePreparedActivationOnlyOnce() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-startup-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-startup-pack", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
GenerationHistory.StartupPreparation preparation = history.prepareStartup(64);
|
||||
GenerationHistoryRuntimeRouter router = attach(engine, preparation, runtimes)) {
|
||||
assertSame(history, router.history());
|
||||
assertSame(engine, router.engine());
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
assertEquals(0, runtimes.loadCount(1L));
|
||||
verify(engine).attachGenerationHistoryRuntimeRouter(router);
|
||||
assertThrows(IllegalStateException.class, () -> attach(engine, preparation, runtimes));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparationPromotesPendingStateBeforeEngineAttachment() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-pending-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-pending-a", "alpha"));
|
||||
stage(history, createPack("prepared-pending-b", "beta"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
GenerationHistory.StartupPreparation preparation = history.prepareStartup(64)) {
|
||||
assertEquals(2L, history.activeActivation().activationId());
|
||||
assertFalse(history.pendingActivation().isPresent());
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
try (GenerationHistoryRuntimeRouter router = attach(engine, preparation, runtimes)) {
|
||||
assertEquals(2L, router.history().activeActivation().activationId());
|
||||
assertEquals(0, runtimes.loadCount(2L));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedManifestRejectsPreparationBeforeAttachingAnEngine() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-stale-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-stale-a", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
GenerationHistory.StartupPreparation preparation = history.prepareStartup(64)) {
|
||||
stage(history, createPack("prepared-stale-b", "beta"));
|
||||
assertThrows(IllegalStateException.class, () -> attach(engine, preparation, runtimes));
|
||||
verify(engine, never()).attachGenerationHistoryRuntimeRouter(any());
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
assertEquals(2L, history.pendingActivation().orElseThrow().activationId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closedPreparationCannotAttach() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-closed-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-closed-pack", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime()) {
|
||||
GenerationHistory.StartupPreparation preparation = history.prepareStartup(64);
|
||||
preparation.close();
|
||||
assertThrows(IllegalStateException.class, () -> attach(engine, preparation, runtimes));
|
||||
verify(engine, never()).attachGenerationHistoryRuntimeRouter(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anotherThreadCannotConsumePreparation() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-thread-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-thread-pack", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
GenerationHistory.StartupPreparation preparation = history.prepareStartup(64)) {
|
||||
FutureTask<Void> attempt = new FutureTask<>(() -> {
|
||||
assertThrows(IllegalStateException.class, () -> attach(engine, preparation, runtimes));
|
||||
return null;
|
||||
});
|
||||
Thread thread = Thread.ofPlatform().daemon().start(attempt);
|
||||
try {
|
||||
attempt.get(5L, TimeUnit.SECONDS);
|
||||
verify(engine, never()).attachGenerationHistoryRuntimeRouter(any());
|
||||
try (GenerationHistoryRuntimeRouter router = attach(engine, preparation, runtimes)) {
|
||||
assertSame(history, router.history());
|
||||
}
|
||||
} finally {
|
||||
thread.join(5_000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAttachmentConsumesPreparationAndFreshPreparationCanRetry() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-retry-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-retry-pack", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine failedEngine = preparedEngine(history, runtimes);
|
||||
IllegalStateException failure = new IllegalStateException("Engine attachment failed");
|
||||
doThrow(failure).when(failedEngine).attachGenerationHistoryRuntimeRouter(any());
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime()) {
|
||||
try (GenerationHistory.StartupPreparation preparation = history.prepareStartup(64)) {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> attach(failedEngine, preparation, runtimes)));
|
||||
IrisEngine replacement = preparedEngine(history, runtimes);
|
||||
assertThrows(IllegalStateException.class, () -> attach(replacement, preparation, runtimes));
|
||||
verify(replacement, never()).attachGenerationHistoryRuntimeRouter(any());
|
||||
}
|
||||
IrisEngine replacement = preparedEngine(history, runtimes);
|
||||
try (GenerationHistory.StartupPreparation retry = history.prepareStartup(64);
|
||||
GenerationHistoryRuntimeRouter router = attach(replacement, retry, runtimes)) {
|
||||
assertSame(history, router.history());
|
||||
verify(replacement).attachGenerationHistoryRuntimeRouter(router);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void failedPreparationReleasesAdmissionForARetry() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-invalid-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-invalid-pack", "alpha"));
|
||||
Path dimension = history.activePackRoot().resolve("dimensions/main.json");
|
||||
byte[] original = Files.readAllBytes(dimension);
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime()) {
|
||||
Files.writeString(dimension, "changed");
|
||||
assertThrows(IOException.class, () -> history.prepareStartup(64));
|
||||
Files.write(dimension, original);
|
||||
try (GenerationHistory.StartupPreparation preparation = history.prepareStartup(64);
|
||||
GenerationHistoryRuntimeRouter router = attach(engine, preparation, runtimes)) {
|
||||
assertSame(history, router.history());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generationAdmissionWaitsUntilPreparedAttachmentScopeCloses() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("prepared-admission-world").toPath();
|
||||
GenerationHistory history = createHistory(world, createPack("prepared-admission-pack", "alpha"));
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
IrisEngine engine = preparedEngine(history, runtimes);
|
||||
GenerationAdmission admission = new GenerationAdmission(world);
|
||||
FutureTask<Void> stage = new FutureTask<>(() -> {
|
||||
try (GenerationAdmission.StageLease ignored = admission.enterStage()) {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
Thread thread = Thread.ofPlatform().daemon().unstarted(stage);
|
||||
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime()) {
|
||||
try {
|
||||
try (GenerationHistory.StartupPreparation preparation = history.prepareStartup(64);
|
||||
GenerationHistoryRuntimeRouter router = attach(engine, preparation, runtimes)) {
|
||||
thread.start();
|
||||
Await.reached("generation to wait for startup preparation", Duration.ofSeconds(5L),
|
||||
() -> thread.getState() == Thread.State.WAITING || stage.isDone());
|
||||
assertFalse(stage.isDone());
|
||||
assertSame(history, router.history());
|
||||
}
|
||||
stage.get(5L, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> history.prepareStartup(64));
|
||||
} finally {
|
||||
thread.join(5_000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisEngine preparedEngine(GenerationHistory history, FakeRuntimeFactory runtimes) throws Exception {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisEngine.GenerationRuntimeBinding binding = runtimes.binding(history, history.activeActivation());
|
||||
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(binding);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static GenerationHistoryRuntimeRouter attach(
|
||||
IrisEngine engine,
|
||||
GenerationHistory.StartupPreparation preparation,
|
||||
FakeRuntimeFactory runtimes
|
||||
) throws Exception {
|
||||
return GenerationHistoryRuntimeRouter.attachPrepared(engine, preparation,
|
||||
(ignored, blockX, blockZ) -> signature(blockX, blockZ), runtimes);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,28 @@ public class SavedBiomeStoreTest {
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void indexesLargeRegionsAndTruncatesOnlyIncompleteRecordLength() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore writer = SavedBiomeStore.open(root);
|
||||
for (int index = 0; index < 512; index++) {
|
||||
writer.claimAndPersist(chunk(index & 31, index >>> 5, 3L));
|
||||
}
|
||||
Path path = regionPath(root, 0, 0);
|
||||
long completeLength = Files.size(path);
|
||||
assertTrue(completeLength > 65536);
|
||||
Files.write(path, new byte[]{0, 1}, StandardOpenOption.APPEND);
|
||||
|
||||
SavedBiomeStore reader = SavedBiomeStore.open(root);
|
||||
assertEquals(Optional.of(chunk(31, 15, 3L)), reader.get(31, 15));
|
||||
assertEquals(completeLength, Files.size(path));
|
||||
assertEquals(Optional.of(chunk(0, 0, 3L)), reader.get(0, 0));
|
||||
assertEquals(Optional.of(chunk(0, 8, 3L)), reader.get(0, 8));
|
||||
SavedBiomeChunk appended = chunk(0, 16, 3L);
|
||||
assertTrue(reader.claimAndPersist(appended));
|
||||
assertEquals(Optional.of(appended), SavedBiomeStore.open(root).get(0, 16));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnknownFormatEvenWithValidHeaderChecksum() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
|
||||
@@ -123,6 +123,49 @@ public final class SavedTerrainChunkTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reusesFullChunkReaderAcrossCodecsAndPreservesValidationAfterFailures() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("reused-full-reader").toPath();
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
for (int compression = 1; compression <= 4; compression++) {
|
||||
for (boolean external : new boolean[]{false, true}) {
|
||||
CompoundTag saved = root("minecraft:noise");
|
||||
saved.putByteArray("padding", new byte[196_608]);
|
||||
writeChunk(world, saved, compression, external);
|
||||
SavedTerrainChunk captured = reader.readChunk(-1, -2, -16, 16);
|
||||
assertEquals("minecraft:noise", captured.nativeStatus());
|
||||
assertEquals("minecraft:stone", captured.column(-16, -32).geometry().voxelAt(-16).stateKey());
|
||||
assertEquals("minecraft:water[level=0]", captured.column(-16, -32).geometry().voxelAt(-15).stateKey());
|
||||
assertEquals("example:saved_biome", captured.column(-16, -32).biomeAtSample(0));
|
||||
saved.putInt("xPos", 0);
|
||||
writeChunk(world, saved, compression, external);
|
||||
assertThrows(IOException.class, () -> reader.readChunk(-1, -2, -16, 16));
|
||||
writeChunk(world, root("minecraft:empty"), compression, external);
|
||||
assertThrows(IOException.class, () -> reader.readChunk(-1, -2, -16, 16));
|
||||
saved = root("minecraft:full");
|
||||
saved.remove("sections");
|
||||
writeChunk(world, saved, compression, external);
|
||||
assertThrows(IOException.class, () -> reader.readChunk(-1, -2, -16, 16));
|
||||
writeChunk(world, root("minecraft:full"), compression, external);
|
||||
assertEquals("minecraft:full", reader.readChunk(-1, -2, -16, 16).nativeStatus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void diskBoundaryCaptureClosesItsReaderAndClearsCachedChunks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("closed-disk-boundary").toPath();
|
||||
writeChunk(world, root("minecraft:full"), 2, false);
|
||||
DiskBoundaryCapture capture = new DiskBoundaryCapture(world, -16, 16);
|
||||
try (capture) {
|
||||
assertEquals("minecraft:stone", capture.sample(-16, -32).geometry().voxelAt(-16).stateKey());
|
||||
assertEquals("minecraft:water[level=0]", capture.sample(-16, -31).geometry().voxelAt(-15).stateKey());
|
||||
}
|
||||
capture.close();
|
||||
assertThrows(IOException.class, () -> capture.sample(-16, -32));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalChunkAndPartialNativeStagePreserveAvailableTerrain() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("external").toPath();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.volmlib.util.nbt.io.NBTUtil;
|
||||
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
|
||||
import net.jpountz.lz4.LZ4BlockOutputStream;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class SavedTerrainRecoveryScanTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void bufferedAndDirectScansMatchAcrossCodecsCoordinatesAndExternalChunks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
for (int index = 191; index >= 0; index--) {
|
||||
int x = index % 32 - 32;
|
||||
int z = index / 32 - 32;
|
||||
write(world, new Chunk(x, z, index % 4 + 1, index % 17 == 0,
|
||||
encode(x, z, index % 3 == 0 ? "minecraft:biomes" : "minecraft:noise")));
|
||||
}
|
||||
write(world, new Chunk(33, 3, 3, false, encode(33, 3, "minecraft:full")));
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scan(world);
|
||||
WorldChunkInventory expected;
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
expected = inventory.filter((x, z) -> SavedTerrainChunk.hasTerrain(reader.readStatus(x, z)));
|
||||
}
|
||||
for (int workers : new int[]{1, 2, 4}) {
|
||||
for (int regionBytes : new int[]{8192, 4 * 1024 * 1024}) {
|
||||
try (SavedTerrainRecoveryScan scan = new SavedTerrainRecoveryScan(world,
|
||||
new SavedTerrainRecoveryScan.Limits(workers, regionBytes))) {
|
||||
WorldChunkInventory actual = scan.filter(inventory, (x, z) -> false);
|
||||
assertEquals(expected.size(), actual.size());
|
||||
inventory.forEach((x, z) -> assertEquals(expected.contains(x, z), actual.contains(x, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doesNotDecodeRetainedHistoricalChunks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
write(world, new Chunk(0, 0, 2, false, new byte[]{10}));
|
||||
write(world, new Chunk(1, 0, 2, false, encode(1, 0, "minecraft:full")));
|
||||
try (SavedTerrainRecoveryScan scan = new SavedTerrainRecoveryScan(world,
|
||||
new SavedTerrainRecoveryScan.Limits(2, 1024 * 1024))) {
|
||||
WorldChunkInventory actual = scan.filter(WorldChunkInventory.scan(world), (x, z) -> x == 0);
|
||||
assertEquals(2, actual.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void propagatesWorkerCorruptionAndCanScanAgain() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
write(world, new Chunk(x, z, 2, false, encode(x, z, "minecraft:full")));
|
||||
}
|
||||
}
|
||||
write(world, new Chunk(1, 1, 2, false, new byte[]{10}));
|
||||
try (SavedTerrainRecoveryScan scan = new SavedTerrainRecoveryScan(world,
|
||||
new SavedTerrainRecoveryScan.Limits(2, 4 * 1024 * 1024))) {
|
||||
IOException failure = assertThrows(IOException.class,
|
||||
() -> scan.filter(WorldChunkInventory.scan(world), (x, z) -> false));
|
||||
assertTrue(failure.getMessage().contains("1,1"));
|
||||
write(world, new Chunk(1, 1, 2, false, encode(1, 1, "minecraft:full")));
|
||||
assertEquals(256, scan.filter(WorldChunkInventory.scan(world), (x, z) -> false).size());
|
||||
}
|
||||
assertEquals(256, SavedTerrainRecoveryScan.scan(world, WorldChunkInventory.scan(world), (x, z) -> false).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsInterruptStatusWhenRecoveryIsInterrupted() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
write(world, new Chunk(0, 0, 2, false, encode(0, 0, "minecraft:full")));
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scan(world);
|
||||
try (SavedTerrainRecoveryScan scan = new SavedTerrainRecoveryScan(world,
|
||||
new SavedTerrainRecoveryScan.Limits(1, 1024 * 1024))) {
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertThrows(IOException.class, () -> scan.filter(inventory, (x, z) -> false));
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capsWorkersAndRegionMemoryForTheAvailableResources() {
|
||||
assertEquals(1, SavedTerrainRecoveryScan.Limits.forRuntime(1, 8L << 30).workers());
|
||||
assertEquals(1, SavedTerrainRecoveryScan.Limits.forRuntime(64, 512L << 20).workers());
|
||||
assertEquals(2, SavedTerrainRecoveryScan.Limits.forRuntime(64, 2L << 30).workers());
|
||||
assertEquals(4, SavedTerrainRecoveryScan.Limits.forRuntime(64, 8L << 30).workers());
|
||||
assertEquals(32 << 20, SavedTerrainRecoveryScan.Limits.forRuntime(64, 512L << 20).regionBytes());
|
||||
assertEquals(64 << 20, SavedTerrainRecoveryScan.Limits.forRuntime(64, 8L << 30).regionBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyInventoriesDoNotNeedARegionDirectory() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
assertTrue(SavedTerrainRecoveryScan.scan(world, WorldChunkInventory.empty(), (x, z) -> false).isEmpty());
|
||||
assertFalse(Files.exists(world.resolve("region")));
|
||||
}
|
||||
|
||||
private static byte[] encode(int x, int z, String status) throws IOException {
|
||||
CompoundTag root = new CompoundTag();
|
||||
root.putInt("xPos", x);
|
||||
root.putInt("zPos", z);
|
||||
root.putString("Status", status);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
NBTUtil.write(root, bytes, false);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static void write(Path world, Chunk chunk) throws IOException {
|
||||
Path directory = Files.createDirectories(world.resolve("region"));
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream();
|
||||
try (OutputStream stream = switch (chunk.compression()) {
|
||||
case 1 -> new GZIPOutputStream(encoded);
|
||||
case 2 -> new DeflaterOutputStream(encoded);
|
||||
case 4 -> new LZ4BlockOutputStream(encoded);
|
||||
default -> encoded;
|
||||
}) {
|
||||
stream.write(chunk.nbt());
|
||||
}
|
||||
byte[] payload = encoded.toByteArray();
|
||||
Path path = directory.resolve("r." + Math.floorDiv(chunk.x(), 32) + "." + Math.floorDiv(chunk.z(), 32) + ".mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "rw")) {
|
||||
int sector = Math.max(2, Math.toIntExact(Math.ceilDiv(file.length(), 4096)));
|
||||
int sectors = chunk.external() ? 1 : Math.ceilDiv(payload.length + 5, 4096);
|
||||
file.setLength((long) (sector + sectors) * 4096);
|
||||
file.seek((Math.floorMod(chunk.z(), 32) * 32L + Math.floorMod(chunk.x(), 32)) * 4);
|
||||
file.writeInt(sector << 8 | sectors);
|
||||
file.seek((long) sector * 4096);
|
||||
file.writeInt(chunk.external() ? 1 : payload.length + 1);
|
||||
file.writeByte(chunk.compression() | (chunk.external() ? 128 : 0));
|
||||
if (!chunk.external()) {
|
||||
file.write(payload);
|
||||
}
|
||||
}
|
||||
if (chunk.external()) {
|
||||
Files.write(directory.resolve("c." + chunk.x() + "." + chunk.z() + ".mcc"), payload);
|
||||
}
|
||||
}
|
||||
|
||||
private record Chunk(int x, int z, int compression, boolean external, byte[] nbt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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;
|
||||
|
||||
public final class SavedTerrainRegionSnapshotTest {
|
||||
private static final int SECTOR_BYTES = 4_096;
|
||||
private static final int HEADER_BYTES = 2 * SECTOR_BYTES;
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void extractsInternalAndExternalPayloadDescriptors() throws Exception {
|
||||
byte[] bytes = new byte[4 * SECTOR_BYTES];
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
buffer.putInt(0, 2 << 8 | 1);
|
||||
buffer.putInt(1023 * Integer.BYTES, 3 << 8 | 1);
|
||||
buffer.putInt(HEADER_BYTES, 4);
|
||||
bytes[HEADER_BYTES + 4] = 2;
|
||||
bytes[HEADER_BYTES + 5] = 12;
|
||||
bytes[HEADER_BYTES + 6] = 34;
|
||||
bytes[HEADER_BYTES + 7] = 56;
|
||||
buffer.putInt(3 * SECTOR_BYTES, 1);
|
||||
bytes[3 * SECTOR_BYTES + 4] = (byte) 132;
|
||||
Path file = writeRegion(bytes);
|
||||
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
SavedTerrainRegionSnapshot.Payload internal = snapshot.payload(0);
|
||||
assertEquals(2, internal.compression());
|
||||
assertFalse(internal.external());
|
||||
assertEquals(3, internal.length());
|
||||
assertArrayEquals(new byte[]{12, 34, 56}, Arrays.copyOfRange(snapshot.bytes(),
|
||||
internal.offset(), internal.offset() + internal.length()));
|
||||
SavedTerrainRegionSnapshot.Payload external = snapshot.payload(1023);
|
||||
assertTrue(external.external());
|
||||
assertEquals(4, external.compression());
|
||||
assertEquals(0, external.length());
|
||||
snapshot.validateUnchanged();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipsOversizedRegionsWithoutReadingTheirPayloads() throws Exception {
|
||||
Path file = temporaryFolder.newFile().toPath();
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.setLength(128L * 1024 * 1024);
|
||||
}
|
||||
assertTrue(SavedTerrainRegionSnapshot.read(file, 64 * 1024, new byte[0]).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsAnExactUnpaddedPayload() throws Exception {
|
||||
byte[] bytes = region(2 << 8 | 1, 3, 3, HEADER_BYTES + 7);
|
||||
bytes[HEADER_BYTES + 5] = 10;
|
||||
bytes[HEADER_BYTES + 6] = 0;
|
||||
Path file = writeRegion(bytes);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
assertEquals(2, snapshot.payload(0).length());
|
||||
snapshot.validateUnchanged();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reusesLargerBuffersWithoutAcceptingStalePayloadBytes() throws Exception {
|
||||
byte[] larger = region(2 << 8 | 1, 5, 2, 4 * SECTOR_BYTES);
|
||||
ByteBuffer.wrap(larger).putInt(3 * SECTOR_BYTES, 1);
|
||||
larger[3 * SECTOR_BYTES + Integer.BYTES] = 2;
|
||||
Path largerFile = writeRegion(larger);
|
||||
SavedTerrainRegionSnapshot original = SavedTerrainRegionSnapshot.read(
|
||||
largerFile, larger.length, new byte[0]).orElseThrow();
|
||||
byte[] reusable = original.bytes();
|
||||
original.validateUnchanged();
|
||||
|
||||
byte[] smaller = region(2 << 8 | 1, 1, 3, HEADER_BYTES + 5);
|
||||
Path smallerFile = writeRegion(smaller);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(
|
||||
smallerFile, smaller.length, reusable).orElseThrow();
|
||||
assertSame(reusable, snapshot.bytes());
|
||||
assertEquals(larger.length, snapshot.bytes().length);
|
||||
assertEquals(0, snapshot.payload(0).length());
|
||||
assertEquals(3, snapshot.payload(0).compression());
|
||||
snapshot.validateUnchanged();
|
||||
assertTrue(SavedTerrainRegionSnapshot.read(largerFile, smaller.length, reusable).isEmpty());
|
||||
|
||||
for (int[] entry : new int[][]{{3 << 8 | 1, 1}, {2 << 8 | 1, 5}}) {
|
||||
Path malformed = writeRegion(region(entry[0], entry[1], 2, smaller.length));
|
||||
SavedTerrainRegionSnapshot invalid = SavedTerrainRegionSnapshot.read(
|
||||
malformed, smaller.length, reusable).orElseThrow();
|
||||
assertSame(reusable, invalid.bytes());
|
||||
assertThrows(IOException.class, () -> invalid.payload(0));
|
||||
invalid.validateUnchanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAtomicReplacementWithMatchingSizeAndTimestamp() throws Exception {
|
||||
byte[] bytes = region(2 << 8 | 1, 1, 2, 3 * SECTOR_BYTES);
|
||||
Path file = writeRegion(bytes);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
FileTime timestamp = Files.getLastModifiedTime(file);
|
||||
Path replacement = writeRegion(bytes);
|
||||
Files.setLastModifiedTime(replacement, timestamp);
|
||||
Files.move(replacement, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
assertThrows(IOException.class, snapshot::validateUnchanged);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSameFilePayloadModificationAndTruncation() throws Exception {
|
||||
byte[] bytes = region(2 << 8 | 1, 2, 2, 3 * SECTOR_BYTES);
|
||||
Path file = writeRegion(bytes);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
FileTime timestamp = Files.getLastModifiedTime(file);
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.seek(HEADER_BYTES + 5);
|
||||
output.writeByte(1);
|
||||
}
|
||||
Files.setLastModifiedTime(file, FileTime.fromMillis(timestamp.toMillis() + 2_000));
|
||||
assertThrows(IOException.class, snapshot::validateUnchanged);
|
||||
|
||||
SavedTerrainRegionSnapshot rewritten = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.setLength(HEADER_BYTES - 1);
|
||||
}
|
||||
assertThrows(IOException.class, rewritten::validateUnchanged);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesBothHeaderSectorsEvenWhenTimestampIsRestored() throws Exception {
|
||||
for (int offset : new int[]{0, SECTOR_BYTES}) {
|
||||
byte[] bytes = region(2 << 8 | 1, 1, 2, 3 * SECTOR_BYTES);
|
||||
Path file = writeRegion(bytes);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
FileTime timestamp = Files.getLastModifiedTime(file);
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.seek(offset);
|
||||
output.writeInt(7);
|
||||
}
|
||||
Files.setLastModifiedTime(file, timestamp);
|
||||
assertThrows(IOException.class, snapshot::validateUnchanged);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidAllocationsAndPayloadLengths() throws Exception {
|
||||
int[][] cases = {
|
||||
{0, 1, 2, 3 * SECTOR_BYTES},
|
||||
{1 << 8 | 1, 1, 2, 3 * SECTOR_BYTES},
|
||||
{2 << 8, 1, 2, 3 * SECTOR_BYTES},
|
||||
{3 << 8 | 1, 1, 2, 3 * SECTOR_BYTES},
|
||||
{0xFFFFFF01, 1, 2, 3 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, 0, 2, 3 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, -1, 2, 3 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, Integer.MAX_VALUE, 2, 3 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, SECTOR_BYTES, 2, 4 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, 2, 130, 3 * SECTOR_BYTES},
|
||||
{2 << 8 | 1, 2, 2, HEADER_BYTES + 5}
|
||||
};
|
||||
for (int[] entry : cases) {
|
||||
byte[] bytes = region(entry[0], entry[1], entry[2], entry[3]);
|
||||
Path file = writeRegion(bytes);
|
||||
SavedTerrainRegionSnapshot snapshot = SavedTerrainRegionSnapshot.read(file, bytes.length, new byte[0]).orElseThrow();
|
||||
assertThrows(IOException.class, () -> snapshot.payload(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTruncatedRegionsDirectoriesAndSymlinks() throws Exception {
|
||||
for (int size : new int[]{0, HEADER_BYTES - 1}) {
|
||||
Path file = writeRegion(new byte[size]);
|
||||
assertThrows(IOException.class, () -> SavedTerrainRegionSnapshot.read(file, 0, new byte[0]));
|
||||
}
|
||||
Path directory = temporaryFolder.newFolder().toPath();
|
||||
assertThrows(IOException.class, () -> SavedTerrainRegionSnapshot.read(directory, HEADER_BYTES, new byte[0]));
|
||||
Path file = writeRegion(new byte[HEADER_BYTES]);
|
||||
Path link = directory.resolve("region-link");
|
||||
Files.createSymbolicLink(link, file);
|
||||
assertThrows(IOException.class, () -> SavedTerrainRegionSnapshot.read(link, HEADER_BYTES, new byte[0]));
|
||||
}
|
||||
|
||||
private Path writeRegion(byte[] bytes) throws IOException {
|
||||
Path file = temporaryFolder.newFile().toPath();
|
||||
Files.write(file, bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static byte[] region(int allocation, int length, int compression, int size) {
|
||||
byte[] bytes = new byte[size];
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
buffer.putInt(0, allocation);
|
||||
buffer.putInt(HEADER_BYTES, length);
|
||||
bytes[HEADER_BYTES + Integer.BYTES] = (byte) compression;
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.volmlib.util.nbt.io.NBTUtil;
|
||||
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
|
||||
import art.arcane.volmlib.util.nbt.tag.NumberTag;
|
||||
import art.arcane.volmlib.util.nbt.tag.StringTag;
|
||||
import art.arcane.volmlib.util.nbt.tag.Tag;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class SavedTerrainStatusNbtTest {
|
||||
|
||||
@Test
|
||||
public void validatesEveryTagAndListType() throws Exception {
|
||||
for (int type = 1; type <= 12; type++) {
|
||||
int tagType = type;
|
||||
check("tag-" + type, root(out -> field(out, tagType, "extra", child -> payload(child, tagType))), 7, -9);
|
||||
}
|
||||
for (int type = 0; type <= 12; type++) {
|
||||
int tagType = type;
|
||||
check("list-" + type, root(out -> field(out, 9, "extra", child -> {
|
||||
child.writeByte(tagType);
|
||||
child.writeInt(2);
|
||||
payload(child, tagType);
|
||||
payload(child, tagType);
|
||||
})), 7, -9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesNumericCoordinateConversions() throws Exception {
|
||||
for (int type = 1; type <= 6; type++) {
|
||||
int tagType = type;
|
||||
check("coordinates-" + type, root(out -> {
|
||||
field(out, tagType, "xPos", child -> numeric(child, tagType, 7));
|
||||
field(out, tagType, "zPos", child -> numeric(child, tagType, -9));
|
||||
}), 7, -9);
|
||||
}
|
||||
double[] special = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, -9.75};
|
||||
for (double value : special) {
|
||||
check("double-coordinate-" + value, root(out -> field(out, 6, "xPos", child -> child.writeDouble(value))), (int) value, -9);
|
||||
}
|
||||
check("long-overflow-coordinate", root(out -> field(out, 4, "xPos", child -> child.writeLong(0x100000007L))), 7, -9);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesModifiedUtfNamesAndValues() throws Exception {
|
||||
String[] strings = {"minecraft:full", "nul\u0000tail", "\u00e9\u4e00", "\ud83d\ude00", "\ud800", "\udc00", "\t \n", ""};
|
||||
for (int index = 0; index < strings.length; index++) {
|
||||
String value = strings[index];
|
||||
check("status-utf-" + index, root(out -> field(out, 8, "Status", child -> child.writeUTF(value))), 7, -9);
|
||||
}
|
||||
byte[][] malformed = {{(byte) 0x80}, {(byte) 0xc2}, {(byte) 0xe1, (byte) 0x80}, {(byte) 0xf0, (byte) 0x90, (byte) 0x80, (byte) 0x80}, {(byte) 0xc2, 0x41}};
|
||||
for (int index = 0; index < malformed.length; index++) {
|
||||
byte[] value = malformed[index];
|
||||
check("malformed-tail-utf-" + index, root(out -> field(out, 8, "unused", child -> rawUtf(child, value))), 7, -9);
|
||||
}
|
||||
check("overlong-status-name-2", namedStatus(2), 7, -9);
|
||||
check("overlong-status-name-3", namedStatus(3), 7, -9);
|
||||
check("overlong-x-name", root(out -> {
|
||||
out.writeByte(3);
|
||||
rawUtf(out, new byte[]{(byte) 0xc1, (byte) 0xb8, 'P', 'o', 's'});
|
||||
out.writeInt(11);
|
||||
}), 11, -9);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesDuplicateAndNestedFieldSemantics() throws Exception {
|
||||
check("duplicate-status-last-wins", root(out -> field(out, 8, "Status", child -> child.writeUTF("minecraft:noise"))), 7, -9);
|
||||
check("duplicate-status-wrong-type", root(out -> field(out, 3, "Status", child -> child.writeInt(4))), 7, -9);
|
||||
check("duplicate-x-last-wins", root(out -> field(out, 3, "xPos", child -> child.writeInt(11))), 11, -9);
|
||||
check("duplicate-x-wrong-type", root(out -> field(out, 8, "xPos", child -> child.writeUTF("7"))), 7, -9);
|
||||
check("duplicate-z-last-wins", root(out -> field(out, 3, "zPos", child -> child.writeInt(11))), 7, 11);
|
||||
check("nested-fields-ignored", root(out -> field(out, 10, "extra", child -> {
|
||||
field(child, 3, "xPos", value -> value.writeInt(33));
|
||||
field(child, 8, "Status", value -> value.writeUTF("minecraft:empty"));
|
||||
child.writeByte(0);
|
||||
})), 7, -9);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesNestingAndArrayLengths() throws Exception {
|
||||
for (int depth : new int[]{510, 511, 512, 513}) {
|
||||
check("compound-depth-" + depth, root(out -> nested(out, depth, false)), 7, -9);
|
||||
check("list-depth-" + depth, root(out -> nested(out, depth, true)), 7, -9);
|
||||
}
|
||||
for (int type : new int[]{7, 11, 12}) {
|
||||
check("negative-array-" + type, root(out -> field(out, type, "extra", child -> child.writeInt(-1))), 7, -9);
|
||||
check("truncated-array-" + type, root(out -> field(out, type, "extra", child -> child.writeInt(65536))), 7, -9);
|
||||
}
|
||||
for (int length : new int[]{-1, 0, 1}) {
|
||||
check("invalid-list-type-length-" + length, root(out -> field(out, 9, "extra", child -> {
|
||||
child.writeByte(99);
|
||||
child.writeInt(length);
|
||||
})), 7, -9);
|
||||
}
|
||||
check("negative-list-length", root(out -> field(out, 9, "extra", child -> {
|
||||
child.writeByte(3);
|
||||
child.writeInt(-1);
|
||||
})), 7, -9);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesTruncatedAndMalformedPayloads() throws Exception {
|
||||
byte[] valid = root(out -> field(out, 8, "tail", child -> child.writeUTF("tailvalue")));
|
||||
for (int cut : new int[]{0, 1, 2, 3, valid.length - 1, valid.length - 2, valid.length - 8}) {
|
||||
check("truncated-at-" + cut, Arrays.copyOf(valid, cut), 7, -9);
|
||||
}
|
||||
check("invalid-tail-tag", root(out -> { out.writeByte(99); out.writeUTF("tail"); }), 7, -9);
|
||||
check("trailing-garbage", Arrays.copyOf(valid, valid.length + 3), 7, -9);
|
||||
check("coordinates-mismatch", valid, 8, -9);
|
||||
check("raw-zero-string", root(out -> field(out, 8, "Status", child -> rawUtf(child, new byte[]{0}))), 7, -9);
|
||||
check("overlong-value", root(out -> field(out, 8, "Status", child -> rawUtf(child, new byte[]{(byte) 0xc1, (byte) 0x93}))), 7, -9);
|
||||
check("non-ascii-root-key", root(out -> field(out, 8, "xP\u00f6s", child -> child.writeUTF("ignored"))), 7, -9);
|
||||
check("maximum-utf-name", root(out -> field(out, 1, "k".repeat(65535), child -> child.writeByte(3))), 7, -9);
|
||||
check("root-not-compound", new byte[]{3, 0, 0, 0, 0, 0, 1}, 7, -9);
|
||||
check("empty-compound-missing-fields", new byte[]{10, 0, 0, 0}, 7, -9);
|
||||
check("root-malformed-utf", new byte[]{10, 0, 1, (byte) 0x80, 0}, 7, -9);
|
||||
check("maximum-empty-list-type", root(out -> field(out, 9, "extra", child -> {
|
||||
child.writeByte(255);
|
||||
child.writeInt(0);
|
||||
})), 7, -9);
|
||||
check("empty-array", root(out -> field(out, 12, "extra", child -> child.writeInt(0))), 7, -9);
|
||||
check("truncated-primitive-tail", root(out -> field(out, 6, "extra", child -> child.writeInt(4))), 7, -9);
|
||||
check("malformed-utf-name-tail", root(out -> {
|
||||
out.writeByte(1);
|
||||
rawUtf(out, new byte[]{(byte) 0xc2});
|
||||
out.writeByte(0);
|
||||
}), 7, -9);
|
||||
for (int depth : new int[]{511, 512}) {
|
||||
check("nonempty-depth-" + depth, root(out -> {
|
||||
for (int index = 0; index < depth; index++) {
|
||||
out.writeByte(10);
|
||||
out.writeUTF("nested");
|
||||
}
|
||||
field(out, 1, "leaf", child -> child.writeByte(1));
|
||||
for (int index = 0; index < depth; index++) {
|
||||
out.writeByte(0);
|
||||
}
|
||||
}), 7, -9);
|
||||
}
|
||||
check("float-nan-coordinate", root(out -> field(out, 5, "xPos", child -> child.writeFloat(Float.NaN))), 0, -9);
|
||||
|
||||
}
|
||||
|
||||
private static byte[] namedStatus(int width) throws IOException {
|
||||
return root(out -> {
|
||||
out.writeByte(8);
|
||||
if (width == 2) {
|
||||
rawUtf(out, new byte[]{(byte) 0xc1, (byte) 0x93, 't', 'a', 't', 'u', 's'});
|
||||
} else {
|
||||
rawUtf(out, new byte[]{(byte) 0xe0, (byte) 0x81, (byte) 0x93, 't', 'a', 't', 'u', 's'});
|
||||
}
|
||||
out.writeUTF("minecraft:noise");
|
||||
});
|
||||
}
|
||||
|
||||
private static void check(String label, byte[] data, int x, int z) {
|
||||
Outcome tree = tree(data, x, z);
|
||||
Outcome cursor;
|
||||
try {
|
||||
cursor = new Outcome(true, SavedTerrainStatusNbt.read(data, 0, data.length, x, z), "");
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
cursor = new Outcome(false, null, failure.getClass().getSimpleName());
|
||||
}
|
||||
boolean same = tree.accepted == cursor.accepted && (!tree.accepted || tree.status.equals(cursor.status));
|
||||
assertTrue(label + " tree=" + tree + " cursor=" + cursor, same);
|
||||
}
|
||||
|
||||
private static Outcome tree(byte[] bytes, int x, int z) {
|
||||
try {
|
||||
Tag<?> parsed = NBTUtil.read(new ByteArrayInputStream(bytes), false).getTag();
|
||||
if (!(parsed instanceof CompoundTag root)
|
||||
|| !(root.get("xPos") instanceof NumberTag<?> savedX) || savedX.asInt() != x
|
||||
|| !(root.get("zPos") instanceof NumberTag<?> savedZ) || savedZ.asInt() != z
|
||||
|| !(root.get("Status") instanceof StringTag status) || status.getValue().isBlank()) {
|
||||
throw new IOException("Invalid chunk fields");
|
||||
}
|
||||
return new Outcome(true, status.getValue(), "");
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
return new Outcome(false, null, failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] root(Writer extra) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
out.writeByte(10);
|
||||
out.writeUTF("\u0000\ud800root");
|
||||
field(out, 3, "xPos", child -> child.writeInt(7));
|
||||
field(out, 3, "zPos", child -> child.writeInt(-9));
|
||||
field(out, 8, "Status", child -> child.writeUTF("minecraft:full"));
|
||||
extra.write(out);
|
||||
out.writeByte(0);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static void field(DataOutputStream out, int type, String key, Writer value) throws IOException {
|
||||
out.writeByte(type);
|
||||
out.writeUTF(key);
|
||||
value.write(out);
|
||||
}
|
||||
|
||||
private static void rawUtf(DataOutputStream out, byte[] bytes) throws IOException {
|
||||
out.writeShort(bytes.length);
|
||||
out.write(bytes);
|
||||
}
|
||||
|
||||
private static void nested(DataOutputStream out, int depth, boolean lists) throws IOException {
|
||||
out.writeByte(lists ? 9 : 10);
|
||||
out.writeUTF("nested");
|
||||
for (int index = 1; index < depth; index++) {
|
||||
out.writeByte(lists ? 9 : 10);
|
||||
if (lists) {
|
||||
out.writeInt(1);
|
||||
} else {
|
||||
out.writeUTF("nested");
|
||||
}
|
||||
}
|
||||
if (lists) {
|
||||
out.writeByte(0);
|
||||
out.writeInt(0);
|
||||
} else {
|
||||
for (int index = 0; index < depth; index++) {
|
||||
out.writeByte(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void payload(DataOutputStream out, int type) throws IOException {
|
||||
switch (type) {
|
||||
case 0 -> { }
|
||||
case 1, 2, 3, 4, 5, 6 -> numeric(out, type, 7);
|
||||
case 7 -> { out.writeInt(3); out.write(new byte[]{1, 2, 3}); }
|
||||
case 8 -> out.writeUTF("\u0000\ud83d\ude00\u00e9\u4e00");
|
||||
case 9 -> { out.writeByte(3); out.writeInt(1); out.writeInt(42); }
|
||||
case 10 -> { field(out, 3, "nested", child -> child.writeInt(42)); out.writeByte(0); }
|
||||
case 11 -> { out.writeInt(2); out.writeInt(42); out.writeInt(-42); }
|
||||
case 12 -> { out.writeInt(2); out.writeLong(42); out.writeLong(-42); }
|
||||
default -> throw new IOException("Unknown type");
|
||||
}
|
||||
}
|
||||
|
||||
private static void numeric(DataOutputStream out, int type, int value) throws IOException {
|
||||
switch (type) {
|
||||
case 1 -> out.writeByte(value);
|
||||
case 2 -> out.writeShort(value);
|
||||
case 3 -> out.writeInt(value);
|
||||
case 4 -> out.writeLong(value);
|
||||
case 5 -> out.writeFloat(value);
|
||||
case 6 -> out.writeDouble(value);
|
||||
default -> throw new IOException("Unknown numeric type");
|
||||
}
|
||||
}
|
||||
|
||||
private record Outcome(boolean accepted, String status, String failure) {
|
||||
}
|
||||
|
||||
private interface Writer {
|
||||
void write(DataOutputStream out) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.volmlib.util.nbt.io.NBTUtil;
|
||||
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
|
||||
import net.jpountz.lz4.LZ4BlockOutputStream;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class SavedTerrainStatusReaderTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void reusesReaderAcrossCodecsRegionsAndExternalChunks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
for (int compression = 1; compression <= 4; compression++) {
|
||||
for (boolean external : new boolean[]{false, true}) {
|
||||
int x = compression * 32 - 160;
|
||||
writeChunk(world, x, -2, encode(root(x, -2, "minecraft:noise")), compression, external);
|
||||
assertEquals("minecraft:noise", reader.readStatus(x, -2));
|
||||
assertEquals("minecraft:noise", SavedTerrainChunk.readStatus(world, x, -2));
|
||||
writeChunk(world, x, -2, encode(root(x, -2, "minecraft:full")), compression, external);
|
||||
assertEquals("minecraft:full", reader.readStatus(x, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void observesAtomicReplacementOfAnOpenRegion() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
Path replacement = temporaryFolder.newFolder().toPath();
|
||||
writeChunk(world, -1, -2, orderedRoot(), 2, false);
|
||||
writeChunk(replacement, -1, -2, encode(root(-1, -2, "minecraft:noise")), 2, false);
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
assertEquals("minecraft:full", reader.readStatus(-1, -2));
|
||||
Files.move(replacement.resolve("region/r.-1.-1.mca"), world.resolve("region/r.-1.-1.mca"),
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
assertEquals("minecraft:noise", reader.readStatus(-1, -2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void identifiesTheChunkWhenDecodingFails() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
writeChunk(world, -1, -2, new byte[]{10}, 2, false);
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
IOException failure = assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
assertTrue(failure.getMessage().contains("-1,-2"));
|
||||
assertTrue(failure.getCause() instanceof IOException);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsReadsAfterCloseAndCanCloseTwice() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
writeChunk(world, -1, -2, encode(root(-1, -2, "minecraft:full")), 2, false);
|
||||
SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world);
|
||||
assertEquals("minecraft:full", reader.readStatus(-1, -2));
|
||||
reader.close();
|
||||
reader.close();
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCorruptionAfterRequiredFieldsAndRecoversForNextChunk() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
byte[] valid = orderedRoot();
|
||||
byte[] malformed = Arrays.copyOf(valid, valid.length + 3);
|
||||
malformed[valid.length - 1] = 99;
|
||||
byte[][] failures = {Arrays.copyOf(valid, valid.length - 1), malformed,
|
||||
encode(root(1, -2, "minecraft:full")), encode(root(-1, -2, " "))};
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
for (byte[] failure : failures) {
|
||||
writeChunk(world, -1, -2, failure, 2, false);
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
writeChunk(world, -1, -2, valid, 2, false);
|
||||
assertEquals("minecraft:full", reader.readStatus(-1, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesCompressionChecksumsAndTruncationBeforeReturningStatus() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
for (int compression : new int[]{1, 2, 4}) {
|
||||
writeChunk(world, -1, -2, orderedRoot(), compression, true);
|
||||
Path external = world.resolve("region/c.-1.-2.mcc");
|
||||
byte[] encoded = Files.readAllBytes(external);
|
||||
Files.write(external, Arrays.copyOf(encoded, encoded.length - 3));
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
writeChunk(world, -1, -2, orderedRoot(), compression, true);
|
||||
byte[] corrupted = Files.readAllBytes(external);
|
||||
corrupted[compression == 4 ? 17 : corrupted.length - 5] ^= 1;
|
||||
Files.write(external, corrupted);
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsOversizedInflationAndRetainsOnlyCurrentPayloadLength() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
writeChunk(world, -1, -2, orderedRoot(), 2, true);
|
||||
Path external = world.resolve("region/c.-1.-2.mcc");
|
||||
try (OutputStream output = new DeflaterOutputStream(Files.newOutputStream(external))) {
|
||||
byte[] padding = new byte[65536];
|
||||
for (int index = 0; index < 1024; index++) {
|
||||
output.write(padding);
|
||||
}
|
||||
output.write(0);
|
||||
}
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
writeChunk(world, -1, -2, orderedRoot(), 2, false);
|
||||
assertEquals("minecraft:full", reader.readStatus(-1, -2));
|
||||
byte[] truncated = Arrays.copyOf(orderedRoot(), orderedRoot().length - 1);
|
||||
writeChunk(world, -1, -2, truncated, 2, false);
|
||||
assertThrows(IOException.class, () -> reader.readStatus(-1, -2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void growsBuffersAndReusesReaderAcrossLargeDirectAndSliceReads() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
Random random = new Random(729L);
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
for (int size : new int[]{196_608, 64, 131_072, 196_608}) {
|
||||
byte[] padding = new byte[size];
|
||||
random.nextBytes(padding);
|
||||
CompoundTag root = root(-1, -2, "minecraft:full");
|
||||
root.putByteArray("padding", padding);
|
||||
byte[] nbt = encode(root);
|
||||
writeChunk(world, -1, -2, nbt, 2, false);
|
||||
assertEquals("minecraft:full", reader.readStatus(-1, -2));
|
||||
byte[] compressed = compress(nbt);
|
||||
byte[] slice = new byte[compressed.length + 96];
|
||||
Arrays.fill(slice, (byte) 127);
|
||||
System.arraycopy(compressed, 0, slice, 37, compressed.length);
|
||||
assertEquals("minecraft:full", reader.readStatus(slice,
|
||||
new SavedTerrainRegionSnapshot.Payload(37, compressed.length, 2, false), -1, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCorruptAndTruncatedSlicesAndRecoversForValidInput() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
byte[] compressed = compress(orderedRoot());
|
||||
byte[] slice = new byte[compressed.length + 64];
|
||||
System.arraycopy(compressed, 0, slice, 31, compressed.length);
|
||||
SavedTerrainRegionSnapshot.Payload stored = new SavedTerrainRegionSnapshot.Payload(
|
||||
31, compressed.length, 2, false);
|
||||
try (SavedTerrainChunkReader.StatusReader reader = new SavedTerrainChunkReader.StatusReader(world)) {
|
||||
assertEquals("minecraft:full", reader.readStatus(slice, stored, -1, -2));
|
||||
SavedTerrainRegionSnapshot.Payload truncated = new SavedTerrainRegionSnapshot.Payload(
|
||||
31, compressed.length - 1, 2, false);
|
||||
assertThrows(IOException.class, () -> reader.readStatus(slice, truncated, -1, -2));
|
||||
assertEquals("minecraft:full", reader.readStatus(slice, stored, -1, -2));
|
||||
byte[] corrupt = slice.clone();
|
||||
corrupt[31 + compressed.length - 1] ^= 1;
|
||||
assertThrows(IOException.class, () -> reader.readStatus(corrupt, stored, -1, -2));
|
||||
assertEquals("minecraft:full", reader.readStatus(slice, stored, -1, -2));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] compress(byte[] bytes) throws IOException {
|
||||
ByteArrayOutputStream compressed = new ByteArrayOutputStream();
|
||||
try (OutputStream output = new DeflaterOutputStream(compressed)) {
|
||||
output.write(bytes);
|
||||
}
|
||||
return compressed.toByteArray();
|
||||
}
|
||||
|
||||
private static CompoundTag root(int x, int z, String status) {
|
||||
CompoundTag root = new CompoundTag();
|
||||
root.putInt("xPos", x);
|
||||
root.putInt("zPos", z);
|
||||
root.putString("Status", status);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static byte[] orderedRoot() throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream output = new DataOutputStream(bytes);
|
||||
output.writeByte(10);
|
||||
output.writeUTF("");
|
||||
output.writeByte(3);
|
||||
output.writeUTF("xPos");
|
||||
output.writeInt(-1);
|
||||
output.writeByte(3);
|
||||
output.writeUTF("zPos");
|
||||
output.writeInt(-2);
|
||||
output.writeByte(8);
|
||||
output.writeUTF("Status");
|
||||
output.writeUTF("minecraft:full");
|
||||
output.writeByte(0);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] encode(CompoundTag root) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
NBTUtil.write(root, bytes, false);
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeChunk(Path world, int x, int z, byte[] nbt, int compression, boolean external)
|
||||
throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (OutputStream output = switch (compression) {
|
||||
case 1 -> new GZIPOutputStream(bytes);
|
||||
case 2 -> new DeflaterOutputStream(bytes);
|
||||
case 4 -> new LZ4BlockOutputStream(bytes);
|
||||
default -> bytes;
|
||||
}) {
|
||||
output.write(nbt);
|
||||
}
|
||||
byte[] payload = bytes.toByteArray();
|
||||
Path directory = Files.createDirectories(world.resolve("region"));
|
||||
Path region = directory.resolve("r." + Math.floorDiv(x, 32) + "." + Math.floorDiv(z, 32) + ".mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
int sectors = external ? 1 : Math.ceilDiv(payload.length + 5, 4096);
|
||||
file.setLength((2L + sectors) * 4096);
|
||||
file.seek((Math.floorMod(z, 32) * 32L + Math.floorMod(x, 32)) * 4);
|
||||
file.writeInt((2 << 8) | sectors);
|
||||
file.seek(8192);
|
||||
file.writeInt(external ? 1 : payload.length + 1);
|
||||
file.writeByte(compression | (external ? 128 : 0));
|
||||
if (!external) {
|
||||
file.write(payload);
|
||||
}
|
||||
}
|
||||
if (external) {
|
||||
Files.write(directory.resolve("c." + x + "." + z + ".mcc"), payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public final class TerrainBoundarySignaturePublicationTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void syncsAllShardFilesThenDirectoryBeforePublishingEitherCatalogPath() throws Exception {
|
||||
assumeTrue(File.separatorChar != '\\');
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("a".repeat(64), List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(4, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(8, 0)));
|
||||
List<TerrainBoundarySignature> signatures = new ArrayList<>();
|
||||
boundary.forEachExposedBlockColumn((x, z) -> signatures.add(signature(x, z)));
|
||||
for (boolean streamed : new boolean[]{false, true}) {
|
||||
TerrainBoundarySignatureStore store = store();
|
||||
List<String> events = new ArrayList<>();
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.equals(store.directory())) {
|
||||
assertEquals(3, shards(store).size());
|
||||
events.add(Files.exists(store.snapshotPath(1L)) ? "catalog-directory" : "shard-directory");
|
||||
} else if (path.getFileName().toString().endsWith(".tmp")) {
|
||||
events.add(fileKind(path));
|
||||
}
|
||||
})) {
|
||||
if (streamed) {
|
||||
store.publish(1L, boundary, TerrainBoundarySignaturePublicationTest::signature);
|
||||
} else {
|
||||
store.publish(1L, signatures);
|
||||
}
|
||||
}
|
||||
assertEquals(List.of("shard", "shard", "shard", "shard-directory", "catalog", "catalog-directory"),
|
||||
events);
|
||||
assertEquals(signatures.size(), store.load(1L).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedShardDirectoryBarrierDoesNotPublishCatalogAndRetrySyncsExistingShards() throws Exception {
|
||||
assumeTrue(File.separatorChar != '\\');
|
||||
TerrainBoundarySignatureStore store = store();
|
||||
List<TerrainBoundarySignature> signatures = List.of(signature(0, 0), signature(64, 0));
|
||||
IOException failure = new IOException("Shard directory force failed");
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.equals(store.directory())) {
|
||||
throw failure;
|
||||
}
|
||||
})) {
|
||||
assertSame(failure, assertThrows(IOException.class, () -> store.publish(1L, signatures)));
|
||||
}
|
||||
assertFalse(Files.exists(store.snapshotPath(1L)));
|
||||
assertEquals(2, shards(store).size());
|
||||
List<String> events = new ArrayList<>();
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.equals(store.directory())) {
|
||||
events.add(Files.exists(store.snapshotPath(1L)) ? "catalog-directory" : "shard-directory");
|
||||
} else if (path.getFileName().toString().endsWith(".tmp")) {
|
||||
events.add(fileKind(path));
|
||||
}
|
||||
})) {
|
||||
assertEquals(2, store.publish(1L, signatures).size());
|
||||
}
|
||||
assertEquals(List.of("shard-directory", "catalog", "catalog-directory"), events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCatalogDirectoryBarrierIsRetriedBeforeExistingCatalogReturns() throws Exception {
|
||||
assumeTrue(File.separatorChar != '\\');
|
||||
TerrainBoundarySignatureStore store = store();
|
||||
List<TerrainBoundarySignature> signatures = List.of(signature(0, 0));
|
||||
IOException failure = new IOException("Catalog directory force failed");
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.equals(store.directory()) && Files.exists(store.snapshotPath(1L))) {
|
||||
throw failure;
|
||||
}
|
||||
})) {
|
||||
assertSame(failure, assertThrows(IOException.class, () -> store.publish(1L, signatures)));
|
||||
}
|
||||
assertTrue(Files.exists(store.snapshotPath(1L)));
|
||||
AtomicInteger directoryForces = new AtomicInteger();
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.equals(store.directory())) {
|
||||
directoryForces.incrementAndGet();
|
||||
}
|
||||
assertFalse(path.getFileName().toString().endsWith(".tmp"));
|
||||
})) {
|
||||
assertEquals(1, store.publish(1L, signatures).size());
|
||||
}
|
||||
assertEquals(1, directoryForces.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shardForceFailureCannotPublishShardOrCatalog() throws Exception {
|
||||
TerrainBoundarySignatureStore store = store();
|
||||
IOException failure = new IOException("Shard file force failed");
|
||||
try (MockedStatic<FileChannel> ignored = channels(path -> {
|
||||
if (path.getFileName().toString().endsWith(".tmp") && fileKind(path).equals("shard")) {
|
||||
throw failure;
|
||||
}
|
||||
})) {
|
||||
assertSame(failure, assertThrows(IOException.class,
|
||||
() -> store.publish(1L, List.of(signature(0, 0)))));
|
||||
}
|
||||
assertTrue(shards(store).isEmpty());
|
||||
assertFalse(Files.exists(store.snapshotPath(1L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingCorruptShardCannotReachCatalogPublication() throws Exception {
|
||||
TerrainBoundarySignatureStore store = store();
|
||||
List<TerrainBoundarySignature> signatures = List.of(signature(0, 0));
|
||||
store.publish(1L, signatures);
|
||||
Path shard = shards(store).getFirst();
|
||||
byte[] bytes = Files.readAllBytes(shard);
|
||||
bytes[bytes.length - 1] ^= 1;
|
||||
Files.write(shard, bytes);
|
||||
assertThrows(IOException.class, () -> store.publish(2L, signatures));
|
||||
assertFalse(Files.exists(store.snapshotPath(2L)));
|
||||
}
|
||||
|
||||
private TerrainBoundarySignatureStore store() throws IOException {
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(temporaryFolder.newFolder().toPath());
|
||||
Files.createDirectories(store.directory());
|
||||
return store;
|
||||
}
|
||||
|
||||
private static List<Path> shards(TerrainBoundarySignatureStore store) throws IOException {
|
||||
List<Path> shards = new ArrayList<>();
|
||||
try (DirectoryStream<Path> entries = Files.newDirectoryStream(store.directory(), "*.irtm")) {
|
||||
for (Path entry : entries) {
|
||||
shards.add(entry);
|
||||
}
|
||||
}
|
||||
return shards;
|
||||
}
|
||||
|
||||
private static String fileKind(Path path) throws IOException {
|
||||
try (DataInputStream input = new DataInputStream(Files.newInputStream(path))) {
|
||||
return switch (input.readInt()) {
|
||||
case 0x4952544D -> "shard";
|
||||
case 0x49525453 -> "catalog";
|
||||
default -> throw new IOException("Unexpected terrain boundary file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static MockedStatic<FileChannel> channels(ForceObserver observer) {
|
||||
return mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
if (invocation.getMethod().getParameterCount() != 2) {
|
||||
return source;
|
||||
}
|
||||
Path path = invocation.getArgument(0);
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
doAnswer(force -> {
|
||||
observer.force(path);
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true);
|
||||
return intercepted;
|
||||
});
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature(int x, int z) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(x, z, 80 + x, 60, OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(-64, 32, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("iris:plains"), new short[]{0})),
|
||||
BoundaryColumnGeometry.empty());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ForceObserver {
|
||||
void force(Path path) throws IOException;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user