diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 510d8e682..03f5acb3d 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -333,6 +333,10 @@ public class CustomBiomeSource extends BiomeSource { return Collections.unmodifiableMap(spawnBiomes); } + Holder getRetainedVanillaSpawnBiome(String derivativeKey) { + return resolveBiomeHolder(biomeRegistry, derivativeKey); + } + Holder getVanillaSpawnBiome(Holder biome) { if (biome == null) { return null; diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 7500eb1c8..014aa6196 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -18,6 +18,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.history.NativeTerrainReceipt; +import art.arcane.iris.engine.history.NativeBiomeSpawnSelection; import art.arcane.iris.engine.history.SavedTerrainChunk; import org.bukkit.NamespacedKey; import org.bukkit.persistence.PersistentDataType; @@ -929,11 +930,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred @Override public WeightedList getMobsAt(Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { + NativeBiomeSpawnSelection selection = NativeBiomeSpawnSelection.at( + engine, blockposition.getX(), blockposition.getY(), blockposition.getZ(), + holder.unwrapKey().map(key -> key.identifier().toString()).orElse("")); + if (selection.mode() == NativeBiomeSpawnSelection.Mode.LOADING) { + return WeightedList.of(List.of()); + } try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( blockposition.getX(), blockposition.getZ(), "bukkit_nms_mob_spawns"); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_mob_spawns"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition); + return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition, selection); } } @@ -941,9 +948,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, - BlockPos blockposition + BlockPos blockposition, + NativeBiomeSpawnSelection selection ) { - Holder vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder); + Holder vanillaSpawnBiome = switch (selection.mode()) { + case RETAINED -> customBiomeSource.getRetainedVanillaSpawnBiome(selection.derivativeKey()); + case CURRENT -> customBiomeSource.getVanillaSpawnBiome(holder); + case NONE, LOADING -> null; + }; if (vanillaSpawnBiome == null) { return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition); } @@ -964,7 +976,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } int spawnRuntimeId = engine.getCacheID(); - SpawnTableKey key = new SpawnTableKey(spawnRuntimeId, holder.value(), enumcreaturetype); + SpawnTableKey key = new SpawnTableKey(spawnRuntimeId, holder.value(), vanillaSpawnBiome.value(), enumcreaturetype); return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns)); } @@ -1611,7 +1623,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } } - private record SpawnTableKey(int runtimeId, Biome biome, MobCategory category) { + private record SpawnTableKey(int runtimeId, Biome biome, Biome vanillaBiome, MobCategory category) { } private record ReachableStructureCache(IrisDimension dimension, int runtimeId, Set keys) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index bd1a53bd2..6ace9bb89 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -600,7 +600,8 @@ public final class IrisWorldGeneratorResolver { throw new IllegalStateException("Immutable Iris generation pack at " + packRoot + " does not contain dimension " + dimensionKey + "."); } - GenerationEpoch.DimensionContract actualContract = captureDimensionContract(data, dimension); + GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch( + dimension, dimensionTypeKey(data, dimension), epoch); if (!epoch.dimensionContract().equals(actualContract)) { throw new IllegalStateException("Immutable Iris generation pack dimension contract changed at " + packRoot + "."); @@ -612,7 +613,15 @@ public final class IrisWorldGeneratorResolver { IrisData data, IrisDimension dimension ) { - String dimensionTypeKey = IrisPlatforms.get() + return GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + dimensionTypeKey(data, dimension) + ); + } + + private static String dimensionTypeKey(IrisData data, IrisDimension dimension) { + return IrisPlatforms.get() .registries() .generationRegistry() .dimensionTypeResourceKey( @@ -620,11 +629,6 @@ public final class IrisWorldGeneratorResolver { dimension.getLoadKey(), dimension.getDimensionTypeKey() ); - return GenerationEpochContractFactory.create( - dimension, - dimension.getLoadKey(), - dimensionTypeKey - ); } private static File requireActivePack(GenerationHistory history) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index a4f56a307..d00c993aa 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -15,6 +15,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; import art.arcane.iris.core.lifecycle.WorldReplacementSeed; +import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.StudioSVC; @@ -115,7 +116,7 @@ public final class PendingWorldReplacementManager implements Listener { return worldKey; } - public synchronized StagedReplacement stageReplacement( + public StagedReplacement stageReplacement( VolmitSender sender, NamespacedKey worldKey, IrisDimension dimension, @@ -128,132 +129,142 @@ public final class PendingWorldReplacementManager implements Listener { OptionalLong seedSelection = requestedSeed == null ? OptionalLong.empty() : OptionalLong.of(requestedSeed.longValue()); - IrisStartupValidation.requireWorldReplacementStagingReady(); - if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { - throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); - } - PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( LifecycleOperationCoordinator.Domain.WORLD_MUTATION, LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE, requiredWorldKey.toString() - )) { - if (findTransaction(requiredWorldSlotKey) != null) { - throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); - } - ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey); - requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); - requireVanillaSlotEnabled(target.slotKind()); - UUID transactionId = UUID.randomUUID(); - ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); - WorldReplacementFilesystem.requireExistingTarget(paths); - String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); - DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); - if (!datapacks.succeeded()) { - throw new IOException("Iris could not compile the dimension datapacks."); - } + ); WorldReplacementProgress progress = WorldReplacementProgress.start( + requiredSender, requiredWorldKey.toString())) { + synchronized (this) { + IrisStartupValidation.requireWorldReplacementStagingReady(); + if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { + throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); + } + PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); + if (findTransaction(requiredWorldSlotKey) != null) { + throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); + } + ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey); + requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); + requireVanillaSlotEnabled(target.slotKind()); + UUID transactionId = UUID.randomUUID(); + ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); + WorldReplacementFilesystem.requireExistingTarget(paths); + String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_DATAPACKS); + DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); + if (!datapacks.succeeded()) { + throw new IOException("Iris could not compile the dimension datapacks."); + } - boolean targetPresent = true; - WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( - ServerProperties.BUKKIT_YML, - worldName - ); - Transaction transaction = null; - boolean journalWritten = false; - boolean configurationApplied = false; - try { - Files.createDirectory(paths.stage()); - long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( - paths.target(), - paths.stage(), - seedSelection - ); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( - requiredSender, - requiredDimension, - paths.stage().toFile(), - effectiveSeed - ); - if (installed == null) { - throw new IOException("Iris could not stage the dimension pack."); - } - requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); - if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { - WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); - } - File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot( - paths.stage().toFile(), - effectiveSeed - ); - IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); - String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); - transaction = new Transaction( - transactionId, - requiredWorldSlotKey, - worldName, - target.levelRoot(), - installed.getLoadKey(), - effectiveSeed, - packFingerprint, - originalConfiguration, - targetPresent, - Phase.PREPARED - ); - writeTransaction(transaction); - journalWritten = true; - GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching( + boolean targetPresent = true; + WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( ServerProperties.BUKKIT_YML, - worldName, - originalConfiguration, - installed.getLoadKey(), - effectiveSeed + worldName ); - if (!replacement.applied()) { - throw new IOException("bukkit.yml changed while the replacement was being staged."); - } - configurationApplied = true; - transaction = transaction.withPhase(Phase.ARMED); - writeTransaction(transaction); - return new StagedReplacement( - requiredWorldKey, - worldName, - installed.getLoadKey(), - effectiveSeed, - targetPresent, - datapacks.restartRequired() - ); - } catch (Throwable failure) { - if (configurationApplied && transaction != null) { - try { - WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); - if (!BukkitWorldConfiguration.restoreIfMatching( - ServerProperties.BUKKIT_YML, - worldName, - replacement, - originalConfiguration - )) { - failure.addSuppressed(new IOException( - "bukkit.yml changed before the failed replacement could be restored.")); - } - } catch (Throwable restoreFailure) { - failure.addSuppressed(restoreFailure); + Transaction transaction = null; + boolean journalWritten = false; + boolean configurationApplied = false; + try { + Files.createDirectory(paths.stage()); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SEED); + long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( + paths.target(), + paths.stage(), + seedSelection + ); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( + requiredSender, + requiredDimension, + paths.stage().toFile(), + effectiveSeed + ); + if (installed == null) { + throw new IOException("Iris could not stage the dimension pack."); } - } - if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) { - try { - WorldReplacementFilesystem.discardStage(paths); - if (journalWritten) { - deleteJournal(transactionId); - } - } catch (Throwable cleanupFailure) { - failure.addSuppressed(cleanupFailure); + requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); + if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PLAYERS); + WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); } + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_VERIFY); + File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot( + paths.stage().toFile(), + effectiveSeed + ); + IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); + String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); + transaction = new Transaction( + transactionId, + requiredWorldSlotKey, + worldName, + target.levelRoot(), + installed.getLoadKey(), + effectiveSeed, + packFingerprint, + originalConfiguration, + targetPresent, + Phase.PREPARED + ); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SAVE); + writeTransaction(transaction); + journalWritten = true; + GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching( + ServerProperties.BUKKIT_YML, + worldName, + originalConfiguration, + installed.getLoadKey(), + effectiveSeed + ); + if (!replacement.applied()) { + throw new IOException("bukkit.yml changed while the replacement was being staged."); + } + configurationApplied = true; + transaction = transaction.withPhase(Phase.ARMED); + writeTransaction(transaction); + return new StagedReplacement( + requiredWorldKey, + worldName, + installed.getLoadKey(), + effectiveSeed, + targetPresent, + datapacks.restartRequired() + ); + } catch (Throwable failure) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_CLEANUP); + if (configurationApplied && transaction != null) { + try { + WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); + if (!BukkitWorldConfiguration.restoreIfMatching( + ServerProperties.BUKKIT_YML, + worldName, + replacement, + originalConfiguration + )) { + failure.addSuppressed(new IOException( + "bukkit.yml changed before the failed replacement could be restored.")); + } + } catch (Throwable restoreFailure) { + failure.addSuppressed(restoreFailure); + } + } + if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) { + try { + WorldReplacementFilesystem.discardStage(paths); + if (journalWritten) { + deleteJournal(transactionId); + } + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure); } - if (failure instanceof IOException ioFailure) { - throw ioFailure; - } - throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java new file mode 100644 index 000000000..3778ad89a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java @@ -0,0 +1,116 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.RuntimeProgressMessages; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.localization.LanguageAudience; +import art.arcane.volmlib.util.localization.MessageArgs; +import art.arcane.volmlib.util.localization.MessageKey; +import art.arcane.volmlib.util.localization.TextKey; +import org.bukkit.entity.Player; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class WorldReplacementProgress implements AutoCloseable { + private static final int HEARTBEAT_INTERVAL_TICKS = 200; + + private final VolmitSender sender; + private final String worldKey; + private final Player player; + private final UUID languageAudience; + private final long startedAtNanos; + private String detail; + private int taskId = -1; + private boolean firstTick = true; + private boolean closed; + + private WorldReplacementProgress(VolmitSender sender, String worldKey) { + this.sender = Objects.requireNonNull(sender, "sender"); + this.worldKey = Objects.requireNonNull(worldKey, "worldKey"); + player = sender.isPlayer() ? sender.player() : null; + languageAudience = player == null ? LanguageAudience.current() : player.getUniqueId(); + startedAtNanos = System.nanoTime(); + detail = IrisLanguage.text( + languageAudience, + RuntimeProgressMessages.WORLD_CREATE_STAGE_INITIALIZING, + MessageArgs.empty() + ); + } + + public static WorldReplacementProgress start(VolmitSender sender, String worldKey) { + WorldReplacementProgress progress = new WorldReplacementProgress(sender, worldKey); + progress.send(RuntimeProgressMessages.WORLD_REPLACE_START); + try { + progress.taskId = J.ar(progress::heartbeat, HEARTBEAT_INTERVAL_TICKS); + } catch (Throwable failure) { + IrisLogging.reportError("Could not start world replacement progress updates for " + worldKey + ".", failure); + } + return progress; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + if (taskId >= 0) { + try { + J.car(taskId); + } catch (Throwable failure) { + IrisLogging.reportError("Could not cancel world replacement progress updates for " + worldKey + ".", failure); + } + } + } + + public synchronized void stage(TextKey stage) { + if (closed || stage == null) { + return; + } + String detail = IrisLanguage.text(languageAudience, stage, MessageArgs.empty()); + if (detail.equals(this.detail)) { + return; + } + this.detail = detail; + send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS); + } + + private synchronized void heartbeat() { + if (closed) { + return; + } + if (firstTick) { + firstTick = false; + return; + } + send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS); + } + + private void send(MessageKey key) { + try { + MessageArgs.Builder arguments = MessageArgs.builder().untrusted("world", worldKey); + if (key == RuntimeProgressMessages.WORLD_REPLACE_PROGRESS) { + arguments.untrusted("stage", detail) + .untrusted("elapsed", TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAtNanos)); + } + String message = IrisLanguage.text(languageAudience, key, arguments.build()); + if (player == null) { + sender.sendMessage(message); + } else { + J.runEntity(player, () -> deliverToPlayer(message)); + } + } catch (Throwable failure) { + IrisLogging.reportError("Could not report world replacement progress for " + worldKey + ".", failure); + } + } + + private synchronized void deliverToPlayer(String message) { + if (!closed) { + sender.sendMessage(message); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 4d60a7f55..295bbf3e7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -257,6 +257,8 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey() + seedDetail + ". Restart once to publish it. The current dimension is retained until Iris " + "verifies the replacement."); + } catch (LifecycleOperationCoordinator.BusyException busy) { + sender().sendMessage(C.YELLOW + busy.getMessage()); } catch (Throwable failure) { Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure); String detail = failure.getMessage() == null || failure.getMessage().isBlank() diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java index 73f7f0acd..b7d784a91 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java @@ -31,6 +31,7 @@ import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -82,21 +83,36 @@ public class CommandWhat implements DirectorExecutor { @Director(description = "What biome am i in?", descriptionKey = "iris.director.commandwhat.director.what_biome_am_i", origin = DirectorOrigin.PLAYER) public void biome() { + VolmitSender commandSender = sender(); + Player player = player(); + onPlayerThread(player, () -> describeBiome(commandSender, player)); + } + + private void describeBiome(VolmitSender commandSender, Player player) { + Location location = player.getLocation(); try { - IrisBiome b = engine().getBiome(player().getLocation().getBlockX(), player().getLocation().getBlockY() - player().getWorld().getMinHeight(), player().getLocation().getBlockZ()); + Engine engine = IrisToolbelt.access(location.getWorld()).getEngine(); + IrisBiome b = engine.getBiome(location.getBlockX(), location.getBlockY() - location.getWorld().getMinHeight(), location.getBlockZ()); Biome derivative = b.getDerivative(); NamespacedKey derivativeKey = resolveBiomeKey(derivative); - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME, MessageArgument.untrusted("value", b.getLoadKey()), MessageArgument.untrusted("value2", derivativeKey == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : derivativeKey.getKey()))); + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME, MessageArgument.untrusted("value", b.getLoadKey()), MessageArgument.untrusted("value2", derivativeKey == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : derivativeKey.getKey()))); + } catch (SavedBiomeUnavailableException e) { + if (e.getCause() != null) { + Iris.reportError(e); + } + commandSender.sendMessage(e.isLoading() + ? "Saved biome data is loading. Run the command again shortly." + : e.getMessage()); } catch (Throwable e) { Iris.reportError(e); - Biome biome = player().getLocation().getBlock().getBiome(); + Biome biome = location.getBlock().getBiome(); NamespacedKey key = resolveBiomeKey(biome); - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_NON_IRIS_BIOME, MessageArgument.untrusted("value", key == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : key))); + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_NON_IRIS_BIOME, MessageArgument.untrusted("value", key == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : key))); if (key == null || key.getKey().equals("custom")) { try { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_DATA_PACK_BIOME_ID, MessageArgument.untrusted("value", INMS.get().getTrueBiomeBaseKey(player().getLocation())), MessageArgument.untrusted("value2", INMS.get().getTrueBiomeBaseId(INMS.get().getTrueBiomeBase(player().getLocation()))))); + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_DATA_PACK_BIOME_ID, MessageArgument.untrusted("value", INMS.get().getTrueBiomeBaseKey(location)), MessageArgument.untrusted("value2", INMS.get().getTrueBiomeBaseId(INMS.get().getTrueBiomeBase(location))))); } catch (Throwable ee) { Iris.reportError(ee); } @@ -118,6 +134,13 @@ public class CommandWhat implements DirectorExecutor { IrisRegion r = EngineBukkitOps.getRegion(engine, chunk); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IREGION, MessageArgument.untrusted("value", r.getLoadKey()), MessageArgument.untrusted("value2", r.getName()))); + } catch (SavedBiomeUnavailableException e) { + if (e.getCause() != null) { + Iris.reportError(e); + } + commandSender.sendMessage(e.isLoading() + ? "Saved biome data is loading. Run the command again shortly." + : e.getMessage()); } catch (Throwable e) { Iris.reportError(e); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY)); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java new file mode 100644 index 000000000..fc0a662c0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.core; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.BusyException; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.Domain; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.Lease; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.OperationKind; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.common.plugin.VolmitSender; +import org.bukkit.NamespacedKey; +import org.junit.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +public class PendingWorldReplacementAdmissionTest { + @Test + public void busyReplacementRejectsBeforeWaitingForTheManagerMonitor() throws Exception { + PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class)); + VolmitSender sender = mock(VolmitSender.class); + IrisDimension dimension = mock(IrisDimension.class); + NamespacedKey worldKey = NamespacedKey.minecraft("overworld"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (Lease lease = LifecycleOperationCoordinator.get().acquire( + Domain.WORLD_MUTATION, OperationKind.WORLD_REPLACE, worldKey.toString())) { + synchronized (manager) { + Future rejected = executor.submit(() -> assertThrows( + BusyException.class, + () -> manager.stageReplacement(sender, worldKey, dimension, null) + )); + assertEquals(lease.operation(), rejected.get(2L, TimeUnit.SECONDS).currentOperation()); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(5L, TimeUnit.SECONDS); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java new file mode 100644 index 000000000..10f67f98d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java @@ -0,0 +1,147 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.RuntimeProgressMessages; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.localization.MessageArgs; +import art.arcane.volmlib.util.localization.MessageKey; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class WorldReplacementProgressTest { + @Test + public void reportsStartStageAndHeartbeatUntilClosed() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + AtomicReference heartbeat = new AtomicReference<>(); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenAnswer(invocation -> { + heartbeat.set(invocation.getArgument(0)); + return 42; + }); + + WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld"); + assertEquals(1, messages.size()); + assertTrue(messages.getFirst().contains("Preparing Iris replacement for minecraft:overworld")); + + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + assertEquals(2, messages.size()); + assertTrue(messages.getLast().contains("Copying and validating the dimension pack")); + assertTrue(messages.getLast().matches(".*\\([0-9]+s elapsed\\).*")); + + heartbeat.get().run(); + assertEquals(2, messages.size()); + heartbeat.get().run(); + assertEquals(3, messages.size()); + assertTrue(messages.getLast().contains("Copying and validating the dimension pack")); + + progress.close(); + progress.close(); + heartbeat.get().run(); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SAVE); + assertEquals(3, messages.size()); + scheduler.verify(() -> J.car(42), times(1)); + } + } + + @Test + public void schedulingFailureDoesNotPreventPhaseReports() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + IllegalStateException failure = new IllegalStateException("Scheduler stopped"); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenThrow(failure); + try (WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld")) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + } + + assertEquals(2, messages.size()); + logging.verify(() -> IrisLogging.reportError(anyString(), eq(failure))); + scheduler.verify(() -> J.car(anyInt()), times(0)); + } + } + + @Test + public void unavailableTimerLeavesPhaseReportsUsable() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenReturn(-1); + try (WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld")) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + } + + assertEquals(2, messages.size()); + scheduler.verify(() -> J.car(anyInt()), times(0)); + } + } + + @Test + public void playerReportsUseEntitySchedulingAndRetainAudience() { + VolmitSender sender = mock(VolmitSender.class); + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(sender.isPlayer()).thenReturn(true); + when(sender.player()).thenReturn(player); + when(player.getUniqueId()).thenReturn(playerId); + List messages = new ArrayList<>(); + List deliveries = new ArrayList<>(); + List audiences = new ArrayList<>(); + AtomicReference heartbeat = new AtomicReference<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic language = mockStatic(IrisLanguage.class)) { + language.when(() -> IrisLanguage.text(any(UUID.class), any(MessageKey.class), any(MessageArgs.class))) + .thenAnswer(invocation -> { + audiences.add(invocation.getArgument(0)); + return "Replacement progress"; + }); + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenAnswer(invocation -> { + heartbeat.set(invocation.getArgument(0)); + return 42; + }); + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> { + deliveries.add(invocation.getArgument(1)); + return true; + }); + + WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld"); + assertTrue(messages.isEmpty()); + deliveries.getFirst().run(); + assertEquals(1, messages.size()); + heartbeat.get().run(); + heartbeat.get().run(); + assertEquals(2, deliveries.size()); + assertEquals(List.of(playerId, playerId, playerId), audiences); + + progress.close(); + deliveries.getLast().run(); + assertEquals(1, messages.size()); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandWhatBiomeTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandWhatBiomeTest.java new file mode 100644 index 000000000..15f6ddb65 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandWhatBiomeTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.RuntimeUiMessages; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.director.DirectorContext; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.localization.MessageArgument; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class CommandWhatBiomeTest { + @Test + public void scheduledBiomeDescriptionUsesThePlayersWorldAfterCommandContextClears() { + VolmitSender sender = mock(VolmitSender.class); + Player player = mock(Player.class); + World world = mock(World.class); + Engine engine = mock(Engine.class); + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + IrisBiome biome = mock(IrisBiome.class); + AtomicReference scheduled = new AtomicReference<>(); + when(sender.player()).thenReturn(player); + when(sender.isPlayer()).thenReturn(true); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 19, 70, -3)); + when(world.getMinHeight()).thenReturn(-64); + when(generator.getEngine()).thenReturn(engine); + when(engine.getBiome(19, 134, -3)).thenReturn(biome); + when(biome.getLoadKey()).thenReturn("retained-flat"); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + MockedStatic language = mockStatic(IrisLanguage.class)) { + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return true; + }); + toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator); + language.when(() -> IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED)).thenReturn("unregistered"); + language.when(() -> IrisLanguage.text(eq(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME), + any(MessageArgument.class), any(MessageArgument.class))).thenReturn("Iris biome: retained-flat"); + DirectorContext.touch(sender); + try { + new CommandWhat().biome(); + } finally { + DirectorContext.remove(); + } + assertNull(DirectorContext.get()); + assertNotNull(scheduled.get()); + verifyNoInteractions(engine); + + scheduled.get().run(); + + verify(engine).getBiome(19, 134, -3); + verify(sender).sendMessage("Iris biome: retained-flat"); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index da7953d75..c0900874b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.iris.nativegen.NativeTransitionColumn; import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.engine.history.NativeBiomeSpawnSelection; import java.util.Optional; import art.arcane.iris.nativegen.NativeGenerationWriteGuard; import art.arcane.iris.core.loader.IrisData; @@ -920,6 +921,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public WeightedList getMobsAt( Holder biome, StructureManager structureManager, MobCategory category, BlockPos pos) { Engine current = engine(); + NativeBiomeSpawnSelection selection = NativeBiomeSpawnSelection.at(current, pos.getX(), pos.getY(), pos.getZ(), + biome.unwrapKey().map(key -> key.identifier().toString()).orElse("")); + if (selection.mode() == NativeBiomeSpawnSelection.Mode.LOADING) { + return WeightedList.of(List.of()); + } try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( current, pos.getX(), pos.getZ(), "modded_mob_spawn_table"); GenerationSessionLease lease = requireGenerationLease(current, "modded_mob_spawn_table"); @@ -933,8 +939,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Registry registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); - spawnTables.initializeVanillaSpawnBiomes(registry); - Holder vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(biome.value()); + Holder vanillaSpawnBiome; + if (selection.mode() == NativeBiomeSpawnSelection.Mode.RETAINED) { + vanillaSpawnBiome = spawnTables.resolveBiomeHolder(registry, selection.derivativeKey()); + } else if (selection.mode() == NativeBiomeSpawnSelection.Mode.CURRENT) { + spawnTables.initializeVanillaSpawnBiomes(registry); + vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(biome.value()); + } else { + vanillaSpawnBiome = null; + } if (vanillaSpawnBiome == null) { return explicitSpawns; } @@ -951,6 +964,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return spawnTables.mergedSpawnTable( current, biome.value(), + vanillaSpawnBiome.value(), category, vanillaSpawns, explicitSpawns diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java index f03b7fa23..1afb21b8f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java @@ -20,13 +20,13 @@ package art.arcane.iris.modded; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.framework.EngineAssignedComponent; import art.arcane.iris.engine.framework.EngineEffects; -import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisCommand; import art.arcane.iris.engine.object.IrisCommandRegistry; import art.arcane.iris.engine.object.IrisEffect; -import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.Holder; @@ -36,6 +36,7 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.protocol.game.ClientboundSoundPacket; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; @@ -193,18 +194,21 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement return; } - samplePlayer(state); + try { + samplePlayer(state); + } catch (SavedBiomeUnavailableException unavailable) { + state.environment = null; + state.sampled(false); + return; + } if (!IrisSettings.get().getWorld().isEffectSystem()) { return; } - IrisRegion region = state.region(); - if (region != null) { - applyEffects(level, player, region.getEffects()); - } - IrisBiome biome = state.biome(); - if (biome != null) { - applyEffects(level, player, biome.getEffects()); + BiomeEnvironment environment = state.environment; + try (BiomeEnvironment.Scope ignored = getEngine().openBiomeEnvironmentScope(environment)) { + applyEffects(level, player, environment.region().getEffects()); + applyEffects(level, player, environment.biome().getEffects()); } } @@ -219,14 +223,14 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement return; } - state.sampled(true); - state.lastPosition(player.getX(), player.getY(), player.getZ()); - state.lastSample(now); int blockX = floor(player.getX()); int blockY = floor(player.getY()) - getEngine().getWorld().minHeight(); int blockZ = floor(player.getZ()); - state.biome(getEngine().getBiome(blockX, blockY, blockZ)); - state.region(getEngine().getRegion(blockX, blockY, blockZ)); + BiomeEnvironment environment = getEngine().getBiomeEnvironment(blockX, blockY, blockZ); + state.environment = environment; + state.sampled(true); + state.lastPosition(player.getX(), player.getY(), player.getZ()); + state.lastSample(now); } private void applyEffects(ServerLevel level, ServerPlayer player, Iterable effects) { @@ -299,9 +303,12 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement double sideways = RNG.r.d(-effect.getParticleDistanceWidth(), effect.getParticleDistanceWidth()); double surfaceX = player.getX() + direction.x * forward + direction.z * sideways; double surfaceZ = player.getZ() + direction.z * forward - direction.x * sideways; - int surfaceY = getEngine().getHeight(floor(surfaceX), floor(surfaceZ)); + if (level.getChunkSource().getChunkNow(floor(surfaceX) >> 4, floor(surfaceZ) >> 4) == null) { + return; + } + int surfaceY = level.getHeight(Heightmap.Types.OCEAN_FLOOR, floor(surfaceX), floor(surfaceZ)); double x = surfaceX + RNG.r.d(); - double y = surfaceY + 1.0D + level.getMinY() + RNG.r.i(effect.getParticleOffset()); + double y = surfaceY + RNG.r.i(effect.getParticleOffset()); double z = surfaceZ + RNG.r.d(); double altX = randomized(effect.getParticleAltX(), effect.isRandomAltX()); double altY = randomized(effect.getParticleAltY(), effect.isRandomAltY()); @@ -421,8 +428,7 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement private static final class PlayerState { private ServerPlayer player; - private IrisBiome biome; - private IrisRegion region; + private BiomeEnvironment environment; private double lastX; private double lastY; private double lastZ; @@ -446,22 +452,6 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement this.player = player; } - private IrisBiome biome() { - return biome; - } - - private void biome(IrisBiome biome) { - this.biome = biome; - } - - private IrisRegion region() { - return region; - } - - private void region(IrisRegion region) { - this.region = region; - } - private double lastX() { return lastX; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index eee1768d6..d62dfd464 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -175,7 +175,7 @@ final class ModdedGenerationHistoryStorage { GenerationEpoch epoch = history.activeEpoch(); Path packRoot = normalize(history.activePackRoot()); validatePack(packRoot, epoch.packFingerprint()); - validateDimension(packRoot, epoch.dimensionContract()); + validateDimension(packRoot, epoch); return new ActivePack( packRoot, epoch.dimensionContract().dimensionKey(), @@ -194,8 +194,9 @@ final class ModdedGenerationHistoryStorage { private static void validateDimension( Path packRoot, - GenerationEpoch.DimensionContract recordedContract + GenerationEpoch epoch ) throws IOException { + GenerationEpoch.DimensionContract recordedContract = epoch.dimensionContract(); IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); try { IrisDimension dimension = data.getDimensionLoader().load(recordedContract.dimensionKey(), false); @@ -203,10 +204,10 @@ final class ModdedGenerationHistoryStorage { throw new IOException("Immutable generation pack does not contain dimension '" + recordedContract.dimensionKey() + "'."); } - GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.create( + GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch( dimension, - dimension.getLoadKey(), - recordedContract.dimensionTypeKey() + recordedContract.dimensionTypeKey(), + epoch ); if (!recordedContract.equals(actualContract)) { throw new IOException("Immutable generation pack no longer matches its recorded dimension contract."); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java index 18146ca3e..52e162a1f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java @@ -137,10 +137,11 @@ final class ModdedSpawnTableMerger { WeightedList mergedSpawnTable( Engine engine, Biome biome, + Biome vanillaBiome, MobCategory category, WeightedList vanillaSpawns, WeightedList explicitSpawns) { - SpawnTableKey key = new SpawnTableKey(engine.getCacheID(), biome, category); + SpawnTableKey key = new SpawnTableKey(engine.getCacheID(), biome, vanillaBiome, category); return mergedSpawnTables.computeIfAbsent( key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns) @@ -153,7 +154,7 @@ final class ModdedSpawnTableMerger { initializedRuntimeIdentities.remove(runtimeIdentity); } - private Holder resolveBiomeHolder(Registry registry, String key) { + Holder resolveBiomeHolder(Registry registry, String key) { if (key == null || key.isBlank()) { return null; } @@ -176,6 +177,6 @@ final class ModdedSpawnTableMerger { private record SpawnBiomeKey(int runtimeIdentity, Biome biome) { } - private record SpawnTableKey(int runtimeIdentity, Biome biome, MobCategory category) { + private record SpawnTableKey(int runtimeIdentity, Biome biome, Biome vanillaBiome, MobCategory category) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 36cad303c..2e16198b3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -22,6 +22,8 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.LootResolver; @@ -32,7 +34,6 @@ import art.arcane.iris.engine.object.IrisEntitySpawn; import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRange; -import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawnGroup; import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.engine.object.IrisSurface; @@ -53,12 +54,16 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.NaturalSpawner; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.phys.AABB; import java.util.HashSet; +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; @@ -152,7 +157,7 @@ public final class ModdedWorldManager implements EngineWorldManager { } private void recoverLoadedInitialSpawns(ServerLevel level) { - if (!markerSystemEnabled()) { + if (!markerSystemEnabled() && !ambientSystemEnabled()) { return; } long now = System.currentTimeMillis(); @@ -194,7 +199,7 @@ public final class ModdedWorldManager implements EngineWorldManager { if (initialSpawnQueue.isEmpty()) { return; } - if (!markerSystemEnabled()) { + if (!markerSystemEnabled() && !ambientSystemEnabled()) { initialSpawnQueue.clear(); return; } @@ -217,6 +222,8 @@ public final class ModdedWorldManager implements EngineWorldManager { retry = true; warmupMantleChunkAsync(key, chunkX, chunkZ); } + } catch (SavedBiomeUnavailableException unavailable) { + retry = unavailable.isLoading(); } catch (Throwable e) { IrisLogging.reportError(e); retry = true; @@ -230,7 +237,7 @@ public final class ModdedWorldManager implements EngineWorldManager { } } - private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { + boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { if (!ModdedEntitySpawner.chunksSafe(level, chunkX, chunkZ)) { return false; } @@ -244,9 +251,15 @@ public final class ModdedWorldManager implements EngineWorldManager { MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); try { + List markers = markerSystemEnabled() + ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of(); + Optional environment = ambientSystemEnabled() + ? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty(); chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { - spawnMarkerSpawners(level, chunkX, chunkZ, chunk, true); - scheduleInitialFollowUp(level, chunkX, chunkZ); + spawnPreparedMarkers(level, markers, true); + if (environment.isPresent()) { + scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get()); + } }); } finally { chunk.release(); @@ -254,7 +267,7 @@ public final class ModdedWorldManager implements EngineWorldManager { return true; } - private void scheduleInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) { + private void scheduleInitialFollowUp(ServerLevel level, int chunkX, int chunkZ, BiomeEnvironment environment) { ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable."); @@ -264,11 +277,11 @@ public final class ModdedWorldManager implements EngineWorldManager { () -> EngineLifecycleTasks.run( engine, "modded_world_manager_initial_spawn_followup", - () -> runInitialFollowUp(level, chunkX, chunkZ)), + () -> runInitialFollowUp(level, chunkX, chunkZ, environment)), RNG.r.i(5, 200)); } - private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) { + private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ, BiomeEnvironment environment) { Mantle mantle = engine.getMantle().getMantle(); if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) { return; @@ -280,16 +293,10 @@ public final class ModdedWorldManager implements EngineWorldManager { return; } - MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); - try { - if (markerSystemEnabled()) { - spawnMarkerSpawners(level, chunkX, chunkZ, chunk, false); + if (ambientSystemEnabled()) { + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + spawnAmbient(level, chunkX, chunkZ, true, environment); } - if (ambientSystemEnabled()) { - spawnAmbient(level, chunkX, chunkZ, true); - } - } finally { - chunk.release(); } } @@ -360,6 +367,8 @@ public final class ModdedWorldManager implements EngineWorldManager { long key = ambientChunkSample[RNG.r.nextInt(sampled)]; try { ambientSpawnChunk(level, unpackX(key), unpackZ(key)); + } catch (SavedBiomeUnavailableException unavailable) { + continue; } catch (Throwable e) { IrisLogging.reportError(e); } @@ -378,7 +387,7 @@ public final class ModdedWorldManager implements EngineWorldManager { MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); try { if (markerSystemEnabled()) { - spawnMarkerSpawners(level, chunkX, chunkZ, chunk, false); + spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false); } if (ambientSystemEnabled()) { spawnAmbient(level, chunkX, chunkZ, false); @@ -388,29 +397,38 @@ public final class ModdedWorldManager implements EngineWorldManager { } } - private void spawnMarkerSpawners(ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk, boolean initial) { + private List prepareMarkerSpawns(ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { int minHeight = engine.getWorld().minHeight(); KList obstructed = new KList<>(); + List prepared = new ArrayList<>(); chunk.iterate(MatterMarker.class, (Integer x, Integer yf, Integer z, MatterMarker marker) -> { String tag = marker.getTag(); if (tag.equals("cave_floor") || tag.equals("cave_ceiling")) { return; } - IrisMarker resolved = engine.getData().getMarkerLoader().load(tag); - if (resolved == null) { - return; - } - int worldX = (chunkX << 4) + (x & 15); int worldZ = (chunkZ << 4) + (z & 15); int worldY = yf + minHeight; + BiomeEnvironment environment; + try { + environment = engine.getBiomeEnvironment(worldX, yf, worldZ); + } catch (SavedBiomeUnavailableException unavailable) { + if (unavailable.isLoading()) { + throw unavailable; + } + return; + } + IrisMarker resolved = environment.data().getMarkerLoader().load(tag); + if (resolved == null) { + return; + } if (resolved.isEmptyAbove() && aboveObstructed(level, worldX, worldY, worldZ)) { obstructed.add(new IrisPosition(worldX, yf, worldZ)); return; } - KList spawners = resolveMarkerSpawners(resolved); + KList spawners = resolveMarkerSpawners(resolved, environment); if (spawners.isEmpty()) { return; } @@ -418,18 +436,19 @@ public final class ModdedWorldManager implements EngineWorldManager { if (chosen == null) { return; } - spawnFromSpawner(level, new IrisPosition(worldX, worldY, worldZ), chosen, initial); + prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment)); }); Mantle mantle = engine.getMantle().getMantle(); for (IrisPosition position : obstructed) { mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); } + return List.copyOf(prepared); } - private KList resolveMarkerSpawners(IrisMarker marker) { + private KList resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) { KList spawners = new KList<>(); for (String key : marker.getSpawners()) { - IrisSpawner spawner = engine.getData().getSpawnerLoader().load(key); + IrisSpawner spawner = environment.data().getSpawnerLoader().load(key); if (spawner == null) { IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName()); continue; @@ -440,6 +459,14 @@ public final class ModdedWorldManager implements EngineWorldManager { return spawners; } + private void spawnPreparedMarkers(ServerLevel level, List markers, boolean initial) { + for (PreparedMarkerSpawn marker : markers) { + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) { + spawnFromSpawner(level, marker.position(), marker.spawner(), initial); + } + } + } + private void spawnFromSpawner(ServerLevel level, IrisPosition position, IrisSpawner spawner, boolean initial) { KList spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); if (spawns.isEmpty()) { @@ -465,22 +492,41 @@ public final class ModdedWorldManager implements EngineWorldManager { } } + private Optional resolveSurfaceEnvironment(int chunkX, int chunkZ) { + try { + return Optional.of(engine.getSurfaceBiomeEnvironment((chunkX << 4) + 8, (chunkZ << 4) + 8)); + } catch (SavedBiomeUnavailableException unavailable) { + if (unavailable.isLoading()) { + throw unavailable; + } + return Optional.empty(); + } + } + private void spawnAmbient(ServerLevel level, int chunkX, int chunkZ, boolean initial) { + Optional resolved = resolveSurfaceEnvironment(chunkX, chunkZ); + if (resolved.isEmpty()) { + return; + } + BiomeEnvironment environment = resolved.get(); + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + spawnAmbient(level, chunkX, chunkZ, initial, environment); + } + } + + private void spawnAmbient(ServerLevel level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) { IrisComplex complex = engine.getComplex(); if (complex == null) { return; } - int blockX = chunkX << 4; - int blockZ = chunkZ << 4; - IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); - IrisRegion region = engine.getRegion(blockX, blockZ); + IrisBiome biome = environment.biome(); int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ); KList pool = new KList<>(); - collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(engine.getDimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); - collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(region.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); - collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); if (pool.isEmpty()) { return; } @@ -556,8 +602,8 @@ public final class ModdedWorldManager implements EngineWorldManager { } else { worldX = (chunkX << 4) + RNG.r.i(16); worldZ = (chunkZ << 4) + RNG.r.i(16); - int surfaceY = level.getMinY() + engine.getHeight(worldX, worldZ, false); - int solidY = level.getMinY() + engine.getHeight(worldX, worldZ, true); + int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, worldX, worldZ) - 1; + int solidY = level.getHeight(Heightmap.Types.OCEAN_FLOOR, worldX, worldZ) - 1; worldY = group == IrisSpawnGroup.NORMAL ? surfaceY + 1 : RNG.r.i(solidY + 1, surfaceY); @@ -864,4 +910,8 @@ public final class ModdedWorldManager implements EngineWorldManager { } return failure; } + + private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) { + } + } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java index 7404d40dc..6ed63a213 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java @@ -24,6 +24,7 @@ import art.arcane.iris.core.localization.IrisMessages; import art.arcane.iris.modded.localization.ModdedCommandMessages; import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.object.IrisBiome; @@ -194,6 +195,13 @@ public final class ModdedWhatCommands { MessageArgument.untrusted("biome", nativeBiome.key()), MessageArgument.trusted("id", nativeBiome.id()))); return 1; + } catch (SavedBiomeUnavailableException error) { + if (error.getCause() != null) { + ModdedIrisLog.error("Iris saved biome lookup failed in {}", + source.getLevel().dimension().identifier(), error); + } + IrisModdedCommands.fail(source, error.getMessage()); + return 0; } catch (Throwable error) { logLookupFailure(source, "biome", error, ModdedCommandMessages.IRIS_MODDED_COMMANDS_BIOME_LOOKUP_FAILED_2); @@ -223,6 +231,13 @@ public final class ModdedWhatCommands { MessageArgument.untrusted("region", region.getLoadKey()), MessageArgument.untrusted("name", region.getName()))); return 1; + } catch (SavedBiomeUnavailableException error) { + if (error.getCause() != null) { + ModdedIrisLog.error("Iris saved biome lookup failed in {}", + source.getLevel().dimension().identifier(), error); + } + IrisModdedCommands.fail(source, error.getMessage()); + return 0; } catch (Throwable error) { logLookupFailure(source, "region", error, ModdedCommandMessages.IRIS_MODDED_COMMANDS_REGION_LOOKUP_FAILED_2); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java new file mode 100644 index 000000000..a2b5a7867 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java @@ -0,0 +1,131 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerPlayer; +import org.junit.Test; +import org.junit.BeforeClass; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import org.mockito.MockedStatic; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedSavedBiomeConsumersTest { + @BeforeClass + public static void initializeRuntimeTypes() throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + PlatformBlockState air = mock(PlatformBlockState.class); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState("AIR")).thenReturn(air); + Class.forName(EngineMantle.class.getName()); + } + } + + @Test + @SuppressWarnings("unchecked") + public void loadingDefinitionsLeaveInitialFlagUnclaimed() { + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + ServerLevel level = mock(ServerLevel.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.isChunkLoaded(2, -1)).thenReturn(true); + when(mantle.getChunk(2, -1)).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + BiomeEnvironment environment = environment(); + when(engine.getSurfaceBiomeEnvironment(40, -8)) + .thenThrow(new SavedBiomeUnavailableException("Loading", true)) + .thenReturn(environment); + IrisSettings settings = new IrisSettings(); + settings.getWorld().setAmbientEntitySpawningSystem(true); + settings.getWorld().setMarkerEntitySpawningSystem(false); + ModdedWorldManager manager = new ModdedWorldManager(engine); + + try (MockedStatic configured = mockStatic(IrisSettings.class); + MockedStatic spawns = mockStatic(ModdedEntitySpawner.class)) { + configured.when(IrisSettings::get).thenReturn(settings); + spawns.when(() -> ModdedEntitySpawner.chunksSafe(level, 2, -1)).thenReturn(true); + assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(level, 2, -1)); + verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any()); + assertTrue(manager.initialSpawnChunk(level, 2, -1)); + verify(chunk).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any()); + } finally { + manager.close(); + } + } + + @Test + public void playerEffectsRetryLoadingWithoutMovement() throws Exception { + Engine engine = mock(Engine.class); + IrisWorld world = mock(IrisWorld.class); + ServerLevel level = mock(ServerLevel.class); + ServerPlayer player = mock(ServerPlayer.class); + when(engine.getWorld()).thenReturn(world); + when(world.minHeight()).thenReturn(-64); + when(player.isAlive()).thenReturn(true); + when(player.level()).thenReturn(level); + BiomeEnvironment environment = environment(); + when(engine.getBiomeEnvironment(0, 64, 0)) + .thenThrow(new SavedBiomeUnavailableException("Loading", true)) + .thenReturn(environment); + IrisSettings settings = new IrisSettings(); + settings.getWorld().setEffectSystem(false); + Class stateType = Class.forName(ModdedEngineEffects.class.getName() + "$PlayerState"); + Constructor constructor = stateType.getDeclaredConstructor(ServerPlayer.class); + constructor.setAccessible(true); + Object state = constructor.newInstance(player); + Method tick = ModdedEngineEffects.class.getDeclaredMethod("tickPlayer", ServerLevel.class, stateType); + tick.setAccessible(true); + Field selected = stateType.getDeclaredField("environment"); + selected.setAccessible(true); + + try (MockedStatic configured = mockStatic(IrisSettings.class)) { + configured.when(IrisSettings::get).thenReturn(settings); + ModdedEngineEffects effects = new ModdedEngineEffects(engine); + tick.invoke(effects, level, state); + assertNull(selected.get(state)); + tick.invoke(effects, level, state); + assertSame(environment, selected.get(state)); + verify(engine, times(2)).getBiomeEnvironment(0, 64, 0); + } + } + + private static BiomeEnvironment environment() { + return new BiomeEnvironment(3L, mock(IrisBiome.class), mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java b/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java index 57864139f..029be4f0e 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java @@ -47,6 +47,15 @@ public final class RuntimeProgressMessages { public static final TextKey WORLD_CREATE_STAGE_PREGENERATE = TextKey.of("iris.runtime.world_create.stage.pregenerate", "Pregenerating"); public static final TextKey WORLD_CREATE_STAGE_FINALIZE = TextKey.of("iris.runtime.world_create.stage.finalize", "Finalizing"); public static final TextKey WORLD_CREATE_STAGE_COMPLETE = TextKey.of("iris.runtime.world_create.stage.complete", "World ready"); + public static final TextKey WORLD_REPLACE_START = TextKey.of("iris.runtime.world_replace.start", C.YELLOW + "Preparing Iris replacement for {world}. The current world remains active until restart."); + public static final TextKey WORLD_REPLACE_PROGRESS = TextKey.of("iris.runtime.world_replace.progress", C.YELLOW + "Replacement for {world}: {stage} ({elapsed}s elapsed)"); + public static final TextKey WORLD_REPLACE_STAGE_DATAPACKS = TextKey.of("iris.runtime.world_replace.stage.datapacks", "Checking dimension datapacks"); + public static final TextKey WORLD_REPLACE_STAGE_SEED = TextKey.of("iris.runtime.world_replace.stage.seed", "Preparing the replacement seed"); + public static final TextKey WORLD_REPLACE_STAGE_PACK = TextKey.of("iris.runtime.world_replace.stage.pack", "Copying and validating the dimension pack"); + public static final TextKey WORLD_REPLACE_STAGE_PLAYERS = TextKey.of("iris.runtime.world_replace.stage.players", "Recording player entry protection"); + public static final TextKey WORLD_REPLACE_STAGE_VERIFY = TextKey.of("iris.runtime.world_replace.stage.verify", "Verifying the staged replacement"); + public static final TextKey WORLD_REPLACE_STAGE_SAVE = TextKey.of("iris.runtime.world_replace.stage.save", "Saving the replacement for restart"); + public static final TextKey WORLD_REPLACE_STAGE_CLEANUP = TextKey.of("iris.runtime.world_replace.stage.cleanup", "Cleaning up the failed replacement"); public static final TextKey WORLD_PREGEN_ACTION = TextKey.of("iris.runtime.world_create.pregen.action", "{bar}" + C.GRAY + " " + C.YELLOW + "{percent}%" + C.GRAY + " | " + C.WHITE + "Pregenerating"); public static final TextKey WORLD_PREGEN_CONSOLE = TextKey.of("iris.runtime.world_create.pregen.console", C.GOLD + "Pregenerating " + C.YELLOW + "{percent}%"); public static final TextKey CHUNK_TITLE_REGEN = TextKey.of("iris.runtime.chunk_job.title.regen", "Regen"); @@ -140,6 +149,15 @@ public final class RuntimeProgressMessages { WORLD_CREATE_STAGE_PREGENERATE, WORLD_CREATE_STAGE_FINALIZE, WORLD_CREATE_STAGE_COMPLETE, + WORLD_REPLACE_START, + WORLD_REPLACE_PROGRESS, + WORLD_REPLACE_STAGE_DATAPACKS, + WORLD_REPLACE_STAGE_SEED, + WORLD_REPLACE_STAGE_PACK, + WORLD_REPLACE_STAGE_PLAYERS, + WORLD_REPLACE_STAGE_VERIFY, + WORLD_REPLACE_STAGE_SAVE, + WORLD_REPLACE_STAGE_CLEANUP, WORLD_PREGEN_ACTION, WORLD_PREGEN_CONSOLE, CHUNK_TITLE_REGEN, diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 899355286..cdde04c18 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EngineMetrics; import art.arcane.iris.engine.framework.EngineMode; @@ -130,6 +131,9 @@ public class IrisEngine implements Engine { final ThreadLocal runtimeAssembly = new ThreadLocal<>(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) + final ThreadLocal biomeEnvironmentScopes = new ThreadLocal<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) final GenerationRuntimeScopeState generationRuntimeScopes = new GenerationRuntimeScopeState(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -543,18 +547,112 @@ public class IrisEngine implements Engine { } } + @Override + public BiomeEnvironment getBiomeEnvironment(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome environment")) { + return Engine.super.getBiomeEnvironment(x, y, z); + } + } + + @Override + public BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, 0, z, true); + if (saved.isPresent()) { + return saved.get(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a surface biome environment")) { + return Engine.super.getSurfaceBiomeEnvironment(x, z); + } + } + + @Override + public BiomeEnvironment.Scope openBiomeEnvironmentScope(BiomeEnvironment environment) { + BiomeEnvironmentBinding binding = new BiomeEnvironmentBinding( + Objects.requireNonNull(environment, "environment"), biomeEnvironmentScopes.get()); + biomeEnvironmentScopes.set(binding); + return binding; + } + + private Optional resolveSavedBiomeEnvironment(int x, int y, int z, boolean surface) { + if (!usesSavedBiomeEnvironment()) { + return Optional.empty(); + } + return generationHistoryRuntimeRouter.biomes().resolve(x, y + getWorld().minHeight(), z, surface); + } + + private boolean usesSavedBiomeEnvironment() { + if (hasGenerationRuntimeScope() || generationHistoryRuntimeRouter == null) { + return false; + } + IrisContext context = IrisContext.get(); + return context == null || context.getEngine() != this || context.getChunkContext() == null; + } + + @BlockCoordinates + @Override + public IrisBiome getBiome(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get().biome(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome")) { + return Engine.super.getBiome(x, y, z); + } + } + + @BlockCoordinates + @Override + public IrisBiome getBiomeOrMantle(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get().biome(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome or mantle biome")) { + return Engine.super.getBiomeOrMantle(x, y, z); + } + } + @BlockCoordinates @Override public IrisRegion getRegion(int x, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, 0, z, true); + if (saved.isPresent()) { + return saved.get().region(); + } try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a region")) { return Engine.super.getRegion(x, z); } } + @BlockCoordinates + @Override + public IrisRegion getRegion(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get().region(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a vertical region")) { + return Engine.super.getRegion(x, y, z); + } + } + @BlockCoordinates @Override public IrisBiome getCaveOrMantleBiome(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get().biome(); + } try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave or mantle biome")) { return Engine.super.getCaveOrMantleBiome(x, y, z); @@ -564,6 +662,12 @@ public class IrisEngine implements Engine { @BlockCoordinates @Override public IrisBiome getCaveBiome(int x, int z) { + if (usesSavedBiomeEnvironment()) { + Optional saved = generationHistoryRuntimeRouter.biomes().resolveCaveBase(x, z); + if (saved.isPresent()) { + return saved.get().biome(); + } + } if (usesScopedNaturalTerrain()) { return Engine.super.getCaveBiome(x, z); } @@ -587,6 +691,10 @@ public class IrisEngine implements Engine { int z, IrisDimensionCarvingResolver.State state ) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get().biome(); + } if (usesScopedNaturalTerrain()) { return Engine.super.getCaveBiome(x, y, z, state); } @@ -599,6 +707,10 @@ public class IrisEngine implements Engine { @BlockCoordinates @Override public IrisBiome getSurfaceBiome(int x, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, 0, z, true); + if (saved.isPresent()) { + return saved.get().biome(); + } if (usesScopedNaturalTerrain()) { return Engine.super.getSurfaceBiome(x, z); } @@ -1183,6 +1295,10 @@ public class IrisEngine implements Engine { @Override public IrisData getData() { + BiomeEnvironmentBinding environment = biomeEnvironmentScopes.get(); + if (environment != null && !hasGenerationRuntimeScope()) { + return environment.environment.data(); + } RuntimeAssembly assembly = runtimeAssembly.get(); if (assembly != null) { return assembly.target.getData(); @@ -1193,6 +1309,10 @@ public class IrisEngine implements Engine { @Override public IrisDimension getDimension() { + BiomeEnvironmentBinding environment = biomeEnvironmentScopes.get(); + if (environment != null && !hasGenerationRuntimeScope()) { + return environment.environment.dimension(); + } RuntimeAssembly assembly = runtimeAssembly.get(); if (assembly != null) { return assembly.target.getDimension(); @@ -1608,4 +1728,36 @@ public class IrisEngine implements Engine { } } + + private final class BiomeEnvironmentBinding implements BiomeEnvironment.Scope { + private final BiomeEnvironment environment; + private final BiomeEnvironmentBinding previous; + private final Thread owner = Thread.currentThread(); + private boolean closed; + + private BiomeEnvironmentBinding(BiomeEnvironment environment, BiomeEnvironmentBinding previous) { + this.environment = environment; + this.previous = previous; + } + + @Override + public void close() { + if (Thread.currentThread() != owner) { + throw new IllegalStateException("Biome environment scope must close on its owning thread."); + } + if (closed) { + return; + } + if (biomeEnvironmentScopes.get() != this) { + throw new IllegalStateException("Biome environment scopes must close in reverse order."); + } + if (previous == null) { + biomeEnvironmentScopes.remove(); + } else { + biomeEnvironmentScopes.set(previous); + } + closed = true; + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java index cf752e7d0..4e8331ce5 100644 --- a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java +++ b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java @@ -18,6 +18,10 @@ package art.arcane.iris.engine; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; + import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisPosition; @@ -28,13 +32,16 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.matter.MatterMarker; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; import org.bukkit.Chunk; import org.bukkit.World; import java.util.Map; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiConsumer; +import java.util.function.Consumer; /** * Resolves the mantle spawn markers of a chunk into spawners. On Folia the mantle read runs @@ -49,80 +56,10 @@ final class MarkerSpawnScanner { this.manager = manager; } - Map> getSpawnersFromMarkers(Chunk c) { - Map> p = new KMap<>(); - Set b = new KSet<>(); - - if (J.isFolia()) { - if (!manager.getMantle().isChunkLoaded(c.getX(), c.getZ())) { - manager.chunkMaintenance.warmupMantleChunkAsync(c.getX(), c.getZ()); - } - return p; - } - - manager.getMantle().iterateChunk(c.getX(), c.getZ(), MatterMarker.class, (x, y, z, t) -> { - if (t.getTag().equals("cave_floor") || t.getTag().equals("cave_ceiling")) { - return; - } - - IrisMarker mark = manager.getData().getMarkerLoader().load(t.getTag()); - if (mark == null) { - return; - } - - IrisPosition pos = new IrisPosition((c.getX() << 4) + x, y, (c.getZ() << 4) + z); - - if (isMarkerObstructed(c, pos, mark.isEmptyAbove())) { - b.add(pos); - return; - } - - for (String i : mark.getSpawners()) { - IrisSpawner m = manager.getData().getSpawnerLoader().load(i); - if (m == null) { - IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); - continue; - } - if (m.isCompatExcluded()) { - continue; - } - m.setReferenceMarker(mark); - - // This is so fucking incorrect its a joke - //noinspection ConstantConditions - if (m != null) { - p.computeIfAbsent(pos, (k) -> new KSet<>()).add(m); - } - } - }); - - for (IrisPosition i : b) { - manager.getEngine().getMantle().getMantle().remove(i.getX(), i.getY(), i.getZ(), MatterMarker.class); - } - - return p; - } - - void forEachMarkerSpawner(Chunk c, BiConsumer> consumer) { - if (c == null || consumer == null) { - return; - } - - if (!J.isFolia()) { - int minY = manager.getEngine().getWorld().minHeight(); - getSpawnersFromMarkers(c).forEach((relative, spawners) -> { - if (spawners.isEmpty()) { - return; - } - - consumer.accept(new IrisPosition(relative.getX(), relative.getY() + minY, relative.getZ()), spawners); - }); - return; - } - - int chunkX = c.getX(); - int chunkZ = c.getZ(); - World world = c.getWorld(); + void scanMarkerSpawners(Chunk chunk, boolean initialOnly, Consumer> consumer) { + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + World world = chunk.getWorld(); long key = Cache.key(chunkX, chunkZ); if (!markerScanQueue.add(key)) { return; @@ -130,57 +67,82 @@ final class MarkerSpawnScanner { J.a(manager.managedTask("bukkit_world_manager_marker_scan", () -> { try { - Map markerData = collectMarkerSpawnData(chunkX, chunkZ); - if (markerData.isEmpty()) { + if (initialOnly && manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) { + markerScanQueue.remove(key); return; } - - J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_marker_scan_region", () -> { - if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { - return; - } - - Chunk chunk = world.getChunkAt(chunkX, chunkZ); - int minY = manager.getEngine().getWorld().minHeight(); - markerData.forEach((relative, data) -> { - if (data.spawners.isEmpty()) { - return; - } - - if (isMarkerObstructed(chunk, relative, data.requiresEmptyAbove)) { - removeMarkerAsync(relative); - return; - } - - consumer.accept(new IrisPosition(relative.getX(), relative.getY() + minY, relative.getZ()), data.spawners); - }); - })); - } catch (Throwable e) { - IrisLogging.reportError(e); - } finally { + Map markerData = collectMarkerSpawnData(chunkX, chunkZ); + boolean accepted = J.runRegion(world, chunkX, chunkZ, + manager.managedTask("bukkit_world_manager_marker_scan_region", () -> { + try { + if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { + return; + } + Chunk loaded = world.getChunkAt(chunkX, chunkZ); + int minimumY = manager.getEngine().getWorld().minHeight(); + List prepared = new ArrayList<>(markerData.size()); + for (Map.Entry entry : markerData.entrySet()) { + IrisPosition position = entry.getKey(); + MarkerSpawnData data = entry.getValue(); + if (data.spawners.isEmpty()) { + continue; + } + if (isMarkerObstructed(loaded, position, data.requiresEmptyAbove)) { + removeMarkerAsync(position); + continue; + } + prepared.add(new PreparedMarkerSpawn(new IrisPosition(position.getX(), + position.getY() + minimumY, position.getZ()), data.spawners, data.environment)); + } + consumer.accept(List.copyOf(prepared)); + } finally { + markerScanQueue.remove(key); + } + }, () -> markerScanQueue.remove(key))); + if (!accepted) { + markerScanQueue.remove(key); + } + } catch (SavedBiomeUnavailableException e) { markerScanQueue.remove(key); + } catch (Throwable e) { + markerScanQueue.remove(key); + IrisLogging.reportError(e); } }, () -> markerScanQueue.remove(key))); } private Map collectMarkerSpawnData(int chunkX, int chunkZ) { + if (manager.getEngine() instanceof IrisEngine engine) { + engine.getGenerationHistoryRuntimeRouter().ifPresent(router -> router.biomes().prepareChunk(chunkX, chunkZ)); + } Map markerData = new KMap<>(); manager.getMantle().iterateChunk(chunkX, chunkZ, MatterMarker.class, (x, y, z, t) -> { if (t.getTag().equals("cave_floor") || t.getTag().equals("cave_ceiling")) { return; } - IrisMarker mark = manager.getData().getMarkerLoader().load(t.getTag()); + BiomeEnvironment environment; + try { + environment = manager.getEngine().getBiomeEnvironment((chunkX << 4) + x, y, + (chunkZ << 4) + z); + } catch (SavedBiomeUnavailableException e) { + if (e.isLoading()) { + throw e; + } + return; + } + IrisData definitions = environment.data(); + IrisMarker mark = definitions.getMarkerLoader().load(t.getTag()); if (mark == null) { return; } IrisPosition position = new IrisPosition((chunkX << 4) + x, y, (chunkZ << 4) + z); - MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData()); + MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment)); data.requiresEmptyAbove = data.requiresEmptyAbove || mark.isEmptyAbove(); for (String i : mark.getSpawners()) { - IrisSpawner spawner = manager.getData().getSpawnerLoader().load(i); + IrisSpawner spawner = definitions.getSpawnerLoader().load(i); if (spawner == null) { IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); continue; @@ -223,8 +185,16 @@ final class MarkerSpawnScanner { })); } + record PreparedMarkerSpawn(IrisPosition position, KSet spawners, BiomeEnvironment environment) { + } + private static final class MarkerSpawnData { private final KSet spawners = new KSet<>(); + private final BiomeEnvironment environment; private boolean requiresEmptyAbove; + + private MarkerSpawnData(BiomeEnvironment environment) { + this.environment = environment; + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/ResolvedTerrainProvider.java b/core/src/main/java/art/arcane/iris/engine/ResolvedTerrainProvider.java index 9c6553b23..dad87b1e1 100644 --- a/core/src/main/java/art/arcane/iris/engine/ResolvedTerrainProvider.java +++ b/core/src/main/java/art/arcane/iris/engine/ResolvedTerrainProvider.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.history.BoundaryColumnGeometry; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.FloatingBiomeOverlay; import art.arcane.iris.engine.history.SavedTerrainChunk; import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -29,10 +30,10 @@ public final class ResolvedTerrainProvider { private static final int MAXIMUM_HEIGHT_CHUNKS = 4_096; private final Engine engine; - private final LinkedHashMap completed = new LinkedHashMap<>(64, 0.75F, true); - private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); - private final LinkedHashMap rawCompleted = new LinkedHashMap<>(64, 0.75F, true); - private final ConcurrentHashMap> rawPending = new ConcurrentHashMap<>(); + private final LinkedHashMap completed = new LinkedHashMap<>(64, 0.75F, true); + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final LinkedHashMap rawCompleted = new LinkedHashMap<>(64, 0.75F, true); + private final ConcurrentHashMap> rawPending = new ConcurrentHashMap<>(); private final LinkedHashMap heights = new LinkedHashMap<>(64, 0.75F, true); public ResolvedTerrainProvider(Engine engine) { @@ -40,7 +41,7 @@ public final class ResolvedTerrainProvider { } public TerrainBoundarySignature column(int blockX, int blockZ) { - return resolve(blockX >> 4, blockZ >> 4).column(blockX, blockZ); + return resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ); } public int height(int blockX, int blockZ, boolean ignoreFluid) { @@ -51,7 +52,7 @@ public final class ResolvedTerrainProvider { return cached[(ignoreFluid ? 256 : 0) + (blockX & 15) * 16 + (blockZ & 15)]; } } - TerrainBoundarySignature column = resolve(blockX >> 4, blockZ >> 4).column(blockX, blockZ); + TerrainBoundarySignature column = resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ); return ignoreFluid ? column.oceanFloorHeight() : column.surfaceHeight(); } @@ -60,7 +61,9 @@ public final class ResolvedTerrainProvider { SavedTerrainChunk terrain; if (context.getComplex().getTransitionGenerationPlan() != null && context.getComplex().getTransitionGenerationPlan().hasTransitionAtChunk(x >> 4, z >> 4)) { - terrain = resolve(x >> 4, z >> 4); + ResolvedTerrain resolved = resolve(x >> 4, z >> 4); + terrain = resolved.terrain(); + context.setFloatingBiomes(resolved.floatingBiomes()); copy(terrain, blocks, biomes, context); } else { mode.generateTerrain(x, z, blocks, biomes, multicore, context); @@ -70,6 +73,7 @@ public final class ResolvedTerrainProvider { GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); if (router != null) { router.recordNaturalTerrain(terrain.boundaryOnly()); + router.recordFloatingBiomes(x >> 4, z >> 4, context.getFloatingBiomes()); } } } @@ -86,36 +90,36 @@ public final class ResolvedTerrainProvider { } } - private SavedTerrainChunk resolve(int chunkX, int chunkZ) { + private ResolvedTerrain resolve(int chunkX, int chunkZ) { return resolve(chunkX, chunkZ, false); } - private SavedTerrainChunk resolve(int chunkX, int chunkZ, boolean raw) { - LinkedHashMap cache = raw ? rawCompleted : completed; - ConcurrentHashMap> loads = raw ? rawPending : pending; + private ResolvedTerrain resolve(int chunkX, int chunkZ, boolean raw) { + LinkedHashMap cache = raw ? rawCompleted : completed; + ConcurrentHashMap> loads = raw ? rawPending : pending; long key = chunkKey(chunkX, chunkZ); synchronized (cache) { - SavedTerrainChunk cached = cache.get(key); + ResolvedTerrain cached = cache.get(key); if (cached != null) { return cached; } } - CompletableFuture result = new CompletableFuture<>(); - CompletableFuture existing = loads.putIfAbsent(key, result); + CompletableFuture result = new CompletableFuture<>(); + CompletableFuture existing = loads.putIfAbsent(key, result); if (existing != null) { return existing.join(); } try { synchronized (cache) { - SavedTerrainChunk published = cache.get(key); + ResolvedTerrain published = cache.get(key); if (published != null) { result.complete(published); return published; } } - SavedTerrainChunk terrain = raw ? compute(chunkX, chunkZ) : contain(chunkX, chunkZ); + ResolvedTerrain terrain = raw ? compute(chunkX, chunkZ) : contain(chunkX, chunkZ); if (!raw) { - cacheHeights(key, terrain); + cacheHeights(key, terrain.terrain()); } synchronized (cache) { cache.put(key, terrain); @@ -156,14 +160,15 @@ public final class ResolvedTerrainProvider { } } - private SavedTerrainChunk contain(int chunkX, int chunkZ) { - SavedTerrainChunk raw = resolve(chunkX, chunkZ, true); + private ResolvedTerrain contain(int chunkX, int chunkZ) { + ResolvedTerrain raw = resolve(chunkX, chunkZ, true); TransitionGenerationPlan plan = engine.getComplex().getTransitionGenerationPlan(); if (plan == null || !plan.hasTransitionAtChunk(chunkX, chunkZ)) { return raw; } try { - return TransitionFluidContainment.contain(raw, plan, (x, z) -> rawColumn(plan, raw, x, z)); + return new ResolvedTerrain(TransitionFluidContainment.contain(raw.terrain(), plan, + (x, z) -> rawColumn(plan, raw.terrain(), x, z)), raw.floatingBiomes()); } catch (IOException failure) { throw new IllegalStateException("Unable to contain transition fluids at " + chunkX + "," + chunkZ, failure); } @@ -181,10 +186,10 @@ public final class ResolvedTerrainProvider { return geometry; } return (x >> 4) == current.chunkX() && (z >> 4) == current.chunkZ() - ? current.column(x, z).geometry() : resolve(x >> 4, z >> 4, true).column(x, z).geometry(); + ? current.column(x, z).geometry() : resolve(x >> 4, z >> 4, true).terrain().column(x, z).geometry(); } - private SavedTerrainChunk compute(int chunkX, int chunkZ) { + private ResolvedTerrain compute(int chunkX, int chunkZ) { int x = Math.multiplyExact(chunkX, 16); int z = Math.multiplyExact(chunkZ, 16); try (GenerationHistoryRuntimeRouter.CoordinateScope runtimeScope = engine instanceof IrisEngine irisEngine @@ -198,7 +203,7 @@ public final class ResolvedTerrainProvider { Hunk biomes = Hunk.newArrayHunk(16, engine.getHeight(), 16); try (IrisContext.Scope ignored = IrisContext.open(engine, sessionId, context)) { engine.getMode().generateTerrain(x, z, blocks, biomes, false, context); - return capture(x, z, blocks, biomes, context, false); + return new ResolvedTerrain(capture(x, z, blocks, biomes, context, false), context.getFloatingBiomes()); } } catch (IOException failure) { throw new IllegalStateException("Unable to scope natural terrain at " + chunkX + "," + chunkZ, failure); @@ -207,6 +212,10 @@ public final class ResolvedTerrainProvider { private SavedTerrainChunk capture(int x, int z, Hunk blocks, Hunk biomes, ChunkContext context, boolean boundaryOnly) { + FloatingBiomeOverlay floating = context.getFloatingBiomes(); + if (floating != null) { + floating.retainHighestSurfaces((localX, localZ) -> highestSolid(blocks, localX, localZ)); + } try { return IrisTransitionGeometryActuator.capture(x, z, blocks, biomes, engine.getMinHeight(), context, boundaryOnly); } catch (IOException failure) { @@ -214,6 +223,16 @@ public final class ResolvedTerrainProvider { } } + private static int highestSolid(Hunk blocks, int localX, int localZ) { + for (int y = blocks.getHeight() - 1; y >= 0; y--) { + PlatformBlockState state = blocks.getRaw(localX, y, localZ); + if (state != null && !state.isAir() && !state.isFluid()) { + return y; + } + } + return -1; + } + private static void copy(SavedTerrainChunk terrain, Hunk blocks, Hunk biomes, ChunkContext context) { PlatformRegistries registries = IrisPlatforms.get().registries(); @@ -237,4 +256,8 @@ public final class ResolvedTerrainProvider { } } } + + private record ResolvedTerrain(SavedTerrainChunk terrain, FloatingBiomeOverlay floatingBiomes) { + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java index f352fd593..79de44fa0 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java @@ -20,18 +20,15 @@ package art.arcane.iris.engine; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.data.cache.Cache; -import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.plugin.Chunks; import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; import art.arcane.volmlib.util.math.Position2; -import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import org.bukkit.Chunk; import org.bukkit.World; @@ -280,37 +277,9 @@ final class WorldChunkMaintenance { EngineBukkitOps.updateChunk(manager.getEngine(), chunk); } - if (!manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) { - return; + if (manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) { + manager.entitySpawner.spawnInitially(chunk); } - - if (!IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) { - return; - } - - if (!J.isFolia() && !manager.getMantle().isChunkLoaded(chunkX, chunkZ)) { - warmupMantleChunkAsync(chunkX, chunkZ); - return; - } - - raiseInitialSpawnMarkerFlag(world, chunkX, chunkZ, () -> { - int delay = RNG.r.i(5, 200); - J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> { - if (!world.isChunkLoaded(chunkX, chunkZ)) { - return; - } - manager.entitySpawner.spawnIn(world.getChunkAt(chunkX, chunkZ), true); - }), delay); - - Chunk markerChunk = world.getChunkAt(chunkX, chunkZ); - manager.markerScanner.forEachMarkerSpawner(markerChunk, (block, spawners) -> { - IrisSpawner s = new KList<>(spawners).getRandom(); - if (s == null) { - return; - } - manager.entitySpawner.spawn(block, s, true); - }); - }); } void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) { diff --git a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java index e5a36ffdd..04f7dc12a 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java @@ -22,11 +22,12 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IRare; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisEntitySpawn; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisSpawner; -import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.plugin.Chunks; @@ -44,6 +45,8 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; import java.util.List; +import java.util.Optional; +import art.arcane.iris.engine.MarkerSpawnScanner.PreparedMarkerSpawn; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -266,6 +269,10 @@ final class WorldEntitySpawner { } void spawnIn(Chunk c, boolean initial) { + if (initial) { + spawnInitially(c); + return; + } if (manager.getEngine().isClosed()) { return; } @@ -280,17 +287,11 @@ final class WorldEntitySpawner { } if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) { - manager.markerScanner.forEachMarkerSpawner(c, (block, spawners) -> { - IrisSpawner s = new KList<>(spawners).getRandom(); - if (s == null) { - return; + manager.markerScanner.scanMarkerSpawners(c, false, markers -> { + for (PreparedMarkerSpawn marker : markers) { + spawnPreparedMarker(marker, false); } - - spawn(block, s, false); - J.runRegion(c.getWorld(), c.getX(), c.getZ(), manager.managedTask( - "bukkit_world_manager_marker_spawn_followup", - () -> manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(c.getWorld(), c.getX(), c.getZ(), - () -> spawn(block, s, true)))); + prepareInitialSpawn(c, markers); }); } @@ -298,26 +299,91 @@ final class WorldEntitySpawner { return; } + try { + BiomeEnvironment environment = manager.getEngine().getSurfaceBiomeEnvironment( + (c.getX() << 4) + 8, (c.getZ() << 4) + 8); + try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(environment)) { + spawnAmbient(c, initial, environment); + } + } catch (SavedBiomeUnavailableException e) { + return; + } + } + + void spawnInitially(Chunk chunk) { + if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld()) { + return; + } + if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) { + manager.markerScanner.scanMarkerSpawners(chunk, true, markers -> prepareInitialSpawn(chunk, markers)); + } else if (IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) { + prepareInitialSpawn(chunk, List.of()); + } + } + + void prepareInitialSpawn(Chunk chunk, List markers) { + Optional environment = Optional.empty(); + if (IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) { + try { + environment = Optional.of(manager.getEngine().getSurfaceBiomeEnvironment( + (chunk.getX() << 4) + 8, (chunk.getZ() << 4) + 8)); + } catch (SavedBiomeUnavailableException e) { + if (e.isLoading()) { + return; + } + } + } + Optional preparedEnvironment = environment; + manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> { + for (PreparedMarkerSpawn marker : markers) { + spawnPreparedMarker(marker, true); + } + if (preparedEnvironment.isPresent()) { + J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(), + manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> { + if (!chunk.getWorld().isChunkLoaded(chunk.getX(), chunk.getZ())) { + return; + } + BiomeEnvironment selected = preparedEnvironment.get(); + try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) { + spawnAmbient(chunk, true, selected); + } + }), RNG.r.i(5, 200)); + } + }); + } + + private void spawnPreparedMarker(PreparedMarkerSpawn marker, boolean initial) { + IrisSpawner spawner = new KList<>(marker.spawners()).getRandom(); + if (spawner == null || manager.getEngine().isClosed()) { + return; + } + try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(marker.environment())) { + spawnMarker(marker.position(), spawner, initial); + } + } + + private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) { //@builder // Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool. Predicate filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ()); ChunkCounter counter = new ChunkCounter(c.getEntities()); - IrisBiome biome = EngineBukkitOps.getSurfaceBiome(manager.getEngine(), c); - IrisEntitySpawn v = spawnRandomly(Stream.concat(manager.getData().getSpawnerLoader() - .loadAll(manager.getDimension().getEntitySpawners()) + IrisBiome biome = environment.biome(); + IrisEntitySpawn v = spawnRandomly(Stream.concat(environment.data().getSpawnerLoader() + .loadAll(environment.dimension().getEntitySpawners()) .shuffleCopy(RNG.r) .stream() .filter(filter) .filter((i) -> i.isValid(biome)), - Stream.concat(manager.getData() + Stream.concat(environment.data() .getSpawnerLoader() - .loadAll(manager.getEngine().getRegion(PowerOfTwoCoordinates.chunkToBlock(c.getX()), PowerOfTwoCoordinates.chunkToBlock(c.getZ())).getEntitySpawners()) + .loadAll(environment.region().getEntitySpawners()) .shuffleCopy(RNG.r) .stream() .filter(filter), - manager.getData().getSpawnerLoader() - .loadAll(manager.getEngine().getSurfaceBiome(PowerOfTwoCoordinates.chunkToBlock(c.getX()), PowerOfTwoCoordinates.chunkToBlock(c.getZ())).getEntitySpawners()) + environment.data().getSpawnerLoader() + .loadAll(environment.biome().getEntitySpawners()) .shuffleCopy(RNG.r) .stream() .filter(filter))) @@ -353,15 +419,7 @@ final class WorldEntitySpawner { } } - void spawn(IrisPosition block, IrisSpawner spawner, boolean initial) { - if (manager.getEngine().isClosed()) { - return; - } - - if (spawner == null) { - return; - } - + private void spawnMarker(IrisPosition block, IrisSpawner spawner, boolean initial) { KList s = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); if (s.isEmpty()) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/BiomeEnvironment.java b/core/src/main/java/art/arcane/iris/engine/framework/BiomeEnvironment.java new file mode 100644 index 000000000..e4d8a8a37 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/BiomeEnvironment.java @@ -0,0 +1,26 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; + +import java.util.Objects; + +public record BiomeEnvironment(long activationId, IrisBiome biome, IrisRegion region, + IrisDimension dimension, IrisData data) { + public BiomeEnvironment { + if (activationId < 0) { + throw new IllegalArgumentException("Biome activation cannot be negative."); + } + Objects.requireNonNull(biome, "biome"); + Objects.requireNonNull(region, "region"); + Objects.requireNonNull(dimension, "dimension"); + Objects.requireNonNull(data, "data"); + } + + public interface Scope extends AutoCloseable { + @Override + void close(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 03808b1db..a5694466e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -93,6 +93,19 @@ import java.util.stream.Collectors; public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, Hotloadable { IrisComplex getComplex(); + default BiomeEnvironment getBiomeEnvironment(int x, int y, int z) { + return new BiomeEnvironment(0L, getBiome(x, y, z), getRegion(x, y, z), getDimension(), getData()); + } + + default BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) { + return new BiomeEnvironment(0L, getSurfaceBiome(x, z), getRegion(x, z), getDimension(), getData()); + } + + default BiomeEnvironment.Scope openBiomeEnvironmentScope(BiomeEnvironment environment) { + return () -> {}; + } + + default @Nullable UpperDimensionContext getUpperContext() { return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java index aefc2be5f..909d7aa7d 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java @@ -22,7 +22,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisEffect; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.scheduling.J; @@ -35,6 +35,7 @@ import org.bukkit.entity.Player; public class EnginePlayer { private final Engine engine; private final Player player; + private BiomeEnvironment environment; private IrisBiome biome; private IrisRegion region; private Location lastLocation; @@ -56,6 +57,12 @@ public class EnginePlayer { return; } + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + applyEffects(); + } + } + + private void applyEffects() { if (region != null) { for (IrisEffect effect : region.getEffects()) { try { @@ -92,15 +99,22 @@ public class EnginePlayer { boolean sampled = lastLocation != null; double distanceSquared = sampled ? current.distanceSquared(lastLocation) : 0D; if (needsSample(sampled, ticksSinceLastSample(), distanceSquared)) { + BiomeEnvironment sampledEnvironment = engine.getBiomeEnvironment(current.getBlockX(), + current.getBlockY() - engine.getWorld().minHeight(), current.getBlockZ()); + environment = sampledEnvironment; + biome = sampledEnvironment.biome(); + region = sampledEnvironment.region(); lastLocation = current; lastSample = M.ms(); - biome = EngineBukkitOps.getBiome(engine, current); - region = EngineBukkitOps.getRegion(engine, current); } return false; + } catch (SavedBiomeUnavailableException e) { + environment = null; + biome = null; + region = null; + lastLocation = null; } catch (Throwable e) { IrisLogging.reportError(e); - } return true; } diff --git a/core/src/main/java/art/arcane/iris/engine/history/FloatingBiomeOverlay.java b/core/src/main/java/art/arcane/iris/engine/history/FloatingBiomeOverlay.java new file mode 100644 index 000000000..7a3504f22 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/FloatingBiomeOverlay.java @@ -0,0 +1,86 @@ +package art.arcane.iris.engine.history; + +import java.util.Objects; +import java.util.function.IntBinaryOperator; + +public final class FloatingBiomeOverlay { + private final int height; + private final int quartHeight; + private final Identity[] volume; + private final Surface[] surfaces = new Surface[256]; + + public FloatingBiomeOverlay(int height) { + if (height <= 0 || height > SavedBiomeChunk.MAXIMUM_HEIGHT) { + throw new IllegalArgumentException("Invalid floating biome height: " + height); + } + this.height = height; + quartHeight = (height + 3) / 4; + volume = new Identity[16 * quartHeight]; + } + + public void record(int localX, int y, int localZ, Identity identity) { + requirePosition(localX, y, localZ); + Objects.requireNonNull(identity, "identity"); + if ((localX & 3) == 0 && (localZ & 3) == 0 && (y & 3) == 0) { + volume[index(localX, y, localZ)] = identity; + } + int column = localX * 16 + localZ; + Surface previous = surfaces[column]; + if (previous == null || y >= previous.y()) { + surfaces[column] = new Surface(y, identity); + } + } + + public Identity volumeAt(int localX, int y, int localZ) { + requirePosition(localX, y, localZ); + return volume[index(localX, y, localZ)]; + } + + public void retainHighestSurfaces(IntBinaryOperator highestSolid) { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int column = localX * 16 + localZ; + Surface surface = surfaces[column]; + if (surface != null && highestSolid.applyAsInt(localX, localZ) != surface.y()) { + surfaces[column] = null; + } + } + } + } + + public Identity surfaceAt(int localX, int localZ) { + requirePosition(localX, 0, localZ); + Surface surface = surfaces[localX * 16 + localZ]; + return surface == null ? null : surface.identity(); + } + + public int surfaceYAt(int localX, int localZ) { + requirePosition(localX, 0, localZ); + Surface surface = surfaces[localX * 16 + localZ]; + return surface == null ? -1 : surface.y(); + } + + public int height() { + return height; + } + + private int index(int localX, int y, int localZ) { + return ((localX >> 2) * 4 + (localZ >> 2)) * quartHeight + (y >> 2); + } + + private void requirePosition(int localX, int y, int localZ) { + if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16 || y < 0 || y >= height) { + throw new IllegalArgumentException("Floating biome position is outside its generation chunk."); + } + } + + public record Identity(String biomeKey, String regionKey) { + public Identity { + Objects.requireNonNull(biomeKey, "biomeKey"); + Objects.requireNonNull(regionKey, "regionKey"); + } + } + + private record Surface(int y, Identity identity) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java index ca8c3fe15..4f24f154a 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java @@ -51,6 +51,27 @@ public final class GenerationEpochContractFactory { String dimensionKey, String dimensionTypeKey, int dimensionTypeFingerprintSchema + ) { + return createContract(dimension, dimensionKey, dimensionTypeKey, + dimensionTypeFingerprintSchema, GenerationPackFingerprint.CURRENT_VERSION); + } + + public static GenerationEpoch.DimensionContract createForEpoch( + IrisDimension dimension, + String dimensionTypeKey, + GenerationEpoch epoch + ) { + GenerationEpoch recorded = Objects.requireNonNull(epoch, "epoch"); + return createContract(dimension, dimension.getLoadKey(), dimensionTypeKey, + recorded.dimensionContract().dimensionTypeFingerprintSchema(), recorded.packFingerprintVersion()); + } + + private static GenerationEpoch.DimensionContract createContract( + IrisDimension dimension, + String dimensionKey, + String dimensionTypeKey, + int dimensionTypeFingerprintSchema, + int packFingerprintVersion ) { requireSupportedDimensionTypeFingerprintSchema(dimensionTypeFingerprintSchema); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); @@ -83,7 +104,7 @@ public final class GenerationEpochContractFactory { upperDimensionKey, upperTerrainEnabled ? requiredDimension.getUpperDimensionGap() : 0, upperTerrainEnabled - ? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey) + ? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey, packFingerprintVersion) : NO_UPPER_TERRAIN_FINGERPRINT, dimensionTypeFingerprintSchema, fingerprintDimensionType(dimensionType, dimensionTypeFingerprintSchema) @@ -151,7 +172,8 @@ public final class GenerationEpochContractFactory { private static String requireUpperTerrainPackFingerprint( IrisDimension dimension, - String upperDimensionKey + String upperDimensionKey, + int packFingerprintVersion ) { IrisData data = Objects.requireNonNull( dimension.getLoader(), @@ -167,7 +189,7 @@ public final class GenerationEpochContractFactory { try { return GenerationPackFingerprint.compute( data.getDataFolder().toPath(), - GenerationPackFingerprint.CURRENT_VERSION + packFingerprintVersion ); } catch (IOException failure) { throw new IllegalStateException("Unable to fingerprint the immutable upper terrain pack.", failure); diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java index 444e57bd0..513c5cd99 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -36,6 +36,7 @@ public final class GenerationHistory { private final GenerationBoundaryStore boundaries; private final TerrainBoundarySignatureStore terrainSignatures; private final GenerationSemanticIndex semantics; + private final SavedBiomeStore savedBiomes; private final GenerationKernelRegistry kernels; private final GenerationAdmission admission; private final Map boundaryCache; @@ -56,12 +57,17 @@ public final class GenerationHistory { this.boundaries = new GenerationBoundaryStore(paths.dimensionRoot()); this.terrainSignatures = new TerrainBoundarySignatureStore(paths.dimensionRoot()); this.semantics = GenerationSemanticIndex.loadRequired(paths.dimensionRoot()); + this.savedBiomes = SavedBiomeStore.open(paths.dimensionRoot()); this.admission = new GenerationAdmission(paths.dimensionRoot()); this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES); this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES); validateReferencedState(); } + public SavedBiomeStore savedBiomes() { + return savedBiomes; + } + public static GenerationHistory create( Path dimensionRoot, Path packSource, @@ -507,7 +513,6 @@ public final class GenerationHistory { promoteSavedBoundary(); } } - packs.releaseArchivedPacks(store.manifest()); } } } @@ -530,6 +535,7 @@ public final class GenerationHistory { return SavedTerrainChunk.hasTerrain(SavedTerrainChunk.readStatus(paths.dimensionRoot(), chunkX, chunkZ)); }); semantics.discardUnstoredClaims(inventory, selected); + savedBiomes.discardUnstoredClaims(inventory, selected); ownership.discardUnstoredClaims(inventory, selected); return inventory; } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java index cf42e2c73..497060470 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java @@ -23,6 +23,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { private final IrisEngine engine; private final GenerationHistory history; + private final SavedBiomeRuntime biomes; private final GenerationBoundarySignatureSampler signatureSampler; private final ActivationRuntimeFactory runtimeFactory; private final LinkedHashMap bindings; @@ -54,6 +55,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { this.inactive = stateLock.newCondition(); this.operationDepth = ThreadLocal.withInitial(() -> 0); this.scopedRoute = new ThreadLocal<>(); + this.biomes = new SavedBiomeRuntime(engine, history); GenerationActivation active = history.activeActivation(); GenerationEpoch epoch = requireEpoch(active); @@ -81,6 +83,10 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } } + public SavedBiomeRuntime biomes() { + return biomes; + } + public static GenerationHistoryRuntimeRouter attach( IrisEngine engine, GenerationHistory history, @@ -297,6 +303,14 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { route.naturalTerrain = captured; } + public void recordFloatingBiomes(int chunkX, int chunkZ, FloatingBiomeOverlay overlay) { + RuntimeRoute route = scopedRoute.get(); + if (route == null || route.chunkX() != chunkX || route.chunkZ() != chunkZ) { + throw new IllegalStateException("Floating biomes must be captured inside their chunk generation route."); + } + route.floatingBiomes = overlay; + } + public void preloadActiveRuntimes() throws IOException { enterOperation(); try { @@ -387,6 +401,11 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } catch (Throwable detachFailure) { failure = detachFailure; } + try { + biomes.close(); + } catch (Throwable biomeFailure) { + failure = appendFailure(failure, biomeFailure); + } try { retireBindings(retired); } catch (Throwable retirementFailure) { @@ -620,18 +639,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { if (failure != null) { throw new IllegalStateException("Failed to retire cached generation runtimes.", failure); } - if (!retired.isEmpty()) { - stateLock.lock(); - try { - if (bindings.size() == 1 && retiringBindings.isEmpty()) { - new GenerationPackRepository(history.paths().dimensionRoot()).releaseArchivedPacks(history.manifest()); - } - } catch (IOException cleanupFailure) { - throw new IllegalStateException("Unable to release archived generation packs after runtime retirement.", cleanupFailure); - } finally { - stateLock.unlock(); - } - } + } private void requireBindingContract( @@ -897,7 +905,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { + activation.activationId() + " pack " + expectedPack + "."); } binding.target().getData().bindGenerationRegistryContract(epoch.registryContract()); - requireDimensionContract(binding.target().getDimension(), epoch.dimensionContract(), actualPack); + requireDimensionContract(binding.target().getDimension(), epoch, actualPack); } @Override @@ -917,7 +925,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { throw new IOException("Immutable generation pack does not contain dimension '" + epoch.dimensionContract().dimensionKey() + "': " + packRoot); } - requireDimensionContract(dimension, epoch.dimensionContract(), packRoot); + requireDimensionContract(dimension, epoch, packRoot); TransitionGenerationPlan plan = activation.isInitial() ? null : history.transitionPlan(activation.activationId()); @@ -947,15 +955,16 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { private static void requireDimensionContract( IrisDimension dimension, - GenerationEpoch.DimensionContract recorded, + GenerationEpoch epoch, Path packRoot ) throws IOException { + GenerationEpoch.DimensionContract recorded = epoch.dimensionContract(); GenerationEpoch.DimensionContract loaded; try { - loaded = GenerationEpochContractFactory.create( + loaded = GenerationEpochContractFactory.createForEpoch( dimension, - dimension.getLoadKey(), - recorded.dimensionTypeKey() + recorded.dimensionTypeKey(), + epoch ); } catch (RuntimeException failure) { throw new IOException("Unable to validate immutable generation dimension at " + packRoot + ".", failure); @@ -976,6 +985,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { private final GenerationTransitionGate.Participation participation; private int activeScopes; private volatile SavedTerrainChunk naturalTerrain; + private volatile FloatingBiomeOverlay floatingBiomes; private boolean closed; private RuntimeRoute( @@ -1044,6 +1054,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { stage, caveSpace ); + router.biomes.capture(SavedBiomeCapture.capture(router.engine, stage, router.biomes, floatingBiomes)); return router.history.claimGeneratedSemantics(stage, semantics); } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java index 1c7a0c9b9..a60444829 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java @@ -23,7 +23,7 @@ import java.util.List; import java.util.Objects; public final class GenerationPackFingerprint { - public static final int CURRENT_VERSION = 1; + public static final int CURRENT_VERSION = 2; private static final int BUFFER_BYTES = 64 * 1_024; private GenerationPackFingerprint() { @@ -31,23 +31,31 @@ public final class GenerationPackFingerprint { public static String compute(Path packRoot, int version) throws IOException { requireSupported(version); - return computeVersionOne(packRoot); + return switch (version) { + case 1 -> computeVersionOne(packRoot); + case 2 -> computeTree(packRoot, true); + default -> throw new IOException("Unsupported Iris generation pack fingerprint version " + version + "."); + }; } public static void requireSupported(int version) throws IOException { - if (version != 1) { + if (version != 1 && version != 2) { throw new IOException("Unsupported Iris generation pack fingerprint version " + version + "."); } } private static String computeVersionOne(Path packRoot) throws IOException { + return computeTree(packRoot, false); + } + + private static String computeTree(Path packRoot, boolean ignoreFinderMetadata) throws IOException { Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root); } Path realRoot = root.toRealPath(LinkOption.NOFOLLOW_LINKS); List entries = new ArrayList<>(); - collectTree(realRoot, entries); + collectTree(realRoot, entries, ignoreFinderMetadata); entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); MessageDigest digest = sha256(); byte[] buffer = new byte[BUFFER_BYTES]; @@ -75,7 +83,11 @@ public final class GenerationPackFingerprint { return HexFormat.of().formatHex(digest.digest()); } - private static void collectTree(Path root, List entries) throws IOException { + private static void collectTree( + Path root, + List entries, + boolean ignoreFinderMetadata + ) throws IOException { Files.walkFileTree(root, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory( @@ -98,7 +110,8 @@ public final class GenerationPackFingerprint { Path relativePath = root.relativize(file); String fileName = file.getFileName().toString(); if ((relativePath.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName)) - || fileName.endsWith(".code-workspace")) { + || fileName.endsWith(".code-workspace") + || (ignoreFinderMetadata && fileName.equals(".DS_Store"))) { return FileVisitResult.CONTINUE; } if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java index e83523761..28e6f969a 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java @@ -15,6 +15,8 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.regex.Pattern; @@ -96,29 +98,6 @@ public final class GenerationPackRepository { } } - public synchronized void releaseArchivedPacks(GenerationManifest manifest) throws IOException { - GenerationManifest retained = Objects.requireNonNull(manifest, "manifest"); - String activeEpochId = retained.activeEpoch().epochId(); - String pendingEpochId = retained.pendingEpoch().map(GenerationEpoch::epochId).orElse(null); - for (GenerationEpoch epoch : retained.epochs()) { - String epochId = epoch.epochId(); - if (epochId.equals(activeEpochId) || epochId.equals(pendingEpochId)) { - continue; - } - try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire( - epochsRoot, ".epoch-" + epochId + ".lock")) { - validateExistingAncestors(epochId); - Path pack = packRoot(epochId); - if (!Files.exists(pack, LinkOption.NOFOLLOW_LINKS)) { - continue; - } - requireSafeDirectory(pack, "Generation epoch pack is unsafe"); - AtomicDirectoryPublisher.deleteTree(pack); - forceDirectory(epochRoot(epochId)); - } - } - } - public Path requireExactPack( String epochId, String packFingerprint, @@ -220,6 +199,7 @@ public final class GenerationPackRepository { + normalizedSource + " and " + normalizedTarget); } + List copiedFiles = new ArrayList<>(); Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { @@ -250,11 +230,8 @@ public final class GenerationPackRepository { return FileVisitResult.CONTINUE; } Path destination = copyDestination(normalizedSource, normalizedTarget, file); - Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent")); Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); - try (FileChannel channel = FileChannel.open(destination, StandardOpenOption.WRITE)) { - channel.force(true); - } + copiedFiles.add(destination); return FileVisitResult.CONTINUE; } @@ -263,6 +240,11 @@ public final class GenerationPackRepository { throw new IOException("Unable to copy pack entry: " + file, failure); } }); + for (Path copiedFile : copiedFiles) { + try (FileChannel channel = FileChannel.open(copiedFile, StandardOpenOption.WRITE)) { + channel.force(true); + } + } } private static Path copyDestination(Path source, Path target, Path entry) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelection.java b/core/src/main/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelection.java new file mode 100644 index 000000000..e8325299b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelection.java @@ -0,0 +1,69 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.framework.Engine; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record NativeBiomeSpawnSelection(Mode mode, String derivativeKey) { + private static final NativeBiomeSpawnSelection CURRENT = new NativeBiomeSpawnSelection(Mode.CURRENT, ""); + private static final NativeBiomeSpawnSelection NONE = new NativeBiomeSpawnSelection(Mode.NONE, ""); + private static final NativeBiomeSpawnSelection LOADING = new NativeBiomeSpawnSelection(Mode.LOADING, ""); + + public NativeBiomeSpawnSelection { + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(derivativeKey, "derivativeKey"); + if (mode == Mode.RETAINED) { + derivativeKey = ChunkGenerationSemantics.requireResourceKey(derivativeKey); + } else if (!derivativeKey.isEmpty()) { + throw new IllegalArgumentException("Only retained native spawn selections have a derivative key"); + } + } + + public static NativeBiomeSpawnSelection at(Engine engine, int blockX, int worldY, int blockZ, String physicalBiomeKey) { + if (!(engine instanceof IrisEngine irisEngine) || irisEngine.hasGenerationRuntimeScope()) { + return CURRENT; + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return CURRENT; + } + try { + return router.biomes().nativeSpawnSelection(blockX, worldY, blockZ, physicalBiomeKey); + } catch (SavedBiomeUnavailableException unavailable) { + return unavailable.isLoading() ? LOADING : NONE; + } + } + + static Map retainedDerivatives(IrisData data) { + Map derivatives = new LinkedHashMap<>(); + for (String key : data.getDimensionLoader().getPossibleKeys()) { + IrisDimension dimension = data.getDimensionLoader().load(key); + if (dimension == null) { + continue; + } + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + if (biome == null || !biome.isCustom()) { + continue; + } + for (IrisBiomeCustom custom : biome.getCustomDerivitives()) { + derivatives.putIfAbsent(data.customBiomeResourceKey(dimension, custom), biome.getVanillaDerivativeKey()); + } + } + } + return Map.copyOf(derivatives); + } + + public enum Mode { + CURRENT, + RETAINED, + NONE, + LOADING + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeCapture.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeCapture.java new file mode 100644 index 000000000..10f87c984 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeCapture.java @@ -0,0 +1,140 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class SavedBiomeCapture { + private SavedBiomeCapture() { + } + + public static SavedBiomeChunk capture(Engine engine, GenerationHistory.GenerationStage stage, SavedBiomeRuntime saved, FloatingBiomeOverlay floating) throws IOException { + int minimumY = engine.getMinHeight(); + int height = engine.getHeight(); + if (floating != null && floating.height() != height) { + throw new IllegalArgumentException("Floating biome overlay has a different generation height."); + } + int startX = Math.multiplyExact(stage.chunkX(), 16); + int startZ = Math.multiplyExact(stage.chunkZ(), 16); + long activationId = stage.activation().activationId(); + SavedBiomeChunk.Builder result = SavedBiomeChunk.builder(new SavedBiomeChunk.Header( + stage.chunkX(), stage.chunkZ(), activationId, minimumY, height)); + TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan(); + Map> historical = new HashMap<>(); + Map> vertical = new HashMap<>(16); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int blockX = startX + localX; + int blockZ = startZ + localZ; + IrisRegion region = engine.getRegion(blockX, blockZ); + IrisBiome surface = engine.getSurfaceBiome(blockX, blockZ); + int surfaceY = minimumY + engine.getHeight(blockX, blockZ); + SavedBiomeChunk.Cell surfaceCell = cell(activationId, surface, region); + if (floating != null) { + FloatingBiomeOverlay.Identity floatingSurface = floating.surfaceAt(localX, localZ); + if (floatingSurface != null) { + surfaceCell = overlay(surfaceCell, floatingSurface); + surfaceY = minimumY + floating.surfaceYAt(localX, localZ); + } + } + if (transition != null) { + surfaceCell = historicalCell(new Sample(blockX, surfaceY, blockZ, true), surfaceCell, transition, saved, historical); + } + int quartX = localX & ~3; + int quartZ = localZ & ~3; + int quartKey = quartX * 16 + quartZ; + List spans = vertical.get(quartKey); + if (spans == null) { + spans = captureVertical(new Column(engine, activationId, startX + quartX, startZ + quartZ), transition, saved, historical, floating); + vertical.put(quartKey, spans); + } + SavedBiomeChunk.Cell caveBase = cell(activationId, engine.getCaveBiome(blockX, blockZ), region); + result.column(localX, localZ, new SavedBiomeChunk.Column(surfaceCell, caveBase, spans)); + } + } + return result.build(); + } + + private static List captureVertical(Column column, TransitionGenerationPlan transition, + SavedBiomeRuntime saved, Map> historical, FloatingBiomeOverlay floating) throws IOException { + Engine engine = column.engine(); + int minimumY = engine.getMinHeight(); + int maximumY = minimumY + engine.getHeight(); + List spans = new ArrayList<>(); + SavedBiomeChunk.Cell previous = null; + int startY = minimumY; + for (int worldY = minimumY; worldY < maximumY; worldY += 4) { + IrisBiome biome = engine.getBiomeOrMantle(column.blockX(), worldY - minimumY, column.blockZ()); + IrisRegion region = engine.getRegion(column.blockX(), worldY - minimumY, column.blockZ()); + SavedBiomeChunk.Cell current = cell(column.activationId(), biome, region); + if (floating != null) { + current = overlay(current, floating.volumeAt(column.blockX() & 15, worldY - minimumY, column.blockZ() & 15)); + } + if (transition != null) { + current = historicalCell(new Sample(column.blockX(), worldY, column.blockZ(), false), current, transition, saved, historical); + } + if (previous != null && !previous.equals(current)) { + spans.add(new SavedBiomeChunk.Span(startY, worldY, previous)); + startY = worldY; + } + previous = current; + } + spans.add(new SavedBiomeChunk.Span(startY, maximumY, Objects.requireNonNull(previous, "biome"))); + return List.copyOf(spans); + } + + private static SavedBiomeChunk.Cell historicalCell(Sample sample, SavedBiomeChunk.Cell current, + TransitionGenerationPlan transition, SavedBiomeRuntime saved, + Map> historical) throws IOException { + TransitionGenerationPlan.TerrainSample terrain = transition.terrainSampleAt(sample.x(), sample.z()); + if (transition.historicalPhysicalBiomeKeyAt(sample.x(), sample.y(), sample.z(), terrain).isEmpty()) { + return current; + } + TerrainBoundarySignature signature = Objects.requireNonNull(terrain.nearestSignature(), "historical biome boundary"); + int chunkX = Math.floorDiv(signature.blockX(), 16); + int chunkZ = Math.floorDiv(signature.blockZ(), 16); + long key = ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); + Optional recorded = historical.get(key); + if (recorded == null) { + try { + recorded = saved.snapshot(chunkX, chunkZ); + } catch (SavedBiomeUnavailableException unavailable) { + recorded = Optional.empty(); + } + historical.put(key, recorded); + } + if (recorded.isEmpty()) { + return SavedBiomeChunk.Cell.unresolved(saved.activationAt(chunkX, chunkZ)); + } + SavedBiomeChunk source = recorded.get(); + int localX = Math.floorMod(signature.blockX(), 16); + int localZ = Math.floorMod(signature.blockZ(), 16); + return sample.surface() ? source.surfaceAt(localX, localZ) + : source.biomeAt(localX, Math.max(source.header().minimumY(), + Math.min(sample.y(), source.header().maximumYExclusive() - 1)), localZ); + } + + private static SavedBiomeChunk.Cell overlay(SavedBiomeChunk.Cell current, FloatingBiomeOverlay.Identity floating) { + return floating == null ? current + : new SavedBiomeChunk.Cell(current.activationId(), floating.biomeKey(), floating.regionKey()); + } + + private static SavedBiomeChunk.Cell cell(long activationId, IrisBiome biome, IrisRegion region) { + return new SavedBiomeChunk.Cell(activationId, Objects.requireNonNull(biome, "biome").getLoadKey(), + Objects.requireNonNull(region, "region").getLoadKey()); + } + + private record Column(Engine engine, long activationId, int blockX, int blockZ) { + } + + private record Sample(int x, int y, int z, boolean surface) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeChunk.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeChunk.java new file mode 100644 index 000000000..76d62afa6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeChunk.java @@ -0,0 +1,226 @@ +package art.arcane.iris.engine.history; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +public final class SavedBiomeChunk { + public static final int MAXIMUM_HEIGHT = 65_536; + public static final long MAXIMUM_ESTIMATED_BYTES = 64L * 1024L * 1024L; + private static final int COLUMN_COUNT = 256; + + private final Header header; + private final Column[] columns; + private final long estimatedBytes; + + private SavedBiomeChunk(Header header, Column[] columns) { + this.header = header; + this.columns = columns.clone(); + long bytes = 128L + COLUMN_COUNT * 16L; + for (Column column : this.columns) { + Objects.requireNonNull(column, "Every saved biome column is required"); + int expectedY = header.minimumY(); + for (Span span : column.vertical()) { + if (span.minimumY() != expectedY) { + throw new IllegalArgumentException("Saved biome spans must cover the column without gaps or overlaps"); + } + expectedY = span.maximumYExclusive(); + bytes = checkedSize(bytes + cellBytes(span.cell())); + } + if (expectedY != header.maximumYExclusive()) { + throw new IllegalArgumentException("Saved biome spans must cover the entire world height"); + } + bytes = checkedSize(bytes + cellBytes(column.surface()) + cellBytes(column.caveBase())); + } + estimatedBytes = bytes; + } + + public static Builder builder(Header header) { + return new Builder(Objects.requireNonNull(header, "header")); + } + + public Header header() { + return header; + } + + public int chunkX() { + return header.chunkX(); + } + + public int chunkZ() { + return header.chunkZ(); + } + + public long activationId() { + return header.activationId(); + } + + public Column column(int localX, int localZ) { + return columns[columnIndex(localX, localZ)]; + } + + public Cell surfaceAt(int localX, int localZ) { + return column(localX, localZ).surface(); + } + + public Cell caveBaseAt(int localX, int localZ) { + return column(localX, localZ).caveBase(); + } + + public Cell biomeAt(int localX, int worldY, int localZ) { + if (worldY < header.minimumY() || worldY >= header.maximumYExclusive()) { + throw new IndexOutOfBoundsException("Biome height is outside the saved world height"); + } + List spans = column(localX, localZ).vertical(); + int low = 0; + int high = spans.size() - 1; + while (low < high) { + int middle = (low + high) >>> 1; + if (worldY >= spans.get(middle).maximumYExclusive()) { + low = middle + 1; + } else { + high = middle; + } + } + return spans.get(low).cell(); + } + + long estimatedBytes() { + return estimatedBytes; + } + + @Override + public boolean equals(Object compared) { + return this == compared || compared instanceof SavedBiomeChunk other + && header.equals(other.header) && Arrays.equals(columns, other.columns); + } + + @Override + public int hashCode() { + return 31 * header.hashCode() + Arrays.hashCode(columns); + } + + private static long checkedSize(long bytes) { + if (bytes > MAXIMUM_ESTIMATED_BYTES) { + throw new IllegalArgumentException("Saved biome chunk exceeds its decoded footprint limit"); + } + return bytes; + } + + private static long cellBytes(Cell cell) { + return cell.isResolved() + ? 160L + cell.biomeKey().length() * 2L + cell.regionKey().length() * 2L + : 160L; + } + + private static int columnIndex(int localX, int localZ) { + if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16) { + throw new IndexOutOfBoundsException("Saved biome local coordinates must be between 0 and 15"); + } + return localZ * 16 + localX; + } + + public record Header(int chunkX, int chunkZ, long activationId, int minimumY, int height) { + public Header { + if (activationId <= 0L || height <= 0 || height > MAXIMUM_HEIGHT + || (long) minimumY + height > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid saved biome chunk header"); + } + } + + public int maximumYExclusive() { + return minimumY + height; + } + } + + public enum Resolution { + RESOLVED, + UNRESOLVED + } + + public record Cell(long activationId, String biomeKey, String regionKey, Resolution resolution) { + public Cell { + if (activationId <= 0L) { + throw new IllegalArgumentException("Saved biome activation must be positive"); + } + Objects.requireNonNull(resolution, "resolution"); + if (resolution == Resolution.RESOLVED) { + if (biomeKey == null || regionKey == null) { + throw new IllegalArgumentException("Resolved saved biomes require both resource keys"); + } + biomeKey = ChunkGenerationSemantics.requireResourceKey(biomeKey); + regionKey = ChunkGenerationSemantics.requireResourceKey(regionKey); + } else if (biomeKey != null || regionKey != null) { + throw new IllegalArgumentException("Unresolved saved biomes must not contain resource keys"); + } + } + + public Cell(long activationId, String biomeKey, String regionKey) { + this(activationId, biomeKey, regionKey, Resolution.RESOLVED); + } + + public static Cell unresolved(long activationId) { + return new Cell(activationId, null, null, Resolution.UNRESOLVED); + } + + public boolean isResolved() { + return resolution == Resolution.RESOLVED; + } + } + + public record Span(int minimumY, int maximumYExclusive, Cell cell) { + public Span { + if (minimumY >= maximumYExclusive) { + throw new IllegalArgumentException("Saved biome spans must have positive height"); + } + Objects.requireNonNull(cell, "cell"); + } + } + + public record Column(Cell surface, Cell caveBase, List vertical) { + public Column { + Objects.requireNonNull(surface, "surface"); + Objects.requireNonNull(caveBase, "caveBase"); + Objects.requireNonNull(vertical, "vertical"); + if (vertical.isEmpty() || vertical.size() > MAXIMUM_HEIGHT) { + throw new IllegalArgumentException("Invalid saved biome column span count"); + } + ArrayList compact = new ArrayList<>(vertical.size()); + for (Span span : vertical) { + Objects.requireNonNull(span, "span"); + if (!compact.isEmpty()) { + Span previous = compact.getLast(); + if (previous.maximumYExclusive() != span.minimumY()) { + throw new IllegalArgumentException("Saved biome spans must be contiguous"); + } + if (previous.cell().equals(span.cell())) { + compact.set(compact.size() - 1, + new Span(previous.minimumY(), span.maximumYExclusive(), span.cell())); + continue; + } + } + compact.add(span); + } + vertical = List.copyOf(compact); + } + } + + public static final class Builder { + private final Header header; + private final Column[] columns = new Column[COLUMN_COUNT]; + + private Builder(Header header) { + this.header = header; + } + + public Builder column(int localX, int localZ, Column column) { + columns[columnIndex(localX, localZ)] = Objects.requireNonNull(column, "column"); + return this; + } + + public SavedBiomeChunk build() { + return new SavedBiomeChunk(header, columns); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRecovery.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRecovery.java new file mode 100644 index 000000000..fa3bbb6c4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRecovery.java @@ -0,0 +1,128 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverPolicy; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class SavedBiomeRecovery { + private SavedBiomeRecovery() { + } + + public static Optional recover(Input input) throws IOException { + Objects.requireNonNull(input, "input"); + Optional recorded = input.history().semantics(input.chunkX(), input.chunkZ()); + if (recorded.isEmpty()) { + return Optional.empty(); + } + ChunkGenerationSemantics semantics = recorded.get(); + if (!semantics.sealed() || semantics.surfaceBiomeKeys().size() != 1 + || semantics.regionKeys().size() != 1) { + return Optional.empty(); + } + GenerationActivation activation = input.history().resolveActivation(input.chunkX(), input.chunkZ()); + if (activation.activationId() != semantics.activationId()) { + return Optional.empty(); + } + String biomeKey = semantics.surfaceBiomeKeys().iterator().next(); + String regionKey = semantics.regionKeys().iterator().next(); + if (!onlyKey(semantics.caveBiomeKeys(), biomeKey)) { + return Optional.empty(); + } + IrisDimension dimension = input.dimension(); + GenerationEpoch.DimensionContract contract = input.history().resolveEpoch(input.chunkX(), input.chunkZ()) + .dimensionContract(); + if (!contract.dimensionKey().equals(dimension.getLoadKey()) + || contract.minHeight() != dimension.getMinHeight() + || contract.maxHeight() != dimension.getMaxHeight() + || contract.upperTerrainEnabled() || dimension.hasUpperDimension() || dimension.hasDimensionStack() + || !dimension.getCarving().isEmpty() + || !optionalKey(dimension.getFocus(), biomeKey) + || !optionalKey(dimension.getFocusRegion(), regionKey) + || !singleChoice(dimension.getRegions(), regionKey)) { + return Optional.empty(); + } + IrisRegion region = input.data().getRegionLoader().load(regionKey); + IrisBiome biome = input.data().getBiomeLoader().load(biomeKey); + if (region == null || biome == null + || !regionKey.equals(region.getLoadKey()) || !biomeKey.equals(biome.getLoadKey()) + || !singleChoice(region.getLandBiomes(), biomeKey) + || !singleChoice(region.getSeaBiomes(), biomeKey) + || !singleChoice(region.getShoreBiomes(), biomeKey) + || !singleChoice(region.getCaveBiomes(), biomeKey) + || !biome.getChildren().isEmpty() || !biome.getFloatingChildBiomes().isEmpty() + || !optionalKey(biome.getCarvingBiome(), biomeKey) + || !sameRiverBiomes(dimension.getRiverPolicy(), biomeKey) + || !sameRiverBiomes(region.getRiverPolicy(), biomeKey) + || !sameRiverBiomes(biome.getRiverPolicy(), biomeKey) + || hasHistoricalColumns(input, activation)) { + return Optional.empty(); + } + SavedBiomeChunk.Header header = new SavedBiomeChunk.Header(input.chunkX(), input.chunkZ(), + activation.activationId(), contract.minHeight(), contract.height()); + SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation.activationId(), biomeKey, regionKey); + SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, + List.of(new SavedBiomeChunk.Span(header.minimumY(), header.maximumYExclusive(), cell))); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(header); + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + builder.column(localX, localZ, column); + } + } + return Optional.of(builder.build()); + } + + private static boolean hasHistoricalColumns(Input input, GenerationActivation activation) throws IOException { + if (activation.isInitial()) { + return false; + } + TransitionGenerationPlan transition = input.history().transitionPlan(activation.activationId()); + int minimumX = Math.multiplyExact(input.chunkX(), 16); + int minimumZ = Math.multiplyExact(input.chunkZ(), 16); + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + if (transition.newEpochWeightAt(Math.addExact(minimumX, localX), + Math.addExact(minimumZ, localZ)) != 1D) { + return true; + } + } + } + return false; + } + + private static boolean singleChoice(Collection keys, String expected) { + return keys != null && !keys.isEmpty() && onlyKey(keys, expected); + } + + private static boolean onlyKey(Collection keys, String expected) { + for (String key : keys) { + if (!expected.equals(key)) { + return false; + } + } + return true; + } + + private static boolean optionalKey(String key, String expected) { + return key != null && (key.isEmpty() || expected.equals(key)); + } + + private static boolean sameRiverBiomes(IrisRiverPolicy policy, String biomeKey) { + return policy == null || onlyKey(policy.getAllBiomeIds(), biomeKey); + } + + public record Input(GenerationHistory history, int chunkX, int chunkZ, IrisData data, IrisDimension dimension) { + public Input { + Objects.requireNonNull(history, "history"); + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(dimension, "dimension"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java new file mode 100644 index 000000000..6331ad396 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java @@ -0,0 +1,421 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisLogging; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public final class SavedBiomeRuntime implements AutoCloseable { + private static final int MAXIMUM_QUERIES = 128; + private static final int MAXIMUM_PENDING_QUERIES = 256; + private static final long MAXIMUM_QUERY_BYTES = SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES; + + private final IrisEngine engine; + private final GenerationHistory history; + private final SavedBiomeStore store; + private final Map definitions = new ConcurrentHashMap<>(); + private final LinkedHashMap queries = new LinkedHashMap<>(32, 0.75F, true); + private final Map pending = new HashMap<>(); + private long queryBytes; + private final ExecutorService reads = Executors.newFixedThreadPool(2, task -> { + Thread thread = new Thread(task, "Iris Saved Biomes"); + thread.setDaemon(true); + return thread; + }); + private volatile boolean closed; + + SavedBiomeRuntime(IrisEngine engine, GenerationHistory history) throws IOException { + this.engine = Objects.requireNonNull(engine, "engine"); + this.history = Objects.requireNonNull(history, "history"); + store = history.savedBiomes(); + } + + public Optional resolve(int blockX, int worldY, int blockZ, boolean surface) { + return resolve(blockX, worldY, blockZ, surface ? QueryKind.SURFACE : QueryKind.VOLUME); + } + + public NativeBiomeSpawnSelection nativeSpawnSelection(int blockX, int worldY, int blockZ, String physicalBiomeKey) { + PreparedQuery prepared = query(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)); + if (prepared.failure() != null) { + throw prepared.failure(); + } + if (prepared.chunk().isEmpty()) { + return new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.CURRENT, ""); + } + SavedBiomeChunk chunk = prepared.chunk().get(); + SavedBiomeChunk.Cell cell = chunk.biomeAt(Math.floorMod(blockX, 16), + Math.max(chunk.header().minimumY(), Math.min(worldY, chunk.header().maximumYExclusive() - 1)), + Math.floorMod(blockZ, 16)); + GenerationActivation activation = history.manifest().activation(cell.activationId()).orElseThrow(() -> + new SavedBiomeUnavailableException("Historical biome generation is missing: " + cell.activationId(), false)); + Definitions source = definitions.get(activation.epochId()); + String derivative = source == null ? null : source.nativeDerivatives().get(physicalBiomeKey); + return derivative == null + ? new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.NONE, "") + : new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.RETAINED, derivative); + } + + public void prepareChunk(int chunkX, int chunkZ) { + PreparedQuery prepared = query(chunkX, chunkZ); + if (prepared.failure() != null) { + throw prepared.failure(); + } + } + + public Optional resolveCaveBase(int blockX, int blockZ) { + return resolve(blockX, 0, blockZ, QueryKind.CAVE_BASE); + } + + private Optional resolve(int blockX, int worldY, int blockZ, QueryKind kind) { + int chunkX = Math.floorDiv(blockX, 16); + int chunkZ = Math.floorDiv(blockZ, 16); + PreparedQuery query = query(chunkX, chunkZ); + if (query.failure() != null) { + throw query.failure(); + } + if (query.chunk().isEmpty()) { + return Optional.empty(); + } + SavedBiomeChunk chunk = query.chunk().get(); + int localX = Math.floorMod(blockX, 16); + int localZ = Math.floorMod(blockZ, 16); + SavedBiomeChunk.Cell cell = switch (kind) { + case SURFACE -> chunk.surfaceAt(localX, localZ); + case CAVE_BASE -> chunk.caveBaseAt(localX, localZ); + case VOLUME -> chunk.biomeAt(localX, Math.max(chunk.header().minimumY(), + Math.min(worldY, chunk.header().maximumYExclusive() - 1)), localZ); + }; + if (!cell.isResolved()) { + throw new SavedBiomeUnavailableException("This position has no recorded Iris biome identity for generation " + + cell.activationId() + ". The saved terrain and Minecraft biome remain available, but Iris biome rules cannot run.", false); + } + SavedBiomeUnavailableException unavailable = query.cellFailures().get(cell); + if (unavailable != null) { + throw unavailable; + } + BiomeEnvironment environment = query.environments().get(cell); + if (environment == null) { + throw new SavedBiomeUnavailableException("Historical biome definitions are unavailable for generation " + cell.activationId() + ".", false); + } + return Optional.of(environment); + } + + public Optional snapshot(int chunkX, int chunkZ) throws IOException { + requireOpen(); + Optional saved = store.get(chunkX, chunkZ); + if (saved.isPresent()) { + return saved; + } + if (history.isActiveUnowned(chunkX, chunkZ) && history.semantics(chunkX, chunkZ).isEmpty()) { + return Optional.empty(); + } + long activationId = history.resolveActivation(chunkX, chunkZ).activationId(); + Definitions source = definitions(activationId); + Optional recovered = SavedBiomeRecovery.recover(new SavedBiomeRecovery.Input( + history, chunkX, chunkZ, source.data(), source.dimension())); + if (recovered.isPresent()) { + store.claimAndPersist(recovered.get()); + return recovered; + } + throw new SavedBiomeUnavailableException("This chunk has no exact saved Iris biome assignment for generation " + + activationId + ". Its terrain is preserved; historical biome rules cannot be recovered safely.", false); + } + + public long activationAt(int chunkX, int chunkZ) { + return history.resolveActivation(chunkX, chunkZ).activationId(); + } + + public void capture(SavedBiomeChunk chunk) throws IOException { + requireOpen(); + store.claimAndPersist(chunk); + synchronized (queries) { + long key = key(chunk.chunkX(), chunk.chunkZ()); + PreparedQuery removed = queries.remove(key); + if (removed != null) { + queryBytes -= removed.estimatedBytes(); + } + PendingQuery loading = pending.get(key); + if (loading != null) { + loading.invalidated = true; + } + } + } + + @Override + public synchronized void close() { + synchronized (queries) { + closed = true; + reads.shutdown(); + } + boolean interrupted = false; + Throwable failure = null; + try { + while (true) { + try { + if (reads.awaitTermination(1L, TimeUnit.DAYS)) { + break; + } + } catch (InterruptedException interruption) { + interrupted = true; + } + } + synchronized (queries) { + queries.clear(); + pending.clear(); + queryBytes = 0L; + } + for (Definitions source : definitions.values()) { + try { + try { + source.data().unregisterEngine(engine); + } finally { + source.data().close(); + } + } catch (Throwable closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + definitions.clear(); + if (failure != null) { + throw new IllegalStateException("Unable to close saved biome definitions", failure); + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + int cachedQueryCount() { + synchronized (queries) { + return queries.size(); + } + } + + long cachedQueryBytes() { + synchronized (queries) { + return queryBytes; + } + } + + int pendingQueryCount() { + synchronized (queries) { + return pending.size(); + } + } + + private PreparedQuery query(int chunkX, int chunkZ) { + long key = key(chunkX, chunkZ); + synchronized (queries) { + requireOpen(); + PreparedQuery cached = queries.get(key); + if (cached != null) { + return cached; + } + if (!pending.containsKey(key) && pending.size() < MAXIMUM_PENDING_QUERIES) { + PendingQuery loading = new PendingQuery(); + pending.put(key, loading); + reads.execute(() -> readQuery(chunkX, chunkZ, loading)); + } + } + throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk " + + chunkX + "," + chunkZ + ". Try again shortly.", true); + } + + private void readQuery(int chunkX, int chunkZ, PendingQuery loading) { + long key = key(chunkX, chunkZ); + PreparedQuery prepared = null; + try { + if (!closed) { + Optional saved = snapshot(chunkX, chunkZ); + prepared = saved.isEmpty() + ? new PreparedQuery(Optional.empty(), Map.of(), Map.of(), null, 256L) + : prepareDefinitions(saved.get()); + } + } catch (SavedBiomeUnavailableException unavailable) { + if (!closed) { + IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(), + unavailable.getMessage()); + } + prepared = new PreparedQuery(Optional.empty(), Map.of(), Map.of(), unavailable, 512L); + } catch (IOException | RuntimeException failure) { + if (!closed && IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + failure.getMessage(), + "Failed to read saved biome information for chunk " + chunkX + "," + chunkZ + ".")) { + IrisLogging.reportError(failure); + } + prepared = new PreparedQuery(Optional.empty(), Map.of(), Map.of(), new SavedBiomeUnavailableException( + "Unable to read the saved biome at chunk " + chunkX + "," + chunkZ + ".", failure), 512L); + } finally { + synchronized (queries) { + pending.remove(key, loading); + if (!closed && !loading.invalidated && prepared != null) { + cacheQuery(key, prepared); + } + } + } + } + + private void cacheQuery(long key, PreparedQuery prepared) { + PreparedQuery previous = queries.put(key, prepared); + if (previous != null) { + queryBytes -= previous.estimatedBytes(); + } + queryBytes += prepared.estimatedBytes(); + Iterator> entries = queries.entrySet().iterator(); + while (queries.size() > MAXIMUM_QUERIES || queryBytes > MAXIMUM_QUERY_BYTES) { + queryBytes -= entries.next().getValue().estimatedBytes(); + entries.remove(); + } + } + + private PreparedQuery prepareDefinitions(SavedBiomeChunk chunk) throws IOException { + if (chunk.estimatedBytes() + 256L > MAXIMUM_QUERY_BYTES) { + throw new SavedBiomeUnavailableException("Saved biome definitions exceed the query memory limit.", false); + } + Map environments = new HashMap<>(); + Map failures = new HashMap<>(); + Set nativeOwners = new HashSet<>(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + SavedBiomeChunk.Column column = chunk.column(x, z); + prepareEnvironment(chunk, column.surface(), environments, failures); + prepareEnvironment(chunk, column.caveBase(), environments, failures); + for (SavedBiomeChunk.Span span : column.vertical()) { + if (nativeOwners.add(span.cell().activationId())) { + prepareNativeDefinitions(span.cell().activationId()); + } + prepareEnvironment(chunk, span.cell(), environments, failures); + } + } + } + return new PreparedQuery(Optional.of(chunk), Map.copyOf(environments), Map.copyOf(failures), null, + chunk.estimatedBytes() + environments.size() * 256L + failures.size() * 512L + 256L); + } + + private void prepareNativeDefinitions(long activationId) throws IOException { + try { + definitions(activationId); + } catch (SavedBiomeUnavailableException unavailable) { + IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(), + unavailable.getMessage()); + } + } + + private void prepareEnvironment(SavedBiomeChunk chunk, SavedBiomeChunk.Cell cell, + Map environments, + Map failures) throws IOException { + if (!cell.isResolved() || environments.containsKey(cell) || failures.containsKey(cell)) { + return; + } + if (chunk.estimatedBytes() + environments.size() * 256L + (failures.size() + 1L) * 512L + 256L > MAXIMUM_QUERY_BYTES) { + throw new SavedBiomeUnavailableException("Saved biome definitions exceed the query memory limit.", false); + } + try { + environments.put(cell, definitions(cell.activationId()).environment(cell)); + } catch (SavedBiomeUnavailableException unavailable) { + failures.put(cell, unavailable); + IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(), + unavailable.getMessage()); + } + } + + private Definitions definitions(long activationId) throws IOException { + GenerationActivation activation = history.manifest().activation(activationId).orElseThrow(() -> + new IOException("Historical biome generation is missing: " + activationId)); + String epochId = activation.epochId(); + Definitions cached = definitions.get(epochId); + if (cached != null) { + return cached; + } + synchronized (definitions) { + cached = definitions.get(epochId); + if (cached != null) { + return cached; + } + GenerationEpoch epoch = history.manifest().epoch(activation.epochId()).orElseThrow(() -> + new IOException("Historical biome epoch is missing: " + activation.epochId())); + Path pack = history.paths().packRoot(epoch.epochId()); + if (!Files.exists(pack, LinkOption.NOFOLLOW_LINKS)) { + throw new SavedBiomeUnavailableException("The saved pack definitions for generation " + activationId + + " are missing. Its terrain remains saved, but its Iris biome rules cannot run.", false); + } + IrisData data = IrisData.openRuntime(history.packRoot(activationId).toFile()); + try { + data.bindGenerationRegistryContract(epoch.registryContract()); + data.registerEngine(engine); + IrisDimension dimension = data.getDimensionLoader().load(epoch.dimensionContract().dimensionKey()); + if (dimension == null) { + throw new IOException("Historical dimension is no longer supported: " + epoch.dimensionContract().dimensionKey()); + } + Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data)); + definitions.put(epochId, loaded); + return loaded; + } catch (Throwable failure) { + data.unregisterEngine(engine); + data.close(); + throw failure; + } + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Saved biome runtime is closed."); + } + } + + private static long key(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); + } + + private record PreparedQuery(Optional chunk, + Map environments, + Map cellFailures, + SavedBiomeUnavailableException failure, long estimatedBytes) { + } + + private static final class PendingQuery { + private boolean invalidated; + } + + private enum QueryKind { + SURFACE, CAVE_BASE, VOLUME + } + + private record Definitions(IrisData data, IrisDimension dimension, Map nativeDerivatives) { + private BiomeEnvironment environment(SavedBiomeChunk.Cell cell) { + IrisBiome biome = data.getBiomeLoader().load(cell.biomeKey()); + IrisRegion region = data.getRegionLoader().load(cell.regionKey()); + if (biome == null || region == null) { + throw new SavedBiomeUnavailableException("Historical biome " + cell.biomeKey() + " in region " + + cell.regionKey() + " is no longer supported for generation " + cell.activationId() + + ". Its terrain remains saved, but its Iris biome rules cannot run.", false); + } + return new BiomeEnvironment(cell.activationId(), biome, region, dimension, data); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java new file mode 100644 index 000000000..ee8776fbb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java @@ -0,0 +1,670 @@ +package art.arcane.iris.engine.history; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.File; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +public final class SavedBiomeStore { + private static final Pattern REGION_FILE = Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.ibio"); + private static final int MAGIC = 0x4942494F; + private static final int FORMAT_VERSION = 1; + private static final int HEADER_BYTES = 20; + private static final int MAXIMUM_PALETTE_CELLS = 65_536; + private static final int MAXIMUM_RECORD_BYTES = 16 * 1024 * 1024; + private static final int MAXIMUM_DECODED_BYTES = 64 * 1024 * 1024; + private static final long MAXIMUM_REGION_BYTES = 1024L * 1024L * 1024L; + private static final int MAXIMUM_CACHED_REGIONS = 64; + private static final int MAXIMUM_CACHED_CHUNKS = 128; + private static final long MAXIMUM_CACHED_BYTES = 64L * 1024L * 1024L; + + private final Path dimensionRoot; + private final Path directory; + private final Object[] regionLocks = new Object[64]; + private final LinkedHashMap regions = new LinkedHashMap<>(64, 0.75F, true); + private final LinkedHashMap> chunks = new LinkedHashMap<>(128, 0.75F, true); + private long cachedBytes; + + private SavedBiomeStore(Path dimensionRoot) throws IOException { + this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").toAbsolutePath().normalize(); + directory = this.dimensionRoot.resolve("iris/generation/biomes"); + requireSafeAncestors(); + for (int index = 0; index < regionLocks.length; index++) { + regionLocks[index] = new Object(); + } + } + + public static SavedBiomeStore open(Path dimensionRoot) throws IOException { + return new SavedBiomeStore(dimensionRoot); + } + + public Path storageDirectory() { + return directory; + } + + public Optional get(int chunkX, int chunkZ) throws IOException { + long chunkKey = key(chunkX, chunkZ); + synchronized (chunks) { + Optional cached = chunks.get(chunkKey); + if (cached != null) { + return cached; + } + } + synchronized (regionLock(chunkX, chunkZ)) { + synchronized (chunks) { + Optional cached = chunks.get(chunkKey); + if (cached != null) { + return cached; + } + } + RegionIndex region = region(chunkX, chunkZ); + int slot = slot(chunkX, chunkZ); + Optional result = region.offsets[slot] == 0L + ? Optional.empty() : Optional.of(readRecord(region, slot)); + cacheChunk(chunkKey, result); + return result; + } + } + + public Optional cached(int chunkX, int chunkZ) { + synchronized (chunks) { + return chunks.getOrDefault(key(chunkX, chunkZ), Optional.empty()); + } + } + + public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException { + SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk"); + synchronized (regionLock(required.chunkX(), required.chunkZ())) { + Optional existing = get(required.chunkX(), required.chunkZ()); + if (existing.isPresent()) { + if (!existing.get().equals(required)) { + throw new IOException("Conflicting saved biome claim at chunk " + required.chunkX() + ", " + required.chunkZ()); + } + return false; + } + byte[] body = encode(required); + RegionIndex region = region(required.chunkX(), required.chunkZ()); + ensureRegionFile(region); + long offset; + try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) { + offset = output.length(); + if (offset + body.length + 8L > MAXIMUM_REGION_BYTES) { + throw new IOException("Saved biome region exceeds its storage limit: " + region.path); + } + try { + output.seek(offset); + output.writeInt(body.length); + output.write(body); + output.writeInt(checksum(body)); + output.getChannel().force(true); + } catch (IOException failure) { + try { + output.setLength(offset); + output.getChannel().force(true); + } catch (IOException rollback) { + failure.addSuppressed(rollback); + } + throw failure; + } + } + int slot = slot(required.chunkX(), required.chunkZ()); + region.offsets[slot] = offset; + region.lengths[slot] = body.length; + cacheChunk(key(required.chunkX(), required.chunkZ()), Optional.of(required)); + return true; + } + } + + public int discardUnstoredClaims(WorldChunkInventory inventory, Set selectedActivations) throws IOException { + Objects.requireNonNull(inventory, "inventory"); + Set selected = Set.copyOf(selectedActivations); + for (long activationId : selected) { + if (activationId <= 0L) { + throw new IllegalArgumentException("Saved biome activation must be positive"); + } + } + requireSafeAncestors(); + if (selected.isEmpty() || !Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return 0; + } + int removed = 0; + try (DirectoryStream files = Files.newDirectoryStream(directory, "r.*.*.ibio")) { + for (Path path : files) { + Matcher matcher = REGION_FILE.matcher(path.getFileName().toString()); + if (!matcher.matches()) { + continue; + } + int regionX = parseRegionCoordinate(matcher.group(1)); + int regionZ = parseRegionCoordinate(matcher.group(2)); + synchronized (regionLock(regionX << 5, regionZ << 5)) { + RegionIndex region = region(regionX << 5, regionZ << 5); + boolean[] discarded = new boolean[1024]; + int count = 0; + for (int slot = 0; slot < 1024; slot++) { + int chunkX = (regionX << 5) + (slot & 31); + int chunkZ = (regionZ << 5) + (slot >> 5); + if (region.offsets[slot] != 0L && !inventory.contains(chunkX, chunkZ) + && selected.contains(readRecord(region, slot).activationId())) { + discarded[slot] = true; + count++; + } + } + if (count != 0) { + rewriteRegion(region, discarded); + removed = Math.addExact(removed, count); + } + } + } + } + return removed; + } + + int cachedRegionCount() { + synchronized (regions) { + return regions.size(); + } + } + + int cachedChunkCount() { + synchronized (chunks) { + return chunks.size(); + } + } + + long cachedByteCount() { + synchronized (chunks) { + return cachedBytes; + } + } + + private RegionIndex region(int chunkX, int chunkZ) throws IOException { + int regionX = chunkX >> 5; + int regionZ = chunkZ >> 5; + long regionKey = key(regionX, regionZ); + synchronized (regions) { + RegionIndex cached = regions.get(regionKey); + if (cached != null) { + return cached; + } + } + requireSafeAncestors(); + RegionIndex loaded = new RegionIndex(directory.resolve("r." + regionX + "." + regionZ + ".ibio"), regionX, regionZ); + if (Files.exists(loaded.path, LinkOption.NOFOLLOW_LINKS)) { + loadIndex(loaded); + } + synchronized (regions) { + regions.put(regionKey, loaded); + while (regions.size() > MAXIMUM_CACHED_REGIONS) { + regions.remove(regions.keySet().iterator().next()); + } + } + return loaded; + } + + private void loadIndex(RegionIndex region) throws IOException { + if (!Files.isRegularFile(region.path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Saved biome region is not a regular file: " + region.path); + } + try (RandomAccessFile input = new RandomAccessFile(region.path.toFile(), "rw")) { + long fileLength = input.length(); + if (fileLength < HEADER_BYTES || fileLength > MAXIMUM_REGION_BYTES) { + throw new IOException("Invalid saved biome region size: " + region.path); + } + byte[] header = new byte[HEADER_BYTES]; + input.readFully(header); + ByteBuffer fields = ByteBuffer.wrap(header); + if (fields.getInt() != MAGIC || fields.getInt() != FORMAT_VERSION + || fields.getInt() != region.regionX || fields.getInt() != region.regionZ + || fields.getInt() != checksum(header, HEADER_BYTES - 4)) { + throw new IOException("Invalid saved biome region header: " + region.path); + } + byte[] buffer = new byte[8192]; + int records = 0; + while (input.getFilePointer() < fileLength) { + long offset = input.getFilePointer(); + if (fileLength - offset < Integer.BYTES) { + truncateTail(input, offset); + break; + } + int length = input.readInt(); + if (length < 12 || length > MAXIMUM_RECORD_BYTES) { + throw new IOException("Invalid saved biome record size: " + region.path); + } + if (fileLength - input.getFilePointer() < length + 4L) { + truncateTail(input, offset); + break; + } + CRC32 crc = new CRC32(); + input.readFully(buffer, 0, 8); + crc.update(buffer, 0, 8); + ByteBuffer coordinates = ByteBuffer.wrap(buffer, 0, 8); + int chunkX = coordinates.getInt(); + int chunkZ = coordinates.getInt(); + for (int remaining = length - 8; remaining > 0;) { + int count = Math.min(remaining, buffer.length); + input.readFully(buffer, 0, count); + crc.update(buffer, 0, count); + remaining -= count; + } + if (input.readInt() != (int) crc.getValue() || chunkX >> 5 != region.regionX || chunkZ >> 5 != region.regionZ) { + throw new IOException("Invalid saved biome record checksum or coordinates: " + region.path); + } + int slot = slot(chunkX, chunkZ); + if (++records > 1024 || region.offsets[slot] != 0L) { + throw new IOException("Duplicate saved biome chunk record: " + region.path); + } + region.offsets[slot] = offset; + region.lengths[slot] = length; + } + } + } + + private SavedBiomeChunk readRecord(RegionIndex region, int slot) throws IOException { + byte[] encoded = new byte[region.lengths[slot]]; + try (RandomAccessFile input = new RandomAccessFile(region.path.toFile(), "r")) { + input.seek(region.offsets[slot]); + if (input.readInt() != encoded.length) { + throw new IOException("Saved biome record length changed: " + region.path); + } + input.readFully(encoded); + if (input.readInt() != checksum(encoded)) { + throw new IOException("Saved biome record checksum mismatch: " + region.path); + } + } + SavedBiomeChunk result = decode(encoded); + if (result.chunkX() >> 5 != region.regionX || result.chunkZ() >> 5 != region.regionZ + || slot(result.chunkX(), result.chunkZ()) != slot) { + throw new IOException("Saved biome record coordinates do not match its index: " + region.path); + } + return result; + } + + private void ensureRegionFile(RegionIndex region) throws IOException { + requireSafeAncestors(); + Files.createDirectories(directory); + if (Files.exists(region.path, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(region.path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Saved biome region is not a regular file: " + region.path); + } + return; + } + ByteBuffer header = ByteBuffer.allocate(HEADER_BYTES); + header.putInt(MAGIC).putInt(FORMAT_VERSION).putInt(region.regionX).putInt(region.regionZ); + header.putInt(checksum(header.array(), HEADER_BYTES - 4)); + Path temporary = Files.createTempFile(directory, ".biomes-", ".tmp"); + try { + try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + header.flip(); + while (header.hasRemaining()) { + output.write(header); + } + output.force(true); + } + Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE); + forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void forceDirectory(Path path) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException failure) { + throw new IOException("Saved biome directory cannot be durability-synced: " + path, failure); + } + } + + private void rewriteRegion(RegionIndex region, boolean[] discarded) throws IOException { + Path temporary = Files.createTempFile(directory, ".biomes-", ".tmp"); + try { + try (FileChannel input = FileChannel.open(region.path, StandardOpenOption.READ); + FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + transfer(input, output, 0L, HEADER_BYTES); + for (int slot = 0; slot < 1024; slot++) { + if (region.offsets[slot] != 0L && !discarded[slot]) { + transfer(input, output, region.offsets[slot], region.lengths[slot] + 8L); + } + } + output.force(true); + } + Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + synchronized (regions) { + regions.remove(key(region.regionX, region.regionZ)); + } + synchronized (chunks) { + for (int slot = 0; slot < 1024; slot++) { + long chunkKey = key((region.regionX << 5) + (slot & 31), (region.regionZ << 5) + (slot >> 5)); + Optional removed = chunks.remove(chunkKey); + if (removed != null && removed.isPresent()) { + cachedBytes -= removed.get().estimatedBytes(); + } + } + } + forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void transfer(FileChannel input, FileChannel output, long offset, long length) throws IOException { + while (length > 0L) { + long count = input.transferTo(offset, length, output); + if (count <= 0L) { + throw new IOException("Saved biome region ended during rewrite"); + } + offset += count; + length -= count; + } + } + + private static int parseRegionCoordinate(String coordinate) throws IOException { + try { + int parsed = Integer.parseInt(coordinate); + if (parsed < Integer.MIN_VALUE >> 5 || parsed > Integer.MAX_VALUE >> 5) { + throw new IOException("Saved biome region coordinate is out of range"); + } + return parsed; + } catch (NumberFormatException failure) { + throw new IOException("Invalid saved biome region coordinate", failure); + } + } + + private void requireSafeAncestors() throws IOException { + for (Path path = directory; path != null && path.startsWith(dimensionRoot); path = path.getParent()) { + if (Files.isSymbolicLink(path)) { + throw new IOException("Saved biome storage must not use symbolic links: " + path); + } + } + } + + private void cacheChunk(long key, Optional value) { + synchronized (chunks) { + Optional previous = chunks.remove(key); + if (previous != null && previous.isPresent()) { + cachedBytes -= previous.get().estimatedBytes(); + } + long weight = value.map(SavedBiomeChunk::estimatedBytes).orElse(0L); + if (weight > MAXIMUM_CACHED_BYTES) { + return; + } + chunks.put(key, value); + cachedBytes += weight; + Iterator>> entries = chunks.entrySet().iterator(); + while (chunks.size() > MAXIMUM_CACHED_CHUNKS || cachedBytes > MAXIMUM_CACHED_BYTES) { + Optional removed = entries.next().getValue(); + cachedBytes -= removed.map(SavedBiomeChunk::estimatedBytes).orElse(0L); + entries.remove(); + } + } + } + + private Object regionLock(int chunkX, int chunkZ) { + long key = key(chunkX >> 5, chunkZ >> 5); + return regionLocks[(int) (key ^ (key >>> 32)) & (regionLocks.length - 1)]; + } + + private static long key(int x, int z) { + return (long) x << 32 | z & 0xffffffffL; + } + + private static int slot(int chunkX, int chunkZ) { + return (chunkZ & 31) * 32 + (chunkX & 31); + } + + private static void truncateTail(RandomAccessFile file, long length) throws IOException { + file.setLength(length); + file.getChannel().force(true); + } + + private static int checksum(byte[] bytes) { + return checksum(bytes, bytes.length); + } + + private static int checksum(byte[] bytes, int length) { + CRC32 checksum = new CRC32(); + checksum.update(bytes, 0, length); + return (int) checksum.getValue(); + } + + private static byte[] encode(SavedBiomeChunk chunk) throws IOException { + LinkedHashMap palette = new LinkedHashMap<>(); + LinkedHashMap columns = new LinkedHashMap<>(); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Column column = chunk.column(x, z); + columns.computeIfAbsent(column, ignored -> columns.size()); + palette.computeIfAbsent(column.surface(), ignored -> palette.size()); + palette.computeIfAbsent(column.caveBase(), ignored -> palette.size()); + for (SavedBiomeChunk.Span span : column.vertical()) { + palette.computeIfAbsent(span.cell(), ignored -> palette.size()); + } + } + } + if (palette.size() > MAXIMUM_PALETTE_CELLS) { + throw new IOException("Saved biome palette exceeds its cell limit"); + } + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(raw)) { + output.writeLong(chunk.activationId()); + output.writeInt(chunk.header().minimumY()); + output.writeInt(chunk.header().height()); + output.writeInt(palette.size()); + for (SavedBiomeChunk.Cell cell : palette.keySet()) { + output.writeLong(cell.activationId()); + output.writeByte(cell.isResolved() ? 0 : 1); + if (cell.isResolved()) { + output.writeUTF(cell.biomeKey()); + output.writeUTF(cell.regionKey()); + } + } + output.writeInt(columns.size()); + for (SavedBiomeChunk.Column column : columns.keySet()) { + writeUnsigned(output, palette.get(column.surface())); + writeUnsigned(output, palette.get(column.caveBase())); + writeUnsigned(output, column.vertical().size()); + for (SavedBiomeChunk.Span span : column.vertical()) { + writeUnsigned(output, span.maximumYExclusive() - chunk.header().minimumY()); + writeUnsigned(output, palette.get(span.cell())); + } + } + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + output.writeByte(columns.get(chunk.column(x, z))); + } + } + } + if (raw.size() > MAXIMUM_DECODED_BYTES) { + throw new IOException("Saved biome chunk exceeds its decoded size limit"); + } + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(encoded); + output.writeInt(chunk.chunkX()); + output.writeInt(chunk.chunkZ()); + output.writeInt(raw.size()); + Deflater deflater = new Deflater(Deflater.BEST_SPEED); + try (DeflaterOutputStream compressed = new DeflaterOutputStream(output, deflater)) { + raw.writeTo(compressed); + } finally { + deflater.end(); + } + if (encoded.size() > MAXIMUM_RECORD_BYTES) { + throw new IOException("Saved biome chunk exceeds its encoded size limit"); + } + return encoded.toByteArray(); + } + + private static SavedBiomeChunk decode(byte[] encoded) throws IOException { + try (DataInputStream header = new DataInputStream(new ByteArrayInputStream(encoded))) { + int chunkX = header.readInt(); + int chunkZ = header.readInt(); + int decodedLength = header.readInt(); + if (decodedLength < 24 || decodedLength > MAXIMUM_DECODED_BYTES) { + throw new IOException("Invalid saved biome decoded size"); + } + byte[] raw; + try (InflaterInputStream compressed = new InflaterInputStream(header)) { + raw = compressed.readNBytes(decodedLength); + if (raw.length != decodedLength || compressed.read() != -1) { + throw new IOException("Saved biome compressed length mismatch"); + } + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(raw))) { + SavedBiomeChunk.Header metadata = new SavedBiomeChunk.Header(chunkX, chunkZ, input.readLong(), input.readInt(), input.readInt()); + DecodeBudget budget = new DecodeBudget(); + int paletteSize = input.readInt(); + if (paletteSize <= 0 || paletteSize > MAXIMUM_PALETTE_CELLS || paletteSize > input.available() / 9) { + throw new IOException("Invalid saved biome palette size"); + } + budget.reserve(paletteSize * 64L); + ArrayList palette = new ArrayList<>(paletteSize); + for (int index = 0; index < paletteSize; index++) { + long activationId = input.readLong(); + int resolution = input.readUnsignedByte(); + SavedBiomeChunk.Cell cell = switch (resolution) { + case 0 -> new SavedBiomeChunk.Cell(activationId, readKey(input, budget), readKey(input, budget)); + case 1 -> SavedBiomeChunk.Cell.unresolved(activationId); + default -> throw new IOException("Invalid saved biome resolution state"); + }; + palette.add(cell); + } + int columnCount = input.readInt(); + if (columnCount <= 0 || columnCount > 256) { + throw new IOException("Invalid saved biome column palette size"); + } + budget.reserve(columnCount * 128L); + ArrayList columns = new ArrayList<>(columnCount); + for (int index = 0; index < columnCount; index++) { + SavedBiomeChunk.Cell surface = paletteCell(palette, readUnsigned(input)); + SavedBiomeChunk.Cell caveBase = paletteCell(palette, readUnsigned(input)); + int count = readUnsigned(input); + if (count <= 0 || count > metadata.height() || count > input.available() / 2) { + throw new IOException("Invalid saved biome span count"); + } + budget.reserve(count * 80L); + ArrayList spans = new ArrayList<>(count); + int start = metadata.minimumY(); + for (int spanIndex = 0; spanIndex < count; spanIndex++) { + int end = Math.addExact(metadata.minimumY(), readUnsigned(input)); + spans.add(new SavedBiomeChunk.Span(start, end, paletteCell(palette, readUnsigned(input)))); + start = end; + } + columns.add(new SavedBiomeChunk.Column(surface, caveBase, spans)); + } + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(metadata); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int column = input.readUnsignedByte(); + if (column >= columns.size()) { + throw new IOException("Invalid saved biome column index"); + } + builder.column(x, z, columns.get(column)); + } + } + if (input.read() != -1) { + throw new IOException("Unexpected saved biome payload bytes"); + } + return builder.build(); + } + } catch (IllegalArgumentException | ArithmeticException failure) { + throw new IOException("Invalid saved biome chunk payload", failure); + } + } + + private static String readKey(DataInputStream input, DecodeBudget budget) throws IOException { + int length = input.readUnsignedShort(); + if (length > ChunkGenerationSemantics.MAX_KEY_BYTES * 3 || length > input.available()) { + throw new IOException("Invalid saved biome resource key size"); + } + budget.reserve(64L + length * 4L); + byte[] encoded = new byte[length + 2]; + ByteBuffer.wrap(encoded).putShort((short) length); + input.readFully(encoded, 2, length); + try (DataInputStream key = new DataInputStream(new ByteArrayInputStream(encoded))) { + return key.readUTF(); + } + } + + private static SavedBiomeChunk.Cell paletteCell(List palette, int index) throws IOException { + if (index < 0 || index >= palette.size()) { + throw new IOException("Invalid saved biome palette index"); + } + return palette.get(index); + } + + private static void writeUnsigned(DataOutputStream output, int value) throws IOException { + while ((value & ~0x7f) != 0) { + output.writeByte((value & 0x7f) | 0x80); + value >>>= 7; + } + output.writeByte(value); + } + + private static int readUnsigned(DataInputStream input) throws IOException { + int result = 0; + for (int shift = 0; shift < 35; shift += 7) { + int value = input.readUnsignedByte(); + if (shift == 28 && (value & 0xf8) != 0) { + throw new IOException("Invalid saved biome variable integer"); + } + result |= (value & 0x7f) << shift; + if ((value & 0x80) == 0) { + return result; + } + } + throw new IOException("Invalid saved biome variable integer"); + } + + private static final class DecodeBudget { + private long remaining = SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES - 8192L; + + private void reserve(long bytes) throws IOException { + if (bytes < 0L || bytes > remaining) { + throw new IOException("Saved biome chunk exceeds its decoded footprint limit"); + } + remaining -= bytes; + } + } + + private static final class RegionIndex { + private final Path path; + private final int regionX; + private final int regionZ; + private final long[] offsets = new long[1024]; + private final int[] lengths = new int[1024]; + + private RegionIndex(Path path, int regionX, int regionZ) { + this.path = path; + this.regionX = regionX; + this.regionZ = regionZ; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeUnavailableException.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeUnavailableException.java new file mode 100644 index 000000000..c1540512c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeUnavailableException.java @@ -0,0 +1,19 @@ +package art.arcane.iris.engine.history; + +public final class SavedBiomeUnavailableException extends IllegalStateException { + private final boolean loading; + + public SavedBiomeUnavailableException(String message, boolean loading) { + super(message); + this.loading = loading; + } + + public SavedBiomeUnavailableException(String message, Throwable cause) { + super(message, cause); + loading = false; + } + + public boolean isLoading() { + return loading; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java index 3b349d4f5..1f216c149 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java @@ -50,6 +50,7 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.engine.history.TransitionGenerationPlan; +import art.arcane.iris.engine.history.FloatingBiomeOverlay; import java.util.Optional; import java.util.IdentityHashMap; @@ -395,7 +396,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier matterByEntry = new IdentityHashMap<>(); + IdentityHashMap matterByEntry = new IdentityHashMap<>(); for (int k = 0; k <= sample.topIdx; k++) { if (!sample.solidMask[k]) { continue; @@ -408,16 +409,18 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier historical = transition.historicalPhysicalBiomeKeyAt(wx, y + getEngine().getMinHeight(), wz); @@ -426,6 +429,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier> 4, location.getBlockZ() >> 4)) { + sampleParticleSurface(player, particle, location); + } else { + J.runAt(location, () -> sampleParticleSurface(player, particle, location)); + } + } + + private void sampleParticleSurface(Player player, Particle particle, Location location) { + World world = location.getWorld(); + if (!world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + return; + } + location.setY(world.getHighestBlockYAt(location.getBlockX(), location.getBlockZ(), HeightMap.OCEAN_FLOOR) + 1); + location.add(RNG.r.d(), 0, RNG.r.d()); + BukkitFx.run(player, () -> emitParticles(player, particle, location)); + } + + private void emitParticles(Player player, Particle particle, Location location) { + if (player.getWorld() != location.getWorld()) { + return; + } + double xOffset = randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX; + double yOffset = randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY; + double zOffset = randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ; + if (extra != 0) { + player.spawnParticle(particle, location.getX(), location.getY() + RNG.r.i(particleOffset), + location.getZ(), particleCount, xOffset, yOffset, zOffset, extra); + } else { + player.spawnParticle(particle, location.getX(), location.getY() + RNG.r.i(particleOffset), + location.getZ(), particleCount, xOffset, yOffset, zOffset); + } + } + public void apply(Entity p) { if (!shouldApplyNow()) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java index 17466b298..2ce68ac4c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java @@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.service.EntityRiseSVC; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.framework.Engine; @@ -507,6 +508,7 @@ public class IrisEntity extends IrisRegistrant { } private static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) { + IrisData definitions = gen.getData(); l.setLootTable(new LootTable() { @Override public NamespacedKey getKey() { @@ -518,7 +520,7 @@ public class IrisEntity extends IrisRegistrant { KList items = new KList<>(); for (String fi : entity.getLoot().getTables()) { - IrisLootTable i = gen.getData().getLootLoader().load(fi); + IrisLootTable i = definitions.getLootLoader().load(fi); items.addAll(i.getLoot(gen.isStudio(), gen.getSeedManager().getLoot(), InventorySlotType.STORAGE, finalAt.getWorld(), finalAt.getBlockX(), finalAt.getBlockY(), finalAt.getBlockZ())); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index 1c4e24062..ad62c23e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -42,6 +42,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import org.bukkit.Chunk; +import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -80,9 +81,9 @@ public class IrisEntitySpawn implements IRare { for (int id = 0; id < spawns; id++) { int x = (c.getX() * 16) + rng.i(16); int z = (c.getZ() * 16) + rng.i(16); - World world = BukkitWorldBinding.tryBind(gen.getWorld()) ? BukkitWorldBinding.world(gen.getWorld()) : null; - int h = gen.getHeight(x, z, true) + (world == null ? -64 : world.getMinHeight()); - int hf = gen.getHeight(x, z, false) + (world == null ? -64 : world.getMinHeight()); + World world = c.getWorld(); + int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR); + int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE); Location l = switch (getReferenceSpawner().getGroup()) { case NORMAL -> new Location(c.getWorld(), x, hf + 1, z); case CAVE -> findCaveSpawnLocation(gen, c, rng); diff --git a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java index e6bc1bf5f..12733aea2 100644 --- a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java @@ -8,6 +8,7 @@ import art.arcane.iris.engine.framework.EngineMetrics; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.FloatingIslandBoundarySampler; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.history.FloatingBiomeOverlay; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBiome; @@ -22,6 +23,7 @@ public class ChunkContext { private boolean naturalTerrain = true; private boolean speculativeTerrain; private Hunk terrainBiomes; + private FloatingBiomeOverlay floatingBiomes; private final int x; private final int z; private final IrisComplex complex; @@ -141,6 +143,21 @@ public class ChunkContext { this.terrainBiomes = terrainBiomes; } + public FloatingBiomeOverlay getFloatingBiomes() { + return floatingBiomes; + } + + public void setFloatingBiomes(FloatingBiomeOverlay floatingBiomes) { + this.floatingBiomes = floatingBiomes; + } + + public FloatingBiomeOverlay floatingBiomes(int height) { + if (floatingBiomes == null) { + floatingBiomes = new FloatingBiomeOverlay(height); + } + return floatingBiomes; + } + public void setNaturalBiome(int localX, int y, int localZ, PlatformBiome biome) { if (terrainBiomes != null) { terrainBiomes.setRaw(localX, y, localZ, biome); diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 1c2383d76..3ca1aacdd 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eDie Iris-Ersetzung für {world} wird vorbereitet. Die aktuelle Welt bleibt bis zum Neustart aktiv.", + "iris.runtime.world_replace.progress": "§eErsetzung für {world}: {stage} ({elapsed}s vergangen)", + "iris.runtime.world_replace.stage.datapacks": "Datapacks der Dimension werden geprüft", + "iris.runtime.world_replace.stage.seed": "Seed für die Ersetzung wird vorbereitet", + "iris.runtime.world_replace.stage.pack": "Dimensionspaket wird kopiert und geprüft", + "iris.runtime.world_replace.stage.players": "Schutz beim Welteintritt der Spieler wird gespeichert", + "iris.runtime.world_replace.stage.verify": "Vorbereitete Ersetzung wird geprüft", + "iris.runtime.world_replace.stage.save": "Ersetzung wird für den Neustart gespeichert", + "iris.runtime.world_replace.stage.cleanup": "Fehlgeschlagene Ersetzung wird bereinigt", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fVorgenerierung", "iris.runtime.world_create.pregen.console": "§6Vorgenerierung §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regenerieren", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 0d0a72523..3cb6ad977 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePreparando el reemplazo de {world} con Iris. El mundo actual permanece activo hasta el reinicio.", + "iris.runtime.world_replace.progress": "§eReemplazo de {world}: {stage} ({elapsed}s transcurridos)", + "iris.runtime.world_replace.stage.datapacks": "Comprobando los paquetes de datos de la dimensión", + "iris.runtime.world_replace.stage.seed": "Preparando la semilla del reemplazo", + "iris.runtime.world_replace.stage.pack": "Copiando y validando el pack de la dimensión", + "iris.runtime.world_replace.stage.players": "Guardando la protección de entrada de los jugadores", + "iris.runtime.world_replace.stage.verify": "Verificando el reemplazo preparado", + "iris.runtime.world_replace.stage.save": "Guardando el reemplazo para el reinicio", + "iris.runtime.world_replace.stage.cleanup": "Limpiando el reemplazo fallido", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregenerando", "iris.runtime.world_create.pregen.console": "§6Pregenerando §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regenerar", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index c0a6afc25..e251146d8 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eIris valmistelee maailman {world} korvaamista. Nykyinen maailma pysyy aktiivisena uudelleenkäynnistykseen asti.", + "iris.runtime.world_replace.progress": "§eMaailman {world} korvaaminen: {stage} ({elapsed}s kulunut)", + "iris.runtime.world_replace.stage.datapacks": "Tarkistetaan ulottuvuuden datapaketteja", + "iris.runtime.world_replace.stage.seed": "Valmistellaan korvaavan maailman siementä", + "iris.runtime.world_replace.stage.pack": "Kopioidaan ja tarkistetaan ulottuvuuspakettia", + "iris.runtime.world_replace.stage.players": "Tallennetaan pelaajien turvallisen maailmaan saapumisen suojaus", + "iris.runtime.world_replace.stage.verify": "Tarkistetaan valmisteltua korvaavaa maailmaa", + "iris.runtime.world_replace.stage.save": "Tallennetaan korvaava maailma uudelleenkäynnistystä varten", + "iris.runtime.world_replace.stage.cleanup": "Siivotaan epäonnistunutta korvausyritystä", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fEsituotanto", "iris.runtime.world_create.pregen.console": "§6Esituotanto §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regin", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 308c86bd5..7351df21e 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePréparation du remplacement de {world} par Iris. Le monde actuel reste actif jusqu’au redémarrage.", + "iris.runtime.world_replace.progress": "§eRemplacement de {world} : {stage} ({elapsed}s écoulées)", + "iris.runtime.world_replace.stage.datapacks": "Vérification des packs de données de la dimension", + "iris.runtime.world_replace.stage.seed": "Préparation de la graine du remplacement", + "iris.runtime.world_replace.stage.pack": "Copie et validation du pack de la dimension", + "iris.runtime.world_replace.stage.players": "Enregistrement de la protection à l’entrée des joueurs", + "iris.runtime.world_replace.stage.verify": "Vérification du remplacement préparé", + "iris.runtime.world_replace.stage.save": "Enregistrement du remplacement pour le redémarrage", + "iris.runtime.world_replace.stage.cleanup": "Nettoyage du remplacement ayant échoué", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPrégénération", "iris.runtime.world_create.pregen.console": "§6Prégénération §e{percent}%", "iris.runtime.chunk_job.title.regen": "Régénérer", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 18847253a..025a42d6e 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eמכין החלפה באמצעות Iris עבור {world}. העולם הנוכחי נשאר פעיל עד להפעלה מחדש.", + "iris.runtime.world_replace.progress": "§eהחלפה עבור {world}: {stage} ({elapsed} שניות חלפו)", + "iris.runtime.world_replace.stage.datapacks": "בודק את חבילות הנתונים של הממד", + "iris.runtime.world_replace.stage.seed": "מכין את זרע העולם החלופי", + "iris.runtime.world_replace.stage.pack": "מעתיק ומאמת את חבילת הממד", + "iris.runtime.world_replace.stage.players": "שומר את ההגנה על כניסת השחקנים", + "iris.runtime.world_replace.stage.verify": "מאמת את ההחלפה שהוכנה", + "iris.runtime.world_replace.stage.save": "שומר את ההחלפה להפעלה מחדש", + "iris.runtime.world_replace.stage.cleanup": "מנקה את ההחלפה שנכשלה", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fהפקה מוקדמת", "iris.runtime.world_create.pregen.console": "§6הפקה מוקדמת §e{percent}%", "iris.runtime.chunk_job.title.regen": "מחדש", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 4fc6bb62b..d89dd31ec 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePreparazione della sostituzione di {world} con Iris. Il mondo attuale rimane attivo fino al riavvio.", + "iris.runtime.world_replace.progress": "§eSostituzione di {world}: {stage} ({elapsed}s trascorsi)", + "iris.runtime.world_replace.stage.datapacks": "Verifica dei pacchetti di dati della dimensione", + "iris.runtime.world_replace.stage.seed": "Preparazione del seme del mondo sostitutivo", + "iris.runtime.world_replace.stage.pack": "Copia e convalida del pacchetto della dimensione", + "iris.runtime.world_replace.stage.players": "Salvataggio della protezione all’ingresso dei giocatori", + "iris.runtime.world_replace.stage.verify": "Verifica della sostituzione preparata", + "iris.runtime.world_replace.stage.save": "Salvataggio della sostituzione per il riavvio", + "iris.runtime.world_replace.stage.cleanup": "Pulizia della sostituzione non riuscita", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregenerazione", "iris.runtime.world_create.pregen.console": "§6Pregenerazione §e{percent}%", "iris.runtime.chunk_job.title.regen": "Rigenera", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index cddcef348..7517c1e98 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world} を Iris で置き換える準備をしています。現在のワールドは再起動まで稼働し続けます。", + "iris.runtime.world_replace.progress": "§e{world} の置き換え: {stage} ({elapsed}秒経過)", + "iris.runtime.world_replace.stage.datapacks": "ディメンションのデータパックを確認しています", + "iris.runtime.world_replace.stage.seed": "置き換え先のシード値を準備しています", + "iris.runtime.world_replace.stage.pack": "ディメンションパックをコピーして検証しています", + "iris.runtime.world_replace.stage.players": "プレイヤーのワールド入場時の保護を記録しています", + "iris.runtime.world_replace.stage.verify": "準備した置き換え内容を検証しています", + "iris.runtime.world_replace.stage.save": "再起動時の置き換え内容を保存しています", + "iris.runtime.world_replace.stage.cleanup": "失敗した置き換えのデータを整理しています", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f事前生成中", "iris.runtime.world_create.pregen.console": "§6事前生成中 §e{percent}%", "iris.runtime.chunk_job.title.regen": "再生成", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index b0b74c46f..9cdf2c228 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world}를 Iris로 교체할 준비를 하고 있습니다. 현재 월드는 재시작 전까지 계속 활성화됩니다.", + "iris.runtime.world_replace.progress": "§e{world} 교체: {stage} ({elapsed}초 경과)", + "iris.runtime.world_replace.stage.datapacks": "차원 데이터 팩 확인 중", + "iris.runtime.world_replace.stage.seed": "교체할 월드의 시드 준비 중", + "iris.runtime.world_replace.stage.pack": "차원 팩 복사 및 검증 중", + "iris.runtime.world_replace.stage.players": "플레이어의 월드 진입 보호 기록 중", + "iris.runtime.world_replace.stage.verify": "준비된 교체 내용 검증 중", + "iris.runtime.world_replace.stage.save": "재시작 시 적용할 교체 내용 저장 중", + "iris.runtime.world_replace.stage.cleanup": "실패한 교체 작업 정리 중", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f연구분야", "iris.runtime.world_create.pregen.console": "§6연구분야 §e{percent}%", "iris.runtime.chunk_job.title.regen": "재생성", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index d5d45cf45..b220c88a2 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eRuošiamas {world} pakeitimas naudojant Iris. Dabartinis pasaulis liks aktyvus iki paleidimo iš naujo.", + "iris.runtime.world_replace.progress": "§e{world} pakeitimas: {stage} (praėjo {elapsed}s)", + "iris.runtime.world_replace.stage.datapacks": "Tikrinami dimensijos duomenų paketai", + "iris.runtime.world_replace.stage.seed": "Ruošiama pakaitinio pasaulio sėkla", + "iris.runtime.world_replace.stage.pack": "Kopijuojamas ir tikrinamas dimensijos paketas", + "iris.runtime.world_replace.stage.players": "Įrašoma žaidėjų įėjimo apsauga", + "iris.runtime.world_replace.stage.verify": "Tikrinamas paruoštas pakeitimas", + "iris.runtime.world_replace.stage.save": "Pakeitimas išsaugomas paleidimui iš naujo", + "iris.runtime.world_replace.stage.cleanup": "Valomi nepavykusio pakeitimo duomenys", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fRegeneravimas", "iris.runtime.world_create.pregen.console": "§6Regeneravimas §e{percent}%", "iris.runtime.chunk_job.title.regen": "Režen", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 7eaf92508..3d9a4b356 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eDe vervanging van {world} met Iris wordt voorbereid. De huidige wereld blijft actief tot de herstart.", + "iris.runtime.world_replace.progress": "§eVervanging van {world}: {stage} ({elapsed}s verstreken)", + "iris.runtime.world_replace.stage.datapacks": "Datapacks van de dimensie controleren", + "iris.runtime.world_replace.stage.seed": "Seed voor de vervangende wereld voorbereiden", + "iris.runtime.world_replace.stage.pack": "Dimensiepakket kopiëren en valideren", + "iris.runtime.world_replace.stage.players": "Bescherming bij het betreden van de wereld vastleggen", + "iris.runtime.world_replace.stage.verify": "Voorbereide vervanging controleren", + "iris.runtime.world_replace.stage.save": "Vervanging opslaan voor de herstart", + "iris.runtime.world_replace.stage.cleanup": "Mislukte vervanging opruimen", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneren", "iris.runtime.world_create.pregen.console": "§6Pregeneren §e{percent}%", "iris.runtime.chunk_job.title.regen": "RegenName", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 4cc3c2623..d9a905191 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePrzygotowywanie zastąpienia {world} przez Iris. Obecny świat pozostaje aktywny do ponownego uruchomienia.", + "iris.runtime.world_replace.progress": "§eZastąpienie {world}: {stage} (upłynęło {elapsed}s)", + "iris.runtime.world_replace.stage.datapacks": "Sprawdzanie pakietów danych wymiaru", + "iris.runtime.world_replace.stage.seed": "Przygotowywanie ziarna nowego świata", + "iris.runtime.world_replace.stage.pack": "Kopiowanie i sprawdzanie pakietu wymiaru", + "iris.runtime.world_replace.stage.players": "Zapisywanie ochrony przy wejściu graczy do świata", + "iris.runtime.world_replace.stage.verify": "Sprawdzanie przygotowanego zastąpienia", + "iris.runtime.world_replace.stage.save": "Zapisywanie zastąpienia do zastosowania po ponownym uruchomieniu", + "iris.runtime.world_replace.stage.cleanup": "Usuwanie pozostałości nieudanego zastąpienia", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneracja", "iris.runtime.world_create.pregen.console": "§6Pregeneracja §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regen.", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index f9640cf95..af64a5de8 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eA preparar a substituição de {world} pelo Iris. O mundo atual permanece ativo até ao reinício.", + "iris.runtime.world_replace.progress": "§eSubstituição de {world}: {stage} ({elapsed}s decorridos)", + "iris.runtime.world_replace.stage.datapacks": "A verificar os pacotes de dados da dimensão", + "iris.runtime.world_replace.stage.seed": "A preparar a semente do mundo de substituição", + "iris.runtime.world_replace.stage.pack": "A copiar e validar o pacote da dimensão", + "iris.runtime.world_replace.stage.players": "A guardar a proteção de entrada dos jogadores", + "iris.runtime.world_replace.stage.verify": "A verificar a substituição preparada", + "iris.runtime.world_replace.stage.save": "A guardar a substituição para o reinício", + "iris.runtime.world_replace.stage.cleanup": "A limpar a substituição falhada", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneração", "iris.runtime.world_create.pregen.console": "§6Pregeneração §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regen.", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 9507d83d5..958d8042f 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eПодготовка замены {world} с помощью Iris. Текущий мир останется активным до перезапуска.", + "iris.runtime.world_replace.progress": "§eЗамена {world}: {stage} (прошло {elapsed}с)", + "iris.runtime.world_replace.stage.datapacks": "Проверка пакетов данных измерения", + "iris.runtime.world_replace.stage.seed": "Подготовка сида заменяющего мира", + "iris.runtime.world_replace.stage.pack": "Копирование и проверка пакета измерения", + "iris.runtime.world_replace.stage.players": "Сохранение защиты при входе игроков в мир", + "iris.runtime.world_replace.stage.verify": "Проверка подготовленной замены", + "iris.runtime.world_replace.stage.save": "Сохранение замены для перезапуска", + "iris.runtime.world_replace.stage.cleanup": "Очистка данных неудавшейся замены", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fгенерирующий", "iris.runtime.world_create.pregen.console": "§6генерирующий §e{percent}%", "iris.runtime.chunk_job.title.regen": "Реген", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index c7924e3f8..e45953a8a 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world} için Iris ile değiştirme hazırlanıyor. Mevcut dünya yeniden başlatılana kadar etkin kalır.", + "iris.runtime.world_replace.progress": "§e{world} değişimi: {stage} ({elapsed} saniye geçti)", + "iris.runtime.world_replace.stage.datapacks": "Boyut veri paketleri kontrol ediliyor", + "iris.runtime.world_replace.stage.seed": "Yeni dünyanın tohumu hazırlanıyor", + "iris.runtime.world_replace.stage.pack": "Boyut paketi kopyalanıp doğrulanıyor", + "iris.runtime.world_replace.stage.players": "Oyuncuların dünyaya giriş koruması kaydediliyor", + "iris.runtime.world_replace.stage.verify": "Hazırlanan değişim doğrulanıyor", + "iris.runtime.world_replace.stage.save": "Değişim yeniden başlatma için kaydediliyor", + "iris.runtime.world_replace.stage.cleanup": "Başarısız değişim temizleniyor", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneating", "iris.runtime.world_create.pregen.console": "§6Pregeneating §e{percent}%", "iris.runtime.chunk_job.title.regen": "Rejen", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 2f0928daa..d2b79adf3 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eĐang chuẩn bị thay thế {world} bằng Iris. Thế giới hiện tại vẫn hoạt động cho đến khi máy chủ khởi động lại.", + "iris.runtime.world_replace.progress": "§eThay thế {world}: {stage} (đã qua {elapsed} giây)", + "iris.runtime.world_replace.stage.datapacks": "Đang kiểm tra các gói dữ liệu của chiều không gian", + "iris.runtime.world_replace.stage.seed": "Đang chuẩn bị hạt giống của thế giới thay thế", + "iris.runtime.world_replace.stage.pack": "Đang sao chép và xác thực gói chiều không gian", + "iris.runtime.world_replace.stage.players": "Đang lưu biện pháp bảo vệ khi người chơi vào thế giới", + "iris.runtime.world_replace.stage.verify": "Đang xác minh bản thay thế đã chuẩn bị", + "iris.runtime.world_replace.stage.save": "Đang lưu bản thay thế để áp dụng khi khởi động lại", + "iris.runtime.world_replace.stage.cleanup": "Đang dọn dẹp bản thay thế bị lỗi", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fTạo ra", "iris.runtime.world_create.pregen.console": "§6Tạo ra §e{percent}%", "iris.runtime.chunk_job.title.regen": "Bản sao", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 8ce7aa884..42e6230b7 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e正在准备使用 Iris 替换 {world}。当前世界将在重启前保持运行。", + "iris.runtime.world_replace.progress": "§e替换 {world}:{stage}(已用时 {elapsed} 秒)", + "iris.runtime.world_replace.stage.datapacks": "正在检查维度数据包", + "iris.runtime.world_replace.stage.seed": "正在准备替换世界的种子", + "iris.runtime.world_replace.stage.pack": "正在复制并验证维度包", + "iris.runtime.world_replace.stage.players": "正在记录玩家进入世界时的保护信息", + "iris.runtime.world_replace.stage.verify": "正在验证已准备的替换内容", + "iris.runtime.world_replace.stage.save": "正在保存替换内容以供重启时应用", + "iris.runtime.world_replace.stage.cleanup": "正在清理失败的替换内容", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f预生成", "iris.runtime.world_create.pregen.console": "§6预生成 §e{percent}%", "iris.runtime.chunk_job.title.regen": "瑞根,你好吗?", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index b5fcea44b..9513a8cf3 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e正在準備使用 Iris 替換 {world}。目前世界將在重新啟動前持續運作。", + "iris.runtime.world_replace.progress": "§e替換 {world}:{stage}(已耗時 {elapsed} 秒)", + "iris.runtime.world_replace.stage.datapacks": "正在檢查維度資料包", + "iris.runtime.world_replace.stage.seed": "正在準備替換世界的種子", + "iris.runtime.world_replace.stage.pack": "正在複製並驗證維度包", + "iris.runtime.world_replace.stage.players": "正在記錄玩家進入世界時的保護資訊", + "iris.runtime.world_replace.stage.verify": "正在驗證已準備的替換內容", + "iris.runtime.world_replace.stage.save": "正在儲存替換內容以供重新啟動時套用", + "iris.runtime.world_replace.stage.cleanup": "正在清理失敗的替換內容", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f預生成", "iris.runtime.world_create.pregen.console": "§6預生成 §e{percent}%", "iris.runtime.chunk_job.title.regen": "瑞根,你好嗎?", diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index a2a495b62..5b8634c18 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -64,12 +64,26 @@ public class WorldReplacementBootstrapTest { } @Test - public void rejectsNestedMetadataAddedAfterImmutableEpochPublication() throws Exception { + public void acceptsFinderMetadataAddedAfterImmutableEpochPublication() throws Exception { Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); configureReplacement(transaction); Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions"); Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata"); + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.published()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void rejectsNestedResourceAddedAfterImmutableEpochPublication() throws Exception { + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(transaction); + Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions"); + Files.writeString(dimensions.resolve(".hidden-resource"), "changed resource"); + assertThrows(IOException.class, this::reconcile); assertTrue(Files.isDirectory(paths(transaction).stage())); diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java index 72d65438d..ba2314ac6 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java @@ -5,8 +5,11 @@ import art.arcane.volmlib.util.localization.LocalizationSnapshot; import art.arcane.volmlib.util.localization.PluginLanguageEditor; import art.arcane.volmlib.util.localization.PluginLanguageService; import art.arcane.volmlib.util.localization.PluralSelector; +import art.arcane.volmlib.util.localization.RemoteLanguageCatalog; import art.arcane.volmlib.util.localization.TextValue; import art.arcane.volmlib.util.localization.VolmitLocales; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -14,11 +17,16 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import static org.junit.Assert.assertArrayEquals; @@ -98,13 +106,51 @@ public class IrisLanguageEditorTest { @Test public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception { - Files.writeString(IrisLanguage.remote(directory).cacheFile("fr_FR"), "{\"locale\":\"fr_FR\",\"messages\":{}}"); - PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); - TextValue value = new TextValue("Langue {locale}"); - PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), - original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); - assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS)); - assertEquals("en_US", languages.defaultLocale()); - assertEquals("en_US", IrisLanguage.activeLocale()); + byte[] complete = Files.readAllBytes(Path.of("src/main/resources/languages/fr_FR.json")); + AtomicInteger requests = new AtomicInteger(); + Field remoteField = IrisLanguage.class.getDeclaredField("remote"); + remoteField.setAccessible(true); + RemoteLanguageCatalog previous = IrisLanguage.remote(directory); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + try (RemoteLanguageCatalog catalog = RemoteLanguageCatalog.load(new RemoteLanguageCatalog.Options( + "Iris", + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"), + "languages", + ".json", + "iris-language-source.properties", + directory.toPath().resolve("languages/downloaded"), + IrisLanguage.class.getClassLoader() + ))) { + server.createContext(catalog.sourceUri("fr_FR").getPath(), + exchange -> respond(exchange, complete, requests)); + server.start(); + remoteField.set(null, catalog); + Path cache = catalog.cacheFile("fr_FR"); + Files.writeString(cache, "{\"locale\":\"fr_FR\",\"messages\":{}}"); + + PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + TextValue value = new TextValue("Langue {locale}"); + PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), + original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); + + assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS)); + assertEquals("en_US", languages.defaultLocale()); + assertEquals("en_US", IrisLanguage.activeLocale()); + assertArrayEquals(complete, Files.readAllBytes(cache)); + assertEquals(1, requests.get()); + } finally { + remoteField.set(null, previous); + server.stop(0); + } + } + + private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException { + requests.incrementAndGet(); + try { + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + } finally { + exchange.close(); + } } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java index 3007eac6f..fa3143068 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -3,6 +3,12 @@ package art.arcane.iris.engine; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes; import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.history.SavedBiomeRuntime; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.object.IrisRegion; +import java.util.Optional; +import java.util.concurrent.TimeUnit; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EngineStage; import art.arcane.iris.engine.framework.EngineTarget; @@ -59,6 +65,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class IrisEngineGenerationRuntimeScopeTest { @@ -86,6 +93,7 @@ public class IrisEngineGenerationRuntimeScopeTest { IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, selected.runtime); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); setField(engine, "generationHistoryRuntimeRouter", router); doReturn(null).when(engine).getDimensionStackContext(); doReturn(false).when(engine).answersFromNaturalTerrain(anyInt(), anyInt()); @@ -128,6 +136,7 @@ public class IrisEngineGenerationRuntimeScopeTest { RuntimeFixture selected = runtime(2, 2D, 2D, 2D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); setField(engine, "generationHistoryRuntimeRouter", router); ProceduralStream caves = mock(ProceduralStream.class); when(active.complex.getCaveBiomeStream()).thenReturn(caves); @@ -149,6 +158,82 @@ public class IrisEngineGenerationRuntimeScopeTest { verify(router, times(3)).openCoordinateScope(19, -3); } + @Test + @SuppressWarnings("unchecked") + public void unboundContentContextSkipsSavedBiomesAndSelectsTheCoordinateRuntime() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture selected = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, selected.runtime); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(router.biomes()).thenReturn(saved); + setField(engine, "generationHistoryRuntimeRouter", router); + doReturn(null).when(engine).getDimensionStackContext(); + doReturn(false).when(engine).answersFromNaturalTerrain(anyInt(), anyInt()); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(active.complex); + when(context.isNaturalTerrain()).thenReturn(false); + IrisBiome cave = mock(IrisBiome.class); + IrisBiome surface = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + ProceduralStream caves = mock(ProceduralStream.class); + ProceduralStream surfaces = mock(ProceduralStream.class); + ProceduralStream regions = mock(ProceduralStream.class); + when(selected.complex.getCaveBiomeStream()).thenReturn(caves); + when(selected.complex.getTrueBiomeStream()).thenReturn(surfaces); + when(selected.complex.getRegionStream()).thenReturn(regions); + when(caves.get(19D, -3D)).thenReturn(cave); + when(surfaces.get(19D, -3D)).thenReturn(surface); + when(regions.get(19D, -3D)).thenReturn(region); + when(router.openCoordinateScope(19, -3)).thenAnswer(invocation -> { + assertSame(active.complex, engine.getComplex()); + IrisEngine.GenerationRuntimeScope runtimeScope = engine.openGenerationRuntimeScope(binding); + GenerationHistoryRuntimeRouter.CoordinateScope coordinateScope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); + doAnswer(close -> { + runtimeScope.close(); + return null; + }).when(coordinateScope).close(); + return coordinateScope; + }); + + try (IrisContext.Scope ignored = IrisContext.open(engine, 73L, context)) { + assertSame(cave, engine.getCaveBiome(19, -3)); + assertSame(surface, engine.getSurfaceBiome(19, -3)); + assertSame(region, engine.getRegion(19, -3)); + assertSame(region, engine.getRegion(19, 20, -3)); + assertSame(active.complex, engine.getComplex()); + } + verify(router, times(4)).openCoordinateScope(19, -3); + verifyNoInteractions(saved); + assertNull(IrisContext.get()); + } + + @Test + public void gameplayContextWithoutAChunkStillReadsSavedBiomes() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + when(active.target.getWorld().minHeight()).thenReturn(-64); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(router.biomes()).thenReturn(saved); + setField(engine, "generationHistoryRuntimeRouter", router); + BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class), + mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class)); + when(saved.resolve(19, -64, -3, true)).thenReturn(Optional.of(environment)); + when(saved.resolveCaveBase(19, -3)).thenReturn(Optional.of(environment)); + + try (IrisContext.Scope ignored = IrisContext.open(engine, 0L, null)) { + assertSame(environment.biome(), engine.getCaveBiome(19, -3)); + assertSame(environment.biome(), engine.getSurfaceBiome(19, -3)); + assertSame(environment.region(), engine.getRegion(19, -3)); + } + verify(saved).resolveCaveBase(19, -3); + verify(saved, times(2)).resolve(19, -64, -3, true); + verify(router, never()).openCoordinateScope(anyInt(), anyInt()); + assertNull(IrisContext.get()); + } + @Test public void scopedGettersResolveDetachedGenerationStateAndWorldServicesStayActive() throws Exception { RuntimeFixture active = runtime(1, 1D, 2D, 3D); @@ -426,6 +511,7 @@ public class IrisEngineGenerationRuntimeScopeTest { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); when(router.engine()).thenReturn(engine); engine.attachGenerationHistoryRuntimeRouter(router); @@ -441,6 +527,7 @@ public class IrisEngineGenerationRuntimeScopeTest { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); when(router.engine()).thenReturn(engine); engine.attachGenerationHistoryRuntimeRouter(router); @@ -457,6 +544,7 @@ public class IrisEngineGenerationRuntimeScopeTest { IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); assertNull(engine.openGenerationHistoryCoordinateScope(0, 0)); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); when(router.engine()).thenReturn(engine); engine.attachGenerationHistoryRuntimeRouter(router); engine.detachGenerationHistoryRuntimeRouter(router); @@ -540,6 +628,83 @@ public class IrisEngineGenerationRuntimeScopeTest { verify(detached.data).close(); } + @Test + public void savedBiomesAnswerWithoutOpeningGenerationRoutes() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + when(active.target.getWorld().minHeight()).thenReturn(-64); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(router.biomes()).thenReturn(saved); + setField(engine, "generationHistoryRuntimeRouter", router); + BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class), + mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class)); + when(saved.resolve(19, -44, -3, false)).thenReturn(Optional.of(environment)); + when(saved.resolve(19, -64, -3, true)).thenReturn(Optional.of(environment)); + when(saved.resolveCaveBase(19, -3)).thenReturn(Optional.of(environment)); + + assertSame(environment, engine.getBiomeEnvironment(19, 20, -3)); + assertSame(environment, engine.getSurfaceBiomeEnvironment(19, -3)); + assertSame(environment.biome(), engine.getBiome(19, 20, -3)); + assertSame(environment.biome(), engine.getBiomeOrMantle(19, 20, -3)); + assertSame(environment.biome(), engine.getCaveOrMantleBiome(19, 20, -3)); + assertSame(environment.biome(), engine.getCaveBiome(19, 20, -3)); + assertSame(environment.biome(), engine.getCaveBiome(19, -3)); + assertSame(environment.biome(), engine.getSurfaceBiome(19, -3)); + assertSame(environment.region(), engine.getRegion(19, -3)); + assertSame(environment.region(), engine.getRegion(19, 20, -3)); + verify(router, never()).openCoordinateScope(anyInt(), anyInt()); + } + + @Test + public void unavailableSavedBiomesNeverUseTheActiveGenerator() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(router.biomes()).thenReturn(saved); + setField(engine, "generationHistoryRuntimeRouter", router); + SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Missing historical biome", false); + when(saved.resolve(19, 20, -3, false)).thenThrow(failure); + + assertSame(failure, assertThrows(SavedBiomeUnavailableException.class, + () -> engine.getBiome(19, 20, -3))); + verify(router, never()).openCoordinateScope(anyInt(), anyInt()); + } + + @Test + public void definitionScopesRestoreAndLeaveGenerationRuntimeUnchanged() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture selected = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + BiomeEnvironment first = new BiomeEnvironment(4L, mock(IrisBiome.class), + mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class)); + BiomeEnvironment second = new BiomeEnvironment(5L, mock(IrisBiome.class), + mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class)); + + try (BiomeEnvironment.Scope outer = engine.openBiomeEnvironmentScope(first)) { + assertSame(first.data(), engine.getData()); + assertSame(first.dimension(), engine.getDimension()); + assertSame(active.complex, engine.getComplex()); + try (BiomeEnvironment.Scope inner = engine.openBiomeEnvironmentScope(second)) { + assertSame(second.data(), engine.getData()); + assertThrows(IllegalStateException.class, outer::close); + CompletableFuture.runAsync(() -> assertThrows(IllegalStateException.class, inner::close)) + .get(5, TimeUnit.SECONDS); + } + assertSame(first.data(), engine.getData()); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope( + detachedBinding(engine, selected.runtime))) { + assertSame(selected.data, engine.getData()); + assertSame(selected.dimension, engine.getDimension()); + assertSame(selected.complex, engine.getComplex()); + } + assertSame(first.data(), engine.getData()); + } + assertSame(active.data, engine.getData()); + assertSame(active.dimension, engine.getDimension()); + } + private static IrisEngine engine( GenerationRuntime generationRuntime, EngineEffects effects, @@ -549,6 +714,7 @@ public class IrisEngineGenerationRuntimeScopeTest { setField(engine, "lifecycleLock", new Object()); setField(engine, "generationHistoryRuntimeRouterLock", new Object()); setField(engine, "runtimeAssembly", new ThreadLocal()); + setField(engine, "biomeEnvironmentScopes", new ThreadLocal()); setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState()); Set detached = Collections.synchronizedSet( Collections.newSetFromMap(new IdentityHashMap())); diff --git a/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java b/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java index 00a1fdcf8..1842335d2 100644 --- a/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/ResolvedTerrainProviderTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineStage; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.FloatingBiomeOverlay; import art.arcane.iris.engine.history.BoundaryColumnGeometry; import art.arcane.iris.engine.history.TerrainBoundarySignatureStore; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -12,6 +13,9 @@ import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.IrisContext; @@ -19,6 +23,7 @@ import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; +import org.mockito.MockedStatic; import java.io.IOException; import java.lang.reflect.Field; @@ -46,12 +51,42 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public final class ResolvedTerrainProviderTest { + @Test + public void cachedSpeculativeFloatingIdentityIsPublishedOnlyByActualGeneration() throws Exception { + Fixture fixture = new Fixture(); + fixture.floatingIdentity = new FloatingBiomeOverlay.Identity("floating-child", "region"); + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true); + when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); + when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); + fixture.provider.column(0, 0); + verify(fixture.router, never()).recordFloatingBiomes(anyInt(), anyInt(), any()); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone); + when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air); + when(registries.biome("minecraft:plains")).thenReturn(fixture.biome); + ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); + try (MockedStatic platforms = mockStatic(IrisPlatforms.class); + IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { + platforms.when(IrisPlatforms::get).thenReturn(platform); + fixture.provider.generate(fixture.mode, 0, 0, Hunk.newArrayHunk(16, 16, 16), + Hunk.newArrayHunk(16, 16, 16), false, context); + } + assertEquals(Integer.valueOf(1), fixture.computations.get("0,0")); + assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().volumeAt(0, 0, 0)); + assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().surfaceAt(0, 0)); + verify(fixture.router).recordFloatingBiomes(0, 0, context.getFloatingBiomes()); + } + @Test public void columnRequestsAreDeterministicAcrossOrderAndNegativeCoordinates() { Fixture first = new Fixture(); @@ -352,6 +387,7 @@ public final class ResolvedTerrainProviderTest { private final ResolvedTerrainProvider provider; private long lastSessionId; private EngineStage terrainOverride; + private FloatingBiomeOverlay.Identity floatingIdentity; @SuppressWarnings("unchecked") private Fixture() { @@ -411,6 +447,10 @@ public final class ResolvedTerrainProviderTest { } } } + if (floatingIdentity != null) { + context.floatingBiomes(16).record(0, 0, 0, floatingIdentity); + context.floatingBiomes(16).record(0, 2, 0, floatingIdentity); + } } private static PlatformBlockState block(String key, boolean air) { diff --git a/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerBiomeTest.java new file mode 100644 index 000000000..2267cf095 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerBiomeTest.java @@ -0,0 +1,117 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.MarkerSpawnScanner.PreparedMarkerSpawn; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisEntitySpawn; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisSpawner; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldEntitySpawnerBiomeTest { + @Test + public void loadingSurfaceDefinitionsDoNotConsumeInitialSpawnFlag() throws Exception { + Engine engine = mock(Engine.class); + IrisWorldManager manager = manager(engine); + WorldChunkMaintenance maintenance = manager.chunkMaintenance; + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(2); + when(chunk.getZ()).thenReturn(-1); + BiomeEnvironment environment = environment(); + when(engine.getSurfaceBiomeEnvironment(40, -8)) + .thenThrow(new SavedBiomeUnavailableException("Loading", true)) + .thenReturn(environment); + IrisSettings settings = new IrisSettings(); + settings.getWorld().setAmbientEntitySpawningSystem(true); + + try (MockedStatic configured = mockStatic(IrisSettings.class)) { + configured.when(IrisSettings::get).thenReturn(settings); + WorldEntitySpawner spawner = new WorldEntitySpawner(manager); + spawner.prepareInitialSpawn(chunk, List.of()); + verify(maintenance, never()).raiseInitialSpawnMarkerFlag(any(), anyInt(), anyInt(), any()); + spawner.prepareInitialSpawn(chunk, List.of()); + verify(maintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(2), eq(-1), any()); + } + } + + @Test + public void preparedInitialMarkersKeepDefinitionsAfterSavedQueryEviction() throws Exception { + Engine engine = mock(Engine.class); + IrisWorldManager manager = manager(engine); + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + BiomeEnvironment environment = environment(); + BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope); + IrisSpawner definition = mock(IrisSpawner.class); + IrisEntitySpawn entity = mock(IrisEntitySpawn.class); + IrisPosition position = new IrisPosition(3, 90, 4); + when(definition.getInitialSpawns()).thenReturn(new KList<>(entity)); + when(definition.canSpawn(engine, 0, 0)).thenReturn(true); + when(entity.getRarity()).thenReturn(1); + when(entity.getReferenceSpawner()).thenReturn(definition); + when(entity.spawn(eq(engine), eq(position), any(RNG.class))).thenReturn(1); + KSet definitions = new KSet<>(); + definitions.add(definition); + PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment); + IrisSettings settings = new IrisSettings(); + settings.getWorld().setAmbientEntitySpawningSystem(false); + + try (MockedStatic configured = mockStatic(IrisSettings.class)) { + configured.when(IrisSettings::get).thenReturn(settings); + new WorldEntitySpawner(manager).prepareInitialSpawn(chunk, List.of(marker)); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(manager.chunkMaintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(0), eq(0), callback.capture()); + doThrow(new SavedBiomeUnavailableException("Evicted", true)).when(engine) + .getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + callback.getValue().run(); + verify(entity).spawn(eq(engine), eq(position), any(RNG.class)); + verify(engine).openBiomeEnvironmentScope(environment); + verify(scope).close(); + verify(engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + } + } + + private static IrisWorldManager manager(Engine engine) throws Exception { + IrisWorldManager manager = mock(IrisWorldManager.class); + when(manager.getEngine()).thenReturn(engine); + Field field = IrisWorldManager.class.getDeclaredField("chunkMaintenance"); + field.setAccessible(true); + field.set(manager, mock(WorldChunkMaintenance.class)); + return manager; + } + + private static BiomeEnvironment environment() { + return new BiomeEnvironment(3L, mock(IrisBiome.class), mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java index 4885a3aee..ee1ba792b 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java @@ -1,6 +1,25 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -13,4 +32,35 @@ public class EnginePlayerTest { assertFalse(EnginePlayer.needsSample(true, 55L, 82D)); assertTrue(EnginePlayer.needsSample(true, 56L, 82D)); } + + @Test + public void loadingSavedBiomesRetryWithoutMovementAndKeepOneDefinitionContext() { + Engine engine = mock(Engine.class); + IrisWorld irisWorld = mock(IrisWorld.class); + World world = mock(World.class); + Player player = mock(Player.class); + when(engine.getWorld()).thenReturn(irisWorld); + when(irisWorld.minHeight()).thenReturn(-64); + when(player.getLocation()).thenReturn(new Location(world, 19, 80, -3)); + BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class), + mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class)); + when(engine.getBiomeEnvironment(19, 144, -3)) + .thenThrow(new SavedBiomeUnavailableException("Loading", true)) + .thenReturn(environment); + + try (MockedStatic binding = mockStatic(BukkitWorldBinding.class)) { + binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + EnginePlayer sampled = new EnginePlayer(engine, player); + assertTrue(sampled.sample()); + assertNull(sampled.getLastLocation()); + assertNull(sampled.getEnvironment()); + assertFalse(sampled.sample()); + assertSame(environment, sampled.getEnvironment()); + assertSame(environment.biome(), sampled.getBiome()); + assertSame(environment.region(), sampled.getRegion()); + assertFalse(sampled.sample()); + verify(engine, times(2)).getBiomeEnvironment(19, 144, -3); + } + } + } diff --git a/core/src/test/java/art/arcane/iris/engine/history/ArchivedRegistryCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/history/ArchivedRegistryCompilerTest.java index fc15ed1dc..9eef16f2c 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/ArchivedRegistryCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/ArchivedRegistryCompilerTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.history; import art.arcane.iris.core.IrisDatapackCompiler; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217; import art.arcane.iris.engine.object.IrisDimension; @@ -47,7 +48,8 @@ public class ArchivedRegistryCompilerTest { String oldEpoch = history.activeEpoch().epochId(); history.stageUpdate(secondPack, second.fingerprint(), second.dimension(), second.registry(), 256); history.promotePending(List.of()); - new GenerationPackRepository(world).releaseArchivedPacks(history.manifest()); + assertTrue(Files.isDirectory(history.paths().packRoot(oldEpoch))); + AtomicDirectoryPublisher.deleteTree(history.paths().packRoot(oldEpoch)); assertFalse(Files.exists(history.paths().packRoot(oldEpoch))); List packs = List.of(history.activePackRoot().toFile()); diff --git a/core/src/test/java/art/arcane/iris/engine/history/ArchivedSemanticQueryTest.java b/core/src/test/java/art/arcane/iris/engine/history/ArchivedSemanticQueryTest.java index 26dc62c80..3698d59e5 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/ArchivedSemanticQueryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/ArchivedSemanticQueryTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.engine.history; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -50,6 +51,8 @@ public final class ArchivedSemanticQueryTest { updated.stageUpdate(newPack, fingerprint(newPack), dimension, GenerationRegistryContract.empty(), 32); updated.prepareCurrentGenerator(32); GenerationHistory archived = GenerationHistory.open(world); + assertTrue(Files.exists(archived.paths().packRoot(oldEpoch))); + AtomicDirectoryPublisher.deleteTree(archived.paths().packRoot(oldEpoch)); assertFalse(Files.exists(archived.paths().packRoot(oldEpoch))); IrisEngine engine = engine(archived); diff --git a/core/src/test/java/art/arcane/iris/engine/history/FloatingBiomeOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/history/FloatingBiomeOverlayTest.java new file mode 100644 index 000000000..7e72f3657 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/FloatingBiomeOverlayTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public class FloatingBiomeOverlayTest { + @Test + public void volumeFollowsTheQuartAnchorAndLastWriterWhileSurfaceUsesTheHighestWrite() { + FloatingBiomeOverlay overlay = new FloatingBiomeOverlay(16); + FloatingBiomeOverlay.Identity first = new FloatingBiomeOverlay.Identity("first", "region"); + FloatingBiomeOverlay.Identity second = new FloatingBiomeOverlay.Identity("second", "region"); + overlay.record(0, 8, 0, first); + overlay.record(0, 9, 0, second); + overlay.record(1, 8, 1, second); + assertEquals(first, overlay.volumeAt(3, 11, 3)); + overlay.record(0, 8, 0, second); + assertEquals(second, overlay.volumeAt(3, 11, 3)); + assertNull(overlay.volumeAt(4, 8, 0)); + overlay.retainHighestSurfaces((x, z) -> x == 0 ? 9 : 12); + assertEquals(second, overlay.surfaceAt(0, 0)); + assertEquals(9, overlay.surfaceYAt(0, 0)); + assertNull(overlay.surfaceAt(1, 1)); + assertEquals(-1, overlay.surfaceYAt(1, 1)); + assertThrows(IllegalArgumentException.class, () -> overlay.record(16, 8, 0, first)); + assertThrows(IllegalArgumentException.class, () -> overlay.volumeAt(0, 16, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java index ecc86f347..c37b69dff 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java @@ -79,6 +79,33 @@ public class GenerationEpochContractFactoryTest { assertEquals(164, contract.internalFluidHeight()); } + @Test + public void recordedEpochKeepsItsUpperTerrainFingerprintVersion() throws Exception { + File pack = temporary.newFolder("recorded-upper-pack"); + Files.createDirectories(pack.toPath().resolve("objects")); + Files.writeString(pack.toPath().resolve("objects/.DS_Store"), "recorded metadata"); + IrisData data = mock(IrisData.class); + IrisDimension dimension = dimension(IrisEnvironment.NORMAL).setUpperDimension("overworld"); + dimension.setLoader(data); + when(data.getDataFolder()).thenReturn(pack); + GenerationEpoch.DimensionContract current = GenerationEpochContractFactory.create( + dimension, "overworld", "iris:overworld"); + GenerationEpoch epoch = mock(GenerationEpoch.class); + when(epoch.dimensionContract()).thenReturn(current); + when(epoch.packFingerprintVersion()).thenReturn(1); + + GenerationEpoch.DimensionContract recorded = GenerationEpochContractFactory.createForEpoch( + dimension, "iris:overworld", epoch); + + assertEquals(GenerationPackFingerprint.compute(pack.toPath(), 1), recorded.upperTerrainPackFingerprint()); + assertNotEquals(current.upperTerrainPackFingerprint(), recorded.upperTerrainPackFingerprint()); + when(epoch.dimensionContract()).thenReturn(recorded); + assertEquals(recorded, GenerationEpochContractFactory.createForEpoch(dimension, "iris:overworld", epoch)); + Files.writeString(pack.toPath().resolve("objects/.DS_Store"), "changed metadata"); + assertNotEquals(recorded, GenerationEpochContractFactory.createForEpoch(dimension, "iris:overworld", epoch)); + assertEquals(current, GenerationEpochContractFactory.create(dimension, "overworld", "iris:overworld")); + } + @Test public void upperTerrainMustBeLocalAndPackIdentityDoesNotChangeItsLayout() throws Exception { File pack = temporary.newFolder("upper-pack"); @@ -127,7 +154,7 @@ public class GenerationEpochContractFactoryTest { dimension, "overworld", "iris:overworld" - ) + ) ); } diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java index b1d6556bc..4d352a466 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -1,7 +1,10 @@ package art.arcane.iris.engine.history; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.framework.GenerationSessionManager; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.util.common.data.B; @@ -40,6 +43,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -177,10 +181,14 @@ public final class GenerationHistoryRuntimeRouterTest { } @Test - public void historicalRoutesWorkAfterArchivedPacksAreReleased() throws Exception { + public void historicalTerrainRoutesWorkWhenFrozenPackFixturesAreMissing() throws Exception { Path world = temporaryFolder.newFolder("router-archived-world").toPath(); GenerationHistory history = createThreeActivationHistory(world, "router-archived"); - new GenerationPackRepository(world).releaseArchivedPacks(history.manifest()); + for (long activationId : List.of(1L, 2L)) { + Path archivedPack = history.paths().packRoot(history.manifest().activation(activationId).orElseThrow().epochId()); + assertTrue(Files.isDirectory(archivedPack)); + AtomicDirectoryPublisher.deleteTree(archivedPack); + } assertFalse(Files.exists(history.paths().packRoot(history.manifest().activation(1L).orElseThrow().epochId()))); IrisEngine engine = mock(IrisEngine.class); FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); @@ -568,6 +576,18 @@ public final class GenerationHistoryRuntimeRouterTest { GenerationHistory history = createHistory(world, pack); IrisEngine engine = mock(IrisEngine.class); IrisComplex complex = mock(IrisComplex.class); + IrisBiome biome = new IrisBiome(); + biome.setLoadKey("alpha"); + IrisRegion region = new IrisRegion(); + region.setLoadKey("alpha"); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(biome); + when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(biome); + when(engine.getBiome(anyInt(), anyInt(), anyInt())).thenReturn(biome); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenReturn(biome); + when(engine.getRegion(anyInt(), anyInt())).thenReturn(region); + when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(region); EngineMantle engineMantle = mock(EngineMantle.class); @SuppressWarnings("unchecked") Mantle mantle = mock(Mantle.class); @@ -596,6 +616,7 @@ public final class GenerationHistoryRuntimeRouterTest { ChunkGenerationSemantics semantics = history.semantics(4, -3).orElseThrow(); assertTrue(semantics.sealed()); assertEquals(1L, semantics.activationId()); + assertEquals("alpha", history.savedBiomes().get(4, -3).orElseThrow().biomeAt(0, 0, 0).biomeKey()); router.close(); } diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java index ab68f8828..c39aec7aa 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java @@ -669,7 +669,7 @@ public final class GenerationHistoryTest { } @Test - public void coldRecoveryKeepsStoredClaimsAndHistoricalMetadataButReleasesOldPacks() throws Exception { + public void coldRecoveryKeepsStoredClaimsHistoricalMetadataAndFrozenPacks() throws Exception { Path world = temporaryFolder.newFolder("cold-recovery-world").toPath(); Path packA = createPack("cold-recovery-a", "alpha"); Path packB = createPack("cold-recovery-b", "beta"); @@ -695,7 +695,9 @@ public final class GenerationHistoryTest { assertTrue(recovered.semantics(0, 0).isPresent()); assertTrue(recovered.semantics(1, 0).isPresent()); assertTrue(recovered.semantics(2, 0).isEmpty()); - assertFalse(Files.exists(recovered.paths().packRoot(oldEpoch))); + assertTrue(Files.isDirectory(recovered.paths().packRoot(oldEpoch))); + assertEquals(GenerationPackFingerprint.compute(packA, GenerationPackFingerprint.CURRENT_VERSION), + GenerationPackFingerprint.compute(recovered.paths().packRoot(oldEpoch), GenerationPackFingerprint.CURRENT_VERSION)); assertTrue(Files.isDirectory(recovered.activePackRoot())); assertTrue(Files.isDirectory(packA)); GenerationHistory reopened = GenerationHistory.open(world); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java index d198655e2..3e8e679ef 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java @@ -30,10 +30,46 @@ public class GenerationPackFingerprintTest { String fingerprint = GenerationPackFingerprint.compute( pack, - GenerationPackFingerprint.CURRENT_VERSION + 1 ); assertEquals("ad7d39c2b95bd3001248778911ce6bbd6b4cbdcece28145838c2b923a0bf73b3", fingerprint); + Files.writeString(pack.resolve("objects/.DS_Store"), "finder"); + assertEquals("b36c630e300a9939fabe3fbd02f546f2a76b31d2ebccbf9106a13fb7d8b8eb98", + GenerationPackFingerprint.compute(pack, 1)); + assertEquals(fingerprint, GenerationPackFingerprint.compute(pack, 2)); + } + + @Test + public void versionTwoIgnoresFinderMetadataAtEveryDepth() throws Exception { + Path source = temporaryFolder.newFolder("metadata-source").toPath(); + Files.createDirectories(source.resolve("objects/trees")); + Files.writeString(source.resolve("objects/trees/oak.json"), "{}"); + Files.writeString(source.resolve("objects/.DS_Store"), "source metadata"); + Path target = temporaryFolder.getRoot().toPath().resolve("metadata-copy"); + GenerationPackRepository.copyPackTree(source, target); + String expected = GenerationPackFingerprint.compute(source, 2); + + Files.writeString(target.resolve(".DS_Store"), "root metadata"); + Files.writeString(target.resolve("objects/.DS_Store"), "destination metadata"); + Files.writeString(target.resolve("objects/trees/.DS_Store"), "nested metadata"); + + assertEquals(expected, GenerationPackFingerprint.compute(target, 2)); + assertNotEquals(GenerationPackFingerprint.compute(source, 1), + GenerationPackFingerprint.compute(target, 1)); + Files.writeString(target.resolve("objects/trees/oak.json"), "{\"changed\":true}"); + assertNotEquals(expected, GenerationPackFingerprint.compute(target, 2)); + } + + @Test + public void versionTwoStillIncludesOtherNestedHiddenResources() throws Exception { + Path pack = temporaryFolder.newFolder("nested-hidden-pack").toPath(); + Files.createDirectories(pack.resolve("objects")); + Files.writeString(pack.resolve("objects/.hidden"), "first"); + String first = GenerationPackFingerprint.compute(pack, 2); + Files.writeString(pack.resolve("objects/.hidden"), "second"); + + assertNotEquals(first, GenerationPackFingerprint.compute(pack, 2)); } @Test @@ -51,7 +87,7 @@ public class GenerationPackFingerprintTest { @Test public void unsupportedVersionsAndSymbolicLinksFailClosed() throws Exception { Path pack = temporaryFolder.newFolder("unsafe-pack").toPath(); - assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 2)); + assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 3)); Path target = temporaryFolder.newFile("outside.json").toPath(); try { diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java index 53ec9164e..e42e5b942 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java @@ -4,6 +4,7 @@ import org.junit.Assume; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.IOException; import java.nio.file.Files; @@ -12,11 +13,13 @@ import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; public class GenerationPackRepositoryTest { @Rule @@ -69,6 +72,50 @@ public class GenerationPackRepositoryTest { )); } + @Test + public void finderMetadataCreatedDuringPublicationDoesNotChangeVersionTwoIdentity() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("finder-world").toPath(); + Path source = createPack("finder-source", "{}"); + String fingerprint = fingerprint(source); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + String epochId = digest('b'); + AtomicBoolean injected = new AtomicBoolean(); + + try (MockedStatic ignored = mockStatic(GenerationPackFingerprint.class, invocation -> { + if (invocation.getMethod().getName().equals("compute")) { + Path pack = invocation.getArgument(0); + if (pack.getFileName().toString().startsWith(".pack-")) { + Files.writeString(pack.resolve(".DS_Store"), "root metadata"); + Files.writeString(pack.resolve("dimensions/.DS_Store"), "nested metadata"); + injected.set(true); + } + } + return invocation.callRealMethod(); + })) { + Path published = repository.publish(epochId, fingerprint, 2, source); + assertTrue(injected.get()); + assertTrue(Files.isRegularFile(published.resolve("dimensions/.DS_Store"))); + assertEquals(published, repository.requireExactPack(epochId, fingerprint, 2)); + Files.writeString(published.resolve("dimensions/main.json"), "{\"changed\":true}"); + assertThrows(IOException.class, () -> repository.requireExactPack(epochId, fingerprint, 2)); + } + } + + @Test + public void versionOnePublicationsKeepTheirOriginalMetadataContract() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("version-one-world").toPath(); + Path source = createPack("version-one-source", "{}"); + Files.writeString(source.resolve("dimensions/.DS_Store"), "original metadata"); + String fingerprint = GenerationPackFingerprint.compute(source, 1); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + String epochId = digest('a'); + + Path published = repository.publish(epochId, fingerprint, 1, source); + assertEquals(published, repository.requireExactPack(epochId, fingerprint, 1)); + Files.writeString(published.resolve("dimensions/.DS_Store"), "changed metadata"); + assertThrows(IOException.class, () -> repository.requireExactPack(epochId, fingerprint, 1)); + } + @Test public void rejectsNestedSymbolicLinks() throws Exception { Path dimensionRoot = temporaryFolder.newFolder("linked-world").toPath(); @@ -149,7 +196,7 @@ public class GenerationPackRepositoryTest { } @Test - public void releasesOnlyArchivedPacksAndCanRepublishTheirSnapshot() throws Exception { + public void publicationAndActivationRetainEveryFrozenPack() throws Exception { Path dimensionRoot = temporaryFolder.newFolder("retention-world").toPath(); Path firstSource = createPack("retention-first", "{\"version\":1}"); Path secondSource = createPack("retention-second", "{\"version\":2}"); @@ -167,18 +214,17 @@ public class GenerationPackRepositoryTest { repository.publish(pending.epochId(), pending.packFingerprint(), pending.packFingerprintVersion(), pendingSource); store.preparePendingActivation(pending, 256); - repository.releaseArchivedPacks(store.manifest()); - repository.releaseArchivedPacks(store.manifest()); - - assertFalse(Files.exists(repository.packRoot(first.epochId()))); + assertTrue(Files.isDirectory(repository.packRoot(first.epochId()))); + assertEquals(first.packFingerprint(), fingerprint(repository.packRoot(first.epochId()))); assertTrue(Files.isRegularFile(repository.epochRoot(first.epochId()).resolve("epoch.json"))); assertTrue(Files.isDirectory(repository.packRoot(second.epochId()))); assertTrue(Files.isDirectory(repository.packRoot(pending.epochId()))); assertTrue(Files.isRegularFile(firstSource.resolve("dimensions/main.json"))); assertEquals(store.manifest(), GenerationHistoryStore.open(repository.generationRoot()).manifest()); - Path republished = repository.publish(first.epochId(), first.packFingerprint(), first.packFingerprintVersion(), firstSource); - assertEquals(Files.readString(firstSource.resolve("dimensions/main.json")), - Files.readString(republished.resolve("dimensions/main.json"))); + Files.writeString(firstSource.resolve("dimensions/main.json"), "{\"version\":99}"); + assertEquals("{\"version\":1}", Files.readString(repository.packRoot(first.epochId()).resolve("dimensions/main.json"))); + assertEquals(repository.packRoot(first.epochId()), repository.requireExactPack( + first.epochId(), first.packFingerprint(), first.packFingerprintVersion())); } private static GenerationEpoch epoch(String fingerprint) { diff --git a/core/src/test/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelectionTest.java b/core/src/test/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelectionTest.java new file mode 100644 index 000000000..0e304e087 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/NativeBiomeSpawnSelectionTest.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +public class NativeBiomeSpawnSelectionTest { + @Test + public void savedSelectionUsesTheActualPhysicalHolderAndOwningRuntime() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.biomes()).thenReturn(saved); + NativeBiomeSpawnSelection retained = new NativeBiomeSpawnSelection( + NativeBiomeSpawnSelection.Mode.RETAINED, "minecraft:plains"); + when(saved.nativeSpawnSelection(-17, 62, 31, "iris:old-physical")).thenReturn(retained); + + assertEquals(retained, NativeBiomeSpawnSelection.at(engine, -17, 62, 31, "iris:old-physical")); + verify(saved).nativeSpawnSelection(-17, 62, 31, "iris:old-physical"); + } + + @Test + public void generationScopesUseCurrentPredictionWithoutReadingSavedBiomes() { + IrisEngine engine = mock(IrisEngine.class); + when(engine.hasGenerationRuntimeScope()).thenReturn(true); + + assertEquals(NativeBiomeSpawnSelection.Mode.CURRENT, + NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:new-physical").mode()); + verify(engine, never()).getGenerationHistoryRuntimeRouter(); + } + + @Test + public void loadingSkipsTheAttemptAndUnsupportedNeverUsesCurrentInheritance() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.biomes()).thenReturn(saved); + when(saved.nativeSpawnSelection(0, 64, 0, "iris:old")) + .thenThrow(new SavedBiomeUnavailableException("loading", true)) + .thenThrow(new SavedBiomeUnavailableException("unsupported", false)); + + assertEquals(NativeBiomeSpawnSelection.Mode.LOADING, + NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:old").mode()); + assertEquals(NativeBiomeSpawnSelection.Mode.NONE, + NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:old").mode()); + } + + @Test + public void retainedPhysicalMappingsAreIndependentOfLaterParentDerivativeChanges() { + IrisData previous = retainedData("iris:same-physical", "minecraft:plains"); + IrisData current = retainedData("iris:same-physical", "minecraft:desert"); + Map oldMappings = NativeBiomeSpawnSelection.retainedDerivatives(previous); + Map newMappings = NativeBiomeSpawnSelection.retainedDerivatives(current); + + assertEquals("minecraft:plains", oldMappings.get("iris:same-physical")); + assertEquals("minecraft:desert", newMappings.get("iris:same-physical")); + assertEquals(null, oldMappings.get("iris:unrelated-physical")); + assertTrue(oldMappings.keySet().contains("iris:same-physical")); + } + + private static IrisData retainedData(String physicalKey, String vanillaDerivative) { + IrisData data = mock(IrisData.class, RETURNS_DEEP_STUBS); + IrisDimension dimension = mock(IrisDimension.class); + IrisBiome biome = mock(IrisBiome.class); + IrisBiomeCustom custom = new IrisBiomeCustom(); + custom.setId("custom"); + when(data.getDimensionLoader().getPossibleKeys()).thenReturn(new String[]{"main"}); + when(data.getDimensionLoader().load("main")).thenReturn(dimension); + when(dimension.getReachableBiomes(any(DataProvider.class))).thenReturn(new KList().qadd(biome)); + when(biome.isCustom()).thenReturn(true); + when(biome.getCustomDerivitives()).thenReturn(new KList().qadd(custom)); + when(biome.getVanillaDerivativeKey()).thenReturn(vanillaDerivative); + when(data.customBiomeResourceKey(dimension, custom)).thenReturn(physicalKey); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeCaptureTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeCaptureTest.java new file mode 100644 index 000000000..73f2cb9bd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeCaptureTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class SavedBiomeCaptureTest { + @Test + public void retainsFloatingChildIdentityWhenItSharesTheHostDerivative() throws Exception { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); + SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class); + IrisBiome host = biome("host"); + IrisBiome child = biome("floating-child"); + host.setDerivative("minecraft:plains"); + child.setDerivative("minecraft:plains"); + IrisRegion region = region("host-region"); + when(stage.activation()).thenReturn(GenerationActivation.initial("a".repeat(64), 1L)); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(16); + when(engine.getHeight(anyInt(), anyInt())).thenReturn(2); + when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null); + when(engine.getRegion(anyInt(), anyInt())).thenReturn(region); + when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(region); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(host); + when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(host); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenReturn(host); + FloatingBiomeOverlay floating = new FloatingBiomeOverlay(16); + FloatingBiomeOverlay.Identity childIdentity = new FloatingBiomeOverlay.Identity(child.getLoadKey(), region.getLoadKey()); + floating.record(0, 8, 0, childIdentity); + floating.record(0, 9, 0, childIdentity); + floating.record(4, 8, 0, childIdentity); + floating.retainHighestSurfaces((x, z) -> x == 0 ? 9 : 12); + + SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage, historical, floating); + + assertEquals(host.getVanillaDerivativeKey(), child.getVanillaDerivativeKey()); + assertEquals("floating-child", chunk.surfaceAt(0, 0).biomeKey()); + assertEquals("host", chunk.surfaceAt(4, 0).biomeKey()); + assertEquals("floating-child", chunk.biomeAt(0, -56, 0).biomeKey()); + assertEquals("floating-child", chunk.biomeAt(3, -53, 3).biomeKey()); + assertEquals("host", chunk.biomeAt(0, -52, 0).biomeKey()); + assertEquals("host", chunk.caveBaseAt(0, 0).biomeKey()); + assertEquals(1L, chunk.biomeAt(0, -56, 0).activationId()); + verifyNoInteractions(historical); + } + + @Test + public void recordsExactSurfaceColumnsAndIndependentCaveAndVerticalIdentities() throws Exception { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); + SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class); + IrisBiome left = biome("left"); + IrisBiome right = biome("right"); + IrisBiome cave = biome("base-cave"); + IrisBiome lower = biome("lower-volume"); + IrisBiome upper = biome("upper-volume"); + IrisRegion surfaceRegion = region("surface-region"); + IrisRegion caveRegion = region("volume-region"); + when(stage.chunkX()).thenReturn(-1); + when(stage.chunkZ()).thenReturn(2); + when(stage.activation()).thenReturn(GenerationActivation.initial("a".repeat(64), 1L)); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(16); + when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null); + when(engine.getRegion(anyInt(), anyInt())).thenReturn(surfaceRegion); + when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(caveRegion); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenAnswer(call -> (int) call.getArgument(0) == -15 ? right : left); + when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(cave); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenAnswer(call -> (int) call.getArgument(1) < 8 ? lower : upper); + + SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage, historical, null); + + assertEquals(new SavedBiomeChunk.Header(-1, 2, 1L, -64, 16), chunk.header()); + assertEquals("left", chunk.surfaceAt(0, 0).biomeKey()); + assertEquals("right", chunk.surfaceAt(1, 0).biomeKey()); + assertEquals("surface-region", chunk.surfaceAt(1, 0).regionKey()); + assertEquals("base-cave", chunk.caveBaseAt(1, 0).biomeKey()); + assertEquals("lower-volume", chunk.biomeAt(1, -57, 0).biomeKey()); + assertEquals("upper-volume", chunk.biomeAt(1, -56, 0).biomeKey()); + assertEquals("volume-region", chunk.biomeAt(1, -56, 0).regionKey()); + assertEquals(2, chunk.column(0, 0).vertical().size()); + assertEquals(chunk.column(0, 0).vertical(), chunk.column(3, 3).vertical()); + verifyNoInteractions(historical); + } + + private static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + private static IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + return region; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRecoveryTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRecoveryTest.java new file mode 100644 index 000000000..9f9360416 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRecoveryTest.java @@ -0,0 +1,219 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingEntry; +import art.arcane.iris.engine.object.IrisDimensionStack; +import art.arcane.iris.engine.object.IrisFloatingChildBiomes; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverPolicy; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SavedBiomeRecoveryTest { + private static final String BIOME = "transition-demo"; + private static final String REGION = "transition-demo"; + private static final int CHUNK_X = -2; + private static final int CHUNK_Z = 3; + + private final GenerationHistory history = mock(GenerationHistory.class); + private final IrisData data = mock(IrisData.class, RETURNS_DEEP_STUBS); + private final IrisDimension dimension = new IrisDimension(); + private final IrisRegion region = new IrisRegion(); + private final IrisBiome biome = new IrisBiome(); + private final SavedBiomeRecovery.Input input = + new SavedBiomeRecovery.Input(history, CHUNK_X, CHUNK_Z, data, dimension); + + @Before + public void prepareSingletonPack() { + dimension.setLoadKey("overworld"); + dimension.setRegions(new KList().qadd(REGION)); + region.setLoadKey(REGION); + region.setLandBiomes(new KList().qadd(BIOME)); + region.setSeaBiomes(new KList().qadd(BIOME)); + region.setShoreBiomes(new KList().qadd(BIOME)); + region.setCaveBiomes(new KList().qadd(BIOME)); + biome.setLoadKey(BIOME); + when(data.getRegionLoader().load(REGION)).thenReturn(region); + when(data.getBiomeLoader().load(BIOME)).thenReturn(biome); + when(history.resolveActivation(CHUNK_X, CHUNK_Z)) + .thenReturn(GenerationActivation.initial("a".repeat(64), 1L)); + GenerationEpoch epoch = mock(GenerationEpoch.class); + GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class); + when(contract.dimensionKey()).thenReturn("overworld"); + when(contract.minHeight()).thenReturn(-64); + when(contract.maxHeight()).thenReturn(320); + when(contract.height()).thenReturn(384); + when(epoch.dimensionContract()).thenReturn(contract); + when(history.resolveEpoch(CHUNK_X, CHUNK_Z)).thenReturn(epoch); + record(semantics(1L).seal().build()); + } + + @Test + public void recoversAllColumnsAndEntireHeightFromTheOwningActivation() throws IOException { + SavedBiomeChunk recovered = SavedBiomeRecovery.recover(input).orElseThrow(); + SavedBiomeChunk.Cell expected = new SavedBiomeChunk.Cell(1L, BIOME, REGION); + assertEquals(new SavedBiomeChunk.Header(CHUNK_X, CHUNK_Z, 1L, -64, 384), recovered.header()); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(expected, recovered.surfaceAt(x, z)); + assertEquals(expected, recovered.caveBaseAt(x, z)); + assertEquals(expected, recovered.biomeAt(x, -64, z)); + assertEquals(expected, recovered.biomeAt(x, 319, z)); + assertEquals(1, recovered.column(x, z).vertical().size()); + } + } + } + + @Test + public void rejectsMissingOrUnsealedRecordedFacts() throws IOException { + when(history.semantics(CHUNK_X, CHUNK_Z)).thenReturn(Optional.empty()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + record(semantics(1L).build()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsMultipleSurfaceOrRegionIdentities() throws IOException { + record(semantics(1L).addSurfaceBiome("other").seal().build()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + record(semantics(1L).addRegion("other").seal().build()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsInconsistentOwnershipAndDifferentCaveIdentity() throws IOException { + record(semantics(2L).seal().build()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + record(semantics(1L).addCaveBiome("other").seal().build()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void acceptsRecordedCavesWithTheSameIdentity() throws IOException { + record(semantics(1L).addCaveBiome(BIOME).seal().build()); + assertTrue(SavedBiomeRecovery.recover(input).isPresent()); + } + + @Test + public void rejectsMissingDefinitions() throws IOException { + when(data.getBiomeLoader().load(BIOME)).thenReturn(null); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + when(data.getBiomeLoader().load(BIOME)).thenReturn(biome); + when(data.getRegionLoader().load(REGION)).thenReturn(null); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsPoolsWithUnrecordedChoicesOrNoVerticalChoice() throws IOException { + region.getCaveBiomes().add("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + region.getCaveBiomes().clear(); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + region.getCaveBiomes().add(BIOME); + region.getSeaBiomes().add("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsChildrenAndFloatingBiomes() throws IOException { + biome.getChildren().add("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + biome.getChildren().clear(); + biome.getFloatingChildBiomes().add(new IrisFloatingChildBiomes()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsStackedUpperAndCarvingOverrides() throws IOException { + dimension.setDimensionStack(new IrisDimensionStack()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + dimension.setDimensionStack(null); + dimension.setUpperDimension("upper"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + dimension.setUpperDimension("none"); + dimension.getCarving().add(new IrisDimensionCarvingEntry()); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsDifferingRiverOverridesAtEachScope() throws IOException { + IrisRiverPolicy policy = new IrisRiverPolicy().setFloodedCaveBiomes(new KList().qadd("other")); + dimension.setRiverPolicy(policy); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + dimension.setRiverPolicy(null); + region.setRiverPolicy(policy); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + region.setRiverPolicy(null); + biome.setRiverPolicy(policy); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsDifferentDimensionAndFocusSelectors() throws IOException { + dimension.setLoadKey("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + dimension.setLoadKey("overworld"); + dimension.setFocus("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + dimension.setFocus(""); + dimension.getRegions().add("other"); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + } + + @Test + public void rejectsOneBlendedColumnEvenWhenAllOtherColumnsAreNew() throws IOException { + TransitionGenerationPlan transition = nextActivation(); + when(transition.newEpochWeightAt(-17, 63)).thenReturn(0.999D); + assertTrue(SavedBiomeRecovery.recover(input).isEmpty()); + verify(transition).newEpochWeightAt(-17, 63); + } + + @Test + public void retainsLaterActivationIdentityForUnblendedChunks() throws IOException { + nextActivation(); + SavedBiomeChunk recovered = SavedBiomeRecovery.recover(input).orElseThrow(); + assertEquals(2L, recovered.activationId()); + assertEquals(2L, recovered.biomeAt(15, 319, 15).activationId()); + } + + @Test + public void propagatesUnreadableTransitionEvidence() throws IOException { + nextActivation(); + when(history.transitionPlan(2L)).thenThrow(new IOException("Unreadable frozen transition")); + assertThrows(IOException.class, () -> SavedBiomeRecovery.recover(input)); + } + + private TransitionGenerationPlan nextActivation() throws IOException { + when(history.resolveActivation(CHUNK_X, CHUNK_Z)) + .thenReturn(GenerationActivation.next(2L, "b".repeat(64), 1L, 2L, 64)); + record(semantics(2L).seal().build()); + TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class); + when(transition.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); + when(history.transitionPlan(2L)).thenReturn(transition); + return transition; + } + + private void record(ChunkGenerationSemantics semantics) { + when(history.semantics(CHUNK_X, CHUNK_Z)).thenReturn(Optional.of(semantics)); + } + + private static ChunkGenerationSemantics.Builder semantics(long activationId) { + return ChunkGenerationSemantics.builder(CHUNK_X, CHUNK_Z, activationId) + .addSurfaceBiome(BIOME).addRegion(REGION); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java new file mode 100644 index 000000000..f8f9eac92 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java @@ -0,0 +1,252 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SavedBiomeRuntimeTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final IrisEngine engine = mock(IrisEngine.class); + private final GenerationHistory history = mock(GenerationHistory.class); + private final SavedBiomeStore store = mock(SavedBiomeStore.class); + private IrisSettings previousSettings; + + @Before + public void prepareServices() throws Exception { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + when(history.savedBiomes()).thenReturn(store); + when(history.paths()).thenReturn(GenerationHistoryPaths.forDimension(temporaryFolder.newFolder().toPath())); + String epochId = "a".repeat(64); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationEpoch epoch = mock(GenerationEpoch.class); + when(history.manifest()).thenReturn(manifest); + when(manifest.activation(1L)).thenReturn(Optional.of(GenerationActivation.initial(epochId, 1L))); + when(manifest.activation(2L)).thenReturn(Optional.of(GenerationActivation.next(2L, epochId, 1L, 2L, 64))); + when(manifest.epoch(epochId)).thenReturn(Optional.of(epoch)); + when(epoch.epochId()).thenReturn(epochId); + } + + @After + public void restoreServices() { + IrisServices.clear(); + IrisSettings.settings = previousSettings; + } + + @Test + public void everyCallerReceivesLoadingWithoutWaitingForDisk() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.of(unresolvedChunk(1)); + }); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + try (ExecutorService caller = Executors.newSingleThreadExecutor()) { + Future response = caller.submit(() -> assertThrows( + SavedBiomeUnavailableException.class, () -> runtime.resolve(0, 0, 0, true))); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertTrue(response.get(1L, TimeUnit.SECONDS).isLoading()); + } finally { + release.countDown(); + runtime.close(); + } + } + + @Test + public void chunkReadinessDoesNotRequireEveryCellToHaveAResolvedIdentity() throws Exception { + when(store.get(0, 0)).thenReturn(Optional.of(unresolvedChunk(1))); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.prepareChunk(0, 0)).isLoading()); + awaitIdle(runtime); + runtime.prepareChunk(0, 0); + SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(0, 0, 0, true)); + assertEquals(false, unavailable.isLoading()); + } + } + + @Test + public void closeWaitsForInvalidatedWorkersAndRestoresInterruptStatus() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + SavedBiomeChunk chunk = unresolvedChunk(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.of(chunk); + }); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(0, 0, 0, true)).isLoading()); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + runtime.capture(chunk); + assertEquals(1, runtime.pendingQueryCount()); + try (ExecutorService closer = Executors.newSingleThreadExecutor()) { + Future closed = closer.submit(() -> { + Thread.currentThread().interrupt(); + runtime.close(); + return Thread.currentThread().isInterrupted(); + }); + try { + assertThrows(TimeoutException.class, () -> closed.get(100L, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + assertTrue(closed.get(5L, TimeUnit.SECONDS)); + assertEquals(0, runtime.pendingQueryCount()); + assertEquals(0, runtime.cachedQueryCount()); + } finally { + release.countDown(); + runtime.close(); + } + } + + @Test + public void completedSnapshotCacheRespectsItsByteBudgetAndNegativeCacheIsBounded() throws Exception { + when(store.get(anyInt(), anyInt())).thenReturn(Optional.of(unresolvedChunk(16))); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + for (int chunkX = 0; chunkX < 110; chunkX++) { + int blockX = chunkX * 16; + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(blockX, 0, 0, true)).isLoading()); + awaitIdle(runtime); + assertTrue(runtime.cachedQueryBytes() <= SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES); + } + assertTrue(runtime.cachedQueryCount() < 110); + } + when(store.get(anyInt(), anyInt())).thenReturn(Optional.empty()); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true); + when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty()); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + for (int chunkX = 0; chunkX < 150; chunkX++) { + int blockX = chunkX * 16; + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(blockX, 0, 0, true)).isLoading()); + awaitIdle(runtime); + assertEquals(Optional.empty(), runtime.resolve(blockX, 0, 0, true)); + assertTrue(runtime.cachedQueryCount() <= 128); + } + } + } + + @Test + public void preparedEnvironmentSurvivesLoaderEvictionWithoutReadingPackFilesAgain() throws Exception { + String epochId = "a".repeat(64); + Path pack = history.paths().packRoot(epochId); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("regions")); + Files.writeString(pack.resolve("dimensions/main.json"), "{}"); + Files.writeString(pack.resolve("biomes/forest.json"), "{}"); + Files.writeString(pack.resolve("regions/main.json"), "{}"); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationEpoch epoch = mock(GenerationEpoch.class); + GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class); + when(history.manifest()).thenReturn(manifest); + when(history.packRoot(1L)).thenReturn(pack); + when(history.packRoot(2L)).thenReturn(pack); + when(manifest.activation(1L)).thenReturn(Optional.of(GenerationActivation.initial(epochId, 1L))); + when(manifest.activation(2L)).thenReturn(Optional.of(GenerationActivation.next(2L, epochId, 1L, 2L, 64))); + when(manifest.epoch(epochId)).thenReturn(Optional.of(epoch)); + when(epoch.epochId()).thenReturn(epochId); + when(epoch.registryContract()).thenReturn(GenerationRegistryContract.empty()); + when(epoch.dimensionContract()).thenReturn(contract); + when(contract.dimensionKey()).thenReturn("main"); + SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(1L, "forest", "main"); + SavedBiomeChunk.Cell repeatedActivation = new SavedBiomeChunk.Cell(2L, "forest", "main"); + SavedBiomeChunk.Cell removed = new SavedBiomeChunk.Cell(2L, "removed", "main"); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 2L, -64, 384)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Cell selected = x < 8 ? cell : x < 12 ? repeatedActivation : removed; + builder.column(x, z, new SavedBiomeChunk.Column(selected, cell, + List.of(new SavedBiomeChunk.Span(-64, 320, selected)))); + } + } + when(store.get(0, 0)).thenReturn(Optional.of(builder.build())); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(0, 0, 0, true)).isLoading()); + awaitIdle(runtime); + runtime.prepareChunk(0, 0); + BiomeEnvironment environment = runtime.resolve(0, 0, 0, true).orElseThrow(); + BiomeEnvironment repeated = runtime.resolve(8, 0, 0, true).orElseThrow(); + assertEquals(2L, repeated.activationId()); + assertSame(environment.data(), repeated.data()); + SavedBiomeUnavailableException missing = assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(12, 0, 0, true)); + assertEquals(false, missing.isLoading()); + assertTrue(missing.getMessage().contains("removed")); + assertSame(environment, runtime.resolveCaveBase(12, 0).orElseThrow()); + environment.data().getBiomeLoader().unload("forest"); + environment.data().getRegionLoader().unload("main"); + Files.delete(pack.resolve("biomes/forest.json")); + Files.delete(pack.resolve("regions/main.json")); + assertSame(environment, runtime.resolve(0, 0, 0, true).orElseThrow()); + assertEquals("forest", environment.biome().getLoadKey()); + } + } + + private static void awaitIdle(SavedBiomeRuntime runtime) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (runtime.pendingQueryCount() != 0 && System.nanoTime() < deadline) { + Thread.sleep(1L); + } + assertEquals(0, runtime.pendingQueryCount()); + } + + private static SavedBiomeChunk unresolvedChunk(int runCount) { + SavedBiomeChunk.Cell first = SavedBiomeChunk.Cell.unresolved(1L); + SavedBiomeChunk.Cell second = SavedBiomeChunk.Cell.unresolved(2L); + ArrayList spans = new ArrayList<>(runCount); + for (int index = 0; index < runCount; index++) { + int start = -64 + index * 384 / runCount; + int end = -64 + (index + 1) * 384 / runCount; + spans.add(new SavedBiomeChunk.Span(start, end, (index & 1) == 0 ? first : second)); + } + return chunk(new SavedBiomeChunk.Column(first, second, spans)); + } + + private static SavedBiomeChunk chunk(SavedBiomeChunk.Column column) { + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 2L, -64, 384)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + builder.column(x, z, column); + } + } + return builder.build(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreTest.java new file mode 100644 index 000000000..48bea64b4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreTest.java @@ -0,0 +1,389 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.zip.CRC32; +import java.util.zip.DeflaterOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SavedBiomeStoreTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preservesEveryColumnHeightRegionAndOwningActivationAcrossReload() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeChunk original = chunk(-33, 31, 8L); + SavedBiomeStore writer = SavedBiomeStore.open(root); + assertTrue(writer.claimAndPersist(original)); + assertFalse(writer.claimAndPersist(original)); + SavedBiomeChunk loaded = SavedBiomeStore.open(root).get(-33, 31).orElseThrow(); + assertEquals(original, loaded); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(original.surfaceAt(x, z), loaded.surfaceAt(x, z)); + assertEquals(original.caveBaseAt(x, z), loaded.caveBaseAt(x, z)); + for (int y = -64; y < 320; y++) { + assertEquals(original.biomeAt(x, y, z), loaded.biomeAt(x, y, z)); + } + } + } + assertEquals(7L, loaded.biomeAt(0, -64, 0).activationId()); + assertEquals("cave/base-0", loaded.caveBaseAt(0, 0).biomeKey()); + assertEquals(8L, loaded.caveBaseAt(0, 0).activationId()); + assertFalse(loaded.caveBaseAt(0, 0).equals(loaded.biomeAt(0, -64, 0))); + assertEquals("region/0", loaded.surfaceAt(0, 0).regionKey()); + assertEquals("region/1", loaded.surfaceAt(0, 1).regionKey()); + assertThrows(IndexOutOfBoundsException.class, () -> loaded.biomeAt(0, -65, 0)); + assertThrows(IndexOutOfBoundsException.class, () -> loaded.biomeAt(0, 320, 0)); + } + + @Test + public void preservesUnresolvedHistoricalCellsAlongsideKnownBiomeAssignments() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeChunk.Cell unresolved = SavedBiomeChunk.Cell.unresolved(1L); + SavedBiomeChunk.Cell resolved = new SavedBiomeChunk.Cell(2L, "biome/current", "region/current"); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(-1, -1, 2L, -64, 384)); + List spans = List.of(new SavedBiomeChunk.Span(-64, 16, unresolved), + new SavedBiomeChunk.Span(16, 320, resolved)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + builder.column(x, z, new SavedBiomeChunk.Column(x < 8 ? unresolved : resolved, unresolved, spans)); + } + } + SavedBiomeChunk original = builder.build(); + SavedBiomeStore.open(root).claimAndPersist(original); + SavedBiomeChunk loaded = SavedBiomeStore.open(root).get(-1, -1).orElseThrow(); + assertEquals(original, loaded); + assertFalse(loaded.surfaceAt(0, 0).isResolved()); + assertEquals(unresolved, loaded.caveBaseAt(15, 15)); + assertEquals(1L, loaded.surfaceAt(0, 0).activationId()); + assertEquals(SavedBiomeChunk.Resolution.UNRESOLVED, loaded.biomeAt(15, -64, 15).resolution()); + assertEquals(null, loaded.biomeAt(15, -64, 15).biomeKey()); + assertEquals(null, loaded.biomeAt(15, -64, 15).regionKey()); + assertTrue(loaded.surfaceAt(8, 0).isResolved()); + assertEquals(resolved, loaded.biomeAt(0, 16, 0)); + assertThrows(IllegalArgumentException.class, () -> SavedBiomeChunk.Cell.unresolved(0L)); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, "biome/known", null)); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, "region/known")); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, "biome/known", null, + SavedBiomeChunk.Resolution.UNRESOLVED)); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, "region/known", + SavedBiomeChunk.Resolution.UNRESOLVED)); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, null, + SavedBiomeChunk.Resolution.RESOLVED)); + } + + @Test + public void rejectsConflictingClaimsWithoutReplacingSavedContent() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + SavedBiomeChunk original = chunk(1, 2, 3L); + store.claimAndPersist(original); + byte[] bytes = Files.readAllBytes(regionPath(root, 0, 0)); + assertThrows(IOException.class, () -> store.claimAndPersist(chunk(1, 2, 4L))); + assertTrue(Arrays.equals(bytes, Files.readAllBytes(regionPath(root, 0, 0)))); + assertEquals(Optional.of(original), SavedBiomeStore.open(root).get(1, 2)); + } + + @Test + public void missingSnapshotsLeaveExistingHistoryUntouchedAndCachedReadsAvoidDisk() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + Files.createDirectories(root.resolve("iris/generation/semantics")); + Path semantics = root.resolve("iris/generation/semantics/existing.isem"); + Files.writeString(semantics, "unchanged format six data"); + SavedBiomeStore store = SavedBiomeStore.open(root); + assertTrue(store.get(0, 0).isEmpty()); + assertFalse(Files.exists(store.storageDirectory())); + SavedBiomeChunk chunk = chunk(0, 0, 3L); + store.claimAndPersist(chunk); + Files.delete(regionPath(root, 0, 0)); + assertEquals(Optional.of(chunk), store.get(0, 0)); + assertEquals(Optional.of(chunk), store.cached(0, 0)); + assertEquals("unchanged format six data", Files.readString(semantics)); + } + + @Test + public void prunesOnlyUnstoredSelectedClaimsAndAllowsReplacementAfterCacheInvalidation() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + SavedBiomeChunk stored = chunk(0, 0, 3L); + SavedBiomeChunk orphan = chunk(1, 0, 3L); + SavedBiomeChunk previous = chunk(2, 0, 2L); + SavedBiomeChunk unrelatedRegion = chunk(32, 0, 2L); + store.claimAndPersist(stored); + store.claimAndPersist(orphan); + store.claimAndPersist(previous); + store.claimAndPersist(unrelatedRegion); + byte[] untouched = Files.readAllBytes(regionPath(root, 1, 0)); + WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(0, 0)); + + assertEquals(1, store.discardUnstoredClaims(inventory, Set.of(3L))); + + assertTrue(store.cached(1, 0).isEmpty()); + assertTrue(store.get(1, 0).isEmpty()); + assertEquals(Optional.of(stored), store.get(0, 0)); + assertEquals(Optional.of(previous), store.get(2, 0)); + assertEquals(Optional.of(unrelatedRegion), store.get(32, 0)); + assertTrue(Arrays.equals(untouched, Files.readAllBytes(regionPath(root, 1, 0)))); + SavedBiomeChunk replacement = chunk(1, 0, 4L); + assertTrue(store.claimAndPersist(replacement)); + assertEquals(0, store.discardUnstoredClaims(inventory, Set.of(3L))); + SavedBiomeStore loaded = SavedBiomeStore.open(root); + assertEquals(Optional.of(replacement), loaded.get(1, 0)); + assertEquals(Optional.of(stored), loaded.get(0, 0)); + assertEquals(Optional.of(previous), loaded.get(2, 0)); + assertEquals(1, loaded.discardUnstoredClaims(WorldChunkInventory.empty(), Set.of(4L))); + assertTrue(SavedBiomeStore.open(root).get(1, 0).isEmpty()); + assertThrows(IllegalArgumentException.class, () -> loaded.discardUnstoredClaims(inventory, Set.of(0L))); + } + + @Test + public void tornLastAppendTruncatesOnlyIncompleteClaimAndAllowsNewAppend() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + SavedBiomeChunk first = chunk(0, 0, 3L); + store.claimAndPersist(first); + Path path = regionPath(root, 0, 0); + long firstLength = Files.size(path); + store.claimAndPersist(chunk(1, 0, 3L)); + byte[] bytes = Files.readAllBytes(path); + Files.write(path, Arrays.copyOf(bytes, bytes.length - 3)); + SavedBiomeStore loaded = SavedBiomeStore.open(root); + assertEquals(Optional.of(first), loaded.get(0, 0)); + assertEquals(firstLength, Files.size(path)); + assertTrue(loaded.get(1, 0).isEmpty()); + assertTrue(loaded.claimAndPersist(chunk(2, 0, 3L))); + assertTrue(SavedBiomeStore.open(root).get(2, 0).isPresent()); + } + + @Test + public void rejectsChecksumCorruptionAndDuplicateCompleteRecords() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 0, 3L)); + Path path = regionPath(root, 0, 0); + byte[] original = Files.readAllBytes(path); + byte[] corrupt = original.clone(); + corrupt[corrupt.length - 1] ^= 1; + Files.write(path, corrupt); + assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0)); + Files.write(path, original); + Files.write(path, Arrays.copyOfRange(original, 20, original.length), StandardOpenOption.APPEND); + assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0)); + } + + @Test + public void rejectsUnknownFormatEvenWithValidHeaderChecksum() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore.open(root).claimAndPersist(chunk(0, 0, 3L)); + Path path = regionPath(root, 0, 0); + byte[] bytes = Files.readAllBytes(path); + ByteBuffer.wrap(bytes).putInt(4, 2); + CRC32 crc = new CRC32(); + crc.update(bytes, 0, 16); + ByteBuffer.wrap(bytes).putInt(16, (int) crc.getValue()); + Files.write(path, bytes); + assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0)); + } + + @Test + public void rejectsOversizedDecodedHeadersPalettesRunsAndStringsBeforeAllocation() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore.open(root).claimAndPersist(chunk(0, 0, 3L)); + Path path = regionPath(root, 0, 0); + rewritePayload(path, new byte[24], Integer.MAX_VALUE); + assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0)); + for (int mode = 0; mode < 3; mode++) { + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(raw)) { + output.writeLong(3L); + output.writeInt(-64); + output.writeInt(384); + output.writeInt(mode == 0 ? Integer.MAX_VALUE : 1); + if (mode == 0) { + output.writeInt(0); + } else { + output.writeLong(3L); + output.writeByte(mode == 1 ? 1 : 0); + if (mode == 1) { + output.writeInt(1); + output.writeByte(0); + output.writeByte(0); + output.write(new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x07}); + } else { + output.writeShort(65_535); + } + } + } + rewritePayload(path, raw.toByteArray(), raw.size()); + assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0)); + } + } + + @Test + public void modelRejectsDenseColumnsBeyondItsDecodedFootprintLimit() { + SavedBiomeChunk.Cell first = new SavedBiomeChunk.Cell(3L, "biome/first", "region/main"); + SavedBiomeChunk.Cell second = new SavedBiomeChunk.Cell(3L, "biome/second", "region/main"); + ArrayList spans = new ArrayList<>(4096); + for (int y = 0; y < 4096; y++) { + spans.add(new SavedBiomeChunk.Span(y, y + 1, (y & 1) == 0 ? first : second)); + } + SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(first, second, spans); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 3L, 0, 4096)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + builder.column(x, z, column); + } + } + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, builder::build); + assertTrue(failure.getMessage().contains("footprint limit")); + } + + @Test + public void boundedCachesReloadEvictedRegionsAndChunks() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + for (int index = 0; index < 140; index++) { + store.claimAndPersist(chunk(index * 32, -32, 3L)); + assertTrue(store.cachedChunkCount() <= 128); + assertTrue(store.cachedRegionCount() <= 64); + assertTrue(store.cachedByteCount() <= 64L * 1024L * 1024L); + } + assertTrue(store.cached(0, -32).isEmpty()); + assertEquals(Optional.of(chunk(0, -32, 3L)), store.get(0, -32)); + assertEquals(Optional.of(chunk(139 * 32, -32, 3L)), SavedBiomeStore.open(root).get(139 * 32, -32)); + } + + @Test + public void compressesRepeatedVerticalColumnsAndKeepsDistinctExactSurfaceColumns() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeChunk.Header header = new SavedBiomeChunk.Header(0, 0, 5L, -64, 384); + SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(5L, "biome/cave", "region/main"); + List vertical = List.of(new SavedBiomeChunk.Span(-64, 320, cave)); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(header); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(5L, "biome/surface-" + x, "region/" + z); + builder.column(x, z, new SavedBiomeChunk.Column(surface, cave, vertical)); + } + } + SavedBiomeChunk original = builder.build(); + SavedBiomeStore.open(root).claimAndPersist(original); + assertTrue(Files.size(regionPath(root, 0, 0)) < 4096); + assertEquals(Optional.of(original), SavedBiomeStore.open(root).get(0, 0)); + } + + @Test + public void concurrentlyPublishesSeparateRegionsAndSingleImmutableClaims() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + try (ExecutorService workers = Executors.newFixedThreadPool(4)) { + ArrayList> futures = new ArrayList<>(); + for (int index = 0; index < 12; index++) { + int chunkX = (index % 6) * 32; + futures.add(workers.submit(() -> store.claimAndPersist(chunk(chunkX, 0, 3L)))); + } + int added = 0; + for (Future future : futures) { + if (future.get()) { + added++; + } + } + assertEquals(6, added); + } + SavedBiomeStore loaded = SavedBiomeStore.open(root); + for (int index = 0; index < 6; index++) { + assertEquals(Optional.of(chunk(index * 32, 0, 3L)), loaded.get(index * 32, 0)); + } + } + + @Test + public void modelCopiesInputsCompactsRunsAndRejectsGapsMissingColumnsAndInvalidOwners() { + SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(3L, "biome/main", "region/main"); + ArrayList spans = new ArrayList<>(List.of( + new SavedBiomeChunk.Span(-64, 0, cell), new SavedBiomeChunk.Span(0, 320, cell))); + SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, spans); + spans.clear(); + assertEquals(List.of(new SavedBiomeChunk.Span(-64, 320, cell)), column.vertical()); + assertThrows(UnsupportedOperationException.class, () -> column.vertical().clear()); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 3L, -64, 384)); + assertThrows(NullPointerException.class, builder::build); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(0L, "biome/main", "region/main")); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Header(0, 0, 3L, Integer.MAX_VALUE, 1)); + assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Column(cell, cell, List.of( + new SavedBiomeChunk.Span(-64, 0, cell), new SavedBiomeChunk.Span(1, 320, cell)))); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + builder.column(x, z, column); + } + } + SavedBiomeChunk saved = builder.build(); + SavedBiomeChunk.Column shorter = new SavedBiomeChunk.Column(cell, cell, List.of(new SavedBiomeChunk.Span(-64, 319, cell))); + builder.column(0, 0, shorter); + assertEquals(column, saved.column(0, 0)); + assertThrows(IllegalArgumentException.class, builder::build); + } + + private static void rewritePayload(Path path, byte[] raw, int declaredSize) throws IOException { + byte[] header = Arrays.copyOf(Files.readAllBytes(path), 20); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + DataOutputStream coordinates = new DataOutputStream(body); + coordinates.writeInt(0); + coordinates.writeInt(0); + coordinates.writeInt(declaredSize); + try (DeflaterOutputStream compressed = new DeflaterOutputStream(coordinates)) { + compressed.write(raw); + } + byte[] encoded = body.toByteArray(); + CRC32 crc = new CRC32(); + crc.update(encoded); + ByteArrayOutputStream file = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(file)) { + output.write(header); + output.writeInt(encoded.length); + output.write(encoded); + output.writeInt((int) crc.getValue()); + } + Files.write(path, file.toByteArray()); + } + + private static Path regionPath(Path root, int regionX, int regionZ) { + return root.resolve("iris/generation/biomes/r." + regionX + "." + regionZ + ".ibio"); + } + + private static SavedBiomeChunk chunk(int chunkX, int chunkZ, long activation) { + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(chunkX, chunkZ, activation, -64, 384)); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(activation - 1L, "cave/" + (x / 4), "region/" + (z / 4)); + SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(activation, "surface/" + x, "region/" + z); + builder.column(x, z, new SavedBiomeChunk.Column(surface, + new SavedBiomeChunk.Cell(activation, "cave/base-" + z, "region/" + z), List.of( + new SavedBiomeChunk.Span(-64, 16 + z, cave), new SavedBiomeChunk.Span(16 + z, 320, surface)))); + } + } + return builder.build(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisEffectSurfaceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisEffectSurfaceTest.java new file mode 100644 index 000000000..37c378444 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisEffectSurfaceTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisEffectSurfaceTest { + @Test + public void samplesLiveHeightOnTargetRegionThenEmitsOnPlayerThread() { + World world = mock(World.class); + Player player = mock(Player.class); + Location location = new Location(world, -17, 100, 32); + when(player.getWorld()).thenReturn(world); + when(world.isChunkLoaded(-2, 2)).thenReturn(true); + when(world.getHighestBlockYAt(-17, 32, HeightMap.OCEAN_FLOOR)).thenReturn(23); + AtomicReference regionTask = new AtomicReference<>(); + AtomicReference playerTask = new AtomicReference<>(); + + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.runAt(eq(location), any(Runnable.class))).thenAnswer(call -> { + regionTask.set(call.getArgument(1)); + return true; + }); + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(call -> { + playerTask.set(call.getArgument(1)); + return true; + }); + + new IrisEffect().applyParticles(player, Particle.FLAME, location); + assertNotNull(regionTask.get()); + assertNull(playerTask.get()); + verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class)); + regionTask.get().run(); + assertEquals(24D, location.getY(), 0D); + assertNotNull(playerTask.get()); + playerTask.get().run(); + verify(player).spawnParticle(eq(Particle.FLAME), anyDouble(), eq(24D), anyDouble(), + anyInt(), anyDouble(), anyDouble(), anyDouble()); + } + } + + @Test + public void unloadedParticleColumnsDoNotLoadTerrainOrEmit() { + World world = mock(World.class); + Player player = mock(Player.class); + Location location = new Location(world, 0, 100, 0); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.isOwnedByCurrentRegion(world, 0, 0)).thenReturn(true); + new IrisEffect().applyParticles(player, Particle.FLAME, location); + verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class)); + scheduler.verify(() -> J.runEntity(eq(player), any(Runnable.class)), never()); + } + } +}