diff --git a/.gitignore b/.gitignore index cadf2e553..653ba488e 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ BUILDS/ # Version-content compat lane worktree. Generated, never source. .compatwt/ + +.worktrees/ diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 6066f3684..9063dea2f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.history.GenerationEpoch; import art.arcane.iris.engine.history.GenerationEpochContractFactory; import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -191,44 +192,46 @@ public final class ModdedWorldEngines { ModdedGenerationHistoryStorage.ActivePack opened ) throws IOException { GenerationHistory history = opened.history(); - history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - opened = ModdedGenerationHistoryStorage.resolveActive(history); - long openedActivationId = history.activeActivation().activationId(); - IrisEngine candidate = buildEngine(level, seed, opened); - boolean ready = false; - try { - GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( - history, - IrisBoundarySignatureSampler.INSTANCE, - IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() - ); - router.preloadActiveRuntimes(); - if (history.activeActivation().activationId() != openedActivationId) { - close(candidate); - ModdedGenerationHistoryStorage.ActivePack promoted = - ModdedGenerationHistoryStorage.resolveActive(history); - candidate = buildEngine(level, seed, promoted); - router = GenerationHistoryRuntimeRouter.attach( - candidate, + try (GenerationAdmission.RuntimeLease startupAdmission = history.retainRuntime()) { + history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); + opened = ModdedGenerationHistoryStorage.resolveActive(history); + long openedActivationId = history.activeActivation().activationId(); + IrisEngine candidate = buildEngine(level, seed, opened); + boolean ready = false; + try { + GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( history, - IrisBoundarySignatureSampler.INSTANCE + IrisBoundarySignatureSampler.INSTANCE, + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() ); router.preloadActiveRuntimes(); - } - requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); - ready = true; - ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", - level.dimension().identifier(), - candidate.getData().getDataFolder().getAbsolutePath(), - candidate.getDimension().getLoadKey(), - seed, - candidate.getDimension().getMinHeight(), - candidate.getDimension().getMaxHeight(), - history.activeActivation().activationId()); - return candidate; - } finally { - if (!ready) { - close(candidate); + if (history.activeActivation().activationId() != openedActivationId) { + close(candidate); + ModdedGenerationHistoryStorage.ActivePack promoted = + ModdedGenerationHistoryStorage.resolveActive(history); + candidate = buildEngine(level, seed, promoted); + router = GenerationHistoryRuntimeRouter.attach( + candidate, + history, + IrisBoundarySignatureSampler.INSTANCE + ); + router.preloadActiveRuntimes(); + } + requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); + ready = true; + ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", + level.dimension().identifier(), + candidate.getData().getDataFolder().getAbsolutePath(), + candidate.getDimension().getLoadKey(), + seed, + candidate.getDimension().getMinHeight(), + candidate.getDimension().getMaxHeight(), + history.activeActivation().activationId()); + return candidate; + } finally { + if (!ready) { + close(candidate); + } } } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java index 17297aa22..a9e5a0406 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java @@ -22,24 +22,15 @@ import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.ArrayList; import java.util.Comparator; import java.util.List; -/** - * Validates biome layer stacks. caveCeilingLayers reuses the height generators built from layers, - * so a biome with more ceiling entries than surface entries has no generator for the extras; the - * engine skips them, and this validator surfaces the mistake to the author at validate time. - */ final class PackBiomeLayerValidator { - private static final int DEFAULT_LAYER_COUNT = 1; - private PackBiomeLayerValidator() { } - static List validateCeilingLayerCounts(File biomesFolder) { + static List validateLayers(File biomesFolder) { List blockingErrors = new ArrayList<>(); if (biomesFolder == null || !biomesFolder.isDirectory()) { return blockingErrors; @@ -49,24 +40,13 @@ final class PackBiomeLayerValidator { biomeFiles.sort(Comparator.comparing(File::getPath)); for (File biomeFile : biomeFiles) { String biomeKey = PackValidationIo.deriveKey(biomesFolder, biomeFile); - JSONObject biome; - try { - biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8)); - } catch (Throwable e) { - // Invalid JSON is reported by the graph validators; layer counts have nothing to add. + JSONObject biome = PackValidationIo.readJson(biomeFile); + if (biome == null) { continue; } - Integer layers = arrayLength(biome, "layers", biomeKey, DEFAULT_LAYER_COUNT, blockingErrors); - Integer ceiling = arrayLength(biome, "caveCeilingLayers", biomeKey, 0, blockingErrors); - if (layers == null || ceiling == null) { - continue; - } - - if (ceiling > layers) { - blockingErrors.add("Biome '" + biomeKey + "' declares " + ceiling + " caveCeilingLayers but only " - + layers + " layers. caveCeilingLayers reuses the layers height generators and must not have more entries."); - } + validateLayerArray(biome, "layers", biomeKey, blockingErrors); + validateLayerArray(biome, "caveCeilingLayers", biomeKey, blockingErrors); } return blockingErrors; } @@ -126,17 +106,15 @@ final class PackBiomeLayerValidator { } } - private static Integer arrayLength(JSONObject biome, String field, String biomeKey, int defaultCount, + private static void validateLayerArray(JSONObject biome, String field, String biomeKey, List blockingErrors) { if (!biome.has(field) || biome.isNull(field)) { - return defaultCount; + return; } JSONArray array = biome.optJSONArray(field); if (array == null) { blockingErrors.add("Biome '" + biomeKey + "' " + field + " must be an array."); - return null; } - return array.length(); } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index e09d62545..5cd39ebd5 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -150,7 +150,7 @@ public final class PackValidator { new File(packFolder, "spawners"), new File(packFolder, "entities"))); blockingErrors.addAll(PackSpawnValidator.validateCustomBiomeSpawns( new File(packFolder, "biomes"), PackSpawnValidator::resolveEntitySpawnCategory)); - blockingErrors.addAll(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(packFolder, "biomes"))); + blockingErrors.addAll(PackBiomeLayerValidator.validateLayers(new File(packFolder, "biomes"))); blockingErrors.addAll(PackBiomeLayerValidator.validateDecoratorPalettes( new File(packFolder, "biomes"), new File(packFolder, "snippet/decorator"))); PackStyledRangeDefaultValidator.Validation styledRanges = PackStyledRangeDefaultValidator.validate(packFolder); diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 3a6ee0db8..baeff05cf 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -68,11 +68,12 @@ final class EngineHotloader { engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previous = engine.runtime; GenerationRuntime previousGeneration = previous.generation(); - IrisComplex nextComplex = null; + RuntimeAssembly assembly = null; + boolean published = false; try { engine.sealForTransition("complex hotload", false); engine.prepareRuntimeHotload(); - RuntimeAssembly assembly = new RuntimeAssembly( + assembly = new RuntimeAssembly( RuntimeAssembly.nextRuntimeId(), previousGeneration.target(), previousGeneration.mantleStorageDirectory(), @@ -82,7 +83,6 @@ final class EngineHotloader { EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); - nextComplex = assembly.complex; assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); @@ -98,25 +98,30 @@ final class EngineHotloader { } Throwable retirementFailure = runCleanup(null, previousGeneration.complex()::close); if (retirementFailure != null) { - retirementFailure = runCleanup(retirementFailure, nextComplex::close); - nextComplex = null; engine.lifecycleState = LifecycleState.FAILED; throw new IllegalStateException("Failed to retire the previous Iris biome complex.", retirementFailure); } engine.runtime = next; + published = true; engine.getGenerationSessions().activateNextSession(); engine.lifecycleState = LifecycleState.RUNNING; engine.getClosing().set(false); engine.backgroundTasks.openBackgroundTaskAdmission(); } catch (Throwable e) { - if (nextComplex != null && nextComplex != previousGeneration.complex()) { - Throwable cleanupFailure = runCleanup(null, nextComplex::close); - if (cleanupFailure != null) { - e.addSuppressed(cleanupFailure); + if (published) { + engine.lifecycleState = LifecycleState.FAILED; + engine.getClosing().set(true); + engine.backgroundTasks.closeBackgroundTaskAdmission(); + } else { + if (assembly != null && assembly.complex != previousGeneration.complex()) { + Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, null); + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + } + if (engine.lifecycleState != LifecycleState.FAILED) { + engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous); } - } - if (engine.lifecycleState != LifecycleState.FAILED) { - engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous); } throw new IllegalStateException("Failed to rebuild the Iris biome complex.", e); } @@ -175,7 +180,7 @@ final class EngineHotloader { broadcastStudioHotload(false, ""); } catch (Throwable e) { if (!published) { - if (replacementData != null) { + if (replacementData != null && !engine.shutdownSequence.retainsData(replacementData)) { replacementData.unregisterEngine(engine); Throwable replacementDataFailure = runCleanup(null, replacementData::close); if (replacementDataFailure != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index 099affb6a..2dd1cb789 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -209,7 +209,12 @@ final class EngineRuntimeBuilder { next.generation().mantle(), null); if (retirementFailure != null) { - retirementFailure = engine.shutdownSequence.closeRuntime(next, retirementFailure); + Throwable cleanupFailure = engine.shutdownSequence.closeRuntime( + next, previous == null ? null : previous.generation().mantle(), null); + if (cleanupFailure != null) { + engine.shutdownSequence.retainUnpublishedRuntime(next); + retirementFailure = EngineShutdownSequence.appendFailure(retirementFailure, cleanupFailure); + } engine.lifecycleState = LifecycleState.FAILED; throw new IllegalStateException("Failed to retire the previous Iris engine runtime.", retirementFailure); } @@ -229,6 +234,7 @@ final class EngineRuntimeBuilder { engine.getClosing().set(true); engine.backgroundTasks.closeBackgroundTaskAdmission(); engine.lifecycleState = LifecycleState.FAILED; + Throwable cleanupFailure = null; try { engine.getGenerationSessions().sealAndAwait( "failed world manager start", @@ -236,11 +242,17 @@ final class EngineRuntimeBuilder { true ); } catch (Throwable drainFailure) { - e.addSuppressed(drainFailure); + cleanupFailure = drainFailure; } - engine.shutdownSequence.closeRuntime(next, e); - if (engine.runtime == next) { - engine.runtime = null; + if (cleanupFailure == null) { + cleanupFailure = engine.shutdownSequence.closeRuntime(next, null); + } + if (cleanupFailure == null) { + if (engine.runtime == next) { + engine.runtime = null; + } + } else { + e.addSuppressed(cleanupFailure); } throw new IllegalStateException("Failed to start the Iris world manager.", e); } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index a9d6ff669..b7d0a5add 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.engine.IrisEngine.LifecycleState; @@ -26,9 +27,19 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.history.GenerationAdmission; +import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + /** * Owns the ordered teardown of a single {@link IrisEngine}, both for a normal close and for a * construction that failed part way through. Every resource release is gated on the previous one @@ -38,6 +49,12 @@ final class EngineShutdownSequence { private static final long CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS = 5000L; private final IrisEngine engine; + private final List generationAdmissions = new ArrayList<>(1); + private final Set incompleteAssemblies = ConcurrentHashMap.newKeySet(); + private final Set unpublishedRuntimes = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap<>())); + private final Set incompleteTargets = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap<>())); private boolean runtimeReleased; private boolean targetReleased; private boolean engineDataReleased; @@ -47,6 +64,10 @@ final class EngineShutdownSequence { this.engine = engine; } + void retainGenerationHistory(GenerationHistory history) { + generationAdmissions.add(history.retainRuntime()); + } + void close() { if (!engine.beginShutdown()) { return; @@ -150,9 +171,12 @@ final class EngineShutdownSequence { && targetReleased && engineDataReleased && preservationReleased) { - engine.closed = true; - engine.lifecycleState = LifecycleState.CLOSED; - IrisLogging.debug("Engine Fully Shutdown!"); + failure = releaseGenerationAdmissions(failure); + if (failure == null) { + engine.closed = true; + engine.lifecycleState = LifecycleState.CLOSED; + IrisLogging.debug("Engine Fully Shutdown!"); + } } } if (failure != null) { @@ -160,6 +184,19 @@ final class EngineShutdownSequence { } } + private Throwable releaseGenerationAdmissions(Throwable failure) { + Iterator admissions = generationAdmissions.iterator(); + while (admissions.hasNext()) { + GenerationAdmission.RuntimeLease admission = admissions.next(); + Throwable admissionFailure = runCleanup(null, admission::close); + failure = appendFailure(failure, admissionFailure); + if (admissionFailure == null) { + admissions.remove(); + } + } + return failure; + } + private void reportIncompleteClose(Throwable failure) { IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + "."); IrisLogging.reportError(failure); @@ -172,17 +209,21 @@ final class EngineShutdownSequence { EngineTickRegistry.unregisterTicking(engine); engine.lifecycleState = LifecycleState.FAILED; Throwable cleanupFailure = null; + boolean sessionsDrained = true; try { engine.getGenerationSessions().sealAndAwait("failed initialization", 0L, true); } catch (Throwable e) { + sessionsDrained = false; cleanupFailure = appendFailure(cleanupFailure, e); } engine.backgroundTasks.cancelBackgroundTasks("failed initialization"); BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("failed initialization"); cleanupFailure = appendFailure(cleanupFailure, backgroundDrain.failure()); - if (!backgroundDrain.allowsResourceRelease()) { - cleanupFailure = appendFailure(cleanupFailure, - new IllegalStateException("Iris background tasks remain active after failed initialization.")); + if (!sessionsDrained || !backgroundDrain.allowsResourceRelease()) { + if (!backgroundDrain.allowsResourceRelease()) { + cleanupFailure = appendFailure(cleanupFailure, + new IllegalStateException("Iris background tasks remain active after failed initialization.")); + } if (cleanupFailure != original) { original.addSuppressed(cleanupFailure); } @@ -192,18 +233,22 @@ final class EngineShutdownSequence { () -> NativeStructureOwnershipStore.close(engine)); cleanupFailure = appendFailure(cleanupFailure, ownershipFailure); if (ownershipFailure == null) { - cleanupFailure = closeDetachedGenerationRuntimes(cleanupFailure); - cleanupFailure = closeRuntime(engine.runtime, cleanupFailure); - engine.runtime = null; - cleanupFailure = runCleanup(cleanupFailure, engine.publishedTarget::close); - cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData); - engine.closed = true; - cleanupFailure = runCleanup(cleanupFailure, () -> { - PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class); - if (registry != null) { - registry.dereference(); + cleanupFailure = releaseRuntime(cleanupFailure); + if (runtimeReleased) { + cleanupFailure = releaseTarget(cleanupFailure); + } + if (targetReleased) { + cleanupFailure = releaseEngineDataForShutdown(cleanupFailure); + } + if (engineDataReleased) { + cleanupFailure = releasePreservation(cleanupFailure); + } + if (runtimeReleased && targetReleased && engineDataReleased && preservationReleased) { + cleanupFailure = releaseGenerationAdmissions(cleanupFailure); + if (generationAdmissions.isEmpty()) { + engine.closed = true; } - }); + } } if (cleanupFailure != null && cleanupFailure != original) { original.addSuppressed(cleanupFailure); @@ -214,37 +259,55 @@ final class EngineShutdownSequence { if (assembly == null) { return failure; } - failure = runCleanup(failure, () -> { - if (assembly.worldManager != null) { - assembly.worldManager.close(); - } - }); - failure = runCleanup(failure, () -> { - if (assembly.effects != null) { - assembly.effects.close(); - } - }); - failure = runCleanup(failure, () -> { - if (assembly.mode != null) { - assembly.mode.close(); - } - }); - failure = runCleanup(failure, () -> { - if (assembly.complex != null) { - assembly.complex.close(); - } - }); - failure = runCleanup(failure, () -> { - if (assembly.hash32 != null) { - assembly.hash32.cancel(true); - } - }); - if (assembly.mantle != null && assembly.ownsMantle) { - assembly.ownsMantle = false; - failure = runCleanup(failure, assembly.mantle::saveAllNow); - failure = runCleanup(failure, assembly.mantle::close); + incompleteAssemblies.add(assembly); + RuntimeAssembly previous = engine == null ? null : engine.runtimeAssembly.get(); + if (engine != null) { + engine.runtimeAssembly.set(assembly); } - return failure; + Throwable assemblyFailure; + try { + assemblyFailure = closeAssemblyResources(assembly); + } finally { + if (engine != null) { + if (previous == null) { + engine.runtimeAssembly.remove(); + } else { + engine.runtimeAssembly.set(previous); + } + } + } + if (assemblyFailure == null) { + incompleteAssemblies.remove(assembly); + } else if (engine != null) { + engine.lifecycleState = LifecycleState.FAILED; + } + return appendFailure(failure, assemblyFailure); + } + + void retainUnpublishedRuntime(EngineRuntime runtime) { + unpublishedRuntimes.add(runtime); + } + + boolean retainsData(IrisData data) { + if (engine.runtime != null && engine.runtime.generation().data() == data) { + return true; + } + for (RuntimeAssembly assembly : incompleteAssemblies) { + if (assembly.target.getData() == data) { + return true; + } + } + for (EngineRuntime runtime : unpublishedRuntimes.toArray(new EngineRuntime[0])) { + if (runtime.generation().data() == data) { + return true; + } + } + for (EngineTarget target : incompleteTargets.toArray(new EngineTarget[0])) { + if (target.getData() == data) { + return true; + } + } + return false; } Throwable closeRuntime(EngineRuntime engineRuntime, Throwable failure) { @@ -259,8 +322,11 @@ final class EngineShutdownSequence { if (engineRuntime == null) { return failure; } - failure = runCleanup(failure, engineRuntime.worldManager()::close); - failure = runCleanup(failure, engineRuntime.effects()::close); + Throwable serviceFailure = runCleanup(null, engineRuntime.worldManager()::close); + serviceFailure = runCleanup(serviceFailure, engineRuntime.effects()::close); + if (serviceFailure != null) { + return appendFailure(failure, serviceFailure); + } return closeGenerationRuntime(engineRuntime.generation(), retainedMantle, failure); } @@ -276,12 +342,18 @@ final class EngineShutdownSequence { if (generationRuntime == null) { return failure; } - failure = runCleanup(failure, generationRuntime.mode()::close); - failure = runCleanup(failure, generationRuntime.complex()::close); + Throwable modeFailure = runCleanup(null, generationRuntime.mode()::close); + if (modeFailure != null) { + return appendFailure(failure, modeFailure); + } + Throwable complexFailure = runCleanup(null, generationRuntime.complex()::close); + failure = appendFailure(failure, complexFailure); + if (complexFailure != null) { + return failure; + } failure = runCleanup(failure, () -> generationRuntime.hash32().cancel(true)); if (generationRuntime.mantle() != retainedMantle) { - failure = runCleanup(failure, generationRuntime.mantle()::saveAllNow); - failure = runCleanup(failure, generationRuntime.mantle()::close); + failure = appendFailure(failure, closeMantle(generationRuntime.mantle())); } return failure; } @@ -290,15 +362,31 @@ final class EngineShutdownSequence { IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding( engine, generationRuntime); + Throwable runtimeFailure; try (IrisEngine.GenerationRuntimeScope ignored = engine.generationRuntimeScopes.open(binding)) { - failure = closeGenerationRuntime(generationRuntime, failure); + runtimeFailure = closeGenerationRuntime(generationRuntime, null); } catch (Throwable scopeFailure) { - failure = appendFailure(failure, scopeFailure); + runtimeFailure = scopeFailure; + } + if (runtimeFailure != null) { + return appendFailure(failure, runtimeFailure); } return closeDetachedTarget(generationRuntime.target(), failure); } Throwable closeDetachedTarget(EngineTarget target, Throwable failure) { + for (RuntimeAssembly assembly : incompleteAssemblies) { + if (assembly.target == target) { + return appendFailure(failure, + new IllegalStateException("Iris runtime assembly still owns the detached target.")); + } + } + for (EngineRuntime runtime : unpublishedRuntimes.toArray(new EngineRuntime[0])) { + if (runtime.generation().target() == target) { + return appendFailure(failure, + new IllegalStateException("Iris unpublished runtime still owns the detached target.")); + } + } failure = runCleanup(failure, () -> target.getData().unregisterEngine(engine)); failure = runCleanup(failure, target::close); failure = runCleanup(failure, target.getData()::close); @@ -309,6 +397,10 @@ final class EngineShutdownSequence { if (runtimeReleased) { return failure; } + Throwable incompleteFailure = closeIncompleteRuntimes(); + if (incompleteFailure != null) { + return appendFailure(failure, incompleteFailure); + } Throwable detachedFailure = closeDetachedGenerationRuntimes(null); if (detachedFailure != null) { return appendFailure(failure, detachedFailure); @@ -322,6 +414,86 @@ final class EngineShutdownSequence { return failure; } + private Throwable closeAssemblyResources(RuntimeAssembly assembly) { + Throwable failure = runCleanup(null, () -> { + if (assembly.worldManager != null) { + assembly.worldManager.close(); + } + }); + failure = runCleanup(failure, () -> { + if (assembly.effects != null) { + assembly.effects.close(); + } + }); + if (failure != null) { + return failure; + } + failure = runCleanup(null, () -> { + if (assembly.mode != null) { + assembly.mode.close(); + } + }); + if (failure != null) { + return failure; + } + failure = runCleanup(null, () -> { + if (assembly.complex != null) { + assembly.complex.close(); + } + }); + if (failure != null) { + return failure; + } + failure = runCleanup(null, () -> { + if (assembly.hash32 != null) { + assembly.hash32.cancel(true); + } + }); + if (assembly.mantle != null && assembly.ownsMantle) { + Throwable mantleFailure = closeMantle(assembly.mantle); + if (mantleFailure == null) { + assembly.ownsMantle = false; + } + failure = appendFailure(failure, mantleFailure); + } + return failure; + } + + private Throwable closeIncompleteRuntimes() { + Throwable failure = null; + for (RuntimeAssembly assembly : incompleteAssemblies.toArray(new RuntimeAssembly[0])) { + Throwable assemblyFailure = closeAssembly(assembly, null); + if (assemblyFailure == null && assembly.target != engine.publishedTarget) { + incompleteTargets.add(assembly.target); + } + failure = appendFailure(failure, assemblyFailure); + } + EngineMantle retainedMantle = engine.runtime == null ? null : engine.runtime.generation().mantle(); + for (EngineRuntime unpublished : unpublishedRuntimes.toArray(new EngineRuntime[0])) { + Throwable runtimeFailure = closeRuntime(unpublished, retainedMantle, null); + if (runtimeFailure == null) { + unpublishedRuntimes.remove(unpublished); + if (unpublished.generation().target() != engine.publishedTarget) { + incompleteTargets.add(unpublished.generation().target()); + } + } + failure = appendFailure(failure, runtimeFailure); + } + for (EngineTarget target : incompleteTargets.toArray(new EngineTarget[0])) { + Throwable targetFailure = closeDetachedTarget(target, null); + if (targetFailure == null) { + incompleteTargets.remove(target); + } + failure = appendFailure(failure, targetFailure); + } + return failure; + } + + private static Throwable closeMantle(EngineMantle mantle) { + Throwable failure = runCleanup(null, mantle::saveAllNow); + return failure == null ? runCleanup(null, mantle::close) : failure; + } + private Throwable closeDetachedGenerationRuntimes(Throwable failure) { GenerationRuntime[] detachedRuntimes = engine.detachedGenerationRuntimes.toArray(new GenerationRuntime[0]); for (GenerationRuntime detached : detachedRuntimes) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 3e5565b6c..8bb5db43b 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -37,6 +37,7 @@ import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRaritySelection; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisGenerator; @@ -1708,74 +1709,25 @@ public class IrisComplex implements DataProvider { } static final class ChildSelectionPlan { - private final IrisBiome[] mappedBiomes; - private final int maxIndex; + private final IrisRaritySelection selection; - private ChildSelectionPlan(IrisBiome[] mappedBiomes) { - this.mappedBiomes = mappedBiomes; - this.maxIndex = mappedBiomes.length - 1; + private ChildSelectionPlan(IrisRaritySelection selection) { + this.selection = selection; } static ChildSelectionPlan create(KList options) { - if (options.isEmpty()) { - return new ChildSelectionPlan(new IrisBiome[0]); - } - - int maxRarity = 1; - for (IrisBiome biome : options) { - if (biome != null && biome.getRarity() > maxRarity) { - maxRarity = biome.getRarity(); - } - } - - int rarityMax = maxRarity + 1; - boolean flip = false; - KList mapped = new KList<>(); - for (IrisBiome biome : options) { - if (biome == null) { - continue; - } - - int rarity = Math.max(1, biome.getRarity()); - int count = rarityMax - rarity; - for (int index = 0; index < count; index++) { - flip = !flip; - if (flip) { - mapped.add(biome); - } else { - mapped.add(0, biome); - } - } - } - - if (mapped.isEmpty()) { - IrisBiome[] fallback = new IrisBiome[]{options.get(0)}; - return new ChildSelectionPlan(fallback); - } - - IrisBiome[] mappedBiomes = mapped.toArray(new IrisBiome[0]); - return new ChildSelectionPlan(mappedBiomes); + return new ChildSelectionPlan(IrisRaritySelection.create(options)); } IrisBiome select(CNG childCell, double x, double z) { - if (mappedBiomes.length == 0) { - return null; + long maximumIndex = selection.size() - 1L; + if (maximumIndex <= 0L) { + return selection.get(0L); } - - if (mappedBiomes.length == 1) { - return mappedBiomes[0]; + if (maximumIndex <= Integer.MAX_VALUE) { + return selection.get(childCell.fit2D(0, (int) maximumIndex, x, z)); } - - int selectedIndex = childCell.fit2D(0, maxIndex, x, z); - if (selectedIndex < 0) { - return mappedBiomes[0]; - } - - if (selectedIndex > maxIndex) { - return mappedBiomes[maxIndex]; - } - - return mappedBiomes[selectedIndex]; + return selection.select(childCell.noiseFast2D(x, z)); } } @@ -1783,9 +1735,9 @@ public class IrisComplex implements DataProvider { } public void close() { - resolvedTerrain.clear(); if (hydrologyRuntime != null) { hydrologyRuntime.close(); } + resolvedTerrain.clear(); } } 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 cdde04c18..ae0e99fa4 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -1010,6 +1010,7 @@ public class IrisEngine implements Engine { if (generationHistoryRuntimeRouter != null) { throw new IllegalStateException("Iris engine already has a generation-history runtime router."); } + shutdownSequence.retainGenerationHistory(required.history()); generationHistoryRuntimeRouter = required; generationHistoryRoutingRequired = true; } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index af77a1014..a10cbec55 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -277,26 +277,26 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } if (!looperStopped) { try { - looperStopped = true; if (looper != null) { looper.interrupt(); - joinQuietly(looper); + awaitLooper(looper); } + looperStopped = true; } catch (Throwable e) { failure = appendCloseFailure(failure, e); } } if (!cleanupServiceStopped) { try { - cleanupServiceStopped = true; if (cleanupService != null) { for (Future future : cleanup.values()) { future.cancel(false); } cleanup.clear(); cleanupService.shutdownNow(); - awaitQuietly(cleanupService); + awaitCleanupService(cleanupService); } + cleanupServiceStopped = true; } catch (Throwable e) { failure = appendCloseFailure(failure, e); } @@ -306,28 +306,30 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } } - private void joinQuietly(Thread thread) { + private void awaitLooper(Thread thread) { if (thread == Thread.currentThread()) { - return; + throw new IllegalStateException("Cannot finish world manager shutdown from its own loop thread."); } try { thread.join(CLOSE_AWAIT_MS); if (thread.isAlive()) { - IrisLogging.warn("Thread " + thread.getName() + " did not stop within " + CLOSE_AWAIT_MS + "ms."); + throw new IllegalStateException("Thread " + thread.getName() + " did not stop within " + CLOSE_AWAIT_MS + "ms."); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the world manager loop to stop.", e); } } - private void awaitQuietly(ScheduledExecutorService service) { + private void awaitCleanupService(ScheduledExecutorService service) { try { if (!service.awaitTermination(CLOSE_AWAIT_MS, TimeUnit.MILLISECONDS)) { - IrisLogging.warn("Mantle cleanup executor did not stop within " + CLOSE_AWAIT_MS + "ms."); + throw new IllegalStateException("Mantle cleanup executor did not stop within " + CLOSE_AWAIT_MS + "ms."); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for mantle cleanup to stop.", e); } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java index 9ec321a06..a4a5083fc 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java @@ -41,7 +41,6 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterBiomeInject; import art.arcane.volmlib.util.matter.slices.BiomeInjectMatter; -import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.List; @@ -49,7 +48,6 @@ import java.util.Objects; public class IrisBiomeActuator extends EngineAssignedActuator { private final RNG rng; - private final ChronoLatch cl = new ChronoLatch(5000); private final KMap resolvedBiomes = new KMap<>(); private final KMap resolvedPhysicalBiomes = new KMap<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index fa05afcba..1407e285f 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -46,11 +46,7 @@ import art.arcane.iris.util.common.data.B; import lombok.Getter; public class IrisTerrainNormalActuator extends EngineAssignedActuator { - private static final PlatformBlockState AIR = B.getState("AIR"); private static final PlatformBlockState BEDROCK = B.getState("BEDROCK"); - private static final PlatformBlockState LAVA = B.getState("LAVA"); - private static final PlatformBlockState GLASS = B.getState("GLASS"); - private static final PlatformBlockState CAVE_AIR = B.getState("CAVE_AIR"); @Getter private final RNG rng; @Getter @@ -83,11 +79,6 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator h, ChunkContext context) { - terrainSliverOptimized(x, z, xf, h, context); - } - - @BlockCoordinates - private void terrainSliverOptimized(int x, int z, int xf, Hunk h, ChunkContext context) { int chunkHeight = h.getHeight(); int chunkDepth = h.getDepth(); IrisDimension dimension = getDimension(); diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java index b0be83ec5..1b95915fc 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTransitionGeometryActuator.java @@ -42,9 +42,7 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen return; } GeometrySource source = new GeometrySource(blocks, biomes, getEngine().getMinHeight(), context); - if (plan != null && plan.hasTransitionAtChunk(x >> 4, z >> 4)) { - blendGeometry(x, z, blocks, context, source, plan); - } + blendGeometry(x, z, blocks, context, source, plan); } public static SavedTerrainChunk capture(int x, int z, Hunk blocks, diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index 7df6777ae..5ceccec9f 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -474,7 +474,7 @@ final class DecoratorCore { && (!caveSkipFluid || !B.isFluid(lower) && !B.isFluid(upper)); } - private static int computeStack(IrisDecorator decorator, RNG rng, double realX, double realZ, + static int computeStack(IrisDecorator decorator, RNG rng, double realX, double realZ, IrisData irisData, int max) { int stack = decorator.getHeight(rng, realX, realZ, irisData); if (decorator.isScaleStack()) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java index 9de54ad7b..8067546da 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java @@ -39,6 +39,10 @@ public class IrisSeaFloorDecorator extends IrisEngineDecorator { @Override public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, int height, int max) { + int limit = Math.min(max, Math.min(getEngine().getHeight(), data.getHeight())); + if (height < 0 || height >= limit) { + return; + } RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); @@ -51,34 +55,25 @@ public class IrisSeaFloorDecorator extends IrisEngineDecorator { && !decorator.getSlopeCondition().isValid(getComplex().getSlopeStream().get(realX, realZ))) { return; } - if (height >= 0 && height < getEngine().getHeight()) { - data.set(x, height, z, decorator.getBlockData100(biome, rng, realX, height, realZ, getData())); + PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + if (block != null) { + data.set(x, height, z, block); } return; } - int stack = decorator.getHeight(rng, realX, realZ, getData()); - if (decorator.isScaleStack()) { - stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100)); - } else { - stack = Math.min(stack, max - height); - } - - if (stack == 1) { - data.set(x, height, z, decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData())); - return; - } - - int engineHeight = getEngine().getHeight(); + int stack = Math.min(limit - height, + DecoratorCore.computeStack(decorator, rng, realX, realZ, getData(), max - height)); for (int i = 0; i < stack; i++) { int h = height + i; - if (h > max || h > engineHeight) { - continue; - } - double threshold = ((double) i) / (stack - 1); - data.set(x, h, z, threshold >= decorator.getTopThreshold() + double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); + PlatformBlockState block = threshold >= decorator.getTopThreshold() ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData())); + : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + if (block == null) { + break; + } + data.set(x, h, z, block); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java index e310f29ea..03f2e8e0e 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java @@ -39,6 +39,10 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { @Override public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, int height, int max) { + int maxBaseY = Math.min(max, Math.min(getEngine().getHeight(), data.getHeight()) - 1); + if (height < 0 || height >= maxBaseY) { + return; + } RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); @@ -48,8 +52,7 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { if (!decorator.isStacking()) { int targetY = height + 1; - if (height >= 0 && targetY < getEngine().getHeight() - && DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + if (DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); if (block != null) { data.set(x, targetY, z, block); @@ -58,33 +61,15 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { return; } - int stack = decorator.getHeight(rng, realX, realZ, getData()); - if (decorator.isScaleStack()) { - stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100)); - } - - if (stack == 1) { - int targetY = height + 1; - if (targetY >= data.getHeight() || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { - return; - } - - PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); - if (block != null) { - data.set(x, targetY, z, block); - } - return; - } - - int engineHeight = getEngine().getHeight(); + int stack = Math.min(maxBaseY - height, + DecoratorCore.computeStack(decorator, rng, realX, realZ, getData(), max - height)); for (int i = 0; i < stack; i++) { int h = height + i; int targetY = h + 1; - if (h >= max || targetY >= engineHeight - || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + if (!DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { break; } - double threshold = ((double) i) / (stack - 1); + double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); PlatformBlockState block = threshold >= decorator.getTopThreshold() ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisEngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisEngineMode.java index 9112f589f..9ccdb0be2 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisEngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisEngineMode.java @@ -42,11 +42,11 @@ public abstract class IrisEngineMode implements EngineMode { return; } - closed = true; dump(); terrainStages.forEach(EngineStage::close); terrainStages.clear(); transitionStage.close(); + closed = true; } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java index 7cbb0c065..0a3713e46 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java @@ -16,7 +16,12 @@ public final class GenerationAdmission { Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") .toAbsolutePath() .normalize(); - this.state = STATES.computeIfAbsent(root, ignored -> new State()); + this.state = STATES.computeIfAbsent(root, State::new); + } + + public RuntimeLease retainRuntime() { + state.retainRuntime(); + return new RuntimeLease(state); } public StageLease enterStage() { @@ -34,6 +39,23 @@ public final class GenerationAdmission { return new CutoverLease(state); } + public static final class RuntimeLease implements AutoCloseable { + private final State state; + private boolean closed; + + private RuntimeLease(State state) { + this.state = state; + } + + @Override + public synchronized void close() { + if (!closed) { + state.releaseRuntime(); + closed = true; + } + } + } + public static final class StageLease implements AutoCloseable { private final State state; private final AtomicBoolean closed; @@ -69,26 +91,71 @@ public final class GenerationAdmission { } private static final class State { + private final Path root; private final ReentrantLock lock; private final Condition changed; + private int runtimeOwners; + private int waitingStages; private int activeStages; private int waitingCutovers; private boolean cutoverActive; private boolean generationAdmissionOpened; + private boolean closed; - private State() { + private State(Path root) { + this.root = root; this.lock = new ReentrantLock(true); this.changed = lock.newCondition(); } + private void retainRuntime() { + lock.lock(); + try { + requireOpen(); + runtimeOwners++; + } finally { + lock.unlock(); + } + } + + private void releaseRuntime() { + lock.lock(); + try { + requireOpen(); + if (runtimeOwners == 1) { + if (activeStages > 0 || waitingStages > 0 || waitingCutovers > 0 || cutoverActive) { + throw new IllegalStateException("Generation admission still has active stages or cutovers."); + } + closed = true; + STATES.remove(root, this); + } + runtimeOwners--; + } finally { + lock.unlock(); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Generation admission belongs to a closed runtime."); + } + } + private void enterStage() { lock.lock(); try { + requireOpen(); generationAdmissionOpened = true; - while (cutoverActive || waitingCutovers > 0) { - changed.awaitUninterruptibly(); + waitingStages++; + try { + while (cutoverActive || waitingCutovers > 0) { + changed.awaitUninterruptibly(); + requireOpen(); + } + activeStages++; + } finally { + waitingStages--; } - activeStages++; } finally { lock.unlock(); } @@ -112,6 +179,7 @@ public final class GenerationAdmission { private void beginCutover(boolean startupOnly) { lock.lock(); try { + requireOpen(); if (startupOnly && generationAdmissionOpened) { throw new IllegalStateException( "Generation activation promotion is only allowed before generation admission opens." @@ -121,6 +189,7 @@ public final class GenerationAdmission { try { while (cutoverActive || activeStages > 0) { changed.awaitUninterruptibly(); + requireOpen(); } cutoverActive = true; } finally { 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 513c5cd99..cdcf7111d 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 @@ -64,6 +64,10 @@ public final class GenerationHistory { validateReferencedState(); } + public GenerationAdmission.RuntimeLease retainRuntime() { + return admission.retainRuntime(); + } + public SavedBiomeStore savedBiomes() { return savedBiomes; } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 827cbb2c1..238a5186e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -2646,7 +2646,7 @@ public final class HydrologyPlanner { potential, parent, outletIndex, - routeLengths(grid, parent), + routeLengths(parent, grid.width(), settings.routing()), List.copyOf(outlets), false ); @@ -3222,31 +3222,43 @@ public final class HydrologyPlanner { return count + (HydrologyHash.unit(stable) < remainder ? 1 : 0); } - private int[] routeLengths(SampledGrid grid, int[] parent) { + static int[] routeLengths(int[] parent, int width, HydrologyPlannerSettings.Routing routing) { int[] lengths = new int[parent.length]; + int[] pending = new int[parent.length]; + double[] distances = new double[parent.length]; + Arrays.fill(distances, Double.NaN); + double diagonalDistance = StrictMath.hypot(routing.sampleSpacing(), routing.sampleSpacing()); for (int sourceIndex = 0; sourceIndex < parent.length; sourceIndex++) { - lengths[sourceIndex] = routeLength(sourceIndex, grid, parent); + if (!Double.isNaN(distances[sourceIndex])) { + continue; + } + int current = sourceIndex; + int pendingCount = 0; + while (current >= 0 && Double.isNaN(distances[current])) { + pending[pendingCount++] = current; + distances[current] = -1D; + current = parent[current]; + } + if (current >= 0 && distances[current] < 0D) { + throw new IllegalStateException("Hydrology drainage routing contains a cycle."); + } + double distance = current < 0 ? 0D : distances[current]; + while (pendingCount > 0) { + int node = pending[--pendingCount]; + int next = parent[node]; + if (next >= 0) { + boolean cardinal = node / width == next / width || node % width == next % width; + distance += cardinal ? routing.sampleSpacing() : diagonalDistance; + } + distances[node] = distance; + lengths[node] = distance > routing.maximumRouteLength() + ? Integer.MAX_VALUE + : (int) StrictMath.ceil(distance); + } } return lengths; } - private int routeLength(int sourceIndex, SampledGrid grid, int[] parent) { - int current = sourceIndex; - int steps = 0; - double length = 0D; - while (parent[current] >= 0) { - int next = parent[current]; - GridNode currentNode = grid.node(current); - GridNode nextNode = grid.node(next); - length += StrictMath.hypot(currentNode.x() - nextNode.x(), currentNode.z() - nextNode.z()); - if (length > settings.routing().maximumRouteLength() || ++steps > settings.routing().maximumRouteNodes()) { - return Integer.MAX_VALUE; - } - current = next; - } - return (int) StrictMath.ceil(length); - } - private int[] countContributions(List sources, RoutingPlan routing) { int[] counts = new int[routing.parent().length]; for (int source : sources) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index 9231ec367..c3b033f54 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -131,6 +132,7 @@ public final class HydrologyTileCache implements AutoCloseable { */ public HydrologyTile get(HydrologyTileKey key) { Objects.requireNonNull(key, "key"); + requireOpen(); if (prefetchExecutor == null) { return planInline(key, cacheEpoch.get()); } @@ -156,7 +158,11 @@ public final class HydrologyTileCache implements AutoCloseable { } CacheLoadKey loadKey = new CacheLoadKey<>(epoch, key); PendingLoad owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); - PendingLoad existing = loading.putIfAbsent(loadKey, owned); + PendingLoad existing; + synchronized (publicationLock) { + requireOpen(); + existing = loading.putIfAbsent(loadKey, owned); + } if (existing != null) { return awaitLoad(existing); } @@ -376,10 +382,45 @@ public final class HydrologyTileCache implements AutoCloseable { @Override public void close() { - closed.set(true); + ArrayList> active = new ArrayList<>(); + ArrayList> queued = new ArrayList<>(); + synchronized (publicationLock) { + for (PendingLoad load : loading.values()) { + collectActiveLoad(active, load); + } + for (PendingLoad load : composing.values()) { + collectActiveLoad(active, load); + } + closed.set(true); + planning.forEach((key, future) -> { + if (!loading.containsKey(key)) { + queued.add(future); + } + }); + } + for (CompletableFuture future : queued) { + future.completeExceptionally(new CancellationException("Hydrology tile cache is closed.")); + } + CompletableFuture.allOf(active.toArray(CompletableFuture[]::new)) + .handle((ignored, failure) -> null) + .join(); + planning.clear(); clear(); } + private static void collectActiveLoad(List> active, PendingLoad load) { + if (load.owner() == Thread.currentThread()) { + throw new IllegalStateException("Hydrology tile cache cannot close from an active load."); + } + active.add(load.future()); + } + + private void requireOpen() { + if (closed.get()) { + throw new CancellationException("Hydrology tile cache is closed."); + } + } + private boolean validSharedTile(HydrologyTile tile, SharedTileKey sharedKey) { SharedCacheScope scope = sharedKey.scope(); return tile.key().equals(sharedKey.tileKey()) @@ -389,6 +430,7 @@ public final class HydrologyTileCache implements AutoCloseable { } private ChunkColumns chunkColumns(int blockX, int blockZ) { + requireOpen(); int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); long epoch = cacheEpoch.get(); @@ -420,7 +462,11 @@ public final class HydrologyTileCache implements AutoCloseable { return present; } PendingLoad owned = new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); - PendingLoad existing = composing.putIfAbsent(loadKey, owned); + PendingLoad existing; + synchronized (publicationLock) { + requireOpen(); + existing = composing.putIfAbsent(loadKey, owned); + } if (existing != null) { return awaitLoad(existing); } @@ -652,6 +698,7 @@ public final class HydrologyTileCache implements AutoCloseable { */ public List tiles(List keys) { Objects.requireNonNull(keys, "keys"); + requireOpen(); HydrologyTile[] loaded = new HydrologyTile[keys.size()]; boolean missing = false; for (int index = 0; index < loaded.length; index++) { @@ -724,17 +771,29 @@ public final class HydrologyTileCache implements AutoCloseable { return CompletableFuture.completedFuture(present); } CompletableFuture future = new CompletableFuture<>(); - CompletableFuture existing = planning.putIfAbsent(loadKey, future); + CompletableFuture existing; + synchronized (publicationLock) { + if (closed.get()) { + return CompletableFuture.failedFuture(new CancellationException("Hydrology tile cache is closed.")); + } + existing = planning.putIfAbsent(loadKey, future); + } if (existing != null) { return existing; } try { prefetchExecutor.execute(() -> { + if (future.isDone()) { + planning.remove(loadKey, future); + return; + } try { future.complete(planInline(key, loadKey.epoch())); } catch (Throwable failure) { future.completeExceptionally(failure); - IrisLogging.reportError(failure); + if (!(failure instanceof CancellationException) || !closed.get()) { + IrisLogging.reportError(failure); + } } finally { planning.remove(loadKey, future); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java index 3a6827c23..02793feff 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java @@ -3,46 +3,48 @@ package art.arcane.iris.engine.hydrology; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.TreeMap; public final class RiverFootprint { + private static final RiverFootprint EMPTY = new RiverFootprint(Map.of()); + + private final Long2ObjectLinkedOpenHashMap indexed; private final Map columns; public RiverFootprint(Map columns) { if (columns == null) { throw new IllegalArgumentException("columns must not be null."); } - TreeMap ordered = new TreeMap<>(); + indexed = new Long2ObjectLinkedOpenHashMap<>(columns.size()); for (Map.Entry entry : columns.entrySet()) { HydrologyColumnSample sample = entry.getValue(); if (sample == null || entry.getKey() != pack(sample.x(), sample.z())) { throw new IllegalArgumentException("Footprint keys must match their column coordinates."); } - ordered.put(entry.getKey(), sample); + indexed.put(entry.getKey().longValue(), sample); } - Long2ObjectLinkedOpenHashMap indexed = - new Long2ObjectLinkedOpenHashMap<>(ordered.size()); - for (Map.Entry entry : ordered.entrySet()) { - indexed.put(entry.getKey().longValue(), entry.getValue()); + long[] order = indexed.keySet().toLongArray(); + Arrays.sort(order); + for (long key : order) { + indexed.getAndMoveToLast(key); } - columns = Collections.unmodifiableMap(indexed); - this.columns = columns; + this.columns = Collections.unmodifiableMap(indexed); } public static RiverFootprint empty() { - return new RiverFootprint(Map.of()); + return EMPTY; } public Optional sample(int x, int z) { - return Optional.ofNullable(columns.get(pack(x, z))); + return Optional.ofNullable(indexed.get(pack(x, z))); } public HydrologyRenderSample renderSample(int x, int z) { - HydrologyColumnSample sample = columns.get(pack(x, z)); + HydrologyColumnSample sample = indexed.get(pack(x, z)); return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample(); } @@ -61,7 +63,7 @@ public final class RiverFootprint { if (area < columns.size()) { for (long z = minimumZ; z < maximumZ; z++) { for (long x = minimumX; x < maximumX; x++) { - HydrologyColumnSample sample = columns.get(pack((int) x, (int) z)); + HydrologyColumnSample sample = indexed.get(pack((int) x, (int) z)); if (sample != null) { selected.add(sample); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java index cc986d34f..e1d067d21 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java @@ -9,7 +9,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -1132,8 +1131,8 @@ public final class HydrologyCaveContainmentPlanner { List throat ) { Queue queue = new ArrayDeque<>(); - Set queued = new HashSet<>(); - Set resolved = new LinkedHashSet<>(); + Set queued = new LinkedHashSet<>(); + int visitedCount = 0; queue.add(source.target()); queued.add(source.target()); @@ -1148,10 +1147,7 @@ public final class HydrologyCaveContainmentPlanner { if (positionRejection != HydrologyCaveRejection.NONE) { return ComponentResult.rejected(positionRejection); } - if (!resolved.add(position)) { - continue; - } - if (resolved.size() > settings.maxFloodVolume()) { + if (++visitedCount > settings.maxFloodVolume()) { return ComponentResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); } @@ -1171,7 +1167,7 @@ public final class HydrologyCaveContainmentPlanner { } } - return ComponentResult.accepted(resolved); + return ComponentResult.accepted(queued); } private HydrologyCaveRejection addThroatContacts( diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index 9ac6649b7..aeec7d360 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -58,6 +58,8 @@ import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; import java.util.Collections; @@ -70,7 +72,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.nio.file.Path; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; import java.util.function.Predicate; @@ -96,7 +97,9 @@ public final class IrisHydrologyRuntime implements AutoCloseable { private final Set profileKeys; private final String defaultProfileKey; private final Object terrainSampleLock; - private final Set unplannedQueries = ConcurrentHashMap.newKeySet(); + private final Cache unplannedQueries = Caffeine.newBuilder() + .maximumSize(MAXIMUM_CACHE_TILES) + .build(); private final LinkedHashMap terrainSamples; public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) { @@ -156,7 +159,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { if (!planned) { int tileSize = settings.routing().tileSize(); HydrologyTileKey key = new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize)); - if (unplannedQueries.add(key)) { + if (unplannedQueries.asMap().putIfAbsent(key, Boolean.TRUE) == null) { IrisLogging.debug("Hydrology tile %d,%d queried before it was planned; natural terrain answers until the plan lands", key.tileX(), key.tileZ()); } @@ -479,6 +482,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { @Override public void close() { cache.close(); + unplannedQueries.invalidateAll(); routingTerrainSampler.close(); synchronized (terrainSampleLock) { terrainSamples.clear(); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java index a3b27c8f9..f087f3c4a 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java @@ -91,11 +91,12 @@ public final class SurfaceFootprintCompiler { && column.x() == centerline.x()[0] && column.z() == centerline.z()[0]; int y = column.role() == SurfaceRole.CHANNEL ? column.headY() : column.height(); HydrologyFeatureRef feature = features.feature(segment, column.role(), source, column.x(), y, column.z(), flowX, flowZ); + String biomeOverride = pool ? poolBiome == null ? column.terrain().parentBiomeKey() : poolBiome : null; columns.add(new SurfaceLayerColumn( column.x(), column.z(), column.terrain(), - pool ? poolLayer(feature, column, course.profileKey(), poolBiome) : layer(feature, column, course.profileKey()), + layer(feature, column, course.profileKey(), biomeOverride), column.role(), column.apron() )); @@ -103,40 +104,6 @@ public final class SurfaceFootprintCompiler { return new SurfaceFootprint(columns, field.uncontainedWetCells()); } - /** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */ - private static HydrologyColumnLayer poolLayer( - HydrologyFeatureRef feature, - SurfaceColumn column, - String profileKey, - String poolBiome - ) { - HydrologyTerrainSample terrain = column.terrain(); - String biome = poolBiome == null ? terrain.parentBiomeKey() : poolBiome; - boolean channel = column.role() == SurfaceRole.CHANNEL; - boolean shore = column.role() == SurfaceRole.SHORE; - return new HydrologyColumnLayer( - feature, - column.height(), - column.headY(), - column.headY(), - channel, - shore, - !channel, - channel, - false, - false, - true, - channel, - false, - profileKey, - biome, - biome, - biome, - biome, - terrain.floodedCaveBiomeKey() - ); - } - private ChannelProfile poolProfile(HydraulicSegment segment, SurfaceCenterline centerline) { int count = centerline.size(); double[] halfWidth = new double[count]; @@ -160,9 +127,15 @@ public final class SurfaceFootprintCompiler { return null; } - private static HydrologyColumnLayer layer(HydrologyFeatureRef feature, SurfaceColumn column, String profileKey) { + /** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */ + private static HydrologyColumnLayer layer( + HydrologyFeatureRef feature, + SurfaceColumn column, + String profileKey, + String biomeOverride + ) { HydrologyTerrainSample terrain = column.terrain(); - if (column.apron()) { + if (column.apron() && biomeOverride == null) { return new HydrologyColumnLayer( feature, column.headY(), @@ -194,10 +167,10 @@ public final class SurfaceFootprintCompiler { channel, false, profileKey, - terrain.surfaceBiomeKey(), - terrain.mouthBiomeKey(), - terrain.shoreBiomeKey(), - terrain.bankBiomeKey(), + biomeOverride == null ? terrain.surfaceBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.mouthBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.shoreBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.bankBiomeKey() : biomeOverride, terrain.floodedCaveBiomeKey() ); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java index 0ae2d9bdf..a5f2ec507 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java @@ -22,12 +22,13 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; public interface MatterGenerator { MultiBurst DISPATCHER = MultiBurst.burst; - ConcurrentHashMap> IN_FLIGHT_COMPONENTS = new ConcurrentHashMap<>(); + ConcurrentHashMap IN_FLIGHT_COMPONENTS = new ConcurrentHashMap<>(); long COMPONENT_TASK_POLL_MS = 1000L; long COMPONENT_TASK_TIMEOUT_MS = Long.getLong("iris.mantle.componentTimeout", 120000L); @@ -368,38 +369,37 @@ public interface MatterGenerator { ) { MantleFlag flag = component.getFlag(); MatterTaskKey key = new MatterTaskKey(getMantle(), chunkX, chunkZ, flag); + MatterComponentTask task = new MatterComponentTask(key); if (chunk.isFlagged(flag)) { - return new MatterComponentTask(key, CompletableFuture.completedFuture(null), null); + task.finish(null); + return task; } - CompletableFuture future = new CompletableFuture<>(); - CompletableFuture existing = IN_FLIGHT_COMPONENTS.putIfAbsent(key, future); + MatterComponentTask existing = IN_FLIGHT_COMPONENTS.putIfAbsent(key, task); if (existing != null) { - return new MatterComponentTask(key, existing, null); + return existing; } try { if (DISPATCHER.ownsCurrentThread()) { // A pool thread runs its claim inline: a task queued behind pool threads that block // on unmanaged waits (structure builds, cache locks) could starve the whole pool. - completeComponentTask(future, key, chunk, component, writer, chunkX, chunkZ, context, IrisContext.get()); - return new MatterComponentTask(key, future, null); + completeComponentTask(task, chunk, component, writer, chunkX, chunkZ, context, IrisContext.get()); + return task; } IrisContext callerContext = IrisContext.get(); - Future submission = DISPATCHER.submit(() -> completeComponentTask( - future, - key, + task.setSubmission(DISPATCHER.submit(() -> completeComponentTask( + task, chunk, component, writer, chunkX, chunkZ, context, - callerContext)); - return new MatterComponentTask(key, future, submission); + callerContext))); + return task; } catch (Throwable throwable) { - IN_FLIGHT_COMPONENTS.remove(key, future); - future.completeExceptionally(throwable); + task.cancel(throwable); throw throwable; } } @@ -449,7 +449,7 @@ public interface MatterGenerator { } private static void awaitComponentTask(MatterComponentTask task) { - CompletableFuture future = task.future(); + CompletableFuture future = task.future; if (future.isDone() && !future.isCompletedExceptionally()) { return; } @@ -459,11 +459,17 @@ public interface MatterGenerator { try { ForkJoinPool.managedBlock(new ComponentTaskBlocker(task)); } catch (InterruptedException interruption) { - Thread.currentThread().interrupt(); + try { + task.await(COMPONENT_TASK_TIMEOUT_MS); + } finally { + Thread.currentThread().interrupt(); + } + } catch (RejectedExecutionException exhaustedPool) { + task.await(COMPONENT_TASK_TIMEOUT_MS); } return; } - awaitComponentTaskBlocking(task); + task.await(COMPONENT_TASK_TIMEOUT_MS); } /** @@ -478,66 +484,22 @@ public interface MatterGenerator { @Override public boolean block() { - awaitComponentTaskBlocking(task); + task.await(COMPONENT_TASK_TIMEOUT_MS); return true; } @Override public boolean isReleasable() { - return task.future().isDone(); - } - } - - private static void awaitComponentTaskBlocking(MatterComponentTask task) { - CompletableFuture future = task.future(); - long start = System.currentTimeMillis(); - boolean interrupted = false; - - try { - while (true) { - try { - future.get(COMPONENT_TASK_POLL_MS, TimeUnit.MILLISECONDS); - return; - } catch (InterruptedException interruption) { - // The writer cannot close while this task still writes through it, so the wait is - // uninterruptible and the flag is restored on the way out. - interrupted = true; - } catch (TimeoutException timeout) { - Future submission = task.submission(); - boolean dropped = submission != null && submission.isDone(); - if (future.isDone()) { - continue; - } - - long waited = System.currentTimeMillis() - start; - if (!dropped && waited < COMPONENT_TASK_TIMEOUT_MS) { - continue; - } - - // The task can no longer complete (the dispatcher dropped it) or is wedged. Drop - // the dedup entry so later generations do not inherit a future that never - // completes, and release anything else already waiting on it. - IllegalStateException failure = new IllegalStateException("Mantle component " + task.key() - + (dropped ? " was dropped by the dispatcher" : " did not complete in " + waited + "ms")); - IN_FLIGHT_COMPONENTS.remove(task.key(), future); - future.completeExceptionally(failure); - IrisLogging.error(failure.getMessage()); - throw new CompletionException(failure); - } catch (ExecutionException executionFailure) { - Throwable cause = executionFailure.getCause(); - throw new CompletionException(cause != null ? cause : executionFailure); - } - } - } finally { - if (interrupted) { - Thread.currentThread().interrupt(); + if (!task.future.isDone()) { + return false; } + task.future.join(); + return true; } } private void completeComponentTask( - CompletableFuture future, - MatterTaskKey key, + MatterComponentTask task, MantleChunk chunk, MantleComponent component, MantleWriter writer, @@ -546,14 +508,17 @@ public interface MatterGenerator { ChunkContext context, IrisContext callerContext ) { + if (!task.start()) { + return; + } + Throwable failure = null; try { runComponentWithContext(chunk, component, writer, chunkX, chunkZ, context, callerContext); - future.complete(null); } catch (Throwable throwable) { - future.completeExceptionally(throwable); + failure = throwable; throw throwable; } finally { - IN_FLIGHT_COMPONENTS.remove(key, future); + task.finish(failure); } } @@ -593,11 +558,98 @@ public interface MatterGenerator { )); } - /** - * A launched component task. {@code submission} is the dispatcher handle when this generation - * owns the task, and null when the future belongs to another generation or already completed. - */ - record MatterComponentTask(MatterTaskKey key, CompletableFuture future, Future submission) { + final class MatterComponentTask { + private final MatterTaskKey key; + private final CompletableFuture future = new CompletableFuture<>(); + private volatile Future submission; + private volatile Throwable failure; + private boolean started; + + MatterComponentTask(MatterTaskKey key) { + this.key = key; + } + + synchronized boolean start() { + if (started || future.isDone()) { + return false; + } + started = true; + return true; + } + + synchronized void setSubmission(Future submission) { + this.submission = submission; + if (failure != null) { + submission.cancel(true); + } + } + + synchronized boolean cancel(Throwable cause) { + if (future.isDone() || failure != null) { + return false; + } + failure = cause; + if (!started) { + finish(null); + } + if (submission != null) { + submission.cancel(true); + } + return true; + } + + synchronized void finish(Throwable cause) { + if (failure == null) { + failure = cause; + } else if (cause != null && cause != failure) { + failure.addSuppressed(cause); + } + IN_FLIGHT_COMPONENTS.remove(key, this); + if (failure == null) { + future.complete(null); + } else { + future.completeExceptionally(failure); + } + } + + void await(long timeoutMillis) { + long start = System.nanoTime(); + boolean interrupted = false; + try { + while (true) { + try { + future.get(COMPONENT_TASK_POLL_MS, TimeUnit.MILLISECONDS); + return; + } catch (InterruptedException interruption) { + // The writer cannot close while this task still writes through it, so the wait is + // uninterruptible and the flag is restored on the way out. + interrupted = true; + } catch (TimeoutException timeout) { + if (future.isDone() || failure != null) { + continue; + } + Future submitted = submission; + boolean dropped = submitted != null && submitted.isDone(); + long waited = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + if (!dropped && waited < timeoutMillis) { + continue; + } + IllegalStateException timeoutFailure = new IllegalStateException("Mantle component " + key + + (dropped ? " was dropped by the dispatcher" : " did not complete in " + waited + "ms")); + if (cancel(timeoutFailure)) { + IrisLogging.error(timeoutFailure.getMessage()); + } + } catch (ExecutionException executionFailure) { + Throwable cause = executionFailure.getCause(); + throw new CompletionException(cause != null ? cause : executionFailure); + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } } record MatterPassPlan(MantlePass pass, int passChunkRadius, int downstreamBlockRadius) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java index 912663a19..5be04aff5 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java @@ -22,6 +22,8 @@ import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; +import java.util.Arrays; + final class CaveCarveScratch { final int[] columnMaxY = new int[256]; final int[] fluidMaxY = new int[256]; @@ -70,4 +72,9 @@ final class CaveCarveScratch { int adaptiveGeometryStep = -1; int adaptiveGeometryAxisCells = -1; boolean fullWeightsInitialized; + + void releaseSections() { + Arrays.fill(sectionMatter, null); + Arrays.fill(sectionSlices, null); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index f7f6483f1..b3391898a 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -436,6 +436,7 @@ public class IrisCaveCarver3D { return carved; } finally { + scratchCache.get().releaseSections(); carving.set(nested); if (nested) { scratchCache.set(previousScratch); @@ -2695,10 +2696,6 @@ public class IrisCaveCarver3D { return weight; } - private double signed(double value) { - return (value * 2D) - 1D; - } - private double applySurfaceCeilingFade( CaveCarveScratch scratch, double threshold, diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index 808ebb13d..2d2416d88 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.mantle.MatterGenerationPhase; import art.arcane.iris.engine.mantle.ComponentFlag; @@ -36,7 +37,6 @@ import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.util.project.stream.utility.ChunkFillableDoubleStream2D; -import art.arcane.iris.util.simd.SimdKernels; import art.arcane.iris.util.simd.SimdSupport; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; @@ -58,6 +58,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { private static final double MIN_WEIGHT = 0.08D; private static final double THRESHOLD_PENALTY = 0.24D; private static final int MAX_BLENDED_PROFILE_PASSES = 2; + private static final int MAX_POOLED_WEIGHT_BUFFERS = 64; private static final int KERNEL_WIDTH = (BLEND_RADIUS * 2) + 1; private static final int KERNEL_SIZE = KERNEL_WIDTH * KERNEL_WIDTH; private static final int[] KERNEL_DX = new int[KERNEL_SIZE]; @@ -107,8 +108,25 @@ public class MantleCarvingComponent extends IrisMantleComponent { if (!complex.allowsMantleChunkWrite(x, z)) { return; } + BlendScratch previous = BLEND_SCRATCH.get(); + BlendScratch scratch = previous.inUse ? new BlendScratch() : previous; + if (scratch != previous) { + BLEND_SCRATCH.set(scratch); + } + scratch.inUse = true; + try { + generateLayer(writer, x, z, context, scratch); + } finally { + scratch.release(); + if (scratch != previous) { + BLEND_SCRATCH.set(previous); + } + } + } + + private void generateLayer(MantleWriter writer, int x, int z, ChunkContext context, BlendScratch blendScratch) { + IrisComplex complex = context.getComplex(); IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); - BlendScratch blendScratch = BLEND_SCRATCH.get(); int[] chunkSurfaceHeights = prepareChunkSurfaceHeights(x, z, context, blendScratch.chunkSurfaceHeights); PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start(); List weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState); @@ -217,12 +235,12 @@ public class MantleCarvingComponent extends IrisMantleComponent { BlendScratch blendScratch = BLEND_SCRATCH.get(); IrisCaveProfile[] profileField = blendScratch.profileField; Map columnProfileWeights = blendScratch.columnProfileWeights; - IdentityHashMap activeProfiles = blendScratch.activeProfiles; List profileOrder = blendScratch.profileOrder; IrisCaveProfile[] kernelProfiles = blendScratch.kernelProfiles; double[] kernelProfileWeights = blendScratch.kernelProfileWeights; - activeProfiles.clear(); + columnProfileWeights.clear(); profileOrder.clear(); + blendScratch.nextWeightBuffer = 0; fillProfileField(profileField, chunkX, chunkZ, complex, resolverState, blendScratch); for (int localX = 0; localX < CHUNK_SIZE; localX++) { @@ -270,12 +288,8 @@ public class MantleCarvingComponent extends IrisMantleComponent { double[] weights = columnProfileWeights.get(profile); if (weights == null) { - weights = new double[CHUNK_AREA]; + weights = blendScratch.acquireWeights(); columnProfileWeights.put(profile, weights); - } else if (!activeProfiles.containsKey(profile)) { - Arrays.fill(weights, 0D); - } - if (activeProfiles.put(profile, Boolean.TRUE) == null) { profileOrder.add(profile); } weights[columnIndex] = columnWeight; @@ -316,28 +330,27 @@ public class MantleCarvingComponent extends IrisMantleComponent { return weightedProfiles; } - Map dimensionColumnPlans = blendScratch.dimensionColumnPlans; - dimensionColumnPlans.clear(); + Engine engine = getEngineMantle().getEngine(); + int baseX = PowerOfTwoCoordinates.chunkToBlock(chunkX); + int baseZ = PowerOfTwoCoordinates.chunkToBlock(chunkZ); for (IrisDimensionCarvingEntry entry : entries) { if (entry == null || !entry.isEnabled()) { continue; } - IrisBiome rootBiome = IrisDimensionCarvingResolver.resolveEntryBiome(getEngineMantle().getEngine(), entry, resolverState); + IrisBiome rootBiome = IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, resolverState); if (rootBiome == null) { continue; } - IrisDimensionCarvingEntry[] columnPlan = dimensionColumnPlans.computeIfAbsent(entry, key -> new IrisDimensionCarvingEntry[CHUNK_AREA]); - buildDimensionColumnPlan(columnPlan, chunkX, chunkZ, entry, resolverState); - Map rootProfileColumnWeights = new IdentityHashMap<>(); List rootProfileOrder = new ArrayList<>(); IrisRange worldYRange = entry.getWorldYRange(); for (int columnIndex = 0; columnIndex < CHUNK_AREA; columnIndex++) { - IrisDimensionCarvingEntry resolvedEntry = columnPlan[columnIndex]; - IrisBiome resolvedBiome = IrisDimensionCarvingResolver.resolveEntryBiome(getEngineMantle().getEngine(), resolvedEntry, resolverState); + IrisDimensionCarvingEntry resolvedEntry = IrisDimensionCarvingResolver.resolveFromRoot( + engine, entry, baseX + (columnIndex >> 4), baseZ + (columnIndex & 15), resolverState); + IrisBiome resolvedBiome = IrisDimensionCarvingResolver.resolveEntryBiome(engine, resolvedEntry, resolverState); if (resolvedBiome == null) { continue; } @@ -349,7 +362,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { double[] columnWeights = rootProfileColumnWeights.get(profile); if (columnWeights == null) { - columnWeights = new double[CHUNK_AREA]; + columnWeights = blendScratch.acquireWeights(); rootProfileColumnWeights.put(profile, columnWeights); rootProfileOrder.add(profile); } @@ -364,19 +377,6 @@ public class MantleCarvingComponent extends IrisMantleComponent { return weightedProfiles; } - private void buildDimensionColumnPlan(IrisDimensionCarvingEntry[] columnPlan, int chunkX, int chunkZ, IrisDimensionCarvingEntry entry, IrisDimensionCarvingResolver.State resolverState) { - int baseX = PowerOfTwoCoordinates.chunkToBlock(chunkX); - int baseZ = PowerOfTwoCoordinates.chunkToBlock(chunkZ); - for (int localX = 0; localX < CHUNK_SIZE; localX++) { - int worldX = baseX + localX; - for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { - int worldZ = baseZ + localZ; - int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); - columnPlan[columnIndex] = IrisDimensionCarvingResolver.resolveFromRoot(getEngineMantle().getEngine(), entry, worldX, worldZ, resolverState); - } - } - } - private void fillProfileField(IrisCaveProfile[] profileField, int chunkX, int chunkZ, IrisComplex complex, IrisDimensionCarvingResolver.State resolverState, BlendScratch blendScratch) { int startX = PowerOfTwoCoordinates.chunkToBlock(chunkX) - BLEND_RADIUS; int startZ = PowerOfTwoCoordinates.chunkToBlock(chunkZ) - BLEND_RADIUS; @@ -698,8 +698,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { private final IrisCaveProfile[] kernelProfiles = new IrisCaveProfile[KERNEL_SIZE]; private final double[] kernelProfileWeights = new double[KERNEL_SIZE]; private final IdentityHashMap columnProfileWeights = new IdentityHashMap<>(); - private final IdentityHashMap dimensionColumnPlans = new IdentityHashMap<>(); - private final IdentityHashMap activeProfiles = new IdentityHashMap<>(); + private final List weightBuffers = new ArrayList<>(); private final List profileOrder = new ArrayList<>(); private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE]; private final double[] fieldFluidHeights = new double[FIELD_SIZE * FIELD_SIZE]; @@ -710,5 +709,33 @@ public class MantleCarvingComponent extends IrisMantleComponent { private final IrisBiome[] fieldCaveBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE]; private final int[] chunkSurfaceHeights = new int[CHUNK_AREA]; private final double[] chunkSurfaceHeightSamples = new double[CHUNK_AREA]; + private int nextWeightBuffer; + private boolean inUse; + + private double[] acquireWeights() { + if (nextWeightBuffer < weightBuffers.size()) { + double[] weights = weightBuffers.get(nextWeightBuffer++); + Arrays.fill(weights, 0D); + return weights; + } + double[] weights = new double[CHUNK_AREA]; + if (weightBuffers.size() < MAX_POOLED_WEIGHT_BUFFERS) { + weightBuffers.add(weights); + nextWeightBuffer++; + } + return weights; + } + + private void release() { + Arrays.fill(profileField, null); + Arrays.fill(kernelProfiles, null); + Arrays.fill(fieldRegions, null); + Arrays.fill(fieldSurfaceBiomes, null); + Arrays.fill(fieldCaveBiomes, null); + columnProfileWeights.clear(); + profileOrder.clear(); + nextWeightBuffer = 0; + inUse = false; + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index ff514cea7..5872cf1dc 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -40,11 +40,19 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.iris.util.common.parallel.BurstExecutor; +import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.VectorMap; + +import java.util.ArrayList; +import java.util.List; public class IrisDepositModifier extends EngineAssignedModifier { + private static final int CLUMPS_PER_BATCH = 8; + private static final int PREPARATION_BATCH_COUNT = 4; + private final RNG rng; public IrisDepositModifier(Engine engine) { @@ -62,29 +70,36 @@ public class IrisDepositModifier extends EngineAssignedModifier terrain, int x, int z, boolean multicore, ChunkContext context) { IrisRegion region = context.getRegion().get(7, 7); IrisBiome biome = context.getBiome().get(7, 7); - BurstExecutor burst = burst().burst(multicore); + List generators = new ArrayList<>( + getDimension().getDeposits().size() + region.getDeposits().size() + biome.getDeposits().size()); + generators.addAll(getDimension().getDeposits()); + generators.addAll(region.getDeposits()); + generators.addAll(biome.getDeposits()); + if (generators.isEmpty()) { + return; + } + BurstExecutor burst = burst().burst(multicore); long seed = x * 341873128712L + z * 132897987541L; - long mask = 0; + PreparedDeposit[] prepared = new PreparedDeposit[PREPARATION_BATCH_COUNT]; + int pending = 0; MantleChunk chunk = getEngine().getMantle().getMantle().getChunk(x, z).use(); try { - for (IrisDepositGenerator k : getDimension().getDeposits()) { - long finalSeed = seed * ++mask; - burst.queue(scopedDepositTask( - () -> generate(k, chunk, terrain, rng.nextParallelRNG(finalSeed), x, z, false, context), context)); - } - - for (IrisDepositGenerator k : region.getDeposits()) { - long finalSeed = seed * ++mask; - burst.queue(scopedDepositTask( - () -> generate(k, chunk, terrain, rng.nextParallelRNG(finalSeed), x, z, false, context), context)); - } - - for (IrisDepositGenerator k : biome.getDeposits()) { - long finalSeed = seed * ++mask; - burst.queue(scopedDepositTask( - () -> generate(k, chunk, terrain, rng.nextParallelRNG(finalSeed), x, z, false, context), context)); + for (int i = 0; i < generators.size(); i++) { + DepositPlan plan = plan(generators.get(i), rng.nextParallelRNG(seed * (i + 1L))); + for (int first = 0; first < plan.attempts(); first += CLUMPS_PER_BATCH) { + int firstAttempt = first; + int limit = Math.min(plan.attempts(), first + CLUMPS_PER_BATCH); + int index = pending++; + burst.queue(scopedDepositTask( + () -> prepared[index] = prepare(plan, firstAttempt, limit, x, z, null, context), context)); + if (pending == prepared.length) { + completeBatches(burst, prepared, chunk, terrain, x, z, context); + pending = 0; + } + } } + completeBatches(burst, prepared, chunk, terrain, x, z, context); } finally { // complete() must run before release() even when queueing throws — already // submitted burst tasks must never write into a released chunk. @@ -96,6 +111,20 @@ public class IrisDepositModifier extends EngineAssignedModifier terrain, int x, int z, ChunkContext context) { + burst.complete(); + try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), context.getGenerationSessionId(), context)) { + for (int i = 0; i < prepared.length; i++) { + PreparedDeposit deposit = prepared[i]; + prepared[i] = null; + if (deposit != null) { + place(deposit, chunk, terrain, x, z, null, context); + } + } + } + } + private Runnable scopedDepositTask(Runnable task, ChunkContext context) { IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine && irisEngine.hasGenerationRuntimeScope() ? irisEngine : null; @@ -115,17 +144,35 @@ public class IrisDepositModifier extends EngineAssignedModifier data, RNG rng, int cx, int cz, boolean safe, HeightMap he, ChunkContext context) { - IrisDimensionCarvingResolver.State carvingState = new IrisDimensionCarvingResolver.State(); - if (k.getSpawnChance() < rng.d()) - return; + DepositPlan plan = plan(k, rng); + for (int first = 0; first < plan.attempts(); first += CLUMPS_PER_BATCH) { + int limit = Math.min(plan.attempts(), first + CLUMPS_PER_BATCH); + place(prepare(plan, first, limit, cx, cz, he, context), chunk, data, cx, cz, he, context); + } + } - boolean oreDeposit = k.isOre(getData()); + private DepositPlan plan(IrisDepositGenerator generator, RNG rng) { + if (generator.getSpawnChance() < rng.d()) { + return new DepositPlan(generator, rng.getSeed(), false, 0); + } + boolean ore = generator.isOre(getData()); + int attempts = rng.i(generator.getMinPerChunk(), generator.getMaxPerChunk() + 1); + return new DepositPlan(generator, rng.getSeed(), ore, attempts); + } - for (int l = 0; l < rng.i(k.getMinPerChunk(), k.getMaxPerChunk() + 1); l++) { - if (k.getPerClumpSpawnChance() < rng.d()) + private PreparedDeposit prepare(DepositPlan plan, int first, int limit, int cx, int cz, HeightMap he, ChunkContext context) { + IrisDepositGenerator k = plan.generator(); + boolean oreDeposit = plan.ore(); + boolean needsCaveBiome = oreDeposit || k.usesCaveBiomeFilter(); + IrisDimensionCarvingResolver.State carvingState = needsCaveBiome ? new IrisDimensionCarvingResolver.State() : null; + List clumps = new ArrayList<>(limit - first); + for (int l = first; l < limit; l++) { + RNG clumpRng = new RNG(clumpSeed(plan.seed(), l)); + if (k.getPerClumpSpawnChance() < clumpRng.d()) { continue; + } - IrisObject clump = k.getClump(getEngine(), rng, getData()); + IrisObject clump = k.getClump(getEngine(), clumpRng, getData()); int dim = clump.getW(); int min = dim / 2; @@ -136,8 +183,8 @@ public class IrisDepositModifier extends EngineAssignedModifier height - 2) + if (clippedHeight && y > height - 2) { continue; + } int biomeY = Math.max(0, Math.min(getEngine().getHeight() - 1, y)); IrisBiome surfaceBiome = context.getBiome().get(x, z); - IrisBiome depositBiome = oreDeposit || k.usesCaveBiomeFilter() + IrisBiome depositBiome = needsCaveBiome ? getEngine().getCaveBiome( (cx << 4) + x, biomeY, (cz << 4) + z, carvingState) : null; @@ -172,13 +220,13 @@ public class IrisDepositModifier extends EngineAssignedModifier data, + int cx, int cz, HeightMap he, ChunkContext context) { + if (deposit.clumps().isEmpty()) { + return; + } + IrisDepositGenerator k = deposit.generator(); + boolean oreDeposit = deposit.ore(); + IrisDimensionCarvingResolver.State carvingState = new IrisDimensionCarvingResolver.State(); + IrisDimension dimension = getDimension(); + for (PreparedClump prepared : deposit.clumps()) { + IrisObject clump = prepared.object(); + int x = prepared.x(); + int y = prepared.y(); + int z = prepared.z(); + RNG rng = prepared.rng(); + + VectorMap.Cursor cursor = clump.getBlocks().cursor(); + while (cursor.next()) { + IrisBlockVector j = cursor.key(); int nx = j.getBlockX() + x; int ny = j.getBlockY() + y; int nz = j.getBlockZ() + z; @@ -236,7 +305,7 @@ public class IrisDepositModifier extends EngineAssignedModifier>> 30)) * 0xbf58476d1ce4e5b9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL; + return mixed ^ (mixed >>> 31); + } + + private record DepositPlan(IrisDepositGenerator generator, long seed, boolean ore, int attempts) { + } + + private record PreparedDeposit(IrisDepositGenerator generator, boolean ore, List clumps) { + } + + private record PreparedClump(IrisObject object, int x, int y, int z, RNG rng) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 2580475c4..94dcbef30 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -92,6 +92,7 @@ public class IrisBiome extends IrisRegistrant implements IRare { private final transient AtomicCache realCarveBiome = new AtomicCache<>(); private final transient AtomicCache> realChildren = new AtomicCache<>(); private final transient AtomicCache> layerHeightGenerators = new AtomicCache<>(); + private final transient AtomicCache> layerCeilingHeightGenerators = new AtomicCache<>(); private final transient AtomicCache> layerSeaHeightGenerators = new AtomicCache<>(); private final transient AtomicCache> surfaceOreCache = new AtomicCache<>(); private final transient AtomicCache> undergroundOreCache = new AtomicCache<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java index e551f1237..fb41a1a75 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java @@ -74,7 +74,7 @@ final class IrisBiomeLayerGenerator { IrisBiomePaletteLayer layer = layers.get(i); double zoom = layer.getZoom(); CNG hgen = heightGenerators.get(i); - double d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); + int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); IrisSlopeClip sc = layer.getSlopeCondition(); @@ -88,17 +88,7 @@ final class IrisBiomeLayerGenerator { continue; } - for (int j = 0; j < d; j++) { - if (data.size() >= maxDepth) { - break; - } - - try { - data.add(layer.get(random, i + j, (wx + j) / zoom, j, (wz - j) / zoom, rdata)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + appendLayer(data, layer, i, d, wx, wz, random, maxDepth, rdata); if (data.size() >= maxDepth) { break; @@ -132,31 +122,20 @@ final class IrisBiomeLayerGenerator { return data; } - KList heightGenerators = getLayerHeightGenerators(biome, random, rdata); - // Ceiling layers reuse the surface-layer height generators, so entries beyond layers.size() have no generator; skip them. - int usableLayers = Math.min(layerCount, heightGenerators.size()); + KList heightGenerators = getHeightGenerators( + biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata); - for (int i = 0; i < usableLayers; i++) { + for (int i = 0; i < layerCount; i++) { IrisBiomePaletteLayer layer = ceilingLayers.get(i); double zoom = layer.getZoom(); CNG hgen = heightGenerators.get(i); - double d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); + int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); if (d <= 0) { continue; } - for (int j = 0; j < d; j++) { - if (data.size() >= maxDepth) { - break; - } - - try { - data.add(layer.get(random, i + j, (wx + j) / zoom, j, (wz - j) / zoom, rdata)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + appendLayer(data, layer, i, d, wx, wz, random, maxDepth, rdata); if (data.size() >= maxDepth) { break; @@ -198,7 +177,7 @@ final class IrisBiomeLayerGenerator { IrisBiomePaletteLayer layer = layers.get(i); double zoom = layer.getZoom(); CNG hgen = heightGenerators.get(i); - double d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); + int d = hgen.fit(layer.getMinHeight(), layer.getMaxHeight(), wx / zoom, wz / zoom); IrisSlopeClip sc = layer.getSlopeCondition(); @@ -212,13 +191,7 @@ final class IrisBiomeLayerGenerator { continue; } - for (int j = 0; j < d; j++) { - try { - data.add(layer.get(random, i + j, (wx + j) / zoom, j, (wz - j) / zoom, rdata)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + appendLayer(data, layer, i, d, wx, wz, random, Integer.MAX_VALUE, rdata); } } @@ -227,9 +200,8 @@ final class IrisBiomeLayerGenerator { } for (int i = 0; i < maxDepth; i++) { - int offset = (512 - height) - i; - int index = offset % data.size(); - real.add(data.get(Math.max(index, 0))); + long offset = 512L - height - i; + real.add(data.get(Math.floorMod(offset, data.size()))); } return real; @@ -264,17 +236,7 @@ final class IrisBiomeLayerGenerator { continue; } - for (int j = 0; j < d; j++) { - if (data.size() >= maxDepth) { - break; - } - - try { - data.add(layer.get(random, i + j, (wx + j) / zoom, j, (wz - j) / zoom, rdata)); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + appendLayer(data, layer, i, d, wx, wz, random, maxDepth, rdata); if (data.size() >= maxDepth) { break; @@ -285,7 +247,16 @@ final class IrisBiomeLayerGenerator { } static KList getLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData rdata) { - AtomicCache> cache = biome.getLayerHeightGenerators(); + return getHeightGenerators(biome.getLayerHeightGenerators(), biome.getLayers(), 7235, rng, rdata); + } + + static KList getLayerSeaHeightGenerators(IrisBiome biome, RNG rng, IrisData data) { + return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data); + } + + private static KList getHeightGenerators(AtomicCache> cache, + KList layers, + int seedOffset, RNG rng, IrisData data) { KList cached = cache.getIfPresent(); if (cached != null) { @@ -296,36 +267,28 @@ final class IrisBiomeLayerGenerator { { KList layerHeightGenerators = new KList<>(); - int m = 7235; + int m = seedOffset; - for (IrisBiomePaletteLayer i : biome.getLayers()) { - layerHeightGenerators.add(i.getHeightGenerator(rng.nextParallelRNG((m++) * m * m * m), rdata)); + for (IrisBiomePaletteLayer layer : layers) { + layerHeightGenerators.add(layer.getHeightGenerator(rng.nextParallelRNG((m++) * m * m * m), data)); } return layerHeightGenerators; }); } - static KList getLayerSeaHeightGenerators(IrisBiome biome, RNG rng, IrisData data) { - AtomicCache> cache = biome.getLayerSeaHeightGenerators(); - KList cached = cache.getIfPresent(); - - if (cached != null) { - return cached; - } - - return cache.aquire(() -> - { - KList layerSeaHeightGenerators = new KList<>(); - - int m = 7735; - - for (IrisBiomePaletteLayer i : biome.getSeaLayers()) { - layerSeaHeightGenerators.add(i.getHeightGenerator(rng.nextParallelRNG((m++) * m * m * m), data)); + private static void appendLayer(KList blocks, IrisBiomePaletteLayer layer, + int layerIndex, int thickness, double x, double z, + RNG random, int maxDepth, IrisData data) { + double zoom = layer.getZoom(); + for (int offset = 0; offset < thickness && blocks.size() < maxDepth; offset++) { + try { + blocks.add(layer.get(random, layerIndex + offset, + (x + offset) / zoom, offset, (z - offset) / zoom, data)); + } catch (Throwable error) { + IrisLogging.reportError(error); } - - return layerSeaHeightGenerators; - }); + } } static PlatformBlockState getSurfaceBlock(IrisBiome biome, int x, int z, RNG rng, IrisData idm) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java index 6a4c2aa79..65ecaa3d8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java @@ -2,7 +2,6 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.util.project.interpolation.IrisInterpolation; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.noise.CNG; @@ -149,21 +148,20 @@ public final class IrisDimensionCarvingResolver { return parent; } - ParentSelectionPlan selectionPlan = state.selectionPlans.get(parent); + IrisRaritySelection selectionPlan = state.selectionPlans.get(parent); if (selectionPlan == null) { selectionPlan = buildSelectionPlan(engine, parent, parentBiome, entryIndex, state); state.selectionPlans.put(parent, selectionPlan); } - if (selectionPlan.parentOnly) { + if (selectionPlan.size() <= 1L) { return parent; } long seed = resolveChildSeed(engine, state); CNG childGenerator = parent.getChildrenGenerator(seed, engine.getData()); double sample = childGenerator.noiseFast2D(worldX, worldZ); - int selectedIndex = (int) Math.round(IrisInterpolation.lerp(0, selectionPlan.maxIndex, sample)); - CarvingChoice selected = selectionPlan.get(selectedIndex); + CarvingChoice selected = selectionPlan.select(sample); if (selected == null || selected.entry == null) { return parent; } @@ -171,7 +169,7 @@ public final class IrisDimensionCarvingResolver { return selected.entry; } - private static ParentSelectionPlan buildSelectionPlan( + private static IrisRaritySelection buildSelectionPlan( Engine engine, IrisDimensionCarvingEntry parent, IrisBiome parentBiome, @@ -201,42 +199,7 @@ public final class IrisDimensionCarvingResolver { } options.add(new CarvingChoice(parent, rarity(parentBiome))); - if (options.size() <= 1) { - return ParentSelectionPlan.parentOnly(); - } - - CarvingChoice[] mappedChoices = buildRarityMappedChoices(options); - if (mappedChoices.length == 0) { - return ParentSelectionPlan.parentOnly(); - } - - return new ParentSelectionPlan(mappedChoices); - } - - private static CarvingChoice[] buildRarityMappedChoices(List choices) { - int max = 1; - for (CarvingChoice choice : choices) { - if (choice.rarity > max) { - max = choice.rarity; - } - } - - max++; - List mapped = new ArrayList<>(); - boolean flip = false; - for (CarvingChoice choice : choices) { - int count = max - choice.rarity; - for (int index = 0; index < count; index++) { - flip = !flip; - if (flip) { - mapped.add(choice); - } else { - mapped.add(0, choice); - } - } - } - - return mapped.toArray(new CarvingChoice[0]); + return IrisRaritySelection.create(options); } private static int rarity(IrisBiome biome) { @@ -285,7 +248,7 @@ public final class IrisDimensionCarvingResolver { public static final class State { private final Map rootEntriesByWorldY = new HashMap<>(); - private final Map selectionPlans = new IdentityHashMap<>(); + private final Map> selectionPlans = new IdentityHashMap<>(); private final Map biomeCache = new IdentityHashMap<>(); private WeakReference engineIdentity; private WeakReference dimensionIdentity; @@ -316,44 +279,6 @@ public final class IrisDimensionCarvingResolver { } } - private static final class ParentSelectionPlan { - private final CarvingChoice[] mappedChoices; - private final int maxIndex; - private final boolean parentOnly; - - private ParentSelectionPlan(CarvingChoice[] mappedChoices) { - this.mappedChoices = mappedChoices; - this.maxIndex = mappedChoices.length - 1; - this.parentOnly = false; - } - - private ParentSelectionPlan() { - this.mappedChoices = null; - this.maxIndex = -1; - this.parentOnly = true; - } - - private static ParentSelectionPlan parentOnly() { - return new ParentSelectionPlan(); - } - - private CarvingChoice get(int index) { - if (mappedChoices == null || mappedChoices.length == 0) { - return null; - } - - if (index < 0) { - return mappedChoices[0]; - } - - if (index >= mappedChoices.length) { - return mappedChoices[mappedChoices.length - 1]; - } - - return mappedChoices[index]; - } - } - private static final class CarvingChoice implements IRare { private final IrisDimensionCarvingEntry entry; private final int rarity; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 4aa79dba4..9d4a37f9e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -110,7 +110,7 @@ public class IrisObject extends IrisRegistrant { this.d = d; center = new Vector3i(w / 2, h / 2, d / 2); shrinkOffset = new Vector3i(0, 0, 0); - var lock = new ReentrantReadWriteLock(); + ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); readLock = lock.readLock(); writeLock = lock.writeLock(); } @@ -135,17 +135,22 @@ public class IrisObject extends IrisRegistrant { return aabb.aquire(() -> getAABBFor(new IrisBlockVector(w, h, d))); } - public synchronized IrisObject copy() { - IrisObject o = new IrisObject(w, h, d); - o.setLoadKey(getLoadKey()); - o.setLoader(getLoader()); - o.setLoadFile(getLoadFile()); - o.setCenter(getCenter().clone()); + public IrisObject copy() { + readLock.lock(); + try { + IrisObject copy = new IrisObject(w, h, d); + copy.setLoadKey(getLoadKey()); + copy.setLoader(getLoader()); + copy.setLoadFile(getLoadFile()); + copy.setCenter(getCenter().clone()); - blocks.forEach((i, v) -> o.blocks.put(i.clone(), v)); - states.forEach((i, v) -> o.states.put(i.clone(), v.clone())); + blocks.forEach(copy.blocks::put); + states.forEach((position, tile) -> copy.states.put(position, tile.clone())); - return o; + return copy; + } finally { + readLock.unlock(); + } } public void readLegacy(InputStream in) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 1d6a38deb..ee36bdc4a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -350,7 +350,7 @@ final class IrisObjectPlacementRunner { return -1; } - if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, translating, translateOffset, ceilingHang, + if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, placementBounds, translating, translateOffset, ceilingHang, yv < 0 && config.getMode() == ObjectPlaceMode.PAINT, warpMargin, x, y + yrand, z)) { return -1; } @@ -533,6 +533,9 @@ final class IrisObjectPlacementRunner { } data = config.getRotation().rotate(data, spinx, spiny, spinz); + if (data == null) { + continue; + } xx = x + (int) Math.round(i.getX()); int yy = y + (int) Math.round(i.getY()); @@ -955,7 +958,7 @@ final class IrisObjectPlacementRunner { * near this placement means no per block work at all. Only when the placement envelope meets a piece does the * precise pass run, and the first solid block inside a piece rejects the whole object before any write. */ - private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin, + private boolean nativeStructureVetoes(IObjectPlacer placer, IrisObjectPlacement config, SpinKernel spin, TransformedBounds bounds, boolean translating, IrisBlockVector translateOffset, boolean ceilingHang, boolean paint, int warpMargin, int x, int y, int z) { Engine engine = placer.getEngine(); @@ -963,23 +966,21 @@ final class IrisObjectPlacementRunner { return false; } - int margin = (Math.max(self.getW(), Math.max(self.getH(), self.getD())) / 2) + 1 + warpMargin; - if (translating) { - margin += Math.max(Math.abs(translateOffset.getBlockX()), - Math.max(Math.abs(translateOffset.getBlockY()), Math.abs(translateOffset.getBlockZ()))); - } - - KList volumes = engine.getNativeStructureVolumes(x - margin, z - margin, x + margin, z + margin); + int minX = x + bounds.minX(); + int maxX = x + bounds.maxX(); + int minZ = z + bounds.minZ(); + int maxZ = z + bounds.maxZ(); + KList volumes = engine.getNativeStructureVolumes(minX, minZ, maxX, maxZ); if (volumes == null || volumes.isEmpty()) { return false; } int worldY = y + engine.getMinHeight(); - int envelopeMinY = paint ? Integer.MIN_VALUE : worldY - margin; - int envelopeMaxY = paint ? Integer.MAX_VALUE : worldY + margin; + int envelopeMinY = paint ? Integer.MIN_VALUE : worldY + bounds.minY() - warpMargin; + int envelopeMaxY = paint ? Integer.MAX_VALUE : worldY + bounds.maxY() + warpMargin; boolean envelopeMeetsPiece = false; for (NativeStructureVolume volume : volumes) { - if (volume.intersects(x - margin, envelopeMinY, z - margin, x + margin, envelopeMaxY, z + margin)) { + if (volume.intersects(minX, envelopeMinY, minZ, maxX, envelopeMaxY, maxZ)) { envelopeMeetsPiece = true; break; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java index bac39db6f..61cba47f9 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java @@ -22,17 +22,10 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.VectorMap; import art.arcane.iris.util.common.math.IrisBlockVector; -import art.arcane.iris.util.common.math.IrisVector; import art.arcane.iris.util.common.math.Vector3i; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - /** * Volume shaping for {@link IrisObject}: smart boring, shrinkwrapping, compaction and the shared block * classification helpers used by placement. @@ -62,30 +55,23 @@ final class IrisObjectShaping { private static void ensureSmartBoredLocked(IrisObject self) { PrecisionStopwatch p = PrecisionStopwatch.start(); PlatformBlockState vair = IrisObject.States.VAIR; - AtomicInteger applied = new AtomicInteger(); - IrisBlockVector max = new IrisBlockVector(Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE); - IrisBlockVector min = new IrisBlockVector(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE); - VectorMap source; - self.readLock.lock(); - try { - if (self.blocks.isEmpty()) { - IrisLogging.warn("Cannot Smart Bore " + self.getLoadKey() + " because it has 0 blocks in it."); - self.smartBored = true; - return; - } + int applied = 0; + IrisBlockVector max = new IrisBlockVector(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE); + IrisBlockVector min = new IrisBlockVector(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + VectorMap source = self.blocks; + if (source.isEmpty()) { + IrisLogging.warn("Cannot Smart Bore " + self.getLoadKey() + " because it has 0 blocks in it."); + self.smartBored = true; + return; + } - source = self.blocks; - - for (IrisBlockVector i : self.blocks.keys()) { - max.setX(Math.max(i.getX(), max.getX())); - min.setX(Math.min(i.getX(), min.getX())); - max.setY(Math.max(i.getY(), max.getY())); - min.setY(Math.min(i.getY(), min.getY())); - max.setZ(Math.max(i.getZ(), max.getZ())); - min.setZ(Math.min(i.getZ(), min.getZ())); - } - } finally { - self.readLock.unlock(); + for (IrisBlockVector i : source.keys()) { + max.setX(Math.max(i.getX(), max.getX())); + min.setX(Math.min(i.getX(), min.getX())); + max.setY(Math.max(i.getY(), max.getY())); + min.setY(Math.min(i.getY(), min.getY())); + max.setZ(Math.max(i.getZ(), max.getZ())); + min.setZ(Math.min(i.getZ(), min.getZ())); } VectorMap bore = new VectorMap<>(); @@ -93,114 +79,94 @@ final class IrisObjectShaping { // bored volume must come from a fixed X->Y->Z order on the calling (lock-owning) // thread, not from pool scheduling. This matches what burst-worker callers (studio, // generation threads) already produced, so output is unchanged where it was stable. - BurstExecutor burst = MultiBurst.burst.burst(); - burst.setMulticore(false); // Smash X for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { - int finalRayY = rayY; - burst.queue(() -> { - for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockX(); ray <= max.getBlockX(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(ray, finalRayY, rayZ))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(i, finalRayY, rayZ), vair, applied); - } + for (int ray = min.getBlockX(); ray <= max.getBlockX(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(ray, rayY, rayZ))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); + + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(i, rayY, rayZ), vair); + } + } + } } // Smash Y for (int rayX = min.getBlockX(); rayX <= max.getBlockX(); rayX++) { - int finalRayX = rayX; - burst.queue(() -> { - for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayZ = min.getBlockZ(); rayZ <= max.getBlockZ(); rayZ++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockY(); ray <= max.getBlockY(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(finalRayX, ray, rayZ))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(finalRayX, i, rayZ), vair, applied); - } + for (int ray = min.getBlockY(); ray <= max.getBlockY(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(rayX, ray, rayZ))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); + + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(rayX, i, rayZ), vair); + } + } + } } // Smash Z for (int rayX = min.getBlockX(); rayX <= max.getBlockX(); rayX++) { - int finalRayX = rayX; - burst.queue(() -> { - for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { - int start = Integer.MAX_VALUE; - int end = Integer.MIN_VALUE; + for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) { + int start = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; - for (int ray = min.getBlockZ(); ray <= max.getBlockZ(); ray++) { - if (boreContains(source, bore, new IrisBlockVector(finalRayX, rayY, ray))) { - start = Math.min(ray, start); - end = Math.max(ray, end); - } - } - - if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { - for (int i = start; i <= end; i++) { - boreCell(source, bore, new IrisBlockVector(finalRayX, rayY, i), vair, applied); - } + for (int ray = min.getBlockZ(); ray <= max.getBlockZ(); ray++) { + if (boreContains(source, bore, new IrisBlockVector(rayX, rayY, ray))) { + start = Math.min(ray, start); + end = Math.max(ray, end); } } - }); - } - burst.complete(); - - self.writeLock.lock(); - try { - if (!self.smartBored) { - bore.forEach((v, s) -> self.blocks.computeIfAbsent(v, (vv) -> s)); - self.smartBored = true; + if (start != Integer.MAX_VALUE && end != Integer.MIN_VALUE) { + for (int i = start; i <= end; i++) { + applied += boreCell(source, bore, new IrisBlockVector(rayX, rayY, i), vair); + } + } } - } finally { - self.writeLock.unlock(); } - IrisLogging.debug("Smart Bore: " + self.getLoadKey() + " in " + Form.duration(p.getMilliseconds(), 2) + " (" + Form.f(applied.get()) + ")"); + source.putAll(bore); + self.smartBored = true; + + IrisLogging.debug("Smart Bore: " + self.getLoadKey() + " in " + Form.duration(p.getMilliseconds(), 2) + " (" + Form.f(applied) + ")"); } private static boolean boreContains(VectorMap source, VectorMap bore, IrisBlockVector v) { return source.containsKey(v) || bore.containsKey(v); } - private static void boreCell(VectorMap source, VectorMap bore, IrisBlockVector v, PlatformBlockState vair, AtomicInteger applied) { + private static int boreCell(VectorMap source, VectorMap bore, IrisBlockVector v, PlatformBlockState vair) { PlatformBlockState existing = source.get(v); if (existing == null) { if (vair.equals(bore.get(v))) { - return; + return 0; } - bore.computeIfAbsent(v, (vv) -> vair); + bore.put(v, vair); } else if (vair.equals(existing)) { - return; + return 0; } - applied.getAndIncrement(); + return 1; } static void shrinkwrap(IrisObject self) { @@ -289,23 +255,4 @@ final class IrisObjectShaping { static String materialKey(PlatformBlockState state) { return IrisProceduralBlocks.materialKey(state); } - - static List blocksBetweenTwoPoints(IrisVector loc1, IrisVector loc2) { - List locations = new ArrayList<>(); - int topBlockX = Math.max(loc1.getBlockX(), loc2.getBlockX()); - int bottomBlockX = Math.min(loc1.getBlockX(), loc2.getBlockX()); - int topBlockY = Math.max(loc1.getBlockY(), loc2.getBlockY()); - int bottomBlockY = Math.min(loc1.getBlockY(), loc2.getBlockY()); - int topBlockZ = Math.max(loc1.getBlockZ(), loc2.getBlockZ()); - int bottomBlockZ = Math.min(loc1.getBlockZ(), loc2.getBlockZ()); - - for (int x = bottomBlockX; x <= topBlockX; x++) { - for (int z = bottomBlockZ; z <= topBlockZ; z++) { - for (int y = bottomBlockY; y <= topBlockY; y++) { - locations.add(new IrisBlockVector(x, y, z)); - } - } - } - return locations; - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java index e2919b2f1..3925a2570 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java @@ -30,10 +30,12 @@ import art.arcane.iris.util.project.interpolation.Interpolation3D; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers. @@ -52,15 +54,23 @@ final class IrisObjectTransforms { self.writeLock.lock(); try { VectorMap d = new VectorMap<>(); + Set omitted = new HashSet<>(); - for (var entry : self.blocks) { - d.put(r.rotate(entry.getKey(), spinx, spiny, spinz), r.rotate(entry.getValue(), spinx, spiny, spinz)); + for (Map.Entry entry : self.blocks) { + PlatformBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz); + if (rotated == null) { + omitted.add(entry.getKey()); + continue; + } + d.put(r.rotate(entry.getKey(), spinx, spiny, spinz), rotated); } VectorMap dx = new VectorMap<>(); - for (var entry : self.states) { - dx.put(r.rotate(entry.getKey(), spinx, spiny, spinz), entry.getValue()); + for (Map.Entry entry : self.states) { + if (!omitted.contains(entry.getKey())) { + dx.put(r.rotate(entry.getKey(), spinx, spiny, spinz), entry.getValue()); + } } self.blocks = d; @@ -96,32 +106,31 @@ final class IrisObjectTransforms { scale = scale - 0.0001; } - IrisPosition l1 = self.getAABB().max(); - IrisPosition l2 = self.getAABB().min(); VectorMap placeBlock = new VectorMap<>(); VectorMap placeTile = new VectorMap<>(); VectorMap placeMax = savedOrigin && scale > 1 ? new VectorMap<>() : null; - IrisVector center = new IrisVector(self.getCenter().getX(), self.getCenter().getY(), self.getCenter().getZ()); - if (self.getH() == 2) { - center = center.setY(center.getBlockY() + 0.5); - } - if (self.getW() == 2) { - center = center.setX(center.getBlockX() + 0.5); - } - if (self.getD() == 2) { - center = center.setZ(center.getBlockZ() + 0.5); - } - - IrisObject oo = savedOrigin ? createOriginScaledObject(self, scale) - : new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); - oo.setLoadKey(self.getLoadKey()); - oo.setLoader(self.getLoader()); - oo.setLoadFile(self.getLoadFile()); - + IrisVector center; + IrisObject oo; boolean hasTiles; self.readLock.lock(); try { + center = new IrisVector(self.getCenter().getX(), self.getCenter().getY(), self.getCenter().getZ()); + if (self.getH() == 2) { + center = center.setY(center.getBlockY() + 0.5); + } + if (self.getW() == 2) { + center = center.setX(center.getBlockX() + 0.5); + } + if (self.getD() == 2) { + center = center.setZ(center.getBlockZ() + 0.5); + } + + oo = savedOrigin ? createOriginScaledObject(self, scale) + : new IrisObject((int) Math.ceil((self.w * scale) + (scale * 2)), (int) Math.ceil((self.h * scale) + (scale * 2)), (int) Math.ceil((self.d * scale) + (scale * 2))); + oo.setLoadKey(self.getLoadKey()); + oo.setLoader(self.getLoader()); + oo.setLoadFile(self.getLoadFile()); hasTiles = !self.states.isEmpty(); for (Map.Entry entry : self.blocks) { PlatformBlockState bd = entry.getValue(); @@ -153,13 +162,27 @@ final class IrisObjectTransforms { if (scale > 1) { IrisVector minimum = savedOrigin ? v : v.clone().add(center); IrisVector maximum = savedOrigin ? placeMax.get(v) : v.clone().add(center).add(sm1); - for (IrisBlockVector vec : IrisObjectShaping.blocksBetweenTwoPoints(minimum, maximum)) { - oo.blocks.put(vec, entry.getValue()); - if (hasTiles) { - if (tile == null) { - oo.states.remove(vec); - } else { - oo.states.put(vec, tile.clone()); + int minX = Math.min(minimum.getBlockX(), maximum.getBlockX()); + int maxX = Math.max(minimum.getBlockX(), maximum.getBlockX()); + int minY = Math.min(minimum.getBlockY(), maximum.getBlockY()); + int maxY = Math.max(minimum.getBlockY(), maximum.getBlockY()); + int minZ = Math.min(minimum.getBlockZ(), maximum.getBlockZ()); + int maxZ = Math.max(minimum.getBlockZ(), maximum.getBlockZ()); + IrisBlockVector position = new IrisBlockVector(minX, minY, minZ); + for (int x = minX; x <= maxX; x++) { + position.setX(x); + for (int z = minZ; z <= maxZ; z++) { + position.setZ(z); + for (int y = minY; y <= maxY; y++) { + position.setY(y); + oo.blocks.put(position, entry.getValue()); + if (hasTiles) { + if (tile == null) { + oo.states.remove(position); + } else { + oo.states.put(position, tile.clone()); + } + } } } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java index f124c8e89..1bac17826 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java @@ -58,7 +58,7 @@ public class IrisOreGenerator { } public PlatformBlockState generate(int x, int y, int z, RNG rng, IrisData data) { - if (!range.contains(y)) { + if (threshold <= 0 || !range.contains(y)) { return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRaritySelection.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRaritySelection.java new file mode 100644 index 000000000..967a05b59 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRaritySelection.java @@ -0,0 +1,60 @@ +package art.arcane.iris.engine.object; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class IrisRaritySelection { + private final List choices; + private final long[] cumulativeCounts; + + private IrisRaritySelection(List choices, long[] cumulativeCounts) { + this.choices = choices; + this.cumulativeCounts = cumulativeCounts; + } + + public static IrisRaritySelection create(List options) { + ArrayList choices = new ArrayList<>(options.size()); + long maximumRarity = 1L; + for (T option : options) { + if (option != null) { + choices.add(option); + maximumRarity = Math.max(maximumRarity, option.getRarity()); + } + } + long[] cumulativeCounts = new long[choices.size()]; + long total = 0L; + for (int index = 0; index < choices.size(); index++) { + total += maximumRarity + 1L - Math.max(1, choices.get(index).getRarity()); + cumulativeCounts[index] = total; + } + return new IrisRaritySelection<>(List.copyOf(choices), cumulativeCounts); + } + + public long size() { + return cumulativeCounts.length == 0 ? 0L : cumulativeCounts[cumulativeCounts.length - 1]; + } + + public T select(double noiseValue) { + long maximumIndex = size() - 1L; + long selectedIndex = Math.round(noiseValue * maximumIndex); + return get(maximumIndex <= Integer.MAX_VALUE ? (int) selectedIndex : selectedIndex); + } + + public T get(long index) { + long size = size(); + if (size == 0L) { + return null; + } + if (choices.size() == 1) { + return choices.getFirst(); + } + long selectedIndex = Math.max(0L, Math.min(size - 1L, index)); + long frontSize = size / 2L; + long insertionIndex = selectedIndex < frontSize + ? 2L * (frontSize - selectedIndex) - 1L + : 2L * (selectedIndex - frontSize); + int choiceIndex = Arrays.binarySearch(cumulativeCounts, insertionIndex + 1L); + return choices.get(choiceIndex < 0 ? -choiceIndex - 1 : choiceIndex); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java index dde2938f5..e7f2822fa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java @@ -88,16 +88,18 @@ public final class CoralGenerator { private static void buildFan(CoralCanvas canvas, IrisCoral coral, int height, long seed) { int half = Math.max(1, coral.getFanWidth()); - for (int y = 0; y < height; y++) { + int tippedWidth = -1; + for (int y = height - 1; y >= 0; y--) { double t = y / (double) Math.max(1, height - 1); double profile = Math.sin(Math.PI * t); int w = (int) Math.round(half * profile); for (int dx = -w; dx <= w; dx++) { canvas.set(dx, y, 0, CoralCanvas.Role.STRUCTURE); + if (Math.abs(dx) > tippedWidth) { + placeTip(canvas, coral, dx, y, 0, seed + dx); + } } - } - for (int dx = -half; dx <= half; dx++) { - placeTip(canvas, coral, dx, height - 1, 0, seed + dx); + tippedWidth = Math.max(tippedWidth, w); } } @@ -170,7 +172,9 @@ public final class CoralGenerator { int y = (int) Math.round(oy + dy * t); int z = (int) Math.round(oz + dz * t - sway); canvas.set(x, y, z, CoralCanvas.Role.STRUCTURE); - tip = new double[]{x, y, z}; + tip[0] = x; + tip[1] = y; + tip[2] = z; } return tip; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java index b801ef582..de02aeec8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java @@ -25,35 +25,48 @@ import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.math.Vector3i; import art.arcane.volmlib.util.math.RNG; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; public final class FormationBlockResolver { - private FormationBlockResolver() { + private final IrisFormation formation; + private final IrisData data; + private final RNG paletteRng; + private final Int2ObjectOpenHashMap strataRngs = new Int2ObjectOpenHashMap<>(); + + public FormationBlockResolver(IrisFormation formation, IrisData data) { + this.formation = formation; + this.data = data; + paletteRng = new RNG(formation.getSeed()); } - public static PlatformBlockState resolve(IrisFormation f, IrisData data, FormationCanvas.Role role, Vector3i raw) { + public PlatformBlockState resolve(FormationCanvas.Role role, Vector3i raw) { int x = raw.getBlockX(); int y = raw.getBlockY(); int z = raw.getBlockZ(); - RNG paletteRng = new RNG(f.getSeed()); - if (role == FormationCanvas.Role.CAP && capDefined(f)) { - PlatformBlockState cap = IrisProceduralBlocks.resolve(f.getCapBlock(), f.getCapPalette(), data, x, y, z, paletteRng); + if (role == FormationCanvas.Role.CAP && capDefined(formation)) { + PlatformBlockState cap = IrisProceduralBlocks.resolve(formation.getCapBlock(), formation.getCapPalette(), data, x, y, z, paletteRng); if (cap != null) { return cap; } } - if (strataDefined(f)) { - IrisMaterialPalette strata = f.getStrataPalette(); - int thickness = Math.max(1, f.getStrataThickness()); + if (strataDefined(formation)) { + IrisMaterialPalette strata = formation.getStrataPalette(); + int thickness = Math.max(1, formation.getStrataThickness()); int band = Math.floorDiv(y, thickness); - PlatformBlockState strataState = strata.get(new RNG(f.getSeed() + (band * 31L)), x, band, z, data); + RNG strataRng = strataRngs.get(band); + if (strataRng == null) { + strataRng = new RNG(formation.getSeed() + band * 31L); + strataRngs.put(band, strataRng); + } + PlatformBlockState strataState = strata.get(strataRng, x, band, z, data); if (strataState != null) { return strataState; } } - return IrisProceduralBlocks.resolve(f.getBlock(), f.getBlockPalette(), data, x, y, z, paletteRng); + return IrisProceduralBlocks.resolve(formation.getBlock(), formation.getBlockPalette(), data, x, y, z, paletteRng); } private static boolean capDefined(IrisFormation f) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java index e6fe4e577..60ae3377f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java @@ -62,8 +62,9 @@ public final class FormationGenerator { } Map resolved = new HashMap<>(); + FormationBlockResolver resolver = new FormationBlockResolver(f, data); for (Map.Entry entry : canvas.getCells().entrySet()) { - PlatformBlockState bd = FormationBlockResolver.resolve(f, data, entry.getValue(), entry.getKey()); + PlatformBlockState bd = resolver.resolve(entry.getValue(), entry.getKey()); if (bd == null) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java index 1e63cdfa0..aace94dc7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java @@ -63,11 +63,7 @@ public final class FungusGenerator { private static void buildUpright(Map roles, IrisFungus fungus, int height, RNG rng, long baseSeed) { int stemHeight = Math.max(1, height); - Map stemCells = new HashMap<>(); - double[] top = FungusStemBuilder.build(stemCells, fungus, stemHeight, baseSeed); - for (Vector3i v : stemCells.keySet()) { - roles.put(v, FungusCellRole.STEM); - } + double[] top = FungusStemBuilder.build(roles, fungus, stemHeight, baseSeed); int radius = pickRadius(fungus, rng); double cx = top[0]; diff --git a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java index 58d5120c0..321a5f9b8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java @@ -20,7 +20,6 @@ package art.arcane.iris.engine.object.fungi; import art.arcane.iris.engine.object.IrisFungus; import art.arcane.iris.engine.object.tree.TreeFunctions; -import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.math.Vector3i; import java.util.Map; @@ -29,7 +28,7 @@ public final class FungusStemBuilder { private FungusStemBuilder() { } - public static double[] build(Map stemCells, IrisFungus fungus, int stemHeight, long seed) { + public static double[] build(Map roles, IrisFungus fungus, int stemHeight, long seed) { int width = Math.max(1, Math.min(3, fungus.getStemWidth())); double maxLean = stemHeight * Math.tan(Math.toRadians(Math.max(0.0, fungus.getStemCurve()))); double leanRad = Math.toRadians(fungus.getStemLeanAzimuth()); @@ -40,6 +39,7 @@ public final class FungusStemBuilder { double waveAzimuth = Math.toRadians(fungus.getStemLeanAzimuth() + 90.0); double waveX = Math.sin(waveAzimuth); double waveZ = Math.cos(waveAzimuth); + double wavePhase = TreeFunctions.valueNoise1D(seed, seed) * Math.PI * 2.0; double topCx = 0.0; double topCz = 0.0; @@ -48,12 +48,16 @@ public final class FungusStemBuilder { for (int y = 0; y < stemHeight; y++) { double t = stemHeight <= 1 ? 0.0 : y / (double) (stemHeight - 1); double lean = maxLean * (t * t); - double wave = waveAmp * Math.sin(2.0 * Math.PI * wavePeriods * t + TreeFunctions.valueNoise1D(seed, seed) * Math.PI * 2.0); + double wave = waveAmp * Math.sin(2.0 * Math.PI * wavePeriods * t + wavePhase); double cx = lean * leanX + wave * waveX; double cz = lean * leanZ + wave * waveZ; - for (int[] xz : squarePositions(cx, cz, width)) { - stemCells.put(new Vector3i(xz[0], y, xz[1]), null); + int ox = (width % 2 == 1 ? (int) Math.round(cx) : (int) Math.floor(cx) + 1) - width / 2; + int oz = (width % 2 == 1 ? (int) Math.round(cz) : (int) Math.floor(cz) + 1) - width / 2; + for (int dx = 0; dx < width; dx++) { + for (int dz = 0; dz < width; dz++) { + roles.put(new Vector3i(ox + dx, y, oz + dz), FungusCellRole.STEM); + } } topCx = cx; @@ -62,34 +66,4 @@ public final class FungusStemBuilder { return new double[]{topCx, topY, topCz}; } - - static int[][] squarePositions(double cx, double cz, int width) { - if (width % 2 == 1) { - int half = width / 2; - int icx = (int) Math.round(cx); - int icz = (int) Math.round(cz); - int[][] out = new int[width * width][2]; - int idx = 0; - for (int dx = -half; dx <= half; dx++) { - for (int dz = -half; dz <= half; dz++) { - out[idx][0] = icx + dx; - out[idx][1] = icz + dz; - idx++; - } - } - return out; - } - int ox = (int) Math.floor(cx) - width / 2 + 1; - int oz = (int) Math.floor(cz) - width / 2 + 1; - int[][] out = new int[width * width][2]; - int idx = 0; - for (int dx = 0; dx < width; dx++) { - for (int dz = 0; dz < width; dz++) { - out[idx][0] = ox + dx; - out[idx][1] = oz + dz; - idx++; - } - } - return out; - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java index a6dbe7e36..cd72bf747 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java @@ -66,9 +66,10 @@ public final class ProceduralTreeGenerator { Map resolved = new HashMap<>(); Set trunkPositions = new HashSet<>(); Set leafPositions = new HashSet<>(); + RNG paletteRng = new RNG(tree.getSeed()); for (Map.Entry entry : canvas.getCells().entrySet()) { TreeBlockCanvas.Cell cell = entry.getValue(); - PlatformBlockState state = TreeBlockResolver.resolve(tree, data, cell, entry.getKey()); + PlatformBlockState state = TreeBlockResolver.resolve(tree, data, cell, entry.getKey(), paletteRng); if (state == null) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java index 04c73e4af..f0e23f988 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisTreeDecorator; +import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.math.RNG; @@ -31,10 +32,7 @@ public final class TreeBlockResolver { private TreeBlockResolver() { } - public static PlatformBlockState resolve(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Cell cell, TreeBlockCanvas.Vec pos) { - RNG paletteRng = new RNG(tree.getSeed()); - RNG posRng = new RNG(tree.getSeed() ^ positionHash(pos)); - + public static PlatformBlockState resolve(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Cell cell, TreeBlockCanvas.Vec pos, RNG paletteRng) { switch (cell.role()) { case TRUNK -> { PlatformBlockState state = resolveBlock(tree.getTrunk(), tree.getTrunkPalette(), data, pos, paletteRng); @@ -51,7 +49,7 @@ public final class TreeBlockResolver { return resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); } case SECONDARY_LEAF -> { - PlatformBlockState state = resolveSecondaryLeaf(tree, data, pos, paletteRng, posRng); + PlatformBlockState state = resolveSecondaryLeaf(tree, data, pos, paletteRng); if (state == null) { state = resolveBlock(tree.getLeaves(), tree.getLeavesPalette(), data, pos, paletteRng); } @@ -94,12 +92,12 @@ public final class TreeBlockResolver { return state; } - private static PlatformBlockState resolveSecondaryLeaf(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng, RNG posRng) { + private static PlatformBlockState resolveSecondaryLeaf(IrisProceduralTree tree, IrisData data, TreeBlockCanvas.Vec pos, RNG paletteRng) { if (TreeTrunkBuilder.paletteSet(tree.getSecondaryLeavesPalette())) { return tree.getSecondaryLeavesPalette().get(paletteRng, pos.x(), pos.y(), pos.z(), data); } if (tree.getWeightedSecondaryLeaves() != null && !tree.getWeightedSecondaryLeaves().isEmpty()) { - String picked = pickWeighted(tree, posRng); + String picked = pickWeighted(tree, new RNG(tree.getSeed() ^ positionHash(pos))); return picked == null ? null : B.getStateOrNull(picked, false); } if (tree.getSecondaryLeaves() != null && !tree.getSecondaryLeaves().isEmpty()) { @@ -109,16 +107,16 @@ public final class TreeBlockResolver { } private static String pickWeighted(IrisProceduralTree tree, RNG rng) { - int total = 0; - for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { + long total = 0; + for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { total += Math.max(0, s.getWeight()); } if (total <= 0) { return null; } double r = rng.nextDouble() * total; - double cumulative = 0; - for (art.arcane.iris.engine.object.IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { + long cumulative = 0; + for (IrisTreeSecondaryLeaf s : tree.getWeightedSecondaryLeaves()) { cumulative += Math.max(0, s.getWeight()); if (r < cumulative) { return s.getBlock(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java index a4f34ffa4..6d36a0469 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java @@ -67,11 +67,24 @@ public final class TreeDecoratorApplier { if (rng.nextDouble() > dec.getChance()) { continue; } - if (canvas.has(e[0], e[1], e[2])) { + int dx = e[0] - e[3]; + int dz = e[2] - e[4]; + boolean alongX = Math.abs(dx) >= Math.abs(dz); + int stepX = alongX ? (dx >= 0 ? 1 : -1) : 0; + int stepZ = alongX ? 0 : (dz >= 0 ? 1 : -1); + int x = e[0] + stepX; + int z = e[2] + stepZ; + TreeBlockCanvas.Cell cell = canvas.get(x, e[1], z); + while (cell != null && cell.role() != TreeBlockCanvas.Role.DECORATOR) { + x += stepX; + z += stepZ; + cell = canvas.get(x, e[1], z); + } + if (cell != null) { continue; } String facing = dec.isAxisAware() ? facingAway(e[3], e[4], e[0], e[2]) : null; - canvas.setDecor(e[0], e[1], e[2], idx, facing); + canvas.setDecor(x, e[1], z, idx, facing); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java index b9e6af329..00e1c6229 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java @@ -50,7 +50,9 @@ public final class TreePlausibility { return; } - Map distance = computeDistances(trunkPositions, realLeaves); + Map distance = tree.isPlausible() + ? computeDistances(trunkPositions, realLeaves) + : Map.of(); for (TreeBlockCanvas.Vec v : realLeaves) { PlatformBlockState leaf = resolved.get(v); diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java index 6f24d9e16..bcb0f6035 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java @@ -18,7 +18,6 @@ package art.arcane.iris.engine.object.tree; -import java.util.ArrayList; import java.util.Map; public final class TreeSupport { @@ -36,7 +35,7 @@ public final class TreeSupport { TreeBlockCanvas.Vec worstWood = null; int worstGap = -1; - for (TreeBlockCanvas.Vec leaf : new ArrayList<>(canvas.getLeaf())) { + for (TreeBlockCanvas.Vec leaf : canvas.getLeaf()) { Integer d = dist.get(leaf); if (d != null && d <= LEGAL_DISTANCE) { continue; diff --git a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java index e69365a12..26a8a19a6 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java @@ -121,7 +121,7 @@ public final class TreeTrunkBuilder { } private static void markExposedEnds(TreeBlockCanvas canvas) { - for (TreeBlockCanvas.Vec v : new ArrayList<>(canvas.getTrunk())) { + for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { if (!canvas.has(v.x(), v.y() + 1, v.z()) || !canvas.has(v.x(), v.y() - 1, v.z())) { canvas.markExposed(v.x(), v.y(), v.z()); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index bdcdf77bf..01fa4d8de 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -48,6 +48,7 @@ import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.history.GenerationEpoch; import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -396,66 +397,68 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private void setupEngine() { - lastMode = StudioMode.NORMAL; - lastJigsawStudioRequestId = null; - if (generationHistory != null) { - try { - generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - targetCache.reset(); - } catch (IOException failure) { - throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure); - } - } - EngineTarget engineTarget = getTarget(); - String packKey = engineTarget.getDimension().getLoadKey(); - JigsawStudioActivation.Request request = studio - ? JigsawStudioActivation.getGeneratorRequest(packKey) - : null; - JigsawStudioSession session = request == null - ? null - : JigsawStudioActivation.getGeneratorSession(packKey); - jigsawStudioActive = request != null - && session != null - && session.sessionId().equals(request.requestId()); - objectStudioActive = studio && ObjectStudioActivation.isActive(packKey); - IrisEngine createdEngine = createEngine(engineTarget); - if (generationHistory != null) { - long initialActivationId = generationHistory.activeActivation().activationId(); - int transitionWidthBlocks = IrisSettings.get() - .getGenerator() - .getGenerationTransitionWidthBlocks(); - try { - createdEngine.attachGenerationHistory( - generationHistory, - IrisBoundarySignatureSampler.INSTANCE, - transitionWidthBlocks); - if (generationHistory.activeActivation().activationId() != initialActivationId) { - createdEngine.close(); + try (GenerationAdmission.RuntimeLease startupAdmission = generationHistory == null ? null : generationHistory.retainRuntime()) { + lastMode = StudioMode.NORMAL; + lastJigsawStudioRequestId = null; + if (generationHistory != null) { + try { + generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); targetCache.reset(); - createdEngine = createEngine(loadActiveGenerationHistoryTarget()); + } catch (IOException failure) { + throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure); + } + } + EngineTarget engineTarget = getTarget(); + String packKey = engineTarget.getDimension().getLoadKey(); + JigsawStudioActivation.Request request = studio + ? JigsawStudioActivation.getGeneratorRequest(packKey) + : null; + JigsawStudioSession session = request == null + ? null + : JigsawStudioActivation.getGeneratorSession(packKey); + jigsawStudioActive = request != null + && session != null + && session.sessionId().equals(request.requestId()); + objectStudioActive = studio && ObjectStudioActivation.isActive(packKey); + IrisEngine createdEngine = createEngine(engineTarget); + if (generationHistory != null) { + long initialActivationId = generationHistory.activeActivation().activationId(); + int transitionWidthBlocks = IrisSettings.get() + .getGenerator() + .getGenerationTransitionWidthBlocks(); + try { createdEngine.attachGenerationHistory( generationHistory, IrisBoundarySignatureSampler.INSTANCE, transitionWidthBlocks); - } - } catch (Throwable failure) { - try { - createdEngine.close(); - } catch (Throwable closeFailure) { - if (failure != closeFailure) { - failure.addSuppressed(closeFailure); + if (generationHistory.activeActivation().activationId() != initialActivationId) { + createdEngine.close(); + targetCache.reset(); + createdEngine = createEngine(loadActiveGenerationHistoryTarget()); + createdEngine.attachGenerationHistory( + generationHistory, + IrisBoundarySignatureSampler.INSTANCE, + transitionWidthBlocks); } + } catch (Throwable failure) { + try { + createdEngine.close(); + } catch (Throwable closeFailure) { + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw propagateEngineSetupFailure(failure); } - throw propagateEngineSetupFailure(failure); } + createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( + isAuthoringStudio(), + studioEntryBootstrapActive.get(), + initializationFailure != null)); + engine = createdEngine; + populators.clear(); + targetCache.reset(); } - createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( - isAuthoringStudio(), - studioEntryBootstrapActive.get(), - initializationFailure != null)); - engine = createdEngine; - populators.clear(); - targetCache.reset(); } private EngineTarget loadActiveGenerationHistoryTarget() throws IOException { @@ -672,7 +675,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public CompletableFuture closeAsync() { - closing = true; // Outside the exclusive-control block so every close path detaches the WorldInit // listener, including a rollback where the world never materialized. Guarded: with no // hosted plugin (unit tests, teardown) volmitPlugin() throws, and that must not stop @@ -689,6 +691,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + boolean alreadyClosing = closing; + closing = true; CompletableFuture operation; try { operation = withExclusiveControlFuture(() -> { @@ -712,11 +716,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun populators.clear(); }); } catch (Throwable throwable) { - future.completeExceptionally(throwable); + if (!alreadyClosing) { + // A failed close must stay retryable; leaving closing latched would permanently + // reject generation for a world that may still be loaded. + closing = false; + } closeFuture.compareAndSet(future, null); - // A failed close must stay retryable; leaving closing latched would permanently - // reject generation for a world that may still be loaded. - closing = false; + future.completeExceptionally(throwable); return future; } @@ -727,6 +733,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun // The close body already ran and tore the engine down; unlike the pre-dispatch // failure above, resetting the gate here would advertise a healthy generator // over a CLOSED or FAILED engine. Stay latched and surface the failure. + closeFuture.compareAndSet(future, null); future.completeExceptionally(throwable); } }); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java index a95fee979..d2d92ad19 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackBiomeLayerValidatorTest.java @@ -11,7 +11,6 @@ import java.nio.file.Path; import java.util.List; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class PackBiomeLayerValidatorTest { @@ -19,14 +18,12 @@ public class PackBiomeLayerValidatorTest { public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test - public void rejectsMoreCeilingLayersThanLayers() throws Exception { + public void acceptsMoreCeilingLayersThanSurfaceLayers() throws Exception { File pack = temporaryFolder.newFolder("pack"); write(pack, "biomes/nested/cave.json", "{\"layers\":[{}],\"caveCeilingLayers\":[{},{},{}]}"); - assertEquals(List.of( - "Biome 'nested/cave' declares 3 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries." - ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + assertTrue(PackBiomeLayerValidator.validateLayers(new File(pack, "biomes")).isEmpty()); } @Test @@ -35,9 +32,7 @@ public class PackBiomeLayerValidatorTest { write(pack, "biomes/defaulted.json", "{\"name\":\"Defaulted\"}"); write(pack, "biomes/implicit.json", "{\"caveCeilingLayers\":[{},{}]}"); - assertEquals(List.of( - "Biome 'implicit' declares 2 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries." - ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + assertTrue(PackBiomeLayerValidator.validateLayers(new File(pack, "biomes")).isEmpty()); } @Test @@ -45,7 +40,7 @@ public class PackBiomeLayerValidatorTest { File pack = temporaryFolder.newFolder("pack"); write(pack, "biomes/ok.json", "{\"layers\":[{},{},{}],\"caveCeilingLayers\":[{},{}]}"); - assertTrue(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes")).isEmpty()); + assertTrue(PackBiomeLayerValidator.validateLayers(new File(pack, "biomes")).isEmpty()); } @Test @@ -56,11 +51,11 @@ public class PackBiomeLayerValidatorTest { assertEquals(List.of( "Biome 'bad' layers must be an array.", "Biome 'bad' caveCeilingLayers must be an array." - ), PackBiomeLayerValidator.validateCeilingLayerCounts(new File(pack, "biomes"))); + ), PackBiomeLayerValidator.validateLayers(new File(pack, "biomes"))); } @Test - public void ceilingLayerOverflowBlocksFullPackValidation() throws Exception { + public void independentCeilingLayersPassFullPackValidation() throws Exception { File pack = temporaryFolder.newFolder("pack"); write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); @@ -68,9 +63,7 @@ public class PackBiomeLayerValidatorTest { PackValidationResult result = PackValidator.validate(pack); - assertFalse(result.isLoadable()); - assertTrue(result.getBlockingErrors().contains( - "Biome 'biome' declares 2 caveCeilingLayers but only 1 layers. caveCeilingLayers reuses the layers height generators and must not have more entries.")); + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java index 5a640bb7d..8015b323a 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java @@ -33,9 +33,9 @@ public class EngineRuntimePublicationContractTest { int publishEnd = source.indexOf("private void scheduleRuntimeTasks", publishStart); String publish = source.substring(publishStart, publishEnd); - assertBefore(publish, "engine.getClosing().set(true)", "closeRuntime(next, e)"); - assertBefore(publish, "closeBackgroundTaskAdmission()", "closeRuntime(next, e)"); - assertBefore(publish, "sealAndAwait(", "closeRuntime(next, e)"); + assertBefore(publish, "engine.getClosing().set(true)", "cleanupFailure = engine.shutdownSequence.closeRuntime(next, null)"); + assertBefore(publish, "closeBackgroundTaskAdmission()", "cleanupFailure = engine.shutdownSequence.closeRuntime(next, null)"); + assertBefore(publish, "sealAndAwait(", "cleanupFailure = engine.shutdownSequence.closeRuntime(next, null)"); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java new file mode 100644 index 000000000..4a5b58410 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java @@ -0,0 +1,635 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedComponent; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.history.GenerationKernelRegistry; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationEpoch; +import art.arcane.iris.engine.history.GenerationEpochContractFactory; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.mockito.InOrder; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class EngineShutdownDrainTest { + private static IrisPlatform previousPlatform; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class)); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void successfulEngineCloseAllowsTheSameWorldToPrepareGenerationAgain() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + assertThrows(IllegalStateException.class, () -> history.prepareCurrentGenerator(32)); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertTrue(fixture.engine.closed); + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + try (GenerationHistory.GenerationStage ignored = reopened.openStage(3, 4)) { + } + } + + @Test + public void failedEngineCloseKeepsAdmissionUntilSuccessfulRetry() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + doThrow(new IllegalStateException("Planner still active")).when(fixture.complex).close(); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + assertThrows(IllegalStateException.class, fixture.shutdown::close); + GenerationHistory premature = GenerationHistory.open(history.paths().dimensionRoot()); + assertThrows(IllegalStateException.class, () -> premature.prepareCurrentGenerator(32)); + doNothing().when(fixture.complex).close(); + fixture.shutdown.close(); + } + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + } + + @Test + public void startupOwnershipSpansReplacementEnginesUsingTheSameHistory() throws Exception { + GenerationHistory history = history(); + ShutdownFixture first = new ShutdownFixture(); + ShutdownFixture replacement = new ShutdownFixture(); + try (GenerationAdmission.RuntimeLease startup = history.retainRuntime(); + MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + first.attachHistory(history); + first.shutdown.close(); + history.prepareCurrentGenerator(32); + replacement.attachHistory(history); + } + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + replacement.shutdown.close(); + } + GenerationHistory.open(history.paths().dimensionRoot()).prepareCurrentGenerator(32); + } + + @Test + public void reopenedWorldPromotesPendingGenerationAfterTheOldEngineCloses() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + Path replacementPack = temporaryFolder.newFolder("replacement-pack").toPath(); + Files.createDirectories(replacementPack.resolve("dimensions")); + Files.writeString(replacementPack.resolve("dimensions/main.json"), "{\"name\":\"replacement\"}"); + history.stageUpdate(replacementPack, + GenerationPackFingerprint.compute(replacementPack, GenerationPackFingerprint.CURRENT_VERSION), + history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 32); + assertThrows(IllegalStateException.class, () -> history.prepareCurrentGenerator(32)); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + assertEquals(2L, reopened.activeActivation().activationId()); + assertFalse(reopened.pendingActivation().isPresent()); + } + + private GenerationHistory history() throws Exception { + Path world = temporaryFolder.newFolder("history-world").toPath(); + Path pack = temporaryFolder.newFolder("history-pack").toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), "{}"); + GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract( + "overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127, + -64, 384, 384, 1D, false, "none", 0, "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64)); + return GenerationHistory.create(world, pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + 42L, contract, GenerationRegistryContract.empty()); + } + + @Test + public void failedPlannerDrainRetainsRuntimeStorage() { + GenerationRuntime runtime = mock(GenerationRuntime.class); + IrisComplex complex = mock(IrisComplex.class); + EngineMantle mantle = mock(EngineMantle.class); + CompletableFuture hash = new CompletableFuture<>(); + when(runtime.mode()).thenReturn(mock(EngineMode.class)); + when(runtime.complex()).thenReturn(complex); + when(runtime.mantle()).thenReturn(mantle); + when(runtime.hash32()).thenReturn(hash); + IllegalStateException failure = new IllegalStateException("Planner still active"); + doThrow(failure).when(complex).close(); + + assertSame(failure, new EngineShutdownSequence(null).closeGenerationRuntime(runtime, null)); + verifyNoInteractions(mantle); + assertFalse(hash.isCancelled()); + } + + @Test + public void successfulPlannerDrainPrecedesMantleRelease() { + GenerationRuntime runtime = mock(GenerationRuntime.class); + IrisComplex complex = mock(IrisComplex.class); + EngineMantle mantle = mock(EngineMantle.class); + CompletableFuture hash = new CompletableFuture<>(); + when(runtime.mode()).thenReturn(mock(EngineMode.class)); + when(runtime.complex()).thenReturn(complex); + when(runtime.mantle()).thenReturn(mantle); + when(runtime.hash32()).thenReturn(hash); + + assertNull(new EngineShutdownSequence(null).closeGenerationRuntime(runtime, null)); + InOrder releases = inOrder(complex, mantle); + releases.verify(complex).close(); + releases.verify(mantle).saveAllNow(); + releases.verify(mantle).close(); + assertTrue(hash.isCancelled()); + } + + @Test + public void failedAssemblyDrainRetainsMantleOwnership() { + RuntimeAssembly assembly = mock(RuntimeAssembly.class); + assembly.complex = mock(IrisComplex.class); + assembly.mantle = mock(EngineMantle.class); + assembly.ownsMantle = true; + assembly.hash32 = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("Planner still active"); + doThrow(failure).when(assembly.complex).close(); + + assertSame(failure, new EngineShutdownSequence(null).closeAssembly(assembly, null)); + verifyNoInteractions(assembly.mantle); + assertTrue(assembly.ownsMantle); + assertFalse(assembly.hash32.isCancelled()); + } + @Test + public void failedAssemblySaveKeepsOwnershipAndDoesNotCloseStorage() { + RuntimeAssembly assembly = mock(RuntimeAssembly.class); + assembly.mantle = mock(EngineMantle.class); + assembly.ownsMantle = true; + IllegalStateException failure = new IllegalStateException("Save failed"); + doThrow(failure).doNothing().when(assembly.mantle).saveAllNow(); + EngineShutdownSequence shutdown = new EngineShutdownSequence(null); + + assertSame(failure, shutdown.closeAssembly(assembly, null)); + assertTrue(assembly.ownsMantle); + verify(assembly.mantle, never()).close(); + assertNull(shutdown.closeAssembly(assembly, null)); + assertFalse(assembly.ownsMantle); + assertNull(shutdown.closeAssembly(assembly, null)); + verify(assembly.mantle, times(2)).saveAllNow(); + verify(assembly.mantle).close(); + } + + @Test + public void failedAssemblyCloseKeepsOwnershipUntilRetrySucceeds() { + RuntimeAssembly assembly = mock(RuntimeAssembly.class); + assembly.mantle = mock(EngineMantle.class); + assembly.ownsMantle = true; + IllegalStateException failure = new IllegalStateException("Close failed"); + doThrow(failure).doNothing().when(assembly.mantle).close(); + EngineShutdownSequence shutdown = new EngineShutdownSequence(null); + + assertSame(failure, shutdown.closeAssembly(assembly, null)); + assertTrue(assembly.ownsMantle); + assertNull(shutdown.closeAssembly(assembly, null)); + assertFalse(assembly.ownsMantle); + verify(assembly.mantle, times(2)).saveAllNow(); + verify(assembly.mantle, times(2)).close(); + } + + @Test + public void failedPartialAssemblyRetainsDataAndRetriesInsideItsAssemblyScope() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + RuntimeAssembly assembly = mock(RuntimeAssembly.class); + EngineTarget detachedTarget = target(); + setField(assembly, "target", detachedTarget); + assembly.complex = mock(IrisComplex.class); + assembly.mantle = mock(EngineMantle.class); + assembly.ownsMantle = true; + RuntimeAssembly previous = mock(RuntimeAssembly.class); + fixture.engine.runtimeAssembly.set(previous); + IllegalStateException failure = new IllegalStateException("Planner still active"); + doAnswer(invocation -> { + assertSame(assembly, fixture.engine.runtimeAssembly.get()); + throw failure; + }).when(assembly.complex).close(); + + assertSame(failure, fixture.shutdown.closeAssembly(assembly, null)); + assertSame(previous, fixture.engine.runtimeAssembly.get()); + fixture.engine.runtimeAssembly.remove(); + assertTrue(fixture.shutdown.retainsData(detachedTarget.getData())); + assertTrue(fixture.shutdown.closeDetachedTarget(detachedTarget, null) instanceof IllegalStateException); + verify(detachedTarget, never()).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + assertThrows(IllegalStateException.class, fixture.shutdown::close); + verify(fixture.target, never()).close(); + verifyNoInteractions(assembly.mantle); + doAnswer(invocation -> { + assertSame(assembly, fixture.engine.runtimeAssembly.get()); + return null; + }).when(assembly.complex).close(); + fixture.shutdown.close(); + } + + assertNull(fixture.engine.runtimeAssembly.get()); + assertFalse(assembly.ownsMantle); + assertFalse(fixture.shutdown.retainsData(detachedTarget.getData())); + verify(assembly.mantle).close(); + verify(detachedTarget).close(); + verify(detachedTarget.getData()).close(); + assertTrue(fixture.engine.closed); + assertNull(fixture.engine.runtime); + } + + @Test + public void failedManagerStartKeepsPublishedRuntimeWhenCleanupFails() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + fixture.engine.runtime = null; + IllegalStateException startFailure = new IllegalStateException("Manager start failed"); + IllegalStateException closeFailure = new IllegalStateException("Planner still active"); + doThrow(startFailure).when(fixture.manager).start(); + doThrow(closeFailure).when(fixture.complex).close(); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new EngineRuntimeBuilder(fixture.engine).publishRuntime(fixture.runtime, null)); + + assertSame(startFailure, failure.getCause()); + assertSame(closeFailure, startFailure.getSuppressed()[0]); + assertSame(fixture.runtime, fixture.engine.runtime); + assertTrue(fixture.shutdown.retainsData(fixture.target.getData())); + verify(fixture.mantle, never()).close(); + doNothing().when(fixture.complex).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertNull(fixture.engine.runtime); + assertTrue(fixture.engine.closed); + verify(fixture.target).close(); + } + + @Test + public void failedStartDrainRetainsResourcesUntilGenerationStops() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + fixture.engine.runtime = null; + doThrow(new IllegalStateException("Manager start failed")).when(fixture.manager).start(); + doThrow(new GenerationSessionException("Generation still active", false)).when(fixture.sessions) + .sealAndAwait("failed world manager start", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); + + assertThrows(IllegalStateException.class, + () -> new EngineRuntimeBuilder(fixture.engine).publishRuntime(fixture.runtime, null)); + + assertSame(fixture.runtime, fixture.engine.runtime); + verify(fixture.manager, never()).close(); + verifyNoInteractions(fixture.complex, fixture.mantle); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertNull(fixture.engine.runtime); + assertTrue(fixture.engine.closed); + } + + @Test + public void failedRetirementRetainsUnpublishedRuntimeWithoutClosingSharedMantle() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + EngineTarget nextTarget = target(); + GenerationRuntime nextGeneration = generation(nextTarget, fixture.mantle); + EngineRuntime next = new EngineRuntime(nextGeneration, + new ChangingEffects(fixture.engine), mock(EngineWorldManager.class)); + EngineMode nextMode = nextGeneration.mode(); + doThrow(new IllegalStateException("Previous planner active")).when(fixture.complex).close(); + doThrow(new IllegalStateException("Next mode active")).when(nextMode).close(); + + assertThrows(IllegalStateException.class, + () -> new EngineRuntimeBuilder(fixture.engine).publishRuntime(next, fixture.runtime)); + + assertSame(fixture.runtime, fixture.engine.runtime); + assertTrue(fixture.shutdown.retainsData(nextTarget.getData())); + verifyNoInteractions(fixture.mantle); + doNothing().when(fixture.complex).close(); + doNothing().when(nextMode).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertFalse(fixture.shutdown.retainsData(nextTarget.getData())); + verify(nextTarget).close(); + verify(fixture.target).close(); + verify(fixture.mantle).saveAllNow(); + verify(fixture.mantle).close(); + assertTrue(fixture.engine.closed); + } + + @Test + public void failedConstructionDrainPreventsAllRuntimeAndTargetRelease() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + doThrow(new GenerationSessionException("Generation still active", false)).doNothing().when(fixture.sessions) + .sealAndAwait("failed initialization", 0L, true); + IllegalStateException original = new IllegalStateException("Construction failed"); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.cleanupFailedConstruction(original); + assertSame(fixture.runtime, fixture.engine.runtime); + assertFalse(fixture.engine.closed); + verifyNoInteractions(fixture.complex, fixture.mantle); + verify(fixture.target, never()).close(); + fixture.shutdown.cleanupFailedConstruction(original); + } + assertNull(fixture.engine.runtime); + assertTrue(fixture.engine.closed); + verify(fixture.mantle).close(); + verify(fixture.target).close(); + } + + @Test + public void failedComplexRetirementRetainsReplacementBeforeSharedStorageCanClose() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + IrisComplex replacement = mock(IrisComplex.class); + fixture.prepareComplexHotload(replacement); + doThrow(new IllegalStateException("Previous planner still active")).when(fixture.complex).close(); + doThrow(new IllegalStateException("Replacement planner still active")).when(replacement).close(); + + assertThrows(IllegalStateException.class, () -> new EngineHotloader(fixture.engine).hotloadComplex()); + + assertSame(fixture.runtime, fixture.engine.runtime); + assertSame(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState); + doNothing().when(fixture.complex).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + assertThrows(IllegalStateException.class, fixture.shutdown::close); + verify(replacement, times(2)).close(); + verify(fixture.mantle, never()).close(); + verify(fixture.target, never()).close(); + doNothing().when(replacement).close(); + fixture.shutdown.close(); + } + assertTrue(fixture.engine.closed); + verify(replacement, times(3)).close(); + verify(fixture.mantle).close(); + } + + @Test + public void failedComplexBuildDrainBlocksRollbackUntilShutdownRetry() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + IrisComplex replacement = mock(IrisComplex.class); + GenerationKernelRegistry.RuntimeKernel kernel = fixture.prepareComplexHotload(replacement); + IllegalStateException buildFailure = new IllegalStateException("Dimension stack failed"); + when(kernel.createDimensionStackContext(fixture.engine)).thenThrow(buildFailure); + IllegalStateException drainFailure = new IllegalStateException("Replacement planner still active"); + doThrow(drainFailure).when(replacement).close(); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new EngineHotloader(fixture.engine).hotloadComplex()); + + assertSame(buildFailure, failure.getCause()); + assertSame(drainFailure, buildFailure.getSuppressed()[0]); + assertSame(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState); + assertSame(fixture.runtime, fixture.engine.runtime); + verify(fixture.sessions, never()).activateNextSession(); + verify(fixture.complex, never()).close(); + doNothing().when(replacement).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertTrue(fixture.engine.closed); + verify(replacement, times(2)).close(); + verify(fixture.mantle).close(); + } + + @Test + public void successfullyDrainedFailedComplexBuildRestoresPreviousRuntime() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + IrisComplex replacement = mock(IrisComplex.class); + GenerationKernelRegistry.RuntimeKernel kernel = fixture.prepareComplexHotload(replacement); + when(kernel.createDimensionStackContext(fixture.engine)) + .thenThrow(new IllegalStateException("Dimension stack failed")); + + assertThrows(IllegalStateException.class, () -> new EngineHotloader(fixture.engine).hotloadComplex()); + + assertSame(fixture.runtime, fixture.engine.runtime); + assertSame(IrisEngine.LifecycleState.RUNNING, fixture.engine.lifecycleState); + verify(replacement).close(); + verify(fixture.complex, never()).close(); + verify(fixture.mantle, never()).close(); + verify(fixture.sessions).activateNextSession(); + } + + @Test + public void successfulComplexHotloadPublishesReplacementWithoutClosingIt() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + IrisComplex replacement = mock(IrisComplex.class); + fixture.prepareComplexHotload(replacement); + + new EngineHotloader(fixture.engine).hotloadComplex(); + + assertSame(replacement, fixture.engine.runtime.generation().complex()); + assertSame(IrisEngine.LifecycleState.RUNNING, fixture.engine.lifecycleState); + verify(fixture.complex).close(); + verify(replacement, never()).close(); + verify(fixture.mantle, never()).close(); + } + + @Test + public void failedComplexActivationKeepsPublishedReplacementOwnedForShutdown() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + IrisComplex replacement = mock(IrisComplex.class); + fixture.prepareComplexHotload(replacement); + doThrow(new IllegalStateException("Session activation failed")).when(fixture.sessions).activateNextSession(); + + assertThrows(IllegalStateException.class, () -> new EngineHotloader(fixture.engine).hotloadComplex()); + + assertSame(replacement, fixture.engine.runtime.generation().complex()); + assertSame(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState); + assertTrue(fixture.engine.getClosing().get()); + verify(fixture.complex).close(); + verify(replacement, never()).close(); + try (MockedStatic ignored = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertTrue(fixture.engine.closed); + verify(replacement).close(); + } + + private static EngineTarget target() { + EngineTarget target = mock(EngineTarget.class); + when(target.getData()).thenReturn(mock(IrisData.class)); + when(target.getWorld()).thenReturn(mock(IrisWorld.class)); + return target; + } + + private static GenerationRuntime generation(EngineTarget target, EngineMantle mantle) { + GenerationRuntime generation = mock(GenerationRuntime.class); + IrisData data = target.getData(); + when(generation.target()).thenReturn(target); + when(generation.data()).thenReturn(data); + when(generation.mantle()).thenReturn(mantle); + when(generation.complex()).thenReturn(mock(IrisComplex.class)); + when(generation.mode()).thenReturn(mock(EngineMode.class)); + when(generation.hash32()).thenReturn(new CompletableFuture<>()); + return generation; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static final class ChangingEffects extends EngineAssignedComponent implements EngineEffects { + private int hash = 1; + + private ChangingEffects(Engine engine) { + super(engine, "changing effects"); + } + + @Override + public void updatePlayerMap() { + } + + @Override + public void tickRandomPlayer() { + } + + @Override + public void close() { + hash++; + } + + @Override + public int hashCode() { + return hash; + } + } + + private static final class ShutdownFixture { + private final IrisEngine engine = mock(IrisEngine.class); + private final EngineTarget target = target(); + private final EngineMantle mantle = mock(EngineMantle.class); + private final GenerationRuntime generation = generation(target, mantle); + private final IrisComplex complex = generation.complex(); + private final EngineWorldManager manager = mock(EngineWorldManager.class); + private final EngineRuntime runtime = new EngineRuntime(generation, mock(EngineEffects.class), manager); + private final GenerationSessionManager sessions = mock(GenerationSessionManager.class); + private final EngineShutdownSequence shutdown = new EngineShutdownSequence(engine); + + private GenerationKernelRegistry.RuntimeKernel prepareComplexHotload(IrisComplex replacement) throws Exception { + GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class); + when(kernel.createComplex(engine, null)).thenReturn(replacement); + when(generation.runtimeKernel()).thenReturn(kernel); + GenerationRuntime nextGeneration = generation(target, mantle); + when(nextGeneration.complex()).thenReturn(replacement); + when(generation.withComplex(anyInt(), same(replacement), isNull(), isNull(), any())) + .thenReturn(nextGeneration); + EngineRuntimeBuilder builder = spy(new EngineRuntimeBuilder(engine)); + doReturn(new GenerationRuntime.BiomeMaxes(0D, 0D, 0D)).when(builder).computeBiomeMaxes(); + setField(engine, "runtimeBuilder", builder); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty()); + return kernel; + } + + private void attachHistory(GenerationHistory history) throws Exception { + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.engine()).thenReturn(engine); + when(router.history()).thenReturn(history); + setField(engine, "generationHistoryRuntimeRouterLock", new Object()); + doCallRealMethod().when(engine).attachGenerationHistoryRuntimeRouter(router); + engine.attachGenerationHistoryRuntimeRouter(router); + } + + private ShutdownFixture() throws Exception { + EngineBackgroundTasks background = mock(EngineBackgroundTasks.class); + when(background.drainBackgroundTasks(anyString())) + .thenReturn(new EngineBackgroundTasks.BackgroundTaskDrain(null, true)); + setField(engine, "backgroundTasks", background); + setField(engine, "shutdownSequence", shutdown); + setField(engine, "engineDataStore", mock(EngineDataStore.class)); + setField(engine, "lifecycleLock", new Object()); + setField(engine, "runtimeAssembly", new ThreadLocal()); + setField(engine, "detachedGenerationRuntimes", ConcurrentHashMap.newKeySet()); + when(engine.getClosing()).thenReturn(new AtomicBoolean()); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class)); + when(engine.getMantle()).thenReturn(mantle); + IrisWorld world = target.getWorld(); + when(engine.getWorld()).thenReturn(world); + when(engine.beginShutdown()).thenReturn(true); + engine.runtime = runtime; + engine.publishedTarget = target; + } + } + +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java index 609887d86..bdadc9a7c 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java @@ -43,14 +43,14 @@ public class EngineShutdownOwnershipContractTest { String cleanup = source.substring(cleanupStart, cleanupEnd); int ownershipClose = cleanup.indexOf("NativeStructureOwnershipStore.close(engine)"); int ownershipGate = cleanup.indexOf("if (ownershipFailure == null)", ownershipClose); - int detachedRelease = cleanup.indexOf("closeDetachedGenerationRuntimes(cleanupFailure)", ownershipGate); - int runtimeRelease = cleanup.indexOf("closeRuntime(engine.runtime, cleanupFailure)", ownershipGate); + int runtimeRelease = cleanup.indexOf("releaseRuntime(cleanupFailure)", ownershipGate); + int targetRelease = cleanup.indexOf("releaseTarget(cleanupFailure)", runtimeRelease); int closedPublication = cleanup.indexOf("engine.closed = true", ownershipGate); assertTrue(ownershipClose >= 0); assertTrue(ownershipGate > ownershipClose); - assertTrue(detachedRelease > ownershipGate); - assertTrue(runtimeRelease > detachedRelease); + assertTrue(runtimeRelease > ownershipGate); + assertTrue(targetRelease > runtimeRelease); assertTrue(closedPublication > ownershipGate); } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java index 7c22081d0..4b7f3828a 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexImplodeParityTest.java @@ -13,8 +13,12 @@ import java.util.List; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; public class IrisComplexImplodeParityTest { private static Method childSelectionCreateMethod; @@ -48,6 +52,27 @@ public class IrisComplexImplodeParityTest { } } + @Test + public void extremeCountsKeepTheRareBiomeReachableAcrossTheIntIndexBoundary() { + IrisBiome rare = createBiome(Integer.MAX_VALUE); + IrisBiome common = createBiome(1); + CNG generator = mock(CNG.class); + doReturn(1_073_741_824).when(generator).fit2D(0, Integer.MAX_VALUE, 12D, -32D); + IrisComplex.ChildSelectionPlan pair = IrisComplex.ChildSelectionPlan.create(new KList<>(rare, common)); + + assertSame(rare, pair.select(generator, 12D, -32D)); + verify(generator).fit2D(0, Integer.MAX_VALUE, 12D, -32D); + verify(generator, never()).noiseFast2D(anyDouble(), anyDouble()); + + CNG largeGenerator = mock(CNG.class); + doReturn(0.5D).when(largeGenerator).noiseFast2D(12D, -32D); + IrisComplex.ChildSelectionPlan large = IrisComplex.ChildSelectionPlan.create( + new KList<>(rare, common, createBiome(2))); + assertSame(rare, large.select(largeGenerator, 12D, -32D)); + verify(largeGenerator).noiseFast2D(12D, -32D); + verify(largeGenerator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble()); + } + @Test public void emptySelectionPlanMatchesLegacyEmptyBehavior() throws Exception { KList options = new KList<>(); 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 6f185ee00..c4f3c86da 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -18,6 +18,8 @@ import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.framework.IrisEngineMode; import art.arcane.iris.engine.framework.NativeStructureVolumeMemo; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisBiome; @@ -515,9 +517,7 @@ public class IrisEngineGenerationRuntimeScopeTest { public void attachedHistoryRouterIsClearedWhenEngineShutdownBegins() 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); - when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); - when(router.engine()).thenReturn(engine); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); assertTrue(engine.beginShutdown()); @@ -531,9 +531,7 @@ public class IrisEngineGenerationRuntimeScopeTest { public void attachedHistoryRejectsGenerationRuntimeHotload() 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); - when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); - when(router.engine()).thenReturn(engine); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); IllegalStateException failure = assertThrows(IllegalStateException.class, engine::hotloadComplex); @@ -548,9 +546,7 @@ public class IrisEngineGenerationRuntimeScopeTest { RuntimeFixture active = runtime(1, 1D, 1D, 1D); 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); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); engine.detachGenerationHistoryRuntimeRouter(router); @@ -710,6 +706,16 @@ public class IrisEngineGenerationRuntimeScopeTest { assertSame(active.dimension, engine.getDimension()); } + private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) { + GenerationHistory history = mock(GenerationHistory.class); + when(history.retainRuntime()).thenReturn(mock(GenerationAdmission.RuntimeLease.class)); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); + when(router.engine()).thenReturn(engine); + when(router.history()).thenReturn(history); + return router; + } + private static IrisEngine engine( GenerationRuntime generationRuntime, EngineEffects effects, diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerShutdownTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerShutdownTest.java new file mode 100644 index 000000000..fe7854ea3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerShutdownTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.volmlib.util.scheduling.Looper; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisWorldManagerShutdownTest { + @Test + public void liveLooperPreventsSuccessfulCloseAndRemainsRetryable() throws Exception { + IrisWorldManager manager = manager(); + HeldLooper looper = new HeldLooper(); + setField(manager, "looper", looper); + looper.start(); + try { + assertTrue(looper.started.await(5L, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, manager::close); + assertFalse(manager.isLooperStopped()); + assertTrue(looper.isAlive()); + assertTrue(manager.isCleanupServiceStopped()); + } finally { + looper.release.countDown(); + looper.join(5_000L); + manager.close(); + } + assertFalse(looper.isAlive()); + assertTrue(manager.isLooperStopped()); + manager.close(); + } + + @Test + public void cleanupTimeoutRemainsRetryableAndSuccessfulCloseIsIdempotent() throws Exception { + IrisWorldManager manager = manager(); + ScheduledThreadPoolExecutor executor = mock(ScheduledThreadPoolExecutor.class); + manager.getCleanupService().shutdownNow(); + setField(manager, "cleanupService", executor); + when(executor.awaitTermination(anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(false, true); + + assertThrows(IllegalStateException.class, manager::close); + assertFalse(manager.isCleanupServiceStopped()); + assertTrue(manager.isLooperStopped()); + + manager.close(); + assertTrue(manager.isCleanupServiceStopped()); + manager.close(); + verify(executor, times(2)).shutdownNow(); + verify(executor, times(2)).awaitTermination(anyLong(), eq(TimeUnit.MILLISECONDS)); + } + + @Test + public void interruptedCleanupWaitRestoresInterruptAndCanBeRetried() throws Exception { + IrisWorldManager manager = manager(); + ScheduledThreadPoolExecutor executor = mock(ScheduledThreadPoolExecutor.class); + manager.getCleanupService().shutdownNow(); + setField(manager, "cleanupService", executor); + when(executor.awaitTermination(anyLong(), eq(TimeUnit.MILLISECONDS))) + .thenThrow(new InterruptedException("Close interrupted")) + .thenReturn(true); + try { + assertThrows(IllegalStateException.class, manager::close); + assertTrue(Thread.currentThread().isInterrupted()); + assertFalse(manager.isCleanupServiceStopped()); + } finally { + Thread.interrupted(); + manager.close(); + } + assertTrue(manager.isCleanupServiceStopped()); + verify(executor, times(2)).awaitTermination(anyLong(), eq(TimeUnit.MILLISECONDS)); + } + + private static IrisWorldManager manager() { + Engine engine = mock(Engine.class); + EngineTarget target = mock(EngineTarget.class); + IrisWorld world = mock(IrisWorld.class); + when(world.name()).thenReturn("shutdown-test"); + when(target.getWorld()).thenReturn(world); + when(engine.getTarget()).thenReturn(target); + return new IrisWorldManager(engine); + } + + private static void setField(IrisWorldManager manager, String name, Object value) throws Exception { + Field field = IrisWorldManager.class.getDeclaredField(name); + field.setAccessible(true); + field.set(manager, value); + } + + private static final class HeldLooper extends Looper { + private final CountDownLatch started = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + + @Override + protected void onStart() { + } + + @Override + protected void onStop() { + } + + @Override + protected long loop() { + started.countDown(); + while (true) { + try { + release.await(); + return -1L; + } catch (InterruptedException ignored) { + } + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisSeaDecoratorBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisSeaDecoratorBoundsTest.java new file mode 100644 index 000000000..064489641 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisSeaDecoratorBoundsTest.java @@ -0,0 +1,147 @@ +package art.arcane.iris.engine.decorator; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDecorationPart; +import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisSeaDecoratorBoundsTest { + @Test + public void floorPercentageCannotGrowAboveTheWaterColumn() { + Fixture fixture = new Fixture(12, false); + fixture.stacking(200, 30); + + fixture.decorate(4, 8); + + assertSame(fixture.body, fixture.output.get(0, 4, 0)); + assertSame(fixture.top, fixture.output.get(0, 7, 0)); + assertNull(fixture.output.get(0, 8, 0)); + } + + @Test + public void floorPercentageRespectsAbsoluteMaximum() { + Fixture fixture = new Fixture(64, false); + fixture.stacking(100, 3); + + fixture.decorate(4, 60); + + assertSame(fixture.body, fixture.output.get(0, 4, 0)); + assertSame(fixture.top, fixture.output.get(0, 6, 0)); + assertNull(fixture.output.get(0, 7, 0)); + } + + @Test + public void surfacePercentageRespectsAbsoluteMaximum() { + Fixture fixture = new Fixture(64, true); + fixture.stacking(100, 3); + + fixture.decorate(4, 64); + + assertSame(fixture.body, fixture.output.get(0, 5, 0)); + assertSame(fixture.top, fixture.output.get(0, 7, 0)); + assertNull(fixture.output.get(0, 8, 0)); + } + + @Test + public void floorStackAtOutputCeilingUsesOneTopBlock() { + Fixture fixture = new Fixture(8, false); + fixture.stacking(200, 30); + + fixture.decorate(7, 20); + + assertSame(fixture.top, fixture.output.get(0, 7, 0)); + } + + @Test + public void unresolvedFloorPalettePreservesExistingWater() { + Fixture fixture = new Fixture(8, false); + fixture.stacking(100, 30); + PlatformBlockState water = mock(PlatformBlockState.class); + fixture.output.set(0, 3, 0, water); + when(fixture.decorator.getBlockData100(any(), any(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(null); + + fixture.decorate(3, 8); + + assertSame(water, fixture.output.get(0, 3, 0)); + assertNull(fixture.output.get(0, 4, 0)); + } + + @Test + public void surfaceStackUsesTheShorterOutputHeight() { + Fixture fixture = new Fixture(8, true); + when(fixture.engine.getHeight()).thenReturn(64); + fixture.stacking(200, 30); + + fixture.decorate(6, 64); + + assertSame(fixture.top, fixture.output.get(0, 7, 0)); + } + + @Test + public void surfaceSingleRejectsOutputCeiling() { + Fixture fixture = new Fixture(8, true); + when(fixture.engine.getHeight()).thenReturn(64); + + fixture.decorate(7, 64); + + assertNull(fixture.output.get(0, 7, 0)); + } + + @Test + public void surfaceSingleStackRejectsNegativeBaseAndEngineCeiling() { + Fixture fixture = new Fixture(16, true); + when(fixture.engine.getHeight()).thenReturn(8); + fixture.stacking(1, 30); + when(fixture.decorator.isScaleStack()).thenReturn(false); + + fixture.decorate(-1, 16); + fixture.decorate(7, 16); + + assertNull(fixture.output.get(0, 0, 0)); + assertNull(fixture.output.get(0, 8, 0)); + } + + private static final class Fixture { + private final IrisDecorator decorator = mock(IrisDecorator.class); + private final IrisBiome biome = mock(IrisBiome.class); + private final PlatformBlockState body = mock(PlatformBlockState.class); + private final PlatformBlockState top = mock(PlatformBlockState.class); + private final Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + private final Hunk output; + private final IrisEngineDecorator placement; + + private Fixture(int height, boolean surface) { + when(engine.getHeight()).thenReturn(height); + output = Hunk.newArrayHunk(1, height, 1); + IrisDecorationPart part = surface ? IrisDecorationPart.SEA_SURFACE : IrisDecorationPart.SEA_FLOOR; + when(biome.getDecoratorBucket(part)).thenReturn(new IrisDecorator[]{decorator}); + when(decorator.passesChanceGate(any(), anyDouble(), anyDouble(), any())).thenReturn(true); + when(decorator.getBlockData100(any(), any(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(body); + when(decorator.getBlockDataForTop(any(), any(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(top); + when(decorator.getTopThreshold()).thenReturn(1D); + placement = surface ? new IrisSeaSurfaceDecorator(engine) : new IrisSeaFloorDecorator(engine); + } + + private void stacking(int percentage, int cap) { + when(decorator.isStacking()).thenReturn(true); + when(decorator.isScaleStack()).thenReturn(true); + when(decorator.getHeight(any(), anyDouble(), anyDouble(), any())).thenReturn(percentage); + when(decorator.getAbsoluteMaxStack()).thenReturn(cap); + } + + private void decorate(int height, int maximum) { + placement.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, height, maximum); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisEngineModeLifecycleTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisEngineModeLifecycleTest.java new file mode 100644 index 000000000..81f5f7c4c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisEngineModeLifecycleTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class IrisEngineModeLifecycleTest { + @Test + public void failedContentStageRemainsRetryable() { + IrisEngineMode mode = new IrisEngineMode(mock(Engine.class)) { + }; + EngineStage content = mock(EngineStage.class); + EngineStage terrain = mock(EngineStage.class); + mode.registerStage(content); + mode.registerTerrainStage(terrain); + IllegalStateException failure = new IllegalStateException("Stage close failed"); + doThrow(failure).doNothing().when(content).close(); + + assertSame(failure, assertThrows(IllegalStateException.class, mode::close)); + verifyNoInteractions(terrain); + mode.close(); + mode.close(); + + assertTrue(mode.getStages().isEmpty()); + assertTrue(mode.getTerrainStages().isEmpty()); + verify(content, times(2)).close(); + verify(terrain).close(); + } + + @Test + public void failedTerrainStageDoesNotRepeatReleasedContent() { + IrisEngineMode mode = new IrisEngineMode(mock(Engine.class)) { + }; + EngineStage content = mock(EngineStage.class); + EngineStage terrain = mock(EngineStage.class); + mode.registerStage(content); + mode.registerTerrainStage(terrain); + IllegalStateException failure = new IllegalStateException("Terrain stage close failed"); + doThrow(failure).doNothing().when(terrain).close(); + + assertSame(failure, assertThrows(IllegalStateException.class, mode::close)); + assertTrue(mode.getStages().isEmpty()); + mode.close(); + mode.close(); + + assertTrue(mode.getTerrainStages().isEmpty()); + verify(content).close(); + verify(terrain, times(2)).close(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java index 86ad178ea..d01046953 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java @@ -4,6 +4,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.nio.file.Path; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.FutureTask; @@ -103,6 +104,99 @@ public class GenerationAdmissionTest { assertThrows(IllegalStateException.class, admission::beginStartupCutover); } + @Test + public void lastRuntimeReleaseAllowsStartupOnAFreshAdmissionOnly() { + Path root = temporaryFolder.getRoot().toPath(); + GenerationAdmission admission = new GenerationAdmission(root); + GenerationAdmission stale = new GenerationAdmission(root); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + try (GenerationAdmission.StageLease ignored = admission.enterStage()) { + } + runtime.close(); + GenerationAdmission reopened = new GenerationAdmission(root); + try (GenerationAdmission.CutoverLease ignored = reopened.beginStartupCutover()) { + } + assertThrows(IllegalStateException.class, stale::enterStage); + assertThrows(IllegalStateException.class, stale::beginCutover); + assertThrows(IllegalStateException.class, stale::retainRuntime); + runtime.close(); + try (GenerationAdmission.StageLease ignored = reopened.enterStage()) { + } + } + + @Test + public void everyRuntimeMustReleaseBeforeAdmissionCanRetire() { + Path root = temporaryFolder.getRoot().toPath(); + GenerationAdmission first = new GenerationAdmission(root); + GenerationAdmission second = new GenerationAdmission(root); + GenerationAdmission.RuntimeLease firstRuntime = first.retainRuntime(); + GenerationAdmission.RuntimeLease secondRuntime = second.retainRuntime(); + try (GenerationAdmission.StageLease ignored = first.enterStage()) { + } + firstRuntime.close(); + assertThrows(IllegalStateException.class, new GenerationAdmission(root)::beginStartupCutover); + try (GenerationAdmission.StageLease ignored = second.enterStage()) { + } + secondRuntime.close(); + try (GenerationAdmission.CutoverLease ignored = new GenerationAdmission(root).beginStartupCutover()) { + } + } + + @Test + public void failedRetirementRetainsTheLeaseUntilActiveStagesFinish() { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.StageLease stage = admission.enterStage(); + assertThrows(IllegalStateException.class, runtime::close); + assertThrows(IllegalStateException.class, admission::beginStartupCutover); + stage.close(); + runtime.close(); + assertThrows(IllegalStateException.class, admission::enterStage); + } + + @Test + public void failedRetirementRetainsTheLeaseUntilActiveCutoversFinish() { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.CutoverLease cutover = admission.beginCutover(); + assertThrows(IllegalStateException.class, runtime::close); + cutover.close(); + runtime.close(); + assertThrows(IllegalStateException.class, admission::beginCutover); + } + + @Test + public void waitingCutoversKeepAdmissionOwnedUntilTheirWorkCompletes() throws Exception { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.StageLease stage = admission.enterStage(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + FutureTask cutover = new FutureTask<>(() -> { + try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) { + entered.countDown(); + await(release); + } + }, null); + Thread thread = Thread.ofPlatform().daemon().unstarted(cutover); + try { + thread.start(); + awaitWaiting(thread); + assertThrows(IllegalStateException.class, runtime::close); + stage.close(); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, runtime::close); + release.countDown(); + cutover.get(5L, TimeUnit.SECONDS); + runtime.close(); + assertThrows(IllegalStateException.class, admission::beginCutover); + } finally { + stage.close(); + release.countDown(); + thread.join(5_000L); + } + } + private static void awaitWaiting(Thread thread) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); while (thread.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java new file mode 100644 index 000000000..c13096af0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class HydrologyRouteLengthsTest { + @Test + public void branchingRoutesMatchIndependentDownstreamWalks() { + Random random = new Random(912541L); + for (int width : new int[]{2, 3, 9, 17, 33, 65}) { + for (int spacing : new int[]{4, 16, 64}) { + for (int maximumLength : new int[]{63, 128, 257, 4096}) { + int[] parent = forest(width, random); + HydrologyPlannerSettings.Routing routing = routing(spacing, maximumLength); + assertArrayEquals(walks(parent, width, routing), + HydrologyPlanner.routeLengths(parent, width, routing)); + reverse(parent); + assertArrayEquals(walks(parent, width, routing), + HydrologyPlanner.routeLengths(parent, width, routing)); + } + } + } + } + + @Test + public void exactLengthLimitAndMissingOutletsKeepTheirBounds() { + int[] parent = {1, 2, 3, 4, -1, -1, -1, -1, -1}; + assertArrayEquals(new int[]{Integer.MAX_VALUE, 12, 8, 4, 0, 0, 0, 0, 0}, + HydrologyPlanner.routeLengths(parent, 9, routing(4, 12))); + } + + @Test + public void longRoutesDoNotUseTheCallStack() { + int width = 257; + int[] parent = new int[width * width]; + for (int row = 0; row < width; row++) { + for (int column = 0; column < width; column++) { + int index = row * width + column; + int direction = row % 2 == 0 ? 1 : -1; + int nextColumn = column + direction; + parent[index] = nextColumn >= 0 && nextColumn < width ? index + direction : index + width; + } + } + parent[parent.length - 1] = -1; + int[] lengths = HydrologyPlanner.routeLengths(parent, width, routing(4, Integer.MAX_VALUE)); + assertEquals((parent.length - 1) * 4, lengths[0]); + assertEquals(0, lengths[lengths.length - 1]); + reverse(parent); + lengths = HydrologyPlanner.routeLengths(parent, width, routing(4, Integer.MAX_VALUE)); + assertEquals((parent.length - 1) * 4, lengths[lengths.length - 1]); + assertEquals(0, lengths[0]); + } + + @Test + public void cyclesFailBeforeTheyCanStallPlanning() { + assertThrows(IllegalStateException.class, + () -> HydrologyPlanner.routeLengths(new int[]{1, 0}, 2, routing(4, 128))); + } + + private static int[] forest(int width, Random random) { + int[] parent = new int[width * width]; + Arrays.fill(parent, -1); + int[] candidates = new int[4]; + for (int index = 1; index < parent.length; index++) { + int x = index % width; + int z = index / width; + int count = 0; + if (x > 0) { + candidates[count++] = index - 1; + } + if (z > 0) { + candidates[count++] = index - width; + if (x > 0) { + candidates[count++] = index - width - 1; + } + if (x + 1 < width) { + candidates[count++] = index - width + 1; + } + } + if (random.nextInt(16) != 0) { + parent[index] = candidates[random.nextInt(count)]; + } + } + return parent; + } + + private static void reverse(int[] parent) { + int[] original = parent.clone(); + for (int index = 0; index < parent.length; index++) { + int next = original[parent.length - 1 - index]; + parent[index] = next < 0 ? -1 : parent.length - 1 - next; + } + } + + private static int[] walks(int[] parent, int width, HydrologyPlannerSettings.Routing routing) { + int[] lengths = new int[parent.length]; + for (int source = 0; source < parent.length; source++) { + double distance = 0D; + int steps = 0; + int current = source; + while (parent[current] >= 0) { + int next = parent[current]; + distance += StrictMath.hypot((current % width - next % width) * routing.sampleSpacing(), + (current / width - next / width) * routing.sampleSpacing()); + if (distance > routing.maximumRouteLength() || ++steps > routing.maximumRouteNodes()) { + distance = Integer.MAX_VALUE; + break; + } + current = next; + } + lengths[source] = (int) StrictMath.ceil(distance); + } + return lengths; + } + + private static HydrologyPlannerSettings.Routing routing(int spacing, int maximumLength) { + return new HydrologyPlannerSettings.Routing(spacing * 8, spacing, 1_000_000, maximumLength, + 0, 0, 1D, 1D, 1D, 1D, 1D, 0); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index 3110efe1a..d518b63eb 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -15,6 +15,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutionException; @@ -23,6 +24,7 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -43,6 +45,104 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Test + public void closeDrainsBackgroundPlanningAndRejectsNewDemand() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(planner.plan(key)).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return tile; + }); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + try { + Future result = callers.submit(() -> cache.get(key)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future closing = callers.submit(cache::close); + awaitClosed(cache); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + assertThrows(CancellationException.class, () -> cache.get(new HydrologyTileKey(1, 0))); + assertThrows(CancellationException.class, () -> cache.columnAt(0, 0)); + release.countDown(); + assertSame(tile, result.get(5, TimeUnit.SECONDS)); + closing.get(5, TimeUnit.SECONDS); + assertEquals(0, cache.size()); + verify(planner, never()).plan(new HydrologyTileKey(1, 0)); + } finally { + release.countDown(); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void closeWaitsForColumnCompositionBeforeReturning() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(tile.columnAt(anyInt(), anyInt())).thenAnswer(invocation -> { + if (invocation.getArgument(0) == 0 && invocation.getArgument(1) == 0) { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return Optional.empty(); + }); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future> result = callers.submit(() -> cache.columnAt(0, 0)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Future closing = callers.submit(cache::close); + awaitClosed(cache); + assertThrows(TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(result.get(5, TimeUnit.SECONDS).isEmpty()); + closing.get(5, TimeUnit.SECONDS); + assertEquals(0, cache.size()); + } finally { + release.countDown(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void closingFromAnOwnedLoadFailsWithoutDeadlockingOrClosingTheCache() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + when(planner.plan(key)).thenAnswer(invocation -> { + assertThrows(IllegalStateException.class, cache::close); + return tile; + }); + assertSame(tile, cache.get(key)); + assertSame(tile, cache.get(key)); + cache.close(); + assertThrows(CancellationException.class, () -> cache.get(key)); + } + + private static void awaitClosed(HydrologyTileCache cache) throws Exception { + Field field = HydrologyTileCache.class.getDeclaredField("closed"); + field.setAccessible(true); + AtomicBoolean closed = (AtomicBoolean) field.get(cache); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!closed.get() && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertTrue(closed.get()); + } + @Test public void explicitAreaIsFullyQueuedBeforeExecutorCallbacksCanAddOldWork() throws Exception { for (boolean pregeneration : new boolean[]{false, true}) { @@ -265,7 +365,7 @@ public class HydrologyTileCacheTest { } @Test - public void planCompletingAfterCloseDoesNotEnterTheSharedStudioCache() throws Exception { + public void closeWaitsForActivePlansWithoutPublishingTheirSharedTiles() throws Exception { HydrologyPlanner closingPlanner = mock(HydrologyPlanner.class); HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class); HydrologyTile lateTile = mock(HydrologyTile.class); @@ -287,13 +387,15 @@ public class HydrologyTileCacheTest { when(lateTile.tileSize()).thenReturn(64); HydrologyTileCache closing = new HydrologyTileCache(closingPlanner, 4, null, null, scope); HydrologyTileCache succeeding = new HydrologyTileCache(succeedingPlanner, 4, null, null, scope); - ExecutorService executor = Executors.newSingleThreadExecutor(); + ExecutorService executor = Executors.newFixedThreadPool(2); try { Future late = executor.submit(() -> closing.get(key)); assertTrue(planningStarted.await(10L, TimeUnit.SECONDS)); - closing.close(); + Future shutdown = executor.submit(closing::close); + assertThrows(TimeoutException.class, () -> shutdown.get(100L, TimeUnit.MILLISECONDS)); allowCompletion.countDown(); assertSame(lateTile, late.get(10L, TimeUnit.SECONDS)); + shutdown.get(10L, TimeUnit.SECONDS); assertEquals(0, closing.size()); assertSame(plannedTile, succeeding.get(key)); verify(succeedingPlanner, times(1)).plan(key); @@ -1451,7 +1553,14 @@ public class HydrologyTileCacheTest { freshPrefetch.run(); } oldDemand.run(); - assertSame(tile, result.get(5, TimeUnit.SECONDS)); + if (close) { + ExecutionException failure = assertThrows(ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException); + verify(planner, never()).plan(new HydrologyTileKey(1, 0)); + } else { + assertSame(tile, result.get(5, TimeUnit.SECONDS)); + } assertTrue(queued.isEmpty()); assertEquals(close ? 0 : 1, cache.size()); verify(planner, never()).plan(new HydrologyTileKey(2, 0)); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java new file mode 100644 index 000000000..4d1495f8a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RiverFootprintTest { + @Test + public void primitiveIndexKeepsSignedKeyOrderAndFullCoordinateRange() { + Random random = new Random(836412L); + HashMap input = new HashMap<>(); + for (int index = 0; index < 2_000; index++) { + HydrologyColumnSample column = column(random.nextInt(), random.nextInt()); + input.put(RiverFootprint.pack(column.x(), column.z()), column); + } + for (int x : new int[]{Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE}) { + for (int z : new int[]{Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE}) { + input.put(RiverFootprint.pack(x, z), column(x, z)); + } + } + TreeMap expected = new TreeMap<>(input); + RiverFootprint footprint = new RiverFootprint(input); + input.clear(); + assertEquals(new ArrayList<>(expected.keySet()), new ArrayList<>(footprint.columns().keySet())); + assertEquals(expected, footprint.columns()); + for (HydrologyColumnSample column : expected.values()) { + assertSame(column, footprint.sample(column.x(), column.z()).orElseThrow()); + } + } + + @Test + public void exposedEntriesCannotMutateTheLookupIndex() { + HydrologyColumnSample original = column(-5, 9); + RiverFootprint footprint = new RiverFootprint(Map.of(RiverFootprint.pack(-5, 9), original)); + Map.Entry entry = footprint.columns().entrySet().iterator().next(); + assertThrows(UnsupportedOperationException.class, () -> entry.setValue(column(-5, 9))); + assertThrows(UnsupportedOperationException.class, () -> footprint.columns().values().clear()); + assertSame(original, footprint.sample(-5, 9).orElseThrow()); + } + + @Test + public void areaQueriesKeepBothLookupPathsAndExclusiveBounds() { + HashMap input = new HashMap<>(); + for (int x = -8; x < 8; x++) { + for (int z = -8; z < 8; z++) { + input.put(RiverFootprint.pack(x, z), column(x, z)); + } + } + RiverFootprint footprint = new RiverFootprint(input); + assertEquals(List.of(column(-1, -1), column(0, -1), column(-1, 0), column(0, 0)), + footprint.columnsIn(-1, -1, 1, 1)); + assertEquals(new ArrayList<>(new TreeMap<>(input).values()), + footprint.columnsIn(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertTrue(footprint.columnsIn(0, 0, 0, 1).isEmpty()); + assertTrue(footprint.columnsIn(100, 100, 101, 101).isEmpty()); + } + + private static HydrologyColumnSample column(int x, int z) { + return new HydrologyColumnSample(x, z, 90, 63, false, "parent", List.of()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java index 6d887a849..16df4d316 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java @@ -21,9 +21,14 @@ import art.arcane.iris.util.common.parallel.MultiBurst; import java.util.List; import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.Future; +import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -41,6 +46,9 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; 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.when; public class MatterGeneratorConcurrencyTest { @@ -215,6 +223,167 @@ public class MatterGeneratorConcurrencyTest { assertFalse(fixture.mantle.getChunk(0, 0).isFlagged(MantleFlag.PLANNED)); } + @Test + public void dispatcherPropagatesAnAlreadyFailedSharedComponent() { + IllegalStateException failure = new IllegalStateException("Shared component failed"); + ExecutionException observed = sharedComponentFailure(failure, ExecutionException.class); + assertSame(failure, observed.getCause()); + } + + @Test + public void dispatcherPropagatesAnAlreadyCancelledSharedComponent() { + sharedComponentFailure(new CancellationException("Shared component cancelled"), CancellationException.class); + } + + @Test + public void timedOutRunningComponentRetainsWriterAndSharedClaimUntilItExits() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch writersAcquired = new CountDownLatch(2); + CountDownLatch cancelled = new CountDownLatch(1); + AtomicInteger runs = new AtomicInteger(); + MantleChunk chunk = fixture.mantle.getChunk(0, 0); + when(chunk.use()).thenAnswer(invocation -> { + writersAcquired.countDown(); + return chunk; + }); + RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 0) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + runs.incrementAndGet(); + started.countDown(); + await(release); + verify(chunk, never()).release(); + } + }; + TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 0, 0))); + CompletableFuture first = CompletableFuture.runAsync( + () -> generator.generateMatter(0, 0, true, fixture.context)); + await(started); + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = MatterGenerator.IN_FLIGHT_COMPONENTS.get(key); + Future submission = mock(Future.class); + when(submission.cancel(true)).thenAnswer(invocation -> { + cancelled.countDown(); + return true; + }); + task.setSubmission(submission); + CompletableFuture second = MultiBurst.burst.completeValueAsync(() -> { + generator.generateMatter(0, 0, true, fixture.context); + return null; + }); + CompletableFuture timeout = CompletableFuture.runAsync(() -> task.await(0L)); + try { + await(writersAcquired); + await(cancelled); + assertSame(task, MatterGenerator.IN_FLIGHT_COMPONENTS.get(key)); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + assertFalse(timeout.isDone()); + verify(chunk, never()).release(); + assertEquals(1, runs.get()); + } finally { + release.countDown(); + } + assertThrows(ExecutionException.class, () -> timeout.get(5L, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> first.get(5L, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> second.get(5L, TimeUnit.SECONDS)); + verify(chunk, times(2)).release(); + assertFalse(MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key)); + } + + @Test + public void queuedTimeoutPreventsLateExecutionAndAllowsANewClaim() { + GeneratorFixture fixture = new GeneratorFixture(); + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key); + MatterGenerator.IN_FLIGHT_COMPONENTS.put(key, task); + Future submission = mock(Future.class); + task.setSubmission(submission); + + CompletionException failure = assertThrows(CompletionException.class, () -> task.await(0L)); + + assertTrue(failure.getCause().getMessage().contains("did not complete")); + assertFalse(task.start()); + assertFalse(MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key)); + verify(submission).cancel(true); + MatterGenerator.MatterComponentTask replacement = new MatterGenerator.MatterComponentTask(key); + assertNull(MatterGenerator.IN_FLIGHT_COMPONENTS.putIfAbsent(key, replacement)); + task.finish(null); + assertSame(replacement, MatterGenerator.IN_FLIGHT_COMPONENTS.get(key)); + replacement.finish(null); + } + + @Test + public void droppedQueuedSubmissionCannotRunAfterTheBarrier() { + GeneratorFixture fixture = new GeneratorFixture(); + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key); + MatterGenerator.IN_FLIGHT_COMPONENTS.put(key, task); + Future submission = mock(Future.class); + when(submission.isDone()).thenReturn(true); + task.setSubmission(submission); + + CompletionException failure = assertThrows(CompletionException.class, + () -> task.await(MatterGenerator.COMPONENT_TASK_TIMEOUT_MS)); + + assertTrue(failure.getCause().getMessage().contains("was dropped")); + assertFalse(task.start()); + assertFalse(MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key)); + } + + @Test + public void interruptedWaiterDrainsRunningTaskAndRestoresInterrupt() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(); + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key); + assertTrue(task.start()); + CountDownLatch waiting = new CountDownLatch(1); + AtomicBoolean restored = new AtomicBoolean(); + CompletableFuture completion = new CompletableFuture<>(); + Thread waiter = new Thread(() -> { + try { + Thread.currentThread().interrupt(); + waiting.countDown(); + task.await(MatterGenerator.COMPONENT_TASK_TIMEOUT_MS); + restored.set(Thread.currentThread().isInterrupted()); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + waiter.start(); + await(waiting); + try { + assertFalse(completion.isDone()); + } finally { + task.finish(null); + } + completion.get(5L, TimeUnit.SECONDS); + waiter.join(5_000L); + assertTrue(restored.get()); + } + + private T sharedComponentFailure(Throwable failure, Class expectedFailure) { + GeneratorFixture fixture = new GeneratorFixture(true); + RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 0); + TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 0, 0))); + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(fixture.mantle, 0, 0, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key); + task.finish(failure); + MatterGenerator.IN_FLIGHT_COMPONENTS.put(key, task); + try { + CompletableFuture generation = MultiBurst.burst.completeValueAsync(() -> { + generator.generateMatter(0, 0, true, fixture.context); + return null; + }); + return assertThrows(expectedFailure, () -> generation.get(5L, TimeUnit.SECONDS)); + } finally { + MatterGenerator.IN_FLIGHT_COMPONENTS.remove(key, task); + } + } + private static void await(CountDownLatch latch) { try { assertTrue(latch.await(5, TimeUnit.SECONDS)); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 387e865cd..96cdf4aab 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -35,12 +35,15 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -114,6 +117,45 @@ public class IrisCaveCarver3DNearParityTest { carveForcedAirField.setAccessible(true); } + @Test + public void carvingReleasesSectionReferencesAfterSuccessAndFailure() throws Exception { + for (boolean fail : new boolean[]{false, true}) { + Engine engine = createEngine(128, 110); + IrisCaveProfile profile = createProfile(false, false).setAdaptiveSampling(false) + .setAllowFluid(false).setDetailWeight(0D) + .setDensityThreshold(new IrisStyledRange(2D, 2D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + WriterCapture capture = createWriterCapture(128); + if (fail) { + AtomicInteger samples = new AtomicInteger(); + CNG density = mock(CNG.class); + doAnswer(invocation -> { + if (samples.incrementAndGet() > 300) { + throw new IllegalStateException("Density sample failed"); + } + return -1D; + }).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + baseDensityField.set(carver, density); + assertThrows(IllegalStateException.class, () -> carver.carve(capture.writer, 0, 0, + fullWeights(), 0D, 0D, null, filledHeights(110))); + } else { + carver.carve(capture.writer, 0, 0, fullWeights(), 0D, 0D, null, filledHeights(110)); + } + assertFalse(capture.carvedCells.isEmpty()); + Field scratchField = IrisCaveCarver3D.class.getDeclaredField("scratchCache"); + scratchField.setAccessible(true); + ThreadLocal scratchCache = (ThreadLocal) scratchField.get(carver); + CaveCarveScratch scratch = (CaveCarveScratch) scratchCache.get(); + assertTrue(scratch.sectionMatter.length > 0); + for (Matter matter : scratch.sectionMatter) { + assertNull(matter); + } + for (MatterSlice slice : scratch.sectionSlices) { + assertNull(slice); + } + } + } + @Test public void parallelDensityPreservesExactAndAdaptiveCavernsAndLiquids() throws Exception { try (ForkJoinPool pool = new ForkJoinPool(4)) { diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentScratchTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentScratchTest.java new file mode 100644 index 000000000..4ce06a67b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentScratchTest.java @@ -0,0 +1,170 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisCaveProfile; +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.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleCarvingComponentScratchTest { + private MantleCarvingComponent component; + private IrisDimension dimension; + private ChunkContext context; + private MantleWriter writer; + + @Before + @SuppressWarnings("unchecked") + public void prepare() { + Engine engine = mock(Engine.class); + EngineMantle mantle = mock(EngineMantle.class); + when(mantle.getEngine()).thenReturn(engine); + component = new MantleCarvingComponent(mantle); + dimension = new IrisDimension(); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); + when(engine.getMetrics()).thenReturn(new EngineMetrics(16)); + IrisComplex complex = mock(IrisComplex.class); + when(complex.allowsMantleChunkWrite(anyInt(), anyInt())).thenReturn(true); + ProceduralStream heights = mock(ProceduralStream.class); + when(heights.getDouble(anyDouble(), anyDouble())).thenAnswer(invocation -> { + double x = invocation.getArgument(0); + return x < 0D ? 90D : 70D; + }); + when(complex.getNaturalHeightStream()).thenReturn(heights); + ProceduralStream regions = mock(ProceduralStream.class); + ProceduralStream biomes = mock(ProceduralStream.class); + ProceduralStream fluids = mock(ProceduralStream.class); + when(regions.get(anyDouble(), anyDouble())).thenReturn(new IrisRegion()); + when(biomes.get(anyDouble(), anyDouble())).thenReturn(new IrisBiome()); + when(complex.getRegionStream()).thenReturn(regions); + when(complex.getNaturalTrueBiomeStream()).thenReturn(biomes); + when(complex.getCaveBiomeStream()).thenReturn(biomes); + when(complex.getFluidStream()).thenReturn(fluids); + context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + writer = mock(MantleWriter.class); + Mantle storage = mock(Mantle.class); + when(storage.getWorldHeight()).thenReturn(128); + when(writer.getMantle()).thenReturn(storage); + } + + @Test + public void nestedGenerationPreservesOuterColumnHeights() throws Exception { + IrisCaveCarver3D carver = installProfile(); + AtomicBoolean entered = new AtomicBoolean(); + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + int chunkX = invocation.getArgument(1); + int[] heights = invocation.getArgument(7); + int expectedHeight = chunkX < 0 ? 90 : 70; + assertEquals(expectedHeight, heights[0]); + calls.incrementAndGet(); + if (entered.compareAndSet(false, true)) { + component.generateLayer(writer, -3, -3, context); + } + for (int height : heights) { + assertEquals(expectedHeight, height); + } + return 0; + }).when(carver).carve(any(), anyInt(), anyInt(), any(), anyDouble(), anyDouble(), + any(), any(), any(), any(), any()); + + component.generateLayer(writer, 2, 2, context); + + assertEquals(2, calls.get()); + assertReleased(); + } + + @Test + public void generationReleasesConfigurationReferencesWhenCarvingFails() throws Exception { + IrisCaveCarver3D carver = installProfile(); + doAnswer(invocation -> { + throw new IllegalStateException("Carving failed"); + }).when(carver).carve(any(), anyInt(), anyInt(), any(), anyDouble(), anyDouble(), + any(), any(), any(), any(), any()); + assertThrows(IllegalStateException.class, () -> component.generateLayer(writer, 2, 2, context)); + assertReleased(); + } + + @Test + public void changedProfilesReuseNumericBuffersWithoutKeepingProfileKeys() throws Exception { + Object firstBuffer = null; + for (int index = 0; index < 4; index++) { + installProfile(); + component.generateLayer(writer, 2, 2, context); + Object scratch = assertReleased(); + List buffers = (List) field(scratch, "weightBuffers"); + assertEquals(1, buffers.size()); + if (firstBuffer == null) { + firstBuffer = buffers.get(0); + } else { + assertSame(firstBuffer, buffers.get(0)); + } + } + } + + private IrisCaveCarver3D installProfile() throws Exception { + IrisCaveProfile profile = new IrisCaveProfile().setEnabled(true); + dimension.setCaveProfile(profile); + IrisCaveCarver3D carver = mock(IrisCaveCarver3D.class); + Map carvers = new IdentityHashMap<>(); + carvers.put(profile, carver); + Field cache = MantleCarvingComponent.class.getDeclaredField("profileCarvers"); + cache.setAccessible(true); + cache.set(component, carvers); + return carver; + } + + private Object assertReleased() throws Exception { + Field scratchField = MantleCarvingComponent.class.getDeclaredField("BLEND_SCRATCH"); + scratchField.setAccessible(true); + ThreadLocal scratchCache = (ThreadLocal) scratchField.get(null); + Object scratch = scratchCache.get(); + for (String name : List.of("profileField", "kernelProfiles", "fieldRegions", "fieldSurfaceBiomes", "fieldCaveBiomes")) { + for (Object value : (Object[]) field(scratch, name)) { + assertNull(value); + } + } + assertEquals(0, ((Map) field(scratch, "columnProfileWeights")).size()); + assertEquals(0, ((List) field(scratch, "profileOrder")).size()); + assertFalse((boolean) field(scratch, "inUse")); + return scratch; + } + + private Object field(Object object, String name) throws Exception { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java index 97a057836..29ce34f42 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java @@ -3,6 +3,9 @@ package art.arcane.iris.engine.modifier; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDepositGenerator; +import art.arcane.iris.engine.object.IrisDepositHeightDistribution; +import art.arcane.iris.engine.object.IrisDepositPlacementScope; +import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -16,6 +19,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.math.RNG; import org.junit.Test; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -33,14 +37,11 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -90,6 +91,25 @@ public class IrisDepositModifierContextTest { } } + @Test + public void failedClumpChanceDoesNotResampleTheConfiguredAttemptCount() { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + IrisDepositGenerator generator = mock(IrisDepositGenerator.class); + when(generator.getSpawnChance()).thenReturn(1D); + when(generator.getMinPerChunk()).thenReturn(0); + when(generator.getMaxPerChunk()).thenReturn(4); + when(generator.getPerClumpSpawnChance()).thenReturn(0D); + RNG rng = mock(RNG.class); + when(rng.d()).thenReturn(0.5D); + when(rng.i(0, 5)).thenReturn(4, 0); + + new IrisDepositModifier(engine).generate(generator, null, null, rng, 0, 0, false, null); + + verify(generator, times(4)).getPerClumpSpawnChance(); + verify(rng, times(1)).i(0, 5); + verify(generator, never()).getClump(any(), any(), any()); + } + private static final class Fixture implements AutoCloseable { private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final Engine engine; @@ -138,16 +158,27 @@ public class IrisDepositModifierContextTest { return null; }).when(scope).close(); } - modifier = spy(new IrisDepositModifier(engine)); - doAnswer(invocation -> { - assertNotSame(caller, Thread.currentThread()); - assertEquals(scoped, workerRuntime.get()); - assertSame(engine, IrisContext.require().getEngine()); - assertSame(context, IrisContext.require().getChunkContext()); - assertEquals(71L, IrisContext.require().getGenerationSessionId()); - completed.incrementAndGet(); - return null; - }).when(modifier).generate(any(), same(chunk), any(), any(), eq(2), eq(-3), eq(false), same(context)); + when(engine.getHeight()).thenReturn(16); + IrisObject clump = new IrisObject(1, 1, 1); + for (IrisDepositGenerator generator : new IrisDepositGenerator[]{dimensionDeposit, regionDeposit, biomeDeposit}) { + when(generator.getSpawnChance()).thenReturn(1D); + when(generator.getMinPerChunk()).thenReturn(1); + when(generator.getMaxPerChunk()).thenReturn(1); + when(generator.getPerClumpSpawnChance()).thenReturn(1D); + when(generator.getPlacementScope()).thenReturn(IrisDepositPlacementScope.FULL_HEIGHT); + when(generator.getHeightDistribution()).thenReturn(IrisDepositHeightDistribution.UNIFORM); + when(generator.matchesBiome(any(), any())).thenReturn(true); + doAnswer(invocation -> { + assertNotSame(caller, Thread.currentThread()); + assertEquals(scoped, workerRuntime.get()); + assertSame(engine, IrisContext.require().getEngine()); + assertSame(context, IrisContext.require().getChunkContext()); + assertEquals(71L, IrisContext.require().getGenerationSessionId()); + completed.incrementAndGet(); + return clump; + }).when(generator).getClump(any(), any(), any()); + } + modifier = new IrisDepositModifier(engine); doAnswer(invocation -> { assertEquals(3, completed.get()); assertEquals(scoped ? 3 : 0, closedScopes.get()); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierOrderingTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierOrderingTest.java new file mode 100644 index 000000000..d077db0ae --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierOrderingTest.java @@ -0,0 +1,223 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDepositGenerator; +import art.arcane.iris.engine.object.IrisDepositHeightDistribution; +import art.arcane.iris.engine.object.IrisDepositPlacementScope; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.parallel.BurstExecutor; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +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.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisDepositModifierOrderingTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + PlatformBlockState stone = state("stone"); + when(registries.block(anyString())).thenReturn(stone); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void reverseWorkerCompletionKeepsDimensionRegionBiomePlacementOrder() throws Exception { + try (Fixture fixture = new Fixture(true, 1)) { + fixture.generate(); + assertEquals(List.of("biome", "region", "dimension"), fixture.completed); + fixture.assertBiomeWins(); + } + } + + @Test + public void inlinePreparationUsesTheSameOverlappingHostRules() throws Exception { + try (Fixture fixture = new Fixture(false, 1)) { + fixture.generate(); + assertEquals(List.of("dimension", "region", "biome"), fixture.completed); + fixture.assertBiomeWins(); + } + } + + @Test + public void manyAttemptBatchesKeepEveryClumpSeedAndOrderedHostReplacement() throws Exception { + List expectedSamples = new ArrayList<>(); + RNG generatorRng = new RNG(0L).nextParallelRNG(0L); + for (String name : List.of("dimension", "region", "biome")) { + for (int i = 0; i < 25; i++) { + expectedSamples.add(name + ":" + IrisDepositModifier.clumpSeed(generatorRng.getSeed(), i)); + } + } + Collections.sort(expectedSamples); + + for (boolean parallel : new boolean[]{false, true}) { + try (Fixture fixture = new Fixture(parallel, 25)) { + fixture.generate(); + Collections.sort(fixture.samples); + assertEquals(expectedSamples, fixture.samples); + assertEquals(75, fixture.completed.size()); + fixture.assertBiomeWins(); + } + } + } + + private static PlatformBlockState state(String material) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn("minecraft:" + material); + when(state.materialKey()).thenReturn("minecraft:" + material); + return state; + } + + private static final class Fixture implements AutoCloseable { + private final ExecutorService executor = Executors.newFixedThreadPool(3); + private final List completed = Collections.synchronizedList(new ArrayList<>()); + private final List samples = Collections.synchronizedList(new ArrayList<>()); + private final CountDownLatch biomeReady = new CountDownLatch(1); + private final CountDownLatch regionReady = new CountDownLatch(1); + private final Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + private final ChunkContext context = mock(ChunkContext.class, RETURNS_DEEP_STUBS); + private final PlatformBlockState stone = state("stone"); + private final PlatformBlockState granite = state("granite"); + private final PlatformBlockState diorite = state("diorite"); + private final PlatformBlockState diamond = state("diamond_ore"); + private final Hunk output = Hunk.newArrayHunk(16, 16, 16); + private final MantleChunk chunk; + private final boolean parallel; + private final int attempts; + + @SuppressWarnings("unchecked") + private Fixture(boolean parallel, int attempts) { + this.parallel = parallel; + this.attempts = attempts; + when(engine.getHeight()).thenReturn(16); + when(engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any())).thenReturn(null); + when(engine.getDimension().getDepositVariants()).thenReturn(new KList<>()); + when(context.getRegion().get(anyInt(), anyInt()).getDepositVariants()).thenReturn(new KList<>()); + when(context.getGenerationSessionId()).thenReturn(11L); + chunk = mock(MantleChunk.class); + Mantle mantle = engine.getMantle().getMantle(); + doReturn(chunk).when(mantle).getChunk(0, 0); + when(chunk.use()).thenReturn(chunk); + MultiBurst pool = mock(MultiBurst.class); + BurstExecutor burst = new BurstExecutor(executor, 3); + burst.setMulticore(parallel); + when(engine.burst()).thenReturn(pool); + when(pool.burst(parallel)).thenReturn(burst); + IrisDepositGenerator dimensionDeposit = generator("dimension", stone, granite); + IrisDepositGenerator regionDeposit = generator("region", granite, diorite); + IrisDepositGenerator biomeDeposit = generator("biome", diorite, diamond); + when(engine.getDimension().getDeposits()).thenReturn(new KList<>(dimensionDeposit)); + when(context.getRegion().get(7, 7).getDeposits()).thenReturn(new KList<>(regionDeposit)); + when(context.getBiome().get(7, 7).getDeposits()).thenReturn(new KList<>(biomeDeposit)); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + output.set(x, 4, z, stone); + } + } + } + + private IrisDepositGenerator generator(String name, PlatformBlockState requiredHost, PlatformBlockState replacement) { + IrisDepositGenerator generator = mock(IrisDepositGenerator.class); + when(generator.getSpawnChance()).thenReturn(1D); + when(generator.getPerClumpSpawnChance()).thenReturn(1D); + when(generator.getMinPerChunk()).thenReturn(attempts); + when(generator.getMaxPerChunk()).thenReturn(attempts); + when(generator.getMinHeight()).thenReturn(4); + when(generator.getMaxHeight()).thenReturn(4); + when(generator.getPlacementScope()).thenReturn(IrisDepositPlacementScope.FULL_HEIGHT); + when(generator.getHeightDistribution()).thenReturn(IrisDepositHeightDistribution.UNIFORM); + when(generator.matchesBiome(any(), any())).thenReturn(true); + when(generator.canReplace(requiredHost)).thenReturn(true); + IrisObject clump = new IrisObject(33, 1, 33); + for (int x = -16; x <= 16; x++) { + for (int z = -16; z <= 16; z++) { + clump.getBlocks().put(new IrisBlockVector(x, 0, z), replacement); + } + } + when(generator.getClump(any(), any(), any())).thenAnswer(invocation -> { + assertTrue("Placement must release the first clump window before more are prepared", + samples.size() < 32 || output.get(0, 4, 0) != stone); + if (parallel && attempts == 1 && name.equals("dimension")) { + assertTrue(regionReady.await(10, TimeUnit.SECONDS)); + } else if (parallel && attempts == 1 && name.equals("region")) { + assertTrue(biomeReady.await(10, TimeUnit.SECONDS)); + } + RNG rng = invocation.getArgument(1); + samples.add(name + ":" + rng.getSeed()); + completed.add(name); + if (name.equals("biome")) { + biomeReady.countDown(); + } else if (name.equals("region")) { + regionReady.countDown(); + } + return clump; + }); + return generator; + } + + private void generate() { + try (MockedStatic blocks = mockStatic(B.class, CALLS_REAL_METHODS)) { + blocks.when(() -> B.toDeepSlateOre(any(), any())).thenAnswer(invocation -> invocation.getArgument(1)); + new IrisDepositModifier(engine).generateDeposits(output, 0, 0, parallel, context); + } + verify(chunk).release(); + } + + private void assertBiomeWins() { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertSame("Deposit collision at " + x + ",4," + z, diamond, output.get(x, 4, z)); + } + } + } + + @Override + public void close() throws Exception { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierProbabilityTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierProbabilityTest.java new file mode 100644 index 000000000..2daf21f0c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierProbabilityTest.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisDepositModifierProbabilityTest { + @Test + public void generatorAndClumpChancesRemainIndependent() { + int samples = 100_000; + int accepted = 0; + for (int seed = 0; seed < samples; seed++) { + if (new RNG(seed).d() <= 0.2D + && new RNG(IrisDepositModifier.clumpSeed(seed, 0)).d() <= 0.2D) { + accepted++; + } + } + assertEquals(0.04D, accepted / (double) samples, 0.003D); + } + + @Test + public void neighboringClumpChancesRemainIndependentAcrossBatchBoundaries() { + int samples = 100_000; + for (int first : new int[]{0, 7, 31}) { + int accepted = 0; + for (int seed = 0; seed < samples; seed++) { + if (new RNG(IrisDepositModifier.clumpSeed(seed, first)).d() <= 0.2D + && new RNG(IrisDepositModifier.clumpSeed(seed, first + 1)).d() <= 0.2D) { + accepted++; + } + } + assertEquals("Clump pair at " + first, 0.04D, accepted / (double) samples, 0.003D); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java deleted file mode 100644 index a97e7938d..000000000 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerGuardTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.math.RNG; -import org.junit.Test; - -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; - -public class IrisBiomeCeilingLayerGuardTest { - private static IrisBiome biome(int layers, int ceilingLayers) { - IrisBiome biome = new IrisBiome(); - KList surface = new KList<>(); - for (int i = 0; i < layers; i++) { - surface.add(new IrisBiomePaletteLayer().zero()); - } - KList ceiling = new KList<>(); - for (int i = 0; i < ceilingLayers; i++) { - IrisBiomePaletteLayer layer = new IrisBiomePaletteLayer().zero(); - layer.setMinHeight(0); - layer.setMaxHeight(0); - ceiling.add(layer); - } - biome.setLayers(surface); - biome.setCaveCeilingLayers(ceiling); - return biome; - } - - @Test - public void moreCeilingLayersThanLayersMustNotThrow() { - IrisBiome biome = biome(1, 3); - - KList result = biome.generateCeilingLayers( - new IrisDimension(), 0, 0, new RNG(1), 8, 64, mock(IrisData.class), null); - - assertTrue("extra ceiling layers are skipped, not crashed on", result.isEmpty()); - } - - @Test - public void inRangeCeilingLayersKeepWorking() { - IrisBiome biome = biome(3, 2); - - KList result = biome.generateCeilingLayers( - new IrisDimension(), 0, 0, new RNG(1), 8, 64, mock(IrisData.class), null); - - assertTrue("zeroed palettes with zero heights produce no blocks", result.isEmpty()); - } - - @Test - public void omittedCeilingLayersLeaveCavesUnchanged() { - assertTrue(new IrisBiome().getCaveCeilingLayers().isEmpty()); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerTest.java new file mode 100644 index 000000000..cc46a9c82 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCeilingLayerTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisBiomeCeilingLayerTest { + @Test + public void emitsEveryCeilingLayerWithoutSurfaceLayers() { + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + PlatformBlockState third = mock(PlatformBlockState.class); + IrisBiome biome = new IrisBiome().setLayers(new KList<>()).setCaveCeilingLayers( + new KList<>(layer(first, 2), layer(second, 1), layer(third, 2))); + + KList result = biome.generateCeilingLayers( + new IrisDimension(), 13, -27, new RNG(1), 8, 64, null, null); + + assertEquals(List.of(first, first, second, third, third), result); + assertNull(biome.getLayerHeightGenerators().getIfPresent()); + } + + @Test + public void ceilingThicknessDoesNotDependOnSurfaceDefinitionOrWarmOrder() { + IrisBiome first = variableBiome(1, 1); + IrisBiome second = variableBiome(7, 31); + RNG rng = new RNG(37); + second.getLayerHeightGenerators(rng, null); + for (int x = -200; x <= 200; x += 7) { + KList firstCeiling = first.generateCeilingLayers( + new IrisDimension(), x, -x, rng, 100, 120, null, null); + KList secondCeiling = second.generateCeilingLayers( + new IrisDimension(), x, -x, rng, 100, 120, null, null); + assertEquals(firstCeiling.size(), secondCeiling.size()); + } + first.getLayerHeightGenerators(rng, null); + assertNotSame(first.getLayerHeightGenerators().getIfPresent().get(0), + first.getLayerCeilingHeightGenerators().getIfPresent().get(0)); + } + + @Test + public void capsCeilingLayersAtAvailableDepth() { + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + IrisBiome biome = new IrisBiome().setCaveCeilingLayers(new KList<>(layer(first, 2), layer(second, 3))); + assertEquals(List.of(first, first, second), biome.generateCeilingLayers( + new IrisDimension(), 0, 0, new RNG(1), 3, 64, null, null)); + assertTrue(biome.generateCeilingLayers( + new IrisDimension(), 0, 0, new RNG(1), 0, 64, null, null).isEmpty()); + } + + @Test + public void omittedCeilingLayersLeaveCavesUnchanged() { + assertTrue(new IrisBiome().getCaveCeilingLayers().isEmpty()); + } + + static IrisBiomePaletteLayer layer(PlatformBlockState block, int thickness) { + IrisBiomePaletteLayer layer = new IrisBiomePaletteLayer().setMinHeight(thickness).setMaxHeight(thickness); + layer.getBlockData().aquire(() -> new KList<>(block)); + return layer; + } + + private static IrisBiome variableBiome(int surfaceMinimum, int surfaceMaximum) { + PlatformBlockState block = mock(PlatformBlockState.class); + return new IrisBiome() + .setLayers(new KList<>(layer(block, surfaceMinimum).setMaxHeight(surfaceMaximum))) + .setCaveCeilingLayers(new KList<>(layer(block, 1).setMaxHeight(9))); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLockedLayerTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLockedLayerTest.java new file mode 100644 index 000000000..e2d0a604c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeLockedLayerTest.java @@ -0,0 +1,35 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +public class IrisBiomeLockedLayerTest { + @Test + public void repeatsLockedLayersAcrossTheReferenceHeight() { + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + PlatformBlockState third = mock(PlatformBlockState.class); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(8).setLayers(new KList<>( + IrisBiomeCeilingLayerTest.layer(first, 1), + IrisBiomeCeilingLayerTest.layer(second, 1), + IrisBiomeCeilingLayerTest.layer(third, 1))); + List cycle = List.of(first, second, third); + int[] heights = {1, 509, 512, 513, 1000, Integer.MAX_VALUE}; + int[][] expectedCycles = {{1, 0, 2}, {0, 2, 1}, {0, 2, 1}, {2, 1, 0}, {1, 0, 2}, {1, 0, 2}}; + for (int index = 0; index < heights.length; index++) { + KList blocks = biome.generateLockedLayers( + 13, -27, new RNG(37), 8, heights[index], null, null); + assertEquals(8, blocks.size()); + for (int depth = 0; depth < blocks.size(); depth++) { + assertEquals(cycle.get(expectedCycles[index][depth % 3]), blocks.get(depth)); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java index e7217db79..edb1e2fa6 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java @@ -35,11 +35,16 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; public class IrisDimensionCarvingResolverParityTest { private static final int MAX_CHILD_DEPTH = 32; @@ -71,6 +76,31 @@ public class IrisDimensionCarvingResolverParityTest { return data; } + @Test + public void extremeChildRarityRemainsReachableWithOneNoiseSample() { + Fixture fixture = createFixture(); + Engine engine = fixture.engine; + IrisData data = engine.getData(); + IrisBiome rare = data.getBiomeLoader().load("child-a"); + IrisBiome common = data.getBiomeLoader().load("child-b"); + IrisBiome parentBiome = data.getBiomeLoader().load("root-low"); + doReturn(Integer.MAX_VALUE).when(rare).getRarity(); + doReturn(1).when(common).getRarity(); + doReturn(2).when(parentBiome).getRarity(); + IrisDimensionCarvingEntry parent = spy(engine.getDimension().getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + CNG generator = mock(CNG.class); + doReturn(0.5D).when(generator).noiseFast2D(-17D, 23D); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + + IrisDimensionCarvingEntry selected = IrisDimensionCarvingResolver.resolveFromRoot( + engine, parent, -17, 23, new IrisDimensionCarvingResolver.State()); + + assertSame(engine.getDimension().getCarvingEntryIndex().get("child-a"), selected); + verify(generator).noiseFast2D(-17D, 23D); + verify(generator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble()); + } + @Test public void resolverStatefulOverloadsMatchLegacyResolverAcrossSampleGrid() { Fixture fixture = createFixture(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectCopyTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectCopyTest.java new file mode 100644 index 000000000..07cd45d59 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectCopyTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +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.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisObjectCopyTest { + @Test + public void copyWaitsForVolumeMutationToComplete() throws Exception { + IrisObject source = new IrisObject(1, 1, 1); + PlatformBlockState block = mock(PlatformBlockState.class); + CountDownLatch started = new CountDownLatch(1); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future result; + source.writeLock.lock(); + try { + result = executor.submit(() -> { + started.countDown(); + return source.copy(); + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> result.get(150, TimeUnit.MILLISECONDS)); + source.setW(3); + source.setCenter(new Vector3i(1, 0, 0)); + source.setUnsigned(2, 0, 0, block); + } finally { + source.writeLock.unlock(); + } + + IrisObject copy = result.get(5, TimeUnit.SECONDS); + assertEquals(3, copy.getW()); + assertEquals(new Vector3i(1, 0, 0), copy.getCenter()); + assertSame(block, copy.getBlocks().get(new IrisBlockVector(1, 0, 0))); + } + } + + @Test + public void copyOwnsItsVolumeCoordinatesAndTiles() { + IrisObject source = new IrisObject(1, 1, 1); + PlatformBlockState block = mock(PlatformBlockState.class); + TileData tile = new TileData("minecraft:chest", new KMap<>()); + tile.getProperties().put("name", "source"); + source.setUnsigned(0, 0, 0, block); + source.setUnsignedTile(0, 0, 0, tile); + + IrisObject copy = source.copy(); + IrisBlockVector position = new IrisBlockVector(0, 0, 0); + TileData copiedTile = copy.getStates().get(position); + assertNotSame(source.getCenter(), copy.getCenter()); + assertNotSame(tile, copiedTile); + assertNotSame(tile.getProperties(), copiedTile.getProperties()); + copiedTile.getProperties().put("name", "copy"); + copy.getBlocks().remove(position); + + assertEquals("source", tile.getProperties().get("name")); + assertSame(block, source.getBlocks().get(position)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java index a46141d86..9eba2a65a 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java @@ -10,6 +10,9 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Material; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Directional; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -19,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -69,6 +73,36 @@ public class IrisObjectPlacementRunnerRegressionTest { IrisPlatforms.unbind(); } + @Test + public void unsupportedRotatedOrientationOmitsBlockTileAndListener() { + PlatformBlockState torch = state("minecraft:wall_torch", false); + Directional directional = mock(Directional.class); + Material material = mock(Material.class); + when(torch.nativeHandle()).thenReturn(directional); + when(directional.clone()).thenReturn(directional); + when(directional.getFacing()).thenReturn(BlockFace.NORTH); + when(directional.getFaces()).thenReturn(Set.of(BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST)); + when(directional.getMaterial()).thenReturn(material); + IrisObjectPlacement placement = placement(); + placement.setRotation(IrisObjectRotation.of(90, 0, 0)); + assertNull(placement.getRotation().rotate(torch, 0, 0, 0)); + IrisObject object = lineObject(3); + object.setUnsigned(1, 0, 0, torch); + object.setUnsignedTile(1, 0, 0, new TileData("minecraft:wall_torch", new KMap<>())); + RecordingPlacer placer = new RecordingPlacer(null); + List placed = new ArrayList<>(); + + int result = object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), + (position, state) -> placed.add(state), null, data); + + assertEquals(ANCHOR_Y, result); + assertEquals(2, placer.writes().size()); + assertEquals(List.of(solid, solid), placed); + assertNull(placer.get(0, ANCHOR_Y, 0)); + assertNull(placer.getData(0, ANCHOR_Y, 0, TileData.class)); + assertEquals(1, object.getStates().size()); + } + @Test public void placementFailurePropagatesToTheCaller() { RecordingPlacer placer = new RecordingPlacer(null); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java new file mode 100644 index 000000000..713adfdd8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectShapingTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectShapingTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + Map states = new HashMap<>(); + when(registries.block(anyString())).thenAnswer(invocation -> states.computeIfAbsent( + invocation.getArgument(0), key -> mock(PlatformBlockState.class))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void smartBoreMatchesOrderedAxisClosureAndPreservesOriginalBlocks() { + Random random = new Random(617431L); + PlatformBlockState stone = mock(PlatformBlockState.class); + for (int fixture = 0; fixture < 24; fixture++) { + int offset = switch (fixture % 3) { + case 0 -> -1050; + case 1 -> 1020; + default -> -3; + }; + IrisObject object = new IrisObject(6, 6, 6); + Map expected = new HashMap<>(); + for (int index = 0; index < 36; index++) { + Cell cell = new Cell(offset + random.nextInt(6), offset + random.nextInt(6), offset + random.nextInt(6)); + PlatformBlockState state = switch (index % 3) { + case 0 -> IrisObject.States.AIR; + case 1 -> IrisObject.States.VAIR; + default -> stone; + }; + object.getBlocks().put(cell.vector(), state); + expected.put(cell, state); + } + for (int axis = 0; axis < 3; axis++) { + closeAxis(expected, axis); + } + + IrisObjectShaping.ensureSmartBored(object); + + assertTrue(object.isSmartBored()); + assertEquals("fixture " + fixture, expected.size(), object.getBlocks().size()); + for (Map.Entry entry : expected.entrySet()) { + assertSame(entry.getValue(), object.getBlocks().get(entry.getKey().vector())); + } + long revision = object.getBlocks().modificationRevision(); + IrisObjectShaping.ensureSmartBored(object); + assertEquals(revision, object.getBlocks().modificationRevision()); + } + } + + @Test + public void emptySmartBoreCompletesWithoutAddingBlocks() { + IrisObject object = new IrisObject(1, 1, 1); + + IrisObjectShaping.ensureSmartBored(object); + + assertTrue(object.isSmartBored()); + assertTrue(object.getBlocks().isEmpty()); + } + + private static void closeAxis(Map blocks, int axis) { + List occupied = new ArrayList<>(blocks.keySet()); + for (Cell start : occupied) { + for (Cell end : occupied) { + if (!start.with(axis, 0).equals(end.with(axis, 0))) { + continue; + } + for (int position = start.coordinate(axis); position <= end.coordinate(axis); position++) { + blocks.putIfAbsent(start.with(axis, position), IrisObject.States.VAIR); + } + } + } + } + + private record Cell(int x, int y, int z) { + private int coordinate(int axis) { + return switch (axis) { + case 0 -> x; + case 1 -> y; + default -> z; + }; + } + + private Cell with(int axis, int coordinate) { + return switch (axis) { + case 0 -> new Cell(coordinate, y, z); + case 1 -> new Cell(x, coordinate, z); + default -> new Cell(x, y, coordinate); + }; + } + + private IrisBlockVector vector() { + return new IrisBlockVector(x, y, z); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java index 0059779c4..01d21393e 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransformsTileTest.java @@ -16,6 +16,9 @@ import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -46,6 +49,40 @@ public class IrisObjectTransformsTileTest { IrisPlatforms.unbind(); } + @Test + public void rotationOmitsUnsupportedBlocksAndTheirTiles() { + IrisObject source = new IrisObject(3, 1, 1); + PlatformBlockState unsupported = state("minecraft:wall_torch"); + source.setUnsigned(0, 0, 0, unsupported); + source.setUnsignedTile(0, 0, 0, tile("minecraft:wall_torch", "omitted")); + source.setUnsigned(1, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(1, 0, 0, tile("minecraft:chest", "kept")); + source.setUnsigned(2, 0, 0, state("minecraft:stone")); + IrisObjectRotation.StateRotator previous = IrisObjectRotation.bindPlatformRotator( + (rotation, block, x, y, z) -> block == unsupported ? null : block); + IrisObject rotated; + try { + rotated = source.rotateCopy(IrisObjectRotation.of(0, 90, 0)); + } finally { + IrisObjectRotation.restorePlatformRotator(previous); + } + + assertEquals(2, rotated.getBlocks().size()); + assertEquals(1, rotated.getStates().size()); + for (Map.Entry entry : rotated.getStates()) { + assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey()); + assertEquals("kept", entry.getValue().getProperties().get("name")); + } + assertEquals(3, source.getBlocks().size()); + assertEquals(2, source.getStates().size()); + } + + @Test + public void scalingWaitsForMatchingGeometryAndVolume() throws Exception { + assertConsistentScaling(false); + assertConsistentScaling(true); + } + @Test public void enlargedTilesFollowEveryExpandedBlockAndCloneProperties() { IrisObject source = new IrisObject(1, 1, 1); @@ -186,6 +223,52 @@ public class IrisObjectTransformsTileTest { assertEquals(8, scaled.getStates().size()); } + private static void assertConsistentScaling(boolean savedOrigin) throws Exception { + IrisObject source = new IrisObject(9, 5, 3); + source.setUnsigned(0, 0, 0, state("minecraft:stone")); + FutureTask scaling = new FutureTask<>(() -> savedOrigin + ? source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE) + : source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE)); + Thread worker = new Thread(scaling, "object-scaling-snapshot"); + source.writeLock.lock(); + try { + worker.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + assertEquals(Thread.State.WAITING, worker.getState()); + source.setW(2); + source.setH(2); + source.setD(2); + source.setCenter(new Vector3i(1, 1, 1)); + source.getBlocks().clear(); + source.setUnsigned(0, 0, 0, state("minecraft:chest")); + source.setUnsignedTile(0, 0, 0, tile("minecraft:chest", "new volume")); + } finally { + source.writeLock.unlock(); + } + try { + IrisObject actual = scaling.get(5, TimeUnit.SECONDS); + IrisObject expected = savedOrigin + ? source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE) + : source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE); + assertEquals(expected.getCenter(), actual.getCenter()); + assertEquals(expected.getW(), actual.getW()); + assertEquals(expected.getH(), actual.getH()); + assertEquals(expected.getD(), actual.getD()); + assertEquals(expected.getBlocks().size(), actual.getBlocks().size()); + assertEquals(expected.getStates().size(), actual.getStates().size()); + for (Map.Entry entry : expected.getBlocks()) { + assertEquals(entry.getValue(), actual.getBlocks().get(entry.getKey())); + assertEquals(expected.getStates().get(entry.getKey()), actual.getStates().get(entry.getKey())); + } + } finally { + worker.interrupt(); + worker.join(TimeUnit.SECONDS.toMillis(5)); + } + } + private static IrisObject asymmetricObject() { IrisObject source = new IrisObject(7, 5, 3); source.setCenter(new Vector3i(5, 1, 2)); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java new file mode 100644 index 000000000..748f09560 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisOreGeneratorTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisOreGeneratorTest { + @Test + public void zeroThresholdDisablesEvenZeroNoiseSamples() { + Fixture fixture = new Fixture(0.0); + fixture.generator.setThreshold(0.0); + + assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null)); + verifyNoInteractions(fixture.noise, fixture.style); + } + + @Test + public void positiveThresholdStillIncludesExactCutoffAndVerticalBounds() { + Fixture fixture = new Fixture(0.5); + fixture.generator.setThreshold(0.5); + + assertSame(fixture.ore, fixture.generator.generate(2, 30, 3, fixture.rng, null)); + assertSame(fixture.ore, fixture.generator.generate(2, 80, 3, fixture.rng, null)); + assertNull(fixture.generator.generate(2, 29, 3, fixture.rng, null)); + assertNull(fixture.generator.generate(2, 81, 3, fixture.rng, null)); + fixture.generator.setThreshold(0.49); + assertNull(fixture.generator.generate(2, 40, 3, fixture.rng, null)); + } + + @Test + public void fullThresholdIncludesMaximumNoise() { + Fixture fixture = new Fixture(1.0); + fixture.generator.setThreshold(1.0); + + assertSame(fixture.ore, fixture.generator.generate(2, 40, 3, fixture.rng, null)); + } + + private static final class Fixture { + private final RNG rng = new RNG(34L); + private final PlatformBlockState ore = mock(PlatformBlockState.class); + private final CNG noise = mock(CNG.class); + private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + private final IrisOreGenerator generator = new IrisOreGenerator(); + + private Fixture(double sample) { + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.getPalette()).thenReturn(new KList<>(mock(IrisBlockData.class))); + when(palette.get(any(), anyDouble(), anyDouble(), anyDouble(), isNull())).thenReturn(ore); + when(noise.noise(anyDouble(), anyDouble(), anyDouble())).thenReturn(sample); + when(style.create(any(), isNull())).thenReturn(noise); + generator.setPalette(palette); + generator.setChanceStyle(style); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRaritySelectionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRaritySelectionTest.java new file mode 100644 index 000000000..a273533c3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRaritySelectionTest.java @@ -0,0 +1,141 @@ +package art.arcane.iris.engine.object; + +import com.google.gson.Gson; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class IrisRaritySelectionTest { + @Test + public void everyIndexMatchesExpandedInsertionOrderForRandomizedChoices() { + Random random = new Random(718938L); + for (int scenario = 0; scenario < 500; scenario++) { + ArrayList choices = new ArrayList<>(); + int count = random.nextInt(12); + for (int index = 0; index < count; index++) { + choices.add(random.nextInt(7) == 0 ? null : new Choice(index, random.nextInt(35) - 3)); + } + List expanded = expand(choices); + IrisRaritySelection selection = IrisRaritySelection.create(choices); + assertEquals(expanded.size(), selection.size()); + if (expanded.isEmpty()) { + assertNull(selection.get(0)); + continue; + } + for (int index = 0; index < expanded.size(); index++) { + assertSame(expanded.get(index), selection.get(index)); + } + assertSame(expanded.getFirst(), selection.get(Long.MIN_VALUE)); + assertSame(expanded.getLast(), selection.get(Long.MAX_VALUE)); + for (double sample : new double[]{-3D, -0D, 0.125D, 0.5D, 0.875D, 1D, 3D, + Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY}) { + int index = (int) Math.round(sample * (expanded.size() - 1)); + assertSame(expanded.get(Math.max(0, Math.min(expanded.size() - 1, index))), selection.select(sample)); + } + } + } + + @Test(timeout = 1000) + public void maximumRarityCountsStayPositiveWithoutExpandingBillionsOfEntries() { + Choice rare = new Choice(0, Integer.MAX_VALUE); + Choice common = new Choice(1, 1); + Choice next = new Choice(2, 2); + IrisRaritySelection selection = IrisRaritySelection.create(List.of(rare, common, next)); + + assertEquals(4_294_967_294L, selection.size()); + assertSame(next, selection.get(0L)); + assertSame(common, selection.get(2_147_483_646L)); + assertSame(rare, selection.get(2_147_483_647L)); + assertSame(common, selection.get(2_147_483_648L)); + assertSame(next, selection.get(selection.size() - 1L)); + assertSame(rare, selection.select(0.5D)); + assertSame(next, selection.select(0D)); + assertSame(next, selection.select(1D)); + } + + @Test + public void equalMaximumRaritiesRetainEveryChoice() { + Choice first = new Choice(0, Integer.MAX_VALUE); + Choice second = new Choice(1, Integer.MAX_VALUE); + Choice third = new Choice(2, Integer.MAX_VALUE); + IrisRaritySelection selection = IrisRaritySelection.create(List.of(first, second, third)); + + assertEquals(3L, selection.size()); + assertSame(second, selection.get(0L)); + assertSame(first, selection.get(1L)); + assertSame(third, selection.get(2L)); + } + + @Test + public void duplicateChoicesKeepTheirSeparateInsertionPositions() { + Choice repeated = new Choice(0, 1); + Choice other = new Choice(1, 3); + List choices = List.of(repeated, other, repeated); + IrisRaritySelection selection = IrisRaritySelection.create(choices); + List expanded = expand(choices); + for (int index = 0; index < expanded.size(); index++) { + assertSame(expanded.get(index), selection.get(index)); + } + } + + @Test + public void deserializedRuntimeRarityValuesUseTheExistingMinimumClamp() { + Gson gson = new Gson(); + IrisBiome zero = gson.fromJson("{\"rarity\":0}", IrisBiome.class); + IrisBiome negative = gson.fromJson("{\"rarity\":-2147483648}", IrisBiome.class); + IrisBiome maximum = gson.fromJson("{\"rarity\":2147483647}", IrisBiome.class); + IrisRaritySelection selection = IrisRaritySelection.create(List.of(zero, negative, maximum)); + + assertEquals(4_294_967_295L, selection.size()); + assertSame(zero, selection.get(2_147_483_647L)); + assertSame(maximum, selection.get(selection.size() - 1L)); + assertEquals(0, zero.getRarity()); + assertEquals(Integer.MIN_VALUE, negative.getRarity()); + } + + @Test + public void nullAndEmptyOptionsDoNotProduceCandidates() { + assertNull(IrisRaritySelection.create(List.of()).select(0.5D)); + assertNull(IrisRaritySelection.create(Arrays.asList(null, null)).get(0L)); + } + + private static List expand(List choices) { + int maximum = 1; + for (Choice choice : choices) { + if (choice != null) { + maximum = Math.max(maximum, choice.rarity()); + } + } + ArrayList expanded = new ArrayList<>(); + boolean append = false; + for (Choice choice : choices) { + if (choice == null) { + continue; + } + int count = maximum + 1 - Math.max(1, choice.rarity()); + for (int index = 0; index < count; index++) { + append = !append; + if (append) { + expanded.add(choice); + } else { + expanded.add(0, choice); + } + } + } + return expanded; + } + + private record Choice(int id, int rarity) implements IRare { + @Override + public int getRarity() { + return rarity; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java b/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java index f3f94280b..368f731f4 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/NativeStructureObjectVetoTest.java @@ -8,6 +8,7 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.Vector3i; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; @@ -101,6 +102,35 @@ public class NativeStructureObjectVetoTest { assertTrue(placer.written().isEmpty()); } + @Test + public void obliqueRotationRejectsNativePiecesBeyondTheUnrotatedRadius() { + IrisObject object = new IrisObject(21, 1, 21); + object.setUnsigned(20, 0, 20, log); + IrisObjectPlacement placement = placement(); + placement.setBottom(false); + placement.setRotation(IrisObjectRotation.of(0, 45, 0)); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(14, SURFACE_Y, 0, 14, SURFACE_Y, 0)); + + assertEquals(-1, object.place(0, SURFACE_Y, 0, placer, placement, new RNG(1234L), data)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void offCenterOriginRejectsNativePiecesAtTheFarEdge() { + IrisObject object = new IrisObject(9, 1, 1); + object.setCenter(new Vector3i(0, 0, 0)); + object.setUnsigned(8, 0, 0, log); + IrisObjectPlacement placement = placement(); + placement.setBottom(false); + placement.setRotation(IrisObjectRotation.of(0, 0, 0)); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(volume(8, SURFACE_Y, 0, 8, SURFACE_Y, 0)); + + assertEquals(-1, object.place(0, SURFACE_Y, 0, placer, placement, new RNG(1234L), data)); + assertTrue(placer.written().isEmpty()); + } + @Test public void pieceOverlappingOnlyTheEnvelopeStillPlaces() { RecordingPlacer placer = new RecordingPlacer(engine); diff --git a/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java new file mode 100644 index 000000000..69325e61a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/coral/CoralGeneratorTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.engine.object.coral; + +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisCoral; +import art.arcane.iris.engine.object.IrisCoralForm; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CoralGeneratorTest { + @Test + public void fanTipsFollowTheTopOfEachOccupiedColumn() { + PlatformBlockState structure = mock(PlatformBlockState.class); + PlatformBlockState tip = mock(PlatformBlockState.class); + for (int height : new int[]{2, 7, 14}) { + for (int width : new int[]{1, 3, 11}) { + IrisCoral coral = new IrisCoral().setForm(IrisCoralForm.FAN) + .setHeightMin(height).setHeightMax(height).setFanWidth(width) + .setWaterlogged(false).setBlockPalette(palette(structure)); + IrisObject bare = CoralGenerator.generate(coral, 0, new RNG(0L), null); + Map tops = new HashMap<>(); + Set barePositions = new HashSet<>(); + for (IrisBlockVector position : bare.getBlocks().keys()) { + barePositions.add(position); + tops.merge(position.getBlockX(), position.getBlockY(), Math::max); + } + coral.setTipPalette(palette(tip)); + for (double chance : new double[]{0.0, 0.35, 1.0}) { + coral.setTipChance(chance); + for (long seed = 0; seed < 16; seed++) { + IrisObject decorated = CoralGenerator.generate(coral, 0, new RNG(seed), null); + Set positions = new HashSet<>(); + int tipCount = 0; + for (IrisBlockVector position : decorated.getBlocks().keys()) { + positions.add(position); + if (decorated.getBlocks().get(position) == tip) { + assertTrue("Fan tip must occupy an existing column", tops.containsKey(position.getBlockX())); + assertEquals(tops.get(position.getBlockX()).intValue(), position.getBlockY()); + tipCount++; + } + } + assertEquals(barePositions, positions); + if (chance == 1.0) { + assertEquals(tops.size(), tipCount); + } + } + } + } + } + } + + private static IrisMaterialPalette palette(PlatformBlockState state) { + IrisBlockData block = mock(IrisBlockData.class); + when(block.getWeight()).thenReturn(1); + when(block.getBlockData(null)).thenReturn(state); + return new IrisMaterialPalette().setPalette(new KList<>(block)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java b/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java new file mode 100644 index 000000000..d5f535eed --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/formation/FormationBlockResolverTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.engine.object.formation; + +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisFormation; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FormationBlockResolverTest { + @Test + public void sharedResolverMatchesFreshPaletteSeedsAcrossBands() { + IrisFormation formation = new IrisFormation().setSeed(43191L).setStrataThickness(3); + IrisMaterialPalette body = palette("stone", "andesite"); + IrisMaterialPalette cap = palette("granite", "diorite"); + IrisMaterialPalette strata = palette("terracotta", "red_terracotta"); + formation.setBlockPalette(body); + List positions = new ArrayList<>(); + for (int x = -8; x <= 8; x++) { + for (int y = -12; y <= 24; y++) { + positions.add(new Vector3i(x, y, x - y)); + } + } + Set materials = new HashSet<>(); + for (boolean withStrata : new boolean[]{false, true}) { + formation.setStrataPalette(withStrata ? strata : null); + for (boolean withCap : new boolean[]{false, true}) { + formation.setCapPalette(withCap ? cap : null); + FormationBlockResolver resolver = new FormationBlockResolver(formation, null); + for (int pass = 0; pass < 2; pass++) { + for (Vector3i position : positions) { + FormationCanvas.Role role = position.getBlockX() % 2 == 0 + ? FormationCanvas.Role.CAP : FormationCanvas.Role.BODY; + IrisMaterialPalette expectedPalette = withCap && role == FormationCanvas.Role.CAP + ? cap : withStrata ? strata : body; + int sampleY = expectedPalette == strata + ? Math.floorDiv(position.getBlockY(), formation.getStrataThickness()) : position.getBlockY(); + long sampleSeed = formation.getSeed() + (expectedPalette == strata ? sampleY * 31L : 0L); + RNG fresh = new RNG(sampleSeed); + PlatformBlockState expected = expectedPalette.get(fresh, + position.getBlockX(), sampleY, position.getBlockZ(), null); + assertSame(expected, resolver.resolve(role, position)); + assertEquals(new RNG(sampleSeed).nextLong(), fresh.nextLong()); + materials.add(expected.key()); + } + Collections.reverse(positions); + } + } + } + assertTrue(materials.size() == 6); + } + + private static IrisMaterialPalette palette(String first, String second) { + return new IrisMaterialPalette().setPalette(new KList<>(block(first), block(second))); + } + + private static IrisBlockData block(String material) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn("minecraft:" + material); + IrisBlockData block = mock(IrisBlockData.class); + when(block.getWeight()).thenReturn(1); + when(block.getBlockData(null)).thenReturn(state); + return block; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/fungi/FungusStemBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/object/fungi/FungusStemBuilderTest.java new file mode 100644 index 000000000..5db50cf78 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/fungi/FungusStemBuilderTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.engine.object.fungi; + +import art.arcane.iris.engine.object.IrisFungus; +import art.arcane.iris.util.common.math.Vector3i; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FungusStemBuilderTest { + @Test + public void straightStemsKeepOddAndEvenWidthAnchors() { + for (int width = 1; width <= 3; width++) { + IrisFungus fungus = new IrisFungus(); + fungus.setStemWidth(width); + fungus.setStemCurve(0D); + fungus.setStemWaveAmplitude(0D); + Map roles = new HashMap<>(); + + double[] top = FungusStemBuilder.build(roles, fungus, 7, 421L); + + assertArrayEquals(new double[]{0D, 6D, 0D}, top, 0D); + assertEquals(width * width * 7, roles.size()); + int origin = width == 3 ? -1 : 0; + for (int y = 0; y < 7; y++) { + for (int x = origin; x < origin + width; x++) { + for (int z = origin; z < origin + width; z++) { + assertEquals(FungusCellRole.STEM, roles.get(new Vector3i(x, y, z))); + } + } + } + } + } + + @Test + public void curvedStemsKeepEveryLayerAndReturnTheirCapAnchor() { + IrisFungus fungus = new IrisFungus(); + fungus.setStemWidth(3); + fungus.setStemCurve(22D); + fungus.setStemLeanAzimuth(225D); + fungus.setStemWaveAmplitude(2D); + fungus.setStemWavePeriods(1.5D); + Map roles = new HashMap<>(); + + double[] top = FungusStemBuilder.build(roles, fungus, 24, -733L); + + assertEquals(24 * 9, roles.size()); + assertTrue(top[0] < 0D); + assertTrue(top[2] < 0D); + assertEquals(23D, top[1], 0D); + assertEquals(FungusCellRole.STEM, roles.get(new Vector3i( + (int) Math.round(top[0]), 23, (int) Math.round(top[2])))); + for (int y = 0; y < 24; y++) { + int count = 0; + for (Vector3i position : roles.keySet()) { + if (position.getY() == y) { + count++; + } + } + assertEquals(9, count); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java index 4e4f5fa31..a19ecfe2b 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/ProceduralTreeGeneratorTest.java @@ -6,9 +6,11 @@ import art.arcane.iris.engine.object.IrisTreeBranches; import art.arcane.iris.engine.object.IrisTreeFunction; import art.arcane.iris.engine.object.IrisTreeLeafMode; import art.arcane.iris.engine.object.IrisTreeProfile; +import art.arcane.iris.spi.PlatformBlockState; import org.junit.Test; import java.util.HashSet; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -18,6 +20,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ProceduralTreeGeneratorTest { @@ -268,4 +273,23 @@ public class ProceduralTreeGeneratorTest { distances.get(new TreeBlockCanvas.Vec(x, 0, 0))); } } + + @Test + public void disabledPlausibilityMakesDisconnectedLeavesPersistent() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setPlausible(false); + TreeBlockCanvas.Vec position = new TreeBlockCanvas.Vec(40, 20, -10); + PlatformBlockState leaf = mock(PlatformBlockState.class); + PlatformBlockState persistent = mock(PlatformBlockState.class); + PlatformBlockState supported = mock(PlatformBlockState.class); + when(leaf.key()).thenReturn("minecraft:oak_leaves[persistent=false,distance=7]"); + when(leaf.withProperty("persistent", "true")).thenReturn(persistent); + when(persistent.withProperty("distance", "1")).thenReturn(supported); + Map resolved = new HashMap<>(); + resolved.put(position, leaf); + + TreePlausibility.apply(resolved, Set.of(new TreeBlockCanvas.Vec(0, 0, 0)), Set.of(position), tree); + + assertSame(supported, resolved.get(position)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java new file mode 100644 index 000000000..5fa5550a2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeBlockResolverTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeSecondaryLeaf; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class TreeBlockResolverTest { + @Test + public void largeSecondaryWeightsKeepTheirRelativeDistribution() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setSeed(91874L); + tree.setLeavesPalette(palette("oak_leaves", "birch_leaves")); + PlatformBlockState blossom = mock(PlatformBlockState.class); + PlatformBlockState light = mock(PlatformBlockState.class); + List normal = new ArrayList<>(); + List large = new ArrayList<>(); + TreeBlockCanvas.Cell cell = new TreeBlockCanvas.Cell( + TreeBlockCanvas.Role.SECONDARY_LEAF, TreeBlockCanvas.Axis.NONE, false, -1, null); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:cherry_leaves", false)).thenReturn(blossom); + blocks.when(() -> B.getStateOrNull("minecraft:shroomlight", false)).thenReturn(light); + for (int weight : new int[]{1, Integer.MAX_VALUE}) { + tree.setWeightedSecondaryLeaves(new KList<>( + new IrisTreeSecondaryLeaf("minecraft:cherry_leaves", weight), + new IrisTreeSecondaryLeaf("minecraft:shroomlight", weight))); + List resolved = weight == 1 ? normal : large; + for (int x = -16; x <= 16; x++) { + for (int y = 0; y < 16; y++) { + resolved.add(TreeBlockResolver.resolve(tree, null, cell, + new TreeBlockCanvas.Vec(x, y, x + y), new RNG(tree.getSeed()))); + } + } + } + } + assertTrue(normal.contains(blossom)); + assertTrue(normal.contains(light)); + assertEquals(normal, large); + } + + @Test + public void sharedPaletteRandomSourceMatchesFreshPerVoxelSources() { + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setSeed(84261L); + tree.setTrunkPalette(palette("oak_log", "spruce_log")); + tree.setLeavesPalette(palette("oak_leaves", "birch_leaves")); + tree.setSecondaryTrunkPalette(palette("birch_log", "jungle_log")); + tree.setSecondaryLeavesPalette(palette("spruce_leaves", "jungle_leaves")); + RNG shared = new RNG(tree.getSeed()); + List freshBlocks = new ArrayList<>(); + List sharedBlocks = new ArrayList<>(); + Set materials = new HashSet<>(); + TreeBlockCanvas.Role[] roles = { + TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Role.LEAF, + TreeBlockCanvas.Role.SECONDARY_TRUNK, TreeBlockCanvas.Role.SECONDARY_LEAF + }; + for (int x = -8; x <= 8; x++) { + for (int y = 0; y < 24; y++) { + TreeBlockCanvas.Vec position = new TreeBlockCanvas.Vec(x, y, x - y); + TreeBlockCanvas.Cell cell = new TreeBlockCanvas.Cell( + roles[Math.floorMod(x + y, roles.length)], TreeBlockCanvas.Axis.NONE, false, -1, null); + PlatformBlockState expected = TreeBlockResolver.resolve(tree, null, cell, position, new RNG(tree.getSeed())); + PlatformBlockState actual = TreeBlockResolver.resolve(tree, null, cell, position, shared); + freshBlocks.add(position + ":" + expected.key()); + sharedBlocks.add(position + ":" + actual.key()); + materials.add(actual.key()); + } + } + + assertEquals(freshBlocks, sharedBlocks); + assertEquals(new RNG(tree.getSeed()).nextLong(), shared.nextLong()); + assertTrue(materials.size() > roles.length); + } + + private static IrisMaterialPalette palette(String first, String second) { + IrisBlockData firstBlock = block(first); + IrisBlockData secondBlock = block(second); + return new IrisMaterialPalette().setPalette(new KList<>(firstBlock, secondBlock)); + } + + private static IrisBlockData block(String material) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn("minecraft:" + material); + IrisBlockData block = mock(IrisBlockData.class); + when(block.getWeight()).thenReturn(1); + when(block.getBlockData(null)).thenReturn(state); + return block; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplierTest.java b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplierTest.java new file mode 100644 index 000000000..6fc05ebae --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplierTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.engine.object.tree; + +import art.arcane.iris.engine.object.IrisProceduralTree; +import art.arcane.iris.engine.object.IrisTreeDecorator; +import art.arcane.iris.engine.object.IrisTreeDecoratorTarget; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class TreeDecoratorApplierTest { + @Test + public void branchTipsExtendOutwardThroughLeavesWithoutReplacingWood() { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + for (int x = -3; x <= 3; x++) { + canvas.setTrunk(x, 4, 0, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.X); + } + canvas.setLeaf(-4, 4, 0, TreeBlockCanvas.Role.LEAF); + canvas.setLeaf(4, 4, 0, TreeBlockCanvas.Role.LEAF); + IrisTreeDecorator decorator = new IrisTreeDecorator() + .setTarget(IrisTreeDecoratorTarget.BRANCH_TIP) + .setBlock("minecraft:shroomlight") + .setChance(1D) + .setAxisAware(true); + IrisProceduralTree tree = new IrisProceduralTree(); + tree.setDecorators(new KList<>(decorator)); + List endpoints = List.of(new int[]{-3, 4, 0, 0, 0}, new int[]{3, 4, 0, 0, 0}); + + TreeDecoratorApplier.apply(canvas, tree, 4321L, endpoints); + + assertEquals(TreeBlockCanvas.Role.DECORATOR, canvas.get(-5, 4, 0).role()); + assertEquals("west", canvas.get(-5, 4, 0).facing()); + assertEquals(TreeBlockCanvas.Role.DECORATOR, canvas.get(5, 4, 0).role()); + assertEquals("east", canvas.get(5, 4, 0).facing()); + assertEquals(7, canvas.getTrunk().size()); + assertEquals(2, canvas.getLeaf().size()); + assertEquals(11, canvas.getCells().size()); + + TreeDecoratorApplier.apply(canvas, tree, 4321L, endpoints); + + assertEquals(11, canvas.getCells().size()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java new file mode 100644 index 000000000..4bb70b4d4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java @@ -0,0 +1,107 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.io.ReactiveFolder; +import org.bukkit.generator.BlockPopulator; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +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.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class BukkitChunkGeneratorCloseTest { + @Test + public void asynchronousCloseFailureCanRetryWithoutReopeningGeneration() throws Exception { + CloseFixture fixture = new CloseFixture(); + IllegalStateException failure = new IllegalStateException("Planner remains active"); + doThrow(failure).doNothing().when(fixture.engine).close(); + CompletableFuture firstOperation = fixture.queueOperation(); + CompletableFuture firstClose = fixture.generator.closeAsync(); + assertSame(firstClose, fixture.generator.closeAsync()); + firstOperation.completeExceptionally(assertThrows(IllegalStateException.class, fixture.operation.get()::run)); + assertTrue(firstClose.isCompletedExceptionally()); + assertTrue(fixture.generator.isClosing()); + + CompletableFuture secondOperation = fixture.queueOperation(); + CompletableFuture secondClose = fixture.generator.closeAsync(); + assertNotSame(firstClose, secondClose); + fixture.operation.get().run(); + secondOperation.complete(null); + secondClose.join(); + assertTrue(fixture.generator.isClosing()); + assertSame(secondClose, fixture.generator.closeAsync()); + verify(fixture.engine, times(2)).close(); + } + + @Test + public void failedRetryDispatchKeepsPartiallyClosedGenerationSealed() throws Exception { + CloseFixture fixture = new CloseFixture(); + CompletableFuture operation = fixture.queueOperation(); + CompletableFuture firstClose = fixture.generator.closeAsync(); + operation.completeExceptionally(new IllegalStateException("Planner remains active")); + assertTrue(firstClose.isCompletedExceptionally()); + doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator) + .withExclusiveControlFuture(any(Runnable.class)); + + CompletableFuture retry = fixture.generator.closeAsync(); + assertNotSame(firstClose, retry); + assertTrue(retry.isCompletedExceptionally()); + assertTrue(fixture.generator.isClosing()); + } + + @Test + public void initialDispatchFailureLeavesTheUnchangedGeneratorAvailable() throws Exception { + CloseFixture fixture = new CloseFixture(); + doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator) + .withExclusiveControlFuture(any(Runnable.class)); + + CompletableFuture failedClose = fixture.generator.closeAsync(); + assertTrue(failedClose.isCompletedExceptionally()); + assertFalse(fixture.generator.isClosing()); + } + + private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class CloseFixture { + private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final AtomicReference operation = new AtomicReference<>(); + private final ConcurrentLinkedQueue> operations = new ConcurrentLinkedQueue<>(); + + private CloseFixture() throws Exception { + setField(generator, "closeFuture", new AtomicReference>()); + setField(generator, "engine", engine); + setField(generator, "folder", mock(ReactiveFolder.class)); + setField(generator, "populators", new KList()); + doAnswer(invocation -> { + operation.set(invocation.getArgument(0, Runnable.class)); + return operations.remove(); + }).when(generator).withExclusiveControlFuture(any(Runnable.class)); + } + + private CompletableFuture queueOperation() { + CompletableFuture future = new CompletableFuture<>(); + operations.add(future); + return future; + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 493a26248..2c481c6ea 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -18,6 +18,7 @@ package art.arcane.iris.probe; +import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; import art.arcane.iris.engine.object.BlockDataMergeSupport; import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.TileData; @@ -57,6 +58,8 @@ public final class StubPlatform implements IrisPlatform { } public static void bindGenerationStateHandlers() { + DecoratorPlatformHooks.bind((state, hunk, rX, rZ, x, y, z, mantle) -> state, + (surface, upward) -> surface.isOccluding()); IrisObjectRotation.bindPlatformRotator(StubPlatform::rotateState); BlockDataMergeSupport.bindPlatformMerger(StubPlatform::mergeStates); TileData.bindPlatformReader(StubTileData::read); diff --git a/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java index 4cc149a7f..62ad735e7 100644 --- a/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.probe; +import art.arcane.iris.engine.decorator.IrisSpeleothems; import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; import org.junit.BeforeClass; import org.junit.Test; @@ -80,6 +82,18 @@ public final class StubPlatformStateTest { assertFalse(state("minecraft:brain_coral_fan").isWaterLogged()); } + @Test + public void resolvesSpikeSupportWithoutServerClasses() { + Hunk blocks = Hunk.newArrayHunk(1, 3, 1); + PlatformBlockState spike = state("minecraft:pointed_dripstone[vertical_direction=up,thickness=tip]"); + blocks.set(0, 0, 0, state("minecraft:stone")); + assertTrue(IrisSpeleothems.isSupported(spike, blocks, 0, 0, 1)); + blocks.set(0, 0, 0, state("minecraft:water[level=0]")); + assertFalse(IrisSpeleothems.isSupported(spike, blocks, 0, 0, 1)); + blocks.set(0, 0, 0, state("minecraft:air")); + assertFalse(IrisSpeleothems.isSupported(spike, blocks, 0, 0, 1)); + } + private PlatformBlockState state(String key) { return IrisPlatforms.get().registries().block(key); }