From 30113c413316f61639f827ea87f630e01852fc6a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 4 Sep 2026 13:57:39 -0400 Subject: [PATCH] Added generation history - World-local pack epochs preserve generated chunks across restart-only updates - Kernel catalogs and source seals retain executable generation behavior across jar changes - Crash-safe ownership, boundary, registry, semantic, and mantle records survive activation changes - New terrain blends lower surfaces and inverted ceilings against frozen historical boundaries - Cave, object, structure, biome, and hydrology work respects activation ownership - Find commands merge recorded facts with eligible active predictions for biomes, regions, structures, objects, and rivers - Bukkit, Fabric, Forge, and NeoForge route generation through retained runtime scopes - Shutdown ordering preserves class-loader resources until world and service cleanup finishes - Tests cover activation chains, failure recovery, retained registries, bounded lookup, and transition geometry --- .../core/nms/v26_2_R1/CustomBiomeSource.java | 240 +- .../nms/v26_2_R1/ImportedFeatureStage.java | 77 +- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 353 ++- .../iris/core/nms/v26_2_R1/NMSBinding.java | 122 +- .../nms/v26_2_R1/NmsGenerationRegistry.java | 182 ++ ...ustomBiomeSourceStructureContractTest.java | 8 +- .../GenerationHistoryRoutingContractTest.java | 164 + ...IrisChunkGeneratorFailureContractTest.java | 25 +- ...MSBindingShutdownBoundaryContractTest.java | 36 +- .../NativeStructureVolumeIndexTest.java | 58 + .../src/main/java/art/arcane/iris/Iris.java | 87 +- .../iris/core/BukkitWorldReconciler.java | 6 +- .../iris/core/IrisWorldGeneratorResolver.java | 169 +- .../core/PendingWorldReplacementManager.java | 29 +- .../iris/core/commands/CommandDeveloper.java | 4 +- .../iris/core/commands/CommandFind.java | 45 +- .../arcane/iris/core/service/CommandSVC.java | 2 + .../arcane/iris/IrisShutdownOrderingTest.java | 55 +- .../core/IrisWorldGeneratorResolverTest.java | 20 +- ...CommandDeveloperGenerationHistoryTest.java | 26 + .../CommandFindRiverContractTest.java | 26 + ...mmandFindStructureHistoryContractTest.java | 27 + .../BukkitEngineLifecycleContractTest.java | 2 +- .../service/CommandSVCFindParsingTest.java | 155 + adapters/fabric/build.gradle | 3 + adapters/forge/build.gradle | 3 + .../NativeStructureReferenceRepair.java | 38 +- .../nativegen/NativeStructureVolumeIndex.java | 120 +- .../iris/modded/IrisModdedBiomeSource.java | 237 +- .../iris/modded/IrisModdedChunkGenerator.java | 503 +++- .../arcane/iris/modded/MainWorldService.java | 13 + .../arcane/iris/modded/ModdedBiomeWriter.java | 6 +- .../iris/modded/ModdedDimensionManager.java | 332 +- .../iris/modded/ModdedDimensionMetadata.java | 14 +- .../modded/ModdedDimensionRegistryStore.java | 10 +- .../iris/modded/ModdedDimensionStorage.java | 4 +- .../iris/modded/ModdedForcedDatapack.java | 456 ++- .../ModdedGenerationHistoryStorage.java | 361 +++ .../iris/modded/ModdedGenerationMode.java | 17 + .../iris/modded/ModdedGenerationRegistry.java | 191 ++ .../modded/ModdedImportedFeatureStage.java | 60 +- .../modded/ModdedNativeStructureStage.java | 32 +- .../arcane/iris/modded/ModdedRegistries.java | 15 + .../iris/modded/ModdedRuntimeRegistry.java | 2 +- .../iris/modded/ModdedSpawnTableMerger.java | 58 +- .../art/arcane/iris/modded/ModdedStartup.java | 8 +- .../iris/modded/ModdedWorldEngines.java | 298 +- .../arcane/iris/modded/ModdedWorldgenIds.java | 34 +- .../command/ModdedCommandSuggestions.java | 11 +- .../modded/command/ModdedLocateCommands.java | 49 +- .../modded/command/ModdedStudioCommands.java | 2 +- .../modded/command/ModdedWorldCommands.java | 56 + .../modded/IrisModdedStructureParityTest.java | 6 +- .../iris/modded/ModdedForcedDatapackTest.java | 31 + ...dGenerationHistoryRoutingContractTest.java | 180 ++ .../ModdedGenerationHistoryStorageTest.java | 362 +++ .../ModdedGenerationLeaseContractTest.java | 6 +- .../ModdedLifecycleFailureContractTest.java | 57 +- .../ModdedRiverLocateContractTest.java | 28 + .../ModdedStructureHistoryContractTest.java | 34 + .../ModdedStudioTeleportContractTest.java | 4 +- adapters/neoforge/build.gradle | 3 + build.gradle | 4 + .../iris/buildtools/GenerationKernelSeal.java | 463 +++ .../test/java/GenerationKernelSealTest.java | 192 ++ core/agent/build.gradle | 8 + .../art/arcane/iris/util/agent/Installer.java | 34 +- .../agent/ClassLoaderCloseDeferralTest.java | 164 + core/build.gradle | 3 + core/generation-kernels/abi-1.seal | 1583 ++++++++++ core/generation-kernels/catalog.tsv | 3 + .../iris/core/IrisDatapackCompiler.java | 303 +- .../art/arcane/iris/core/IrisSettings.java | 5 + .../arcane/iris/core/IrisWorldStorage.java | 35 +- .../java/art/arcane/iris/core/IrisWorlds.java | 4 +- .../arcane/iris/core/ServerConfigurator.java | 2 +- .../lifecycle/BukkitWorldConfiguration.java | 15 +- .../lifecycle/WorldReplacementFilesystem.java | 17 +- .../art/arcane/iris/core/loader/IrisData.java | 139 +- .../localization/BukkitRuntimeMessages.java | 16 +- .../localization/DirectorCommandMessages.java | 8 +- .../art/arcane/iris/core/nms/INMSBinding.java | 22 +- .../iris/core/nms/ServerShutdownBoundary.java | 29 +- .../iris/core/service/LogFilterSVC.java | 17 +- .../iris/core/service/PreservationSVC.java | 19 +- .../arcane/iris/core/service/StudioSVC.java | 203 +- .../arcane/iris/core/tools/IrisCreator.java | 6 +- .../arcane/iris/core/tools/IrisToolbelt.java | 24 +- .../iris/core/tools/IrisWorldCreator.java | 35 +- .../arcane/iris/engine/EngineHotloader.java | 34 +- .../art/arcane/iris/engine/EngineRuntime.java | 37 +- .../iris/engine/EngineRuntimeBuilder.java | 297 +- .../iris/engine/EngineShutdownSequence.java | 112 +- .../arcane/iris/engine/GenerationRuntime.java | 98 + .../engine/GenerationRuntimeScopeState.java | 55 + .../art/arcane/iris/engine/IrisComplex.java | 206 +- .../art/arcane/iris/engine/IrisEngine.java | 743 ++++- .../arcane/iris/engine/IrisEngineMantle.java | 59 +- .../iris/engine/UpperDimensionContext.java | 74 +- .../engine/actuator/IrisBiomeActuator.java | 69 +- .../engine/actuator/IrisDecorantActuator.java | 7 +- .../actuator/IrisTerrainNormalActuator.java | 8 +- .../iris/engine/framework/EngineMode.java | 20 +- .../iris/engine/framework/HintedLocator.java | 206 +- .../engine/framework/HistoryAwareLocator.java | 227 ++ .../framework/IrisStructureLocator.java | 52 + .../arcane/iris/engine/framework/Locator.java | 198 +- .../NativeStructureOwnershipStore.java | 163 +- .../NativeStructurePlacementPlanner.java | 17 +- .../framework/NativeStructureVolumeMemo.java | 30 +- .../history/ChunkGenerationOwnership.java | 467 +++ .../history/ChunkGenerationSemantics.java | 479 +++ .../engine/history/GenerationActivation.java | 207 ++ .../engine/history/GenerationAdmission.java | 147 + .../iris/engine/history/GenerationBlend.java | 66 + .../engine/history/GenerationBoundary.java | 435 +++ .../GenerationBoundarySignatureSampler.java | 10 + .../history/GenerationBoundaryStore.java | 733 +++++ .../iris/engine/history/GenerationEpoch.java | 449 +++ .../GenerationEpochContractFactory.java | 211 ++ .../engine/history/GenerationFindCatalog.java | 221 ++ .../engine/history/GenerationHistory.java | 1127 +++++++ .../history/GenerationHistoryPaths.java | 111 + .../GenerationHistoryRuntimeRouter.java | 1295 ++++++++ .../history/GenerationHistoryStore.java | 383 +++ .../history/GenerationKernelCatalog.java | 91 + .../history/GenerationKernelRegistry.java | 253 ++ .../history/GenerationKernelSourceSeal.java | 133 + .../engine/history/GenerationKernelV1.java | 98 + .../engine/history/GenerationManifest.java | 560 ++++ .../history/GenerationPackFingerprint.java | 142 + .../history/GenerationPackRepository.java | 275 ++ .../history/GenerationPublicationLock.java | 106 + .../history/GenerationRegistryContract.java | 605 ++++ .../GenerationRegistryContractFactory.java | 1742 +++++++++++ .../history/GenerationSemanticCapture.java | 224 ++ .../history/GenerationSemanticIndex.java | 2681 +++++++++++++++++ .../history/GenerationSemanticQueries.java | 261 ++ .../engine/history/GenerationTransition.java | 123 + .../history/IrisBoundarySignatureSampler.java | 157 + .../history/RegionGenerationOwnership.java | 555 ++++ .../history/TerrainBoundarySignature.java | 170 ++ .../TerrainBoundarySignatureStore.java | 1397 +++++++++ .../history/TransitionBoundarySampler.java | 367 +++ .../history/TransitionGenerationPlan.java | 221 ++ .../engine/history/WorldChunkInventory.java | 238 ++ .../iris/engine/hydrology/HydrologyTile.java | 18 +- .../runtime/IrisHydrologyRuntime.java | 43 +- .../iris/engine/mantle/EngineMantle.java | 5 +- .../iris/engine/mantle/MantleWriter.java | 35 +- .../iris/engine/mantle/MatterGenerator.java | 26 +- .../CaveObjectPlacementTransaction.java | 8 + .../FloatingObjectPlacementTransaction.java | 9 + .../components/IrisStructureComponent.java | 34 +- .../components/MantleCarvingComponent.java | 12 +- .../MantleFloatingObjectComponent.java | 17 + .../components/MantleHydrologyComponent.java | 21 +- .../components/MantleObjectComponent.java | 55 +- .../engine/modifier/IrisCarveModifier.java | 4 +- .../IrisFloatingChildBiomeModifier.java | 13 +- .../engine/object/IrisBiomeDerivatives.java | 37 +- .../object/IrisCustomBiomeAliasResolver.java | 39 + .../iris/engine/object/IrisDimension.java | 134 +- .../engine/platform/BukkitChunkGenerator.java | 144 +- .../iris/engine/platform/EngineBukkitOps.java | 4 +- .../platform/bukkit/BukkitRegistries.java | 6 + .../LocatableObjectHandler.java | 13 + .../ReachableBiomeHandler.java | 3 +- .../ReachableRegionHandler.java | 23 + .../specialhandlers/StructureHandler.java | 4 + .../arcane/iris/util/project/agent/Agent.java | 27 + core/src/main/resources/languages/de_DE.json | 18 +- core/src/main/resources/languages/es_ES.json | 18 +- core/src/main/resources/languages/fi_FI.json | 18 +- core/src/main/resources/languages/fr_FR.json | 18 +- core/src/main/resources/languages/he_IL.json | 18 +- core/src/main/resources/languages/it_IT.json | 18 +- core/src/main/resources/languages/ja-JP.json | 18 +- core/src/main/resources/languages/ko_KR.json | 18 +- core/src/main/resources/languages/lt_LT.json | 18 +- core/src/main/resources/languages/nl_NL.json | 18 +- core/src/main/resources/languages/pl_PL.json | 18 +- core/src/main/resources/languages/pt_PT.json | 18 +- core/src/main/resources/languages/ru_RU.json | 18 +- core/src/main/resources/languages/tr_TR.json | 18 +- core/src/main/resources/languages/vi_VI.json | 18 +- core/src/main/resources/languages/zh_CN.json | 18 +- core/src/main/resources/languages/zh_TW.json | 18 +- ...sDatapackCompilerInputFingerprintTest.java | 96 + .../iris/core/IrisDatapackCompilerTest.java | 234 +- .../iris/core/IrisSettingsDefaultsTest.java | 11 + .../iris/core/IrisWorldStorageTest.java | 72 +- ...rConfiguratorRegistryVerificationTest.java | 1 + .../BukkitWorldConfigurationTest.java | 2 +- .../WorldReplacementBootstrapTest.java | 98 +- .../WorldReplacementFilesystemTest.java | 51 +- .../core/loader/IrisDataRetainedPackTest.java | 63 + .../core/localization/IrisLanguageTest.java | 26 +- .../core/nms/ServerShutdownBoundaryTest.java | 51 +- .../DefaultPackBootstrapProvisionerTest.java | 39 +- .../iris/core/service/LogFilterSVCTest.java | 61 + .../core/service/PreservationSVCTest.java | 68 + ...tudioSVCWorldPackFeedbackContractTest.java | 4 +- .../StudioSVCWorldPackPublishTest.java | 20 + ...isPersistentWorldCreationContractTest.java | 9 +- .../IrisToolbeltShutdownContractTest.java | 26 + .../EngineRuntimePublicationContractTest.java | 64 + .../EngineShutdownOwnershipContractTest.java | 42 +- .../engine/IrisComplexTransitionTest.java | 123 + .../IrisEngineGenerationRuntimeScopeTest.java | 620 ++++ .../engine/UpperDimensionTransitionTest.java | 112 + .../IrisBiomeActuatorCoordinateTest.java | 46 +- ...risTerrainNormalActuatorHydrologyTest.java | 28 + .../engine/framework/HintedLocatorTest.java | 276 ++ .../IrisStructureLocatorContractTest.java | 11 + .../NativeStructureOwnershipStoreTest.java | 111 + .../NativeStructurePlacementPlannerTest.java | 12 + .../NativeStructureVolumeMemoTest.java | 42 +- .../history/ChunkGenerationOwnershipTest.java | 260 ++ .../history/ChunkGenerationSemanticsTest.java | 131 + .../history/GenerationAdmissionTest.java | 100 + .../engine/history/GenerationBlendTest.java | 46 + .../history/GenerationBoundaryStoreTest.java | 277 ++ .../history/GenerationBoundaryTest.java | 140 + .../GenerationEpochContractFactoryTest.java | 288 ++ .../engine/history/GenerationEpochTest.java | 320 ++ .../history/GenerationFindCatalogTest.java | 283 ++ .../history/GenerationHistoryPathsTest.java | 37 + .../GenerationHistoryRuntimeRouterTest.java | 1073 +++++++ .../history/GenerationHistoryStoreTest.java | 341 +++ .../engine/history/GenerationHistoryTest.java | 789 +++++ .../history/GenerationKernelCatalogTest.java | 42 + .../history/GenerationKernelRegistryTest.java | 101 + .../GenerationKernelSourceSealTest.java | 56 + .../history/GenerationManifestTest.java | 136 + .../GenerationPackFingerprintTest.java | 67 + .../history/GenerationPackRepositoryTest.java | 233 ++ ...GenerationRegistryContractFactoryTest.java | 810 +++++ .../GenerationRegistryContractTest.java | 229 ++ .../history/GenerationSemanticIndexTest.java | 730 +++++ .../GenerationSemanticQueriesTest.java | 253 ++ .../history/GenerationTransitionTest.java | 83 + .../IrisBoundarySignatureSamplerTest.java | 58 + .../RegionGenerationOwnershipTest.java | 157 + .../TerrainBoundarySignatureStoreTest.java | 445 +++ .../history/TerrainBoundarySignatureTest.java | 80 + .../history/TransitionGenerationPlanTest.java | 486 +++ .../history/WorldChunkInventoryTest.java | 212 ++ .../engine/hydrology/HydrologyModelTest.java | 68 +- .../mantle/MantleWriterOverlayTest.java | 31 +- .../MatterGeneratorCarvePassRadiusTest.java | 32 + .../CaveObjectPlacementTransactionTest.java | 20 + ...ntleObjectComponentBoundaryRadiusTest.java | 19 + ...eneratorGenerationHistoryContractTest.java | 91 + gradle/generation-kernel-seals.gradle | 133 + gradle/modded-generation-kernel-seals.gradle | 51 + .../iris/spi/PlatformGenerationRegistry.java | 84 + .../arcane/iris/spi/PlatformRegistries.java | 6 + 258 files changed, 38965 insertions(+), 1659 deletions(-) create mode 100644 adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/GenerationHistoryRoutingContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandDeveloperGenerationHistoryTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindRiverContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindStructureHistoryContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/CommandSVCFindParsingTest.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationMode.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryRoutingContractTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedRiverLocateContractTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStructureHistoryContractTest.java create mode 100644 buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationKernelSeal.java create mode 100644 buildSrc/src/test/java/GenerationKernelSealTest.java create mode 100644 core/agent/src/test/java/art/arcane/iris/util/project/agent/ClassLoaderCloseDeferralTest.java create mode 100644 core/generation-kernels/abi-1.seal create mode 100644 core/generation-kernels/catalog.tsv create mode 100644 core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java create mode 100644 core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/HistoryAwareLocator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationOwnership.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationActivation.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationBlend.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationBoundary.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationBoundarySignatureSampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationEpoch.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationFindCatalog.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryPaths.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationKernelCatalog.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationKernelSourceSeal.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationManifest.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationPublicationLock.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContract.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContractFactory.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticQueries.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/GenerationTransition.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/IrisBoundarySignatureSampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/RegionGenerationOwnership.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignature.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/TransitionGenerationPlan.java create mode 100644 core/src/main/java/art/arcane/iris/engine/history/WorldChunkInventory.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisCustomBiomeAliasResolver.java create mode 100644 core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/LocatableObjectHandler.java create mode 100644 core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableRegionHandler.java create mode 100644 core/src/test/java/art/arcane/iris/core/loader/IrisDataRetainedPackTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/LogFilterSVCTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/PreservationSVCTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltShutdownContractTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisComplexTransitionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationSemanticsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationBlendTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationEpochTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationFindCatalogTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryPathsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationKernelCatalogTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationKernelSourceSealTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationManifestTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractFactoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticQueriesTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationTransitionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/IrisBoundarySignatureSamplerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/RegionGenerationOwnershipTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStoreTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationHistoryContractTest.java create mode 100644 gradle/generation-kernel-seals.gradle create mode 100644 gradle/modded-generation-kernel-seals.gradle create mode 100644 spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 4d61690c4..a96cf9b63 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -5,9 +5,11 @@ import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisDimension; @@ -34,6 +36,7 @@ import org.bukkit.craftbukkit.CraftWorld; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -59,14 +62,11 @@ public class CustomBiomeSource extends BiomeSource { private final Registry biomeRegistry; private final AtomicCache registryAccess = new AtomicCache<>(); private final Holder fallbackBiome; - private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>(); - private volatile KMap> customBiomes; - private volatile Map> vanillaSpawnBiomes; - private volatile IrisDimension cacheDimension; - private volatile int cacheRuntimeId; + private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap runtimeBiomeStates = new ConcurrentHashMap<>(); public CustomBiomeSource(long seed, Engine engine, World world) { this.engine = engine; @@ -74,10 +74,10 @@ public class CustomBiomeSource extends BiomeSource { this.biomeCustomRegistry = registry().lookup(Registries.BIOME).orElse(null); this.biomeRegistry = ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())).lookup(Registries.BIOME).orElse(null); this.fallbackBiome = resolveFallbackBiome(this.biomeRegistry, this.biomeCustomRegistry); - this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome); - this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine); - this.cacheDimension = engine.getDimension(); - this.cacheRuntimeId = engine.getCacheID(); + if (engine instanceof IrisEngine irisEngine) { + irisEngine.addGenerationRuntimeRetirementListener(this::evictRuntimeCaches); + } + runtimeBiomeState(); } private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine) { @@ -187,7 +187,7 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { throw new IllegalStateException("Iris possible biome lookup has no active engine runtime"); } - ensureCachesCurrent(); + runtimeBiomeState(); World world = BukkitWorldBinding.world(engine.getWorld()); if (world == null) { throw new IllegalStateException("Iris biome source has no bound Bukkit world"); @@ -263,8 +263,7 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { return null; } - ensureCachesCurrent(); - return vanillaSpawnBiomes.get(biome.value()); + return runtimeBiomeState().vanillaSpawnBiomes().get(biome.value()); } } @@ -285,20 +284,22 @@ public class CustomBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { - GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_structure_biome"); - if (lease == null) { - throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); - } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + int blockX = x << 2; + int blockZ = z << 2; + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openHistoryCoordinateScope(blockX, blockZ, "bukkit_structure_biome"); + GenerationSessionLease lease = requireGenerationLease( + "bukkit_structure_biome", + "Iris structure biome lookup was rejected during an engine transition"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { if (!isRuntimeAvailable()) { throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); } - ensureCachesCurrent(); if (isGuaranteedSurfaceBiome(y)) { return getSurfaceStructureBiomeHolder(x, z); } - long cacheKey = packNoiseKey(x, y, z); + RuntimeNoiseKey cacheKey = new RuntimeNoiseKey(engine.getCacheID(), packNoiseKey(x, y, z)); Holder cachedHolder = structureBiomeCache.get(cacheKey); if (cachedHolder != null) { return cachedHolder; @@ -340,7 +341,6 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { throw new IllegalStateException("Iris structure ring biome lookup has no active engine runtime"); } - ensureCachesCurrent(); return findNaturalSurfaceBiomeHorizontal( x, y, z, searchRadius, quartStep, allowed, random); } @@ -362,7 +362,6 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { throw new IllegalStateException("Iris biome radius lookup has no active engine runtime"); } - ensureCachesCurrent(); int minQuartY = QuartPos.fromBlock(y - radius); boolean monumentQuery = radius == 29 && y == engine.getMinHeight() + engine.getDimension().getFluidHeight(); @@ -385,20 +384,27 @@ public class CustomBiomeSource extends BiomeSource { } private Holder getSurfaceStructureBiomeHolder(int x, int z) { - long columnKey = packColumnKey(x, z); - Holder surfaceHolder = surfaceStructureBiomeCache.get(columnKey); - if (surfaceHolder != null) { - return surfaceHolder; + int blockX = x << 2; + int blockZ = z << 2; + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openHistoryCoordinateScope(blockX, blockZ, "bukkit_surface_structure_biome")) { + RuntimeColumnKey columnKey = new RuntimeColumnKey( + engine.getCacheID(), packColumnKey(x, z)); + Holder surfaceHolder = surfaceStructureBiomeCache.get(columnKey); + if (surfaceHolder != null) { + return surfaceHolder; + } + Holder resolvedSurfaceHolder = resolveSurfaceStructureBiomeHolder(x, z); + Holder existingSurfaceHolder = surfaceStructureBiomeCache.putIfAbsent( + columnKey, resolvedSurfaceHolder); + if (existingSurfaceHolder != null) { + return existingSurfaceHolder; + } + if (surfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + surfaceStructureBiomeCache.clear(); + } + return resolvedSurfaceHolder; } - Holder resolvedSurfaceHolder = resolveSurfaceStructureBiomeHolder(x, z); - Holder existingSurfaceHolder = surfaceStructureBiomeCache.putIfAbsent(columnKey, resolvedSurfaceHolder); - if (existingSurfaceHolder != null) { - return existingSurfaceHolder; - } - if (surfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - surfaceStructureBiomeCache.clear(); - } - return resolvedSurfaceHolder; } private Pair> findNaturalSurfaceBiomeHorizontal( @@ -438,21 +444,27 @@ public class CustomBiomeSource extends BiomeSource { } private Holder getNaturalSurfaceStructureBiomeHolder(int x, int z) { - long columnKey = packColumnKey(x, z); - Holder cachedHolder = naturalSurfaceStructureBiomeCache.get(columnKey); - if (cachedHolder != null) { - return cachedHolder; + int blockX = x << 2; + int blockZ = z << 2; + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openHistoryCoordinateScope(blockX, blockZ, "bukkit_natural_structure_biome")) { + RuntimeColumnKey columnKey = new RuntimeColumnKey( + engine.getCacheID(), packColumnKey(x, z)); + Holder cachedHolder = naturalSurfaceStructureBiomeCache.get(columnKey); + if (cachedHolder != null) { + return cachedHolder; + } + Holder resolvedHolder = resolveNaturalSurfaceStructureBiomeHolder(x, z); + Holder existingHolder = naturalSurfaceStructureBiomeCache.putIfAbsent( + columnKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + if (naturalSurfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + naturalSurfaceStructureBiomeCache.clear(); + } + return resolvedHolder; } - Holder resolvedHolder = resolveNaturalSurfaceStructureBiomeHolder(x, z); - Holder existingHolder = naturalSurfaceStructureBiomeCache.putIfAbsent( - columnKey, resolvedHolder); - if (existingHolder != null) { - return existingHolder; - } - if (naturalSurfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - naturalSurfaceStructureBiomeCache.clear(); - } - return resolvedHolder; } private boolean isGuaranteedSurfaceBiome(int quartY) { @@ -500,11 +512,12 @@ public class CustomBiomeSource extends BiomeSource { } public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { - GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_visible_biome"); - if (lease == null) { - throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); - } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + x << 2, z << 2, "bukkit_visible_biome"); + GenerationSessionLease lease = requireGenerationLease( + "bukkit_visible_biome", + "Iris visible biome lookup was rejected during an engine transition"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { prepareVisibleBiomeBatch(); return getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler); } @@ -514,7 +527,7 @@ public class CustomBiomeSource extends BiomeSource { if (!isRuntimeAvailable()) { throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); } - ensureCachesCurrent(); + runtimeBiomeState(); } Holder getVisibleNoiseBiomeWithActiveGenerationLease( @@ -533,7 +546,7 @@ public class CustomBiomeSource extends BiomeSource { Climate.Sampler sampler, IrisDimensionCarvingResolver.State resolverState ) { - long cacheKey = packNoiseKey(x, y, z); + RuntimeNoiseKey cacheKey = new RuntimeNoiseKey(engine.getCacheID(), packNoiseKey(x, y, z)); Holder cachedHolder = noiseBiomeCache.get(cacheKey); if (cachedHolder != null) { return cachedHolder; @@ -567,32 +580,71 @@ public class CustomBiomeSource extends BiomeSource { } } + private GenerationSessionLease requireGenerationLease(String operation, String rejectionMessage) { + GenerationSessionLease lease = tryAcquireGenerationLease(operation); + if (lease == null) { + throw new IllegalStateException(rejectionMessage); + } + return lease; + } + private boolean isRuntimeAvailable() { return !engine.isClosed() && engine.getComplex() != null; } - private void ensureCachesCurrent() { + private GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( + int blockX, + int blockZ, + String operation + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + return null; + } + try { + return irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + + " could not route generation history at " + + blockX + "," + blockZ + ".", failure); + } + } + + private void evictRuntimeCaches(int runtimeId) { + runtimeBiomeStates.remove(runtimeId); + noiseBiomeCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + structureBiomeCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + surfaceStructureBiomeCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + naturalSurfaceStructureBiomeCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + } + + private RuntimeBiomeState runtimeBiomeState() { IrisDimension dimension = engine.getDimension(); int runtimeId = engine.getCacheID(); - if (cacheDimension == dimension && cacheRuntimeId == runtimeId) { - return; + RuntimeBiomeState existing = runtimeBiomeStates.get(runtimeId); + if (existing != null) { + if (existing.dimension() != dimension) { + throw new IllegalStateException("Iris biome runtime cache ID is shared by different dimensions."); + } + return existing; } - synchronized (this) { - if (cacheDimension == dimension && cacheRuntimeId == runtimeId) { - return; + synchronized (runtimeBiomeStates) { + existing = runtimeBiomeStates.get(runtimeId); + if (existing != null) { + if (existing.dimension() != dimension) { + throw new IllegalStateException("Iris biome runtime cache ID is shared by different dimensions."); + } + return existing; } KMap> refreshedCustomBiomes = fillCustomBiomes( biomeCustomRegistry, engine, fallbackBiome); Map> refreshedSpawnBiomes = fillVanillaSpawnBiomes( biomeCustomRegistry, biomeRegistry, engine); - noiseBiomeCache.clear(); - structureBiomeCache.clear(); - surfaceStructureBiomeCache.clear(); - naturalSurfaceStructureBiomeCache.clear(); - customBiomes = refreshedCustomBiomes; - vanillaSpawnBiomes = refreshedSpawnBiomes; - cacheDimension = dimension; - cacheRuntimeId = runtimeId; + RuntimeBiomeState created = new RuntimeBiomeState( + dimension, + refreshedCustomBiomes, + refreshedSpawnBiomes); + runtimeBiomeStates.put(runtimeId, created); + return created; } } @@ -619,6 +671,14 @@ public class CustomBiomeSource extends BiomeSource { int z, IrisDimensionCarvingResolver.State resolverState ) { + int blockX = x << 2; + int blockY = y << 2; + int blockZ = z << 2; + Optional historicalKey = engine.getComplex().historicalPhysicalBiomeKeyAt( + blockX, blockY, blockZ); + if (historicalKey.isPresent()) { + return resolvePhysicalBiomeHolder(historicalKey.get()); + } BiomeResolution resolution = resolveBiomeResolution(x, y, z, resolverState); if (resolution == null) { return getFallbackBiome(); @@ -642,7 +702,7 @@ public class CustomBiomeSource extends BiomeSource { private Holder resolveCustomHolder(BiomeResolution resolution) { IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ); if (customBiome != null) { - Holder holder = customBiomes.get(customBiome.getId()); + Holder holder = runtimeBiomeState().customBiomes().get(customBiome.getId()); if (holder != null) { return holder; } @@ -651,6 +711,18 @@ public class CustomBiomeSource extends BiomeSource { return getFallbackBiome(); } + private Holder resolvePhysicalBiomeHolder(String physicalKey) { + Holder holder = resolveBiomeHolder(biomeCustomRegistry, physicalKey); + if (holder == null) { + holder = resolveBiomeHolder(biomeRegistry, physicalKey); + } + if (holder == null) { + throw new IllegalStateException("Historical Iris biome '" + physicalKey + + "' is not registered in the active world registry."); + } + return holder; + } + private BiomeResolution resolveBiomeResolution(int x, int y, int z) { return resolveBiomeResolution(x, y, z, null); } @@ -739,10 +811,11 @@ public class CustomBiomeSource extends BiomeSource { return null; } - Identifier resourceLocation = Identifier.fromNamespaceAndPath( - engine.getDimension().getLoadKey().toLowerCase(java.util.Locale.ROOT), - customBiomeId.toLowerCase(java.util.Locale.ROOT) - ); + String physicalKey = engine.getData().customBiomeResourceKey(engine.getDimension(), customBiomeId); + Identifier resourceLocation = Identifier.tryParse(physicalKey); + if (resourceLocation == null) { + throw new IllegalStateException("Invalid Iris custom biome resource key '" + physicalKey + "'."); + } Biome biome = customRegistry.getValue(resourceLocation); if (biome == null) { return null; @@ -823,4 +896,17 @@ public class CustomBiomeSource extends BiomeSource { this.rng = rng; } } + + private record RuntimeNoiseKey(int runtimeId, long coordinateKey) { + } + + private record RuntimeColumnKey(int runtimeId, long coordinateKey) { + } + + private record RuntimeBiomeState( + IrisDimension dimension, + KMap> customBiomes, + Map> vanillaSpawnBiomes + ) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java index 06313f6fd..e8d5fd3ce 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java @@ -40,6 +40,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Bukkit twin of the modded imported-feature stage. Same control, same semantics, same seeds: with @@ -57,9 +58,7 @@ final class ImportedFeatureStage { private static final String CYCLE_MARKER = "Feature order cycle found"; private final Engine engine; - private volatile FeatureTable featureTable; - private volatile IrisDimension inertDimension; - private volatile int settledRuntimeId; + private final ConcurrentHashMap runtimeStates = new ConcurrentHashMap<>(); ImportedFeatureStage(Engine engine) { this.engine = engine; @@ -71,7 +70,8 @@ final class ImportedFeatureStage { * does today. */ BiomeGenerationSettings generationSettings(Holder biome) { - FeatureTable table = featureTable; + RuntimeFeatureState state = currentState(); + FeatureTable table = state == null ? null : state.featureTable(); if (table == null) { return null; } @@ -82,50 +82,46 @@ final class ImportedFeatureStage { /** * Builds the table on the first decorated chunk, or leaves the stage inert. A feature-order cycle is * reported once here and degrades to features-off; it never reaches chunk generation as a crash. - * - *

The volatile fast path is unlocked, so a prepared stage costs two reads per chunk. The build is - * synchronized: every worldgen thread decorating a chunk calls this, and two threads that both found the - * stage unprepared would each run {@code FeatureSorter}, whose cycle detection is the expensive part. */ void prepare(WorldGenLevel level) { IrisDimension dimension = engine.getDimension(); int runtimeId = engine.getCacheID(); - if (settled(dimension, runtimeId)) { + if (settled(runtimeStates.get(runtimeId), dimension)) { return; } synchronized (this) { - if (settled(dimension, runtimeId)) { + if (settled(runtimeStates.get(runtimeId), dimension)) { return; } - build(level, dimension); - settledRuntimeId = runtimeId; + runtimeStates.put(runtimeId, build(level, dimension)); } } - private boolean settled(IrisDimension dimension, int runtimeId) { - if (settledRuntimeId != runtimeId) { - return false; - } - FeatureTable current = featureTable; - if (current != null && current.dimension() == dimension) { - return true; - } - return inertDimension == dimension; + void evictRuntime(int runtimeId) { + runtimeStates.remove(runtimeId); } - private void build(WorldGenLevel level, IrisDimension dimension) { + private RuntimeFeatureState currentState() { + IrisDimension dimension = engine.getDimension(); + RuntimeFeatureState state = runtimeStates.get(engine.getCacheID()); + return settled(state, dimension) ? state : null; + } + + private static boolean settled(RuntimeFeatureState state, IrisDimension dimension) { + return state != null && state.dimension() == dimension; + } + + private RuntimeFeatureState build(WorldGenLevel level, IrisDimension dimension) { IrisImportedFeatureControl control; try { control = NativeFeatureGenerationPolicy.control(engine); } catch (RuntimeException error) { IrisLogging.error("Iris could not read importedFeatures for this dimension; features off: " + error); - markInert(dimension); - return; + return new RuntimeFeatureState(dimension, null); } if (!control.shouldGenerateFeatures()) { - markInert(dimension); - return; + return new RuntimeFeatureState(dimension, null); } FeatureTable built; try { @@ -133,23 +129,15 @@ final class ImportedFeatureStage { } catch (Throwable error) { IrisLogging.error("Iris importedFeatures is off for " + dimensionKey() + ": feature table construction failed: " + error); - markInert(dimension); - return; + return new RuntimeFeatureState(dimension, null); } if (built == null) { - markInert(dimension); - return; + return new RuntimeFeatureState(dimension, null); } - featureTable = built; - inertDimension = null; IrisLogging.info("Iris importedFeatures on for " + dimensionKey() + ": " + built.biomes().size() + " biomes, " + built.steps().size() + " steps, " + built.derivatives().size() + " custom-biome derivative maps"); - } - - private void markInert(IrisDimension dimension) { - featureTable = null; - inertDimension = dimension; + return new RuntimeFeatureState(dimension, built); } private FeatureTable buildTable(WorldGenLevel level, IrisImportedFeatureControl control, @@ -198,7 +186,6 @@ final class ImportedFeatureStage { */ private Set visibleBiomeKeys() { Set keys = new LinkedHashSet<>(); - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); for (IrisBiome irisBiome : engine.getAllBiomes()) { addKey(keys, irisBiome.getStructureDerivativeKey()); addKey(keys, irisBiome.getDerivativeKey()); @@ -213,7 +200,7 @@ final class ImportedFeatureStage { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - addKey(keys, namespace + ":" + customBiome.getId()); + addKey(keys, engine.getData().customBiomeResourceKey(engine.getDimension(), customBiome)); } } return keys; @@ -226,7 +213,6 @@ final class ImportedFeatureStage { private Map> customBiomeDerivatives(Registry registry, Map> byKey) { Map> derivatives = new HashMap<>(); - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); for (IrisBiome irisBiome : engine.getAllBiomes()) { if (!irisBiome.isCustom()) { continue; @@ -246,7 +232,10 @@ final class ImportedFeatureStage { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - derivatives.put(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT), derivative); + derivatives.put( + engine.getData().customBiomeResourceKey(engine.getDimension(), customBiome), + derivative + ); } } return derivatives; @@ -277,7 +266,8 @@ final class ImportedFeatureStage { * control is disabled or the table degraded. */ void run(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator owner) { - FeatureTable table = featureTable; + RuntimeFeatureState state = currentState(); + FeatureTable table = state == null ? null : state.featureTable(); if (table == null) { return; } @@ -405,4 +395,7 @@ final class ImportedFeatureStage { List steps, Map> derivatives, boolean filtered) { } + + private record RuntimeFeatureState(IrisDimension dimension, FeatureTable featureTable) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 972726209..635e188de 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -5,9 +5,11 @@ import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; @@ -99,6 +101,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -130,8 +133,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private final int runtimeHeight; private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); - private volatile IrisDimension spawnTableDimension; - private volatile int spawnTableRuntimeId; private final ImportedFeatureStage importedFeatures; private final AtomicReference retainedStudioStructureState = new AtomicReference<>(); private volatile ReachableStructureCache reachableStructureCache; @@ -155,9 +156,25 @@ public class IrisChunkGenerator extends CustomChunkGenerator { this.runtimeMinY = level.getMinY(); this.runtimeHeight = level.getHeight(); this.runtimeSeaLevel = runtimeMinY + engine.getDimension().getFluidHeight(); + if (engine instanceof IrisEngine irisEngine) { + irisEngine.addGenerationRuntimeRetirementListener(this::evictRuntimeCaches); + } installNativeStructureVolumeIndex(level); } + private void evictRuntimeCaches(int runtimeId) { + importedFeatures.evictRuntime(runtimeId); + mergedSpawnTables.keySet().removeIf(key -> key.runtimeId() == runtimeId); + ReachableStructureCache reachable = reachableStructureCache; + if (reachable != null && reachable.runtimeId() == runtimeId) { + reachableStructureCache = null; + } + StructureStepCache steps = structureStepCache; + if (steps != null && steps.runtimeId() == runtimeId) { + structureStepCache = null; + } + } + private void installNativeStructureVolumeIndex(ServerLevel level) { WeakReference levelReference = new WeakReference<>(level); WeakReference generatorReference = new WeakReference<>(this); @@ -183,7 +200,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (level != runtimeLevel || level.getChunkSource().getGenerator() != this) { return null; } - try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + SectionPos.blockToSectionCoord(pos.getX()), + SectionPos.blockToSectionCoord(pos.getZ()), + "bukkit_nms_structure_locate"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { HolderSet reachable = filterReachableStructures(level, holders); NativeStructureVanillaLocator.Candidate nativeCandidate = @@ -362,9 +384,16 @@ public class IrisChunkGenerator extends CustomChunkGenerator { || runtimeLevel.getChunkSource().getGeneratorState() != structureState) { return; } + ChunkPos chunkPos = access.getPos(); try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_structures"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_create_structures"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!allowsRoutedDiscreteGeneration(access, ChunkStatus.STRUCTURE_STARTS)) { + return; + } Map previousStarts = new HashMap<>(access.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); Map configuredStarts = NativeStructureStartInjector.inject( @@ -396,15 +425,28 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (!start.isValid() || previousStarts.get(structure) == start) { continue; } - if (configuredStarts.containsKey(structure)) { - continue; - } Identifier id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); if (structureId == null) { throw NativeStructureGenerationException.failure( "resolution", null, chunkPos.x(), chunkPos.z()); } + BoundingBox footprint = start.getBoundingBox(); + if (!engine.getComplex().allowsNewGenerationFootprint( + footprint.minX(), + footprint.minZ(), + footprint.maxX(), + footprint.maxZ())) { + access.setStartForStructure(structure, StructureStart.INVALID_START); + if (configuredStarts.containsKey(structure)) { + NativeStructureOwnershipStore.discard( + engine, structureId, chunkPos.x(), chunkPos.z()); + } + continue; + } + if (configuredStarts.containsKey(structure)) { + continue; + } boolean undergroundStep = NativeStructureVegetationClearer.isUndergroundStep(structure.step()); IrisNativeStructureDecision decision; try { @@ -631,7 +673,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (runtimeLevel.getChunkSource().getGenerator() != this) { return; } + ChunkPos chunkPos = ichunkaccess.getPos(); try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_references"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_create_references"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { NativeStructureReferenceRepair.createReferences( @@ -641,7 +687,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public CompletableFuture createBiomes(RandomState randomstate, Blender blender, StructureManager structuremanager, ChunkAccess ichunkaccess) { + ChunkPos chunkPos = ichunkaccess.getPos(); try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_biomes"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_create_biomes"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { customBiomeSource.prepareVisibleBiomeBatch(); @@ -656,31 +706,63 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void buildSurface(WorldGenRegion regionlimitedworldaccess, StructureManager structuremanager, RandomState randomstate, ChunkAccess ichunkaccess) { - delegate.buildSurface(regionlimitedworldaccess, structuremanager, randomstate, ichunkaccess); + ChunkPos chunkPos = ichunkaccess.getPos(); + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_build_surface"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_build_surface"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_build_surface"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + delegate.buildSurface(regionlimitedworldaccess, structuremanager, randomstate, ichunkaccess); + } } @Override public void applyCarvers(WorldGenRegion regionlimitedworldaccess, long seed, RandomState randomstate, BiomeManager biomemanager, StructureManager structuremanager, ChunkAccess ichunkaccess) { - delegate.applyCarvers(regionlimitedworldaccess, seed, randomstate, biomemanager, structuremanager, ichunkaccess); + ChunkPos chunkPos = ichunkaccess.getPos(); + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_apply_carvers"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_apply_carvers"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_apply_carvers"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + delegate.applyCarvers(regionlimitedworldaccess, seed, randomstate, biomemanager, structuremanager, ichunkaccess); + } } @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomstate, StructureManager structuremanager, ChunkAccess ichunkaccess) { + ChunkPos chunkPos = ichunkaccess.getPos(); BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage( - ichunkaccess.getPos(), + chunkPos, "bukkit_nms_chunk_pipeline"); - GenerationSessionLease lease; + GenerationHistoryRuntimeRouter.RuntimeRoute route; try { - lease = requireGenerationLease("bukkit_nms_chunk_pipeline"); + route = openHistoryRoute(chunkPos.x(), chunkPos.z(), "bukkit_nms_chunk_pipeline"); } catch (RuntimeException | Error failure) { stage.close(); throw failure; } + GenerationSessionLease lease; try { - CompletableFuture pipeline = delegate - .fillFromNoise(blender, randomstate, structuremanager, ichunkaccess) + lease = requireGenerationLease("bukkit_nms_chunk_pipeline"); + } catch (RuntimeException | Error failure) { + closeHistoryRoute(route, failure); + stage.close(); + throw failure; + } + try { + CompletableFuture delegatePipeline; + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + delegatePipeline = delegate.fillFromNoise( + blender, randomstate, structuremanager, ichunkaccess); + } + CompletableFuture pipeline = delegatePipeline .thenApply(filled -> { - try (IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = + openHistoryRuntimeScope(route); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { primeWorldgenHeightmaps(filled); return filled; } @@ -688,24 +770,78 @@ public class IrisChunkGenerator extends CustomChunkGenerator { CompletableFuture completion = new CompletableFuture<>(); pipeline.whenComplete((filled, failure) -> { boolean cancelled = isCancellationFailure(failure); - lease.close(); - stage.close(); - if (failure == null) { + Throwable completionFailure = closeNoisePipelineResources( + failure, lease, route, stage); + if (completionFailure == null) { completion.complete(filled); } else if (cancelled) { completion.cancel(false); } else { - completion.completeExceptionally(failure); + completion.completeExceptionally(completionFailure); } }); return completion; } catch (RuntimeException | Error failure) { lease.close(); + closeHistoryRoute(route, failure); stage.close(); throw failure; } } + private static Throwable closeNoisePipelineResources( + Throwable failure, + GenerationSessionLease lease, + GenerationHistoryRuntimeRouter.RuntimeRoute route, + BukkitChunkGenerator.GenerationStagePermit stage + ) { + Throwable result = failure; + try { + lease.close(); + } catch (Throwable closeFailure) { + result = appendFailure(result, closeFailure); + } + try { + if (route != null) { + route.close(); + } + } catch (Throwable closeFailure) { + result = appendFailure(result, closeFailure); + } + try { + stage.close(); + } catch (Throwable closeFailure) { + result = appendFailure(result, closeFailure); + } + return result; + } + + private static void closeHistoryRoute( + GenerationHistoryRuntimeRouter.RuntimeRoute route, + Throwable failure + ) { + if (route == null) { + return; + } + try { + route.close(); + } catch (Throwable closeFailure) { + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private static Throwable appendFailure(Throwable failure, Throwable closeFailure) { + if (failure == null) { + return closeFailure; + } + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + return failure; + } + private static boolean isCancellationFailure(Throwable failure) { Throwable current = failure; while (current instanceof CompletionException && current.getCause() != null) { @@ -728,6 +864,20 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public WeightedList getMobsAt(Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { + try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( + blockposition.getX(), blockposition.getZ(), "bukkit_nms_mob_spawns"); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_mob_spawns"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition); + } + } + + private WeightedList getMobsAtWithActiveRuntime( + Holder holder, + StructureManager structuremanager, + MobCategory enumcreaturetype, + BlockPos blockposition + ) { Holder vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder); if (vanillaSpawnBiome == null) { return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition); @@ -748,18 +898,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return explicitSpawns; } - IrisDimension spawnDimension = engine.getDimension(); int spawnRuntimeId = engine.getCacheID(); - if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) { - synchronized (mergedSpawnTables) { - if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) { - mergedSpawnTables.clear(); - spawnTableDimension = spawnDimension; - spawnTableRuntimeId = spawnRuntimeId; - } - } - } - SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype); + SpawnTableKey key = new SpawnTableKey(spawnRuntimeId, holder.value(), enumcreaturetype); return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns)); } @@ -775,19 +915,26 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) { + ChunkPos chunkPos = ichunkaccess.getPos(); try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_biome_decoration"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + chunkPos.x(), chunkPos.z(), "bukkit_nms_biome_decoration"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); - IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a - // feature-order cycle is reported once and degraded to features-off. - importedFeatures.prepare(generatoraccessseed); - addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); - placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); - // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with - // addVanillaDecorations=false below, so the vanilla half never runs twice. Inert unless the - // dimension set importedFeatures.enabled. - importedFeatures.run(generatoraccessseed, ichunkaccess, this); - delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (allowsRoutedDiscreteGeneration(ichunkaccess, ChunkStatus.FEATURES)) { + // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a + // feature-order cycle is reported once and degraded to features-off. + importedFeatures.prepare(generatoraccessseed); + addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); + placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); + // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with + // addVanillaDecorations=false below, so the vanilla half never runs twice. Inert unless the + // dimension set importedFeatures.enabled. + importedFeatures.run(generatoraccessseed, ichunkaccess, this); + delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); + } + claimGeneratedSemantics(route, "bukkit_nms_biome_decoration"); } } @@ -839,6 +986,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { List starts = structureManager.startsForStructure(sectionPos, structure); List resolvedPlacements = new ArrayList<>(starts.size()); for (StructureStart start : starts) { + BoundingBox footprint = start.getBoundingBox(); + if (!engine.getComplex().allowsNewGenerationFootprint( + footprint.minX(), + footprint.minZ(), + footprint.maxX(), + footprint.maxZ())) { + continue; + } NativeStructureOwnershipRecord ownership = NativeStructureOwnershipRecovery.resolve( engine, world.getLevel(), structureId, structure, start); @@ -957,13 +1112,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } private List> structuresByStep(Registry registry) { + int runtimeId = engine.getCacheID(); StructureStepCache cached = structureStepCache; - if (cached != null && cached.registry() == registry) { + if (cached != null && cached.runtimeId() == runtimeId && cached.registry() == registry) { return cached.structures(); } synchronized (this) { cached = structureStepCache; - if (cached != null && cached.registry() == registry) { + if (cached != null && cached.runtimeId() == runtimeId && cached.registry() == registry) { return cached.structures(); } int steps = GenerationStep.Decoration.values().length; @@ -978,7 +1134,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { grouped.set(step, List.copyOf(grouped.get(step))); } List> resolved = List.copyOf(grouped); - structureStepCache = new StructureStepCache(registry, resolved); + structureStepCache = new StructureStepCache(runtimeId, registry, resolved); return resolved; } } @@ -1010,7 +1166,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void addVanillaDecorations(WorldGenLevel level, ChunkAccess chunkAccess, StructureManager structureManager) { - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_heightmaps"); + ChunkPos chunkPos = chunkAccess.getPos(); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + chunkPos.getMinBlockX(), chunkPos.getMinBlockZ(), "bukkit_nms_heightmaps"); + GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_heightmaps"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { SectionPos sectionPos = SectionPos.of(chunkAccess.getPos(), level.getMinSectionY()); BlockPos blockPos = sectionPos.origin(); @@ -1041,12 +1200,30 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void spawnOriginalMobs(WorldGenRegion region) { ChunkPos center = region.getCenter(); - Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); - Holder vanillaBiome = customBiomeSource.getVanillaSpawnBiome(visibleBiome); - WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); - random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); - NaturalSpawner.spawnMobsForChunkGeneration( - region, vanillaBiome == null ? visibleBiome : vanillaBiome, center, random); + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_spawn_original_mobs"); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + center.x(), center.z(), "bukkit_nms_spawn_original_mobs"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_spawn_original_mobs"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!allowsRoutedDiscreteGeneration(region.getChunk(center.x(), center.z()), ChunkStatus.SPAWN)) { + return; + } + Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + Holder vanillaBiome = customBiomeSource.getVanillaSpawnBiome(visibleBiome); + WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); + random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); + NaturalSpawner.spawnMobsForChunkGeneration( + region, vanillaBiome == null ? visibleBiome : vanillaBiome, center, random); + } + } + + private boolean allowsRoutedDiscreteGeneration(ChunkAccess chunk, ChunkStatus stage) { + if (chunk.getPersistedStatus().isOrAfter(stage)) { + return false; + } + ChunkPos chunkPos = chunk.getPos(); + return engine.getComplex().allowsNewGenerationChunk(chunkPos.x(), chunkPos.z()); } private static WeightedList mergeSpawnTables( @@ -1079,7 +1256,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getBaseHeight(int i, int j, Heightmap.Types heightmap_type, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); + try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( + i, j, "bukkit_nms_base_height"); + GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { return levelheightaccessor.getMinY() + engine.getHeight(i, j, !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState())) @@ -1091,7 +1270,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_column"); + try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( + i, j, "bukkit_nms_base_column"); + GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_column"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { int block = engine.getHeight(i, j, true); int water = engine.getHeight(i, j, false); @@ -1119,6 +1300,69 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } + private GenerationHistoryRuntimeRouter.RuntimeRoute openHistoryRoute( + int chunkX, + int chunkZ, + String operation + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + return null; + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + if (platformGenerator != null && platformGenerator.getGenerationHistory() != null) { + throw new IllegalStateException("Iris " + operation + + " has no generation-history runtime router."); + } + return null; + } + try { + return router.openRoute(chunkX, chunkZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + + " could not route generation history for chunk " + + chunkX + "," + chunkZ + ".", failure); + } + } + + private GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( + int blockX, + int blockZ, + String operation + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + return null; + } + try { + return irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + + " could not route generation history at " + + blockX + "," + blockZ + ".", failure); + } + } + + private static GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope openHistoryRuntimeScope( + GenerationHistoryRuntimeRouter.RuntimeRoute route + ) { + return route == null ? null : route.openRuntimeScope(); + } + + private static void claimGeneratedSemantics( + GenerationHistoryRuntimeRouter.RuntimeRoute route, + String operation + ) { + if (route == null) { + return; + } + try { + route.claimGeneratedSemantics(); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + + " could not durably claim generated chunk semantics.", failure); + } + } + private BukkitChunkGenerator.GenerationStagePermit requireGenerationStage(String operation) { return platformGenerator == null ? BukkitChunkGenerator.GenerationStagePermit.noop() @@ -1190,13 +1434,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } - private record SpawnTableKey(Biome biome, MobCategory category) { + private record SpawnTableKey(int runtimeId, Biome biome, MobCategory category) { } private record ReachableStructureCache(IrisDimension dimension, int runtimeId, Set keys) { } - private record StructureStepCache(Registry registry, List> structures) { + private record StructureStepCache(int runtimeId, Registry registry, + List> structures) { } record StudioStructureState( diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index dd45728a3..785d19c1c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -33,6 +33,7 @@ import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.view.ChunkDataHunkHolder; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -57,6 +58,9 @@ import it.unimi.dsi.fastutil.shorts.ShortList; import net.bytebuddy.ByteBuddy; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.agent.builder.ResettableClassFileTransformer; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.DynamicType; +import net.bytebuddy.utility.JavaModule; import net.bytebuddy.asm.Advice; import net.bytebuddy.matcher.ElementMatchers; import net.minecraft.core.BlockPos; @@ -169,7 +173,6 @@ import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -181,11 +184,22 @@ public class NMSBinding implements INMSBinding { private final AtomicBoolean injected = new AtomicBoolean(); private volatile ResettableClassFileTransformer levelStorageAccessTransformer; private volatile ResettableClassFileTransformer serverLevelTransformer; + private volatile ResettableClassFileTransformer pluginClassLoaderTransformer; + private boolean pluginClassLoaderCloseDeferred; private final AtomicCache> registryCache = new AtomicCache<>(); private final AtomicCache> globalCache = new AtomicCache<>(); private final AtomicCache registryAccess = new AtomicCache<>(); private final AtomicCache byIdRef = new AtomicCache<>(); + @Override + public PlatformGenerationRegistry generationRegistry() { + return new NmsGenerationRegistry( + this::registry, + "bukkit-generation-registry-v1", + "bukkit-v26_2_R1-generated-registry-json-v1" + ); + } + private static Object getFor(Class type, Object source) { Object o = fieldFor(type, source); @@ -1539,6 +1553,23 @@ public class NMSBinding implements INMSBinding { } try { IrisLogging.info("Injecting Bukkit"); + Agent.requireClassLoaderCloseDeferral(); + Class loaderCloseType = getClass().getClassLoader().getClass() + .getMethod("close").getDeclaringClass(); + PluginClassLoaderInjectionListener loaderListener = new PluginClassLoaderInjectionListener(); + pluginClassLoaderTransformer = new AgentBuilder.Default() + .disableClassFormatChanges() + .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) + .with(AgentBuilder.RedefinitionStrategy.Listener.ErrorEscalating.FAIL_FAST) + .with(loaderListener) + .type(ElementMatchers.is(loaderCloseType)) + .transform((builder, typeDescription, classLoader, module, protectionDomain) -> + builder.visit(Advice.to(PluginClassLoaderCloseAdvice.class) + .on(ElementMatchers.named("close") + .and(ElementMatchers.takesArguments(0)) + .and(ElementMatchers.returns(void.class))))) + .installOn(Agent.getInstrumentation()); + loaderListener.requireInstalled(); levelStorageAccessTransformer = new AgentBuilder.Default() .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) @@ -1574,18 +1605,22 @@ public class NMSBinding implements INMSBinding { IrisLogging.reportError(C.RED + "Failed to inject Bukkit", e); ResettableClassFileTransformer partialServerLevel = serverLevelTransformer; ResettableClassFileTransformer partialStorageAccess = levelStorageAccessTransformer; + ResettableClassFileTransformer partialPluginClassLoader = pluginClassLoaderTransformer; serverLevelTransformer = null; levelStorageAccessTransformer = null; + pluginClassLoaderTransformer = null; for (ResettableClassFileTransformer partial : new ResettableClassFileTransformer[]{ partialServerLevel, - partialStorageAccess + partialStorageAccess, + partialPluginClassLoader }) { if (partial == null) { continue; } try { partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); - } catch (Throwable ignored) { + } catch (Throwable cleanupFailure) { + IrisLogging.reportError("Failed to remove partial Bukkit lifecycle injection", cleanupFailure); } } return false; @@ -1632,16 +1667,52 @@ public class NMSBinding implements INMSBinding { } @Override - public boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) { - MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer(); - return ServerShutdownBoundary.await( + public boolean isServerStopping() { + return ((CraftServer) Bukkit.getServer()).getServer().hasStopped(); + } + + @Override + public ServerShutdownBoundary createServerShutdownBoundary() { + MinecraftServer server = ((CraftServer) Bukkit.getServer()).getServer(); + return new ServerShutdownBoundary( () -> server.hasFullyShutdown, - server.getRunningThread(), - timeout, - unit + server.getRunningThread() ); } + @Override + public void deferPluginClassLoaderClose() { + synchronized (injected) { + if (pluginClassLoaderTransformer == null || pluginClassLoaderCloseDeferred) { + return; + } + Agent.retainClassLoader(getClass().getClassLoader()); + pluginClassLoaderCloseDeferred = true; + } + } + + @Override + public void releasePluginClassLoaderClose() { + ResettableClassFileTransformer transformer; + boolean releaseLoader; + synchronized (injected) { + transformer = pluginClassLoaderTransformer; + pluginClassLoaderTransformer = null; + releaseLoader = pluginClassLoaderCloseDeferred; + pluginClassLoaderCloseDeferred = false; + } + try { + if (transformer != null + && !transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)) { + throw new IllegalStateException("Failed to remove Iris plugin class loader lifecycle injection"); + } + } finally { + if (releaseLoader) { + Agent.releaseClassLoader(getClass().getClassLoader()); + } + } + } + @Override public KMap> getBlockProperties() { KMap> states = new KMap<>(); @@ -1686,6 +1757,39 @@ public class NMSBinding implements INMSBinding { return new FlatLevelSource(settings); } + private static final class PluginClassLoaderInjectionListener extends AgentBuilder.Listener.Adapter { + private volatile boolean transformed; + private volatile Throwable failure; + + @Override + public void onTransformation(TypeDescription type, ClassLoader loader, JavaModule module, + boolean loaded, DynamicType dynamicType) { + transformed = true; + } + + @Override + public void onError(String typeName, ClassLoader loader, JavaModule module, + boolean loaded, Throwable throwable) { + failure = throwable; + } + + private void requireInstalled() { + if (!transformed || failure != null) { + throw new IllegalStateException("Iris plugin class loader lifecycle injection failed", failure); + } + } + } + + private static class PluginClassLoaderCloseAdvice { + @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class) + static boolean enter(@Advice.This ClassLoader loader) throws ReflectiveOperationException { + Class installer = Class.forName( + "art.arcane.iris.util.project.agent.Installer", true, ClassLoader.getSystemClassLoader()); + return Boolean.TRUE.equals(installer.getMethod("deferClassLoaderClose", ClassLoader.class) + .invoke(null, loader)); + } + } + private static class ChunkAccessAdvice { @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class) static boolean enter(@Advice.This ChunkAccess access, @Advice.Argument(1) int index) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java new file mode 100644 index 000000000..7c84c9c62 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java @@ -0,0 +1,182 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.spi.PlatformGenerationRegistry; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.dimension.DimensionType; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Supplier; + +final class NmsGenerationRegistry implements PlatformGenerationRegistry { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String STRUCTURE_REGISTRY = "minecraft:worldgen/structure"; + private static final String DIMENSION_TYPE_REGISTRY = "minecraft:dimension_type"; + private static final String BLOCK_REGISTRY = "minecraft:block"; + private static final String ENTITY_TYPE_REGISTRY = "minecraft:entity_type"; + + private final Supplier registryAccess; + private final String runtimeIdentity; + private final String rendererIdentity; + + NmsGenerationRegistry( + Supplier registryAccess, + String runtimeIdentity, + String rendererIdentity + ) { + this.registryAccess = Objects.requireNonNull(registryAccess, "registryAccess"); + this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity"); + this.rendererIdentity = requireText(rendererIdentity, "rendererIdentity"); + } + + @Override + public String runtimeIdentity() { + return runtimeIdentity; + } + + @Override + public String generatedDefinitionRendererIdentity() { + return rendererIdentity; + } + + @Override + public String customBiomeResourceKey(String identitySha256) { + return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256); + } + + @Override + public List legacyCustomBiomeResourceKeys( + String packName, + String dimensionKey, + String customBiomeId + ) { + return List.of(requireText(dimensionKey, "dimensionKey").toLowerCase(Locale.ROOT) + + ":" + requireText(customBiomeId, "customBiomeId").toLowerCase(Locale.ROOT)); + } + + @Override + public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { + return "iris:" + requireText(dimensionTypeKey, "dimensionTypeKey").toLowerCase(Locale.ROOT); + } + + @Override + public Definition definition(String registryKey, String resourceKey) { + RegistryAccess access = Objects.requireNonNull(registryAccess.get(), "Minecraft registry access"); + return switch (registryKey) { + case BIOME_REGISTRY -> presence(access, Registries.BIOME, registryKey, resourceKey); + case STRUCTURE_REGISTRY -> presence(access, Registries.STRUCTURE, registryKey, resourceKey); + case DIMENSION_TYPE_REGISTRY -> presence( + access, + Registries.DIMENSION_TYPE, + registryKey, + resourceKey + ); + case BLOCK_REGISTRY -> presence(access, Registries.BLOCK, registryKey, resourceKey); + case ENTITY_TYPE_REGISTRY -> presence(access, Registries.ENTITY_TYPE, registryKey, resourceKey); + default -> throw new IllegalArgumentException("Unsupported generation registry: " + registryKey); + }; + } + + @Override + public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) { + RegistryAccess access = Objects.requireNonNull(registryAccess.get(), "Minecraft registry access"); + return switch (registryKey) { + case BIOME_REGISTRY -> canonicalize(access, Biome.DIRECT_CODEC, sourceJson); + case DIMENSION_TYPE_REGISTRY -> canonicalize(access, DimensionType.DIRECT_CODEC, sourceJson); + default -> throw new IllegalArgumentException( + "Registry does not accept generated JSON definitions: " + registryKey + ); + }; + } + + @Override + public Definition generatedDefinition(String registryKey, String resourceKey) { + RegistryAccess access = Objects.requireNonNull(registryAccess.get(), "Minecraft registry access"); + return switch (registryKey) { + case BIOME_REGISTRY -> exactRegistered( + access, + Registries.BIOME, + Biome.DIRECT_CODEC, + resourceKey + ); + case DIMENSION_TYPE_REGISTRY -> exactRegistered( + access, + Registries.DIMENSION_TYPE, + DimensionType.DIRECT_CODEC, + resourceKey + ); + default -> throw new IllegalArgumentException( + "Registry does not contain Iris-generated definitions: " + registryKey + ); + }; + } + + private static Definition presence( + RegistryAccess access, + ResourceKey> registryKey, + String registryResourceKey, + String resourceKey + ) { + Registry registry = access.lookupOrThrow(registryKey); + Identifier identifier = requireIdentifier(resourceKey); + T value = registry.getValue(identifier); + if (value == null) { + throw new IllegalStateException("Missing registry definition " + registryKey.identifier() + + " / " + resourceKey + "."); + } + return Definition.resourceIdentity(registryResourceKey, resourceKey); + } + + private static Definition canonicalize(RegistryAccess access, Codec codec, String sourceJson) { + RegistryOps ops = RegistryOps.create(JsonOps.INSTANCE, access); + JsonElement source = JsonParser.parseString(Objects.requireNonNull(sourceJson, "sourceJson")); + T decoded = codec.parse(ops, source).getOrThrow(IllegalArgumentException::new); + JsonElement encoded = codec.encodeStart(ops, decoded).getOrThrow(IllegalStateException::new); + return Definition.exactJson(encoded.toString()); + } + + private static Definition exactRegistered( + RegistryAccess access, + ResourceKey> registryKey, + Codec codec, + String resourceKey + ) { + Registry registry = access.lookupOrThrow(registryKey); + Identifier identifier = requireIdentifier(resourceKey); + T value = registry.getValue(identifier); + if (value == null) { + throw new IllegalStateException("Missing generated registry definition " + + registryKey.identifier() + " / " + resourceKey + "."); + } + RegistryOps ops = RegistryOps.create(JsonOps.INSTANCE, access); + JsonElement encoded = codec.encodeStart(ops, value).getOrThrow(IllegalStateException::new); + return Definition.exactJson(encoded.toString()); + } + + private static Identifier requireIdentifier(String resourceKey) { + Identifier identifier = Identifier.tryParse(resourceKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registry resource key: " + resourceKey); + } + return identifier; + } + + private static String requireText(String value, String label) { + String required = Objects.requireNonNull(value, label).trim(); + if (required.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return required; + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 3d4d48e82..73cb132cf 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -28,10 +28,10 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_spawn_biome\")")); assertFalse(source.contains("Iris spawn biome lookup was rejected during an engine transition")); assertFalse(source.contains("Iris spawn biome lookup has no active engine runtime")); - assertTrue(source.contains("vanillaSpawnBiomes.get(biome.value())")); - assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_biome\")")); + assertTrue(source.contains("runtimeBiomeState().vanillaSpawnBiomes().get(biome.value())")); + assertTrue(source.contains("requireGenerationLease(\n \"bukkit_structure_biome\"")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_biomes_within\")")); - assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); + assertTrue(source.contains("requireGenerationLease(\n \"bukkit_visible_biome\"")); assertTrue(source.contains("if (engine.isClosed())")); assertTrue(source.contains("engine.isClosing() || e.isExpectedTeardown()")); assertTrue(source.contains("catch (GenerationSessionException e)")); @@ -87,7 +87,7 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(lookupEnd > lookupStart); String lookup = source.substring(lookupStart, lookupEnd); - assertTrue(lookup.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); + assertTrue(lookup.contains("requireGenerationLease(\n \"bukkit_visible_biome\"")); assertTrue(lookup.contains("prepareVisibleBiomeBatch()")); assertTrue(lookup.contains("getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler)")); assertTrue(lookup.indexOf("prepareVisibleBiomeBatch()") diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/GenerationHistoryRoutingContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/GenerationHistoryRoutingContractTest.java new file mode 100644 index 000000000..fcaf33f16 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/GenerationHistoryRoutingContractTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class GenerationHistoryRoutingContractTest { + @Test + public void everyChunkStageScopesItsHistoryRuntimeBeforeSessionAdmission() throws IOException { + String source = chunkGeneratorSource(); + + assertRoutedStage(source, "public void createStructures(", "private void adjustGeneratedStructures"); + assertRoutedStage(source, "public void createReferences(", "public CompletableFuture createBiomes"); + assertRoutedStage(source, "public CompletableFuture createBiomes", "public void buildSurface"); + assertRoutedStage(source, "public void buildSurface(", "public void applyCarvers("); + assertRoutedStage(source, "public void applyCarvers(", "public CompletableFuture fillFromNoise"); + assertRoutedStage(source, "public void spawnOriginalMobs(", "private boolean allowsRoutedDiscreteGeneration("); + assertRoutedStage( + source, + "public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)", + "public BiomeGenerationSettings getBiomeGenerationSettings"); + + String baseHeight = method(source, "public int getBaseHeight(", "public NoiseColumn getBaseColumn("); + String baseColumn = method(source, "public NoiseColumn getBaseColumn(", "private GenerationSessionLease requireGenerationLease"); + assertBefore(baseHeight, "openHistoryCoordinateScope(", "engine.acquireGenerationLease("); + assertBefore(baseColumn, "openHistoryCoordinateScope(", "engine.acquireGenerationLease("); + } + + @Test + public void asyncNoiseKeepsRouteUntilCompletionAndReopensThreadLocalScopes() throws IOException { + String source = chunkGeneratorSource(); + String noise = method( + source, + "public CompletableFuture fillFromNoise", + "private static boolean isCancellationFailure"); + + assertBefore(noise, "requireNoiseGenerationStage(", "openHistoryRoute("); + assertBefore(noise, "openHistoryRoute(", "requireGenerationLease("); + assertTrue(count(noise, "openHistoryRuntimeScope(route)") >= 2); + assertBefore(noise, ".thenApply(filled ->", "pipeline.whenComplete("); + assertTrue(noise.contains("closeNoisePipelineResources(\n failure, lease, route, stage)")); + } + + @Test + public void transitionBandSuppressesDiscreteGenerationAndCrossingStarts() throws IOException { + String source = chunkGeneratorSource(); + String structures = method(source, "public void createStructures(", "private void adjustGeneratedStructures"); + String adjustment = method(source, "private void adjustGeneratedStructures", "public ChunkGeneratorStructureState createState"); + String decoration = method( + source, + "public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)", + "public BiomeGenerationSettings getBiomeGenerationSettings"); + + assertTrue(structures.contains("allowsRoutedDiscreteGeneration(access, ChunkStatus.STRUCTURE_STARTS)")); + assertTrue(decoration.contains("allowsRoutedDiscreteGeneration(ichunkaccess, ChunkStatus.FEATURES)")); + String discrete = method(source, "private boolean allowsRoutedDiscreteGeneration(", + "private static WeightedList mergeSpawnTables("); + assertTrue(discrete.contains("chunk.getPersistedStatus().isOrAfter(stage)")); + assertTrue(discrete.contains("engine.getComplex().allowsNewGenerationChunk(chunkPos.x(), chunkPos.z())")); + assertBefore(decoration, "delegate.applyBiomeDecoration(", "claimGeneratedSemantics(route,"); + assertTrue(adjustment.contains("start.getBoundingBox()")); + assertTrue(adjustment.contains("allowsNewGenerationFootprint(")); + assertTrue(adjustment.contains("NativeStructureOwnershipStore.discard(")); + } + + @Test + public void biomeCachesAndVisibleHistoricalBiomesAreRuntimeAware() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))) + .replace("\r\n", "\n"); + String visibleResolution = method( + source, + "private Holder resolveVisibleBiomeHolder(", + "private Holder resolveCustomHolder("); + String activeBatch = method( + source, + "Holder getVisibleNoiseBiomeWithActiveGenerationLease(\n int x,", + "private GenerationSessionLease tryAcquireGenerationLease"); + + assertTrue(source.contains("record RuntimeNoiseKey(int runtimeId, long coordinateKey)")); + assertTrue(source.contains("record RuntimeColumnKey(int runtimeId, long coordinateKey)")); + assertTrue(source.contains("ConcurrentHashMap runtimeBiomeStates")); + assertTrue(source.contains("addGenerationRuntimeRetirementListener(this::evictRuntimeCaches)")); + assertTrue(source.contains("runtimeBiomeStates.remove(runtimeId)")); + assertTrue(source.contains("noiseBiomeCache.keySet().removeIf(key -> key.runtimeId() == runtimeId)")); + assertBefore(visibleResolution, "historicalPhysicalBiomeKeyAt(", "resolveBiomeResolution("); + assertFalse(activeBatch.contains("openHistoryCoordinateScope(")); + + String chunkGenerator = chunkGeneratorSource(); + assertTrue(chunkGenerator.contains("addGenerationRuntimeRetirementListener(this::evictRuntimeCaches)")); + assertTrue(chunkGenerator.contains("mergedSpawnTables.keySet().removeIf(key -> key.runtimeId() == runtimeId)")); + } + + @Test + public void importedFeatureTablesAreRuntimeScopedAndRetiredCentrally() throws IOException { + String stage = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")) + .resolveSibling("ImportedFeatureStage.java")) + .replace("\r\n", "\n"); + String generator = chunkGeneratorSource(); + + assertTrue(stage.contains("ConcurrentHashMap runtimeStates")); + assertTrue(stage.contains("runtimeStates.get(engine.getCacheID())")); + assertTrue(stage.contains("runtimeStates.remove(runtimeId)")); + assertFalse(stage.contains("volatile FeatureTable featureTable")); + assertFalse(stage.contains("volatile int settledRuntimeId")); + assertTrue(generator.contains("importedFeatures.evictRuntime(runtimeId)")); + } + + @Test + public void nativeReferencesResolvePolicyInTheOriginRuntime() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource")) + .resolveSibling("NativeStructureReferenceRepair.java")) + .replace("\r\n", "\n"); + String references = method(source, "public static void createReferences(", + "private static GenerationHistoryRuntimeRouter.CoordinateScope openOriginRuntimeScope("); + + assertBefore(references, "openOriginRuntimeScope(engine, originChunkX, originChunkZ)", "scanStart("); + assertBefore(references, "scanStart(", "isTargetRelevant("); + assertTrue(source.contains("irisEngine.openGenerationHistoryCoordinateScope(chunkX << 4, chunkZ << 4)")); + assertFalse(references.contains("allowsNewGenerationFootprint(")); + } + + private static void assertRoutedStage(String source, String start, String end) { + String stage = method(source, start, end); + assertBefore(stage, "requireGenerationStage(", "openHistoryRoute("); + assertBefore(stage, "openHistoryRoute(", "requireGenerationLease("); + assertBefore(stage, "openHistoryRuntimeScope(route)", "requireGenerationLease("); + } + + private static String chunkGeneratorSource() throws IOException { + return Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))) + .replace("\r\n", "\n"); + } + + private static String method(String source, String startToken, String endToken) { + int start = source.indexOf(startToken); + int end = source.indexOf(endToken, start); + assertTrue("Missing source contract token: " + startToken, start >= 0); + assertTrue("Missing source contract token: " + endToken, end > start); + return source.substring(start, end); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static int count(String source, String token) { + int count = 0; + int index = 0; + while ((index = source.indexOf(token, index)) >= 0) { + count++; + index += token.length(); + } + return count; + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index aaeb3b7ab..40db940a4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -293,26 +293,29 @@ public class IrisChunkGeneratorFailureContractTest { assertBefore(productionFill, "BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(", + "GenerationHistoryRuntimeRouter.RuntimeRoute route"); + assertBefore(productionFill, + "GenerationHistoryRuntimeRouter.RuntimeRoute route", "GenerationSessionLease lease"); assertBefore(productionFill, "requireNoiseGenerationStage(", "\"bukkit_nms_chunk_pipeline\")"); assertBefore(productionFill, "lease = requireGenerationLease(\"bukkit_nms_chunk_pipeline\")", - ".fillFromNoise(blender, randomstate, structuremanager, ichunkaccess)"); + "delegate.fillFromNoise("); assertTrue(productionFill.contains("IrisContext.open(engine, lease.sessionId(), null)")); + assertTrue(productionFill.contains("openHistoryRuntimeScope(route)")); assertBefore(productionFill, "primeWorldgenHeightmaps(filled)", "pipeline.whenComplete("); assertTrue(productionFill.contains("CompletableFuture completion = new CompletableFuture<>()")); - assertBefore(completion, "boolean cancelled = isCancellationFailure(failure);", "lease.close();"); - assertBefore(completion, "lease.close();", "stage.close();"); - assertBefore(completion, "stage.close();", "completion.complete(filled)"); - assertBefore(completion, "stage.close();", "completion.cancel(false)"); - assertBefore(completion, "stage.close();", "completion.completeExceptionally(failure)"); + assertBefore(completion, "boolean cancelled = isCancellationFailure(failure);", "closeNoisePipelineResources("); + assertBefore(completion, "closeNoisePipelineResources(", "completion.complete(filled)"); + assertBefore(completion, "closeNoisePipelineResources(", "completion.cancel(false)"); + assertBefore(completion, "closeNoisePipelineResources(", "completion.completeExceptionally(completionFailure)"); assertTrue(completion.contains("else if (cancelled)")); assertFalse(completion.contains("pipeline.isCancelled()")); assertFalse(completion.contains("finally")); assertTrue(productionFill.contains("catch (RuntimeException | Error failure)")); - assertTrue(productionFill.contains("lease.close();\n stage.close();\n throw failure;")); + assertTrue(productionFill.contains("lease.close();\n closeHistoryRoute(route, failure);\n stage.close();\n throw failure;")); assertTrue(productionFill.contains("return completion;")); assertFalse(productionFill.contains("return pipeline;")); assertFalse(productionFill.contains("pipeline.cancel(")); @@ -420,6 +423,9 @@ public class IrisChunkGeneratorFailureContractTest { String structures = method(source, "public void createStructures(", "private void adjustGeneratedStructures"); String references = method(source, "public void createReferences(", "public CompletableFuture createBiomes"); String biomes = method(source, "public CompletableFuture createBiomes", "public void buildSurface"); + String surface = method(source, "public void buildSurface(", "public void applyCarvers("); + String carvers = method(source, "public void applyCarvers(", "public CompletableFuture fillFromNoise"); + String mobs = method(source, "public void spawnOriginalMobs(", "private boolean allowsRoutedDiscreteGeneration("); String noise = method(source, "public CompletableFuture fillFromNoise", "private static boolean isCancellationFailure"); String decoration = method(source, "public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)", @@ -428,6 +434,9 @@ public class IrisChunkGeneratorFailureContractTest { assertStageBeforeLease(structures, "bukkit_nms_create_structures"); assertStageBeforeLease(references, "bukkit_nms_create_references"); assertStageBeforeLease(biomes, "bukkit_nms_create_biomes"); + assertStageBeforeLease(surface, "bukkit_nms_build_surface"); + assertStageBeforeLease(carvers, "bukkit_nms_apply_carvers"); + assertStageBeforeLease(mobs, "bukkit_nms_spawn_original_mobs"); assertBefore(noise, "requireNoiseGenerationStage(", "requireGenerationLease(\"bukkit_nms_chunk_pipeline\")"); @@ -435,7 +444,7 @@ public class IrisChunkGeneratorFailureContractTest { assertBefore(decoration, "requireGenerationLease(\"bukkit_nms_biome_decoration\")", "importedFeatures.prepare(generatoraccessseed)"); - assertEquals(4, occurrences(source, "requireGenerationStage(\"bukkit_nms_")); + assertEquals(7, occurrences(source, "requireGenerationStage(\"bukkit_nms_")); assertEquals(2, occurrences(source, "requireNoiseGenerationStage(")); assertFalse(source.contains("GenerationSessionManager")); } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java index 7af5cd242..d0bab68ee 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java @@ -6,16 +6,48 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; public class NMSBindingShutdownBoundaryContractTest { + @Test + public void shutdownClassificationUsesTheServerStopFlagInsteadOfPluginEnablement() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource"))); + String stopping = section(source, "public boolean isServerStopping()", "public ServerShutdownBoundary createServerShutdownBoundary"); + + assertTrue(stopping.contains("((CraftServer) Bukkit.getServer()).getServer().hasStopped()")); + assertFalse(stopping.contains("isEnabled()")); + assertFalse(stopping.contains("isRunning()")); + } + + @Test + public void pluginLoaderGuardUsesSystemAgentIdentityAndIsRemovedAtRelease() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource"))); + String injection = section(source, "public boolean injectBukkit()", "public void ensureServerLevelInjection()"); + String guard = section(source, "private static class PluginClassLoaderCloseAdvice", "private static class ChunkAccessAdvice"); + String release = section(source, "public void releasePluginClassLoaderClose()", "public KMap> getBlockProperties"); + + assertTrue(injection.contains("Agent.requireClassLoaderCloseDeferral()")); + assertTrue(injection.contains("getMethod(\"close\").getDeclaringClass()")); + assertTrue(injection.contains("Advice.to(PluginClassLoaderCloseAdvice.class)")); + assertTrue(guard.contains("skipOn = Advice.OnNonDefaultValue.class")); + assertTrue(guard.contains("ClassLoader.getSystemClassLoader()")); + assertTrue(guard.contains("getMethod(\"deferClassLoaderClose\", ClassLoader.class)")); + assertTrue(guard.contains(".invoke(null, loader)")); + assertFalse(guard.contains("Bukkit.getPluginManager()")); + assertTrue(release.indexOf("transformer.reset(") > release.indexOf("pluginClassLoaderCloseDeferred = false")); + assertTrue(release.indexOf("Agent.releaseClassLoader(") > release.indexOf("transformer.reset(")); + } + @Test public void shutdownBoundaryUsesPaperFullyShutdownStateAndServerThread() throws Exception { String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource"))).replace("\r\n", "\n"); - String boundary = section(source, "public boolean awaitServerShutdownBoundary", "public KMap> getBlockProperties"); + String boundary = section(source, "public ServerShutdownBoundary createServerShutdownBoundary", "public KMap> getBlockProperties"); - assertTrue(boundary.contains("ServerShutdownBoundary.await(")); + assertTrue(boundary.contains("new ServerShutdownBoundary(")); assertTrue(boundary.contains("server.hasFullyShutdown")); assertTrue(boundary.contains("server.getRunningThread()")); + assertTrue(boundary.contains("((CraftServer) Bukkit.getServer()).getServer()")); + assertFalse(boundary.contains("getHandle()")); } private static String section(String source, String startMarker, String endMarker) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java index 3691e9d41..05021ee9b 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java @@ -19,6 +19,7 @@ import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; +import java.lang.reflect.Proxy; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -80,6 +81,30 @@ public class NativeStructureVolumeIndexTest { assertEquals(coldResolutions, resolver.resolutions()); } + @Test + public void runtimeBucketsStayIsolatedAndRetirementEvictsOnlyTheirEntries() { + AtomicInteger runtimeId = new AtomicInteger(1); + Engine engine = runtimeEngine(runtimeId); + CountingResolver resolver = new CountingResolver(0, 0); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + + index.resolve(engine, 0, 0, 15, 15); + assertEquals(289, resolver.resolutions()); + index.resolve(engine, 0, 0, 15, 15); + assertEquals(289, resolver.resolutions()); + + runtimeId.set(2); + index.resolve(engine, 0, 0, 15, 15); + assertEquals(578, resolver.resolutions()); + index.resolve(engine, 0, 0, 15, 15); + assertEquals(578, resolver.resolutions()); + + index.evictRuntime(1); + runtimeId.set(1); + index.resolve(engine, 0, 0, 15, 15); + assertEquals(867, resolver.resolutions()); + } + @Test public void independentIndexesResolveIdenticalVolumesFromTheSameSeed() { NativeStructureVolumeIndex first = NativeStructureVolumeIndex.forTesting(new CountingResolver(0, 0)); @@ -188,12 +213,45 @@ public class NativeStructureVolumeIndexTest { assertTrue(source.contains("isStructureChunk(")); } + @Test + public void originResolutionRoutesAndCachesByScopedRuntime() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource"))); + + assertTrue(source.contains("openHistoryCoordinateScope(engine, chunkX, chunkZ)")); + assertTrue(source.contains("new RuntimeChunkKey(runtimeId, chunkKey(chunkX, chunkZ))")); + assertTrue(source.contains("addGenerationRuntimeRetirementListener(retirementListener)")); + assertTrue(source.contains("originCache.keySet().removeIf(key -> key.runtimeId() == runtimeId)")); + assertTrue(source.contains("queryCache.keySet().removeIf(key -> key.runtimeId() == runtimeId)")); + } + private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { Structure source = new SwampHutStructure(new Structure.StructureSettings(HolderSet.empty())); SwampHutPiece piece = new SwampHutPiece(RandomSource.create(17L), x, z); return new StructureStart(source, new ChunkPos(chunkX, chunkZ), 0, new PiecesContainer(List.of(piece))); } + private static Engine runtimeEngine(AtomicInteger runtimeId) { + return (Engine) Proxy.newProxyInstance( + Engine.class.getClassLoader(), + new Class[]{Engine.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("getCacheID")) { + return runtimeId.get(); + } + if (method.getName().equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (method.getName().equals("equals")) { + return proxy == arguments[0]; + } + if (method.getName().equals("toString")) { + return "runtime-engine-" + runtimeId.get(); + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + private static int findDisjointWindow(int chunkX, int chunkZ) { long sourceMask = NativeStructureVolumeIndex.originWindowStripeMask(chunkX, chunkZ); for (int candidate = 64; candidate < 16_384; candidate += 17) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 8e7e5b7bf..d55fa2124 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -55,6 +55,7 @@ import art.arcane.volmlib.util.director.help.DirectorMiniMenu; import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher; import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.ServerShutdownBoundary; import art.arcane.iris.core.gui.BukkitGuiHost; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.service.BoardSVC; @@ -139,6 +140,8 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.FileOutputStream; +import java.io.FileDescriptor; +import java.io.PrintStream; import java.io.PrintWriter; import java.lang.reflect.Modifier; import java.util.ArrayList; @@ -160,6 +163,7 @@ import java.util.regex.Pattern; @SuppressWarnings("CanBeFinal") public class Iris extends VolmitPlugin implements Listener, ReloadAware { private static final long SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS = 300L; + private static final PrintStream SHUTDOWN_ERRORS = new PrintStream(new FileOutputStream(FileDescriptor.err), true); private static final long SERVER_STOP_PREGEN_TIMEOUT_MILLIS = 30000L; private static final Queue syncJobs = new ShurikenQueue<>(); @@ -190,6 +194,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false); private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false); private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); + private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false); + private volatile boolean generatorDrainCompleted; private volatile PlaceholderRegistration papiRegistration; private volatile IrisPapiListener papiListener; private volatile IrisPapiState papiState; @@ -206,6 +212,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private BukkitDebugDump debugDump; private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile Thread serverLifecycleThread; + private volatile ServerShutdownBoundary serverShutdownBoundary; public static VolmitSender getSender() { if (sender == null) { @@ -427,6 +434,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { if (e == null) { return; } + if (instance != null && instance.serverStopTeardownDeferred.get()) { + e.printStackTrace(SHUTDOWN_ERRORS); + return; + } boolean debug = false; if (instance != null) { @@ -466,6 +477,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public static void reportError(String context, Throwable e) { Throwable error = e == null ? new IllegalStateException("Unknown Iris failure") : e; String message = context == null || context.isBlank() ? "Unhandled Iris failure." : context; + if (instance != null && instance.serverStopTeardownDeferred.get()) { + SHUTDOWN_ERRORS.println("[Iris] " + message); + error.printStackTrace(SHUTDOWN_ERRORS); + return; + } try { if (instance != null) { @@ -603,6 +619,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { sharedRuntimeClosed.set(false); startupBoundaryRestart.set(false); terminalCleanupCompleted.set(false); + runtimeTeardownFailed.set(false); + generatorDrainCompleted = false; deferredShutdownGenerators.clear(); MultiBurst.burst.reopen(); MultiBurst.ioBurst.reopen(); @@ -742,6 +760,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void addShutdownHook() { removeShutdownHook(); serverLifecycleThread = Thread.currentThread(); + serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null; shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook"); try { Runtime.getRuntime().addShutdownHook(shutdownHook); @@ -963,6 +982,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { drainWorldGenerators(reason, timeoutSeconds); } + if (serverStopTeardownDeferred.get() && !generatorDrainCompleted) { + return; + } + if (services != null && servicesDisabled.compareAndSet(false, true)) { // Only services whose onEnable actually completed; disabling a service that // never initialized runs teardown against uninitialized state. @@ -970,6 +993,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { try { service.onDisable(); } catch (Throwable e) { + runtimeTeardownFailed.set(true); Iris.reportError("Failed to disable " + service.getClass().getSimpleName() + ".", e); } } @@ -979,8 +1003,18 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return; } - J.attempt(MultiBurst.burst::close); - J.attempt(MultiBurst.ioBurst::close); + try { + MultiBurst.burst.close(); + } catch (Throwable failure) { + runtimeTeardownFailed.set(true); + Iris.reportError("Failed to close Iris generation workers.", failure); + } + try { + MultiBurst.ioBurst.close(); + } catch (Throwable failure) { + runtimeTeardownFailed.set(true); + Iris.reportError("Failed to close Iris I/O workers.", failure); + } clearQueues(); IrisServices.clear(); } @@ -988,6 +1022,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private void quiesceRuntimeForServerShutdown(String reason) { serverStopTeardownDeferred.set(true); + INMS.get().deferPluginClassLoaderClose(); JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class); if (jigsawStudioService != null) { try { @@ -1060,29 +1095,35 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return; } if (!awaitServerShutdownBoundary()) { - Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary."); + SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit."); return; } finishDeferredRuntimeTeardown("shutdown-hook", 30L); } private boolean awaitServerShutdownBoundary() { - if (!INMS.isBound()) { + ServerShutdownBoundary boundary = serverShutdownBoundary; + if (boundary == null) { return true; } try { - return INMS.get().awaitServerShutdownBoundary( + return boundary.await( SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS, TimeUnit.SECONDS ); } catch (Throwable e) { - Iris.reportError("Failed to await Paper's post-world-close shutdown boundary.", e); + SHUTDOWN_ERRORS.println("[Iris] Failed to await Paper's post-world-close shutdown boundary."); + e.printStackTrace(SHUTDOWN_ERRORS); return false; } } private void finishDeferredRuntimeTeardown(String reason, long timeoutSeconds) { teardownRuntime(reason, timeoutSeconds); + if (serverStopTeardownDeferred.get() && !generatorDrainCompleted) { + SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup is incomplete; retaining services, pools and the plugin class loader until JVM exit."); + return; + } finishTerminalCleanup(); } @@ -1094,12 +1135,36 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { try { runPostShutdown(); } catch (Throwable e) { + runtimeTeardownFailed.set(true); Iris.reportError("Failed to run Iris post-shutdown cleanup.", e); } finally { + if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) { + runtimeTeardownFailed.set(true); + SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit."); + } IrisPlatforms.unbind(); + if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) { + SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit."); + } else { + try { + INMS.get().releasePluginClassLoaderClose(); + } catch (Throwable failure) { + SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup."); + failure.printStackTrace(SHUTDOWN_ERRORS); + } + } } } + private boolean hasActiveShutdownResources() { + if (!MultiBurst.burst.isTerminated() || !MultiBurst.ioBurst.isTerminated()) { + return true; + } + return services != null + && services.get(PreservationSVC.class) instanceof PreservationSVC preservation + && preservation.hasActiveResources(); + } + private void drainWorldGenerators(String reason, long timeoutSeconds) { List irisWorlds = new ArrayList<>(); List generators = new ArrayList<>(); @@ -1115,6 +1180,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } if (generators.isEmpty()) { + generatorDrainCompleted = true; Iris.debug("No Iris worlds to freeze."); return; } @@ -1139,12 +1205,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { try { CompletableFuture.allOf(closes.toArray(new CompletableFuture[0])) .get(timeoutSeconds, TimeUnit.SECONDS); - Iris.debug("All Iris chunk generators parked. Safe to unload."); + generatorDrainCompleted = closes.size() == generators.size(); + if (generatorDrainCompleted) { + Iris.debug("All Iris chunk generators parked. Safe to unload."); + } } catch (TimeoutException e) { - Iris.warn("Iris generator drain timed out after " + timeoutSeconds + "s; unload proceeding anyway."); + Iris.reportError("Iris generator drain timed out after " + timeoutSeconds + "s.", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - Iris.warn("Iris generator drain interrupted; unload proceeding."); + Iris.reportError("Iris generator drain was interrupted.", e); } catch (ExecutionException e) { Iris.reportError(e.getCause() == null ? e : e.getCause()); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java index e49e955be..0263af5b5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java @@ -765,13 +765,13 @@ public final class BukkitWorldReconciler { @Override public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) { - File snapshotRoot = snapshotRoot(worldKey); - IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot); String configuredWorldName = configuredWorldName(worldKey); IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\"."); } + File snapshotRoot = snapshotRoot(worldKey); + IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot); } private File snapshotRoot(NamespacedKey worldKey) { @@ -788,7 +788,7 @@ public final class BukkitWorldReconciler { throw new IllegalStateException("Iris world storage does not match the current platform layout for " + worldKey + "."); } - return IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); + return IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 7d1cdab1a..bd1a53bd2 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -20,6 +20,7 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; +import art.arcane.iris.core.lifecycle.WorldReplacementSeed; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackDownloader; @@ -32,6 +33,13 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; +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.GenerationHistoryPaths; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.volmlib.util.bukkit.WorldIdentity; @@ -255,15 +263,21 @@ public final class IrisWorldGeneratorResolver { File levelRoot = IrisWorldStorage.levelRoot(); NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()); - File pack = IrisWorldStorage.frozenDimensionRoot( + File dimensionRoot = IrisWorldStorage.frozenDimensionRoot( Bukkit.getWorldContainer(), levelRoot, configuredWorldName, worldKey ) - .map(IrisWorldStorage::requireFrozenPackRoot) .orElse(null); - IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id); + IrisDimension dimension = null; + if (dimensionRoot != null) { + long worldSeed = requireStoredWorldSeed(configuredWorldName, dimensionRoot); + GenerationHistory history = requireGenerationHistory(dimensionRoot, id, worldSeed); + File pack = requireActivePack(history); + requireSnapshotLoadable(pack); + dimension = requireHistoricalDimension(history, pack, id); + } if (dimension == null) dimension = IrisData.loadAnyDimension(id, null); if (dimension == null) { File packsRoot = IrisPlatforms.get().packsFolderNoCreate(); @@ -445,7 +459,7 @@ public final class IrisWorldGeneratorResolver { return; } if (dimensionRoot != null - && (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) { + && (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasGenerationStorage(dimensionRoot))) { return; } NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); @@ -455,9 +469,10 @@ public final class IrisWorldGeneratorResolver { + " type=; Iris registers them with Multiverse itself."); } - private static boolean hasFrozenPack(File dimensionRoot) { - Path packRoot = dimensionRoot.toPath().toAbsolutePath().normalize().resolve("iris").resolve("pack"); - return Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS); + private static boolean hasGenerationStorage(File dimensionRoot) { + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath()); + return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS) + || Files.isDirectory(paths.legacyPackRoot(), LinkOption.NOFOLLOW_LINKS); } private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) { @@ -475,7 +490,9 @@ public final class IrisWorldGeneratorResolver { throw new IllegalStateException("Frozen Iris world storage does not match the current platform layout for " + worldKey + "."); } - File snapshotRoot = IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); + long worldSeed = requireStoredWorldSeed(worldName, dimensionRoot); + GenerationHistory history = requireGenerationHistory(dimensionRoot, id, worldSeed); + File snapshotRoot = requireActivePack(history); try { requireSnapshotLoadable(snapshotRoot); } catch (BrokenPackException exception) { @@ -487,18 +504,14 @@ public final class IrisWorldGeneratorResolver { throw exception; } - IrisDimension dimension = IrisData.get(snapshotRoot).getDimensionLoader().load(id, false); - if (dimension == null) { - throw new IllegalStateException("Frozen Iris pack snapshot at " + snapshotRoot - + " does not contain dimension " + id + "."); - } + IrisDimension dimension = requireHistoricalDimension(history, snapshotRoot, id); Iris.debug("Assuming IrisDimension: " + dimension.getName()); IrisWorld world = IrisWorld.builder() .platformIdentity(worldKey.toString()) .name(worldName) - .seed(1337) + .seed(history.activeEpoch().worldSeed()) .worldFolder(dimensionRoot) .minHeight(dimension.getMinHeight()) .maxHeight(dimension.getMaxHeight()) @@ -506,7 +519,133 @@ public final class IrisWorldGeneratorResolver { Iris.debug("Generator Config: " + world); - return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey()); + return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey(), history); + } + + private static GenerationHistory requireGenerationHistory( + File dimensionRoot, + String dimensionKey, + long worldSeed + ) { + Path root = dimensionRoot.toPath().toAbsolutePath().normalize(); + try { + Optional current = GenerationHistory.openIfPresent(root, worldSeed); + GenerationHistory history = current.isPresent() + ? current.get() + : adoptLegacyGenerationHistory(root, dimensionKey, worldSeed); + List retainedContracts = history.manifest() + .epochs() + .stream() + .map(GenerationEpoch::registryContract) + .toList(); + history.requireRegistryDefinitions( + GenerationRegistryContractFactory.captureRequiredDefinitions(retainedContracts) + ); + return history; + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure); + } + } + + private static GenerationHistory adoptLegacyGenerationHistory( + Path dimensionRoot, + String dimensionKey, + long worldSeed + ) throws IOException { + Path legacyPack = GenerationHistoryPaths.forDimension(dimensionRoot).legacyPackRoot(); + requireSnapshotLoadable(legacyPack.toFile()); + String fingerprint = GenerationPackFingerprint.compute( + legacyPack, + GenerationPackFingerprint.CURRENT_VERSION + ); + IrisData data = IrisData.openDatapackCompiler(legacyPack.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IOException("Legacy Iris pack does not contain dimension " + dimensionKey + "."); + } + GenerationEpoch.DimensionContract dimensionContract = captureDimensionContract(data, dimension); + GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create( + data, + dimension, + fingerprint, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + ); + return GenerationHistory.adoptLegacyPack( + dimensionRoot, + fingerprint, + worldSeed, + dimensionContract, + registryContract + ); + } finally { + data.close(); + } + } + + private static IrisDimension requireHistoricalDimension( + GenerationHistory history, + File packRoot, + String dimensionKey + ) { + GenerationEpoch epoch = history.activeEpoch(); + if (!epoch.dimensionContract().dimensionKey().equals(dimensionKey)) { + throw new IllegalStateException("Configured dimension " + dimensionKey + + " does not match active generation history dimension " + + epoch.dimensionContract().dimensionKey() + "."); + } + IrisData data = IrisData.get(packRoot); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IllegalStateException("Immutable Iris generation pack at " + packRoot + + " does not contain dimension " + dimensionKey + "."); + } + GenerationEpoch.DimensionContract actualContract = captureDimensionContract(data, dimension); + if (!epoch.dimensionContract().equals(actualContract)) { + throw new IllegalStateException("Immutable Iris generation pack dimension contract changed at " + + packRoot + "."); + } + return dimension; + } + + private static GenerationEpoch.DimensionContract captureDimensionContract( + IrisData data, + IrisDimension dimension + ) { + String dimensionTypeKey = IrisPlatforms.get() + .registries() + .generationRegistry() + .dimensionTypeResourceKey( + data.getDataFolder().getName(), + dimension.getLoadKey(), + dimension.getDimensionTypeKey() + ); + return GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + dimensionTypeKey + ); + } + + private static File requireActivePack(GenerationHistory history) { + try { + return history.activePackRoot().toFile(); + } catch (IOException failure) { + throw new IllegalStateException("Active Iris generation pack is unusable.", failure); + } + } + + private static long requireStoredWorldSeed(String worldName, File dimensionRoot) { + Long configuredSeed = IrisWorlds.readBukkitWorldSeed(worldName); + if (configuredSeed != null) { + return configuredSeed; + } + try { + return WorldReplacementSeed.readAuthoritativeSeed(dimensionRoot.toPath()); + } catch (IOException failure) { + throw new IllegalStateException("Iris world " + worldName + + " has no authoritative generation seed.", failure); + } } private record FreshValidation( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index 76dcbf23d..a4f56a307 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -164,24 +164,28 @@ public final class PendingWorldReplacementManager implements Listener { boolean configurationApplied = false; try { Files.createDirectory(paths.stage()); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( - requiredSender, - requiredDimension, - paths.stage().toFile() - ); - if (installed == null) { - throw new IOException("Iris could not stage the dimension pack."); - } - requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( paths.target(), paths.stage(), seedSelection ); + IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( + requiredSender, + requiredDimension, + paths.stage().toFile(), + effectiveSeed + ); + if (installed == null) { + throw new IOException("Iris could not stage the dimension pack."); + } + requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); } - File stagedPack = paths.stage().resolve("iris/pack").toFile(); + File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot( + paths.stage().toFile(), + effectiveSeed + ); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); transaction = new Transaction( @@ -806,7 +810,10 @@ public final class PendingWorldReplacementManager implements Listener { validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind()); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id()); String fingerprint = WorldReplacementFilesystem.fingerprintPack( - paths.target().resolve("iris/pack")); + IrisWorldStorage.requireActiveGenerationPackRoot( + paths.target().toFile(), + transaction.seed() + ).toPath()); if (!transaction.packFingerprint().equals(fingerprint)) { throw new IOException("The replacement pack changed before runtime verification."); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index 3789cdb0f..b9814e788 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -176,7 +176,7 @@ public class CommandDeveloper implements DirectorExecutor { return z ^ (z >>> 31); } - @Director(description = "Update the pack of a world (UNSAFE!)", descriptionKey = "iris.director.commanddeveloper.director.update_pack_world_unsafe", name = "update-world", aliases = "^world") + @Director(description = "Stage a world generation update", descriptionKey = "iris.director.commanddeveloper.director.stage_world_generation_update", name = "update-world", aliases = "^world") public void updateWorld( @Param(description = "The world to update", descriptionKey = "iris.director.commanddeveloper.param.world_update", contextual = true, contextualOverride = true) World world, @@ -202,7 +202,7 @@ public class CommandDeveloper implements DirectorExecutor { LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH, pack.getLoadKey() )) { - Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder); + Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder, world.getSeed()); } catch (LifecycleOperationCoordinator.BusyException e) { sender().sendMessage(C.YELLOW + e.getMessage()); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 15677459a..f3f9f86d4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -28,8 +28,8 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.history.GenerationSemanticQueries; import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; -import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -41,8 +41,10 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.specialhandlers.HydrologyTypeHandler; -import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler; +import art.arcane.iris.engine.history.GenerationFindCatalog; +import art.arcane.iris.util.common.director.specialhandlers.LocatableObjectHandler; import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler; +import art.arcane.iris.util.common.director.specialhandlers.ReachableRegionHandler; import art.arcane.iris.util.common.director.specialhandlers.StructureHandler; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -90,7 +92,7 @@ public class CommandFind implements DirectorExecutor { @Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region") public void region( - @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look") + @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class) IrisRegion region, @Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported_2", defaultValue = "true") boolean teleport @@ -138,10 +140,6 @@ public class CommandFind implements DirectorExecutor { return; } IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime(); - if (runtime == null) { - commandSender.sendMessage(C.YELLOW + "Hydrology is not active in this world."); - return; - } HydrologyFeatureQuery query; try { query = HydrologyFeatureQuery.parse(type); @@ -150,15 +148,19 @@ public class CommandFind implements DirectorExecutor { return; } Location origin = target.getLocation(); - int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15); - int maximumDistance = runtime.maximumFeatureSearchDistance( - origin.getBlockX(), origin.getBlockZ(), requestedDistance); + int requestedDistance = runtime == null + ? 8192 + : Math.min(8192, runtime.settings().routing().tileSize() * 15); + int maximumDistance = runtime == null + ? requestedDistance + : runtime.maximumFeatureSearchDistance( + origin.getBlockX(), origin.getBlockZ(), requestedDistance); commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "..."); J.a(() -> { try { - HydrologyFeatureRef feature = runtime.nearestFeature( - query.types(), - query.profileKey(), + GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver( + activeEngine, + query, origin.getBlockX(), origin.getBlockZ(), maximumDistance, @@ -171,15 +173,14 @@ public class CommandFind implements DirectorExecutor { + maximumDistance + " blocks."); return; } - int worldY = feature.y() + activeEngine.getDimension().getMinHeight(); String label = type + " hydrology feature"; if (!teleport) { sendStructureMessage(target, commandSender, - C.GREEN + "Found " + label + " at " + feature.x() + ", " + worldY + ", " + feature.z() + "."); + C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + "."); return; } Location destination = new Location( - target.getWorld(), feature.x(), worldY, feature.z()); + target.getWorld(), feature.x(), feature.y(), feature.z()); prepareStructureTeleport( target, target.getWorld(), commandSender, label, destination, true); } catch (Throwable error) { @@ -233,6 +234,12 @@ public class CommandFind implements DirectorExecutor { registered, decision, nativePlacement, locatableNativePlacement, locatableEditablePlacement, nativeGenerationEnabled, reachable); + if (route != StructureLookupRoute.NATIVE && route != StructureLookupRoute.IRIS + && GenerationFindCatalog.hasRetainedStructurePlacement(e, structureKey)) { + locateIrisStructure(e, structureKey, commandSender); + return; + } + if (route == StructureLookupRoute.UNKNOWN) { commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey))); return; @@ -672,7 +679,7 @@ public class CommandFind implements DirectorExecutor { J.a(() -> { try { IrisStructureLocator.LocateResult result = - IrisStructureLocator.locate(engine, structure, blockX, blockZ, 1024); + GenerationSemanticQueries.nearestStructure(engine, structure, blockX, blockZ, 1024); if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { sendStructureMessage(target, commandSender, C.YELLOW + "Unable to locate " + structure @@ -697,7 +704,7 @@ public class CommandFind implements DirectorExecutor { @Director(description = "Find an object", descriptionKey = "iris.director.commandfind.director.find_object") public void object( - @Param(description = "The object to look for", descriptionKey = "iris.director.commandfind.param.object_look", customHandler = ObjectHandler.class) + @Param(description = "The object to look for", descriptionKey = "iris.director.commandfind.param.object_look", customHandler = LocatableObjectHandler.class) String object, @Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported_4", defaultValue = "true") boolean teleport @@ -721,7 +728,7 @@ public class CommandFind implements DirectorExecutor { } } - if (e.hasObjectPlacement(object)) { + if (GenerationFindCatalog.hasObjectPlacement(e, object)) { EngineBukkitOps.gotoObject(e, object, player(), teleport); return; } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java index 8316a7653..00e06b2e1 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java @@ -279,12 +279,14 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D private DirectorExecutionResult runDirector(CommandSender sender, String label, String[] args) { dispatchSenders.set(sender); + DirectorContext.touch(new VolmitSender(sender)); try (LanguageAudience.Scope audience = LanguageAudience.open(sender instanceof Player player ? player.getUniqueId() : null)) { return getDirector().execute(new DirectorInvocation(new BukkitDirectorSender(sender), label, Arrays.asList(args))); } catch (Throwable e) { Iris.warn("Director command execution failed: " + e.getClass().getSimpleName() + " " + e.getMessage()); return DirectorExecutionResult.notHandled(); } finally { + DirectorContext.remove(); dispatchSenders.remove(); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java index 0e0138ba5..fee53c500 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java @@ -9,6 +9,40 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class IrisShutdownOrderingTest { + @Test + public void serverStopHoldsThePluginLoaderUntilTerminalCleanupCompletes() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); + String quiesce = section(source, "private void quiesceRuntimeForServerShutdown", "private void startPostStopFinisher"); + String cleanup = section(source, "private void finishTerminalCleanup()", "private void drainWorldGenerators"); + String teardown = section(source, "private void teardownRuntime", "private void quiesceRuntimeForServerShutdown"); + String deferred = section(source, "private void finishDeferredRuntimeTeardown", "private void finishTerminalCleanup"); + + assertOrdered(quiesce, "serverStopTeardownDeferred.set(true)", "deferPluginClassLoaderClose()", + "jigsawStudioService.quiesceForServerShutdown()"); + assertOrdered(cleanup, "runPostShutdown()", "hasActiveShutdownResources()", "IrisPlatforms.unbind()", + "runtimeTeardownFailed.get()", "releasePluginClassLoaderClose()"); + assertTrue(cleanup.contains("!MultiBurst.burst.isTerminated() || !MultiBurst.ioBurst.isTerminated()")); + assertTrue(cleanup.contains("preservation.hasActiveResources()")); + assertOrdered(teardown, "drainWorldGenerators(reason, timeoutSeconds)", "!generatorDrainCompleted", + "return;", "service.onDisable()"); + assertOrdered(deferred, "teardownRuntime(reason, timeoutSeconds)", "!generatorDrainCompleted", + "SHUTDOWN_ERRORS.println", "return;", "finishTerminalCleanup()"); + } + + @Test + public void deferredShutdownErrorsBypassClosedLoggingServices() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); + String throwableReport = section(source, "public static void reportError(Throwable e)", + "public static void reportError(String context, Throwable e)"); + String contextualReport = section(source, "public static void reportError(String context, Throwable e)", + "public static void dump()"); + + assertOrdered(throwableReport, "serverStopTeardownDeferred.get()", + "e.printStackTrace(SHUTDOWN_ERRORS)", "boolean debug = false"); + assertOrdered(contextualReport, "serverStopTeardownDeferred.get()", + "SHUTDOWN_ERRORS.println(", "error.printStackTrace(SHUTDOWN_ERRORS)", "Iris.error(message)"); + } + @Test public void drainWorldGenerators_closesGeneratorsWithoutEagerMantleSave() throws Exception { String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); @@ -18,6 +52,22 @@ public class IrisShutdownOrderingTest { assertFalse("Shutdown must not close Mantle plates before generation drains", drain.contains("saveAllNow()")); } + @Test + public void shutdownBoundaryIsPreparedBeforeHookRegistrationAndDoesNotUseClosedBindings() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); + String registration = section(source, "public void addShutdownHook()", "public void removeShutdownHook()"); + String boundary = section(source, "private boolean awaitServerShutdownBoundary()", + "private void finishDeferredRuntimeTeardown"); + + assertOrdered(registration, "createServerShutdownBoundary()", "new Thread(this::runShutdownHook", + "Runtime.getRuntime().addShutdownHook"); + assertTrue(boundary.contains("boundary.await(")); + assertFalse(boundary.contains("INMS.get()")); + assertFalse(boundary.contains("Iris.reportError")); + assertTrue(source.contains("new FileOutputStream(FileDescriptor.err)")); + assertOrdered(boundary, "SHUTDOWN_ERRORS.println(", "e.printStackTrace(SHUTDOWN_ERRORS)", "return false;"); + } + @Test public void serverStop_defersRuntimeTeardownUntilPaperShutdownBoundary() throws Exception { String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); @@ -43,6 +93,7 @@ public class IrisShutdownOrderingTest { "finishDeferredRuntimeTeardown(\"startup-boundary-restart-hook\", 30L)", "return;", "awaitServerShutdownBoundary()", + "SHUTDOWN_ERRORS.println", "finishDeferredRuntimeTeardown(\"shutdown-hook\", 30L)"); assertOrdered(finisher, "finisher.setDaemon(false)", @@ -85,8 +136,8 @@ public class IrisShutdownOrderingTest { assertOrdered(teardown, "drainWorldGenerators(reason, timeoutSeconds)", "service.onDisable()", - "MultiBurst.burst::close", - "MultiBurst.ioBurst::close"); + "MultiBurst.burst.close()", + "MultiBurst.ioBurst.close()"); assertTrue("Engine service must retain its generator close behind deferred service teardown", engineDisable.contains("startClose(")); assertTrue("Studio service must retain its generator close behind deferred service teardown", diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index 88467a5a8..e86eaca7e 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -195,7 +195,7 @@ public class IrisWorldGeneratorResolverTest { } @Test - public void configuredWorldResolutionUsesOnlyFrozenWorldLocalSnapshot() throws Exception { + public void configuredWorldResolutionUsesOnlyActiveImmutableGenerationPack() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n"); int resolverStart = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator("); @@ -208,26 +208,32 @@ public class IrisWorldGeneratorResolverTest { "Frozen Iris world storage does not match the current platform layout", currentPlatformRoot ); - int snapshotRoot = resolver.indexOf("IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)"); + int history = resolver.indexOf("requireGenerationHistory(dimensionRoot, id, worldSeed)"); + int snapshotRoot = resolver.indexOf("requireActivePack(history)", history); int validation = resolver.indexOf("requireSnapshotLoadable(snapshotRoot)"); - int exactLoad = resolver.indexOf( - "IrisData.get(snapshotRoot).getDimensionLoader().load(id, false)" - ); + int exactLoad = resolver.indexOf("requireHistoricalDimension(history, snapshotRoot, id)"); int canonicalIdentity = resolver.indexOf(".platformIdentity(worldKey.toString())"); + int historicalSeed = resolver.indexOf(".seed(history.activeEpoch().worldSeed())"); int resolvedStorage = resolver.indexOf(".worldFolder(dimensionRoot)"); assertTrue(dimensionRoot >= 0); assertTrue(currentPlatformRoot > dimensionRoot); assertTrue(layoutRefusal > currentPlatformRoot); - assertTrue(snapshotRoot > layoutRefusal); + assertTrue(history > layoutRefusal); + assertTrue(snapshotRoot > history); assertTrue(validation > snapshotRoot); assertTrue(exactLoad > validation); assertTrue(canonicalIdentity > exactLoad); - assertTrue(resolvedStorage > canonicalIdentity); + assertTrue(historicalSeed > canonicalIdentity); + assertTrue(resolvedStorage > historicalSeed); assertFalse(resolver.contains("loadDimension(")); assertFalse(resolver.contains("loadAnyDimension(")); assertFalse(resolver.contains("replaceIntoWorld(")); assertFalse(resolver.contains("installIntoWorld(")); + assertFalse(resolver.contains(".seed(1337)")); + assertTrue(resolver.contains("GenerationHistory.openIfPresent(root, worldSeed)")); + assertTrue(resolver.contains("GenerationHistory.adoptLegacyPack(")); + assertTrue(resolver.contains("GenerationRegistryContractFactory.captureRequiredDefinitions(")); } @Test diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandDeveloperGenerationHistoryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandDeveloperGenerationHistoryTest.java new file mode 100644 index 000000000..c2308b371 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandDeveloperGenerationHistoryTest.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class CommandDeveloperGenerationHistoryTest { + @Test + public void updateWorldStagesThePackWithTheLiveWorldSeed() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java" + )).replace("\r\n", "\n"); + int updateStart = source.indexOf("public void updateWorld("); + int updateEnd = source.indexOf("@Director(description = \"Test\"", updateStart); + String update = source.substring(updateStart, updateEnd); + + assertTrue(update.contains( + "replaceIntoWorld(sender(), pack, folder, world.getSeed())" + )); + assertFalse(update.contains("iris/pack")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindRiverContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindRiverContractTest.java new file mode 100644 index 000000000..eaf5a6624 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindRiverContractTest.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class CommandFindRiverContractTest { + @Test + public void riverLookupMergesRecordedAndEligibleActiveFeatures() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); + int methodStart = source.indexOf("public void river("); + int methodEnd = source.indexOf("public void structure(", methodStart); + String method = source.substring(methodStart, methodEnd); + + assertTrue(method.contains("GenerationSemanticQueries.nearestRiver(")); + assertTrue(method.contains("runtime == null")); + assertTrue(method.contains("feature.y()")); + assertFalse(method.contains("runtime.nearestFeature(")); + assertFalse(method.contains("feature.y() + activeEngine.getDimension().getMinHeight()")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindStructureHistoryContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindStructureHistoryContractTest.java new file mode 100644 index 000000000..119657887 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindStructureHistoryContractTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class CommandFindStructureHistoryContractTest { + @Test + public void admitsRetainedStructuresBeforeActiveOnlyRejectionsAndUsesSemanticSearch() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); + int structureStart = source.indexOf("public void structure("); + int retainedAdmission = source.indexOf("GenerationFindCatalog.hasRetainedStructurePlacement(e, structureKey)", structureStart); + int unknownRejection = source.indexOf("if (route == StructureLookupRoute.UNKNOWN)", structureStart); + assertTrue(retainedAdmission > structureStart && retainedAdmission < unknownRejection); + + int locateStart = source.indexOf("private void locateIrisStructure("); + int locateEnd = source.indexOf("public void object(", locateStart); + String locate = source.substring(locateStart, locateEnd); + assertTrue(locate.contains("GenerationSemanticQueries.nearestStructure(")); + assertFalse(locate.contains("IrisStructureLocator.locate(")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java index 06bd55ecc..c715cf9a0 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java @@ -30,7 +30,7 @@ public class BukkitEngineLifecycleContractTest { String baseHeight = method(source, "public int getBaseHeight("); assertTrue(baseHeight.contains("currentEngine.acquireGenerationLease(\"bukkit_base_height\")")); assertTrue(baseHeight.contains("IrisContext.open(currentEngine, lease.sessionId(), null)")); - assertTrue(baseHeight.contains("catch (GenerationSessionException e)")); + assertTrue(baseHeight.contains("catch (GenerationSessionException | IOException e)")); String generation = method(source, "public void generateNoise("); assertTrue(generation.contains("throw new IllegalStateException")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/CommandSVCFindParsingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/CommandSVCFindParsingTest.java new file mode 100644 index 000000000..df30ba43e --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/CommandSVCFindParsingTest.java @@ -0,0 +1,155 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.commands.CommandIris; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationFindCatalog; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.director.DirectorContext; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.DirectorEngineOptions; +import art.arcane.volmlib.util.director.compat.DirectorEngineFactory; +import art.arcane.volmlib.util.director.runtime.DirectorExecutionDispatcher; +import art.arcane.volmlib.util.director.runtime.DirectorExecutionResult; +import art.arcane.volmlib.util.director.runtime.DirectorRuntimeEngine; +import art.arcane.volmlib.util.plugin.ComponentMessenger; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class CommandSVCFindParsingTest { + @Test + public void registeredBiomeHandlerResolvesActiveThenRemovedNamesDuringParsing() throws Exception { + Fixture fixture = new Fixture(); + TestCommandService service = new TestCommandService(DirectorExecutionDispatcher.IMMEDIATE); + IrisBiome plateau = biome("plateau"); + IrisBiome meadow = biome("meadow"); + try (MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + MockedStatic catalog = mockStatic(GenerationFindCatalog.class); + MockedStatic operations = mockStatic(EngineBukkitOps.class)) { + toolbelt.when(() -> IrisToolbelt.access(fixture.world)).thenReturn(fixture.generator); + catalog.when(() -> GenerationFindCatalog.biomes(fixture.engine)) + .thenReturn(new KList<>(plateau, meadow)); + + assertTrue(execute(service, fixture.player, "biome", "biome=plateau").isSuccess()); + assertNull(DirectorContext.get()); + assertTrue(execute(service, fixture.player, "biome", "biome=meadow").isSuccess()); + assertNull(DirectorContext.get()); + + operations.verify(() -> EngineBukkitOps.gotoBiome(same(fixture.engine), same(plateau), same(fixture.player), eq(true))); + operations.verify(() -> EngineBukkitOps.gotoBiome(same(fixture.engine), same(meadow), same(fixture.player), eq(true))); + catalog.verify(() -> GenerationFindCatalog.biomes(fixture.engine), times(2)); + catalog.verifyNoMoreInteractions(); + } + } + + @Test + public void registeredHistoricalHandlersParseBeforeDeferredInvocationAndClearFailedContext() throws Exception { + Fixture fixture = new Fixture(); + List queued = new ArrayList<>(); + TestCommandService service = new TestCommandService((mode, action) -> queued.add(action)); + IrisBiome meadow = biome("meadow"); + IrisRegion oldRegion = new IrisRegion(); + oldRegion.setLoadKey("old-region"); + try (MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + MockedStatic catalog = mockStatic(GenerationFindCatalog.class); + MockedStatic operations = mockStatic(EngineBukkitOps.class); + MockedStatic messages = mockStatic(ComponentMessenger.class)) { + toolbelt.when(() -> IrisToolbelt.access(fixture.world)).thenReturn(fixture.generator); + catalog.when(() -> GenerationFindCatalog.biomes(fixture.engine)).thenReturn(new KList<>(meadow)); + catalog.when(() -> GenerationFindCatalog.regions(fixture.engine)).thenReturn(new KList<>(oldRegion)); + catalog.when(() -> GenerationFindCatalog.objectKeys(fixture.engine)).thenReturn(new KList<>("old-tree")); + operations.when(() -> EngineBukkitOps.gotoBiome(fixture.engine, meadow, fixture.player, true)) + .thenAnswer(invocation -> { + assertSame(fixture.player, DirectorContext.get().player()); + return null; + }); + + assertTrue(execute(service, fixture.player, "biome", "biome=meadow").isSuccess()); + assertTrue(execute(service, fixture.player, "region", "region=old-region").isSuccess()); + assertTrue(execute(service, fixture.player, "object", "object=old-tree").isSuccess()); + assertEquals(3, queued.size()); + assertNull(DirectorContext.get()); + + queued.getFirst().run(); + assertNull(DirectorContext.get()); + operations.verify(() -> EngineBukkitOps.gotoBiome(fixture.engine, meadow, fixture.player, true)); + + DirectorExecutionResult rejected = execute(service, fixture.player, "biome", "biome=authoring-only"); + assertFalse(rejected.isSuccess()); + assertTrue(rejected.getMessage().contains("Cannot convert")); + assertEquals(3, queued.size()); + assertNull(DirectorContext.get()); + catalog.verify(() -> GenerationFindCatalog.regions(fixture.engine)); + catalog.verify(() -> GenerationFindCatalog.objectKeys(fixture.engine)); + } + } + + private static DirectorExecutionResult execute(CommandSVC service, Player player, String command, String argument) + throws Exception { + Method run = CommandSVC.class.getDeclaredMethod("runDirector", CommandSender.class, String.class, String[].class); + run.setAccessible(true); + return (DirectorExecutionResult) run.invoke(service, player, "iris", + new String[]{"find", command, argument, "teleport=true"}); + } + + private static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + private static final class Fixture { + private final Engine engine = mock(Engine.class); + private final Player player = mock(Player.class); + private final World world = mock(World.class); + private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + + private Fixture() { + when(player.getWorld()).thenReturn(world); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(generator.getEngine()).thenReturn(engine); + } + } + + private static final class TestCommandService extends CommandSVC { + private final DirectorRuntimeEngine director; + + private TestCommandService(DirectorExecutionDispatcher dispatcher) { + try (MockedStatic iris = mockStatic(Iris.class)) { + director = DirectorEngineFactory.create(new CommandIris(), DirectorEngineOptions.builder() + .dispatcher(dispatcher) + .invocationHook(this) + .build()); + } + } + + @Override + public DirectorRuntimeEngine getDirector() { + return director; + } + } +} diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index c989041c2..cf232ed1c 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -26,6 +26,8 @@ plugins { alias(libs.plugins.shadow) } +apply from: file('../../gradle/modded-generation-kernel-seals.gradle') + Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) @@ -163,6 +165,7 @@ dependencies { "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), libs.paralithic, + libs.asm, libs.lru, libs.caffeine, libs.dom4j, diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 5dab53473..a86d70d36 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -26,6 +26,8 @@ plugins { alias(libs.plugins.shadow) } +apply from: file('../../gradle/modded-generation-kernel-seals.gradle') + Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) @@ -162,6 +164,7 @@ dependencies { "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), libs.paralithic, + libs.asm, libs.lru, libs.caffeine, libs.dom4j, diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java index 68866a784..1c9daf4db 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java @@ -1,9 +1,11 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.spi.IrisLogging; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; @@ -23,6 +25,7 @@ import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; import java.util.ArrayList; +import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,14 +51,17 @@ public final class NativeStructureReferenceRepair { originChunkX <= target.x() + REFERENCE_DISTANCE_CHUNKS; originChunkX++) { for (int originChunkZ = target.z() - REFERENCE_DISTANCE_CHUNKS; originChunkZ <= target.z() + REFERENCE_DISTANCE_CHUNKS; originChunkZ++) { - ChunkAccess originChunk = level.getChunk( - originChunkX, originChunkZ, ChunkStatus.STRUCTURE_STARTS); - for (Map.Entry entry : originChunk.getAllStarts().entrySet()) { - ScannedStart start = scanStart( - engine, serverLevel, structureManager, targetChunk, registry, - originChunk, entry.getKey(), entry.getValue()); - if (start != null && isTargetRelevant(engine, targetChunk, start)) { - starts.add(start); + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openOriginRuntimeScope(engine, originChunkX, originChunkZ)) { + ChunkAccess originChunk = level.getChunk( + originChunkX, originChunkZ, ChunkStatus.STRUCTURE_STARTS); + for (Map.Entry entry : originChunk.getAllStarts().entrySet()) { + ScannedStart start = scanStart( + engine, serverLevel, structureManager, targetChunk, registry, + originChunk, entry.getKey(), entry.getValue()); + if (start != null && isTargetRelevant(engine, targetChunk, start)) { + starts.add(start); + } } } } @@ -75,6 +81,22 @@ public final class NativeStructureReferenceRepair { } } + private static GenerationHistoryRuntimeRouter.CoordinateScope openOriginRuntimeScope( + Engine engine, + int chunkX, + int chunkZ + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + return null; + } + try { + return irisEngine.openGenerationHistoryCoordinateScope(chunkX << 4, chunkZ << 4); + } catch (IOException failure) { + throw new IllegalStateException("Unable to resolve native structure references for origin " + + chunkX + "," + chunkZ + " through generation history.", failure); + } + } + private static boolean isTargetRelevant( Engine engine, ChunkAccess targetChunk, ScannedStart scanned) { StructureStart start = scanned.start(); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 1a1a12f1e..6515b1793 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -1,11 +1,13 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; @@ -28,6 +30,7 @@ import net.minecraft.world.level.levelgen.structure.StructureSet; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import java.io.IOException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -38,6 +41,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import java.util.function.IntBinaryOperator; +import java.util.function.IntConsumer; import java.util.function.Supplier; /** @@ -60,13 +64,14 @@ public final class NativeStructureVolumeIndex { private final Context context; private final OriginResolver origins; - private final Map> originCache = lru(MAX_CACHED_ORIGIN_CHUNKS); - private final Map> queryCache = lru(MAX_CACHED_QUERY_CHUNKS); - private final ConcurrentHashMap>> originBuilds = + private final Map> originCache = lru(MAX_CACHED_ORIGIN_CHUNKS); + private final Map> queryCache = lru(MAX_CACHED_QUERY_CHUNKS); + private final ConcurrentHashMap>> originBuilds = new ConcurrentHashMap<>(); - private final ConcurrentHashMap>> queryBuilds = + private final ConcurrentHashMap>> queryBuilds = new ConcurrentHashMap<>(); private final ReentrantLock[] originWindowStripes = createOriginWindowStripes(); + private final IntConsumer retirementListener = this::evictRuntime; private NativeStructureVolumeIndex(Context context) { this.context = Objects.requireNonNull(context, "Native structure volume context must not be null"); @@ -79,13 +84,25 @@ public final class NativeStructureVolumeIndex { } public static void install(Engine engine, Context context) { - Objects.requireNonNull(engine, "Native structure volume index requires an engine"); - INDEXES.put(engine, new NativeStructureVolumeIndex(context)); + Engine requiredEngine = Objects.requireNonNull(engine, "Native structure volume index requires an engine"); + NativeStructureVolumeIndex installed = new NativeStructureVolumeIndex(context); + synchronized (INDEXES) { + NativeStructureVolumeIndex previous = INDEXES.put(requiredEngine, installed); + if (previous != null) { + previous.detachRetirementListener(requiredEngine); + } + installed.attachRetirementListener(requiredEngine); + } } public static void uninstall(Engine engine) { if (engine != null) { - INDEXES.remove(engine); + synchronized (INDEXES) { + NativeStructureVolumeIndex removed = INDEXES.remove(engine); + if (removed != null) { + removed.detachRetirementListener(engine); + } + } } } @@ -103,6 +120,7 @@ public final class NativeStructureVolumeIndex { } KList resolve(Engine engine, int minX, int minZ, int maxX, int maxZ) { + int runtimeId = runtimeId(engine); int fromChunkX = Math.min(minX, maxX) >> 4; int toChunkX = Math.max(minX, maxX) >> 4; int fromChunkZ = Math.min(minZ, maxZ) >> 4; @@ -110,7 +128,7 @@ public final class NativeStructureVolumeIndex { KList matches = null; for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { - for (NativeStructureVolume volume : chunkVolumes(engine, chunkX, chunkZ)) { + for (NativeStructureVolume volume : chunkVolumes(engine, runtimeId, chunkX, chunkZ)) { if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { continue; } @@ -126,8 +144,13 @@ public final class NativeStructureVolumeIndex { return matches == null ? NativeStructureVolume.NONE : matches; } - private KList chunkVolumes(Engine engine, int chunkX, int chunkZ) { - return cached(queryCache, queryBuilds, chunkKey(chunkX, chunkZ), + private KList chunkVolumes( + Engine engine, + int runtimeId, + int chunkX, + int chunkZ + ) { + return cached(queryCache, queryBuilds, new RuntimeChunkKey(runtimeId, chunkKey(chunkX, chunkZ)), () -> buildChunkVolumes(engine, chunkX, chunkZ)); } @@ -178,8 +201,16 @@ public final class NativeStructureVolumeIndex { } KList originVolumes(Engine engine, int chunkX, int chunkZ) { - return cached(originCache, originBuilds, chunkKey(chunkX, chunkZ), - () -> origins.volumesAt(engine, chunkX, chunkZ)); + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openHistoryCoordinateScope(engine, chunkX, chunkZ)) { + int runtimeId = runtimeId(engine); + return cached( + originCache, + originBuilds, + new RuntimeChunkKey(runtimeId, chunkKey(chunkX, chunkZ)), + () -> origins.volumesAt(engine, chunkX, chunkZ) + ); + } } private KList buildOriginVolumes(Engine engine, int chunkX, int chunkZ) { @@ -344,9 +375,23 @@ public final class NativeStructureVolumeIndex { return target; } - private KList cached(Map> cache, - ConcurrentHashMap>> builds, - long key, Supplier> loader) { + void evictRuntime(int runtimeId) { + synchronized (originCache) { + originCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + } + synchronized (queryCache) { + queryCache.keySet().removeIf(key -> key.runtimeId() == runtimeId); + } + originBuilds.keySet().removeIf(key -> key.runtimeId() == runtimeId); + queryBuilds.keySet().removeIf(key -> key.runtimeId() == runtimeId); + } + + private KList cached( + Map> cache, + ConcurrentHashMap>> builds, + RuntimeChunkKey key, + Supplier> loader + ) { synchronized (cache) { KList hit = cache.get(key); if (hit != null) { @@ -374,7 +419,9 @@ public final class NativeStructureVolumeIndex { try { KList built = loader.get(); synchronized (cache) { - cache.put(key, built); + if (builds.get(key) == future) { + cache.put(key, built); + } } future.complete(built); return built; @@ -433,15 +480,52 @@ public final class NativeStructureVolumeIndex { } } - private static Map> lru(int capacity) { + private static GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( + Engine engine, + int chunkX, + int chunkZ + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + return null; + } + try { + return irisEngine.openGenerationHistoryCoordinateScope(chunkX << 4, chunkZ << 4); + } catch (IOException failure) { + throw new IllegalStateException("Unable to route native structure origin " + + chunkX + "," + chunkZ + " through generation history.", failure); + } + } + + private static int runtimeId(Engine engine) { + return engine == null ? 0 : engine.getCacheID(); + } + + private void attachRetirementListener(Engine engine) { + if (engine instanceof IrisEngine irisEngine) { + irisEngine.addGenerationRuntimeRetirementListener(retirementListener); + } + } + + private void detachRetirementListener(Engine engine) { + if (engine instanceof IrisEngine irisEngine) { + irisEngine.removeGenerationRuntimeRetirementListener(retirementListener); + } + } + + private static Map> lru(int capacity) { return new LinkedHashMap<>(16, 0.75F, true) { @Override - protected boolean removeEldestEntry(Map.Entry> eldest) { + protected boolean removeEldestEntry( + Map.Entry> eldest + ) { return size() > capacity; } }; } + private record RuntimeChunkKey(int runtimeId, long chunkKey) { + } + /** * The generator, biome source and structure state arrive as suppliers so an installed index never holds the * level (and through it the engine) strongly. That keeps the weakly keyed registry collectable on world unload. diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java index de3e09a03..b2f6b7c81 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -18,9 +18,11 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.util.project.context.IrisContext; @@ -43,6 +45,7 @@ import net.minecraft.world.level.biome.Climate; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureSet; +import java.io.IOException; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; @@ -63,7 +66,6 @@ final class IrisModdedBiomeSource extends BiomeSource { private final BiomeSource serializedSource; private final Set warnedUnresolvedBiomeKeys = ConcurrentHashMap.newKeySet(); - private final Set structureStateSources = ConcurrentHashMap.newKeySet(); // Pack generation. Every cache on this source is keyed by it, which is what keeps memoized tables from // surviving a repoint with the previous pack's content. private final AtomicLong packGeneration = new AtomicLong(); @@ -96,9 +98,12 @@ final class IrisModdedBiomeSource extends BiomeSource { possibleStructureBiomeKeys = null; biomeKeySets = null; possibleBiomesCache = null; - for (StructureStateBiomeSource source : structureStateSources) { - source.clearCache(); - } + } + + void evictRuntime(int runtimeIdentity) { + visibleBiomeCache.evictRuntime(runtimeIdentity); + structureBiomeCache.evictRuntime(runtimeIdentity); + surfaceStructureBiomeCache.evictRuntime(runtimeIdentity); } /** @@ -132,7 +137,6 @@ final class IrisModdedBiomeSource extends BiomeSource { } }); StructureStateBiomeSource source = new StructureStateBiomeSource(this, Set.copyOf(possible)); - structureStateSources.add(source); return source; } @@ -261,15 +265,19 @@ final class IrisModdedBiomeSource extends BiomeSource { if (engine == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } - GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome"); - if (lease == null) { - throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); - } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - if (!isReady(engine)) { - throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + engine, quartX << 2, quartZ << 2, "modded_structure_biome"); + try (historyScope) { + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + } + return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); } - return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); } } @@ -279,13 +287,14 @@ final class IrisModdedBiomeSource extends BiomeSource { return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); } long key = packNoiseKey(quartX, quartY, quartZ); + int runtimeIdentity = engine.getCacheID(); BiomeHolderTable cache = structureBiomeCache; - Holder cached = cache.get(key); + Holder cached = cache.get(runtimeIdentity, key); if (cached != null) { return cached; } Holder resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler); - cache.put(key, resolved); + cache.put(runtimeIdentity, key, resolved); return resolved; } @@ -294,23 +303,28 @@ final class IrisModdedBiomeSource extends BiomeSource { if (engine == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } - GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_visible_biome"); - if (lease == null) { - throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); - } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - if (!isReady(engine)) { - throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); + GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + engine, quartX << 2, quartZ << 2, "modded_visible_biome"); + try (historyScope) { + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_visible_biome"); + if (lease == null) { + throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); } - long key = packNoiseKey(quartX, quartY, quartZ); - BiomeHolderTable cache = visibleBiomeCache; - Holder cached = cache.get(key); - if (cached != null) { - return cached; + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); + } + long key = packNoiseKey(quartX, quartY, quartZ); + int runtimeIdentity = engine.getCacheID(); + BiomeHolderTable cache = visibleBiomeCache; + Holder cached = cache.get(runtimeIdentity, key); + if (cached != null) { + return cached; + } + Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); + cache.put(runtimeIdentity, key, resolved); + return resolved; } - Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); - cache.put(key, resolved); - return resolved; } } @@ -349,13 +363,19 @@ final class IrisModdedBiomeSource extends BiomeSource { if (lease == null) { throw new IllegalStateException("Iris biome radius lookup was rejected during an engine transition"); } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - if (!isReady(engine)) { - throw new IllegalStateException("Iris biome radius lookup has no active engine runtime"); + boolean surfaceQuery; + try (lease) { + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + engine, x, z, "modded_biomes_within"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris biome radius lookup has no active engine runtime"); + } + boolean monumentQuery = isMonumentSurfaceBiomeQuery( + y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); + surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight()); } - boolean monumentQuery = isMonumentSurfaceBiomeQuery( - y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); - if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight())) { + if (!surfaceQuery) { return super.getBiomesWithin(x, y, z, radius, sampler); } int minQuartX = QuartPos.fromBlock(x - radius); @@ -366,7 +386,11 @@ final class IrisModdedBiomeSource extends BiomeSource { Set> biomes = new HashSet<>(columns); for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { - biomes.add(getSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + engine, quartX << 2, quartZ << 2, "modded_biomes_within"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + biomes.add(getSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + } } } return biomes; @@ -376,13 +400,14 @@ final class IrisModdedBiomeSource extends BiomeSource { private Holder getSurfaceStructureBiome(Engine engine, int quartX, int quartZ, Climate.Sampler sampler) { long key = packColumnKey(quartX, quartZ); + int runtimeIdentity = engine.getCacheID(); BiomeHolderTable cache = surfaceStructureBiomeCache; - Holder cached = cache.get(key); + Holder cached = cache.get(runtimeIdentity, key); if (cached != null) { return cached; } Holder resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler); - cache.put(key, resolved); + cache.put(runtimeIdentity, key, resolved); return resolved; } @@ -436,6 +461,19 @@ final class IrisModdedBiomeSource extends BiomeSource { if (resolution == null || registry == null) { return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); } + String historicalBiomeKey = engine.getComplex().historicalPhysicalBiomeKeyAt( + resolution.blockX(), + resolution.blockY(), + resolution.blockZ()).orElse(null); + if (historicalBiomeKey != null) { + Holder historical = resolveHolder(registry, historicalBiomeKey); + if (historical == null) { + throw new IllegalStateException("Historical Iris biome '" + historicalBiomeKey + + "' is not registered at block " + resolution.blockX() + "," + + resolution.blockY() + "," + resolution.blockZ()); + } + return historical; + } String biomeKey; if (resolution.irisBiome().isCustom()) { IrisBiomeCustom customBiome = resolution.irisBiome().getCustomBiome( @@ -490,42 +528,30 @@ final class IrisModdedBiomeSource extends BiomeSource { return new BiomeResolution(irisBiome, underground, blockX, blockY, blockZ, new RNG(seed)); } - private Holder resolveRequiredStructureBiome(int quartX, int quartY, int quartZ) { + private Holder resolveRequiredStructureBiome( + int quartX, + int quartY, + int quartZ, + Climate.Sampler sampler + ) { IrisModdedChunkGenerator current = generator; if (current == null) { throw new IllegalStateException("Iris structure biome source is not bound to its generator"); } Engine engine = current.awaitStructureEngine(); - GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_state_biome"); - if (lease == null) { - throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); - } - try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - if (!isReady(engine)) { - throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + engine, quartX << 2, quartZ << 2, "modded_structure_state_biome"); + try (historyScope) { + GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_state_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure biome lookup was rejected during an engine transition"); } - Registry registry = biomeRegistry(); - if (registry == null) { - throw new IllegalStateException("Iris structure biome lookup has no biome registry"); + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome lookup has no active engine runtime"); + } + return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); } - IrisBiome irisBiome; - if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { - irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); - } else { - BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); - irisBiome = resolution == null ? null : resolution.irisBiome(); - } - if (irisBiome == null) { - throw new IllegalStateException("Iris returned no structure biome at quart " - + quartX + "," + quartY + "," + quartZ); - } - String derivativeKey = irisBiome.getStructureDerivativeKey(); - Holder resolved = resolveHolder(registry, derivativeKey); - if (resolved == null) { - throw new IllegalStateException("Iris structure biome derivative '" + derivativeKey - + "' is not registered at quart " + quartX + "," + quartY + "," + quartZ); - } - return resolved; } } @@ -576,6 +602,30 @@ final class IrisModdedBiomeSource extends BiomeSource { return current == null ? null : current.structureEngineOrNull(); } + private GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( + Engine engine, + int blockX, + int blockZ, + String operation + ) { + IrisModdedChunkGenerator current = generator; + if (current != null && current.allowsGenerationHistoryBypass(engine)) { + return null; + } + if (!(engine instanceof IrisEngine irisEngine)) { + throw new IllegalStateException("Iris " + operation + " requires an IrisEngine runtime."); + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElseThrow(() -> + new IllegalStateException("Iris " + operation + + " requires an attached generation-history runtime router.")); + try { + return router.openCoordinateScope(blockX, blockZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + " could not route block " + + blockX + "," + blockZ + " through generation history.", failure); + } + } + private GenerationSessionLease tryAcquireGenerationLease(Engine engine, String operation) { if (engine == null || engine.isClosed()) { return null; @@ -652,6 +702,7 @@ final class IrisModdedBiomeSource extends BiomeSource { } LinkedHashSet required = new LinkedHashSet<>(); LinkedHashSet visible = new LinkedHashSet<>(); + required.addAll(IrisModdedChunkGenerator.retainedBiomeKeys(engine)); for (IrisBiome irisBiome : engine.getAllBiomes()) { String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); if (derivative != null) { @@ -782,35 +833,36 @@ final class IrisModdedBiomeSource extends BiomeSource { private volatile AtomicReferenceArray table; - Holder get(long key) { + Holder get(int runtimeIdentity, long key) { AtomicReferenceArray slots = table; if (slots == null) { return null; } - int home = home(key); + int home = home(runtimeIdentity, key); for (int probe = 0; probe < PROBE_LIMIT; probe++) { Entry entry = slots.get((home + probe) & MASK); if (entry == null) { return null; } - if (entry.key() == key) { + if (entry.runtimeIdentity() == runtimeIdentity && entry.key() == key) { return entry.value(); } } return null; } - void put(long key, Holder value) { + void put(int runtimeIdentity, long key, Holder value) { AtomicReferenceArray slots = table; if (slots == null) { slots = install(); } - int home = home(key); - Entry entry = new Entry(key, value); + int home = home(runtimeIdentity, key); + Entry entry = new Entry(runtimeIdentity, key, value); for (int probe = 0; probe < PROBE_LIMIT; probe++) { int slot = (home + probe) & MASK; Entry existing = slots.get(slot); - if (existing == null || existing.key() == key) { + if (existing == null + || (existing.runtimeIdentity() == runtimeIdentity && existing.key() == key)) { slots.set(slot, entry); return; } @@ -818,6 +870,19 @@ final class IrisModdedBiomeSource extends BiomeSource { slots.set(home, entry); } + void evictRuntime(int runtimeIdentity) { + AtomicReferenceArray slots = table; + if (slots == null) { + return; + } + for (int index = 0; index < slots.length(); index++) { + Entry entry = slots.get(index); + if (entry != null && entry.runtimeIdentity() == runtimeIdentity) { + slots.compareAndSet(index, entry, null); + } + } + } + private synchronized AtomicReferenceArray install() { AtomicReferenceArray existing = table; if (existing != null) { @@ -828,29 +893,25 @@ final class IrisModdedBiomeSource extends BiomeSource { return created; } - private static int home(long key) { - long mixed = key * 0x9E3779B97F4A7C15L; + private static int home(int runtimeIdentity, long key) { + long mixed = (key ^ ((long) runtimeIdentity * 0xC2B2AE3D27D4EB4FL)) + * 0x9E3779B97F4A7C15L; return (int) ((mixed ^ (mixed >>> 32)) & MASK); } - private record Entry(long key, Holder value) { + private record Entry(int runtimeIdentity, long key, Holder value) { } } private static final class StructureStateBiomeSource extends BiomeSource { private final IrisModdedBiomeSource delegate; private final Set> possibleBiomes; - private volatile BiomeHolderTable resolvedBiomes = new BiomeHolderTable(); private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set> possibleBiomes) { this.delegate = delegate; this.possibleBiomes = possibleBiomes; } - private void clearCache() { - resolvedBiomes = new BiomeHolderTable(); - } - @Override protected MapCodec codec() { throw new UnsupportedOperationException("Structure state biome sources are not serializable"); @@ -863,15 +924,7 @@ final class IrisModdedBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { - long key = packNoiseKey(x, y, z); - BiomeHolderTable cache = resolvedBiomes; - Holder cached = cache.get(key); - if (cached != null) { - return cached; - } - Holder resolved = delegate.resolveRequiredStructureBiome(x, y, z); - cache.put(key, resolved); - return resolved; + return delegate.resolveRequiredStructureBiome(x, y, z, sampler); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index d72be750e..1e5bb9669 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -20,11 +20,17 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackValidationRegistry; +import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.history.GenerationEpoch; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.nativegen.NativeStructureStartInjector; @@ -72,6 +78,7 @@ import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.LegacyRandomSource; import net.minecraft.world.level.levelgen.RandomState; @@ -84,16 +91,24 @@ import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; import java.util.Arrays; +import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.IntBinaryOperator; +import java.util.function.IntConsumer; public final class IrisModdedChunkGenerator extends ChunkGenerator { // Vanilla-shaped fallback for an unbound generator (matches IrisDimension defaults). getMinY, @@ -124,16 +139,20 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this); private final ModdedImportedFeatureStage importedFeatures; private final AtomicBoolean announced = new AtomicBoolean(false); + private final IntConsumer generationRuntimeRetirementListener = this::retireGenerationRuntimeCaches; private volatile boolean unloading; private volatile Engine engine; private volatile String activePack; private volatile String activeDimensionKey; + private volatile Path immutablePackRoot; + private volatile ModdedGenerationMode generationMode = ModdedGenerationMode.PERSISTENT_RESTORE; private volatile long seedOverride = Long.MIN_VALUE; private volatile long lastChunkGenAt = 0L; private volatile Set configuredStructureBiomeKeys; private volatile ModdedDimensionMetadata.ConfiguredPack configuredPack; private volatile ModdedDimensionMetadata.DimensionMetadata heightMetadata; private volatile ServerLevel boundLevel; + private volatile IrisEngine retirementListenerEngine; public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) { this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource)); @@ -166,12 +185,39 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } public synchronized void repoint(String pack, String packDimensionKey, long seed) { + repoint( + pack, + packDimensionKey, + seed, + null, + ModdedGenerationMode.PERSISTENT_RESTORE + ); + } + + synchronized void repoint(String pack, String packDimensionKey, long seed, Path packRoot) { + repoint( + pack, + packDimensionKey, + seed, + packRoot, + ModdedGenerationMode.PERSISTENT_RESTORE + ); + } + + synchronized void repoint( + String pack, + String packDimensionKey, + long seed, + Path packRoot, + ModdedGenerationMode generationMode + ) { ServerLevel level = boundLevel(); if (level != null) { + this.generationMode = generationMode; repointAndBind(level, pack, packDimensionKey, seed); return; } - applyUnboundConfiguration(pack, packDimensionKey, seed); + applyUnboundConfiguration(pack, packDimensionKey, seed, packRoot, generationMode); } synchronized void repointAndBind(ServerLevel level, String pack, String packDimensionKey, long seed) { @@ -181,7 +227,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } requireGlobalStructureGeneration( level.getServer().getWorldGenSettings().options().generateStructures(), dimensionKey); - Engine replacement = ModdedWorldEngines.prepareReplacement(level, pack, packDimensionKey, seed); + Engine replacement = ModdedWorldEngines.prepareReplacement( + level, + pack, + packDimensionKey, + seed, + generationMode + ); try { ModdedWorldEngines.installReplacement(level, replacement); replacement.getPlatformHooks().applyWorldBoundary(replacement); @@ -204,6 +256,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; this.engine = replacement; + bindGenerationRuntimeRetirementListener(replacement); + this.immutablePackRoot = replacement.getData().getDataFolder().toPath().toAbsolutePath().normalize(); this.configuredStructureBiomeKeys = null; this.configuredPack = null; this.heightMetadata = engineHeights(replacement); @@ -235,10 +289,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private void clearEngineBinding() { + unbindGenerationRuntimeRetirementListener(); this.engine = null; this.boundLevel = null; this.configuredStructureBiomeKeys = null; this.configuredPack = null; + this.immutablePackRoot = null; this.engineBinding.reset(); this.announced.set(false); this.structureBiomeSource.clearCaches(); @@ -247,10 +303,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.spawnTables.resetVanillaSpawnBiomes(); } - private void applyUnboundConfiguration(String pack, String packDimensionKey, long seed) { + private void applyUnboundConfiguration( + String pack, + String packDimensionKey, + long seed, + Path packRoot, + ModdedGenerationMode generationMode + ) { + unbindGenerationRuntimeRetirementListener(); this.activePack = pack; this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; + this.immutablePackRoot = packRoot == null ? null : packRoot.toAbsolutePath().normalize(); + this.generationMode = Objects.requireNonNull(generationMode, "generationMode"); this.engine = null; this.boundLevel = null; this.configuredStructureBiomeKeys = null; @@ -265,7 +330,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } public synchronized void resetToDefault() { - repoint(defaultPack, defaultDimensionKey, Long.MIN_VALUE); + repoint( + defaultPack, + defaultDimensionKey, + Long.MIN_VALUE, + null, + ModdedGenerationMode.PERSISTENT_RESTORE + ); } public String activePack() { @@ -305,7 +376,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { BlockPos pos, int radius, boolean findUnexplored) { Engine current = engine(); - try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate"); + int chunkX = Math.floorDiv(pos.getX(), 16); + int chunkZ = Math.floorDiv(pos.getZ(), 16); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkX, chunkZ, "modded_structure_locate"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { HolderSet reachable = nativeStructures.filterReachableNativeStructures( level, holders, current); @@ -474,14 +550,22 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return existing; } try { - Engine created = ModdedWorldEngines.get(level, activePack, activeDimensionKey, seedOverride); + Engine created = ModdedWorldEngines.get( + level, + activePack, + activeDimensionKey, + seedOverride, + generationMode + ); requireCompletedShutdown(created); if (created.isClosed() || created.getComplex() == null) { throw new IllegalStateException("Iris generator '" + dimensionKey + "' created an engine without a ready biome complex"); } engine = created; + bindGenerationRuntimeRetirementListener(created); boundLevel = level; + immutablePackRoot = created.getData().getDataFolder().toPath().toAbsolutePath().normalize(); heightMetadata = engineHeights(created); configuredStructureBiomeKeys = null; structureBiomeSource.clearCaches(); @@ -642,10 +726,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Engine current = engine; if (current != null && !current.isClosed() && !current.isClosing()) { + Set retained = retainedBiomeKeys(current); + if (!retained.isEmpty()) { + configuredStructureBiomeKeys = retained; + return retained; + } try (GenerationSessionLease lease = current.acquireGenerationLease("modded_configured_biome_keys"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { Set resolved = ModdedDimensionMetadata.collectConfiguredBiomeKeys( - current.getAllBiomes(), current.getDimension().getLoadKey()); + current.getAllBiomes(), + customBiome -> current.getData().customBiomeResourceKey( + current.getDimension(), + customBiome + )); configuredStructureBiomeKeys = resolved; return resolved; } catch (GenerationSessionException e) { @@ -662,6 +755,27 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + static Set retainedBiomeKeys(Engine engine) { + if (!(engine instanceof IrisEngine irisEngine)) { + return Set.of(); + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return Set.of(); + } + LinkedHashSet keys = new LinkedHashSet<>(); + Collection epochs = router.history().manifest().epochs(); + for (GenerationEpoch epoch : epochs) { + GenerationRegistryContract contract = epoch.registryContract(); + for (GenerationRegistryContract.PhysicalResourceKey key : contract.definitions().keySet()) { + if (GenerationRegistryContractFactory.BIOME_REGISTRY.equals(key.registryKey())) { + keys.add(key.resourceKey()); + } + } + } + return Set.copyOf(keys); + } + private static ModdedDimensionMetadata.DimensionMetadata engineHeights(Engine engine) { IrisDimension dimension = engine.getDimension(); int minY = engine.getMinHeight(); @@ -706,8 +820,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (configuredPack != null) { return configuredPack; } - PackValidationRegistry.requireLoadable(activePack); - File packDirectory = ModdedWorldEngines.resolvePack(activePack, activeDimensionKey); + Path packRoot = immutablePackRoot; + File packDirectory; + if (packRoot == null) { + PackValidationRegistry.requireLoadable(activePack); + packDirectory = ModdedWorldEngines.resolvePack(activePack, activeDimensionKey); + } else { + PackValidationRegistry.requireLoadable(packRoot); + packDirectory = packRoot.toFile(); + } IrisData data = IrisData.get(packDirectory); IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey); if (dimension == null) { @@ -762,36 +883,54 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public WeightedList getMobsAt( Holder biome, StructureManager structureManager, MobCategory category, BlockPos pos) { - WeightedList explicitSpawns = biome.value().getMobSettings().getMobs(category); - WeightedList resolvedSpawns = super.getMobsAt( - biome, structureManager, category, pos); - if (resolvedSpawns != explicitSpawns) { - return resolvedSpawns; - } + Engine current = engine(); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + current, pos.getX(), pos.getZ(), "modded_mob_spawn_table"); + GenerationSessionLease lease = requireGenerationLease(current, "modded_mob_spawn_table"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + WeightedList explicitSpawns = + biome.value().getMobSettings().getMobs(category); + WeightedList resolvedSpawns = super.getMobsAt( + biome, structureManager, category, pos); + if (resolvedSpawns != explicitSpawns) { + return resolvedSpawns; + } - Registry registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); - spawnTables.initializeVanillaSpawnBiomes(registry); - Holder vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(biome.value()); - if (vanillaSpawnBiome == null) { - return explicitSpawns; - } + Registry registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); + spawnTables.initializeVanillaSpawnBiomes(registry); + Holder vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(current, biome.value()); + if (vanillaSpawnBiome == null) { + return explicitSpawns; + } - WeightedList vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(category); - if (explicitSpawns.isEmpty()) { - return vanillaSpawns; - } - if (vanillaSpawns.isEmpty()) { - return explicitSpawns; - } + WeightedList vanillaSpawns = + vanillaSpawnBiome.value().getMobSettings().getMobs(category); + if (explicitSpawns.isEmpty()) { + return vanillaSpawns; + } + if (vanillaSpawns.isEmpty()) { + return explicitSpawns; + } - return spawnTables.mergedSpawnTable(biome.value(), category, vanillaSpawns, explicitSpawns); + return spawnTables.mergedSpawnTable( + current, + biome.value(), + category, + vanillaSpawns, + explicitSpawns + ); + } } @Override public CompletableFuture createBiomes(RandomState randomState, Blender blender, StructureManager structureManager, ChunkAccess chunk) { Engine current = engine(); - try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_biomes"); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_create_biomes"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_create_biomes"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { chunk.fillBiomesFromNoise(structureBiomeSource::getVisibleNoiseBiome, randomState.sampler()); return CompletableFuture.completedFuture(chunk); @@ -806,19 +945,35 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { ModdedIrisLog.debug("Iris generating chunk {},{}", pos.x(), pos.z()); PlatformBlockState air = IrisPlatforms.get().registries().air(); + GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + generationEngine, pos.x(), pos.z(), "modded_chunk_pipeline"); - if (ModdedGenPool.parallelChunkSystem()) { - return CompletableFuture.completedFuture( - generateTerrain(chunk, generationEngine, pos, air)); + try { + if (ModdedGenPool.parallelChunkSystem()) { + try { + return CompletableFuture.completedFuture( + generateTerrain(chunk, generationEngine, pos, air, route)); + } finally { + if (route != null) { + route.close(); + } + } + } + CompletableFuture pipeline = CompletableFuture.supplyAsync( + () -> generateTerrain(chunk, generationEngine, pos, air, route), + ModdedGenPool.pool()); + return closeRouteOnCompletion(pipeline, route); + } catch (RuntimeException | Error failure) { + closeHistoryRoute(route, failure); + throw failure; } - return CompletableFuture.supplyAsync( - () -> generateTerrain(chunk, generationEngine, pos, air), - ModdedGenPool.pool()); } private ChunkAccess generateTerrain(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, - PlatformBlockState air) { - try (GenerationSessionLease lease = generationEngine.acquireGenerationLease("modded_chunk_pipeline"); + PlatformBlockState air, + GenerationHistoryRuntimeRouter.RuntimeRoute route) { + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = generationEngine.acquireGenerationLease("modded_chunk_pipeline"); IrisContext.Scope ignored = IrisContext.open(generationEngine, lease.sessionId(), null)) { if (announced.compareAndSet(false, true)) { ModdedIrisLog.info("Iris generating {} through IrisModdedChunkGenerator (dim={} first chunk {},{})", @@ -837,6 +992,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Heightmap.Types.MOTION_BLOCKING, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES)); ModdedWorldManager.enqueueGenerated(generationEngine, pos.x(), pos.z()); + if (route != null) { + route.claimGeneratedSemantics(); + } return chunk; } catch (GenerationSessionException e) { if (generationEngine.isClosing() || e.isExpectedTeardown()) { @@ -852,6 +1010,67 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + private static CompletableFuture closeRouteOnCompletion( + CompletableFuture pipeline, + GenerationHistoryRuntimeRouter.RuntimeRoute route + ) { + if (route == null) { + return pipeline; + } + CompletableFuture completion = new CompletableFuture<>(); + pipeline.whenComplete((ChunkAccess chunk, Throwable failure) -> { + boolean cancelled = isCancellationFailure(failure); + Throwable completionFailure = failure; + try { + route.close(); + } catch (Throwable closeFailure) { + completionFailure = appendFailure(completionFailure, closeFailure); + } + if (completionFailure == null) { + completion.complete(chunk); + } else if (cancelled) { + completion.cancel(false); + } else { + completion.completeExceptionally(completionFailure); + } + }); + return completion; + } + + private static void closeHistoryRoute( + GenerationHistoryRuntimeRouter.RuntimeRoute route, + Throwable failure + ) { + if (route == null) { + return; + } + try { + route.close(); + } catch (Throwable closeFailure) { + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private static Throwable appendFailure(Throwable failure, Throwable closeFailure) { + if (failure == null) { + return closeFailure; + } + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + return failure; + } + + private static boolean isCancellationFailure(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current instanceof CancellationException; + } + private void writeTerrainHeightmaps(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, int height) { int baseX = pos.getMinBlockX(); int baseZ = pos.getMinBlockZ(); @@ -923,18 +1142,47 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyCarvers(WorldGenRegion region, long seed, RandomState randomState, BiomeManager biomeManager, StructureManager structureManager, ChunkAccess chunk) { + Engine current = engine(region.getLevel()); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_apply_carvers"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_apply_carvers"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + } } @Override public void buildSurface(WorldGenRegion region, StructureManager structureManager, RandomState randomState, ChunkAccess chunk) { + Engine current = engine(region.getLevel()); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_build_surface"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_build_surface"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + } } @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { - Engine current = engine(); - importedFeatures.prepare(current); - try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration"); + Engine current = engine(level.getLevel()); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_biome_decoration"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + if (!allowsRoutedDiscreteGeneration( + current, + route, + chunkPos, + chunk, + ChunkStatus.FEATURES + )) { + return; + } + importedFeatures.prepare(current); nativeStructures.placeVanillaStructures(level, chunk, structureManager); // Vanilla's placed-feature pass, on THIS thread and never on ModdedGenPool: the FEATURES chunk // step writes into the eight neighbouring chunks and is not parallel-safe. Inert unless the @@ -946,8 +1194,21 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { Engine current = engine(levelKey); - try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_structures"); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_create_structures"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_create_structures"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + if (!allowsRoutedDiscreteGeneration( + current, + route, + chunkPos, + chunk, + ChunkStatus.STRUCTURE_STARTS + )) { + return; + } Map previousStarts = new HashMap<>(chunk.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, chunk, templateManager, levelKey); Map configuredStarts = NativeStructureStartInjector.inject( @@ -970,7 +1231,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void createReferences(WorldGenLevel level, StructureManager structureManager, ChunkAccess chunk) { Engine current = engine(level.getLevel()); - try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); + ChunkPos chunkPos = chunk.getPos(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, chunkPos.x(), chunkPos.z(), "modded_create_references"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { NativeStructureReferenceRepair.createReferences( current, level, structureManager, chunk); @@ -983,15 +1248,32 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void spawnOriginalMobs(WorldGenRegion region) { - Registry registry = region.registryAccess().lookupOrThrow(Registries.BIOME); - spawnTables.initializeVanillaSpawnBiomes(registry); ChunkPos center = region.getCenter(); - Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); - Holder vanillaBiome = spawnTables.vanillaSpawnBiome(visibleBiome.value()); - WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); - random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); - NaturalSpawner.spawnMobsForChunkGeneration( - region, vanillaBiome == null ? visibleBiome : vanillaBiome, center, random); + Engine current = engine(region.getLevel()); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = openHistoryRoute( + current, center.x(), center.z(), "modded_spawn_original_mobs"); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope runtimeScope = openHistoryRuntimeScope(route); + GenerationSessionLease lease = requireGenerationLease(current, "modded_spawn_original_mobs"); + IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { + ChunkAccess centerChunk = region.getChunk(center.x(), center.z()); + if (!allowsRoutedDiscreteGeneration( + current, + route, + center, + centerChunk, + ChunkStatus.SPAWN + )) { + return; + } + Registry registry = region.registryAccess().lookupOrThrow(Registries.BIOME); + spawnTables.initializeVanillaSpawnBiomes(registry); + Holder visibleBiome = region.getBiome(center.getWorldPosition().atY(region.getMaxY())); + Holder vanillaBiome = spawnTables.vanillaSpawnBiome(current, visibleBiome.value()); + WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(RandomSupport.generateUniqueSeed())); + random.setDecorationSeed(region.getSeed(), center.getMinBlockX(), center.getMinBlockZ()); + NaturalSpawner.spawnMobsForChunkGeneration( + region, vanillaBiome == null ? visibleBiome : vanillaBiome, center, random); + } } @Override @@ -1027,7 +1309,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { Engine current = requireDataQueryEngine("base height"); boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); - try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height"); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + current, x, z, "modded_base_height"); + GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { return heightAccessor.getMinY() + current.getHeight(x, z, ignoreFluid) + 1; } catch (GenerationSessionException e) { @@ -1040,7 +1324,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { int minY = heightAccessor.getMinY(); BlockState[] states = new BlockState[heightAccessor.getHeight()]; Engine current = requireDataQueryEngine("base column"); - try (GenerationSessionLease lease = current.acquireGenerationLease("modded_base_column"); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( + current, x, z, "modded_base_column"); + GenerationSessionLease lease = current.acquireGenerationLease("modded_base_column"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { BlockState airState = Blocks.AIR.defaultBlockState(); int surface = current.getHeight(x, z, true); @@ -1058,6 +1344,115 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + private boolean allowsRoutedDiscreteGeneration( + Engine current, + GenerationHistoryRuntimeRouter.RuntimeRoute route, + ChunkPos chunkPos, + ChunkAccess chunk, + ChunkStatus stage + ) { + if (chunk.getPersistedStatus().isOrAfter(stage)) { + return false; + } + if (route == null) { + return allowsGenerationHistoryBypass(current); + } + return current.getComplex().allowsNewGenerationChunk(chunkPos.x(), chunkPos.z()); + } + + private void bindGenerationRuntimeRetirementListener(Engine current) { + if (!(current instanceof IrisEngine irisEngine)) { + unbindGenerationRuntimeRetirementListener(); + return; + } + IrisEngine previous = retirementListenerEngine; + if (previous == irisEngine) { + return; + } + if (previous != null) { + previous.removeGenerationRuntimeRetirementListener(generationRuntimeRetirementListener); + } + irisEngine.addGenerationRuntimeRetirementListener(generationRuntimeRetirementListener); + retirementListenerEngine = irisEngine; + } + + private void unbindGenerationRuntimeRetirementListener() { + IrisEngine previous = retirementListenerEngine; + if (previous == null) { + return; + } + retirementListenerEngine = null; + previous.removeGenerationRuntimeRetirementListener(generationRuntimeRetirementListener); + } + + private void retireGenerationRuntimeCaches(int runtimeIdentity) { + structureBiomeSource.evictRuntime(runtimeIdentity); + importedFeatures.evictRuntime(runtimeIdentity); + spawnTables.evictRuntime(runtimeIdentity); + } + + private GenerationHistoryRuntimeRouter.RuntimeRoute openHistoryRoute( + Engine current, + int chunkX, + int chunkZ, + String operation + ) { + if (allowsGenerationHistoryBypass(current)) { + return null; + } + GenerationHistoryRuntimeRouter router = requireHistoryRouter(current, operation); + try { + return router.openRoute(chunkX, chunkZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + " could not route chunk " + + chunkX + "," + chunkZ + " through generation history.", failure); + } + } + + private GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( + Engine current, + int blockX, + int blockZ, + String operation + ) { + if (allowsGenerationHistoryBypass(current)) { + return null; + } + GenerationHistoryRuntimeRouter router = requireHistoryRouter(current, operation); + try { + return router.openCoordinateScope(blockX, blockZ); + } catch (IOException failure) { + throw new IllegalStateException("Iris " + operation + " could not route block " + + blockX + "," + blockZ + " through generation history.", failure); + } + } + + private GenerationHistoryRuntimeRouter requireHistoryRouter(Engine current, String operation) { + if (!(current instanceof IrisEngine irisEngine)) { + throw new IllegalStateException("Iris " + operation + " requires an IrisEngine runtime."); + } + return irisEngine.getGenerationHistoryRuntimeRouter().orElseThrow(() -> + new IllegalStateException("Iris " + operation + + " requires an attached generation-history runtime router.")); + } + + boolean allowsGenerationHistoryBypass(Engine current) { + if (generationMode != ModdedGenerationMode.TRANSIENT_STUDIO) { + return false; + } + if (current instanceof IrisEngine irisEngine && irisEngine.isStudio()) { + return true; + } + throw new IllegalStateException("Only an explicitly transient Iris Studio engine may bypass " + + "generation-history routing."); + } + + private static GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope openHistoryRuntimeScope( + GenerationHistoryRuntimeRouter.RuntimeRoute route + ) { + return route == null ? null : route.openRuntimeScope(); + } + GenerationSessionLease requireGenerationLease(Engine current, String operation) { try { return current.acquireGenerationLease(operation); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java index 444979642..ef2e55f8e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java @@ -142,6 +142,19 @@ public final class MainWorldService { } } + static Path configuredWorldRootIfPresent() throws IOException { + Path instanceRoot = Path.of("").toAbsolutePath().normalize(); + Path properties = instanceRoot.resolve(PROPERTIES_NAME); + if (!Files.isRegularFile(properties)) { + return null; + } + try { + return resolveWorldRoot(instanceRoot, properties); + } catch (MissingWorldRootException missing) { + return null; + } + } + /** * net.minecraft.server.Main reads server.properties as Paths.get("server.properties"), so the authoritative * instance root is the JVM working directory - not configDir().getParent(), which points somewhere else diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java index ffe05cfb2..53d3ed240 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java @@ -97,11 +97,7 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { return key; } Engine engine = context.getEngine(); - String prefix = engine.getDimension().getLoadKey() + ":"; - if (!key.regionMatches(true, 0, prefix, 0, prefix.length())) { - return key; - } - return ModdedWorldgenIds.biomeRef(engine, key.substring(prefix.length())); + return engine.getData().physicalBiomeResourceKey(engine.getDimension(), key); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index b57943039..124ed0035 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -19,7 +19,9 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.object.IrisDimension; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; @@ -47,9 +49,12 @@ import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.ServerLevelData; import net.minecraft.world.level.storage.WorldData; -import java.io.File; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -120,14 +125,76 @@ public final class ModdedDimensionManager { return generator.commandEngine(); } - public static Handle create(MinecraftServer server, String dimensionId, String pack, String packDimensionKey, long seed) { - ModdedStartup.requirePackForWorldCreation(pack); + public static Handle createTransientStudio( + MinecraftServer server, + String dimensionId, + String pack, + String packDimensionKey, + long seed + ) { + return create( + server, + dimensionId, + pack, + packDimensionKey, + seed, + ModdedGenerationMode.TRANSIENT_STUDIO + ); + } + + static Handle restorePersistent( + MinecraftServer server, + String dimensionId, + String pack, + String packDimensionKey, + long seed + ) { + return create( + server, + dimensionId, + pack, + packDimensionKey, + seed, + ModdedGenerationMode.PERSISTENT_RESTORE + ); + } + + private static Handle create( + MinecraftServer server, + String dimensionId, + String pack, + String packDimensionKey, + long seed, + ModdedGenerationMode generationMode + ) { ModdedServerAccess serverAccess = requireAccess(); synchronized (LOCK) { ResourceKey key = levelKey(dimensionId); + RuntimePack runtimePack = switch (generationMode) { + case TRANSIENT_STUDIO -> transientStudioPack(pack, packDimensionKey); + case PERSISTENT_CREATE -> persistentRuntimePack( + ModdedGenerationHistoryStorage.createOrStage( + server, + key, + pack, + packDimensionKey, + seed + ), + generationMode + ); + case PERSISTENT_RESTORE -> persistentRuntimePack( + ModdedGenerationHistoryStorage.restoreOrAdopt( + server, + key, + pack, + packDimensionKey, + seed + ), + generationMode + ); + }; Handle existing = HANDLES.get(dimensionId); if (existing != null && serverAccess.hasLevel(server, key)) { - existing.generator().repointAndBind(existing.level(), pack, packDimensionKey, seed); Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator()); HANDLES.put(dimensionId, refreshed); return refreshed; @@ -138,14 +205,22 @@ public final class ModdedDimensionManager { throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded"); } ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId); - generator.repointAndBind(present, pack, packDimensionKey, seed); Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, present, generator); HANDLES.put(dimensionId, handle); return handle; } try { - Handle handle = inject(server, serverAccess, dimensionId, key, pack, packDimensionKey, seed); + Handle handle = inject( + server, + serverAccess, + dimensionId, + key, + pack, + packDimensionKey, + seed, + runtimePack + ); HANDLES.put(dimensionId, handle); ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed); return handle; @@ -157,28 +232,109 @@ public final class ModdedDimensionManager { } public static Handle createPersistent(MinecraftServer server, String dimensionId, String pack, String packDimensionKey, long seed) { - ModdedDimensionRegistryStore.PersistentDimension previous = - ModdedDimensionRegistryStore.get(server, dimensionId); - ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, pack, packDimensionKey, seed)); - try { - return create(server, dimensionId, pack, packDimensionKey, seed); - } catch (Throwable e) { + synchronized (LOCK) { + ModdedDimensionRegistryStore.PersistentDimension previous = + ModdedDimensionRegistryStore.get(server, dimensionId); + if (previous != null) { + throw new IllegalStateException("Iris dimension '" + dimensionId + + "' already exists. Use the Iris world update command to stage a pack update."); + } + ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension( + dimensionId, + pack, + packDimensionKey, + seed + )); try { - if (previous == null) { + return create( + server, + dimensionId, + pack, + packDimensionKey, + seed, + ModdedGenerationMode.PERSISTENT_CREATE + ); + } catch (Throwable e) { + try { ModdedDimensionRegistryStore.remove(server, dimensionId); - } else { - ModdedDimensionRegistryStore.put(server, previous); + } catch (Throwable rollbackFailure) { + e.addSuppressed(rollbackFailure); } - } catch (Throwable rollbackFailure) { - e.addSuppressed(rollbackFailure); + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e); } - if (e instanceof RuntimeException runtimeException) { - throw runtimeException; + } + } + + public static UpdateResult stagePersistentUpdate( + MinecraftServer server, + String dimensionId, + String pack, + String packDimensionKey + ) { + synchronized (LOCK) { + ModdedDimensionRegistryStore.PersistentDimension previous = + ModdedDimensionRegistryStore.get(server, dimensionId); + if (previous == null) { + throw new IllegalStateException("Iris dimension '" + dimensionId + + "' is not registered. Create it before staging an update."); } - if (e instanceof Error fatalError) { - throw fatalError; + ModdedDimensionRegistryStore.PersistentDimension updated = + new ModdedDimensionRegistryStore.PersistentDimension( + dimensionId, + pack, + packDimensionKey, + previous.seed() + ); + ModdedDimensionRegistryStore.put(server, updated); + try { + ModdedGenerationHistoryStorage.ActivePack active = + ModdedGenerationHistoryStorage.createOrStage( + server, + levelKey(dimensionId), + pack, + packDimensionKey, + previous.seed() + ); + Optional pending = active.history().pendingActivation(); + long activeActivationId = active.history().activeActivation().activationId(); + OptionalLong pendingActivationId = pending.isPresent() + ? OptionalLong.of(pending.orElseThrow().activationId()) + : OptionalLong.empty(); + UpdateResult result = new UpdateResult( + pending.isPresent(), + pending.isPresent(), + activeActivationId, + pendingActivationId + ); + if (result.restartRequired()) { + ModdedIrisLog.warn("Iris dimension '{}' staged generation activation {} from pack={}:{}. Restart the server to activate it.", + dimensionId, pendingActivationId.orElseThrow(), pack, packDimensionKey); + } else { + ModdedIrisLog.info("Iris dimension '{}' already uses pack={}:{}; no generation update was staged.", + dimensionId, pack, packDimensionKey); + } + return result; + } catch (Throwable failure) { + try { + ModdedDimensionRegistryStore.put(server, previous); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris generation update staging failed for " + + dimensionId + ".", failure); } - throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e); } } @@ -524,48 +680,47 @@ public final class ModdedDimensionManager { server.execute(task); } - private static Holder resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) { + private static Holder resolveDimensionType( + RegistryAccess registryAccess, + String pack, + String packDimensionKey, + RuntimePack runtimePack + ) { Registry registry = registryAccess.lookupOrThrow(Registries.DIMENSION_TYPE); - IrisDimension dimension = loadPackDimension(pack, packDimensionKey); - String typeRef = ModdedWorldgenIds.dimensionTypeRef(pack, packDimensionKey); + String typeRef = runtimePack.dimensionTypeKey(); ResourceKey typeKey = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse(typeRef)); - ModdedRuntimeRegistry.ensureCustomBiomes(registryAccess, dimension, pack); - ModdedRuntimeRegistry.ensureDimensionType(registry, typeKey, typeRef); return ModdedForcedDatapack.requireRegisteredDimensionType( typeRef, registry.get(typeKey), pack, packDimensionKey); } - private static IrisDimension loadPackDimension(String pack, String packDimensionKey) { - try { - File packFolder = ModdedWorldEngines.packFolder(pack); - if (!packFolder.isDirectory()) { - throw new IllegalStateException("Iris pack directory is missing: " + packFolder.getAbsolutePath()); - } - IrisDimension dimension = IrisData.get(packFolder).getDimensionLoader().load(packDimensionKey); - if (dimension == null) { - throw new IllegalStateException("Iris pack '" + pack - + "' does not contain dimension '" + packDimensionKey + "'"); - } - return dimension; - } catch (Throwable e) { - ModdedIrisLog.error("Iris could not load pack '{}' dimension '{}' for dimension type resolution", - pack, packDimensionKey, e); - if (e instanceof Error fatalError) { - throw fatalError; - } - throw new IllegalStateException("Iris could not load pack '" + pack + "' dimension '" - + packDimensionKey + "' for dimension type resolution", e); - } - } - - private static Handle inject(MinecraftServer server, ModdedServerAccess serverAccess, String dimensionId, ResourceKey key, String pack, String packDimensionKey, long seed) { + private static Handle inject( + MinecraftServer server, + ModdedServerAccess serverAccess, + String dimensionId, + ResourceKey key, + String pack, + String packDimensionKey, + long seed, + RuntimePack runtimePack + ) { RegistryAccess registryAccess = server.registryAccess(); - Holder dimensionType = resolveDimensionType(registryAccess, pack, packDimensionKey); + Holder dimensionType = resolveDimensionType( + registryAccess, + pack, + packDimensionKey, + runtimePack + ); Holder plains = registryAccess.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS); FixedBiomeSource biomeSource = new FixedBiomeSource(plains); String generatorRef = pack.equals(packDimensionKey) ? pack : pack + ":" + packDimensionKey; IrisModdedChunkGenerator generator = new IrisModdedChunkGenerator(biomeSource, generatorRef); - generator.repoint(pack, packDimensionKey, seed); + generator.repoint( + pack, + runtimePack.dimensionKey(), + seed, + runtimePack.packRoot(), + runtimePack.generationMode() + ); LevelStem stem = new LevelStem(dimensionType, generator); WorldData worldData = server.getWorldData(); @@ -681,6 +836,77 @@ public final class ModdedDimensionManager { return bound; } + private static RuntimePack persistentRuntimePack( + ModdedGenerationHistoryStorage.ActivePack active, + ModdedGenerationMode generationMode + ) { + return new RuntimePack( + active.packRoot(), + active.dimensionKey(), + active.dimensionContract().dimensionTypeKey(), + generationMode + ); + } + + private static RuntimePack transientStudioPack(String pack, String dimensionKey) { + ModdedStartup.requirePackForWorldCreation(pack); + Path packRoot = ModdedWorldEngines.resolvePack(pack, dimensionKey) + .toPath() + .toAbsolutePath() + .normalize(); + PackValidationRegistry.requireLoadable(packRoot); + IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '" + + dimensionKey + "'."); + } + } finally { + data.close(); + } + return new RuntimePack( + packRoot, + dimensionKey, + ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey), + ModdedGenerationMode.TRANSIENT_STUDIO + ); + } + + private record RuntimePack( + Path packRoot, + String dimensionKey, + String dimensionTypeKey, + ModdedGenerationMode generationMode + ) { + private RuntimePack { + packRoot = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); + dimensionKey = Objects.requireNonNull(dimensionKey, "dimensionKey"); + dimensionTypeKey = Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey"); + generationMode = Objects.requireNonNull(generationMode, "generationMode"); + } + } + public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, ServerLevel level, IrisModdedChunkGenerator generator) { } + + public record UpdateResult( + boolean pending, + boolean restartRequired, + long activeActivationId, + OptionalLong pendingActivationId + ) { + public UpdateResult { + pendingActivationId = Objects.requireNonNull( + pendingActivationId, + "pendingActivationId" + ); + if (pending != pendingActivationId.isPresent()) { + throw new IllegalArgumentException("Pending update status must match the pending activation ID."); + } + if (restartRequired != pending) { + throw new IllegalArgumentException("A pending generation activation requires a restart."); + } + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java index 3f6090d80..a9033f355 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java @@ -28,6 +28,7 @@ import net.minecraft.resources.Identifier; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; +import java.util.function.Function; final class ModdedDimensionMetadata { private ModdedDimensionMetadata() { @@ -45,7 +46,10 @@ final class ModdedDimensionMetadata { static Set collectConfiguredBiomeKeys(IrisDimension dimension, IrisData data) { LinkedHashSet keys = new LinkedHashSet<>( - collectConfiguredBiomeKeys(dimension.getReachableBiomes(() -> data), dimension.getLoadKey())); + collectConfiguredBiomeKeys( + dimension.getReachableBiomes(() -> data), + customBiome -> data.customBiomeResourceKey(dimension, customBiome) + )); for (IrisRegion region : dimension.getAllRegions(() -> data)) { if (region == null) { continue; @@ -60,9 +64,11 @@ final class ModdedDimensionMetadata { return Set.copyOf(keys); } - static Set collectConfiguredBiomeKeys(Iterable biomes, String dimensionLoadKey) { + static Set collectConfiguredBiomeKeys( + Iterable biomes, + Function customBiomeKey + ) { LinkedHashSet keys = new LinkedHashSet<>(); - String namespace = dimensionLoadKey.toLowerCase(Locale.ROOT); for (IrisBiome irisBiome : biomes) { if (irisBiome == null) { continue; @@ -75,7 +81,7 @@ final class ModdedDimensionMetadata { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - keys.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + keys.add(customBiomeKey.apply(customBiome).toLowerCase(Locale.ROOT)); } } return Set.copyOf(keys); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java index f4441600e..8a846ef49 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java @@ -53,6 +53,10 @@ public final class ModdedDimensionRegistryStore { return contents(file).dimensions(); } + static List loadWorldRoot(Path worldRoot) { + return load(storeFile(worldRoot)); + } + /** * Boot-safe load: a corrupt registry must not abort server start. The broken file is renamed aside so the * next write starts clean, and every id we can still recognise in the raw text is reported as lost. @@ -232,7 +236,11 @@ public final class ModdedDimensionRegistryStore { } private static Path storeFile(MinecraftServer server) { - return server.getWorldPath(LevelResource.ROOT).resolve("iris").resolve(FILE_NAME); + return storeFile(server.getWorldPath(LevelResource.ROOT)); + } + + private static Path storeFile(Path worldRoot) { + return worldRoot.resolve("iris").resolve(FILE_NAME); } public record PersistentDimension(String id, String pack, String dimension, long seed) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java index bb18182fe..9713e0e05 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.IrisEngineMantle; +import art.arcane.iris.engine.history.GenerationHistoryPaths; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.Level; @@ -38,7 +39,8 @@ public final class ModdedDimensionStorage { "region", "entities", "poi", - IrisEngineMantle.STORAGE_FOLDER_NAME + IrisEngineMantle.STORAGE_FOLDER_NAME, + GenerationHistoryPaths.IRIS_DIRECTORY_NAME ); private ModdedDimensionStorage() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index 93f845ee2..46482b19c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -18,7 +18,7 @@ package art.arcane.iris.modded; -import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.core.IrisDatapackCompiler; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeUiMessages; @@ -27,11 +27,25 @@ import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.engine.history.GenerationEpoch; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationHistoryPaths; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContract.PhysicalResourceKey; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisCustomBiomeAliasResolver; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import net.minecraft.network.chat.Component; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.packs.PackLocationInfo; import net.minecraft.server.packs.PackSelectionConfig; import net.minecraft.server.packs.PackType; @@ -39,16 +53,16 @@ import net.minecraft.server.packs.PathPackResources; import net.minecraft.server.packs.repository.Pack; import net.minecraft.server.packs.repository.PackSource; import net.minecraft.server.packs.repository.RepositorySource; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.storage.LevelResource; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -56,9 +70,12 @@ import java.util.Arrays; import java.util.Comparator; import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -68,14 +85,10 @@ public final class ModdedForcedDatapack { private static final String PACK_ID = "iris_worldgen"; private static final String PACK_FOLDER = "iris"; private static final String HASH_FILE_NAME = "packs.hash"; - // Bump this whenever the emitted datapack content changes for reasons the pack-directory hash cannot see. - // v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has - // to regenerate once. - private static final String HASH_SALT = "iris-forced-datapack-v2"; + private static final String HASH_SALT = "iris-forced-datapack-v3-content-addressed-biomes"; private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L; private static final Object LOCK = new Object(); private static final AtomicBoolean LOADED = new AtomicBoolean(false); - private static final AtomicBoolean STALE_SERVE_LOGGED = new AtomicBoolean(false); private static volatile PublishedState published; private static volatile HashMemo packsHashMemo; @@ -120,11 +133,9 @@ public final class ModdedForcedDatapack { reason = "no published pack"; } else if (!hash.isEmpty() && !state.packsHash().equals(hash)) { reason = "stale cache (hash changed)"; + } else if (hash.isEmpty()) { + reason = "current registry inputs could not be verified"; } else { - if (hash.isEmpty() && STALE_SERVE_LOGGED.compareAndSet(false, true)) { - ModdedIrisLog.warn("Iris cannot hash the installed packs; serving the last generated forced datapack from {} unverified", - state.directory()); - } try { return requireReadablePack(state.directory()); } catch (RuntimeException unreadable) { @@ -164,17 +175,7 @@ public final class ModdedForcedDatapack { } private static Pack buildPack() { - try { - return requireReadablePack(regenerate()); - } catch (RuntimeException | Error generationFailure) { - Path lastKnownGood = packDirectory(); - if (Files.isRegularFile(lastKnownGood.resolve("pack.mcmeta"))) { - ModdedIrisLog.error("Iris kept the last known-good generated datapack after regeneration failed", - generationFailure); - return requireReadablePack(lastKnownGood); - } - throw generationFailure; - } + return requireReadablePack(regenerate()); } private static Pack requireReadablePack(Path directory) { @@ -264,7 +265,6 @@ public final class ModdedForcedDatapack { // Publish the hash this run was built from as the memo too: a memo captured before staging would // otherwise mismatch what was just published and send the next serve straight back into buildPack. packsHashMemo = new HashMemo(packsHash, System.nanoTime()); - STALE_SERVE_LOGGED.set(false); return packDirectory(); } catch (IOException | RuntimeException | Error failure) { try { @@ -336,10 +336,6 @@ public final class ModdedForcedDatapack { return hash; } - /** - * Content hash over the installed packs: relative path, size and mtime of every regular file, plus the - * pack format and loader the generated datapack is shaped for. - */ private static String packsHash() throws IOException { MessageDigest digest; try { @@ -349,39 +345,13 @@ public final class ModdedForcedDatapack { } digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName() + '|' + DataVersion.getLatest().getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8)); - Path root = packsRoot(); - if (Files.isDirectory(root)) { - List entries = new ArrayList<>(); - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { - return !directory.equals(root) - && PackDirectoryResolver.isHiddenName(directory.getFileName().toString()) - ? FileVisitResult.SKIP_SUBTREE - : FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) { - if (attributes.isRegularFile()) { - entries.add(root.relativize(file).toString().replace('\\', '/') - + '|' + attributes.size() + '|' + attributes.lastModifiedTime().toMillis()); - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException failure) { - entries.add(root.relativize(file).toString().replace('\\', '/') + "|unreadable"); - return FileVisitResult.CONTINUE; - } - }); - entries.sort(Comparator.naturalOrder()); - for (String entry : entries) { - digest.update(entry.getBytes(StandardCharsets.UTF_8)); - digest.update((byte) '\n'); - } - } + String inputFingerprint = IrisDatapackCompiler.computeInputFingerprint( + compilationPackRoots(), + List.of(), + DataVersion.getLatest().get(), + false + ); + digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } @@ -391,10 +361,11 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); - File root = packsRoot().toFile(); - List packs = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(root); - for (File pack : packs) { - if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) { + Path worldRoot = worldRootOrNull(); + Set legacySelections = unadoptedSelections(worldRoot); + for (File pack : compilationPackRoots(worldRoot)) { + PackStageContext context = packStageContext(pack.toPath(), legacySelections); + if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) { packCount++; } } @@ -415,19 +386,28 @@ public final class ModdedForcedDatapack { } } - private static boolean stagePack(File sourcePack, IDataFixer fixer, Path stagingDirectory, + private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory, Map> seenBiomes, KList presetIds) throws IOException { + File sourcePack = context.packRoot().toFile(); PackValidationResult validation; try { validation = PackValidator.validateForDatapackBootstrap(sourcePack); } catch (Throwable validationFailure) { + if (context.required()) { + throw requiredPackFailure(context, "validation", validationFailure); + } ModdedIrisLog.error("Iris excluded pack '{}' from Create World because validation failed", sourcePack.getName(), validationFailure); rethrowIfUnrecoverable(validationFailure); return false; } if (!validation.isLoadable()) { + if (context.required()) { + throw new IOException("Retained Iris generation pack at " + context.packRoot() + + " has " + validation.getBlockingErrors().size() + + " blocking validation error(s): " + validation.getBlockingErrors().getFirst()); + } ModdedIrisLog.error("Iris excluded pack '{}' from Create World: {} blocking validation error(s); first error: {}", sourcePack.getName(), validation.getBlockingErrors().size(), validation.getBlockingErrors().getFirst()); @@ -442,8 +422,11 @@ public final class ModdedForcedDatapack { packFolders.add(packStagingDirectory.toFile()); boolean installed; try { - installed = installPack(sourcePack, fixer, packFolders, packBiomes, packPresetIds); + installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds); } catch (Throwable installationFailure) { + if (context.required()) { + throw requiredPackFailure(context, "datapack serialization", installationFailure); + } ModdedIrisLog.error("Iris excluded pack '{}' from Create World because datapack serialization failed", sourcePack.getName(), installationFailure); rethrowIfUnrecoverable(installationFailure); @@ -482,7 +465,7 @@ public final class ModdedForcedDatapack { } } - private static void mergeDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException { + static void mergeDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException { List entries = new ArrayList<>(); try (Stream walk = Files.walk(sourceDirectory)) { walk.sorted(Comparator.comparingInt(Path::getNameCount)).forEach(entries::add); @@ -491,9 +474,14 @@ public final class ModdedForcedDatapack { Path relative = sourceDirectory.relativize(source); Path destination = destinationDirectory.resolve(relative); if (Files.isDirectory(source)) { + if (Files.exists(destination) && !Files.isDirectory(destination)) { + throw new IOException("Conflicting Iris datapack output at " + destination + "."); + } Files.createDirectories(destination); } else if (!Files.exists(destination)) { Files.copy(source, destination); + } else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) { + throw new IOException("Conflicting Iris datapack output at " + destination + "."); } } } @@ -506,10 +494,11 @@ public final class ModdedForcedDatapack { } } - private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, + private static boolean installPack(PackStageContext context, IDataFixer fixer, KList folders, Map> seenBiomes, KList presetIds) throws IOException { - String packName = packFolder.getName(); + File packFolder = context.packRoot().toFile(); + String packName = context.packName(); File dimensionsDirectory = new File(packFolder, "dimensions"); if (!dimensionsDirectory.isDirectory()) { return false; @@ -523,25 +512,55 @@ public final class ModdedForcedDatapack { return false; } Arrays.sort(dimensionFiles, Comparator.comparing(File::getName)); - IrisData data = IrisData.get(packFolder); - for (File dimensionFile : dimensionFiles) { - String dimensionKey = dimensionFile.getName().substring(0, dimensionFile.getName().length() - ".json".length()); - IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); - if (dimension == null) { - throw new IllegalStateException("Iris pack '" + packName + "' dimension '" - + dimensionKey + "' did not load while building the forced datapack"); + IrisData data = IrisData.openDatapackCompiler(packFolder); + try { + if (context.retainedContract() != null) { + data.bindGenerationRegistryContract(context.retainedContract()); } - String biomePathPrefix = ModdedWorldgenIds.biomePathPrefix(packName, dimensionKey); - dimension.installBiomes(fixer, () -> data, folders, "irisworldgen", biomePathPrefix, - biomesForNamespace(seenBiomes, biomePathPrefix)); - dimension.installBiomes(fixer, () -> data, folders, - biomesForNamespace(seenBiomes, dimension.getLoadKey())); - writeDimensionType(folders, fixer, dimension, packName, dimensionKey); - String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); - writeWorldPreset(folders, packName, dimensionKey, presetRef); - presetIds.add(presetRef); + int installedDimensions = 0; + for (File dimensionFile : dimensionFiles) { + String dimensionKey = dimensionFile.getName().substring( + 0, + dimensionFile.getName().length() - ".json".length() + ); + if (context.dimensionKey() != null && !context.dimensionKey().equals(dimensionKey)) { + continue; + } + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Iris pack '" + packName + "' dimension '" + + dimensionKey + "' did not load while building the forced datapack"); + } + dimension.installBiomes( + fixer, + () -> data, + folders, + biomesForNamespace(seenBiomes, "iris"), + context + ); + writeDimensionType( + folders, + fixer, + dimension, + context.dimensionTypeRef(dimensionKey), + context.emitLegacyDimensionTypeAlias(), + context + ); + if (context.emitPresets()) { + String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); + writeWorldPreset(folders, packName, dimensionKey, presetRef); + presetIds.add(presetRef); + } + installedDimensions++; + } + if (context.required() && installedDimensions == 0) { + throw new IOException("Retained Iris generation pack at " + context.packRoot() + + " does not contain dimension '" + context.dimensionKey() + "'."); + } + return installedDimensions > 0; + } finally { + data.close(); } - return true; } static KSet biomesForNamespace(Map> biomes, String namespace) { @@ -631,15 +650,41 @@ public final class ModdedForcedDatapack { static void writeDimensionType(KList folders, IDataFixer fixer, IrisDimension dimension, String pack, String packDimensionKey) throws IOException { + writeDimensionType( + folders, + fixer, + dimension, + ModdedWorldgenIds.dimensionTypeRef(pack, packDimensionKey), + true, + IrisCustomBiomeAliasResolver.none() + ); + } + + private static void writeDimensionType( + KList folders, + IDataFixer fixer, + IrisDimension dimension, + String typeRef, + boolean emitLegacyAlias, + IrisCustomBiomeAliasResolver sourceResolver + ) throws IOException { IrisDimensionType type = dimension.getDimensionType(); - String json = type.toJson(fixer); - String typeRef = ModdedWorldgenIds.dimensionTypeRef(pack, packDimensionKey); + String json = Objects.requireNonNull(sourceResolver, "sourceResolver").generatedSource( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + typeRef, + type.toJson(fixer), + fixer + ); String typePath = typeRef.substring(typeRef.indexOf(':') + 1); + String typeNamespace = typeRef.substring(0, typeRef.indexOf(':')); for (File datapackRoot : folders) { - Path output = datapackRoot.toPath().resolve("data").resolve("irisworldgen") + Path output = datapackRoot.toPath().resolve("data").resolve(typeNamespace) .resolve("dimension_type").resolve(typePath + ".json"); Files.createDirectories(output.getParent()); Files.writeString(output, json, StandardCharsets.UTF_8); + if (!emitLegacyAlias) { + continue; + } // Load-bearing, not dead: worlds created before the scoped pack path reference // irisworldgen: in their level.dat, and ModdedWorldEngines accepts that legacy // key when validating the runtime dimension contract. Removing this emission unloads those worlds. @@ -731,10 +776,239 @@ public final class ModdedForcedDatapack { } } + private static List compilationPackRoots() throws IOException { + return compilationPackRoots(worldRootOrNull()); + } + + private static List compilationPackRoots(Path worldRoot) throws IOException { + Path dataDirectory = packsRoot().getParent(); + Path historyRoot = worldRoot == null + ? dataDirectory.resolve(".iris-world-unavailable") + : worldRoot; + return IrisDatapackCompiler.collectPackRoots(dataDirectory, historyRoot); + } + + private static Path worldRootOrNull() throws IOException { + MinecraftServer server = ModdedEngineBootstrap.currentServer(); + if (server != null) { + return server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + } + if (ModdedEngineBootstrap.loader().clientEnvironment()) { + return null; + } + return MainWorldService.configuredWorldRootIfPresent(); + } + + private static Set unadoptedSelections(Path worldRoot) throws IOException { + if (worldRoot == null) { + return Set.of(); + } + Set selections = new LinkedHashSet<>(); + for (ModdedDimensionRegistryStore.PersistentDimension dimension + : ModdedDimensionRegistryStore.loadWorldRoot(worldRoot)) { + Identifier identifier = Identifier.tryParse(dimension.id()); + if (identifier == null) { + throw new IOException("Invalid persistent Iris dimension ID '" + dimension.id() + "'."); + } + ResourceKey key = ResourceKey.create(Registries.DIMENSION, identifier); + Path dimensionRoot = DimensionType.getStorageFolder(key, worldRoot).toAbsolutePath().normalize(); + Path generationRoot = GenerationHistoryPaths.forDimension(dimensionRoot).generationRoot(); + if (!Files.exists(generationRoot)) { + selections.add(new PackSelection(dimension.pack(), dimension.dimension())); + } + } + return Set.copyOf(selections); + } + + private static PackStageContext packStageContext( + Path packRoot, + Set unadoptedSelections + ) throws IOException { + Path normalizedRoot = packRoot.toAbsolutePath().normalize(); + RetainedPack retained = retainedPack(normalizedRoot); + if (retained != null) { + ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds + .scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey()) + .orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: " + + retained.epoch().dimensionContract().dimensionTypeKey())); + if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) { + throw new IOException("Retained Iris dimension type identity does not match its epoch contract."); + } + return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch()); + } + + String packName = normalizedRoot.getFileName().toString(); + Set legacyDimensions = new LinkedHashSet<>(); + for (PackSelection selection : unadoptedSelections) { + if (selection.pack().equals(packName)) { + legacyDimensions.add(selection.dimension()); + } + } + return PackStageContext.installed(normalizedRoot, packName, legacyDimensions); + } + + private static RetainedPack retainedPack(Path packRoot) throws IOException { + Path epochRoot = packRoot.getParent(); + Path epochsRoot = epochRoot == null ? null : epochRoot.getParent(); + Path generationRoot = epochsRoot == null ? null : epochsRoot.getParent(); + Path irisRoot = generationRoot == null ? null : generationRoot.getParent(); + Path dimensionRoot = irisRoot == null ? null : irisRoot.getParent(); + if (epochRoot == null + || epochsRoot == null + || generationRoot == null + || irisRoot == null + || dimensionRoot == null + || !"pack".equals(packRoot.getFileName().toString()) + || !GenerationHistoryPaths.EPOCHS_DIRECTORY_NAME.equals(epochsRoot.getFileName().toString()) + || !GenerationHistoryPaths.GENERATION_DIRECTORY_NAME.equals(generationRoot.getFileName().toString()) + || !GenerationHistoryPaths.IRIS_DIRECTORY_NAME.equals(irisRoot.getFileName().toString())) { + return null; + } + GenerationHistory history = GenerationHistory.open(dimensionRoot); + String epochId = epochRoot.getFileName().toString(); + GenerationEpoch epoch = history.manifest().epoch(epochId).orElseThrow( + () -> new IOException("Retained Iris pack is not referenced by generation history: " + packRoot) + ); + if (!history.paths().packRoot(epochId).toAbsolutePath().normalize().equals(packRoot)) { + throw new IOException("Retained Iris pack path does not match generation history: " + packRoot); + } + return new RetainedPack(history, epoch); + } + + private static IOException requiredPackFailure( + PackStageContext context, + String operation, + Throwable failure + ) { + if (failure instanceof IOException ioFailure) { + return ioFailure; + } + return new IOException("Retained Iris generation pack at " + context.packRoot() + + " failed " + operation + ".", failure); + } + private static Path packsRoot() { return IrisPlatforms.get().packsFolderNoCreate().toPath(); } + private record PackSelection(String pack, String dimension) { + } + + private record RetainedPack(GenerationHistory history, GenerationEpoch epoch) { + } + + private record PackStageContext( + Path packRoot, + String packName, + String dimensionKey, + String retainedDimensionTypeRef, + boolean emitPresets, + boolean emitLegacyDimensionTypeAlias, + boolean required, + Set legacyDimensions, + GenerationRegistryContract retainedContract + ) implements IrisCustomBiomeAliasResolver { + private static PackStageContext installed( + Path packRoot, + String packName, + Set legacyDimensions + ) { + return new PackStageContext( + packRoot, + packName, + null, + null, + true, + true, + false, + Set.copyOf(legacyDimensions), + null + ); + } + + private static PackStageContext retained( + Path packRoot, + String packName, + GenerationEpoch epoch + ) { + return new PackStageContext( + packRoot, + packName, + epoch.dimensionContract().dimensionKey(), + epoch.dimensionContract().dimensionTypeKey(), + false, + true, + true, + Set.of(), + epoch.registryContract() + ); + } + + private String dimensionTypeRef(String installedDimensionKey) { + return retainedDimensionTypeRef == null + ? ModdedWorldgenIds.dimensionTypeRef(packName, installedDimensionKey) + : retainedDimensionTypeRef; + } + + @Override + public String physicalResourceKey( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String currentPhysicalResourceKey + ) throws IOException { + if (retainedContract == null) { + return currentPhysicalResourceKey; + } + return dimension.getLoader().customBiomeResourceKey(dimension, customBiome); + } + + @Override + public List aliases( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String physicalResourceKey + ) { + List candidates = ModdedWorldgenIds.legacyBiomeRefs( + packName, + dimension.getLoadKey(), + customBiome.getId() + ); + if (legacyDimensions.contains(dimension.getLoadKey())) { + return candidates; + } + if (retainedContract == null) { + return List.of(); + } + List aliases = new ArrayList<>(candidates.size()); + for (String candidate : candidates) { + PhysicalResourceKey key = new PhysicalResourceKey( + GenerationRegistryContractFactory.BIOME_REGISTRY, + candidate + ); + if (retainedContract.definitions().containsKey(key)) { + aliases.add(candidate); + } + } + return List.copyOf(aliases); + } + + @Override + public String generatedSource( + String registryKey, + String resourceKey, + String currentSource, + IDataFixer fixer + ) throws IOException { + if (retainedContract == null) { + return currentSource; + } + PhysicalResourceKey key = new PhysicalResourceKey(registryKey, resourceKey); + return GenerationRegistryContractFactory.requireGeneratedSource(retainedContract, key, fixer); + } + } + private record PublishedState(Path directory, String packsHash) { } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java new file mode 100644 index 000000000..eee1768d6 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -0,0 +1,361 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.pack.BrokenPackException; +import art.arcane.iris.core.pack.PackValidationRegistry; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +final class ModdedGenerationHistoryStorage { + private ModdedGenerationHistoryStorage() { + } + + static ActivePack createOrStage( + MinecraftServer server, + ResourceKey levelKey, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) { + Path dimensionRoot = dimensionRoot(server, levelKey); + try { + Path source = selectedPackSource(selectedPack, selectedDimensionKey); + Candidate candidate = captureCandidate(source, selectedDimensionKey); + GenerationHistory history = createOrStage( + dimensionRoot, + source, + worldSeed, + candidate, + ModdedGenerationHistoryStorage::requireRegistryDefinitions, + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() + ); + return resolveActive(history); + } catch (IOException failure) { + throw historyFailure(levelKey, failure); + } + } + + static ActivePack restoreOrAdopt( + MinecraftServer server, + ResourceKey levelKey, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) { + Path dimensionRoot = dimensionRoot(server, levelKey); + try { + GenerationHistory history = restoreOrAdopt( + dimensionRoot, + worldSeed, + () -> { + Path source = selectedPackSource(selectedPack, selectedDimensionKey); + return new AdoptionSource(source, captureCandidate( + source, + selectedDimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + )); + }, + ModdedGenerationHistoryStorage::requireRegistryDefinitions + ); + return resolveActive(history); + } catch (IOException failure) { + throw historyFailure(levelKey, failure); + } + } + + static ActivePack openOrAdopt( + ServerLevel level, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) { + return restoreOrAdopt( + level.getServer(), + level.dimension(), + selectedPack, + selectedDimensionKey, + worldSeed + ); + } + + static GenerationHistory createOrStage( + Path dimensionRoot, + Path source, + long worldSeed, + Candidate candidate, + RegistryContractValidator validator, + int transitionWidthBlocks + ) throws IOException { + Path requiredRoot = normalize(dimensionRoot); + Path requiredSource = normalize(source); + Candidate requiredCandidate = Objects.requireNonNull(candidate, "candidate"); + RegistryContractValidator requiredValidator = Objects.requireNonNull(validator, "validator"); + Optional existing = GenerationHistory.openIfPresent(requiredRoot, worldSeed); + if (existing.isEmpty()) { + captureAndRequireRegistryDefinitions( + List.of(requiredCandidate.registryContract()), + requiredValidator + ); + Files.createDirectories(requiredRoot); + return GenerationHistory.create( + requiredRoot, + requiredSource, + requiredCandidate.packFingerprint(), + worldSeed, + requiredCandidate.dimensionContract(), + requiredCandidate.registryContract() + ); + } + + GenerationHistory history = existing.get(); + captureAndRequireRegistryDefinitions(registryContracts(history), requiredValidator); + history.stageUpdate( + requiredSource, + requiredCandidate.packFingerprint(), + requiredCandidate.dimensionContract(), + requiredCandidate.registryContract(), + transitionWidthBlocks + ); + return history; + } + + static GenerationHistory restoreOrAdopt( + Path dimensionRoot, + long worldSeed, + AdoptionSourceSupplier sourceSupplier, + RegistryContractValidator validator + ) throws IOException { + Path requiredRoot = normalize(dimensionRoot); + RegistryContractValidator requiredValidator = Objects.requireNonNull(validator, "validator"); + Optional existing = GenerationHistory.openIfPresent(requiredRoot, worldSeed); + if (existing.isPresent()) { + requireRegistryDefinitions(existing.get(), requiredValidator); + return existing.get(); + } + + AdoptionSource adoption = Objects.requireNonNull(sourceSupplier, "sourceSupplier").get(); + Candidate candidate = Objects.requireNonNull(adoption.candidate(), "candidate"); + captureAndRequireRegistryDefinitions(List.of(candidate.registryContract()), requiredValidator); + Files.createDirectories(requiredRoot); + return GenerationHistory.create( + requiredRoot, + normalize(adoption.packSource()), + candidate.packFingerprint(), + worldSeed, + candidate.dimensionContract(), + candidate.registryContract() + ); + } + + static ActivePack resolveActive(GenerationHistory history) throws IOException { + GenerationEpoch epoch = history.activeEpoch(); + Path packRoot = normalize(history.activePackRoot()); + validatePack(packRoot, epoch.packFingerprint()); + validateDimension(packRoot, epoch.dimensionContract()); + return new ActivePack( + packRoot, + epoch.dimensionContract().dimensionKey(), + epoch.dimensionContract(), + history + ); + } + + private static void validatePack(Path packRoot, String fingerprint) { + PackValidationResult validation = PackValidator.validate(packRoot.toFile()); + PackValidationRegistry.publish(packRoot, validation, fingerprint); + if (!validation.isLoadable()) { + throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); + } + } + + private static void validateDimension( + Path packRoot, + GenerationEpoch.DimensionContract recordedContract + ) throws IOException { + IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(recordedContract.dimensionKey(), false); + if (dimension == null) { + throw new IOException("Immutable generation pack does not contain dimension '" + + recordedContract.dimensionKey() + "'."); + } + GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + recordedContract.dimensionTypeKey() + ); + if (!recordedContract.equals(actualContract)) { + throw new IOException("Immutable generation pack no longer matches its recorded dimension contract."); + } + } finally { + data.close(); + } + } + + private static Candidate captureCandidate(Path source, String dimensionKey) throws IOException { + return captureCandidate( + source, + dimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY + ); + } + + private static Candidate captureCandidate( + Path source, + String dimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy aliasPolicy + ) throws IOException { + String fingerprint = GenerationPackFingerprint.compute( + source, + GenerationPackFingerprint.CURRENT_VERSION + ); + IrisData data = IrisData.openDatapackCompiler(source.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IOException("Selected Iris pack does not contain dimension '" + dimensionKey + "'."); + } + PlatformGenerationRegistry generationRegistry = IrisPlatforms.get() + .registries() + .generationRegistry(); + String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey( + source.getFileName().toString(), + dimension.getLoadKey(), + dimension.getDimensionTypeKey() + ); + GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + dimensionTypeKey + ); + GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create( + data, + dimension, + fingerprint, + aliasPolicy + ); + return new Candidate(fingerprint, dimensionContract, registryContract); + } finally { + data.close(); + } + } + + private static Path selectedPackSource(String pack, String dimensionKey) { + ModdedStartup.requirePackForWorldCreation(pack); + return normalize(ModdedWorldEngines.resolvePack(pack, dimensionKey).toPath()); + } + + private static GenerationRegistryContract requireRegistryDefinitions( + Collection contracts + ) + throws IOException { + return GenerationRegistryContractFactory.captureRequiredDefinitions(contracts); + } + + private static void requireRegistryDefinitions( + GenerationHistory history, + RegistryContractValidator validator + ) throws IOException { + captureAndRequireRegistryDefinitions(registryContracts(history), validator); + } + + private static void captureAndRequireRegistryDefinitions( + Collection contracts, + RegistryContractValidator validator + ) throws IOException { + GenerationRegistryContract available = validator.capture(contracts); + for (GenerationRegistryContract contract : contracts) { + contract.requireDefinitionsAvailableIn(available); + } + } + + private static List registryContracts(GenerationHistory history) { + Collection epochs = history.manifest().epochs(); + List contracts = new ArrayList<>(epochs.size()); + for (GenerationEpoch epoch : epochs) { + contracts.add(epoch.registryContract()); + } + return contracts; + } + + private static Path dimensionRoot(MinecraftServer server, ResourceKey levelKey) { + return normalize(ModdedDimensionStorage.storageFolder(server, levelKey).toPath()); + } + + private static Path normalize(Path path) { + return Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + } + + private static IllegalStateException historyFailure(ResourceKey levelKey, IOException failure) { + return new IllegalStateException("Iris generation history is unusable for '" + + levelKey.identifier() + "'.", failure); + } + + record ActivePack( + Path packRoot, + String dimensionKey, + GenerationEpoch.DimensionContract dimensionContract, + GenerationHistory history + ) { + ActivePack { + packRoot = normalize(packRoot); + dimensionKey = Objects.requireNonNull(dimensionKey, "dimensionKey"); + dimensionContract = Objects.requireNonNull(dimensionContract, "dimensionContract"); + history = Objects.requireNonNull(history, "history"); + } + } + + record Candidate( + String packFingerprint, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract + ) { + Candidate { + packFingerprint = Objects.requireNonNull(packFingerprint, "packFingerprint"); + dimensionContract = Objects.requireNonNull(dimensionContract, "dimensionContract"); + registryContract = Objects.requireNonNull(registryContract, "registryContract"); + } + } + + record AdoptionSource(Path packSource, Candidate candidate) { + AdoptionSource { + packSource = normalize(packSource); + candidate = Objects.requireNonNull(candidate, "candidate"); + } + } + + @FunctionalInterface + interface AdoptionSourceSupplier { + AdoptionSource get() throws IOException; + } + + @FunctionalInterface + interface RegistryContractValidator { + GenerationRegistryContract capture(Collection contracts) throws IOException; + } + +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationMode.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationMode.java new file mode 100644 index 000000000..c13f73816 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationMode.java @@ -0,0 +1,17 @@ +package art.arcane.iris.modded; + +enum ModdedGenerationMode { + PERSISTENT_CREATE(true), + PERSISTENT_RESTORE(true), + TRANSIENT_STUDIO(false); + + private final boolean historyRequired; + + ModdedGenerationMode(boolean historyRequired) { + this.historyRequired = historyRequired; + } + + boolean historyRequired() { + return historyRequired; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java new file mode 100644 index 000000000..9511ead45 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java @@ -0,0 +1,191 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.spi.PlatformGenerationRegistry; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.dimension.DimensionType; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +final class ModdedGenerationRegistry implements PlatformGenerationRegistry { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String STRUCTURE_REGISTRY = "minecraft:worldgen/structure"; + private static final String DIMENSION_TYPE_REGISTRY = "minecraft:dimension_type"; + private static final String BLOCK_REGISTRY = "minecraft:block"; + private static final String ENTITY_TYPE_REGISTRY = "minecraft:entity_type"; + + private final Supplier server; + private final String runtimeIdentity; + private final String rendererIdentity; + + ModdedGenerationRegistry( + Supplier server, + String runtimeIdentity, + String rendererIdentity + ) { + this.server = Objects.requireNonNull(server, "server"); + this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity"); + this.rendererIdentity = requireText(rendererIdentity, "rendererIdentity"); + } + + @Override + public String runtimeIdentity() { + return runtimeIdentity; + } + + @Override + public String generatedDefinitionRendererIdentity() { + return rendererIdentity; + } + + @Override + public String customBiomeResourceKey(String identitySha256) { + return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256); + } + + @Override + public List legacyCustomBiomeResourceKeys( + String packName, + String dimensionKey, + String customBiomeId + ) { + return ModdedWorldgenIds.legacyBiomeRefs( + requireText(packName, "packName"), + requireText(dimensionKey, "dimensionKey"), + requireText(customBiomeId, "customBiomeId") + ); + } + + @Override + public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { + return ModdedWorldgenIds.dimensionTypeRef( + requireText(packName, "packName"), + requireText(dimensionKey, "dimensionKey") + ); + } + + @Override + public Definition definition(String registryKey, String resourceKey) { + MinecraftServer instance = Objects.requireNonNull(server.get(), "Minecraft server"); + RegistryAccess access = instance.registryAccess(); + return switch (registryKey) { + case BIOME_REGISTRY -> presence(access, Registries.BIOME, registryKey, resourceKey); + case STRUCTURE_REGISTRY -> presence(access, Registries.STRUCTURE, registryKey, resourceKey); + case DIMENSION_TYPE_REGISTRY -> presence( + access, + Registries.DIMENSION_TYPE, + registryKey, + resourceKey + ); + case BLOCK_REGISTRY -> presence(access, Registries.BLOCK, registryKey, resourceKey); + case ENTITY_TYPE_REGISTRY -> presence(access, Registries.ENTITY_TYPE, registryKey, resourceKey); + default -> throw new IllegalArgumentException("Unsupported generation registry: " + registryKey); + }; + } + + @Override + public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) { + MinecraftServer instance = Objects.requireNonNull(server.get(), "Minecraft server"); + RegistryAccess access = instance.registryAccess(); + return switch (registryKey) { + case BIOME_REGISTRY -> canonicalize(access, Biome.DIRECT_CODEC, sourceJson); + case DIMENSION_TYPE_REGISTRY -> canonicalize(access, DimensionType.DIRECT_CODEC, sourceJson); + default -> throw new IllegalArgumentException( + "Registry does not accept generated JSON definitions: " + registryKey + ); + }; + } + + @Override + public Definition generatedDefinition(String registryKey, String resourceKey) { + MinecraftServer instance = Objects.requireNonNull(server.get(), "Minecraft server"); + RegistryAccess access = instance.registryAccess(); + return switch (registryKey) { + case BIOME_REGISTRY -> exactRegistered( + access, + Registries.BIOME, + Biome.DIRECT_CODEC, + resourceKey + ); + case DIMENSION_TYPE_REGISTRY -> exactRegistered( + access, + Registries.DIMENSION_TYPE, + DimensionType.DIRECT_CODEC, + resourceKey + ); + default -> throw new IllegalArgumentException( + "Registry does not contain Iris-generated definitions: " + registryKey + ); + }; + } + + private static Definition presence( + RegistryAccess access, + ResourceKey> registryKey, + String registryResourceKey, + String resourceKey + ) { + Registry registry = access.lookupOrThrow(registryKey); + Identifier identifier = requireIdentifier(resourceKey); + T value = registry.getValue(identifier); + if (value == null) { + throw new IllegalStateException("Missing registry definition " + registryKey.identifier() + + " / " + resourceKey + "."); + } + return Definition.resourceIdentity(registryResourceKey, resourceKey); + } + + private static Definition canonicalize(RegistryAccess access, Codec codec, String sourceJson) { + RegistryOps ops = RegistryOps.create(JsonOps.INSTANCE, access); + JsonElement source = JsonParser.parseString(Objects.requireNonNull(sourceJson, "sourceJson")); + T decoded = codec.parse(ops, source).getOrThrow(IllegalArgumentException::new); + JsonElement encoded = codec.encodeStart(ops, decoded).getOrThrow(IllegalStateException::new); + return Definition.exactJson(encoded.toString()); + } + + private static Definition exactRegistered( + RegistryAccess access, + ResourceKey> registryKey, + Codec codec, + String resourceKey + ) { + Registry registry = access.lookupOrThrow(registryKey); + Identifier identifier = requireIdentifier(resourceKey); + T value = registry.getValue(identifier); + if (value == null) { + throw new IllegalStateException("Missing generated registry definition " + + registryKey.identifier() + " / " + resourceKey + "."); + } + RegistryOps ops = RegistryOps.create(JsonOps.INSTANCE, access); + JsonElement encoded = codec.encodeStart(ops, value).getOrThrow(IllegalStateException::new); + return Definition.exactJson(encoded.toString()); + } + + private static Identifier requireIdentifier(String resourceKey) { + Identifier identifier = Identifier.tryParse(resourceKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registry resource key: " + resourceKey); + } + return identifier; + } + + private static String requireText(String value, String label) { + String required = Objects.requireNonNull(value, label).trim(); + if (required.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return required; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java index 682bed88d..c02918df5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java @@ -57,6 +57,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; /** @@ -77,13 +78,11 @@ import java.util.concurrent.locks.ReentrantLock; */ final class ModdedImportedFeatureStage { private static final String CYCLE_MARKER = "Feature order cycle found"; - private static final long NO_GENERATION = Long.MIN_VALUE; - private final IrisModdedBiomeSource biomeSource; private final ReentrantLock buildLock = new ReentrantLock(); + private final ConcurrentHashMap featureTables = new ConcurrentHashMap<>(); + private final ConcurrentHashMap inertGenerations = new ConcurrentHashMap<>(); private volatile IrisModdedChunkGenerator generator; - private volatile FeatureTable featureTable; - private volatile long inertGeneration = NO_GENERATION; ModdedImportedFeatureStage(IrisModdedBiomeSource biomeSource) { this.biomeSource = biomeSource; @@ -98,12 +97,12 @@ final class ModdedImportedFeatureStage { * pack can never serve another. */ void invalidate() { - featureTable = null; - inertGeneration = NO_GENERATION; + featureTables.clear(); + inertGenerations.clear(); } boolean active() { - return featureTable != null; + return !featureTables.isEmpty(); } /** @@ -115,7 +114,8 @@ final class ModdedImportedFeatureStage { *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. */ BiomeGenerationSettings generationSettings(Holder biome) { - FeatureTable table = featureTable; + Engine engine = generator == null ? null : generator.engineOrNull(); + FeatureTable table = engine == null ? null : featureTables.get(engine.getCacheID()); if (table == null) { return biome.value().getGenerationSettings(); } @@ -146,8 +146,9 @@ final class ModdedImportedFeatureStage { if (engine == null || engine.isClosed() || engine.isClosing()) { return; } + int runtimeIdentity = engine.getCacheID(); long generation = biomeSource.packGeneration(); - if (settled(generation)) { + if (settled(runtimeIdentity, generation)) { return; } if (waitForBuild) { @@ -156,35 +157,35 @@ final class ModdedImportedFeatureStage { return; } try { - if (settled(generation)) { + if (settled(runtimeIdentity, generation)) { return; } - build(engine, generation); + build(engine, runtimeIdentity, generation); } finally { buildLock.unlock(); } } - private boolean settled(long generation) { - FeatureTable current = featureTable; + private boolean settled(int runtimeIdentity, long generation) { + FeatureTable current = featureTables.get(runtimeIdentity); if (current != null && current.generation() == generation) { return true; } - return inertGeneration == generation; + return Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity)); } - private void build(Engine engine, long generation) { + private void build(Engine engine, int runtimeIdentity, long generation) { IrisImportedFeatureControl control; try { control = NativeFeatureGenerationPolicy.control(engine); } catch (RuntimeException error) { ModdedIrisLog.error("Iris could not read importedFeatures for this dimension; features off: {}", error.toString()); - markInert(generation); + markInert(runtimeIdentity, generation); return; } if (!control.shouldGenerateFeatures()) { - markInert(generation); + markInert(runtimeIdentity, generation); return; } FeatureTable built; @@ -194,15 +195,15 @@ final class ModdedImportedFeatureStage { } catch (Throwable error) { ModdedIrisLog.error("Iris importedFeatures is off for {}: feature table construction failed: {}", dimensionKey(engine), error.toString()); - markInert(generation); + markInert(runtimeIdentity, generation); return; } if (built == null) { - markInert(generation); + markInert(runtimeIdentity, generation); return; } - featureTable = built; - inertGeneration = NO_GENERATION; + featureTables.put(runtimeIdentity, built); + inertGenerations.remove(runtimeIdentity); // Arm the worldcheck log watch here, before any chunk decorates: arming from the first pass instead // missed every far-chunk write the first chunk made. No-op unless -Diris.worldcheck is set. WorldCheckFeaturePlacement.arm(); @@ -211,9 +212,9 @@ final class ModdedImportedFeatureStage { built.derivatives().size()); } - private void markInert(long generation) { - featureTable = null; - inertGeneration = generation; + private void markInert(int runtimeIdentity, long generation) { + featureTables.remove(runtimeIdentity); + inertGenerations.put(runtimeIdentity, generation); } private FeatureTable buildTable(Engine engine, IrisImportedFeatureControl control, long generation) { @@ -300,7 +301,8 @@ final class ModdedImportedFeatureStage { * control is disabled or the table degraded. */ void run(WorldGenLevel level, ChunkAccess chunk, Engine engine) { - FeatureTable table = featureTable; + int runtimeIdentity = engine.getCacheID(); + FeatureTable table = featureTables.get(runtimeIdentity); if (table == null) { WorldCheckFeaturePlacement.recordFeaturesOff(); return; @@ -308,7 +310,8 @@ final class ModdedImportedFeatureStage { if (table.generation() != biomeSource.packGeneration()) { // A repoint landed between the prepare above and this chunk. Refuse stale content outright; the // next chunk's prepare rebuilds against the new pack. - invalidate(); + featureTables.remove(runtimeIdentity, table); + inertGenerations.remove(runtimeIdentity); return; } IrisModdedChunkGenerator owner = generator; @@ -349,6 +352,11 @@ final class ModdedImportedFeatureStage { WorldCheckFeaturePlacement.recordPlacementPass(); } + void evictRuntime(int runtimeIdentity) { + featureTables.remove(runtimeIdentity); + inertGenerations.remove(runtimeIdentity); + } + private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData, Registry featureRegistry, Set> chunkBiomes, IrisModdedChunkGenerator owner, WorldgenRandom random, long decorationSeed, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 58a870f4e..f35d48ca4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -230,17 +231,30 @@ final class ModdedNativeStructureStage { if (!start.isValid() || previousStarts.get(structure) == start) { continue; } - if (configuredStarts.containsKey(structure)) { - recordWorldCheckStructureShift( - configuredStarts.get(structure).source().getStructure(), start.getChunkPos(), 0); - continue; - } Identifier id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); if (structureId == null) { throw NativeStructureGenerationException.failure( "resolution", null, chunkPos.x(), chunkPos.z()); } + BoundingBox footprint = start.getBoundingBox(); + if (!current.getComplex().allowsNewGenerationFootprint( + footprint.minX(), + footprint.minZ(), + footprint.maxX(), + footprint.maxZ())) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + if (configuredStarts.containsKey(structure)) { + NativeStructureOwnershipStore.discard( + current, structureId, chunkPos.x(), chunkPos.z()); + } + continue; + } + if (configuredStarts.containsKey(structure)) { + recordWorldCheckStructureShift( + configuredStarts.get(structure).source().getStructure(), start.getChunkPos(), 0); + continue; + } boolean undergroundStep = NativeStructureVegetationClearer.isUndergroundStep(structure.step()); IrisNativeStructureDecision decision; try { @@ -323,6 +337,14 @@ final class ModdedNativeStructureStage { List starts = structureManager.startsForStructure(sectionPos, structure); List resolvedPlacements = new ArrayList<>(starts.size()); for (StructureStart start : starts) { + BoundingBox footprint = start.getBoundingBox(); + if (!current.getComplex().allowsNewGenerationFootprint( + footprint.minX(), + footprint.minZ(), + footprint.maxX(), + footprint.maxZ())) { + continue; + } NativeStructureOwnershipRecord ownership = NativeStructureOwnershipRecovery.resolve( current, world.getLevel(), structureId, structure, start); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java index 19c269bd3..55b9107a3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.BuildConstants; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.modded.api.ModdedDataType; import art.arcane.iris.spi.IrisLogging; @@ -25,6 +26,7 @@ import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockProperty; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.data.UnresolvedKeyLog; @@ -59,6 +61,19 @@ public final class ModdedRegistries implements PlatformRegistries { this.server = server; } + @Override + public PlatformGenerationRegistry generationRegistry() { + MinecraftServer instance = server.get(); + if (instance == null) { + throw new IllegalStateException("Minecraft server is not ready for generation registry capture."); + } + return new ModdedGenerationRegistry( + server, + "modded-generation-registry-v1", + "modded-generated-registry-json-v1|minecraft:" + BuildConstants.MINECRAFT_VERSION + ); + } + @Override public PlatformBlockState block(String key) { return ModdedBlockResolution.get(key); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java index f998a3680..ac5a1ce53 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java @@ -71,7 +71,7 @@ public final class ModdedRuntimeRegistry { if (!seen.add(biomeId)) { continue; } - String biomeRef = ModdedWorldgenIds.biomeRef(pack, dimension.getLoadKey(), biomeId); + String biomeRef = data.customBiomeResourceKey(dimension, customBiome); ResourceKey biomeKey = ResourceKey.create(Registries.BIOME, Identifier.parse(biomeRef)); if (registry.get(biomeKey).isEmpty()) { missing.add(biomeRef); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java index f7fde8a5c..8012b40e4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java @@ -31,8 +31,8 @@ import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.MobSpawnSettings; -import java.util.Locale; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** @@ -41,32 +41,30 @@ import java.util.concurrent.ConcurrentHashMap; */ final class ModdedSpawnTableMerger { private final IrisModdedChunkGenerator generator; - private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); - private volatile boolean vanillaSpawnBiomesInitialized; + private final Set initializedRuntimeIdentities = ConcurrentHashMap.newKeySet(); ModdedSpawnTableMerger(IrisModdedChunkGenerator generator) { this.generator = generator; } void initializeVanillaSpawnBiomes(Registry registry) { - // Volatile fast path BEFORE the monitor: this runs per mob category per spawn attempt - // on the server thread, and the generator monitor is contended by binds/hotloads. - if (vanillaSpawnBiomesInitialized) { + Engine current = generator.engineOrNull(); + if (current == null) { + return; + } + int runtimeIdentity = current.getCacheID(); + if (initializedRuntimeIdentities.contains(runtimeIdentity)) { return; } synchronized (generator) { - if (vanillaSpawnBiomesInitialized) { - return; - } - Engine current = generator.engineOrNull(); - if (current == null) { + if (initializedRuntimeIdentities.contains(runtimeIdentity)) { return; } try (GenerationSessionLease lease = generator.requireGenerationLease(current, "modded_spawn_biomes"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); for (IrisBiome irisBiome : current.getDimension().getReachableBiomes(current)) { if (irisBiome == null || !irisBiome.isCustom()) { continue; @@ -76,29 +74,44 @@ final class ModdedSpawnTableMerger { continue; } for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - Holder customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId()); + String resourceKey = current.getData().customBiomeResourceKey( + current.getDimension(), + customBiome + ); + Holder customHolder = resolveBiomeHolder(registry, resourceKey); if (customHolder != null) { - vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder); + vanillaSpawnBiomes.putIfAbsent( + new SpawnBiomeKey(runtimeIdentity, customHolder.value()), + vanillaHolder + ); } } } - vanillaSpawnBiomesInitialized = true; + initializedRuntimeIdentities.add(runtimeIdentity); } } } - Holder vanillaSpawnBiome(Biome biome) { - return vanillaSpawnBiomes.get(biome); + Holder vanillaSpawnBiome(Engine engine, Biome biome) { + return vanillaSpawnBiomes.get(new SpawnBiomeKey(engine.getCacheID(), biome)); } WeightedList mergedSpawnTable( - Biome biome, MobCategory category, + Engine engine, + Biome biome, + MobCategory category, WeightedList vanillaSpawns, WeightedList explicitSpawns) { - SpawnTableKey key = new SpawnTableKey(biome, category); + SpawnTableKey key = new SpawnTableKey(engine.getCacheID(), biome, category); return mergedSpawnTables.computeIfAbsent(key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns)); } + void evictRuntime(int runtimeIdentity) { + vanillaSpawnBiomes.keySet().removeIf(key -> key.runtimeIdentity() == runtimeIdentity); + mergedSpawnTables.keySet().removeIf(key -> key.runtimeIdentity() == runtimeIdentity); + initializedRuntimeIdentities.remove(runtimeIdentity); + } + private Holder resolveBiomeHolder(Registry registry, String key) { if (key == null || key.isBlank()) { return null; @@ -115,10 +128,13 @@ final class ModdedSpawnTableMerger { synchronized (generator) { vanillaSpawnBiomes.clear(); mergedSpawnTables.clear(); - vanillaSpawnBiomesInitialized = false; + initializedRuntimeIdentities.clear(); } } - private record SpawnTableKey(Biome biome, MobCategory category) { + private record SpawnBiomeKey(int runtimeIdentity, Biome biome) { + } + + private record SpawnTableKey(int runtimeIdentity, Biome biome, MobCategory category) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index cd4ce78e8..ed4608108 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -223,7 +223,13 @@ public final class ModdedStartup { index++; long dimensionStartedAt = System.currentTimeMillis(); try { - ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed()); + ModdedDimensionManager.restorePersistent( + server, + dimension.id(), + dimension.pack(), + dimension.dimension(), + dimension.seed() + ); injected++; ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms", index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(), 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 b8dc735d4..f1ebdf733 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 @@ -18,21 +18,30 @@ package art.arcane.iris.modded; -import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; +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.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.modded.command.ModdedGuiHost; +import art.arcane.iris.spi.IrisPlatforms; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.storage.LevelResource; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Map; @@ -45,13 +54,25 @@ public final class ModdedWorldEngines { private ModdedWorldEngines() { } - public static Engine get(ServerLevel level, String pack, String dimensionKey, long seedOverride) { + public static Engine get( + ServerLevel level, + String pack, + String dimensionKey, + long seedOverride, + ModdedGenerationMode generationMode + ) { Engine existing = ENGINES.get(level); if (existing != null) { return existing; } return ENGINES.computeIfAbsent(level, - (ServerLevel l) -> createAndApplyWorldBoundary(l, pack, dimensionKey, seedOverride)); + (ServerLevel l) -> createAndApplyWorldBoundary( + l, + pack, + dimensionKey, + seedOverride, + generationMode + )); } public static Collection activeEngines() { @@ -81,8 +102,14 @@ public final class ModdedWorldEngines { ModdedIrisLog.info("Iris engine evicted for {}", level.dimension().identifier()); } - static Engine prepareReplacement(ServerLevel level, String pack, String dimensionKey, long seedOverride) { - return create(level, pack, dimensionKey, seedOverride); + static Engine prepareReplacement( + ServerLevel level, + String pack, + String dimensionKey, + long seedOverride, + ModdedGenerationMode generationMode + ) { + return create(level, pack, dimensionKey, seedOverride, generationMode); } static void installReplacement(ServerLevel level, Engine replacement) { @@ -103,9 +130,14 @@ public final class ModdedWorldEngines { ModdedGuiHost.unbind(engine); } - private static Engine createAndApplyWorldBoundary(ServerLevel level, String pack, - String dimensionKey, long seedOverride) { - Engine engine = create(level, pack, dimensionKey, seedOverride); + private static Engine createAndApplyWorldBoundary( + ServerLevel level, + String pack, + String dimensionKey, + long seedOverride, + ModdedGenerationMode generationMode + ) { + Engine engine = create(level, pack, dimensionKey, seedOverride, generationMode); try { engine.getPlatformHooks().applyWorldBoundary(engine); return engine; @@ -126,22 +158,178 @@ public final class ModdedWorldEngines { } } - private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) { + private static Engine create( + ServerLevel level, + String pack, + String dimensionKey, + long seedOverride, + ModdedGenerationMode generationMode + ) { ModdedEngineBootstrap.bind(); - File packDir = resolvePack(pack, dimensionKey); - PackValidationRegistry.requireLoadable(pack); - IrisData data = IrisData.openRuntime(packDir); - IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); - if (dimension == null) { - ModdedIrisLog.error("Iris pack '{}' at {} does not contain dimension '{}' (expected dimensions/{}.json). Install a matching Iris pack and restart.", - pack, packDir.getAbsolutePath(), dimensionKey, dimensionKey); - throw new IllegalStateException("Iris dimension '" + dimensionKey + "' missing from pack " + packDir.getAbsolutePath()); - } - long seed = seedOverride == Long.MIN_VALUE ? level.getSeed() : seedOverride; - validateDimensionContract(pack, dimensionKey, dimension, level); - File worldFolder = DimensionType.getStorageFolder(level.dimension(), level.getServer().getWorldPath(LevelResource.ROOT)).toFile(); - IrisWorld world = IrisWorld.builder() + ModdedGenerationMode requiredMode = Objects.requireNonNull(generationMode, "generationMode"); + if (requiredMode == ModdedGenerationMode.TRANSIENT_STUDIO) { + return createTransientStudioEngine(level, pack, dimensionKey, seed); + } + ModdedGenerationHistoryStorage.ActivePack active = ModdedGenerationHistoryStorage.openOrAdopt( + level, + pack, + dimensionKey, + seed + ); + try { + return createHistoryEngine(level, seed, active); + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history could not initialize runtime routing for '" + + level.dimension().identifier() + "'.", failure); + } + } + + private static Engine createHistoryEngine( + ServerLevel level, + long seed, + ModdedGenerationHistoryStorage.ActivePack opened + ) throws IOException { + GenerationHistory history = opened.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, + 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); + } + } + } + + private static IrisEngine buildEngine( + ServerLevel level, + long seed, + ModdedGenerationHistoryStorage.ActivePack active + ) throws IOException { + File packDir = active.packRoot().toFile(); + PackValidationRegistry.requireLoadable(active.packRoot()); + IrisData data = IrisData.openRuntime(packDir); + boolean runtimeOwnsData = false; + try { + IrisDimension dimension = data.getDimensionLoader().load(active.dimensionKey()); + if (dimension == null) { + ModdedIrisLog.error("Iris immutable generation pack at {} does not contain dimension '{}' (expected dimensions/{}.json).", + packDir.getAbsolutePath(), active.dimensionKey(), active.dimensionKey()); + throw new IllegalStateException("Iris dimension '" + active.dimensionKey() + + "' missing from immutable pack " + packDir.getAbsolutePath()); + } + + validateDimensionContract(active.dimensionContract(), dimension, level); + File worldFolder = DimensionType.getStorageFolder( + level.dimension(), + level.getServer().getWorldPath(LevelResource.ROOT) + ).toFile(); + IrisWorld world = buildWorld(level, seed, dimension, worldFolder); + GenerationHistory history = active.history(); + GenerationActivation activation = history.activeActivation(); + GenerationEpoch epoch = history.activeEpoch(); + TransitionGenerationPlan transitionPlan = activation.isInitial() + ? null + : history.transitionPlan(activation.activationId()); + IrisEngine engine = new IrisEngine( + new EngineTarget(world, dimension, data), + IrisEngine.InitializationMode.RUNTIME, + history.paths().activationMantleRoot(activation.activationId()), + epoch.kernelVersion(), + transitionPlan); + runtimeOwnsData = true; + return engine; + } finally { + if (!runtimeOwnsData) { + data.close(); + } + } + } + + private static IrisEngine createTransientStudioEngine( + ServerLevel level, + String pack, + String dimensionKey, + long seed + ) { + File packDir = resolvePack(pack, dimensionKey); + PackValidationRegistry.requireLoadable(packDir.toPath()); + IrisData data = IrisData.openRuntime(packDir); + boolean runtimeOwnsData = false; + boolean ready = false; + IrisEngine engine = null; + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '" + + dimensionKey + "'."); + } + File worldFolder = DimensionType.getStorageFolder( + level.dimension(), + level.getServer().getWorldPath(LevelResource.ROOT) + ).toFile(); + IrisWorld world = buildWorld(level, seed, dimension, worldFolder); + engine = new IrisEngine( + new EngineTarget(world, dimension, data), + IrisEngine.InitializationMode.STUDIO + ); + runtimeOwnsData = true; + requireReady(engine, level, ModdedGenerationMode.TRANSIENT_STUDIO); + ready = true; + ModdedIrisLog.info("Iris transient Studio engine up for {}: pack={} dim={} seed={} height={}..{}", + level.dimension().identifier(), + packDir.getAbsolutePath(), + dimension.getLoadKey(), + seed, + dimension.getMinHeight(), + dimension.getMaxHeight()); + return engine; + } finally { + if (!ready && engine != null) { + close(engine); + } else if (!runtimeOwnsData) { + data.close(); + } + } + } + + private static IrisWorld buildWorld( + ServerLevel level, + long seed, + IrisDimension dimension, + File worldFolder + ) { + return IrisWorld.builder() .platformIdentity(level.dimension().identifier().toString()) .name(level.dimension().identifier().toString().replace(':', '_')) .seed(seed) @@ -150,40 +338,52 @@ public final class ModdedWorldEngines { .maxHeight(dimension.getMaxHeight()) .platformWorld(new ModdedPlatformWorld(level)) .build(); - Engine engine = new IrisEngine( - new EngineTarget(world, dimension, data), - IrisEngine.InitializationMode.RUNTIME); - if (engine.isClosed() || engine.getComplex() == null) { - IllegalStateException failure = new IllegalStateException("Iris engine for " - + level.dimension().identifier() + " did not initialize a ready biome complex"); - try { - close(engine); - } catch (Throwable cleanupError) { - failure.addSuppressed(cleanupError); - } - throw failure; - } - - ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{}", - level.dimension().identifier(), packDir.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); - return engine; } - private static void validateDimensionContract(String pack, String dimensionKey, - IrisDimension dimension, ServerLevel level) { + private static void requireReady( + IrisEngine engine, + ServerLevel level, + ModdedGenerationMode generationMode + ) { + if (engine.isClosed() || engine.getComplex() == null) { + throw new IllegalStateException("Iris engine for " + level.dimension().identifier() + + " did not initialize a ready runtime"); + } + if (generationMode.historyRequired() + && engine.getGenerationHistoryRuntimeRouter().isEmpty()) { + throw new IllegalStateException("Persistent Iris engine for " + level.dimension().identifier() + + " did not initialize a generation-history runtime router"); + } + if (!generationMode.historyRequired() + && (!engine.isStudio() || engine.getGenerationHistoryRuntimeRouter().isPresent())) { + throw new IllegalStateException("Transient Iris Studio engine for " + + level.dimension().identifier() + " has an invalid history mode"); + } + } + + private static void validateDimensionContract( + GenerationEpoch.DimensionContract recorded, + IrisDimension dimension, + ServerLevel level + ) { + GenerationEpoch.DimensionContract loaded = GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + recorded.dimensionTypeKey() + ); + if (!recorded.equals(loaded)) { + throw new IllegalStateException("Immutable Iris dimension contract changed for '" + + level.dimension().identifier() + "'."); + } DimensionType actualType = level.dimensionType(); String actualTypeKey = level.dimensionTypeRegistration().unwrapKey() .map(key -> key.identifier().toString()) .orElse(""); - String legacyTypeKey = "irisworldgen:" + dimension.getDimensionTypeKey(); - String expectedTypeKey = legacyTypeKey.equals(actualTypeKey) - ? legacyTypeKey - : ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey); IrisDimensionRuntimeContract expected = new IrisDimensionRuntimeContract( - expectedTypeKey, - dimension.getMinHeight(), - dimension.getMaxHeight() - dimension.getMinHeight(), - dimension.getLogicalHeight()); + recorded.dimensionTypeKey(), + recorded.minHeight(), + recorded.height(), + recorded.logicalHeight()); IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract( actualTypeKey, actualType.minY(), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java index 64f99715c..b10ae55e5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java @@ -3,7 +3,9 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Locale; +import java.util.Optional; public final class ModdedWorldgenIds { private static final String NAMESPACE = "irisworldgen"; @@ -29,8 +31,33 @@ public final class ModdedWorldgenIds { } public static String biomeRef(Engine engine, String biome) { - return biomeRef(engine.getData().getDataFolder().getName(), - engine.getDimension().getLoadKey(), biome); + return engine.getData().customBiomeResourceKey(engine.getDimension(), biome); + } + + public static List legacyBiomeRefs(String pack, String dimension, String biome) { + String scoped = biomeRef(pack, dimension, biome); + String unscoped = dimension.toLowerCase(Locale.ROOT) + ":" + biome.toLowerCase(Locale.ROOT); + return scoped.equals(unscoped) ? List.of(scoped) : List.of(scoped, unscoped); + } + + static Optional scopedDimensionType(String dimensionTypeRef) { + String prefix = NAMESPACE + ":"; + if (dimensionTypeRef == null || !dimensionTypeRef.startsWith(prefix)) { + return Optional.empty(); + } + String[] parts = dimensionTypeRef.substring(prefix.length()).split("/"); + if (parts.length != 5 + || !"packs".equals(parts[0]) + || !"dimensions".equals(parts[2]) + || !"dimension_type".equals(parts[4])) { + return Optional.empty(); + } + String pack = decode(parts[1]); + String dimension = decode(parts[3]); + if (pack == null || dimension == null) { + return Optional.empty(); + } + return Optional.of(new ScopedDimension(pack, dimension)); } public static String displayName(String presetPath) { @@ -100,4 +127,7 @@ public final class ModdedWorldgenIds { } return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1); } + + record ScopedDimension(String pack, String dimension) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index 95e6b292a..f3a9c27a6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -26,6 +26,7 @@ import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.history.GenerationFindCatalog; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; @@ -109,7 +110,7 @@ final class ModdedCommandSuggestions { } static Set reachableBiomeKeys(Engine engine) { - return reachableBiomeKeys(engine.getAllBiomes()); + return reachableBiomeKeys(GenerationFindCatalog.biomes(engine)); } static Set reachableBiomeKeys(Iterable biomes) { @@ -123,7 +124,7 @@ final class ModdedCommandSuggestions { } static boolean isReachableBiome(Engine engine, String biomeKey) { - return isReachableBiome(engine.getAllBiomes(), biomeKey); + return GenerationFindCatalog.biome(engine, biomeKey) != null; } static boolean isReachableBiome(Iterable biomes, String biomeKey) { @@ -135,7 +136,8 @@ final class ModdedCommandSuggestions { try { Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); if (engine != null) { - return SharedSuggestionProvider.suggest(engine.getDimension().getRegions(), builder); + return SharedSuggestionProvider.suggest(GenerationFindCatalog.regions(engine).stream() + .map(region -> region.getLoadKey()), builder); } } catch (Throwable e) { warnTabFailure("region keys", context.getSource(), e); @@ -148,7 +150,7 @@ final class ModdedCommandSuggestions { try { Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); if (engine != null) { - return SharedSuggestionProvider.suggest(engine.getData().getObjectLoader().getPossibleKeys(), builder); + return SharedSuggestionProvider.suggest(GenerationFindCatalog.objectKeys(engine), builder); } } catch (Throwable e) { warnTabFailure("object keys", context.getSource(), e); @@ -191,6 +193,7 @@ final class ModdedCommandSuggestions { Collection unregisteredIrisKeys = eligibleUnregisteredEditableKeys( irisKeys, registeredKeys, (String candidate) -> IrisStructureLocator.hasNativePlacement(engine, candidate)); + nativeKeys.addAll(GenerationFindCatalog.retainedStructureKeys(engine)); return SharedSuggestionProvider.suggest( combineStructureKeys(unregisteredIrisKeys, nativeKeys), builder); } catch (Throwable e) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index 505f4f07c..27ac43ad5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -29,8 +29,9 @@ import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.WrongEngineBroException; +import art.arcane.iris.engine.history.GenerationSemanticQueries; +import art.arcane.iris.engine.history.GenerationFindCatalog; import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; -import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -86,15 +87,11 @@ final class ModdedLocateCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS_8)); return 0; } - IrisBiome biome = engine.getData().getBiomeLoader().load(key.trim()); + IrisBiome biome = GenerationFindCatalog.biome(engine, key); if (biome == null) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); return 0; } - if (!ModdedCommandSuggestions.isReachableBiome(engine, biome.getLoadKey())) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("value", biome.getLoadKey()))); - return 0; - } locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); return 1; } @@ -111,15 +108,11 @@ final class ModdedLocateCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS_9)); return 0; } - IrisRegion region = engine.getData().getRegionLoader().load(key.trim()); + IrisRegion region = GenerationFindCatalog.region(engine, key); if (region == null) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_REGION_2, MessageArgument.untrusted("key", key))); return 0; } - if (!engine.getDimension().getRegions().contains(region.getLoadKey())) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("value", region.getLoadKey()))); - return 0; - } locate(source, level, engine, player, Locator.region(region.getLoadKey()), "region " + region.getLoadKey()); return 1; } @@ -132,7 +125,7 @@ final class ModdedLocateCommands { return 0; } String key = keyRaw.trim(); - if (!engine.hasObjectPlacement(key)) { + if (!GenerationFindCatalog.hasObjectPlacement(engine, key)) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_CONFIGURED_ANY_REGION_BIOME_OBJECT_PLACEMENTS_OBJECT_KEYS, MessageArgument.untrusted("key", key), MessageArgument.untrusted("value", engine.getData().getObjectLoader().getPossibleKeys().length))); return 0; } @@ -158,10 +151,6 @@ final class ModdedLocateCommands { return 0; } IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); - if (runtime == null) { - IrisModdedCommands.fail(source, "Hydrology is not active in this dimension."); - return 0; - } HydrologyFeatureQuery query; try { query = HydrologyFeatureQuery.parse(type); @@ -171,15 +160,19 @@ final class ModdedLocateCommands { } int originX = player.blockPosition().getX(); int originZ = player.blockPosition().getZ(); - int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15); - int maximumDistance = runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance); + int requestedDistance = runtime == null + ? 8192 + : Math.min(8192, runtime.settings().routing().tileSize() * 15); + int maximumDistance = runtime == null + ? requestedDistance + : runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance); MinecraftServer server = source.getServer(); IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "..."); - MultiBurst.burst.completeValueAsync(() -> runtime.nearestFeature( - query.types(), query.profileKey(), originX, originZ, maximumDistance, + MultiBurst.burst.completeValueAsync(() -> GenerationSemanticQueries.nearestRiver( + engine, query, originX, originZ, maximumDistance, (int visited) -> server.execute(() -> IrisModdedCommands.ok(source, "Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null)) - .whenComplete((HydrologyFeatureRef feature, Throwable error) -> server.execute(() -> { + .whenComplete((GenerationSemanticQueries.RiverResult feature, Throwable error) -> server.execute(() -> { if (error != null) { ModdedIrisLog.error("Hydrology locate failed for {}", type, error); IrisModdedCommands.fail(source, @@ -192,13 +185,12 @@ final class ModdedLocateCommands { + maximumDistance + " blocks."); return; } - int worldY = feature.y() + engine.getDimension().getMinHeight() + 2; teleportToStructure( source, level, player, feature.x(), - worldY, + feature.y() + 2, feature.z(), type + " hydrology feature" ); @@ -225,8 +217,9 @@ final class ModdedLocateCommands { } Optional resolved = resolveNativeStructure(source, level, engine, key); if (resolved.isEmpty()) { - if (!IrisStructureLocator.hasNativePlacement(engine, key) - && IrisStructureLocator.hasLocatableEditablePlacement(engine, key)) { + if ((!IrisStructureLocator.hasNativePlacement(engine, key) + && IrisStructureLocator.hasLocatableEditablePlacement(engine, key)) + || GenerationFindCatalog.hasRetainedStructurePlacement(engine, key)) { locateIrisStructure(source, level, engine, player, key); return 1; } @@ -247,6 +240,10 @@ final class ModdedLocateCommands { if (!ModdedCommandSuggestions.isEligibleRegisteredStructure( decision, nativePlacement, locatableNativePlacement, locatableEditableReplacement, reachable, nativeGenerationEnabled)) { + if (GenerationFindCatalog.hasRetainedStructurePlacement(engine, key)) { + locateIrisStructure(source, level, engine, player, key); + return 1; + } IrisModdedCommands.fail(source, registeredStructureUnavailableMessage( target.key(), target.availability(), decision, nativePlacement, locatableNativePlacement, @@ -312,7 +309,7 @@ final class ModdedLocateCommands { Thread thread = new Thread(() -> { try { IrisStructureLocator.LocateResult result = - IrisStructureLocator.locate(engine, key, blockX, blockZ, 1024); + GenerationSemanticQueries.nearestStructure(engine, key, blockX, blockZ, 1024); if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNABLE_LOCATE_IRIS_PLACED_STRUCTURE_DENSITY_SEARCH_SAFETY_LIMIT_WAS, MessageArgument.untrusted("key", key)))); return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 5f52089d4..16598d11c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -488,7 +488,7 @@ public final class ModdedStudioCommands { ModdedDimensionManager.Handle handle = null; try { replaceExistingStudio(server, owner, dimensionId); - handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed); + handle = ModdedDimensionManager.createTransientStudio(server, dimensionId, pack, pack, seed); STUDIOS.put(owner, dimensionId); if (owner.equals(CONSOLE_OWNER)) { completeConsoleOpen(source, dimensionId, pack, seed, handle); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index a4bc4c9fc..b86e7dc16 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -78,6 +78,7 @@ public final class ModdedWorldCommands { root.then(enableTree("enable")); root.then(enableTree("create")); + root.then(updateTree()); root.then(replaceOverworldTree()); root.then(mainWorldTree()); @@ -110,6 +111,20 @@ public final class ModdedWorldCommands { .executes(ModdedCommandTree.localized((CommandContext context) -> disable(context.getSource(), dimensionArgument(context), false)))); } + private static LiteralArgumentBuilder updateTree() { + return Commands.literal("update") + .then(Commands.argument("dimension", IdentifierArgument.id()).suggests(LOADED_DIMENSIONS) + .then(Commands.argument("pack", StringArgumentType.string()) + .suggests(IrisModdedCommands.PACK_NAMES) + .executes(ModdedCommandTree.localized( + (CommandContext context) -> update( + context.getSource(), + dimensionArgument(context), + StringArgumentType.getString(context, "pack") + ) + )))); + } + private static LiteralArgumentBuilder deleteTree(String name) { return Commands.literal(name) .then(Commands.argument("dimension", IdentifierArgument.id()).suggests(LOADED_DIMENSIONS) @@ -197,6 +212,47 @@ public final class ModdedWorldCommands { return 1; } + private static int update(CommandSourceStack source, String targetDimension, String packRaw) { + String dimensionId; + try { + dimensionId = normalizeDimensionId(targetDimension); + } catch (IllegalArgumentException failure) { + IrisModdedCommands.fail(source, failure.getMessage()); + return 0; + } + String[] packRef = parsePackRef(packRaw); + String pack = packRef[0]; + String packDimension = packRef[1]; + if (!validPackRef(source, pack, packDimension) + || blockIfPackBroken(source, dimensionId, pack) + || !loadPackDimension(source, pack, packDimension)) { + return 0; + } + try { + ModdedDimensionManager.UpdateResult result = ModdedDimensionManager.stagePersistentUpdate( + source.getServer(), + dimensionId, + pack, + packDimension + ); + if (result.restartRequired()) { + IrisModdedCommands.ok(source, "Iris world update staged for " + dimensionId + + " from " + pack + ":" + packDimension + + ". Restart the server to activate it; the running world remains on its current pack."); + } else { + IrisModdedCommands.ok(source, "Iris world " + dimensionId + + " already uses this immutable pack; no restart is required."); + } + return 1; + } catch (Throwable failure) { + ModdedIrisLog.error("Iris world update staging failed for {} (pack={} dim={})", + dimensionId, pack, packDimension, failure); + IrisModdedCommands.fail(source, "Iris world update failed for " + dimensionId + + ": " + IrisLanguage.errorDetail(failure)); + return 0; + } + } + private static int replaceOverworld(CommandSourceStack source, String packRaw, String seedRaw) { MinecraftServer server = source.getServer(); Long seed = parseSeed(source, seedRaw); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java index 6cf161e4a..8d31c14d3 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -126,10 +127,11 @@ public class IrisModdedStructureParityTest { .setInferredType(InferredType.SEA); Set keys = ModdedDimensionMetadata.collectConfiguredBiomeKeys( - List.of(ocean, custom, shore, unsafeSea), "OverWorld"); + List.of(ocean, custom, shore, unsafeSea), + customBiome -> "iris:biomes/" + customBiome.getId().toLowerCase(Locale.ROOT)); assertEquals(Set.of("minecraft:deep_ocean", "minecraft:forest", "minecraft:beach", - "minecraft:the_void", "overworld:aurora"), keys); + "minecraft:the_void", "iris:biomes/aurora"), keys); assertFalse(keys.contains("minecraft:desert")); assertFalse(keys.contains("minecraft:plains")); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java index 6ac12df55..4f74a5172 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -52,6 +52,19 @@ public class ModdedForcedDatapackTest { assertEquals("iris:overworld", ModdedWorldgenIds.generatorIdentity("overworld")); assertEquals("iris:other/overworld", ModdedWorldgenIds.generatorIdentity("other:overworld")); + assertEquals( + new ModdedWorldgenIds.ScopedDimension("overworld", "overworld"), + ModdedWorldgenIds.scopedDimensionType( + ModdedWorldgenIds.dimensionTypeRef("overworld", "overworld") + ).orElseThrow() + ); + assertEquals( + List.of( + ModdedWorldgenIds.biomeRef("overworld", "overworld", "mist"), + "overworld:mist" + ), + ModdedWorldgenIds.legacyBiomeRefs("overworld", "overworld", "mist") + ); } @Test @@ -148,6 +161,24 @@ public class ModdedForcedDatapackTest { } } + @Test + public void rejectsConflictingRegistryDefinitionsDuringMerge() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-conflict"); + try { + Path source = Files.createDirectories(root.resolve("source/data/iris/worldgen/biome")); + Path destination = Files.createDirectories(root.resolve("destination/data/iris/worldgen/biome")); + Files.writeString(source.resolve("shared.json"), "{\"temperature\":1}", StandardCharsets.UTF_8); + Files.writeString(destination.resolve("shared.json"), "{\"temperature\":2}", StandardCharsets.UTF_8); + + assertThrows( + IOException.class, + () -> ModdedForcedDatapack.mergeDirectory(root.resolve("source"), root.resolve("destination")) + ); + } finally { + deleteTree(root); + } + } + private List directoryEntries(Path root) throws IOException { try (Stream entries = Files.list(root)) { return entries.map((Path path) -> path.getFileName().toString()) diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryRoutingContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryRoutingContractTest.java new file mode 100644 index 000000000..57944bfae --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryRoutingContractTest.java @@ -0,0 +1,180 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedGenerationHistoryRoutingContractTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void startupPublishesOnlyAnImmutableHistoryBoundRuntime() throws IOException { + String source = source("ModdedWorldEngines.java"); + String create = method(source, "private static Engine create("); + String createHistoryEngine = method(source, "private static Engine createHistoryEngine("); + String buildEngine = method(source, "private static IrisEngine buildEngine("); + + assertTrue(create.contains("ModdedGenerationHistoryStorage.openOrAdopt(")); + assertFalse(create.contains("resolvePack(")); + assertTrue(createHistoryEngine.contains("candidate.attachGenerationHistory(")); + assertTrue(createHistoryEngine.contains("IrisBoundarySignatureSampler.INSTANCE")); + assertTrue(createHistoryEngine.contains("getGenerationTransitionWidthBlocks()")); + assertTrue(createHistoryEngine.contains("router.preloadActiveRuntimes();")); + assertBefore(createHistoryEngine, "close(candidate);", "ModdedGenerationHistoryStorage.resolveActive(history)"); + assertTrue(createHistoryEngine.contains("GenerationHistoryRuntimeRouter.attach(")); + assertTrue(buildEngine.contains("active.packRoot().toFile()")); + assertTrue(buildEngine.contains("history.paths().activationMantleRoot(activation.activationId())")); + assertTrue(buildEngine.contains("epoch.kernelVersion()")); + assertTrue(buildEngine.contains("transitionPlan")); + } + + @Test + public void onlyExplicitTransientStudioEnginesBypassHistory() throws IOException { + String manager = source("ModdedDimensionManager.java"); + String transientCreate = method(manager, "public static Handle createTransientStudio("); + String persistentCreate = method(manager, "public static Handle createPersistent("); + String engines = source("ModdedWorldEngines.java"); + String studio = method(engines, "private static IrisEngine createTransientStudioEngine("); + String generator = source("IrisModdedChunkGenerator.java"); + String bypass = method(generator, "boolean allowsGenerationHistoryBypass("); + String route = method(generator, "private GenerationHistoryRuntimeRouter.RuntimeRoute openHistoryRoute("); + + assertTrue(transientCreate.contains("ModdedGenerationMode.TRANSIENT_STUDIO")); + assertTrue(persistentCreate.contains("ModdedGenerationMode.PERSISTENT_CREATE")); + assertTrue(studio.contains("IrisData.openRuntime(packDir)")); + assertTrue(studio.contains("IrisEngine.InitializationMode.STUDIO")); + assertFalse(studio.contains("ModdedGenerationHistoryStorage")); + assertFalse(studio.contains("attachGenerationHistory(")); + assertTrue(bypass.contains("generationMode != ModdedGenerationMode.TRANSIENT_STUDIO")); + assertTrue(bypass.contains("irisEngine.isStudio()")); + assertTrue(bypass.contains("Only an explicitly transient Iris Studio engine")); + assertBefore(route, "allowsGenerationHistoryBypass(current)", "requireHistoryRouter(current, operation)"); + } + + @Test + public void everyCoordinateBearingChunkStageOpensItsRoutedRuntime() throws IOException { + String source = source("IrisModdedChunkGenerator.java"); + + assertRuntimeRoute(source, "public Pair> findNearestMapStructure("); + assertCoordinateRoute(source, "public WeightedList getMobsAt("); + assertRuntimeRoute(source, "public CompletableFuture createBiomes("); + assertRuntimeRoute(source, "public void applyCarvers("); + assertRuntimeRoute(source, "public void buildSurface("); + assertRuntimeRoute(source, "public void applyBiomeDecoration("); + assertRuntimeRoute(source, "public void createStructures("); + assertRuntimeRoute(source, "public void createReferences("); + assertRuntimeRoute(source, "public void spawnOriginalMobs("); + assertCoordinateRoute(source, "public int getBaseHeight("); + assertCoordinateRoute(source, "public NoiseColumn getBaseColumn("); + } + + @Test + public void asyncTerrainKeepsTheRouteUntilDurableSemanticClaim() throws IOException { + String source = source("IrisModdedChunkGenerator.java"); + String fill = method(source, "public CompletableFuture fillFromNoise("); + String terrain = method(source, "private ChunkAccess generateTerrain("); + String completion = method(source, "private static CompletableFuture closeRouteOnCompletion("); + + assertBefore(fill, "openHistoryRoute(", "CompletableFuture.supplyAsync("); + assertTrue(fill.contains("generateTerrain(chunk, generationEngine, pos, air, route)")); + assertTrue(fill.contains("return closeRouteOnCompletion(pipeline, route);")); + assertTrue(terrain.contains("openHistoryRuntimeScope(route)")); + assertBefore(terrain, "generationEngine.generate(", "route.claimGeneratedSemantics();"); + assertBefore(terrain, "route.claimGeneratedSemantics();", "return chunk;"); + assertTrue(completion.contains("pipeline.whenComplete(")); + assertTrue(completion.contains("route.close();")); + } + + @Test + public void biomeQueriesRouteEachCoordinateAndKeepRuntimeSpecificCaches() throws IOException { + String source = source("IrisModdedBiomeSource.java"); + String structure = method(source, "public Holder getNoiseBiome("); + String visible = method(source, "Holder getVisibleNoiseBiome("); + String batch = method(source, "public Set> getBiomesWithin("); + String resolution = method(source, "private Holder resolveVisibleBiome("); + + assertTrue(structure.contains("openHistoryCoordinateScope(")); + assertTrue(visible.contains("openHistoryCoordinateScope(")); + assertTrue(batch.contains("quartX << 2, quartZ << 2")); + assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();")); + assertTrue(source.contains("cache.get(runtimeIdentity, key)")); + assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)")); + assertBefore(resolution, "historicalPhysicalBiomeKeyAt(", "ModdedWorldgenIds.biomeRef(engine"); + } + + @Test + public void discreteStagesResumeIncompleteHistoricalChunksAndRejectTransitionOwnership() throws IOException { + String generator = source("IrisModdedChunkGenerator.java"); + String discrete = method(generator, "private boolean allowsRoutedDiscreteGeneration("); + String decoration = method(generator, "public void applyBiomeDecoration("); + String structures = method(generator, "public void createStructures("); + String mobs = method(generator, "public void spawnOriginalMobs("); + String nativeStage = source("ModdedNativeStructureStage.java"); + String adjust = method(nativeStage, "void adjustGeneratedStructures("); + String place = method(nativeStage, "void placeVanillaStructures("); + + assertTrue(discrete.contains("chunk.getPersistedStatus().isOrAfter(stage)")); + assertFalse(discrete.contains("router.history().activeActivation().activationId()")); + assertFalse(discrete.contains("return true;")); + assertTrue(discrete.contains("allowsNewGenerationChunk(")); + assertTrue(decoration.contains("ChunkStatus.FEATURES")); + assertTrue(structures.contains("ChunkStatus.STRUCTURE_STARTS")); + assertTrue(mobs.contains("ChunkStatus.SPAWN")); + assertBefore(decoration, "allowsRoutedDiscreteGeneration(", "nativeStructures.placeVanillaStructures("); + assertBefore(structures, "allowsRoutedDiscreteGeneration(", "super.createStructures("); + assertBefore(mobs, "allowsRoutedDiscreteGeneration(", "NaturalSpawner.spawnMobsForChunkGeneration("); + assertBefore(adjust, "allowsNewGenerationFootprint(", "recordWorldCheckStructureShift("); + assertTrue(place.contains("allowsNewGenerationFootprint(")); + } + + private static void assertRuntimeRoute(String source, String signature) { + String body = method(source, signature); + assertTrue(signature + " must open a generation-history route", body.contains("openHistoryRoute(")); + assertTrue(signature + " must open its routed runtime", body.contains("openHistoryRuntimeScope(route)")); + } + + private static void assertCoordinateRoute(String source, String signature) { + String body = method(source, signature); + assertTrue(signature + " must route its coordinate", body.contains("openHistoryCoordinateScope(")); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static String source(String fileName) throws IOException { + String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY); + assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, + sourceRoot != null && !sourceRoot.isBlank()); + return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName)); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java new file mode 100644 index 000000000..6ac1849d3 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java @@ -0,0 +1,362 @@ +package art.arcane.iris.modded; + +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Comparator; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ModdedGenerationHistoryStorageTest { + private static final String DIMENSION_FINGERPRINT = "11".repeat(32); + private static final String DEFINITION_FINGERPRINT = "22".repeat(32); + private static final int TRANSITION_WIDTH_BLOCKS = 256; + + @Test + public void newDimensionPublishesAnImmutableWorldLocalPack() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-create"); + try { + Path source = pack(root.resolve("global-pack"), "first"); + ModdedGenerationHistoryStorage.Candidate candidate = candidate(source, registryContract()); + GenerationHistory history = ModdedGenerationHistoryStorage.createOrStage( + root.resolve("world-dimension"), + source, + 41L, + candidate, + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + + Path activePack = history.activePackRoot(); + assertEquals("first", Files.readString(activePack.resolve("content.txt"), StandardCharsets.UTF_8)); + Files.writeString(source.resolve("content.txt"), "mutated", StandardCharsets.UTF_8); + assertEquals("first", Files.readString(activePack.resolve("content.txt"), StandardCharsets.UTF_8)); + assertEquals(candidate.packFingerprint(), history.activeEpoch().packFingerprint()); + } finally { + deleteTree(root); + } + } + + @Test + public void existingDimensionAdoptsItsSelectedPackOnlyOnce() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-adopt"); + try { + Path source = pack(root.resolve("global-pack"), "adopted"); + Path dimensionRoot = root.resolve("world-dimension"); + AtomicInteger sourceReads = new AtomicInteger(); + ModdedGenerationHistoryStorage.AdoptionSourceSupplier supplier = () -> { + sourceReads.incrementAndGet(); + return new ModdedGenerationHistoryStorage.AdoptionSource( + source, + candidate(source, registryContract()) + ); + }; + + GenerationHistory adopted = ModdedGenerationHistoryStorage.restoreOrAdopt( + dimensionRoot, + 91L, + supplier, + ModdedGenerationHistoryStorageTest::union + ); + String epochId = adopted.activeEpoch().epochId(); + Files.writeString(source.resolve("content.txt"), "changed", StandardCharsets.UTF_8); + GenerationHistory restored = ModdedGenerationHistoryStorage.restoreOrAdopt( + dimensionRoot, + 91L, + supplier, + ModdedGenerationHistoryStorageTest::union + ); + + assertEquals(1, sourceReads.get()); + assertEquals(epochId, restored.activeEpoch().epochId()); + assertEquals("adopted", Files.readString( + restored.activePackRoot().resolve("content.txt"), + StandardCharsets.UTF_8 + )); + } finally { + deleteTree(root); + } + } + + @Test + public void selectedUpdateStagesWithoutReplacingTheActivePack() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-stage"); + try { + Path dimensionRoot = root.resolve("world-dimension"); + Path first = pack(root.resolve("first-pack"), "first"); + GenerationHistory created = ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + first, + 5L, + candidate(first, registryContract()), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + String activeEpoch = created.activeEpoch().epochId(); + Path second = pack(root.resolve("second-pack"), "second"); + GenerationHistory staged = ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + second, + 5L, + candidate(second, registryContract()), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + + assertEquals(activeEpoch, staged.activeEpoch().epochId()); + assertTrue(staged.pendingActivation().isPresent()); + assertNotEquals(activeEpoch, staged.pendingActivation().orElseThrow().epochId()); + assertEquals("first", Files.readString( + staged.activePackRoot().resolve("content.txt"), + StandardCharsets.UTF_8 + )); + assertEquals("second", Files.readString( + staged.packRoot(staged.pendingActivation().orElseThrow().activationId()).resolve("content.txt"), + StandardCharsets.UTF_8 + )); + } finally { + deleteTree(root); + } + } + + @Test + public void unavailableUpdateRegistryDoesNotCreatePendingActivation() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-stage-registry"); + try { + Path dimensionRoot = root.resolve("world-dimension"); + Path first = pack(root.resolve("first-pack"), "first"); + GenerationHistory history = ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + first, + 63L, + candidate(first, registryContract()), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + Path second = pack(root.resolve("second-pack"), "second"); + + assertThrows(IOException.class, () -> ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + second, + 63L, + candidate(second, registryContract()), + contracts -> GenerationRegistryContract.empty(), + TRANSITION_WIDTH_BLOCKS + )); + + assertTrue(history.pendingActivation().isEmpty()); + assertEquals("first", Files.readString( + history.activePackRoot().resolve("content.txt"), + StandardCharsets.UTF_8 + )); + } finally { + deleteTree(root); + } + } + + @Test + public void updateWithANewCustomBiomeKeyStagesBeforeThatKeyIsRegistered() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-new-biome"); + try { + Path dimensionRoot = root.resolve("world-dimension"); + Path first = pack(root.resolve("first-pack"), "first"); + GenerationRegistryContract activeContract = registryContract("minecraft:plains"); + ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + first, + 71L, + candidate(first, activeContract), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + Path second = pack(root.resolve("second-pack"), "second"); + GenerationRegistryContract candidateContract = registryContract( + "iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + AtomicInteger validationCalls = new AtomicInteger(); + + GenerationHistory staged = ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + second, + 71L, + candidate(second, candidateContract), + contracts -> { + validationCalls.incrementAndGet(); + assertEquals(1, contracts.size()); + assertEquals(activeContract, contracts.iterator().next()); + return union(contracts); + }, + TRANSITION_WIDTH_BLOCKS + ); + + assertEquals(1, validationCalls.get()); + assertTrue(staged.pendingActivation().isPresent()); + assertEquals( + candidateContract, + staged.manifest() + .epoch(staged.pendingActivation().orElseThrow().epochId()) + .orElseThrow() + .registryContract() + ); + } finally { + deleteTree(root); + } + } + + @Test + public void restoreRejectsASeedMismatchBeforeConsultingTheGlobalPack() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-seed"); + try { + Path source = pack(root.resolve("global-pack"), "seeded"); + Path dimensionRoot = root.resolve("world-dimension"); + ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + source, + 123L, + candidate(source, registryContract()), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + AtomicInteger sourceReads = new AtomicInteger(); + + assertThrows(IOException.class, () -> ModdedGenerationHistoryStorage.restoreOrAdopt( + dimensionRoot, + 124L, + () -> { + sourceReads.incrementAndGet(); + return new ModdedGenerationHistoryStorage.AdoptionSource( + source, + candidate(source, registryContract()) + ); + }, + ModdedGenerationHistoryStorageTest::union + )); + assertEquals(0, sourceReads.get()); + } finally { + deleteTree(root); + } + } + + @Test + public void restoreRejectsUnavailableRetainedRegistryDefinitions() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-registry"); + try { + Path source = pack(root.resolve("global-pack"), "registry"); + Path dimensionRoot = root.resolve("world-dimension"); + ModdedGenerationHistoryStorage.createOrStage( + dimensionRoot, + source, + 12L, + candidate(source, registryContract()), + ModdedGenerationHistoryStorageTest::union, + TRANSITION_WIDTH_BLOCKS + ); + + assertThrows(IOException.class, () -> ModdedGenerationHistoryStorage.restoreOrAdopt( + dimensionRoot, + 12L, + () -> { + throw new AssertionError("Existing history consulted the global pack"); + }, + contracts -> GenerationRegistryContract.empty() + )); + } finally { + deleteTree(root); + } + } + + private static ModdedGenerationHistoryStorage.Candidate candidate( + Path source, + GenerationRegistryContract registryContract + ) throws IOException { + return new ModdedGenerationHistoryStorage.Candidate( + GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), + dimensionContract(), + registryContract + ); + } + + private static GenerationEpoch.DimensionContract dimensionContract() { + return new GenerationEpoch.DimensionContract( + "surface", + "irisworldgen:packs/7061636b/dimensions/73757266616365/dimension_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + DIMENSION_FINGERPRINT + ); + } + + private static GenerationRegistryContract registryContract() { + return registryContract("minecraft:stone"); + } + + private static GenerationRegistryContract registryContract(String resourceKey) { + return GenerationRegistryContract.fromDefinitions(Map.of( + new GenerationRegistryContract.PhysicalResourceKey( + resourceKey.startsWith("iris:biomes/") + ? "minecraft:worldgen/biome" + : "minecraft:block", + resourceKey + ), + DEFINITION_FINGERPRINT + )); + } + + private static GenerationRegistryContract union(Collection contracts) + throws IOException { + TreeMap definitions = new TreeMap<>(); + for (GenerationRegistryContract contract : contracts) { + for (Map.Entry entry + : contract.definitions().entrySet()) { + String previous = definitions.putIfAbsent(entry.getKey(), entry.getValue()); + if (previous != null && !previous.equals(entry.getValue())) { + throw new IOException("Conflicting registry definition"); + } + } + } + return GenerationRegistryContract.fromDefinitions(definitions); + } + + private static Path pack(Path root, String content) throws IOException { + Files.createDirectories(root); + Files.writeString(root.resolve("content.txt"), content, StandardCharsets.UTF_8); + return root; + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream walk = Files.walk(root)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java index da472a744..6bb7d329a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -26,7 +26,9 @@ public class ModdedGenerationLeaseContractTest { assertTrue(source.contains("catch (GenerationSessionException e)")); assertTrue(source.contains("e.isExpectedTeardown()")); assertTrue(source.contains("Iris structure biome key lookup was rejected during an engine transition")); - assertTrue(source.contains("source.clearCache()")); + assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();")); + assertTrue(source.contains("cache.get(runtimeIdentity, key)")); + assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)")); } @Test @@ -203,7 +205,7 @@ public class ModdedGenerationLeaseContractTest { String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); String start = method(jobSource, "public static boolean start("); int taskConstruction = start.indexOf("PregenTask task = PregenTask.builder()"); - int profileMutation = start.indexOf("PregenPerformanceProfile.apply(engine);"); + int profileMutation = start.indexOf("PregenPerformanceProfile.apply(engine)"); assertTrue(taskConstruction >= 0); assertTrue(profileMutation > taskConstruction); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java index a7060b573..9aee5e796 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java @@ -14,15 +14,16 @@ public class ModdedLifecycleFailureContractTest { private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; @Test - public void existingDimensionRepointBindsBeforeHandlePublication() throws IOException { + public void existingDimensionStagesWithoutReplacingTheLiveEngine() throws IOException { String managerSource = source("ModdedDimensionManager.java"); - String create = method(managerSource, "public static Handle create("); + String create = method(managerSource, "private static Handle create("); int existingStart = requiredIndex(create, "if (existing != null"); int presentStart = requiredIndex(create, "if (serverAccess.hasLevel(server, key))", existingStart + 1); int injectionStart = requiredIndex(create, "try {", presentStart); - assertRepointBeforePublication(create.substring(existingStart, presentStart)); - assertRepointBeforePublication(create.substring(presentStart, injectionStart)); + assertFalse(create.substring(existingStart, presentStart).contains("repointAndBind(")); + assertFalse(create.substring(presentStart, injectionStart).contains("repointAndBind(")); + assertBefore(create, "ModdedGenerationHistoryStorage.createOrStage(", "if (existing != null"); String generatorSource = source("IrisModdedChunkGenerator.java"); String repointAndBind = method(generatorSource, "void repointAndBind("); @@ -40,6 +41,31 @@ public class ModdedLifecycleFailureContractTest { assertFalse(installReplacement.contains("catch (")); } + @Test + public void persistentPackUpdatesUseAnExplicitRestartBoundPath() throws IOException { + String managerSource = source("ModdedDimensionManager.java"); + String createPersistent = method(managerSource, "public static Handle createPersistent("); + String update = method(managerSource, "public static UpdateResult stagePersistentUpdate("); + + assertBefore(createPersistent, "if (previous != null)", "ModdedDimensionRegistryStore.put("); + assertTrue(createPersistent.contains("Use the Iris world update command")); + assertTrue(update.contains("ModdedGenerationHistoryStorage.createOrStage(")); + assertTrue(update.contains("active.history().pendingActivation()")); + assertTrue(update.contains("restartRequired()")); + assertFalse(update.contains("inject(")); + assertFalse(update.contains("repointAndBind(")); + assertBefore(update, "ModdedDimensionRegistryStore.put(server, updated);", + "ModdedGenerationHistoryStorage.createOrStage("); + assertTrue(update.contains("ModdedDimensionRegistryStore.put(server, previous);")); + + String commandsSource = source("command/ModdedWorldCommands.java"); + assertTrue(commandsSource.contains("root.then(updateTree());")); + String command = method(commandsSource, "private static int update("); + assertTrue(command.contains("ModdedDimensionManager.stagePersistentUpdate(")); + assertTrue(command.contains("Restart the server to activate it")); + assertTrue(command.contains("running world remains on its current pack")); + } + @Test public void injectionBindsBeforeRegistrationAndRollsBackEveryFailure() throws IOException { String source = source("ModdedDimensionManager.java"); @@ -110,18 +136,13 @@ public class ModdedLifecycleFailureContractTest { } @Test - public void packDimensionLoadingNeverSuppressesTheFailureAsNull() throws IOException { - String source = source("ModdedDimensionManager.java"); - String loading = method(source, "private static IrisDimension loadPackDimension("); + public void persistentRestoreOpensHistoryBeforeConsultingTheGlobalPack() throws IOException { + String source = source("ModdedGenerationHistoryStorage.java"); + String restore = method(source, "static GenerationHistory restoreOrAdopt("); - assertFalse(loading.contains("return null;")); - assertFalse(loading.contains("e.toString()")); - int catchIndex = loading.indexOf("catch ("); - if (catchIndex >= 0) { - String failure = blockAt(loading, requiredIndex(loading, "{", catchIndex)); - assertTrue(failure.contains("throw ")); - assertTrue(failure.contains(", e);") || failure.contains("throw e;")); - } + assertBefore(restore, "GenerationHistory.openIfPresent(", "sourceSupplier\").get();"); + assertTrue(restore.contains("if (existing.isPresent())")); + assertBefore(restore, "requireRegistryDefinitions(existing.get()", "return existing.get();"); } @Test @@ -249,12 +270,6 @@ public class ModdedLifecycleFailureContractTest { assertBefore(failure, "rollback.restore(failure);", "throw "); } - private static void assertRepointBeforePublication(String branch) { - assertBefore(branch, "repointAndBind(", "new Handle("); - assertBefore(branch, "repointAndBind(", "HANDLES.put("); - assertBefore(branch, "repointAndBind(", "return "); - } - private static void assertBefore(String source, String first, String second) { int firstIndex = requiredIndex(source, first); int secondIndex = requiredIndex(source, second); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedRiverLocateContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedRiverLocateContractTest.java new file mode 100644 index 000000000..120328f1f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedRiverLocateContractTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.modded.command; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class ModdedRiverLocateContractTest { + @Test + public void riverLookupMergesRecordedAndEligibleActiveFeatures() throws IOException { + Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources")) + .resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java"); + String source = Files.readString(sourcePath); + int methodStart = source.indexOf("static int gotoRiver("); + int methodEnd = source.indexOf("static int gotoStructure(", methodStart); + String method = source.substring(methodStart, methodEnd); + + assertTrue(method.contains("GenerationSemanticQueries.nearestRiver(")); + assertTrue(method.contains("runtime == null")); + assertTrue(method.contains("feature.y() + 2")); + assertFalse(method.contains("runtime.nearestFeature(")); + assertFalse(method.contains("feature.y() + engine.getDimension().getMinHeight()")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStructureHistoryContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStructureHistoryContractTest.java new file mode 100644 index 000000000..4acb0a399 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStructureHistoryContractTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris.modded.command; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class ModdedStructureHistoryContractTest { + @Test + public void retainedStructureNamesReachTheSemanticSearchOnBothAdmissionBranches() throws IOException { + Path root = Path.of(System.getProperty("iris.moddedCommonSources")); + String source = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java")); + int start = source.indexOf("static int gotoStructure("); + int end = source.indexOf("static String registeredStructureUnavailableMessage(", start); + String admission = source.substring(start, end); + String retainedCheck = "GenerationFindCatalog.hasRetainedStructurePlacement(engine, key)"; + int firstCheck = admission.indexOf(retainedCheck); + assertTrue(firstCheck >= 0); + assertTrue(admission.indexOf(retainedCheck, firstCheck + retainedCheck.length()) >= 0); + + int locateStart = source.indexOf("private static void locateIrisStructure("); + int locateEnd = source.indexOf("private static void runNativeStructureLocate(", locateStart); + String locate = source.substring(locateStart, locateEnd); + assertTrue(locate.contains("GenerationSemanticQueries.nearestStructure(")); + assertFalse(locate.contains("IrisStructureLocator.locate(")); + + String suggestions = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedCommandSuggestions.java")); + assertTrue(suggestions.contains("GenerationFindCatalog.retainedStructureKeys(engine)")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java index cd809f183..e7047cadd 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedStudioTeleportContractTest.java @@ -21,8 +21,8 @@ public class ModdedStudioTeleportContractTest { assertTrue(open.contains("TRANSITIONS.submit(")); assertFalse(open.contains("orTimeout(")); assertFalse(open.contains("deadlineNanos")); - assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.create("); - assertBefore(execute, "ModdedDimensionManager.create(", "ModdedDimensionManager.teleportAsync("); + assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.createTransientStudio("); + assertBefore(execute, "ModdedDimensionManager.createTransientStudio(", "ModdedDimensionManager.teleportAsync("); assertFalse(execute.contains("deadlineNanos")); assertFalse(source.contains("player.teleportTo(studio")); } diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index c721e1c16..b68018daa 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -26,6 +26,8 @@ plugins { alias(libs.plugins.shadow) } +apply from: file('../../gradle/modded-generation-kernel-seals.gradle') + Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) @@ -132,6 +134,7 @@ dependencies { "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), libs.paralithic, + libs.asm, libs.lru, libs.caffeine, libs.dom4j, diff --git a/build.gradle b/build.gradle index db68c4a91..ff1c3fa8f 100644 --- a/build.gradle +++ b/build.gradle @@ -426,6 +426,8 @@ tasks.named('neoforgeJar').configure { } List commonModdedArtifactEntries = [ + 'META-INF/iris/generation-kernels/catalog.tsv', + 'META-INF/iris/generation-kernels/abi-1.seal', 'art/arcane/iris/engine/IrisEngine.class', 'art/arcane/iris/spi/IrisPlatform.class', 'art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class', @@ -530,6 +532,8 @@ tasks.register('verifyModdedArtifacts') { // the two plugin descriptors and the slimjar manifests. Nothing in the reference graph points at // them, so a shadowJar exclude that drops one is silent until a server hits that path. List requiredBukkitArtifactEntries = [ + 'META-INF/iris/generation-kernels/catalog.tsv', + 'META-INF/iris/generation-kernels/abi-1.seal', 'plugin.yml', 'paper-plugin.yml', 'slimjar.dat', diff --git a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationKernelSeal.java b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationKernelSeal.java new file mode 100644 index 000000000..8880d003c --- /dev/null +++ b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationKernelSeal.java @@ -0,0 +1,463 @@ +package art.arcane.iris.buildtools; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Pattern; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public final class GenerationKernelSeal { + public static final String FORMAT = "iris-generation-kernel-seal-v1"; + private static final Pattern HASH = Pattern.compile("[0-9a-f]{64}"); + private static final long MAXIMUM_SEAL_BYTES = 8L * 1024L * 1024L; + private static final int MAXIMUM_ENTRIES = 30_000; + + private GenerationKernelSeal() { + } + + public static void main(String[] arguments) throws IOException { + if (arguments.length < 4 || arguments.length % 2 != 0 || !arguments[0].equals("verify-dependencies")) { + throw new IllegalArgumentException("Expected verify-dependencies pairs."); + } + Map dependencies = new TreeMap<>(); + for (int index = 2; index < arguments.length; index += 2) { + if (dependencies.putIfAbsent(arguments[index], Path.of(arguments[index + 1])) != null) { + throw new IllegalArgumentException("Duplicate resolved generation dependency."); + } + } + verifyDependencySubset(Path.of(arguments[1]), dependencies); + } + + public static void verifyDependencySubset(Path seal, Map dependencies) throws IOException { + SourceManifest expected = read(seal); + for (Map.Entry dependency : dependencyHashes(dependencies).entrySet()) { + if (!Objects.equals(expected.dependencies().get(dependency.getKey()), dependency.getValue())) { + throw new IOException("Resolved platform dependency does not match generation ABI " + expected.abi() + + ": " + dependency.getKey()); + } + } + } + + public static void verifyCatalog(Path catalog, Map manifests) throws IOException { + if (!Files.isRegularFile(catalog) || Files.size(catalog) > 16 * 1024) { + throw new IOException("Missing or oversized generation kernel catalog."); + } + String source = Files.readString(catalog, StandardCharsets.UTF_8); + String[] lines = source.split("\n", -1); + if (!source.endsWith("\n") || source.indexOf('\r') >= 0 || lines.length < 4 || lines.length > 128 + || !lines[0].equals("iris-generation-kernel-catalog-v1")) { + throw new IOException("Invalid generation kernel catalog."); + } + try { + String[] current = lines[1].split("\t", -1); + if (current.length != 4 || !current[0].equals("current")) { + throw new IllegalArgumentException("Missing current generation version."); + } + int currentAbi = positive(current[1]); + AlgorithmVersion algorithms = new AlgorithmVersion(positive(current[2]), positive(current[3])); + SourceManifest selected = manifests.get(currentAbi); + if (selected == null || !selected.algorithms().contains(algorithms)) { + throw new IllegalArgumentException("Current generation version has no matching sealed factory."); + } + TreeSet registered = new TreeSet<>(); + int previous = 0; + for (int index = 2; index < lines.length - 1; index++) { + String[] kernel = lines[index].split("\t", -1); + if (kernel.length != 2 || !kernel[0].equals("kernel")) { + throw new IllegalArgumentException("Invalid kernel registration."); + } + int abi = positive(kernel[1]); + if (abi <= previous) { + throw new IllegalArgumentException("Kernel registrations must be unique and sorted."); + } + previous = abi; + registered.add(abi); + } + if (!registered.equals(manifests.keySet())) { + throw new IllegalArgumentException("Kernel catalog must register exactly the retained sealed kernels."); + } + } catch (IllegalArgumentException failure) { + throw new IOException("Invalid generation kernel catalog.", failure); + } + } + + private static int positive(String value) { + if (!value.matches("[1-9][0-9]{0,8}")) { + throw new IllegalArgumentException("Invalid generation version."); + } + return Integer.parseInt(value); + } + + public static SourceManifest capture(CaptureOptions options, Map dependencies) throws IOException { + CaptureOptions required = Objects.requireNonNull(options, "options"); + Map sourceHashes = sources(required.repository(), required.roots(), required.exclusions()); + if (sourceHashes.isEmpty()) { + throw new IOException("Generation kernel source scope is empty."); + } + return new SourceManifest(required.abi(), required.factoryClass(), required.algorithms(), required.roots(), required.exclusions(), + sourceHashes, dependencyHashes(dependencies)); + } + + public static void verify(Path repository, Path seal, Map dependencies) throws IOException { + SourceManifest expected = read(seal); + SourceManifest actual = capture(new CaptureOptions(repository, expected.abi(), expected.factoryClass(), expected.algorithms(), + expected.roots(), expected.exclusions()), dependencies); + List failures = new ArrayList<>(); + differences("source", expected.sources(), actual.sources(), failures); + differences("dependency", expected.dependencies(), actual.dependencies(), failures); + if (!failures.isEmpty()) { + throw new IOException("Generation ABI " + expected.abi() + " no longer matches its sealed implementation:\n" + + String.join("\n", failures) + + "\nRetain this kernel's implementation and add a separately versioned kernel for changed generation."); + } + } + + public static void writeNew(Path destination, SourceManifest manifest) throws IOException { + Files.createDirectories(destination.toAbsolutePath().getParent()); + Files.writeString(destination, encode(manifest), StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + } + + public static SourceManifest read(Path path) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) > MAXIMUM_SEAL_BYTES) { + throw new IOException("Missing or oversized generation kernel seal: " + path); + } + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + if (lines.size() < 4 || !FORMAT.equals(lines.getFirst()) || lines.size() > MAXIMUM_ENTRIES) { + throw new IOException("Invalid generation kernel seal: " + path); + } + int abi = 0; + String factory = null; + List roots = new ArrayList<>(); + List algorithms = new ArrayList<>(); + List exclusions = new ArrayList<>(); + Map sources = new TreeMap<>(); + Map dependencies = new TreeMap<>(); + try { + for (int index = 1; index < lines.size(); index++) { + String[] entry = lines.get(index).split("\t", -1); + if (entry.length < 2 || entry.length > 3) { + throw new IllegalArgumentException("Invalid seal entry."); + } + switch (entry[0]) { + case "abi" -> { + if (abi != 0 || entry.length != 2) { + throw new IllegalArgumentException("Duplicate or invalid ABI."); + } + abi = Integer.parseInt(entry[1]); + } + case "factory" -> { + if (factory != null || entry.length != 2) { + throw new IllegalArgumentException("Duplicate or invalid factory."); + } + factory = entry[1]; + } + case "scope" -> addPath(roots, entry); + case "algorithm" -> { + if (entry.length != 3) { + throw new IllegalArgumentException("Invalid algorithm version."); + } + algorithms.add(new AlgorithmVersion(Integer.parseInt(entry[1]), Integer.parseInt(entry[2]))); + } + case "exclude" -> addPath(exclusions, entry); + case "source" -> addHash(sources, entry, true); + case "dependency" -> addHash(dependencies, entry, false); + default -> throw new IllegalArgumentException("Unknown generation kernel seal entry."); + } + } + SourceManifest manifest = new SourceManifest(abi, factory, algorithms, roots, exclusions, sources, dependencies); + if (!Files.readString(path, StandardCharsets.UTF_8).equals(encode(manifest))) { + throw new IllegalArgumentException("Generation kernel seal is not canonical."); + } + return manifest; + } catch (RuntimeException failure) { + throw new IOException("Invalid generation kernel seal: " + path, failure); + } + } + + public static String fingerprint(SourceManifest manifest) { + return hash(encode(manifest).getBytes(StandardCharsets.UTF_8)); + } + + public static String encode(SourceManifest manifest) { + StringBuilder text = new StringBuilder(FORMAT).append('\n'); + text.append("abi\t").append(manifest.abi()).append('\n'); + text.append("factory\t").append(manifest.factoryClass()).append('\n'); + for (AlgorithmVersion algorithm : manifest.algorithms()) { + text.append("algorithm\t").append(algorithm.rng()).append('\t').append(algorithm.seed()).append('\n'); + } + for (String scope : manifest.roots()) { + text.append("scope\t").append(scope).append('\n'); + } + for (String exclusion : manifest.exclusions()) { + text.append("exclude\t").append(exclusion).append('\n'); + } + appendHashes(text, "source", manifest.sources()); + appendHashes(text, "dependency", manifest.dependencies()); + return text.toString(); + } + + public static String artifactHash(Path artifact) throws IOException { + if (!Files.isRegularFile(artifact)) { + throw new IOException("Missing generation dependency artifact: " + artifact); + } + MessageDigest digest = digest(); + try (ZipFile zip = new ZipFile(artifact.toFile())) { + hashRuntimeManifest(zip, digest); + Map entries = new TreeMap<>(); + for (ZipEntry entry : zip.stream().toList()) { + String name = entry.getName(); + if (entry.isDirectory() || metadata(name)) { + continue; + } + if (entries.putIfAbsent(name, entry) != null) { + throw new IOException("Duplicate dependency artifact entry: " + name); + } + } + for (Map.Entry entry : entries.entrySet()) { + update(digest, entry.getKey()); + try (InputStream input = zip.getInputStream(entry.getValue())) { + MessageDigest content = digest(); + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + content.update(buffer, 0, read); + } + update(digest, HexFormat.of().formatHex(content.digest())); + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static void hashRuntimeManifest(ZipFile zip, MessageDigest digest) throws IOException { + ZipEntry entry = zip.getEntry("META-INF/MANIFEST.MF"); + if (entry == null) { + return; + } + Manifest manifest; + try (InputStream input = zip.getInputStream(entry)) { + manifest = new Manifest(input); + } + List runtimeAttributes = List.of("Multi-Release", "Automatic-Module-Name", "Class-Path", "Sealed", + "Agent-Class", "Premain-Class", "Launcher-Agent-Class", "Boot-Class-Path", + "Can-Redefine-Classes", "Can-Retransform-Classes", "Can-Set-Native-Method-Prefix"); + for (String name : runtimeAttributes) { + String value = manifest.getMainAttributes().getValue(name); + if (value != null) { + update(digest, "manifest/" + name); + update(digest, value); + } + } + for (Map.Entry section : new TreeMap<>(manifest.getEntries()).entrySet()) { + String sealed = section.getValue().getValue("Sealed"); + if (sealed != null) { + update(digest, "manifest/section/" + section.getKey()); + update(digest, sealed); + } + } + } + + private static Map sources(Path repository, List roots, List exclusions) + throws IOException { + Path base = repository.toAbsolutePath().normalize(); + Map hashes = new TreeMap<>(); + for (String root : roots) { + Path sourceRoot = base.resolve(root).normalize(); + if (!sourceRoot.startsWith(base) || !Files.exists(sourceRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Missing generation source scope: " + root); + } + try (Stream walk = Files.walk(sourceRoot)) { + for (Path source : walk.toList()) { + String relative = base.relativize(source).toString().replace('\\', '/'); + if (excluded(relative, exclusions)) { + continue; + } + if (Files.isSymbolicLink(source)) { + throw new IOException("Generation source must not be a symbolic link: " + relative); + } + if (Files.isRegularFile(source) && (source.equals(sourceRoot) + || source.getFileName().toString().endsWith(".java"))) { + String content = Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); + hashes.put(relative, hash(content.getBytes(StandardCharsets.UTF_8))); + } + } + } + } + return hashes; + } + + private static Map dependencyHashes(Map dependencies) throws IOException { + Map hashes = new TreeMap<>(); + for (Map.Entry dependency : Objects.requireNonNull(dependencies, "dependencies").entrySet()) { + String key = token(dependency.getKey()); + hashes.put(key, artifactHash(dependency.getValue())); + } + if (hashes.isEmpty()) { + throw new IOException("Generation kernel dependency scope is empty."); + } + return hashes; + } + + private static void differences(String type, Map expected, Map actual, + List failures) { + TreeSet keys = new TreeSet<>(expected.keySet()); + keys.addAll(actual.keySet()); + for (String key : keys) { + if (!Objects.equals(expected.get(key), actual.get(key))) { + String change = !expected.containsKey(key) ? "added" : !actual.containsKey(key) ? "deleted" : "changed"; + failures.add(type + " " + change + ": " + key); + } + } + } + + private static boolean excluded(String path, List exclusions) { + for (String exclusion : exclusions) { + if (path.equals(exclusion) || path.startsWith(exclusion + "/")) { + return true; + } + } + return false; + } + + private static boolean metadata(String name) { + return name.equals("META-INF/MANIFEST.MF") || name.startsWith("META-INF/maven/") + || name.startsWith("META-INF/") && (name.endsWith(".SF") || name.endsWith(".RSA") || name.endsWith(".DSA")); + } + + private static void addPath(List paths, String[] entry) { + if (entry.length != 2 || paths.contains(entry[1])) { + throw new IllegalArgumentException("Duplicate or invalid source scope."); + } + paths.add(relative(entry[1])); + } + + private static void addHash(Map hashes, String[] entry, boolean path) { + if (entry.length != 3 || !HASH.matcher(entry[2]).matches()) { + throw new IllegalArgumentException("Invalid sealed hash."); + } + String key = path ? relative(entry[1]) : token(entry[1]); + if (hashes.putIfAbsent(key, entry[2]) != null) { + throw new IllegalArgumentException("Duplicate sealed entry."); + } + } + + private static void appendHashes(StringBuilder text, String type, Map hashes) { + for (Map.Entry entry : new TreeMap<>(hashes).entrySet()) { + text.append(type).append('\t').append(entry.getKey()).append('\t').append(entry.getValue()).append('\n'); + } + } + + private static String relative(String value) { + String path = token(value); + if (path.startsWith("/") || path.contains("\\") || path.contains(":") + || !Path.of(path).normalize().toString().replace('\\', '/').equals(path) + || path.equals("..") || path.startsWith("../")) { + throw new IllegalArgumentException("Invalid generation source path: " + path); + } + return path; + } + + private static String token(String value) { + String required = Objects.requireNonNull(value, "seal token"); + if (required.isBlank() || required.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Invalid generation seal token."); + } + return required; + } + + private static List paths(Collection values) { + TreeSet sorted = new TreeSet<>(); + for (String value : values) { + if (!sorted.add(relative(value))) { + throw new IllegalArgumentException("Duplicate generation source scope."); + } + } + return List.copyOf(sorted); + } + + private static String hash(byte[] bytes) { + return HexFormat.of().formatHex(digest().digest(bytes)); + } + + private static MessageDigest digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException(failure); + } + } + + private static void update(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) '\n'); + } + + public record CaptureOptions(Path repository, int abi, String factoryClass, List algorithms, List roots, + List exclusions) { + public CaptureOptions { + Objects.requireNonNull(repository, "repository"); + if (abi < 1) { + throw new IllegalArgumentException("Generation ABI must be positive."); + } + factoryClass = token(factoryClass); + TreeSet sortedAlgorithms = new TreeSet<>(algorithms); + if (sortedAlgorithms.isEmpty() || sortedAlgorithms.size() != algorithms.size()) { + throw new IllegalArgumentException("Generation algorithm versions must be nonempty and unique."); + } + algorithms = List.copyOf(sortedAlgorithms); + roots = paths(roots); + exclusions = paths(exclusions); + if (roots.isEmpty()) { + throw new IllegalArgumentException("Generation source scope must not be empty."); + } + } + } + + public record SourceManifest(int abi, String factoryClass, List algorithms, List roots, List exclusions, + Map sources, Map dependencies) { + public SourceManifest { + CaptureOptions options = new CaptureOptions(Path.of("."), abi, factoryClass, algorithms, roots, exclusions); + algorithms = options.algorithms(); + roots = options.roots(); + exclusions = options.exclusions(); + sources = Map.copyOf(sources); + dependencies = Map.copyOf(dependencies); + if (sources.isEmpty() || dependencies.isEmpty() || sources.size() + dependencies.size() > MAXIMUM_ENTRIES) { + throw new IllegalArgumentException("Invalid generation kernel seal entry count."); + } + } + } + + public record AlgorithmVersion(int rng, int seed) implements Comparable { + public AlgorithmVersion { + if (rng < 1 || seed < 1) { + throw new IllegalArgumentException("Generation algorithm versions must be positive."); + } + } + + @Override + public int compareTo(AlgorithmVersion other) { + int byRng = Integer.compare(rng, other.rng); + return byRng == 0 ? Integer.compare(seed, other.seed) : byRng; + } + } +} diff --git a/buildSrc/src/test/java/GenerationKernelSealTest.java b/buildSrc/src/test/java/GenerationKernelSealTest.java new file mode 100644 index 000000000..f83f6ca56 --- /dev/null +++ b/buildSrc/src/test/java/GenerationKernelSealTest.java @@ -0,0 +1,192 @@ +import art.arcane.iris.buildtools.GenerationKernelSeal; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationKernelSealTest { + @Rule + public final TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void verifiesUnchangedSourceAndCanonicalDependencyContents() throws Exception { + Fixture fixture = fixture(); + GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies()); + String original = GenerationKernelSeal.artifactHash(fixture.dependency()); + artifact(fixture.dependency(), "same code", "another version", 1_700_000_000_000L); + assertEquals(original, GenerationKernelSeal.artifactHash(fixture.dependency())); + GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies()); + assertEquals(GenerationKernelSeal.fingerprint(GenerationKernelSeal.read(fixture.seal())), + GenerationKernelSeal.fingerprint(GenerationKernelSeal.capture(fixture.options(), fixture.dependencies()))); + } + + @Test + public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception { + Fixture fixture = fixture(); + Path source = fixture.root().resolve("generation/Noise.java"); + Files.writeString(source, "class Noise { int value = 2; }"); + assertTrue(assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())) + .getMessage().contains("source changed")); + Files.writeString(source, "class Noise { int value = 1; }"); + Path added = fixture.root().resolve("generation/Added.java"); + Files.writeString(added, "class Added {}"); + assertTrue(assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())) + .getMessage().contains("source added")); + Files.delete(added); + Files.delete(source); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())); + } + + @Test + public void rejectsChangedAddedAndDeletedDependencies() throws Exception { + Fixture fixture = fixture(); + String original = GenerationKernelSeal.artifactHash(fixture.dependency()); + artifact(fixture.dependency(), "changed code", "same version", 0L); + assertNotEquals(original, GenerationKernelSeal.artifactHash(fixture.dependency())); + assertTrue(assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())) + .getMessage().contains("dependency changed")); + artifact(fixture.dependency(), "same code", "same version", 0L); + assertThrows(IOException.class, () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), + Map.of("math", fixture.dependency(), "new-math", fixture.dependency()))); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), Map.of())); + } + + @Test + public void rejectsMultiReleaseManifestChanges() throws Exception { + Fixture fixture = fixture(); + String original = GenerationKernelSeal.artifactHash(fixture.dependency()); + artifact(fixture.dependency(), "same code", "same version\r\nMulti-Release: true", 0L); + assertNotEquals(original, GenerationKernelSeal.artifactHash(fixture.dependency())); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())); + } + + @Test + public void rejectsAgentEntrypointAndCapabilityChanges() throws Exception { + Fixture fixture = fixture(); + String original = GenerationKernelSeal.artifactHash(fixture.dependency()); + for (String attribute : List.of("Agent-Class", "Premain-Class", "Launcher-Agent-Class", "Boot-Class-Path", + "Can-Redefine-Classes", "Can-Retransform-Classes", "Can-Set-Native-Method-Prefix")) { + artifact(fixture.dependency(), "same code", "same version\r\n" + attribute + ": changed", 0L); + assertNotEquals(attribute, original, GenerationKernelSeal.artifactHash(fixture.dependency())); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies())); + } + } + + @Test + public void sealsExplicitAgentBuildInputsAndExecutableSources() throws Exception { + Fixture fixture = fixture(); + Path agentSource = fixture.root().resolve("agent/src/main/java/Installer.java"); + Path agentBuild = fixture.root().resolve("agent/build.gradle"); + Files.createDirectories(agentSource.getParent()); + Files.writeString(agentSource, "class Installer {}"); + Files.writeString(agentBuild, "plugins { id 'java' }"); + GenerationKernelSeal.CaptureOptions options = new GenerationKernelSeal.CaptureOptions( + fixture.root(), 1, "example.KernelV1", List.of(new GenerationKernelSeal.AlgorithmVersion(1, 1)), + List.of("generation", "agent/build.gradle", "agent/src/main/java"), List.of("generation/kernels")); + Path seal = fixture.root().resolve("agent.seal"); + GenerationKernelSeal.writeNew(seal, GenerationKernelSeal.capture(options, fixture.dependencies())); + GenerationKernelSeal.verify(fixture.root(), seal, fixture.dependencies()); + Files.writeString(agentBuild, "plugins { id 'application' }"); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), seal, fixture.dependencies())); + Files.writeString(agentBuild, "plugins { id 'java' }"); + Files.writeString(agentSource, "class Installer { boolean defers; }"); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verify(fixture.root(), seal, fixture.dependencies())); + } + + @Test + public void checksTheActualPlatformDependencySubset() throws Exception { + Fixture fixture = fixture(); + GenerationKernelSeal.verifyDependencySubset(fixture.seal(), fixture.dependencies()); + assertThrows(IOException.class, () -> GenerationKernelSeal.verifyDependencySubset(fixture.seal(), + Map.of("unknown-library", fixture.dependency()))); + artifact(fixture.dependency(), "platform override", "same version", 0L); + assertThrows(IOException.class, + () -> GenerationKernelSeal.verifyDependencySubset(fixture.seal(), fixture.dependencies())); + } + + @Test + public void catalogCannotSelectAnUnsealedAlgorithmTuple() throws Exception { + Fixture fixture = fixture(); + Path catalog = fixture.root().resolve("catalog.tsv"); + Files.writeString(catalog, "iris-generation-kernel-catalog-v1\ncurrent\t1\t1\t1\nkernel\t1\n"); + Map manifests = Map.of(1, GenerationKernelSeal.read(fixture.seal())); + GenerationKernelSeal.verifyCatalog(catalog, manifests); + Files.writeString(catalog, "iris-generation-kernel-catalog-v1\ncurrent\t1\t2\t1\nkernel\t1\n"); + assertThrows(IOException.class, () -> GenerationKernelSeal.verifyCatalog(catalog, manifests)); + Files.writeString(catalog, "iris-generation-kernel-catalog-v1\ncurrent\t1\t1\t1\nkernel\t1\nkernel\t2\n"); + assertThrows(IOException.class, () -> GenerationKernelSeal.verifyCatalog(catalog, manifests)); + } + + @Test + public void permitsSeparatelyScopedFutureKernelsWithoutChangingRetainedSeal() throws Exception { + Fixture fixture = fixture(); + Path future = fixture.root().resolve("generation/kernels/v2/NewMath.java"); + Files.createDirectories(future.getParent()); + Files.writeString(future, "class NewMath {}"); + GenerationKernelSeal.verify(fixture.root(), fixture.seal(), fixture.dependencies()); + GenerationKernelSeal.SourceManifest next = GenerationKernelSeal.capture(new GenerationKernelSeal.CaptureOptions( + fixture.root(), 2, "example.KernelV2", List.of(new GenerationKernelSeal.AlgorithmVersion(2, 3)), + List.of("generation/kernels/v2"), List.of()), fixture.dependencies()); + assertNotEquals(GenerationKernelSeal.fingerprint(next), + GenerationKernelSeal.fingerprint(GenerationKernelSeal.read(fixture.seal()))); + assertThrows(IOException.class, + () -> GenerationKernelSeal.writeNew(fixture.seal(), next)); + } + + private Fixture fixture() throws Exception { + Path root = temporary.newFolder().toPath(); + Files.createDirectories(root.resolve("generation")); + Files.writeString(root.resolve("generation/Noise.java"), "class Noise { int value = 1; }"); + Path dependency = root.resolve("math.jar"); + artifact(dependency, "same code", "same version", 0L); + GenerationKernelSeal.CaptureOptions options = new GenerationKernelSeal.CaptureOptions( + root, 1, "example.KernelV1", List.of(new GenerationKernelSeal.AlgorithmVersion(1, 1)), + List.of("generation"), List.of("generation/kernels")); + Path seal = root.resolve("abi-1.seal"); + Map dependencies = Map.of("math", dependency); + GenerationKernelSeal.writeNew(seal, GenerationKernelSeal.capture(options, dependencies)); + return new Fixture(root, seal, dependency, dependencies, options); + } + + private static void artifact(Path path, String content, String version, long timestamp) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(path))) { + ZipEntry metadata = new ZipEntry("META-INF/MANIFEST.MF"); + metadata.setTime(timestamp); + output.putNextEntry(metadata); + output.write(("Manifest-Version: 1.0\r\nImplementation-Version: " + version + "\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + ZipEntry code = new ZipEntry("example/Math.class"); + code.setTime(timestamp); + output.putNextEntry(code); + output.write(content.getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + + private record Fixture(Path root, Path seal, Path dependency, Map dependencies, + GenerationKernelSeal.CaptureOptions options) { + } +} diff --git a/core/agent/build.gradle b/core/agent/build.gradle index baff85547..5a51eb7ec 100644 --- a/core/agent/build.gradle +++ b/core/agent/build.gradle @@ -4,6 +4,14 @@ plugins { id 'java' } +repositories { + mavenCentral() +} + +dependencies { + testImplementation('junit:junit:4.13.2') +} + tasks.named('jar', Jar).configure { manifest.attributes( 'Agent-Class': 'art.arcane.iris.util.project.agent.Installer', diff --git a/core/agent/src/main/java/art/arcane/iris/util/agent/Installer.java b/core/agent/src/main/java/art/arcane/iris/util/agent/Installer.java index d88085758..2bc6ee73a 100644 --- a/core/agent/src/main/java/art/arcane/iris/util/agent/Installer.java +++ b/core/agent/src/main/java/art/arcane/iris/util/agent/Installer.java @@ -1,9 +1,41 @@ package art.arcane.iris.util.project.agent; import java.lang.instrument.Instrumentation; +import java.io.Closeable; +import java.io.IOException; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; public class Installer { private static volatile Instrumentation instrumentation; + private static final Map retainedClassLoaders = new IdentityHashMap<>(); + + public static synchronized void retainClassLoader(ClassLoader loader) { + ClassLoader requiredLoader = Objects.requireNonNull(loader, "Plugin class loader"); + if (!(requiredLoader instanceof Closeable)) { + throw new IllegalArgumentException("Plugin class loader is not closeable"); + } + retainedClassLoaders.putIfAbsent(requiredLoader, false); + } + + public static synchronized boolean deferClassLoaderClose(ClassLoader loader) { + if (!retainedClassLoaders.containsKey(loader)) { + return false; + } + retainedClassLoaders.put(loader, true); + return true; + } + + public static void releaseClassLoader(ClassLoader loader) throws IOException { + boolean closeRequested; + synchronized (Installer.class) { + closeRequested = Boolean.TRUE.equals(retainedClassLoaders.remove(loader)); + } + if (closeRequested) { + ((Closeable) loader).close(); + } + } public static Instrumentation getInstrumentation() { Instrumentation instrumentation = Installer.instrumentation; @@ -26,4 +58,4 @@ public class Installer { return; Installer.instrumentation = instrumentation; } -} \ No newline at end of file +} diff --git a/core/agent/src/test/java/art/arcane/iris/util/project/agent/ClassLoaderCloseDeferralTest.java b/core/agent/src/test/java/art/arcane/iris/util/project/agent/ClassLoaderCloseDeferralTest.java new file mode 100644 index 000000000..f57dc8399 --- /dev/null +++ b/core/agent/src/test/java/art/arcane/iris/util/project/agent/ClassLoaderCloseDeferralTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.util.project.agent; + +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ClassLoaderCloseDeferralTest { + @Test + public void unarmedPluginDisableClosesNormally() throws Exception { + TestLoader loader = new TestLoader(new URL[0]); + + loader.close(); + Installer.releaseClassLoader(loader); + + assertEquals(1, loader.closes.get()); + } + + @Test + public void holdingOnePluginDoesNotHoldAnotherEqualLoader() throws Exception { + TestLoader iris = new TestLoader(new URL[0]); + TestLoader other = new TestLoader(new URL[0]); + Installer.retainClassLoader(iris); + try { + other.close(); + iris.close(); + + assertEquals(1, other.closes.get()); + assertEquals(0, iris.closes.get()); + } finally { + Installer.releaseClassLoader(iris); + } + assertEquals(1, iris.closes.get()); + } + + @Test + public void deferredJarRemainsReadableUntilCleanupReleasesIt() throws Exception { + Path jar = Files.createTempFile("iris-loader-close", ".jar"); + try { + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new JarEntry("late-cleanup.txt")); + output.write("available".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + TestLoader loader = new TestLoader(new URL[]{jar.toUri().toURL()}); + Installer.retainClassLoader(loader); + try { + loader.close(); + loader.close(); + try (InputStream input = loader.getResourceAsStream("late-cleanup.txt")) { + assertEquals("available", new String(input.readAllBytes(), StandardCharsets.UTF_8)); + } + } finally { + Installer.releaseClassLoader(loader); + } + Installer.releaseClassLoader(loader); + assertEquals(1, loader.closes.get()); + assertNull(loader.getResourceAsStream("late-cleanup.txt")); + } finally { + Files.delete(jar); + } + } + + @Test + public void cancellingBeforePaperRequestsCloseDoesNotCloseTheLoader() throws Exception { + TestLoader loader = new TestLoader(new URL[0]); + Installer.retainClassLoader(loader); + Installer.releaseClassLoader(loader); + + assertEquals(0, loader.closes.get()); + loader.close(); + assertEquals(1, loader.closes.get()); + } + + @Test + public void concurrentReleaseClosesOnceOutsideLifecycleLock() throws Exception { + TestLoader loader = new TestLoader(new URL[0]); + loader.closeEntered = new CountDownLatch(1); + loader.finishClose = new CountDownLatch(1); + Installer.retainClassLoader(loader); + loader.close(); + AtomicReference failure = new AtomicReference<>(); + Thread first = new Thread(() -> release(loader, failure), "first-loader-release"); + first.start(); + try { + assertTrue(loader.closeEntered.await(5L, TimeUnit.SECONDS)); + Thread second = new Thread(() -> release(loader, failure), "second-loader-release"); + second.start(); + second.join(5000L); + assertFalse(second.isAlive()); + assertEquals(1, loader.closes.get()); + } finally { + loader.finishClose.countDown(); + first.join(5000L); + } + assertFalse(first.isAlive()); + assertNull(failure.get()); + } + + private static void release(TestLoader loader, AtomicReference failure) { + try { + Installer.releaseClassLoader(loader); + } catch (Throwable exception) { + failure.compareAndSet(null, exception); + } + } + + private static final class TestLoader extends URLClassLoader { + private final AtomicInteger closes = new AtomicInteger(); + private CountDownLatch closeEntered; + private CountDownLatch finishClose; + + private TestLoader(URL[] urls) { + super(urls, ClassLoader.getPlatformClassLoader()); + } + + @Override + public void close() throws IOException { + if (Installer.deferClassLoaderClose(this)) { + return; + } + assertFalse(Thread.holdsLock(Installer.class)); + closes.incrementAndGet(); + if (closeEntered != null) { + closeEntered.countDown(); + try { + if (!finishClose.await(5L, TimeUnit.SECONDS)) { + throw new IOException("Loader close was not released"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException(exception); + } + } + super.close(); + } + + @Override + public boolean equals(Object other) { + return other instanceof TestLoader; + } + + @Override + public int hashCode() { + return 1; + } + } +} diff --git a/core/build.gradle b/core/build.gradle index c21c13dc4..2ebfd5b5d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -128,6 +128,7 @@ dependencies { testImplementation('junit:junit:4.13.2') testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.paper.api) + testImplementation(libs.log4j.core) testRuntimeOnly(libs.paper.api) testImplementation(libs.multiverseCore) } @@ -520,3 +521,5 @@ tasks.register('bukkitPurityRatchet') { tasks.named('check').configure { dependsOn('bukkitPurityRatchet') } + +apply from: rootProject.file('gradle/generation-kernel-seals.gradle') diff --git a/core/generation-kernels/abi-1.seal b/core/generation-kernels/abi-1.seal new file mode 100644 index 000000000..c2aaf0db3 --- /dev/null +++ b/core/generation-kernels/abi-1.seal @@ -0,0 +1,1583 @@ +iris-generation-kernel-seal-v1 +abi 1 +factory art.arcane.iris.engine.history.GenerationKernelV1 +algorithm 1 1 +scope adapters/bukkit/nms/v26_2_R1/src/main/java +scope adapters/bukkit/plugin/src/main/java +scope adapters/client-common/src/main/java +scope adapters/fabric/src/main/java +scope adapters/forge/src/main/java +scope adapters/minecraft-common/src/main/java +scope adapters/modded-common/src/main/java +scope adapters/neoforge/src/main/java +scope core/agent/build.gradle +scope core/agent/src/main/java +scope core/src/main/java +scope lombok.config +scope spi/src/main/java +exclude core/src/main/java/art/arcane/iris/engine/kernels +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java 335a98d29fdd5a605173d1f82a278542054d92e76471fd9d6ffcf3f976010a62 +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java b1ae6b3ab4e0ed1d0292681e90a875dbbf449fef29cc7af281ec01a34100fa14 +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java 561b09521fd77ee38f5d5bab9abf87533bfdb9446da56f4e2701208db90248fc +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java 658aaa121b0baac9211c1d2f5c6ec694a03c71743ef6e620013984ce0d00321b +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java fa52fad2c99b5ce80d17e1195c7123d0c06cb6c64b2734ef4fa460044eb83cc0 +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java 85272d0fd4361eeda204ed31b5050c759ffb504b214b1a0d759aade9badc40dc +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsGenerationRegistry.java 8e7aa89be6166c40801edbe3c76bc7ff767e6a80874bdfc454c98ac7615da297 +source adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java bc9ef399119dd325e9c7256ffb9c9d070022f30af7c23c3711aeb2f736ba724b +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java cf9660620417c5a5a081c54f3ae8489ed386df08053d47eac29fde15e0cf75c8 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java fb6aa0c9b69f639dffd20a83e03760b01b6b2c23374a21e3521fd7b92c66697b +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisPluginLoader.java f544908a6ad92e6a0a3a67ff939d1efdba45fcbdd4c0fc8d7f908b60837559b8 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenPhase.java 7605129e12bdd0ac5df72c1db3a83b5f2789b9fbb22593c75b69b534ccb42d91 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenProgress.java b0a84a5276fbfaece298f62256d39df2dda0d265be2160266efeec32bd38a9df +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenerationEvent.java 9c84409db4c426c599e473e516ba74515d7ec0d3b8ea9bc3fab830ad1a7a6777 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java ce08d675a0b64c5b4af3e61442018024eb3d11510447075a0bdf2da0c835a3a8 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnQuery.java dd029894680f30e0cd65778c781073fdfc733ee21b766e70ef7994704e4783ef +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java b4097827f74fd0ad8780b5e35c1b1ad82181bd1990d7615ba49169fe62edde04 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java b6546b3a05309039f10ade65464b51122a486f7db8221b3ef76dfaf694e2f324 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java 104e7591d9779dad06fed389fa3960081d8cba22de54a5c5bb738babe5593ae9 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java 219c5ac38cf885a3e78ec5357aa8384b04af1faf8bcb95fd05470eaf4c138408 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java 95aa8ee1efb2696651c4d823c33ed782dec0a3c24b82eaf8b4fd8efc237a86e1 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisWorldInfo.java 166bf8502b2ea31e4ce854430b0ed0d743226ee6771a08ab266b657ec8ffe606 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/IrisTreeFellerService.java 29f687164a144b0a4c821e20c59e23889abd38083479f478773db3ea0a1cc57a +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerAccess.java dfb2d56b4bea6d1f076ba6f249d5921b013ada315065fdd6b55cf614737b2460 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerOptions.java 94390f221326d6809a7a77ab634ce566e013553f2517473ca7c920c7401fc88c +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/tree/TreeFellerRunHooks.java 99a0be61a7c605bebc22a94bd8ae79ff7ece2a1dcf4bbf4d0872a5e8f783d788 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldEngineEvent.java 13669ed862bf3d47b32918ab1261a21e8290b1665a4c9ee86fe7aa832350d579 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldPhase.java 1ae836e7b447ed9ba9f131a2ec1672a684e4547c6232b5da6593ad3197e72cc0 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java b90fdaa6f38081a2e24cb58a6f9ad5f94571fa6f550902d6b195572c7ea483df +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisFailClosedChunkGenerator.java a8737b548c90bab2c7c51e5ec3350c741607df191063aaade3ce8150a49ab3c5 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisStartupAdmissionListener.java 90b2ad47d82a8ac0f2f7bf817d5f670070037fefe6f8918b5a6deb768ad83248 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java 1fd8650149eeb40f1ab5fb04e922a2b69d4c8f538c38eaa88bcd71d7fae51c6f +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java f184d47447d80de0ef575240c460f87bba776c9c224347b8c090ad0d544a7ba4 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java 7d66da829880666260dff9e08fb06abb0f2bc2c26a32ef0b2702c78c4f76e999 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java 984140ff6f2dc6ce9ce557a80f57cbe81c2d101b69e8612f11ee6a1da25e8e8e +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDatapack.java 636eedaa93e181d6657fe2387582edcc5c819cdd601c30270b9cddd356bedaa3 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java 29f7b506942ed071e2693fb4f88aec08f80940cd9d7fc02353563589f2e36e4f +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandEdit.java 485c3ec7d31eabac2fffefa07ff5ba2e69c7645353890827089dbace8514bf53 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b1e9470c8a506f72e79cc412aef911583bd611e31d4a38e65ea1b6c3d650b586 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java f24acdca32add8f304cea3a7a6c81e7875f46ebef47e46feb23997158288e143 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java 88cb72e493fa598ac2b0ebf05e6cbe0be36247fab52defd127734bc1a5652791 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java d8615226c6e7072a5149da289ffa560525f88ca3a4731485a3c17daa2530ead5 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java 380b057617e958e160ce6416590606b7b7dfd49d02dbe8893935a0a8dee98893 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java 36debe4af8794bff860cf08d27773e433c2298945ab896783808d17f6092eea4 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java f263d228a51ea1c8b9ea21cebfe52d088e47a87299da392e4954a74b1c25d1f1 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java afc5fd80cc1ff3c2e281b735ae7bab93c1e9e832a9ee2342f8f430a23588d68b +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java a8b8d36327424a8508de312213edef79365ea01c29f9b06a1dbeb740035921b5 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/ObjectPasteBounds.java 8d63943167d469935c714429952ac6cd53f0a76ee5e329abb7aa9dcc73304151 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/PaperCommandRegistrar.java 8b924642b04211d6d55d4c584635db194f2ea9f322258102b1dc583bbd0426a3 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/edit/BlockEditor.java f7c6a5d0c62078d2eb9abc6792f27e3345a78f0523c53ba0a4e68b68f049c9c2 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/edit/BukkitBlockEditor.java ced058b70ec2019fbe7b17cd7de185ccde5ef4de058eb12cbba32c624ffc2f1c +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java 1dc3da7c0c35985791aabb394d494abba2538f51377c71bc27c8fe45f4fff638 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java 5fe5b7ea98afa17be8f47e4a7958bf99b707a81cd6465e49d975815821522e7e +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiExpansion.java 398ddd36b788886abec956f5645c9133b406c9ca40836749ea1ab0ef7e5f0174 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java cad212fdb9a74daf8e2f59735ecd587f3f799b6e151772f3525c8aae0adc22c9 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiListener.java 5d8f1322bf6d702e45693002584e9d78f1e7593fc23c8337e76ef45cec9a3a96 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPosition.java 9b5bab4bc3ac4e08855f5f1a1a9946085bb86eff6a4599fb0fe311a87b8d5466 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPregenView.java bdc1963226d7e33c27ce1b899e6b20b4462edd7d0adf8f8d2c28c26a3facdff2 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiState.java 459d916dfcb6c3641c7d941a0ad59c8743714c10f034d4771e4d0cacf43a0d0a +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiWorldView.java 7480e4ca0c5f237e7f66742a62e672e6fb54acb41f33911462b72e4bf7455ca5 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java 0376328ed441af5eb2158267553d82e3349412074c89200cdf68d4312369c616 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b72fca8e7923955e95198644f1e2890bc61e7a31e1c58fcc583404331ed471ab +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java 05524cc261099f8e205367e16d708d41943203e108f4248baa2a23da5662d935 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/EditSVC.java 7b94aa7643809fe7031a620a6859947853058a27687d345eb381a5cfa677072a +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/FellingRun.java f160336b3d2094ba65fc19e96ac79e3dab87c89747dd7ad9a516e633b0f3b0d8 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java 8ace4038bf4f3ceb29f4e75b46f90a9b90689b761474901f316a2af41507c784 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java 0c0972e90f42084c5b8658654ff9d0683121816514d692cb38474592e7e7c635 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineStatus.java 974c4dc3d4a808cbbfc2ef0dfc27f8dfdcec20a996a3586e7d709566dd56d8b1 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisIntegrationService.java 13b9d92307cd9b6a13d668d06f0d2d5335f73f4eec803a791409c6fd198f6697 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisProtocolService.java 411d34c393cc68391aea3c7f0f53fd088a69e822ad0784e6b025d6bd55f9c72c +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTelemetrySnapshot.java 3ef396b6a29c6e50e7ba33a0228d73e8b38fb527661772a18552da32d58b7d26 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java 2ca03961fceb8a4a7f4f639c52b780b7b901430631164e0c6bf71477ff2c6bcb +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisWorldPhaseLedger.java 2e8dafa5aa698c22a16f4ab08283bd1b21ba1c18f19a2ad39e77fe8b7990fce9 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/RoutedBlockBreakEvent.java 704d8d4fcc062b0c2a31b95a8fab4293d96f46ba599dbc46b751b446cbefd3d0 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerModel.java b7987e53d2a71dfd499f7d79e56a225f9eef54872ae1a50dd499009d4551c084 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerPresentation.java 876913f1b67306708e370497cb341b7c9a21ca75f908ae7ff3b5efa93c4e8c30 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellerSVC.java 5265c07e4130521454949bb85b01c130859fce9d4198ba13ac3ec0aa9c209652 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeFellingRunner.java e077b08c8e054f08ade0bfbf8b76c53d6249c8917284193a0104863048c8761d +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/TreeProvenance.java 09efb12817c6e357f3b1a5bf718f65b1eb4e6b0a7febeb7bf187d149a3b802ea +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/WandSVC.java 13bba0bd8b9d71e83a4ba242ddb71096860bdce0ccfb51642360e41453075e2e +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuard.java af15d9aae4fc97f846f142428ee5be4c31ff683adab9ba691337f83a36f74cef +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisColumnWalk.java 95b1200d20cead79b9877eb811e01e50fb867076f5a48a66f7e05816a84836f1 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSampleLimits.java 3d24e9aaee06b15b07133f900a30ed4ed2e932ee366d3a7e5c4b1bb0c49ce95e +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java 25ef003a93514248f155be51ef985c00ed3773acabdd05f16e64c70be452ce48 +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java 91d90523ae4d33f064007fe0935a1c7b4ec4823a70e73d7cc8ca6f609a1aeb6f +source adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/wand/WandSelection.java 3fbddab00ba23d6ed0fdd2925c6b22615f96ab22dab44f1c54020daf62fec486 +source adapters/client-common/src/main/java/art/arcane/iris/client/ClientPacketSink.java e53e0e73de4c0cfe69563d4df47fc8a2ec048b890ba5c67939e1ff55670f57a0 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClient.java 157b86a3f7b21f60005b98657734aa1b8fccb4b87be1d7f1b84d31b0b6705867 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientCursor.java 848de6e66c93b899d411263925f97396bba3e363247a470b937799252acdae9e +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientDimension.java f7b5c974e6aef3d66b5bce4fc4dadde5f0f5c097f9f2e51bac4d78313f67fa42 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientHud.java 2c2a1fff1293e017d2792dab7b1c43c1ff9623db1affb3ee182c3abde91aa5a3 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientKeybinds.java fcad5f33d4d6caadadee42b1416c73b9b799ffcf11f71a2a81e3c63bea14c37a +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientMarkers.java a78d59d10c3dbe5cdd8d7fc064e3bc6be272109c878cd6650429afbe32c9198c +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java 079ba030df365ba93fb4f6de71cda1ab8f44772cb630824d3092dde82a4d03e4 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientRegionMap.java 99922bcb3b61c66849bd292565083c488caeb6bc2cc8a136426ffe0766e08ff9 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientSession.java 204f38014894611db2cd76d1b8d76b4b256bf5ef53e427e59d4c3ca152ecdf42 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientTileCache.java 95c253e464636b01e75ad03c011b4f02677158da4d2765deb5c1cb0f9cbe90b0 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientToasts.java 7ba6be6083a6c51ad2688f722d100db900f1d06ecc906198f028eada3b7a9036 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisPregenHud.java f628f7a580c94c32cbd039f1f7f243e7e40a7516e1dbe47d627d92a26db32b4b +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileAssembler.java 27f82c251b8f0213af75b98739add8362bc01376cac84bb5a11bc5e054264b46 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileCodec.java 50b0673452f9965d1bcf63a373b8eabdaa6ecf3c5a595ce52714b17e0a7fb29f +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileImage.java 534c350e4f2c6e9e6f2e034acb61486017c7dbdf22313a7080f01499a3e9503c +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileKey.java cb7507b6e0c4778c2b57cb38902ec47bae31e18be6f23dfbf540f81963fd560c +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisToastPresenter.java 5c8e05933fc2e0f66b9a9d66da10045a27c11b075d9bd7ea9a2d9e380517f798 +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisVisionScreen.java 321b02feb9ae07614ed0da66901f3d50871a309c129bef72180627aea95d99dd +source adapters/client-common/src/main/java/art/arcane/iris/client/IrisWhatOverlay.java dbbf674ef480870340e5afdb80e53bb577cefc7ba78191b34257cfc9fd4174c4 +source adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldOpenFlowsMixin.java a28656a13456e7bc914752afa607e3de97b8325f282245f2331df37a6f3738c8 +source adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldTypeEntryMixin.java 80b32f7c7f1ae0a7febebe3b47fd273952370075c1020f798009829a92e61021 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java d93b8386427aed2b34c301bf4adeb1bc25af5e677a39ea5f64bbe39d43d94ef6 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java a08e326281cceff349581ff809c7260b523ce6264d7858c5598dc478d37d673b +source adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricProtocolNetworking.java 617687a1dbca4bf5e096edeb97023f674c597fbd83b18e74544c9a2410606304 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java 0fea90cb82830579ba06a369769d07c4a49eb2786d2e2ae6da65b7d3d18284d0 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricClient.java fcd2c6284ebec5546b0d934d7b311077ca6d70a36688aa4e8924e71efdc0ea07 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockItemMixin.java 5e30d60f38a9224bf40d3acf974a141d408cfedbb9e11c688bad1ac11d9fb830 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java 5d22c29b208fed4031eff56c146e92c7e497f198ad47723e5f61324eeb9a6881 +source adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/PackRepositoryMixin.java 88128b0cc77e20866951639211c51577a47ef09e63bbe45f9e7bd57b972b2956 +source adapters/forge/src/main/java/art/arcane/iris/forge/ForgeClientProtocol.java 96806e77af41eef59435442d4851ccdc7937c06717d776fbbdb2579e56069775 +source adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java 4d9e05cbfdf46b6c0335fbfb75b1b8312c34c06e5147658b0631a36c926e2fce +source adapters/forge/src/main/java/art/arcane/iris/forge/ForgeProtocolNetworking.java 75a08ac153d0bb84d3658d375a65ccf7ff0c4353da904ffcfaae402bea03b285 +source adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java 98070e54a6b83860d2b7f9df1d90de0d8ba4e37f3da1ec159efcf5d9cc09108a +source adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java f7d1afc599c5bb291f0c02295c037a0eb858da8b53e45630ae21f9a8080404be +source adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeClient.java cab2c100b6bbe03090468ba057e91438444a1a4d323e02fc9e94f8d6527e372a +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/ForcedStructureChunkGenerator.java 2d2a6adf6bb9366f51c228b6405484bf8a73e1ab7779becec74fe1ee9c088e00 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeBuildFutures.java cf8255340cb2eeb237e0636800156c96e09033e2d48be88ce3593138ea01ad0d +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFactory.java b17b7f24eebcaf2ecdd5f0dec3ce2d5c7b2385686cf0c56b339c0443ce91d5e5 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFoundationBuilder.java b00e69a91688daf9b47c7971f2636b64c142d36017c3d298a1d14b9453fd692b +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationException.java d4ded11b20d19fce2e2c5b8c96b85c12ed071bb614932f61acbdeac875ff0a56 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java 4ddcac8ff0e401272bb143ae09e73331495cd6a4e3ad1b17460cf55ff034d900 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocatePersistence.java d1f2ce22b1d21447701b79986695c79855538206b9eb01e3ad02362fcfbc2db2 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java 71093b9323be5e2f92c69165a13b438afc70239d7a48668cf1da672eb6bb1914 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprint.java b008c3457a63fe59bbc02df942452f7c615daa2a6da74c017e6f2305a145f8e5 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java 7ab5b29411d994e3ee79b7b292e57a1510c5ae19549760dc177ce1dcac696503 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java 273aba5c6e2cdd00e13ac6f367bfb00ad9039b4aa11cca22bd5867b189abc2cc +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java e92c041793ddff8c45420daf00fc32b95c6f11431c9f8e7007dd78ef17230d2f +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java 209b3392869bef825ceb944261a1c6946d464486c57b76793f2f5607e840d478 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java ffe81e06ab09f7be9f376700074e51199f6d27333cd2ceed5e2ab552ff16862f +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java 9e3fd639c5998fa81f3f3060663a2610eda7940a48952ea5f8e0754e7667bce2 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java 40ba10f4891b114234d9bdfcda0b931220bbbdb26f68daa26023a2a0e6599103 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java 7bc8b260551b54365dc82aa015071ad978382b6f1be05d2801d8ca7e7195796a +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancy.java 72ac1096ffc7f06123d9f48b025ce06fa45d576602094cb6fbdac5b2a1025375 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplatePoolBounds.java 9f5166543ee377c177feeea9fa004e8344d2fba5b127def8a4d831a8a61db8bf +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java e127042121cb1fbbf19a1e52444f3fb8c820fb7d381dbe7408cd06f07deb70e1 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVanillaLocator.java 5202a908d731182b06b6628ca5305d530004c152d46eddb11876a77ca3923c94 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java 1e7c570c4ef7427f94e2e96972c57a98caf5fc6b7be014509b968e1f017c15a6 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVerticalPlacer.java 6f48f9f3e359e84e2da0b5bf234a9047f8f4aa2380b235f419f87a144a443bd9 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java 0ec684fd454d5709f87e57485c01a9af7833cf7843490da69b3a2dfde540d453 +source adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/WorldgenTerrainHeightmaps.java 3633876842907318ce93545e5de9253476dc5a30ccb34fa447c0b6b0934fab11 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java a56f2efbb4976a27e0cd2233938a5e7266d547467a75bd7a242519dca6639567 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java 42042da0022347ac67e69155a6d8af55b8bb918cf98cda4bc5d1b99a2796a16a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java 332f6987b85b7ee1a0ce21bad66994cc81624420a8c4926807e2c7a139248fc9 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java 6e10c7e89850e997b6ea865dba2fbd80585a71b4e5ce7001def52132e4e88b77 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiome.java f1415ce9307d56d6457b8902948bb6373589ccca6188512ab5d15522e116c45f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java a17ae6b1281f4bacd31e08c0b55aeea27929293581e4c6ff79ce4d19d6d39bda +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java c9fe31c148b03d8502e4777291d5d4ff1b3a7320eff8378bb8ca3df7fd44dcef +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBuffer.java d0917cf1af55a81cadf26159e27e712aa71118c2285d87c2ee3f7eb2ace91c18 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java a16d498b5eb0dcf252a05ea93b2369cac592326395cba3bff88ea2a6628c37af +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java a9e76d82bf984b223734dfb4900d1f08d0132f92a452d8e3f1af5468a4c00a11 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java 12606b4eef4f3f683cfce0af53077b0fca486f989e19f07b42fa192e1b0d85d5 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java 4269442a7910127e584dfbf3a012bcc0762f0fe6b47e5bf07aa9cdaff5fdaa91 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java 98ab527d19f3c0230e00b190e58a414083c846f367f0495fc2dc63c13eea5902 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionMetadata.java 306f838ac57d7339919c28f54341d14c9da55e93d1b0fd832b75deba317a2106 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java e11fffecdb7b786bfb171c4fd2054397d75c6cb96806c3b077c99a726ebde853 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java 976e2c0258d836f7f20c5dbac75b1ceaecfdffac38224816673bceb840bcdff2 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBinding.java 58de5ae3dd30e11fd1cae8074a6f83814d9e673677cb05ab5c978122146e3ac4 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java 3d9b02008bd263a1b44b9ff935c7b641b9ca85d26104ed09e4b2d24d3f595577 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java 804ebe5c7e7d71aa41e7fea7b761dbd4a5889c67d4e569ae10669a1741fbd2c9 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityAwareness.java 2f30d3625cf8b9501d245dd2337b3d74919b87c6a86964d617871e368b4ddec9 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityCommandRunner.java 3e77485ddb81778cddbaab40929f1ea40e256ecc94f2c6a69ac9c6c001a27a1c +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityPersistence.java 63c76022ab75b1a8fd6e6129d787d8d0406905f941273492aca1ffffeb8e0bb3 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java 4541b15d82469d3d954b6b12323d0fa2453e1461df5becb5e511cf0d0b829e92 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityType.java f127a943bab820a1aad844a77481df95fa60cd22f17a9b61491990da7f488bec +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java a0edb371d30a4186b0f4e95015bfc98e2c4cc56314a3f8e3dd3380155b6aeb27 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java 67458ff63087234f34d56191b1500f55b2f73069c2e337af060df1d474c06b57 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java 44b55e2f028a281513c82b24afdb6745fc011579e635a9f379275773f14bd7f4 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationMode.java 5ec2158c9a78da99900d42f0a650ee336fc2b0728d2a0ad5e3e2fcd5e533d148 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java 21ceaf9189ae3411f8c740a11e1ecfddd50e1dc03a5a1996e0a6d3d6a5d39d43 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedHeightmaps.java 159aed3f54445f521301ebb3d2a6f129a89214725cb108d95de219c40fd3f852 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java 0368b70fa7f2f4d2b2fd5f9a04c379a3557da66bf7766cd35441476d24eb3a2b +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java 62f2bc771b53878047e21fe6bfd22d2e339406b89e2fe849ab9f385ac7c50214 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisPayload.java e83ccddb4eb1f700fd1700fbe63379a338e66e854018d924808262877a077396 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java deea21fb476c4970b926f9e114bf959e9c0e11fb8ab62e350128e539e6ee74ef +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItem.java bc94d3314130d9534ed289ca210fc6320df693102e2b7faf3af443b7658c6e3a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java c832a32d0f3ff3a63eeea25a4147d42a3242945a55ef42fd19cc2e7a30290d13 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java f33df4cc272940bb78fab828e8e5fb92f806a94ad86872ef7d9fee61d83adab3 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java 2ea310f89bce7ac0b21dffd0a56d3c5f403e1b109f353c1c95d8c2b36894acc0 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java 47af3a3d80a089e6244a4e27f1f7ef8b1c6b9796b7fad1680735057e4434ca65 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java ce320fadbc7f070bbea585ca08a6d95f8f4a6fc186dfb7b6c72848cb7ca35bf8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java b638bbd0878ca6ab536b76ab27d94b814898dd6d61155389f020951d56a97faf +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java 48e455073e8ba5c96e2be5b825386675d8cd4fbc15dcc1f81e2742983e7e5e50 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java 8acc55d2d5d90c04463a5e95c6f9d288999fc61100bd31936de7b6d10ad250bf +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedParityProbe.java 255494459d4b01f76ca8263246220b3a92b270f9a35d11ce3477b1fca8ebd584 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java 71192880396e7ec187d5af9941363f8c6a7eb4b6cb97afcaabe0f4c74a3b7b04 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatformWorld.java c5a411032b76f6400c901ecea74932ecc85c66834316694fee0b1b5a4d088919 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java 6d7fc0a3c809a6cfe56d6c3e80dbc84eb961b9976280e2fd11720df8f7f156f8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolChannel.java 6c0e8f606972fdefaccb8d31dbaf663a4e19fe332da2b4f249bf0f7c64874e2f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java 60bd17268ac8b09c8fcf3dda54e9dca0a9284fb181ecf096672e479a90e7c105 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolTransport.java 27ddec12aba235c45bb8c9dfe93bac6bd300dbaedf7fe4967e86b7b89da2bd0f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java bee33dcb7dc645ef0d2200aa3a26bdc32f3e62755b66f3a9a874867afcc6bbf9 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java e51c0bfcecf8a16912859253d418d2211719a69f088e9a4eb6c1137f621d9fdf +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java 090fc2ae95d7b9d845717014a491e8bd239177607c40b10e85b7c7017d3df88f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java ccbfe88252680fe73307c9f4305aa1bc967eeec9a5ae05cdf0e628088ee339a8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerCommands.java ae4655de25be05f79f234906c6a3de71ede257cc5621a43a6dbe2ce98ce0164b +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java bb98d21fa6d4eb6c3acd925f2f389aa908fa4b21c30db5c5eeb466a3c7b61f04 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java 795a55ef5c6a5414308dfc684b760bfc0ac8308a63dbd091dc17838402c272dc +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedSpawnTableMerger.java 876d1a6bc73a46315a88c3da5d9419a65b4190759742e7693cef0f3b247c3247 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java c5eb8451bba769513cc9b497672be9c8575b8bf82c0c6ea1bee520c31a642892 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java 2d3f43a0ee0909ea53cb0ff0be65982766d38bc705d8c84cf6b9fc2f96eeacc4 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java 06e1f17ac84cd45557cb146c1c924125b5038d2cff1514d658e1cb316579a075 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java bcf32041849ff0f015956825044dd99af844f0ef01d136b42d79ab5d97e2bcc5 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureSetFrequencyOverrides.java 198400f1ad6179b1d470bcc0dfd412285c793a5ce75c6ae6d3a506acc28548c1 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportBounds.java e798e114950b9ccc12c3d8ddd872e4bac5678fc0db1bb7ee41a58c7b3343c6a8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java cfa39080e1ad37c148234a73d905eb26200a50faa4bc9b4e49bced812c63559d +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileReader.java 237e42b4762b345c803e4329ae4aab4fad7a9fc0f28bde2e5c851311a723b22e +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorkspaceGenerator.java b02b8f46bf808fc4ef8142c3189742fb7d10e937d4b5a925deaa04a04a33f956 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java d8798d3022bb64263ff6cfcee5b88ed12fda8e9c968915b857abcaf192c2e510 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java 493d3697b8de9c740118c211af197b9ee3df2d94a6cfb618703f3ae76ad4534a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java f9d7671625429ed63e354c4620ddd79d0df59c0dc24ca9135e6b3073b6000592 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java 34ce8b87c3eeba9bbd2e10c4e578aa558e33fc0eb1cb7bd9219556888c0a77eb +source adapters/modded-common/src/main/java/art/arcane/iris/modded/NativeSpawnTableMerger.java 68d0a981cb737733a2358325ce67b43c6057db5802f4d1c82c95e04cb60fff09 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java 291310b36e63f4e2424988dfe29310bcee01986fd8f3ee72a8a3973fe82720c1 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckFeaturePlacement.java c5cf84d7519027e08fdce47f07404adba4e5a1b834ce1b8cfb248ea91e258728 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckMaterials.java c532aa1febc49d3030569051cff81d93983fa184fc272867af94009b65b1472c +source adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckPredicates.java 9b68af294e8fb3559ff6141df2731be3c62b3db3edf1553035ffb115337ef562 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckStructureAudit.java 8a11d12ef7ffac604d1eb738439c45aa80ed065cc16affd67bd03b5f15113a94 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java a218fdeac999b0effcabdee6972b6324324d14d829f5e7d27de84c7f26182ebe +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockData.java 45b3a78b293c20f20653915bac2e58849ee76d0444fad7833dc271505b8bf2b6 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockPlacementContext.java 1fb20ed06338c50ca4c70a22fae67e8c1cbf8fa0b177fd120923d6eb958df3d6 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java 4845d72515d413be36d50b3c3e1a778afd0526476084a12d2e68885f90fefa2f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java b4c060c95afee495cb151876f305e2c15ab9bac042d6ef882a42759803e3b1b7 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataType.java 16b2dc90d445fdd1b2847ef4b3fd05f1140b54351d7a220c4fee82e4f225ac23 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java 80132c03eac9d242ac27a1e47a13063dd1bce9b9d99b9da842011778bb4abe31 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandFeedback.java 3e11d7c9af1b5718172a942a040d427cce7b5e96cfe026f49b4f9a5ceeb131cc +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java f1306269a76e8708f6191f7b8348d6d684c411bfdfdc807d7e22e370aca83532 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java acb6dbb599fab95bc70d33b6c203898dfe68ff3c834b790c00c2bd0c2dec76b0 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java e9d05d9adafa4bcfd5134e5c4356d989b7fb503ecb9cfd351ca68dd9ed39b94f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java 2f643389cd0f6063b842465948e326a076d0b69ad2943c236e4d244aed723271 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDeveloperCommands.java 94848f60d1198d32c8959f53b6f5b144a3f0392ed844d18d43a602ce834bb533 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java fc70a6dcc9a193def8de368a92495b4c9e1ac3e3f4d05e9e896474f7cea47d82 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedEditCommands.java 69ef38a4a9d1bb9b817725331bd328439a46a5857ea59a44e86ab22a3503247b +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java e79c39a9132b5a7cf17b326eb6520de78a777f0d7eb4481d43a6449a643e936a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java 6bb96135c36d49c32ec349df444f22f4702e91c4758cbbf4c12725e154ce13c9 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLanguageCommands.java d86ec60dc82766df66a75ca0972d067a5e07c4e28c43d4b47bc2f74c8f06e399 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java 00b2ca75d4df6a498b7cf5a6cdd03f210936a74a26fedc755f7d010ea8eacabb +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java 2ae08263fb6ef83faa1b1dbc43c80ed3b8b1287a5d083a7a28de0d7762ab8bb8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java 8be2015a9736fbbfe4b51d4a031d26346717db3241775bb97c3789aa7415d9da +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java 5718c0b08781016a8e9d60c0418cf6e8fabc056fba9770361c6bfa861ae9369d +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java 8ec72be2810d77190d5ee4ac92836c4d24b41170114f2958feed57861d3b4a7a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenBossBar.java b6d083a155121f1000f4c6b33b36e41c14d631a5a2b73b0c4fd69140f0d0be15 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenCommands.java 489a0dd63a519268bbc1ab2befc071f0b54d350c1a45143c5d3c86f21c08d1e6 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java 21ee87dc14051131ffd1a1b64acc1d8a067fff29304e8faa6a384651eed765d0 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java 461f0621b6c13300b6f6eff4a06e9a0c313928ea8482e6afff1cf9893e4d914d +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java 0dd8a976a9e89b03408ada5c0433699d204e2df02ea7b637a154700f8de84c51 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java 6646261a1a48f27ebd6f192a1c403dcf5ab066114aec9d056a0406ad08856912 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java 7532537ba7675ade13e158fbaa61eeb1e69c281aa22a6816b91042c465cc1122 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioTransitionQueue.java 090df22af548326554e52bd02ea4732c72a86ebb8d24d8101aa9d4039333d976 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedUnregisteredStructures.java 32cfe601c69c8dafcd86f07aec427a8b55ce462b424f56eb94ea8d5ba68addd6 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java de9655cf1897798392146e2b9dc501cd61a9013ab23b962bf4201cdde001f6aa +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWandService.java f9b62e087b42d96127bdd7bd593e97231dce8fa9df954028a2f6674c4920dc3b +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWhatCommands.java e4d3dfa5a812308d5844d088248704b6042064f22d8d7ec151271da5f1792b16 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java e1519ebf2eb4a5fd904c415ccb5a2dae2f598eea11cdfeb46e1478685ce13e14 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java dfed792bde45d2eb70a96833caec0f79006d3aa0deff45c3ed2096d2997318cb +source adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java d3da0e23927d947cfe967152dbb124f568067fff8fbcab7107ae0b213a1a2e10 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java bfb431eb1dbbc7ccbc51249403093534d3c9e6fc24e926bc69717c725da719f6 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java 6a939219a54acc25e4410f9d332a9734c1f4b389b77f674c918b54a68b54cfb7 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java 8f1bad08e941e92424a547617a24d6c1543b4d1593b9867bd05c35b1cfd49690 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java efe3daf4d03bb65b6a228564fba73174b3c6f3e3a8269af8fd6e95df228eca5a +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEntitySpawnService.java fb2bbcc54beb227e0e095f3d83fc592afa08ac4fdb54154f5e79661a890038e8 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java 4992f40b4d4e4a7a82f2a52ea9f20af2f18949fbda1dd80e8935ad3f54fd019f +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java 45aa5d42d07f50cb440190d869179eb6adcb57172345f075c5beccc395752491 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedService.java 355579ee520992e3e488b68e97011591b51854b7b6f98f10a795de2d42bad595 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java 599a549398a9a21663dcbce2a4f7162767703b4a60782933959197f984d841e4 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java 07cb65be65d1aa55c85461abf7027807ea81708c7247f4553f6de395395eff11 +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTickableService.java ca3614152280551cbf31a6db943b65fb5330bb3c3181863bdac6fb7f4ee925af +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerPresentation.java ab5ab6f4030f82d259d8c41e244a652e68be2c60c67e6f8c2309a1d24bb1428d +source adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTreeFellerService.java 1b153545c2f33ef2773f82c6ff45d7528307712ac09ec3b5d66ea1cdfc154a9b +source adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java 61b83e5093be8e7715c4b655486ab652be561d8f4995b9dbf94e04a6b68ff7e3 +source adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeClient.java 47920a2adcda3c61188f449b3949a9018a81e48327c243147d4260c4e85d36ec +source adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b6e2eecc0c4e8441fcf321e8881762ece2fff0146be5eca5fde28ac43b26b203 +source adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeProtocolNetworking.java c4a0ec708a661ffd06e42a0ca806bf3e79e0be5f10bbd88a7e58267136767c0c +source core/agent/build.gradle fd4259a77b7a5107e3f865f89ee1faa4264efd65de6180a55aba190fab7deeeb +source core/agent/src/main/java/art/arcane/iris/util/agent/Installer.java 390303a2585620e1d31146b7b1defdd384adbed354491ef778ad795c9dc076dd +source core/src/main/java/art/arcane/iris/core/DatapackInstallResult.java 7be2b6b369479b287927dbc816576b2b95f35a48c495d8f34fa3f3a7746bd4f7 +source core/src/main/java/art/arcane/iris/core/ExactWorldSlotPathPolicy.java 7bc43355f0c78fbd87c415bbd9904d74dfe7a6de7847bd7c395e3de772778c8b +source core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java 867430bd38b91b86a1774c4d44f966f0caf172e78ca6f2074c48e155d4ea2d1c +source core/src/main/java/art/arcane/iris/core/IrisPaperLikeBackendMode.java 220867995c31d5ff2f3f941d6190b5274a4bd8001708b29d1177e737210ea283 +source core/src/main/java/art/arcane/iris/core/IrisRuntimeSchedulerMode.java 3366dce6fb33db8a9355b6d16d4feea119f78111da8c4cb4c021f6e144309cff +source core/src/main/java/art/arcane/iris/core/IrisSettings.java 55f47fc6288d6312dca4fae3792ea6f1349e1040c6e910fc097c1b8e12ea130a +source core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java 0fbcb1e85c024d9ae761298eb8cff58e04118fa78cb39e32bb88b7703c38bc76 +source core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java 83a34a3430ddfda929495d11b4ce61f5fc2cf722ee89a51b13b9a13a6c972578 +source core/src/main/java/art/arcane/iris/core/IrisWorlds.java 4fcceba0d5e502fc509dee6424f21dd5ef79df9db47d0445c15a8785c5a6659b +source core/src/main/java/art/arcane/iris/core/ServerConfigurator.java 3326f5e85b831e6dd40777326f7f8256e48cf3677156a6d1d584c34abbf73dea +source core/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java 722626042885fdc27f3eec37a496b079ca38193d4d6516e5718e56ce92090d37 +source core/src/main/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleter.java 30580d7847c4527fa834f2832640c78f1b6ba675a4e37dd11c50cb73aadb04a4 +source core/src/main/java/art/arcane/iris/core/WorldCreatorCompat.java 68ab4da60dac12bda8c911928db612f8d8b26e4105d7329303ffcddbf7591454 +source core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java 71e59d1244bda18d9a73a6a0c51fe0125ad542efa8989f966ea815a53f3df94b +source core/src/main/java/art/arcane/iris/core/WorldSlotKey.java 0adcf57eb1a61f867a334f0c7c456a3bd7f0dd5f601c9e8a15aaf928e1a6becf +source core/src/main/java/art/arcane/iris/core/compat/CompatAction.java 74ca80baedeaea62bc65c8ef4592a812b9bc020f426dd01a796d9553127f8527 +source core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java 1ec638d73fca19ad6dd71ff216320a1fa69cf46a4aea7a129bfe47414c63cfad +source core/src/main/java/art/arcane/iris/core/compat/CompatRegistry.java 99f4de14517c16ad110c9ce3ab1fd1a202c6b429725fa0756a7eadeade371f93 +source core/src/main/java/art/arcane/iris/core/compat/CompatStatus.java ffb40e0cac779223eae01cbdec2599654b9e6aeeeb9de516634deb9e916aa441 +source core/src/main/java/art/arcane/iris/core/compat/CompatWalker.java 9a7463fd6c18e3ea2f28fbc956c2f99c3cf3b7ed8db360833345581160a03513 +source core/src/main/java/art/arcane/iris/core/compat/ContentGate.java 455df9cf0685711f8ec884faf445b243b2c89ebc0aaf56bd39bf58697e628004 +source core/src/main/java/art/arcane/iris/core/compat/KeyStatus.java d0bb444203ef07ee1367fc075e8fec8561bd47e94a2d77284bd777488a24ff55 +source core/src/main/java/art/arcane/iris/core/compat/LegacyBlockRenames.java a48b132f18640e828954aa579f3d40ce174e8b3e4598f6e2d6a149c27290cae5 +source core/src/main/java/art/arcane/iris/core/compat/MissingBlockState.java 7f1cbc274a97d9bcf4908ea5d7837d0b76ee8f842324cb12eedb5433c613d801 +source core/src/main/java/art/arcane/iris/core/compat/PackCompatReport.java 20ad8bcef783655f121e8befee2fc11b19408917f10760b2fa21b01fb7a225bb +source core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java 44b27e46a5fdce06a2e21e3521caf98f54f7e957df952406a3b0ac9084935401 +source core/src/main/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndex.java 1d323a99b013f369ad740c5ad39c7672a606efd82d7e32134e0534fc9fc510dd +source core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java df34ef49c1a21fcae4a6730098f2b57c99fd04dda94b0744aafccc22fc930d4e +source core/src/main/java/art/arcane/iris/core/edit/BlockSignal.java 93771191d4e4c966c4b1251d4eb7d87309752a341e62112ef0ddc5bb458a6f6e +source core/src/main/java/art/arcane/iris/core/edit/DustRevealer.java ae654caaadb339011ff8766e4301827c33cdd718df77070758c7622ac57de945 +source core/src/main/java/art/arcane/iris/core/events/IrisEngineEvent.java e0374fd7bac1366a2678f46a1586605afaadc68b0901dafca309ac814f57fad3 +source core/src/main/java/art/arcane/iris/core/events/IrisEngineHotloadEvent.java 5328b316f78b9a5be5e5dd9ed904ad6522ecf5e70b42246d7ae78ac6cd2d8376 +source core/src/main/java/art/arcane/iris/core/events/IrisLootEvent.java cff694a692c53f19afd3821ab708799823a6fbd5829e6bd208594b54126de55b +source core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b512f01895f9529667bf95e4f59524778a56463dad5ef8ec1c1f07ea341338ad +source core/src/main/java/art/arcane/iris/core/gui/GuiMarker.java 67c8a2763b484b61535d2ce534b4af3ad07e3466ff4ba406f9d7ee473c0b14c6 +source core/src/main/java/art/arcane/iris/core/gui/GuiOverlay.java 4dc15202ce94ead8b0ea0225ff7aa6a40fe4eefe9a1be099c1caeed2d8b84c36 +source core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioGUI.java cc89b37d0126332970f3c4043867a485d97b6286d46cad93caf4757babfcb9f6 +source core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioModel.java 425457201138eeb5d3f4954609e3654b08b2dcd23c0f461d78762b41f7004bbc +source core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanel.java 80c5b0863df93889f537caeec494965b358af722fbc556bd9918840df12376c8 +source core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java 5e1ea38b0cb6c388868a83b9f1823c7a78c90570fb2542ba165a0a57ce2d30b2 +source core/src/main/java/art/arcane/iris/core/gui/NoisePalette.java 266e65c7b804c77f6726d117083201345e610eaefd2a7237f004755ea69d2132 +source core/src/main/java/art/arcane/iris/core/gui/NoiseRenderCoordinator.java 99165c4e280ce60fe1f5ac4bb274f2a3724efed03ea84d34afb3610fc25b312f +source core/src/main/java/art/arcane/iris/core/gui/NoiseViewport.java ff283b313b401f5cc3736e2870166265b54ca588314b0642eccdc12e7878710e +source core/src/main/java/art/arcane/iris/core/gui/PregenRenderSource.java 880d24027611c29cf43968ad08bc6608bb9442e126bac9b73613c6d0e84398ea +source core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java 6efefe10d618bc78b8c62b1d0353454b18af2f7cf650f1ebfefd2ce5322a945b +source core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java f2a3f09dfa72600b64101cf5e93c3da368b53f063e9c1c45a4b0bfed8bd5236b +source core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java ef1474b624cc556ec756124ae4de6d60170cc137ffc66eeb34a44197b3cd6f84 +source core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java 2593c694bb6201ddad992c1cd9ba494d6f80dc62a0da5c59c6c421c12b0c4c16 +source core/src/main/java/art/arcane/iris/core/gui/VisionViewport.java 3bfb6301a080882e479148e170515749c371a478ffa94ce715a87dd2851ff7b1 +source core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java ca5352207b12daded63329ab37c76de9168ea6422eac12e296f2c9422bda90ca +source core/src/main/java/art/arcane/iris/core/lifecycle/BukkitStartupPaths.java 308768060e867e238d18ad824262a8db45199913319b9bb0dac32576bbafbcae +source core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java 109e541198aa636fe1a9a52ee4b8f5ae4094a3bbdcd3bf33dc145e165132748b +source core/src/main/java/art/arcane/iris/core/lifecycle/CapabilityResolution.java 6a1ee0f8868653d0ad7fe25e31dad3673c72b666bf0463604459d77bedb5a696 +source core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java a46e8f9b43b4db07761ea60335512335a90bf38a6041d29892cc4941b63c1f5b +source core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java 3bf12f3bcb74390fe1d5250de274a6b70012a1104688fc3b148aa78ad0980a25 +source core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java 52408d6a3ea1e01e8d38b58a659f3a2bff0024eb8c9a84307a5b6755dc64bf63 +source core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java e2226677842c4623f0eb4661bb79c09878c2310268be5c0f7622106d5cf996c7 +source core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java 5b6f9c939f24c3868546da4b38aa20fbfa6da006afe4176749b721276f3e852e +source core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java 3314e5c112e90457d0f3afe822ed7540703b37c6b3dd39e9613c6610af66433e +source core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java 9041970f68cdf7119f42376585f189f6e607289d6667cdf7b0613a49e892f264 +source core/src/main/java/art/arcane/iris/core/lifecycle/PaperLibBootstrap.java 17ac328180835f05beb4104a14a5c470f3ccde239f935a438d2971adbce87cc2 +source core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java 3360980851da86cd5604bddcdeeb784351757177858a2365b951eec9be6646ac +source core/src/main/java/art/arcane/iris/core/lifecycle/ServerFamily.java 4c6cd416ea277362c64d0ac55ed9f04ef2a099559b007e2068bce5818ba16129 +source core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java 74938a321b355ab84fa2ee6b597e0de8772fa87579a63628a3d263aef7f387e4 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java 9aea543c4431ee2af25ec874605b7dffaaea61e29b8e5e621e6c7625690e5ebb +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleCaller.java 7293d978b786befd2eecd4c406e90665c2b322e8ac23555c086fa9223c840fd3 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java f1c81dc7e41d8a74fe39ac00fe3ebebddb960ff8f515bd121af27372eb892ddd +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java 184863570f74e850728726c1e666820aeb34646b7eb047e265a2d890f5abf4c1 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java af1e212f53cd8e2c7162b8b7601e8a3c4f68dc6453961d4e0a24e6faede01e67 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java 5887b932a5295e26e2c45d4604aaf7da3b8535ae5f31560606fd1f2231b33d7f +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java 2776dcde425e4b9d7ce000cc5cac8260a24ce0268eb67a7154e5750a94abe0e0 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapMarker.java a2574c798f127cce116522263d0d0e59e9117484254487d8a2026db9bbcc2b54 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuard.java 648c352ac2d56c5eafcb11af769263b18242eedeaac506784e17fbe6ec46d9b0 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java f876a9ca7b6d83047462ecbc3598b0c2336f360cf06a7a9d5d5f95e3290455a1 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java 9e50adfa530ec7138359dfef497bd4c41508108788e9839670abf6fa773eb276 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java b3b5f994c9605151e3c1f9c0461de47b15f4958ba35a42af2c983794c6f0ff05 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistry.java cb1a0cdde3eea7b181d1f56a82fbfbcd2432ea028cc60abb35a53a62f4b52600 +source core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java 9c8ebedd7f3801ae1cd337697f9389f474aaba16e970ec31525ab8aa2867c749 +source core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java 6c9f14fd54d6aa0f3447602709f22abe5111b57c0307ac350df3cb59787d403c +source core/src/main/java/art/arcane/iris/core/link/Identifier.java ebad82ddde1641b258b8ee3fefd7566fd09a984bda621d7ee26f9f291e1b09c8 +source core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java c26b06ace2ab1b74846a5010049d70f17f791571c95ff4229505beba305606b5 +source core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java de0839a519e9b2fd2cc94d713be3997157d40399148f8e3c50cbe22b2f109118 +source core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java 6c4a3378aac26886e6a443ddf35bad36013f65e431df0bf0735d3f7b9e873787 +source core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java 508d4e512fa9040ef4d83d15c7e10141eae758e459b9589dbb52387b4606e509 +source core/src/main/java/art/arcane/iris/core/link/data/DataType.java c3353364dce7959caaa9df093964c2a04eb44a0e8818c1f3dfdc9e8f4c7689c6 +source core/src/main/java/art/arcane/iris/core/link/data/EcoItemsDataProvider.java 1b1ae16c4327760860ebcbb020c885242f869f0f6104aa874e163071ccf8e075 +source core/src/main/java/art/arcane/iris/core/link/data/ExecutableItemsDataProvider.java 663ba05f0e759fbc108a257ad5611f7c3bab8d84779c2718070cc50a40da7dbc +source core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java 31b63f60f7ac024d829c145a00df21dec9fc69350af3bf2e7ed2d9862845642b +source core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java 0173463f400b324b9898b78a2ac25831d0114e97830dbc0056429824a698c43e +source core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java 5a476f7117140e4811045be74b14de5a43f3a5b6ab740a9acbebd31a7165bc69 +source core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java 19d24c98be6fd2a6d095160b6935a64e5cd2906ebb3901e090465118fc6180d2 +source core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java fb03db539b241d3b638d200edef60a3c490e791dffc955f82a31bf0b99be26ff +source core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java 7450f0967ac5848b051ac2e9239847d6b0a7a66695f26a65d7ad53e37e55343f +source core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java 5bfa853cd00b0ac3b5bcc99be81094cba4dee7d5b2ccd19cee83b947a3405835 +source core/src/main/java/art/arcane/iris/core/loader/IrisData.java e83626f106729f18290c87f75ff2eea10eaa1fe588220eddbe1b3c0e184509da +source core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java 74fe1fbb52a56380c033a743c55710570ae9f661e9e6bf6c548781221d8a0b67 +source core/src/main/java/art/arcane/iris/core/loader/JsonSchemaValidator.java b6735fe7b9f0ddf6c75812c0f6c98a5ffa78949caf9e8692dd001deb27c4ff9e +source core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java 971f70e4fdd349293c9e25fc69730d27428e12e97cacca71c5988cf1aa06776f +source core/src/main/java/art/arcane/iris/core/loader/ObjectResourceLoader.java a25232eaca96a17ba94720d34f9bbf52d08561c85f00c3d05d4c035aef206f64 +source core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java e9f4df3b428e7349816daff817bb065a7c743dad6d3dcbc9899ca1bac2629f1f +source core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessages.java 8b64aaaa630c2bef89f8e6a39371787453b4168d41f71b75d44d7783759452f0 +source core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java 72b1b5035f73ff379a79987c57bfaec2e78252eea5f26f335379a0e8e3447f82 +source core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java 5a46babcc6d8476bd4d3cbcb8ad7a9623791bd511978e1d656936d1ed0ae6acf +source core/src/main/java/art/arcane/iris/core/localization/BukkitUiMessages.java f13fb2e5cfe7af11a98691879d8d80b579d0287bde157a3ac93ce8f68ba95cba +source core/src/main/java/art/arcane/iris/core/localization/ClientUiMessages.java 9b9fc2b82114ee929647460e4bc6cfee689ac2e184ab9b97b3e1ffc64e96e316 +source core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java 943b81e8b1d2f8480aa0a4eb0dfc01392c29f59c9d7963f7f8ceba7f6b305413 +source core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java 7ce97890d97bdbe7a3029662d4c4075376b7aab101ccf5207fec7869b6885e9d +source core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java 4327768a9d1af6aa4fdce488627ef3005e387c8a09a2f9cde3935e8348cd66bf +source core/src/main/java/art/arcane/iris/core/localization/IrisMessages.java 7e011e7727d367dc56061782cd1c7a79e32c74ffb7f78792fddb6e8d9769a75c +source core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadSnapshot.java a68c62b03ff72ea01f91058890aafdc261f892014abc29ed84cd62a66faaf7c1 +source core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java 47ef537dedc4490264d5ba336cc848954c43c7e12fc1dfa8c30e8424c673af69 +source core/src/main/java/art/arcane/iris/core/localization/ModdedHelpMessages.java de8cacc13fa4417f233921ba4bdd2ae7c1087c63bf67f8ccf44d3088c4f1aa41 +source core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java 1def6380432c341eec65cf7984f92845ba9b667fb7edf862e5674df927ced4ef +source core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java 4ec986db9171a1d92f96a84c5221907fe9426cd1b9a58850134d89b67706c689 +source core/src/main/java/art/arcane/iris/core/localization/RuntimeUiMessages.java 13cb09cdc52745e22ffe1547d0ca9984890bdcbdf9ebbc221510406dfc341056 +source core/src/main/java/art/arcane/iris/core/nms/DatapackStructureScopeResult.java 30faa3f5973e0f96618b5da18a8d166cb04bf07019dd00bc187f36d85f79ff54 +source core/src/main/java/art/arcane/iris/core/nms/INMS.java 3bb3598e7c35e629e3194757687cd1924ef9ed4c2d156a1f91d3133e4b121221 +source core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java 44b1315acb1e9468d1b177e09090f75c89a5d8e90d235b601ed6e133c8685020 +source core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java 1fa721ce890a3393e374d5c214a7924eec10b00f6b4d07ffc637a97dccc52b04 +source core/src/main/java/art/arcane/iris/core/nms/NmsBindingSelector.java f2ca75425d7c51b57ceb9b6d3aa92bb7acd6d99bc8faba0b52684ab5dcb043a8 +source core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java ff79df31b623046e7a8f0e13142a0e23771346d6a783fd5c3176109ebd2f3569 +source core/src/main/java/art/arcane/iris/core/nms/container/BiomeColor.java 8ad3e00e29f6695aa23bd0c583707b2a25b1c2e206164d76e20737eb021e4947 +source core/src/main/java/art/arcane/iris/core/nms/container/BlockPos.java f63f1c7201a4f6276f195c2ab6f02b6278d98dac48f25ce38a57c1365301e043 +source core/src/main/java/art/arcane/iris/core/nms/container/BlockProperty.java b988803f1e02af1481ffb972b18d85b58f16ba85c368c6e43ce7268fba8f6e6e +source core/src/main/java/art/arcane/iris/core/nms/container/Pair.java 57435bb0532ad0a86624af0ca09e52db36a339e1d3e60a2de9372fdfa46131b2 +source core/src/main/java/art/arcane/iris/core/nms/datapack/DataVersion.java 9b9d2bcf705b0280b3080afadde6ada038b143bee43ea60a5afb059bfbafc27e +source core/src/main/java/art/arcane/iris/core/nms/datapack/IDataFixer.java 7d20eb16cf0eccb8064a0b8ac9006184b619ad5a8dcf8d359b92576acd0b092c +source core/src/main/java/art/arcane/iris/core/nms/datapack/v1192/DataFixerV1192.java b94915ec84037ddd7104ca604819fe1d214caee44154546873a5f00eb0c037f8 +source core/src/main/java/art/arcane/iris/core/nms/datapack/v1206/DataFixerV1206.java 2df52dbdc7ad1e1e2e67c5876cfacfac10ea133b4d0e812d8b910ef75e20cf4c +source core/src/main/java/art/arcane/iris/core/nms/datapack/v1213/DataFixerV1213.java 134cf12ab88100796eeff1b1cb3ae7cdf9e4d9e9f01d94f9df35ff546bfefc0c +source core/src/main/java/art/arcane/iris/core/nms/datapack/v1217/DataFixerV1217.java bee11359e9be80c3cd610e52f182b0405c2998c247ebf0cf7ba9352b421c3a93 +source core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java bd813ed2d0f3f6ebcfb688772c0c110bf3e06b045a079db79464764520f96e86 +source core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java 66207bc92ed2f9e7a8caaa23f91d9d98a5c72be3fa517ab6f16e91609e2f87f7 +source core/src/main/java/art/arcane/iris/core/pack/BrokenPackException.java 0033915832bfeb71872b90e972ee50d3f7d6f4fe9141bbff2669a3e65d2daa2e +source core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java 247b1d88b1d241843d9e73169dfbe1d3a5444a9004156113b3a7fb78d762b321 +source core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java 10fecd5ea8e8616236def7fa1b9209fa808f454dc82d639c1b7a1a81c9a1a6c5 +source core/src/main/java/art/arcane/iris/core/pack/ImageMapPackageClosure.java 242c2dfe910667e829488c3f9fc91a0cbbc915dfd5254b5bf6b3685cbd3e41b9 +source core/src/main/java/art/arcane/iris/core/pack/IrisPack.java 23cf378635662fc0195ff6cb3f5c4f0e6b8e29a12903eb7d9e50dcd9faf87173 +source core/src/main/java/art/arcane/iris/core/pack/IrisPackRepository.java e1cd483e990faa5a8a9af998de4be4d7d9be7c0b138b439bbc33e166d11638f5 +source core/src/main/java/art/arcane/iris/core/pack/PackBiomeLayerValidator.java 13778b2f4708bacc70cf90a8e6036a5359bfad8b4725af025456c2b0b85c978e +source core/src/main/java/art/arcane/iris/core/pack/PackCaveProfileValidator.java 5fb21a5cff6d717c3fbcfc43ef001ea1b82a1686e9ba04f78d3540ac0158d8d0 +source core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java ea5a474593f5854e538dd3fb94c64cbd67aa780b5742322db74e1b3c65b371be +source core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java 19cc02ce077c9bfadf94dacb2e58290518953dca79aa882af57c8282541e5c32 +source core/src/main/java/art/arcane/iris/core/pack/PackDownloadExecution.java 3275cc7709a865d95dffd8b4b406bac0ee0a4ae2648f67654b396aa95945eeab +source core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java c1e28224b8a233628df76272fc666f9a82c5b38b9a5c25413d1c07966d85d1cf +source core/src/main/java/art/arcane/iris/core/pack/PackExportClosure.java 7fb2c73c9e3d3179beb1f5da4104462f68cfa0c54a6607949d161783ab0bd86a +source core/src/main/java/art/arcane/iris/core/pack/PackGeneratorDuplicateValidator.java 6b42b685cc9ebd7e4b2b3eaffe131d73c0037e4a8a9d114593de5383fc18c391 +source core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java 5e794743d74ebcfce763e12f0afc102669e16411915c50f7034a085718667d4d +source core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java dc006ea8dea2edc4146f3e0c1110f30396f36568036899ea17b36c72198c0cf6 +source core/src/main/java/art/arcane/iris/core/pack/PackJsonFieldChecks.java 119b8a55673ac754aee819fdd0925f0cd666412d7e02746c7e6f2b7804f204d5 +source core/src/main/java/art/arcane/iris/core/pack/PackLootValidator.java fa303aa998b7d81bc6dfb76f729e40b3b7e4df08105de612a33a5f8c13328576 +source core/src/main/java/art/arcane/iris/core/pack/PackNativeStructureValidator.java 878765d22f222acbd347a35a94ab3dbba9a26d6802c4899c30e7b6d94782bea2 +source core/src/main/java/art/arcane/iris/core/pack/PackObjectSurfaceValidator.java eb6e50dae0c2268bc10551ab10efcc625e8f70ea48ff3fcfdaf87c58a6a6cd66 +source core/src/main/java/art/arcane/iris/core/pack/PackResourceCleanup.java 97db29cfd45f76cb6ce7166ac2d1234faaf5f6283bcdd7760ff28b3810bd6b1c +source core/src/main/java/art/arcane/iris/core/pack/PackSpawnValidator.java f7548ed3e4bdc62f321521b719f4956c05da785e03a31c24e173f52c1d5ce510 +source core/src/main/java/art/arcane/iris/core/pack/PackStaticObjectValidator.java 7a208ffad08f4c8264958aae0cac0933132ad07b6fae483bb300a6a18c0de9ce +source core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java 194d66f40313ab2ddd3fc259d7f140b92590e515c7df66640f740591ccdf5cd1 +source core/src/main/java/art/arcane/iris/core/pack/PackStyledRangeDefaultValidator.java f09eb46e4b97e73b9db2d1e7e0d74496488ad4648ffb816a44e4befddc8b5775 +source core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java f78e37e77fae41da44614dc40bacc1c5dd30866e85f935bf48ad26a1fc932d0e +source core/src/main/java/art/arcane/iris/core/pack/PackValidationIo.java f4fd445405401269021abc749699ba0ec9963baf3441a78c3d945b08dfdac953 +source core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java 196417c667e06f93878c066f414b9037218ad6a43629b2b043c29e1cb5f17105 +source core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java c334c47dae069a1bd8682302933ee54c955744929ec80d5afb90fc47f3665783 +source core/src/main/java/art/arcane/iris/core/pack/PackValidator.java c7b87690ad6e1b98f31ddfdbfbed34dbb4dfb82910cf34c5b14b2e80c48534e2 +source core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java 2e88ed567e1f477f1b77450932983ebf5c9e2fae866a66ad4713879f1f233d11 +source core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java 6d0f68a637ef260729d5e67da6b2211d204c78fb2d07866467923740e9f9ffb6 +source core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java f06951398de67eef6d2ea60c607673113353991b3c0db838ea4ce33d73d35be2 +source core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java 8a01dfff59d07b75f90edffef9599d9a0ae44b3a33cced65af190fd9576690fb +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java aa6fca878aedd70c394c034cf62b6b7a0051875275682b154805121422da00d3 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java bdf3ba2e9e2ccd4b34d272635bd615de4be7a09166ed01798811c0574b1d24d2 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenListener.java f7058614dc01a8977075a2fa5dd428a2564bf26ba5e745aac67083ac07dad27f +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java 8d0e49cf5d2a7ca022c626f74dbd0afb89b5c9150fcc6d7bec41c0652fa65a46 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java 25ee55fe54b2cb1b1dbf034e8a4bd514239c288ecb841ca5567233cdd77d9c48 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java d57637b5a9e7c8e09c58e71fc8a1ff23eb295435c9fcd411746d5e8176a9e015 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenRateTracker.java 53304e35af5b0e32b86dd75bdb1d4052630150ace2751fc8ba86ca38e94ae527 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenRates.java d348acd7898d701e71bda83da20eb9ba09bad4f6779966ad87aa8a60522c3c8d +source core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java a52c1ff8be510f5357bc721b88e735b6bf14c389956f50e249f74f8970af2320 +source core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java 6b259a5b14fd513c441f26c053e5713a646b7aac88542dc5c4e53f6aa4c008af +source core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCache.java e6af20dfaeea6967ee0d561631f612e0b2dae7e22182773001844d3522bd79de +source core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java 2dafaaeb0b5bc8e65db9b0cf25ed2786cfa0f561c6712b7b5817568c239a9eb6 +source core/src/main/java/art/arcane/iris/core/pregenerator/cache/SynchronizedCache.java 22b8954a43725acbabaa1e8f37ecbdc3bb2f9c90dfd5c2833163ead500070669 +source core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java 7736a76250b1a2788743b5660da97b72c009fe63aaa0b5c47179d6fd008a32d4 +source core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java 629c893a2674d731192d04922888ce3c435b4b0dc0aa4ee64b95db17bf3cf5d4 +source core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java 0321a1c4e3c3766edece992b7377d7f64ce208d9b83e48b52b3da769c0544bd1 +source core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java 7911cf2d23b1cdc11dfc00ad0b6d49cd465bf0336187aa580b332ff5b121a24a +source core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java 6d1e48f1a8b2bbf5359b5231fd620ada6a193f7073de6ea8a28ad6a8271ed4ee +source core/src/main/java/art/arcane/iris/core/project/ImageMapStudioExporter.java 4c9ac3e780419457dde5651131db38c2a9543a11991a030da7ecf84466ff6b68 +source core/src/main/java/art/arcane/iris/core/project/IrisCodeWorkspace.java 405421807e4b4a62cca70deabca9466ea1f95cfb34de69c1a46e712c0d9ab3f2 +source core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java 41f4e56e18facbbad104f6b6b3f1303e24a4612f2b03ff38de5cf3183d306aaf +source core/src/main/java/art/arcane/iris/core/project/IrisProject.java 1b3daebe71e2d5651cf032fc7cb0e681704f6d507319cedd18953c8f12853b09 +source core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java 187f61104888358469e6ab93a51e7189668ef8ff4196cfea33e1faba461ddb8e +source core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java d6af3c1bb7df4922159daf69aea23493b59ebd095f4bf3cbf86922e8692f8fdd +source core/src/main/java/art/arcane/iris/core/project/SchemaKeyedTypes.java b326649564af4197e908216acabddcb6a07b2d5025252792f440a6f25eaf0acb +source core/src/main/java/art/arcane/iris/core/project/StudioOpenProgressReporter.java 3fa2ca5c70333e5b4b2c1ee5472c9d987ee91caf361c08e46f68a108f064a55a +source core/src/main/java/art/arcane/iris/core/protocol/CursorInfoRequestHandler.java 8922d56eb28cfa598214341f9c505a01f5b548bb14740847bc44ad697b43fa4e +source core/src/main/java/art/arcane/iris/core/protocol/EngineResolver.java 9b1b4fbcb3fab969812f6e9ff1147d21971d1eb3027e0705e85733d915354f9d +source core/src/main/java/art/arcane/iris/core/protocol/IrisCursorRequestService.java dd798abfbf387d29c65d1b581d2f82ba564e0dc5b25cb0187281393062915b34 +source core/src/main/java/art/arcane/iris/core/protocol/IrisCursorResolver.java ac0b6f29ee3c8e38ae40f844295775dcc478fe9fdbc7b466f4b9e8582371e4b1 +source core/src/main/java/art/arcane/iris/core/protocol/IrisProtocolServer.java 76d55691c7711acb131eb8df837debe1fa43fb80d929b4c4d31b4192eba3f015 +source core/src/main/java/art/arcane/iris/core/protocol/IrisServerTransport.java e82859ae3bf1b4ad60c99c49ce4dc9fdd40bb2257f74e276366cdb8a012be8f4 +source core/src/main/java/art/arcane/iris/core/protocol/IrisSession.java d3ad1a90301125908f0b814bb3250185f13c36179056aea5aaa7444a7d8c06d3 +source core/src/main/java/art/arcane/iris/core/protocol/IrisSessionRegistry.java ff49a87a6158105379d6c63514b4008629dfdca2a57854e020ad311c64b3f67b +source core/src/main/java/art/arcane/iris/core/protocol/IrisTileEncoder.java 789cb71f026dff4286d9948613fd29ced979f623db03786d7daa0e9dd2944c4b +source core/src/main/java/art/arcane/iris/core/protocol/IrisVisionRequestService.java a0ce1b436429ad1d7f6542a871d89fa044404b3c46c70e94cfb2c6129d59ddde +source core/src/main/java/art/arcane/iris/core/protocol/VisionTileRequestHandler.java 3ed8f9cadbb17996f40e021039a4985d32454249fe6b529099e2a08cca3140b3 +source core/src/main/java/art/arcane/iris/core/report/Report.java 2c8b24131637c03a8670e275d549f24843c9d62cdb09dd7ab8a7869c2864f3c3 +source core/src/main/java/art/arcane/iris/core/report/ReportType.java 65a83094386ea07b8cfd2cfd17ea441152158fbca162c8e61ff5f783f9288b6e +source core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java dec12aad794a8c8989a25a33db6171d911ac429f1715f1e55f7bc4770643207c +source core/src/main/java/art/arcane/iris/core/runtime/ChunkClearer.java 127315079846b30e76e68c20124471d91133af7d81bd14e70d74d400e7d4a7e5 +source core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java 6947e54164d2f0d2616b517c9f502b1b609927b42292e23c9e768ba8061f710e +source core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java 2d5dc166e272d66ece78c7ce9989aeacebde32284cb826fdf17e76cab7bee130 +source core/src/main/java/art/arcane/iris/core/runtime/GoldenHashScanner.java 2b47df172ebcb7dc7f8cd29639661b9e1692710891740e9bf59227b351a124bf +source core/src/main/java/art/arcane/iris/core/runtime/InPlaceChunkRegenerator.java 3f47b05af871b8dbec12bdb8e269dda1287eb301f25a800d6048d20da2859859 +source core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioActivation.java b3fa731a267ace9e052f1d4cbc437ecc1b6102c2c0ae030c21c9262c41069386 +source core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java b78868e5adefca27c3f1f9544256317b1949fe208f02ccee7dbffc61c389e129 +source core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java 443047b33624db675576a189c843952854fb8852c9df979eca044e8f0e3c23e6 +source core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b501913f1704a3e59bcf6982d6683110565c549725d00311061a7dbb7dc0ef88 +source core/src/main/java/art/arcane/iris/core/runtime/TransientWorldCleanupSupport.java 54fe5614808ffa4cef920e6428f6a1fd70c23d01e3ab16d75fac862271dd2e97 +source core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java 4e77be2cb6c1bde9abc38253208c4a86bae0e575d8b6ddd7defdada414248ac3 +source core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java 0827d2b8412ffdc33ab35f1abb732584f85935dac3e882e9e04994b0cfd3fcce +source core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java 1fcca53857eef148692ebd83c0fc92e691b79424914330d74c9653077a381fd0 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarArchetype.java 7ac518d17d1ce1759551fd9fa6b2763e8d6e976747e941e21cca75dce8a4a6e9 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarDirection.java d65637cea86b0e68258130b7bc265c3886fe9c094a3b39adc3a028febc5609d6 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopology.java 75e8515fa423f1aae90ad0b11077806340d9fdd53b6b8d58a1719d9ae8831646 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyKind.java 6987a27bac81d6c7a1deef244756e4b887325582da5ec72a058a2a9d62d97d8d +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivation.java 978182cc8d95b33fe86d7daf2e8b45d0495c3e682cf6d8225fbbfbdca89831f4 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioAuthoringAccess.java a021aba93078344f44df69e86c08b85b1dfb38ac0814795d5d98dbb7397f56d0 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBay.java ef8674ae39cc9fac2c0b10d455e622740754af356607e7fe10f611eb47c303fc +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBayKind.java 009ff01c4d87a35583703a7ef9b7c6ae8ddda3344b1b79b1d45374c8132fff60 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBounds.java 13644dd511bfcc19bf19d40dddc2ab90e40bb5b96a7ab8b3c38d431b7ca09913 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCellDimensions.java cf0ba6c8609e42651707152fef546d82a33bff53777d86babecc63979e2deaf1 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCompatibilityTarget.java d454cbc32ffbc9fda805a32fc2c58c01c3ebd67f7ea3dd8ad596dffab80a56d3 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioControlPosition.java c02f47417e89cf96ace2ecd1d94d7c8309afce01985dc05eb3912e845175c87a +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditor.java 1f3c472c64e30ce717f199b13910167ef697f4ea321d8bfa6df2e4b570fac165 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapper.java 80aa753d91f42234586b3f056f7599c3bc34e654d0c90b0e26882e0cdbd55447 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayout.java 1840b746e1fb64f4a3c669c39b61f55556a7afa3dfaf20c2eeb44226bc4f6cad +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMarkerKeyCodec.java 18b450bcf3751a5f9a3f95e8b2458e5404f137e1e855147e990a419456d2ed29 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMode.java 278833b49bb9cc213bef0088ddb586d823b7479019ee0828d3ad76aaf2b6d1a4 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPieceRules.java 1a1293e33a18122efba628d613b08fe5b7c36528ef3a926dccdbc9f6f8c1b9cf +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolEditor.java f973b8763189a822b8c0d658dfc19c1962bb174d0c620e62dff65641ef4b77b2 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolMembership.java 682cb4feee2aa7fbbb969e260e5633d058b85f208dcf1159f97db552fc420b87 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreator.java ff29ccc8f3ea1665dccbb8b0310f649e2bdd1f8defbfc2aaa4ba3f7f4cfa20b8 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectDeletionService.java aaebdf801a66baba401a57a5d4b53a8e0d9032b4b36de031f94c2950a0409dda +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSession.java 70da806b2ac637486bd78fe14a80a051e0e41801262646162dae1cf3dbb40e08 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioStructureEditor.java 636964d593e7d1133b4c48cd48c736b2d9005db1577b197d1398ba35d8d708b5 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolAction.java 9f58c7f7b8503e23332bbc9f5046a867fffe536d3e729e96a81c68b96e6c448f +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolPayload.java 5aa47c18e949f55818364090f459e1e5acc2d9b0197590346f5532dd82aa5580 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTracker.java a7cf91e40ac847f7af673cefbadd11d3e321fea624609c4713f7a37c206d5364 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariant.java 73d95289d7385ee38900409371516e8ecb4712f7d423241b7fa23a0ea6c12f43 +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariantCatalog.java caac4475f86254a733eafe8aee1b19e5f4a5bae4efa8b1b9ee54dba0720b3fef +source core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioWorkcellSpec.java c0bb579f674ef0d8a98c5ec78fe694a25cc77dccc64b51d95b4b62e4703c18ce +source core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java 6e8ec60b98bd7940f208195e70a2af68310ebe5591660e66e3d78df021ddaf97 +source core/src/main/java/art/arcane/iris/core/safeguard/Mode.java 6a9f7b5d70c26982dab81ad2ceaa5e5380d546b746a86fc786d3e5e22e3d4007 +source core/src/main/java/art/arcane/iris/core/safeguard/task/Diagnostic.java 037f9d0f50b5ae3cd3a00cf0f143541f009f7b06173bf18bd831955ac815234d +source core/src/main/java/art/arcane/iris/core/safeguard/task/Task.java 9f6ddd3cad020d36ab35b63b5833fc66767e225b1653a0f80c3e7e0048b5f58a +source core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java 6a00df7216cabf8aaade159844ecb7bfd5d6430c48dd60602be237b9ae9dbb07 +source core/src/main/java/art/arcane/iris/core/safeguard/task/ValueWithDiagnostics.java f6690608c8a4f935f383dac79f91d7efd08d392889310fd6b6e03933f8aa62d9 +source core/src/main/java/art/arcane/iris/core/service/BoardSVC.java 39ef26cec1aadd925755703b3d62be21755e7c72e23f807f430b2858867f99eb +source core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java 9ef59f55500e8fbe0b95b4048da962047653223ee1c23d0d8283fbe4f0d5792c +source core/src/main/java/art/arcane/iris/core/service/EntityRiseSVC.java b25c1ea511bb23905182675234f6297604c328dfbf96a3cbf2113e649ce345d4 +source core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java 3f39470951486396ce81e00af9e8d1f4c20bd7848bfb75c8562fd76567ba4f5c +source core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java fe489117323f2713a0002c1d2a72ccfbd37c5fa79430b5147838b9d22a2f05f7 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardContext.java b0058fad41aa4ce12e481a31e77503fbd2dc4be11665ca613cf98de309e8efd2 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardState.java d54258d6581468835c3bf30f769d5ced534b0b3ec3d1f8390ae1c6d77902925d +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioEvaluationState.java 8e9a93e4bb54070158a929d9e171115a83accff6ac63921f15e1238102376197 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioGraphEvaluation.java ebb768a6c8741fea4459f929b00a83a567c642e7b76b3f8ce463771620479380 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioHistoryStore.java e69d90723099c1eaf85366d4edd645a48393b526499ada860fdcc9ed151d0279 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioMarkerParser.java 6c315e71f46d69146fdbc0be86c2d5179701d35a35ef2a0d8c258bd85099bc55 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java 1272f8e7f5f7b17ee5cbd27a5f566c84905f3922ee29c8fb8b2b4531ada2cf5e +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuState.java 6962d2b1fc583d12aa955e82207cbe0b877cce7d80b5e1dbb2bc3a03a6edfb7e +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java 3440d07233625e0188008ddfadae72b5ae51257f2df920beb60ef88ee1aaf33b +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssembler.java 02fd237c6a8324a6e219e1afd09ac87ae030c5432a0140e153b0e0c9b059c219 +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java 061f927f557b12c70f8caf8886c75e98918f1dff7dd85a2ac49e11292436b77c +source core/src/main/java/art/arcane/iris/core/service/JigsawStudioToolCodec.java ff89fc0aa377a350ae6013a216784a2e00fe8dc15dd866a68364cf99eec0aa29 +source core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java 7a8e320acc2235e85452fac81d39c01f328e686b2ae2eac2a7d4261a0c27cf59 +source core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java 63e2d46b4e142d7049edb098b0f8d59e595fed30abfe035a0868298847938d55 +source core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java f1b1ce5e4b40b366d5a92765b7ca0119af3e933767080401b82ccec09a325c38 +source core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java 45104a4f7496ecdd9764837fea59fc580e8ab1493b3626c02cfce898bb520268 +source core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java db20ad4b9a2dafd9daae8fd05872168a5ff3ccd6eeff2823e128d3d5a720c371 +source core/src/main/java/art/arcane/iris/core/service/PreservationSVC.java 19efbaceba5a3445987ab0db9069ec7962c8a374ff03e5460222530e423a8287 +source core/src/main/java/art/arcane/iris/core/service/StudioSVC.java 986daa76b6feda6e7e365f12cc67d3f93b250e6bc8046c08583ef7c5b5295bf2 +source core/src/main/java/art/arcane/iris/core/service/TreeSVC.java c2e9e9b517200ee449d081e718e34b1803c9cf39eadcf5e7f8eae8f10bf7406a +source core/src/main/java/art/arcane/iris/core/service/tree/BlockDropRouter.java ecaf3263d807f07cd67b83597767bfe4d53bd34e7456c40f6bc18e6d4f2e7a50 +source core/src/main/java/art/arcane/iris/core/service/tree/TreeDefinitionIndex.java 37bfe4dc7b97c9cfd601c9d2c1b858a674338e12a0daf2f9059b1be0fee1191b +source core/src/main/java/art/arcane/iris/core/service/tree/TreeMarkerTraversal.java 2d8870a50fcf451dd47c18e999c30ab24164540ebd35d45a78106fef436b8b74 +source core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java 5d281dececccfbb4f71079d842cfdd3999ef90f2ad7742d0e48a2be7fb4864f8 +source core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java b43ba3104d46d05a16203432c9d85eb1e88c0b9eec7eeb7e2c1a0b187cc537c2 +source core/src/main/java/art/arcane/iris/core/splash/IrisSplashRenderer.java 866de255a052166f76ebe68498ababe9edfc2abdc92c310655c8a7cc1f272768 +source core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java 3c8d9062ff38458f9eb841d01163642390b0360c01ceaccbcbedca1ea9d27522 +source core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java 0859766e81005afeca0cb09695de99d4704ad6e719fef6a179336ebc896ecb32 +source core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java b57df7f806c499a07b7cf1263b6afa495e8f3f128732c877485138f502d84fe0 +source core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java af50d86c34a135305037f203580938f86e287a3e170562398c35e091a276b22e +source core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java 6efd5e5071763884a1a7e7a99f0f1beb5388a8735dd570608b1c0486c376f9c8 +source core/src/main/java/art/arcane/iris/core/structure/StructureSchemaKeys.java eabe12088f14f7b76692c37e17494073960d93e310d95f519f1c829fddb7c2e7 +source core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java f73042d512f177f7ed7732e0d08afc2f2a7ac961785b2005fb6c035e9023be8f +source core/src/main/java/art/arcane/iris/core/structure/authoring/IrisStructureBundleFactory.java 02da3743e55ec3930cd02660edf7879357694e737cfae1018529bb7abe54b05a +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureBackend.java ace3b5afe06f69dc1b70999efd527062963f39933ddcb995fbdf5438ce841d19 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureCapability.java 9cadde94909c49f02fd959003225a0495fa51ac385390f9951cf5e33da8e4c88 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java 4f2805a067b2b312f3a6100a928b94984a942fe71a58eb5967783f9a35b5f1bc +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureHash.java 4967d159c691d9c4b6e904e4ea46da5f30d4be0ed78fc1da937bfe597781aa31 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureKey.java cc5c440fb9aa262d8305c8efff5c922147cd51c83f7a963f10133edb07406606 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureLoss.java 360daac29ace9d74416bda012f262987f474d3038197c0bf2ceb802c94930c31 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java 3a064e816af6c2fb1054f3096fdc6725868ca09fde98ae38a35ea91a29d30bc1 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureRecoveryResult.java c3f0d6dc90822a8e1371451d207fabebe8fc675321dc587371fedbfe8c996246 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureResourceBundle.java 09588de332fa753cfcfba96d44b800873aabab0cd4cfadd77149eb7d0df26a3d +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureSource.java 3062cdd11c8a0b5da05592e3a346aa89f276613c5be3eb1c37d4d137a76f252e +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java 6ff1b555a1dba60d217991d491c2038896f269647c68beae98c9ffff484fda38 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionReadSet.java 90cbff5b223737455e891095896620ff35139fcd84c3d7b98e52d34c075d239c +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java 8955d856c9e435c60207ca7d141c8e7992f3c8d2a5251db542b11164ff0c5469 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteMode.java ad751c43dc034bee19a9579386256ad5a79b4a26138bb15ec43b991c810b36b2 +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java 742b0ef567a48c82d1feae463fe40f25b2aeddeb31552798ecf80a5e3e58820f +source core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java a011b069989e78c5a3d03ff5a56e516a9d5494902c8410a6ce9a55bee9d20bbc +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDiagnostic.java d7907300dcdef922c44c4c0bc2e676a2efaf4bf4fd1bc908bbe3d395387dcffe +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDisposition.java 676b24b2f15e2c3e1be019c773f775367c68bd0cd25754812472c689739cff94 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionInputKind.java 97f10d973342d66d75e1a19107c323d3837921561f1da7f6248a5d7e08e672ab +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionLimits.java bf5eeaddaec24b136d758d1f33bf2a7ccd930044e12e3b158590e06759d3cfa0 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionPlan.java c183acf72edf0e98ae27fa8208a14fe5a4db2b83283918093b0945d4d4688ff6 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionReceipt.java e37b2a67f085fdea6b7fecedffcca2dbd2a3a9f4f9e107b301c561df88068d40 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionRequest.java 9d11a2f5a828e382de7b0bb842eb5502979d1f78de4fa90cc2bc6a9650e76bb2 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionResult.java afb0f7e2284207d6ccd1cd8ed0b3d9313216ddeb4338c4063d0d21a5cbf9ec1a +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionService.java 22d8278b06658a73fc0462175119997151d0c56a6808f3610add8512990b1e37 +source core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionStrategy.java 96e3f8df4d12ec829b687a0bb0e77f08ec1c9062fdd3d7325b200f6409892360 +source core/src/main/java/art/arcane/iris/core/structure/export/AtomicDatapackPublisher.java 2d23368731b42bee475d6f5fd91f36eb03156c198f41d95939f28c99ab704913 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaBlockState.java 80815c775d2c1b8df6153bc239788ab549708891341e0df03e44be619bbdad5d +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporter.java 0c6c1377dd7c07b65d0a65f4f1564e47d3709b7df94d319d8022dfffab2bcf36 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportCompiler.java 635bffe86d3ae3935faa4696668adb0c2dca0e006fa1554a2a72586b5dbb4b69 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportDiagnostic.java 00640fc4ac1e9f6bb5ce5faab0cb98f960aa1354240dbaf12cf1c39c57ba12c2 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportFormat.java 331851b55f524c594a646716b61e9384a1419da9c2eb8a5e7e888f936366973a +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportRequest.java 88ec95660d7a5b9fa2c6a0cc9bc24f6a23993f61baab04d5f0e8fa924c36951b +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportResult.java 593733d6360278a6b32494216adff361cd0b24c2280e4950a6de03df718181fd +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSettings.java 993378717d72a5eeab0d101d963021f7313ffdd44e0face6beb0c9a1224698f1 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSource.java 0da758a570f45f53c9dc665a44b1ba6d0821ce5561eb243515ce7ff1a6c3502e +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportValidation.java 2bde24455a7ea4e9c92a69e4e2d4c0daf0dd33be8291fd56a6ea8409738de0d0 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaResourceIdentifier.java 59eec41602fb65e398a39ebdec8bb66888fa1fc1e0d5f03e77d31cac8ce53cc9 +source core/src/main/java/art/arcane/iris/core/structure/export/VanillaStructureTemplateEncoder.java 49f907702a05e1752b0550333d8520c00c1d2720adf894f6a346b629cdecf7ea +source core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java 27dd734ac27b6c0c61a59b864177a09a81747de96e7cf442fb33609ea73c856c +source core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b9e2d8654385ee104936a070170ff6a930c076b9be49dbd85f163c3ec508e391 +source core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java aae81460e27818c657feac112375f3a006b2d52b4677d177dd00af7509ddd400 +source core/src/main/java/art/arcane/iris/core/tools/IrisReflectiveAPI.java 8f79765d8c267ef3ed56331771178dda3cbcaa7c231a0bb953c1af2e97dbcbc3 +source core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java bdeb0dd8d29cd4a27442257fd254be8fcddf6e8192f03f7c92b1cfc186c064a6 +source core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java 7600864f9ab2816a5a5a3aa9383e59af54d8801674730df44c233f20c4c2b2d1 +source core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizeBatch.java 4e7a7da1047e4ed19f04391429134f970465d4a9237e2870fb6e98a56381d869 +source core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java d8bce0161edd72aadbd95f646f46eb6e4acc3e4feda8b061d39996bd7f865dfb +source core/src/main/java/art/arcane/iris/core/tools/WorldCreationProgressReporter.java 099aa1298326369af0f48b22f6eea627c3a9d94a6df4c04ba209d615723d9527 +source core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java b8b8310ee456b841df1801ba96f3ba61329a84271c8d7553a0e4c7a9555431c7 +source core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java 2a6816ad45ae2bad1dcf85c787b1c4b98373243a7614e05fc2e4e867abee04aa +source core/src/main/java/art/arcane/iris/engine/EngineDataStore.java fae22d19d7f159f132b79874f1a2d2e701e969fa156a5d54c6c300330e24cbad +source core/src/main/java/art/arcane/iris/engine/EngineHotloader.java 7ba49ae011f893f0196b45b51baeea68d95a0ecd08a18600db52b1b954de8ebc +source core/src/main/java/art/arcane/iris/engine/EngineMetricsReport.java ac15c31f8a3b2c1feae0dc7209816f728e39bd62ca6a74855e6926fb220d3774 +source core/src/main/java/art/arcane/iris/engine/EnginePanic.java bf6eaec2ecb1212f39706cf56759d927ca90dffa90acfe9bb75cde70019e4d61 +source core/src/main/java/art/arcane/iris/engine/EngineRuntime.java 423e70bf2aa84a465d8f3f643153d6436ac6748713247c8ecfa3f4de030f26c8 +source core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java 0bb62209d412f58826e62830e49edfccb984b817ce17c389299688a40421ee59 +source core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java 3b2b78f4ff64903b288c66da7efd01adc25f25e5f2db20f4475a3d27fb1e53ce +source core/src/main/java/art/arcane/iris/engine/EngineTickRegistry.java 9ae20d3c6304ed7eb3676f0d5947d44f3f5591a2c33bab2d1087b8d68f3a662e +source core/src/main/java/art/arcane/iris/engine/GenerationCacheWarmer.java 821455b42549511328dfd90e5447dffcdf8ea1311490f1b0ada2e65ca1a311d2 +source core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java e7c263078fc428b84db42fa7d9d5ea84d807aab5516049d3577692c8d357e771 +source core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java a05de937d2d8ed1b5017549f1083e5f6592d944fa5a136dcfb5e9bc33eb78f2d +source core/src/main/java/art/arcane/iris/engine/IrisComplex.java 257916da58a83632887123debc9f0dea9a63087de61391550774982f5c613fb6 +source core/src/main/java/art/arcane/iris/engine/IrisEngine.java f713443a6823cdf1f011c8733e2ffcd52354bbff7bdbf639e7e266f939e1f93e +source core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java 67cf96aed6c213dbd6d6cb81866afd29cd30b555cd4e88712395bc089e50f43c +source core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java 2c4251a6ad59b5435488fc3b83191c3753d5e356bc5c58832bba2c56a3a5aae9 +source core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java 5f441c4ba85045b4c273e934599e65df0122586d97cb23ddae3ed76c9471c759 +source core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java 8ef17c72732a387e49f401f53ae71f6e9c2e9d814018418d59ec4a6c4f84cfee +source core/src/main/java/art/arcane/iris/engine/NativeStructureBootstrapBarrier.java ba6545337f8547279e49f613fbbcb1c2f53181a8d3073cdbef0aad5bff425b91 +source core/src/main/java/art/arcane/iris/engine/SharedCornerBounds.java 75bb0a41f4707ef2435dcb9c48aa85816e685c7ac15dd3980eba09ae07a2e4d2 +source core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java 758aa1c40a41befcd133a6a46c3ce29af6309ff8aa160dbab53ab33afe4cbd22 +source core/src/main/java/art/arcane/iris/engine/WorldBlockDropRouter.java bd39f4ea0d47a2c959cffbb0a516767d49d84c279d3ef7b6e032c941449c55ce +source core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java d3b3b2eaf6094661af53ebbcd85fe65ef8a4ec82f045623c3a585b4f89861903 +source core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java d4ca0060064752e5651b46ca3f124c1e731b2c119d838dacdf86170048aa2639 +source core/src/main/java/art/arcane/iris/engine/WorldTeleportWarmup.java 4726b21c78898e45f90df758f87921f3b72d9fac8078893e06bbcc2f05308bfc +source core/src/main/java/art/arcane/iris/engine/actuator/HydrologyFluidLayerSelector.java b512d17cba3769f756144d1e7ff740a4b6ab4b9ea0e1b08162d5d53bf263d998 +source core/src/main/java/art/arcane/iris/engine/actuator/IrisBiomeActuator.java e30d6493f64eedad56ced1c113d9c75ffec303982537510fef15d92b656c3831 +source core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java 9fa53a4e10473004f53a21a2a77687d83b0e48a84b9f3c533e04fd5829c8651a +source core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java af8a315ddd1a1ba4cb8194282bc470fea72fa30ebb4d429e118057952e05085d +source core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java d30843c3a1d726737164a96109a2be2cca9c7111611b769325c409beff2fd436 +source core/src/main/java/art/arcane/iris/engine/data/cache/Cache.java c5bb7071aad28a7992d005dff53ba2ef8accdbb77e5d537af4bd15874cbc0e57 +source core/src/main/java/art/arcane/iris/engine/data/cache/LazyBoundedCache.java 2feda25bc1ff17396e66e86346bae39ecf72fb48ecfc016b115d95107d2c68a0 +source core/src/main/java/art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java 443641621f5ecfb95334a37d86f925565720ca269d2a197858c2d1e537a7b4bc +source core/src/main/java/art/arcane/iris/engine/data/chunk/TerrainChunk.java b917e0d50bc2f285dbd94501f74bed594ff62af05c4b313aad243bb166c7dc55 +source core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java 9e9fed190f83b3efcc0da205de60551b422dace9d39cba97b507491aa443c891 +source core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java 1294cbd6001a4a88b105ba59bcd83cef74473ab7cd5e50a2f45e181722390b60 +source core/src/main/java/art/arcane/iris/engine/decorator/FloatingDecorator.java ed178dfcc566b0d42d3df5f2bc4dec09d37fe76e5b79d84c411d76e47fe377a0 +source core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java 62e56a07facdce70cb235277b851fa356193bdf2258119d96bfb554690c1dd70 +source core/src/main/java/art/arcane/iris/engine/decorator/IrisEngineDecorator.java 033d20c8a8af3b51e80ea348e0d1249ac0ba297db735a77096fda1ee4eb1330e +source core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java 0dd3099a46d431bdf0a22a3421ed15db6668117e48f915cb8a2d848a378328e9 +source core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java 63d13573ebf6eefcdf699e05d748ceef5e666741d7ffaec3423a09dd1608c24d +source core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java 9fb4a007d87c13e11960cdf106c304c7f91777e4285e60e2a8d220422d31ade0 +source core/src/main/java/art/arcane/iris/engine/decorator/IrisSpeleothems.java 0be913b5f2f7fc3731ddf501982e78838432bb7aea91dfc3809ec52324621736 +source core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java 7eb072f6c062442b20f4aeca8631d682fc796b945c25b961df14aad03f2052bc +source core/src/main/java/art/arcane/iris/engine/framework/BlockEditAccess.java c27a4e22ec63a099648bca5bf60478b4410e035aaf16063819f5d571617469aa +source core/src/main/java/art/arcane/iris/engine/framework/BlockUpdater.java 453e0505b4afbf5e869fea947149d6dc9624724145e7af8db4be9533aa4f9134 +source core/src/main/java/art/arcane/iris/engine/framework/BukkitEngineWorldManager.java 19bde699f439a085aa35c97b383e75502f70aa2ea3f1564ed87cf7e593fdf8ed +source core/src/main/java/art/arcane/iris/engine/framework/Engine.java ec645aed117998146e037c7046c6e42a677d8972281ca3feecc2ee61d86e1565 +source core/src/main/java/art/arcane/iris/engine/framework/EngineActuator.java 2cdf4accb678a3d208c93d8015203f2c3deb0f96c5e83937c8b2997630300186 +source core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedActuator.java 19eadbaccd498c1998c3470a3d1f22c2a413e256d4df03cef635392c03d3da25 +source core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedComponent.java bcd525a0c57631cdfc861344a04769c8b14e2b9d68bd24b041693ad83f77ee3f +source core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedModifier.java cacc209c920c3a708462b6d50437ee81b6318650bbfb3215c7c828d27e0137b5 +source core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java be45f07902215f0290a0c8aba70d5a67301a7886a0a29eb77ccb7f472473254d +source core/src/main/java/art/arcane/iris/engine/framework/EngineComponent.java 529005359f213ce1456eaca6ea86efcde5ab2973b83d0d792cf16c8bffd906f0 +source core/src/main/java/art/arcane/iris/engine/framework/EngineComponentCleanup.java a75ed490cdec2e52de958b572b670ac686a309119718218ea14744b0ccecd0c7 +source core/src/main/java/art/arcane/iris/engine/framework/EngineDecorator.java efbf2c8b7442fce1ee5df591a05a8a1a2af27c43a197d0f2d1390cf9dd24bd03 +source core/src/main/java/art/arcane/iris/engine/framework/EngineEffects.java 97bab6dbbd3502cb8f44e1e8ddec7c848ea23a903fb21b5d25a1ffc671e2d412 +source core/src/main/java/art/arcane/iris/engine/framework/EngineEffectsProvider.java 5cb096768fa3a59e677e0dff784ba76ee4f0fa806c9d993858c40590b4038fa0 +source core/src/main/java/art/arcane/iris/engine/framework/EngineLifecycleTasks.java 8363f850ad7b0f65340e0b0c97bef960cfc259a2c2e84329be74edc5c993fd9f +source core/src/main/java/art/arcane/iris/engine/framework/EngineMetrics.java fc1b0d5c90784da7fc25c7d1ced54e74c37745aed82dbb0e7eb59283a270d234 +source core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java fb998a7111ef2c663ff2030d7028a8d7932469a19015d14714ceda111b6385e8 +source core/src/main/java/art/arcane/iris/engine/framework/EngineModifier.java 6cf63dc7142f19c33400525126728e6f72c4778a2d5ac1ec7471083119d8924a +source core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java ac85a866bdd55503f5175617ee1875f38128714ea3a055428e6a895bb8675aab +source core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java 9b4b1b8aea343b8aa4f8c3c0603283a54e8988004e9927f84567761993efebaf +source core/src/main/java/art/arcane/iris/engine/framework/EngineStage.java 68f6fe0411a883f3be7296ef007b1ed6d36cba3f71536380921111da1ddc9283 +source core/src/main/java/art/arcane/iris/engine/framework/EngineTarget.java 2c2b4a10438f51e0cce580506de7a0e57cbb4e9e9024a36dba134422c1b43794 +source core/src/main/java/art/arcane/iris/engine/framework/EngineTelemetrySnapshot.java fd74a583f89c81748f7bcc37f3571f3e9bed2ecec087ee49d3eb33d265a59a7b +source core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java 4ef11d18c377c1fd55499a60bc4959d2c830063f680d179c500014243c85ac9f +source core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManagerProvider.java 798bf4594bc8f59c3547ac2b3c56bae6cbad8397c43e4d7e94affadf8d3876f9 +source core/src/main/java/art/arcane/iris/engine/framework/Fallible.java 1fed3b4b882aa8450fe2595746d90855d43a0420b7dc2e587620cc37d22688bd +source core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionException.java 7e300e94b5d38636cbf35ec37d8ae4ef1e7154b30f4f7d66981523841d3e317b +source core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionLease.java 3590f8b6cd21d9f57ffb86445346c5f8bda9cb6159205b98196f20774e5c3cfc +source core/src/main/java/art/arcane/iris/engine/framework/GenerationSessionManager.java 12df8e96c27776882b719fa9b8bc58ed1bef67bfdbaf37c46890227ce14e19b0 +source core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java 9816b8413ca53c65c1f99617eda840bf5997006f7ca98ea7cda9b7bc8c88bc01 +source core/src/main/java/art/arcane/iris/engine/framework/HistoryAwareLocator.java a75d1db64618a7a51441ac2fe21c2047c2a47e2eda2eea9ed4902302b467373b +source core/src/main/java/art/arcane/iris/engine/framework/Hotloadable.java 167b07b43e2a95f840dcbb34bfc84dcf4a9f7b1c70936b25c7f08707af9934d7 +source core/src/main/java/art/arcane/iris/engine/framework/IrisEngineMode.java bc9814b96f7a550247e84e3594f99aca4459eaeeb3516575697c975c2e165f4b +source core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java e6719e9181daeded4cb0f68f88a2a05287ab974a77c1459ce44c89fec1550d73 +source core/src/main/java/art/arcane/iris/engine/framework/ListFunction.java d2d743e0d01a17827e889e04d2e1be10dd146abde37f9c6fdd9cf279f753e4be +source core/src/main/java/art/arcane/iris/engine/framework/Locator.java cbb3910980f4c39de010e4883edcda9cbae67b650dcd73afa671a5c91c301c75 +source core/src/main/java/art/arcane/iris/engine/framework/LocatorCanceller.java a7b9a9ff4707d318542ee6c1dd6b60ca276694f68dd53835659380d27526de86 +source core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java 7d12b1239d13201ee7d4ffa50f3376fa03de966cf2c9b43306d7d625d8cadc60 +source core/src/main/java/art/arcane/iris/engine/framework/MeteredCache.java 132068ab7b120ffe032ca58f9224d10a4e4ab3cf27ce1b6937be482f20488b6a +source core/src/main/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicy.java 8d08e4f4c84a1b40782a02a906e7354fb40f37094b5adab4ff63357e1c8fd3a5 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureFrequencyScale.java 669d0b3dd52f5b4be4a2c77d0d3b108aa1c08c1433321c1b98020d6f263f648c +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicy.java f909b7a14390923c01f6e471b7a9cfb80e50fe25886177b2c4a94090f0d0d129 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipBundle.java 62fee5319135775ed3a5de40c1eb7a84ba771b6a063971c6b949bdbe737f95e6 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java 30802a31e98bd802e88a149f6e23fcae53595df9bf80feb37f3e23cd4f422268 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java 4d2581de9c441e4bcd9199f9a929ca874ac9041360a7246bfca8480ae67a39c6 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java 2658654944626066fd25579cef62715c5f99160d63cedd0199ab2f80228071cf +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureStartPlan.java dff14250cb3d45c6a128fab52344f735ebdf5a248a8c473bd81565efc76f227b +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java b0343519a3a34f8f1a8422ef3875d9ae87e9f18908d3e147e4430a4812cea7d8 +source core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java bcf8821ab91afb81b88c7c7ae82f531deb85370ad04d0ba6b10a19edc4dfd357 +source core/src/main/java/art/arcane/iris/engine/framework/PlacedObject.java 533816a9d53c147b81bec266d6e02575ea3a491589d758659b89691db3d4b4a5 +source core/src/main/java/art/arcane/iris/engine/framework/PlacedStructurePiece.java 2d79a83d5c89b5c1914cf58bea7e4264f322968d77e0a038d0a2a5271085b586 +source core/src/main/java/art/arcane/iris/engine/framework/PreservationRegistry.java 9b15cdac5b1831c49b1861953d29235fea3c3ef3ad06d89641bb6313184ed4f1 +source core/src/main/java/art/arcane/iris/engine/framework/SeedManager.java 0738b6129967bef649f9bc0ec46928f811abbafbaff18aac2308823996650865 +source core/src/main/java/art/arcane/iris/engine/framework/Staged.java 7e6c7de380e58c6d39400795e5dfc0180fca8bd42886885f783e9db8671c4f3e +source core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java 4030b4a74ff19f27e1e6f7b05e63cde4b0f5bf0fda4a0bdc75d1c962eb44439f +source core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java 55dff6da5de9557f68eeb0deb7b51539f485c32dfc8013223c5f7a20187d31ec +source core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java 632aa8e153584fbc564a9c1e0aeba3292ce453e0c45ddebb24d1781ed05c4699 +source core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementMarker.java 4596be075ab6d015aeb9f3472f7ab69ee08d5e098b02723e8244417c63a9f3bb +source core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java c6124b8a74301a4113ea88c16ec6eb23ec42b90fa83ea7dcdaa08b284025393b +source core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java c71fcfdc18d491fd3b5408af069f9435d83923003f4d66e04f007be25aa1f482 +source core/src/main/java/art/arcane/iris/engine/framework/StructureVerticalBounds.java 31534d99462922396fd89f323e395cdcf9536e3b3f33dde3ebd064c87dd2be20 +source core/src/main/java/art/arcane/iris/engine/framework/TreeBlockMaterial.java e69f4f932467f51667c22b6729555912cc6e1d6226b3d644fd4f3c567ca44d92 +source core/src/main/java/art/arcane/iris/engine/framework/WrongEngineBroException.java 0fb43e28006a20976361bc6d57061b789beb8e7e53d173c6fa688e95f06b73ee +source core/src/main/java/art/arcane/iris/engine/framework/placer/HeightmapObjectPlacer.java 8d467c3ff58f0b3a26ad98e8b168ece40bdb58c44be3f884eeab926174328439 +source core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java 4d97f8f0ceb213677626ca780b3d0343a2426ce1e2bde64c4a4b9236b41d34f9 +source core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java 84fac66e4cb0c8c9470c3d454cc0a5e6f71fe53396123d65e186dd3289b40fe3 +source core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java 60b374601a9ef6e43dc049278331176ccadad25f3915317fc2984f12b765481c +source core/src/main/java/art/arcane/iris/engine/framework/render/Renderer.java 095b96141acee8f1b142bddb50a767b2fc97243c3df9ab91d3d9ca8d4354d551 +source core/src/main/java/art/arcane/iris/engine/framework/structure/CompiledStructureGraph.java 2589e08727111aa3f62a52c50e64380b292cf9451fdda9ce4166aeaf12f7f8bd +source core/src/main/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReader.java 2a740825c4d6409229a12a4d27e620e31f8cb1ad5ce5f43e5b01d66e8461cc27 +source core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java 949edbbfa1accbbbe8bb1733f6924b33aa234eea6bbfcaeeceb5dcf187297189 +source core/src/main/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolver.java 489be3bcc7343517e0eeca636a0b96b26ef147a4d972876b8330fca848e339e1 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResult.java 39db9401b1ca49f3018def61060e3e9066312e2d2839c45a48c6ac6d7d1a34e0 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyStatus.java ca8f8403b48e70f0bf43c11c8ae93448d9efff654aaaec4304586cb17b5e690c +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java 8696ac40916176142a42814056ee093a72bbef04ad7984233b8dba2a13d5068d +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java e5897ced312bb0e1d8c5a7870b05662671e4d4ae5758d9e2ff14e82a4fd0691e +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java 6acfcf85c48ed02ea5d23a2e635514dc1647b6bb0f729af2bf637cbd4fcdab50 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java 0e6fea0b9eebad4bf855bcd0e540d5652f0351de2ce393dcd9a537e6718317d5 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java 4ca011e67033f62843aa80703c13198c455570c89c4423e974502f53ef4518e0 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphResolver.java 2df0f605ea0009f76ab68200ade964276bcc096a0ffb8a67bb6d42718de5f45c +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphValidationException.java 7872236245db6e94b12d523f21b63b183705456c1a9eafedea5d459b7138c699 +source core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java a1c3670bf5645ed9d4fdda4ce7580e42e6c91bcd67eb8dd75b21892c941d2bed +source core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationOwnership.java 77166bf1631597f0ba57159b8943b8f0d2446abc0c3ea317fe6be07770da2154 +source core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java 737216a6b0918c34fec806685e064cda06103fe3138b666896a4eaa595c5325a +source core/src/main/java/art/arcane/iris/engine/history/GenerationActivation.java 7f50f7675c80595b84bc914bac80a341122ad9e6a60acf124f5519cfb4da80ec +source core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java cef7b2c1a6152163943e527ea40605c4f7f6279420ae2a8da7d3d8f033c32550 +source core/src/main/java/art/arcane/iris/engine/history/GenerationBlend.java 10029ec9b6d6157c2fbf9040bed1f5834df99ae9f5d185ead6921f3780ced24a +source core/src/main/java/art/arcane/iris/engine/history/GenerationBoundary.java e91bffef9a977fad40dd883d49aae3a4c31d014ac7d4814cdce314ec64d080b6 +source core/src/main/java/art/arcane/iris/engine/history/GenerationBoundarySignatureSampler.java fc1dddbb5fa7638b111198be037cc6289b8cfb1f9b4862c1dbc79e69fe671760 +source core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java 28a23986559bb14b7637b89478098450dc2debe2b7fcc7e4a3315bff626dde62 +source core/src/main/java/art/arcane/iris/engine/history/GenerationEpoch.java d2c52d6566a4db09c1b43d08405067854cd806d30d055de819ea62ef5878eecc +source core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java f2870dce0091f4f02707f902285ee9637824c7140e521ede47a9bb355d66eb9b +source core/src/main/java/art/arcane/iris/engine/history/GenerationFindCatalog.java 027b5a18d59803de55ee11a41544899d31bbc804e65962c138610e53199a022f +source core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java 83067b31473407397005722244d9a619d97a768dacbc6e770e592ebea1549093 +source core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryPaths.java 200f0d6454b5e5258f113f4ede58e0f50eeef748c662d20660c3a064876b81bd +source core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java 8f77ecaa387f0b94951a64602ef88dbc25e2b075a514bdc7645ce3f07cb4405a +source core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java ad28e74fac15de6fde17603edf6bc52086a9020df6ab89c05262a097e0e2764a +source core/src/main/java/art/arcane/iris/engine/history/GenerationKernelCatalog.java 2ecde868b74fb173a6f429fa5d3bb1c2da371c2a72e85c8144ad97000d2fc8b4 +source core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java 0c632f4df547dd450aee61e8129c9f3dae05ff12474c9f2005d519f4d15c1e1c +source core/src/main/java/art/arcane/iris/engine/history/GenerationKernelSourceSeal.java 17ef50f051d8a165c4af032b6cd53f200cb368ea4cdd4091433faf2e2e98097e +source core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java cec56ac95586c9e39d6e483458ea5095019e6031cd84ebf5e389d1899286f3da +source core/src/main/java/art/arcane/iris/engine/history/GenerationManifest.java d3f787277d62681be62e5bd7943090c7ea3c3e3497588ef9f846b2d3cd1d57ff +source core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java b01f725ef66bcf64303e8d0b4807f6c740b864bd80d155ff908eb046d0ccd2d0 +source core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java a10a74379157027c440708f29ef848098e7bdc1f6917ea8f7e3afbc8a156f81c +source core/src/main/java/art/arcane/iris/engine/history/GenerationPublicationLock.java 852fefed8751bbd111011c64e4ddc161a76487e47896202372acfd5d6c89f891 +source core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContract.java b5ae41ffc7d15300f91c156c1cac69a74089fa39e6ff7333f17c3ce2b2fbe3b0 +source core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContractFactory.java 0c6d29055828ff6dffcfe78d1086ecb805edda465a8e13ea81f0dc7a89b81693 +source core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java 23ba9e97a353a7e2ca31f5b34f84f1ee90bc773808b72b7168c95a25a728ebd9 +source core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java f030ffa9c4275e269db6e1c25304e3825ef1b8317f9037982f048651912c539b +source core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticQueries.java a62539b4893a88f1b4c953b58e411b09bddcb8c816feba31d33e6a2a023c7853 +source core/src/main/java/art/arcane/iris/engine/history/GenerationTransition.java bb5a21fe3b282c508416975bad8763a8b3a871188068bdb1e76daf06c9ab3fbd +source core/src/main/java/art/arcane/iris/engine/history/IrisBoundarySignatureSampler.java 2a34217fcdf2f01df306736bf2c4a212a613e4749965f9604ecb029fb49bbbb9 +source core/src/main/java/art/arcane/iris/engine/history/RegionGenerationOwnership.java 19eef0b83719461e373ea5175c1083b0b90cac2fa64d666daf91e6d6ee096a04 +source core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignature.java 293dcd10a2417b7c53be6ae1d9ecf3b494e4b4acff8cdfc5e7e016acbcc2972e +source core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java 5927cd7c3a07ebd5700757a41a2362610229ab5656a14120218949e2c332f653 +source core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java 2899dedf8863494c7fe0f7ecb4c19d404de3ae91217b3c928d46da3dd2813fab +source core/src/main/java/art/arcane/iris/engine/history/TransitionGenerationPlan.java 7b077551600ce61d098ad76ccb784f15cb7d4b3ad79724810181659803eb2333 +source core/src/main/java/art/arcane/iris/engine/history/WorldChunkInventory.java f1fc23646d07ec16b1aef7915c2752fd9f14d1ce8708b2b743acd799ea578659 +source core/src/main/java/art/arcane/iris/engine/hydrology/DrainageEdge.java ba1f1761fcda44a22f954436bfe8bd7131f779cc67dedce245263298ec183159 +source core/src/main/java/art/arcane/iris/engine/hydrology/DrainageNode.java 4cbf272410a4fed6731723c6fb3cbc66182a0a93c5b72b7d195eca5cc3a771d6 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java f2b259965faedef9db58c811aa0168ba7f4e86960326907b7738955dd94ab149 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java 07228baa7ac628f3b711116080737b0645885a8deaaf7e264fe24f536d61ad37 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java 8ef0b47c6006bf3ac04b9a8f2c76252a4d5a9969bbec1985a9720afe54914869 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java 364b7f33b48a756b748317a0b98160a6e794ea4478f91d5912ba654d38a2fc7d +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java 932a430287264ccac67086e019303aef5a12303c751bf65e33b9fe2d1f9e3997 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnLayer.java f97523a78d7b0224eba9cfd3dbcd90029ac76ce689aa986515c08e839cc29b04 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java 591e132654fe1b459f0a1e394b991593f9d793182d49a31eec1b422b8b88da76 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmission.java c364d91273fd7a6caeedf5f905c55519b090c25d3be8fe88d1cf53877629f42e +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java e3edce502c45f10f6d8b282595417817eaf29c39a4e9c8d0e82d54989b633e71 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java 1296aebd409d5597d62a588434f7191f34c6b5f46dd09596286d9fb980f9f153 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticRenderSample.java 3f164f58120c978d2b25f53a820f2985d1bd1be090e3726f97cc98a1a281642e +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java 60e6478e03dd339f57e7d6633c02bb6308d2de27ea86a634b26745b697121708 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureRef.java 6e02a66c7ec7c9ee4732176eea248e4ba3174c66c164da02c12dee7e229cb0ee +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java aa574734891b581bd140320cd23707d9f61bd9b3afa851909d4c8fe6e20dc1e4 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java 076eb55abe053524cd1990c62458e007092fcba69118c620592edfef0fbad4f2 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java 25005438b669b33bad5344ad6dd08127d0e928a3d383c2bb25610acb385322f1 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java 77ce4ed588dd7238ae448e8997a688df8cc3a21ed4fc8f829d593b9bfbfdd7c5 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java 41771991828e67f219c36985ffc8dd3f65e9664b0a51f4e30be16b13b55e9c1d +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java 2ddc53c8961e1db153ac36f7f5f4e71742a22316bb644187e02c52740dbcf41d +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java eac4e7f4ba41814d01c2fc4bade4653c89e53db7bd8ef498e0c54ad22179fb15 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java 7f6aa7fa674a4f0293cbd42de1808347d0c101874ba0f5c870117b9ede5a562f +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b3c98015b4623049027effae6a2f8dccc5c82c97787304e2de1cac6338e62f51 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java 5165f5b6faf5ab79156d93f94687c8f7249f16726a63592fb51c46c1b1ddb75c +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPoint.java 015a50924678d09c8447e0d66fa24c93da6a23178bc819bc0b8c320b9d82e7dd +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRenderSample.java 03587336c3d95369ba7c2dba1e17346b637c5cda75b6ce93e1aa3761bc64815c +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRoutingTerrainSampler.java 639735edf4431ea4b75e6009d674e698e8ccd9060cb891a5e294fb86380383af +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelView.java ed038c059020bdb6d10040e29946349be6ac50df894f5b5e1f71acecb4b553ef +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java f1ea65b71befeb5d0cdb6d3c8402f0023f925fb13c16cb0c07ff1610f4079cd6 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampler.java 190fd761a0be72700e05f5bd26626dcb51a87e59e280e9861ad3d79d5c86c494 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java 33ee976cdba3b051d31738ab3acc6d5c4eea01c7043fa4d2ed24cb094b0cd704 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java 0f39c34037f39945f99fbf17e27327d7cbc890956aab578a19129030f28f0da1 +source core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileKey.java b806c0df952d005f0f0543225b18dc1990ed6f9a85f68a9489c96011b3ef5a5c +source core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java f919c7ce0953a08a65e015d84195e6a43980d9c409bbf5d3dd9cde86a9307e7f +source core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java 7bbb28a498f0d7705caf5e278e68922bacdec16a16fa75643e14feca721ebb40 +source core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java 13e34daba714ed0e43b4ae4f59559840ae12ec1cb5feac5093de465f5998fe93 +source core/src/main/java/art/arcane/iris/engine/hydrology/RiverOutlet.java 2c413eafb48bfd3a6e4cf81bc14516a4b0e89057cbafc071d24d41000c9807f2 +source core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java 605ebeaa737b65274de99886a00893eb7f875ecd4a93d51100548863f544f5a0 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePosition.java 7fcbd256263acf6debc2d76e5cda0fb324253095144ba0a497889772ad3f0daf +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndex.java fdf82e18f3123ed976cc7a2f688db113179941dd6d8337228df9f31f5475c784 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxel.java 6ab8b8407eb7e2f92e8284d8026e228951ae8074a679f82055456f5cb5dd6083 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java 84be0fd58827ed34c09aaa4c41c1f008dca34248b75c581e156906a78dcae742 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelView.java a80a491c3bddfe65b52981e461e8a2ecd356e99f0bb8604810dab4416603f4bb +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveAction.java a7bdc7fd153f10e9789509f1fb2885792b19c7ad8d98d8e0777167a57ee1c78a +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCandidate.java 4f5c03cad0fee52b1da5efc1abbfd931dddf34f68ff5e761a1681fc7deb26a68 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCell.java 928d5bb1a942a8c7d339668dd026841f0cdc4feb8c491be33e3d858a00655a85 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveConflictPolicy.java c054815c89d861b687b09eade77e2bd262e481a31d196deb12ad596287dc58f7 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java d3bfe93d1bf9758874463600b0f310770d0c3bec2a6f3f2eee1b234d993dedb3 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveFluidPolicy.java c83ad9e7d90a13a2f3d3e7f199bc27fcd55cfa5f89fd405825e9716d3c0f9079 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java 5367bd021c702ed49bf31a5db9c87e42d17fa894f63442b934c8b7193d4026fe +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java 4a23289e4a1225cf3b49f4eb63cef22b0bb7b0f64ddd5a625a0c6b1258ec6000 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlan.java 663e076a30bb37d4571c4cf83795a8f67dc26fc9abce712631edcb426efb0323 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java 9d674dae223fcaa8a2ea46d866aa2834a85775821cb268b4d4bb2d7d75d7b2b0 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanningResult.java d5e02895561b3bb00995b0a3e92162d3fddbe0207e53240d43a8842b4e45a776 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java 31021b9a21335b8941c5eb9e72336483433bec74853409a8f1a14087bbbd4140 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java 69a6f64125daf30a21294b504c86b973edc7c1675333a94f95e87fabf5d0f5c2 +source core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java 0a38293e05ea043564a0d9730da456c0519d29a0c5c4bb70680047b1ca02a602 +source core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java 864f4783aa9461bb83829e9af356388949ea077b9cb4c231a2749bbb9b589b3b +source core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverConfinement.java eac8bb97d861650990c362d056e9360d3e16773601843347648d3d463e39e318 +source core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java 3276c240e13279794f39c2ec99942a141da7dfb49a46e449a1b19a8f73c000d7 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearch.java e8c13f021ebf46adf1a41a8ea62c61c2f98b5e6114dc37ea670e12d30df5749b +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBounds.java 01d2c49448bf7e2066259164e0bf72b97c94369b60d9b3c3bf21986198618a5e +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalHeightDescriber.java 2f57270103d92fc3a4d1b43ae14ab25ae8a0e375224ea5c357dffb809279d0d8 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java 77fc411b5ab183af7c89f9cb566480ca28671efacf5084b6197589cb00a84d88 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalOceanClassifier.java 4848892e471bffb9298b1b52d385a95ae013fa9230e8bb1df13629603ed44fe3 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSample.java 841c0f0247cc8a8bc545b6059cb9830a44b716142002220f979591623522bcf4 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSampleProvider.java 44235a4234bfc0a68c43e9388fdd06dbff103623d9a0126f96c94e0c2ba732d9 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java 444b226c065e3b7babf22c9f2d6c5b45ec86058bd32b136a604dc0bc5b01af6e +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java 855b6d19b16cd11b244af38707bbaf208887181f562a205536613160ae783a70 +source core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java 97ebbaf84c00cfa4a4f5f57dbe25cb6f1793badabf9bd79e0bf33ebe09e630c3 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java fe0519eb051d51942c6ab974344965b0ccf511f519ce1d9fc78efebc76b29dd3 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java 328a2574e071e59e0d39e9330b7b11dfe90e6f46c250c1a6aa3a565fd06976dc +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java a46e6e4e4a128a630d1e6650de705563f8d973d9072c710a8dc690b3b2ca879b +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java 5c5fb78f59e8b745e97c50c2645babae417876a32d362934727c73275a9b503e +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java 8e8d102a8db3fea6b0d083a1c2fe0e7cc5304ba2729de067748ce3c7b57d7db8 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java e773ba1da9bcf281b0c2673f8acd2dba1fb9730d02cd08e25266f2c601405757 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java 0553416db22228579f56eef8b2b5e115b149df86e7766d02ca2d5338428dcabe +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java c24a31f41fa2d852956ccf2f8a77e1ab4080d042e6b3674a5a24919cbf2ecede +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java 480d067b688e98a92046f39d869c18887d96bd54bb6d2849f460cbcc319b19ea +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java 507b9ecf58214b0c923dda38e91e89cb6e4a9d5b9639c263bd25f20fd23df31e +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java a3871d85e5f9582c5a73fdd33329cb5e6cd8549ed5a0f416e885b84322ab5e50 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java 9b25471a2ca368dc596ae7d0f64826d7a02445e076b4d865b1b352f6190e34ae +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java 235dcdd2df1ebb56a874578f02c5293e585c935db5781eeebdb1465394ba0a9d +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java 320e864d0bb59f456fafa630f398510ec125d3c298a54f0f222062ac4a76956f +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java f5016d9082476c5ebf0a61a9dd7dc2d3de9883d0f26303f0b9d496df4ea0196a +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java 29ed68a65282648dbd52e71694bd0db5ed74fc6acd4189d90d05573285f8f64c +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java 1c6c1a807ef369cdc31a1214732729d4a18fa2589f3826e1f0e39fbe9d243fe6 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java 7ab6228c41a5b2152e2c05d09e1d92ef51b7f52743c4dc4601cdb46d3f96b3f0 +source core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java 84888d4cc72f123650e2d4746942782173f8462af536f1170aef84cf36ecb717 +source core/src/main/java/art/arcane/iris/engine/image/CompiledIrisImageMap.java b24cba9ee930e24123a46dbf52ec878cfa066ecb08dc0212ed5b22e8db58b4e2 +source core/src/main/java/art/arcane/iris/engine/image/IrisImageMapCompiler.java b02d531c86bbf7666ad5c38290942c14c931d35bd7d150e3941e06976f11a298 +source core/src/main/java/art/arcane/iris/engine/image/IrisImageMapMaskSampler.java 5683d3db2ac95d9481a328f55ac508817639af18ab99ce1f5307869654c20845 +source core/src/main/java/art/arcane/iris/engine/image/IrisImageMapRuntime.java 841cf62cf792e181b6dffae756d0f28d14132750eaf2aa0060f09500965b69fd +source core/src/main/java/art/arcane/iris/engine/image/IrisImageMapSourceMetadata.java f38986b2c6bd3f0bbfc061ee08e082e253b3098bb6ff34610e414205deec6818 +source core/src/main/java/art/arcane/iris/engine/image/IrisImageMapValidationException.java 6bc109f5a1a97b6c17bbf8fb9505f247e4fa02884d2c87bfce907b03819bbdf6 +source core/src/main/java/art/arcane/iris/engine/mantle/ComponentFlag.java 9bcebb1c21e374f1a0be57bf9d9059359a2c820f2e2bcb158b021c7c8376901f +source core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java 87956964268c76d40be33a0eb2c614ca36a4552b9739ab54ba61a82f9b3aa50e +source core/src/main/java/art/arcane/iris/engine/mantle/IrisMantleComponent.java 83abdcdc1e6928405c25a90fcd0a21158a41410cb5cbba2b5b72d14f6ceef0f2 +source core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java dd9e939d71a344bbf2d0c2e1a750e895dcc827d67ff4336b799df00940415f4e +source core/src/main/java/art/arcane/iris/engine/mantle/MantlePass.java 5eabdc2598c092cbbcca358832bce14a87e34329e8606da20634606dae6d7b06 +source core/src/main/java/art/arcane/iris/engine/mantle/MantleSliceRetention.java 1bf17b10ea02662d89d7aaee9f5988194cc01d415feb9395a2f1dcfb399ee36b +source core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java e403c4bb5a4658bc3fd3806b380cae693efec54c802b1f8a35c13c233715d616 +source core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java ec06e4df7d1b7cbee870e6fc949cb29274f521de88c19a7e8fb9ee7462070d28 +source core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java 6c06d7f015bc230132d19f92543ce5c17d6e34ee574a8a367b9f0a06c81cf492 +source core/src/main/java/art/arcane/iris/engine/mantle/components/CaveCarveScratch.java c87a6a782b85770c524a07a418fd12e07a729ff184fdd85b92746eb1edd95607 +source core/src/main/java/art/arcane/iris/engine/mantle/components/CaveFieldModuleState.java 3f734e2b32f0fa0ea37e706677ce41939fcdccfac8db419be30fed536b64b841 +source core/src/main/java/art/arcane/iris/engine/mantle/components/CaveFluidSupportPlan.java c0328f4a29ad9fc903ce6c026cfe71232da6f4f5c475663c3642b186d5137ac7 +source core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java 08aad8ec8edbef4037dfbb8e1268e7df13689a669fe4dd940ad36dec45da2916 +source core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingIslandSampleResolver.java b40d3996c47afc832467567c7f5b00739398c76821a7bf6e2fb5172213a83af6 +source core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java d3ae16cddedf58673d4ca50d4058b6d13500c1c6d85dbd7acf5a93fa72383ca1 +source core/src/main/java/art/arcane/iris/engine/mantle/components/GoldenDebugObjectPlacer.java fd1230b323182e5e44b1810a3ff4d2934c1d98704ae93ec8275e73c0be940634 +source core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java 983434e62cd5bc0ee84fb3163e52451615fdee630e32efd9a3056ddb1e706b28 +source core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java 9449a3e411e818d5e6e47dcc7eff357fa6735fe6b67236bc1d45f5c7762ccdf9 +source core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java 617a452172d46289a1a9464ae32ca19996db8fc6fef31cf0819cecb3ee12b094 +source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java 4462fbbe6d904be0eff1a1b9a8b2cb753709d60be63c135d0410b7a247a3b4fb +source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java fee3db6abe5699398cc96b758ae3427f1481e38da1af8e12aea5d2763248c0fd +source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java 2bf58236a9ab2d2993bd05765cdae32bea45dbe6b2e21e827df55b18b804802b +source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java 16180f714c38c3b91b80c263bd22d1a9e2f060b6392aafcd849a6860401b156a +source core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java f284bdbb066e6c814fdb19fc9fa8a66c8d445849d10e740e6b9eb2e51101a8ca +source core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransaction.java b791f909a2ca3b79819c1974067dfa3537a7ef06b887c761a684255477c79203 +source core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectPassPlacer.java 519569552a31c8d72aa32254230cd962937428f2af228c1363375ee15f911434 +source core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlan.java a28da64e9d3a5678ac3bddaf405163cd2f313b748dcf1c60ab46e68e884a4820 +source core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCache.java 97c3073c49e66de797f98a8e80db9662ae19949f684ffdef4f40f052b2f3cfc0 +source core/src/main/java/art/arcane/iris/engine/mantle/components/StructureCarveEnvelope.java b1649c7614251bdacfc9a696d79bb51fe9a84cfb10c9e8aa2dc8897d056e3422 +source core/src/main/java/art/arcane/iris/engine/mantle/components/StructureCarvingFootprint.java 169cd6844a3257df8fbf71bddb60a87e05260de346e98c5c9571cccdb039d2de +source core/src/main/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlanner.java f99651e5ff66615ac6ae5a0de250b70937470f0f6affa28f7c8d9bfb18743f59 +source core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java 247fdce9e80ed44c5cb0fdceea8730adec61cf7ec370a4ae3173f9652dd24aa3 +source core/src/main/java/art/arcane/iris/engine/mode/ModeEnclosure.java 902d832a8fb79b0f1688059ba976044dc5701a14f55953e3ebfaf464adae3d94 +source core/src/main/java/art/arcane/iris/engine/mode/ModeIslands.java d4f57395c2f6065d2983a6fc33a2fe67da298330b47d166e9af31c44ee09f936 +source core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java 522a186be13181e1a3ae29b6ca1a42202eacdd70f2151f6f4019953c9dc33dc0 +source core/src/main/java/art/arcane/iris/engine/mode/ModeSuperFlat.java b5893a0f1837d80d63f78631bb5b56df5061be60587358314db05cc79f072a9d +source core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java a7e3bdeb8b4c9acabcacf1d4eacf96284d59ee3d71ce40cf085f2d0073d964e7 +source core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveScratch.java 660d900a7dbf6be43b87e87db83c2d5453e4ecf2df769596054fb3268fc0e3de +source core/src/main/java/art/arcane/iris/engine/modifier/IrisCustomModifier.java 0c3a23f2f1cda4a616c422bcd6c42c1724149b638ce3982b723eb591775bf8fb +source core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java 63c7774c09177b79cab7f41ca7e2eee1447ced9271ab1973845c56ea185967ca +source core/src/main/java/art/arcane/iris/engine/modifier/IrisFloatingChildBiomeModifier.java 137eb421b3c8df0d3156ba877f6fc46c6d5dd0caf7478bebe2470b5e1cbede9b +source core/src/main/java/art/arcane/iris/engine/modifier/IrisPerfectionModifier.java 64ea8c7554b2c1f704a32e52c683d203330caac36da4fb769c2a1f9f3ce11fa8 +source core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java 23a9c38bab0191f05fe0f3d577624492902ed7b09685144fc861774dbd6f7320 +source core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java acdd8dbecb9a34f2fe46a60325fb53fbbb6efe08513e799afa25de367c2cb1a7 +source core/src/main/java/art/arcane/iris/engine/object/CarveResult.java 002fd8cf16c937e44dfcabccda6ceb7b249812bfaca3ce7881598676b1defbe1 +source core/src/main/java/art/arcane/iris/engine/object/CarvingMode.java 6264731af9f3de3a7e32b512a83398999a8b0bf18a8bebca5ce8c6097363a7e6 +source core/src/main/java/art/arcane/iris/engine/object/CaveResult.java 28e0792c304efd0a9642651a85c7f9fdd8630f2802c7afeb1451e97ad9b23477 +source core/src/main/java/art/arcane/iris/engine/object/CompatPools.java 29b8bac9ed15615fb271c731a8d2dfbef487b999736ef23ef9038d5d0d7bcaeb +source core/src/main/java/art/arcane/iris/engine/object/DecayControlPlacer.java 729926e5a3e989f3b5f9403fbacfc6c3f3f303b089680f85fe7a9d1f5c105abc +source core/src/main/java/art/arcane/iris/engine/object/FloatingBottomPaletteMode.java 33e6278cf5fe6279ef300804d44dd080de4badd67147b873b80f3aec1a13dfe1 +source core/src/main/java/art/arcane/iris/engine/object/FloatingIslandBoundarySampler.java 17422cd87c450b73ba01d9bee7ef4f4f63c4afcad6a28c2394d664d5d59d0851 +source core/src/main/java/art/arcane/iris/engine/object/FloatingIslandEdgeProfile.java 027cdf47558901ffc91593b3ec4259c4f918c51152f988d3de975153a5026c96 +source core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java 0d0b6594a138a2367f5a3a9f78a0d734328b704c56ba54d4ff409c78b27d832d +source core/src/main/java/art/arcane/iris/engine/object/FloatingObjectFootprint.java 0246aa29bf37fe1b9608ba26323e257916abfd9465b4b79ffdb9a9decc7f6f49 +source core/src/main/java/art/arcane/iris/engine/object/IObjectLoot.java 55dfe78884f5acd9026e76a6650592496a31b5412bb7048a76220126a569df37 +source core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java 2377005797e05dc28d3030f5c82449364a18f1132725b47b297878ea2e653aa9 +source core/src/main/java/art/arcane/iris/engine/object/IPostBlockAccess.java 0d6161f0008b374f3232a8d063980719ae561b89579b005ad00b8e44f1080fc8 +source core/src/main/java/art/arcane/iris/engine/object/IRare.java 2ed7ab7a868bab87b9b00a3d36513fecefa3ea901d5b53d9a8a56bc236b67289 +source core/src/main/java/art/arcane/iris/engine/object/InferredType.java e1748de37d9141172816b9ff11e79871b2394087b6b4140de9415518fdbae9a8 +source core/src/main/java/art/arcane/iris/engine/object/InventorySlotType.java e2eaa8daa5c9320a534f39688c9c2fded737164179a0d97bacd504c875999e8f +source core/src/main/java/art/arcane/iris/engine/object/IrisAttributeModifier.java c285df180f48799d131a4ad6c0d92833ab3ecc8673e5cc24ddf0a23c1ef9cd6d +source core/src/main/java/art/arcane/iris/engine/object/IrisAxisRotationClamp.java 9db923cb3a1793b8e51ac280b83ca8b4903ac03d822d9f31f18d4d420495a1ac +source core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java 6e1acfd3ce0bbfe4973db12aacec59a64179ca88c4518d14b00d1e5306e23e9b +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java ebaccbbd2a13b10adede59e41a82b073e259d25b1b296e7f82b360613cd88592 +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java 5b7ba7f9cfe7bc7ba1db5e115717b4e45fbc85e71f2d18fa4b9842bd87492aac +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomCategory.java 1aac3e93a08ccb0345cd45155608cd72f96a65b3de9b609959fe990b83f2f57e +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomParticle.java 2c5f9af511f7253f8bcdb4fd16eeb7193866d70d2f6319a1908d1355f6f9475e +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomPrecipType.java 3b1514b0c11f6043aee7b04c23428d2451d8e8207db053de93c1fcf01fc659dc +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java 90d9a3333fe37f3e1e0c81db9822af59e7dbde546a761b11a92e6c7fc6e815c8 +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawnType.java 6852af298cbe3aedef954a87aad9f340fe6963aba9bd0ac4b0a3de93f1f44b2c +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeDerivatives.java 85de8587c19b1323453e818bae21e52e36592700a40bbfefa20aa1b67c14e53a +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeGenLinks.java 4e80bf12caef43ace6f6220161220694d16e439e764ba3286805e99f92359250 +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeGeneratorLink.java dd9ffd9eb3d4fa9625996ebba72ff0d111fbe86548bd79fc1614c0626bddbba3 +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeLayerGenerator.java b60e9e10af820a9dbaa7fdb1db27107d49ab40b553eac2bed6163730f458c61a +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomeOres.java dba854c06c639d671e72c94aa80b5689a6920ca9ff4a566d8ed5b853a6288beb +source core/src/main/java/art/arcane/iris/engine/object/IrisBiomePaletteLayer.java 4a8d6ac6e4ea6c5e0abeb4ae2feea9670859392002487ec4ce280df07a328eaf +source core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java 0d0d61ec71d7afbef3eb493a2baa9caa1e6e435ef09fe42f89a45b86f230671e +source core/src/main/java/art/arcane/iris/engine/object/IrisBlockDrops.java d8e3217895573c1e4a31bd61735dd8d0065f3ca2faa244a48c094a73c6f3b7fb +source core/src/main/java/art/arcane/iris/engine/object/IrisCaveAnchorMode.java bd742762e9e7b7d7e7df29b0e86b7e8f235e64e66df1468d31f1c7ffef4bde98 +source core/src/main/java/art/arcane/iris/engine/object/IrisCaveFieldModule.java fd80a3eca5ba74672d9283881bfb1554a59121f8ccd3527271e5269f582a9b09 +source core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfile.java 66336bd0f84e1332b93f55e7e9ffbdb40c4242779139b80022d8d69fc0decdf4 +source core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfileSampler.java 72cb85bd98fb74e18696f501a20c7fa44681f4ed1e995386575b002d1b215e20 +source core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java 492f557e348911331c23140850a018fbcf93ea50ef49b7f3512acf1d1288052d +source core/src/main/java/art/arcane/iris/engine/object/IrisColor.java dcb5c3a07a030cfbbdcf8394f5b35593634bafdf8fd0ff9070ffa755afad05be +source core/src/main/java/art/arcane/iris/engine/object/IrisCommand.java 606a62de009428a384b8e7873c3f03f838813cb44f905818e8e63af88c2b6ef1 +source core/src/main/java/art/arcane/iris/engine/object/IrisCommandRegistry.java f91e440d35f6da863e2f3234abc363a9a33198b31afb991773081dbd35eb5904 +source core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java 9f24ce8a4f400289d5506fdb1b72e3dd793ae44e29091e41c5924eb5759df953 +source core/src/main/java/art/arcane/iris/engine/object/IrisCompatabilityBlockFilter.java 01ee6a8629e653afd6b7560d464184ec2fe2bbfa6094de3cf32e1874c63fc892 +source core/src/main/java/art/arcane/iris/engine/object/IrisCompatabilityItemFilter.java 20c2c9ff1ed906196a07fb5492e2dc4f2280af38bbff02c40b96c5a4f5b38239 +source core/src/main/java/art/arcane/iris/engine/object/IrisCoral.java ed256b40eaa2b2c5da6b8e8d53bfca6f019197e2ea4c62358a7f3f3fb91624bc +source core/src/main/java/art/arcane/iris/engine/object/IrisCoralBranchAzimuth.java ec39b19e08897dafda633b7852f1bb5fb8b5e7e0d7a31a4ac973d3cfd0545c9d +source core/src/main/java/art/arcane/iris/engine/object/IrisCoralForm.java 4a2f5ec3877ce1ccbf1321daebe3db4863599b8f178e17acb563b06f683fb979 +source core/src/main/java/art/arcane/iris/engine/object/IrisCrystal.java 2f00c8e7ce333d45398cd630bb1dd5b707e51dc244f89a7457fe83e6a042ae5c +source core/src/main/java/art/arcane/iris/engine/object/IrisCrystalDistribution.java e31ec96b1310d11e2a01591d591c88f07b1b077956815ea2e59895697b3dbad3 +source core/src/main/java/art/arcane/iris/engine/object/IrisCrystalSurface.java 9cb4e4031171f87b6c1c772d34a2c42953cf8d22cedf8d79a3f3be5d18d6b975 +source core/src/main/java/art/arcane/iris/engine/object/IrisCustomBiomeAliasResolver.java 70e6698d51ee1eaef6faf642f750261820d425928389a0d2076ac95e61ec2d0a +source core/src/main/java/art/arcane/iris/engine/object/IrisDecorationPart.java 7e713a3ade81bfb4add35e27732a2327baffc3851d1e4049fa820d3f96d2ad59 +source core/src/main/java/art/arcane/iris/engine/object/IrisDecorationStep.java 678ea725c13037c900f3a8267240354437077ae44673636d7882d136612c8fbc +source core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java 51b7306a743ec2b5bbc11cdfde6967960c81c23ac35508a5de01edece7d3eb8d +source core/src/main/java/art/arcane/iris/engine/object/IrisDeepFluidConfig.java ef3a10717ce05b75159023c59b6d5d4c18a508f747a1541d08b8324c3ad48bd4 +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositBiomeScope.java 1c1328711aa25702e61013897f63c2d75994f3236e6cd7b3c4d7e416bd92ce6b +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java bd54bc2702af646dfa1811016b0faaa409f72ceb6636ce5d232cbb44a60f7659 +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositHeightDistribution.java 4652f04172c8decde8405afb9904664e0f89bf80148bcb306fc1f5db5b1b57ca +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositPlacementScope.java 0b1b15482a85d473eac3b9d58be5b681b8e210846900ab5a2b4e945bf3873105 +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositShape.java 07647e01b010a338e1cf01f5372f8dfbccf34f3915742032baecacccda5293fc +source core/src/main/java/art/arcane/iris/engine/object/IrisDepositVariant.java 0234c4caa8c723047be7293fea0452df2221450b05ef3102bbc152170a5e5038 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java e74323309322f2e2df155aee424622b09a9bedea99f7231d8156377c6c13c0a5 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java a0c5971a455b2ff2e4c82c7a0387110ab4f1e0dd27238fbcd57b7a0b3b8899dc +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java 51ecf9f7f108311951baa621614f33085a569ffa88f7d314daf0fcdaf39e44b2 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionContractException.java 6d64f24dd2ff4b759ade515a2f6bff218fa7b59a68cd856f7e82cf2ae7d0fb65 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionMode.java 0365ec35f06d0cd6d59d0d74e1a9b0d1ab8757c327ad974b31384ec284cd2577 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionModeType.java a26e4213c6370fd41f5c264e7e67050507d0b1bb49732173170f7e6dc2290e37 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContract.java b1a3f4ddff860e71f9d93d867b6e484b7956beff5cfeae161c539753c3f11acd +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionType.java 91119dc5bf04a8a4760294fe8756d0a95f1aa1041f85e2dd04b24a911d553740 +source core/src/main/java/art/arcane/iris/engine/object/IrisDimensionTypeOptions.java 6498d1841a9755c0ff5b8dc0e69a70f7facb19666638195a7be0b0e92bd88c8f +source core/src/main/java/art/arcane/iris/engine/object/IrisDirection.java bf942f58caf6d814eea2c61088f47b51efb1b20f5c21ff3ffc83454ebfb4a1b3 +source core/src/main/java/art/arcane/iris/engine/object/IrisDuration.java ca2358c75f0c38aecf4f67a25168b2d6a6cadd5ed73f7ed4409d807f41c0fd17 +source core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java e4bc736071f43e270d1a698f21a3fd5a1463465e2b2910bd542c6463f89b81ba +source core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java 99e56d9c891ed62b75300cb6f524707df4d6db3e1494ce5531809c45e18e2243 +source core/src/main/java/art/arcane/iris/engine/object/IrisEngineData.java 98f0c9f865d6251ba831a44f7d88bf8427fee6c2b36224f6d9d348f62a8e2fd9 +source core/src/main/java/art/arcane/iris/engine/object/IrisEngineSpawnerCooldown.java 3ed656dfbd3d33520e25839d80efdecffe7ef510ef8b6dd849be37b4193bd2d5 +source core/src/main/java/art/arcane/iris/engine/object/IrisEngineStatistics.java fd322600f410aa8212b54a19734d796e1547b79dac2455390154dbee76a60484 +source core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java a1ada0938ec368980212fd22c02732f418c76350c1c531e337c7d6b92bcc7286 +source core/src/main/java/art/arcane/iris/engine/object/IrisEngineValueType.java 287b1de2617be341e6815dda28fa066d3cbb0e4d2c6e780f7c3fd2d732f5a44d +source core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java 977c6b130dba4218bc25b705eb8d1272483c0527d85316e2b535c6237630c65c +source core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java 0739bc688adb0e506b79978f48c9651886888bb344a7d3558ad2cabc02eaebec +source core/src/main/java/art/arcane/iris/engine/object/IrisEnvironment.java 8a9fdd3898bcf89a2d4991c3d4972630be5c3e73655705c7b47a9a99a9990e93 +source core/src/main/java/art/arcane/iris/engine/object/IrisExpression.java 53c1237fef12f7f95901206efbf6bc61786abbc24bf0c4ecfeefc266dbab53dc +source core/src/main/java/art/arcane/iris/engine/object/IrisExpressionFunction.java 876504f198f15bd565af8ecb29562e51ca2086e2861832556e41a908527f4090 +source core/src/main/java/art/arcane/iris/engine/object/IrisExpressionLoad.java 8768b79b885381ea8812b7a5c2aa9d264b525e045d899fae7295597566b87b27 +source core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java cb58881275b1f9834fce2b37465f397c421d42ab05d3b7c12f0970de324c6d4c +source core/src/main/java/art/arcane/iris/engine/object/IrisFormation.java 231c926fc4589bf8ec1ca9839a262f118ec88f0d13391243b0e82717250e0503 +source core/src/main/java/art/arcane/iris/engine/object/IrisFormationForm.java 830a54a825e49d9ffa4715aea771d008c61b2c789afd8eadaf31d6fac984053b +source core/src/main/java/art/arcane/iris/engine/object/IrisFormationProfile.java 24bfe493a25624593c9a869055034e6a6f7d170aca394013da83711592a9516e +source core/src/main/java/art/arcane/iris/engine/object/IrisFungus.java 4edf5e93158a16a3bf041cec57b88463e8f41eb1a7b12965f6239593631e9997 +source core/src/main/java/art/arcane/iris/engine/object/IrisFungusCapShape.java fc73287ce22b472c6494c440aa79c4bea41f18d71cd3c2c738339fef06e86770 +source core/src/main/java/art/arcane/iris/engine/object/IrisGenerator.java 6de0a72c2e20214cf6a239cb7ec2ce3f0da1c33078afd80ab730a75556121fe5 +source core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java e79547388638f9d2a59dffbc69045e504fb748e78a93b6c0512f4244a21daf56 +source core/src/main/java/art/arcane/iris/engine/object/IrisGrottoPoolLevel.java 18a27f37ed2555578b334493779d42dc2ed44b538f9e435e6c0eb322addb3594 +source core/src/main/java/art/arcane/iris/engine/object/IrisHydrology.java 4d2ad747194e89729b9762224978066fb26e7e475a0e5dc0d2ba14f995c41baa +source core/src/main/java/art/arcane/iris/engine/object/IrisImage.java 9ac67fe87646810497d2d1210f625ce6fee9d1d67a31ff344927a97e7eee87d1 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageColorMode.java 1c75d95d1d02fbe45effce4535a6eab48901f95467e8742090e181ad83e538a9 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java 33bc41a8f2501955e00be59b8ded48cffec972ca1b1a836b6f951f3643314888 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapAlpha.java 3373807fae5afdb60858bb0f65030516700094701ec35cd122b75591cf0aa3dc +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapApplication.java d438486bced5e418d5259fea229d9b8c96eef35eed5823634cf28611b512d776 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapBinding.java 677dba2cebcb3d343e66af18aec97938a931a54275f31e9baaf32eeaac7cc2b9 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMask.java 4134d1b217a7fad2aea381072f4996f05dbf51a5a1f43f7637462c4c00b9c1da +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMaskOperation.java dfcc81092395dc11206b0d15a86b11a07780b845df539015501cc95bc2793222 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOrigin.java f7b5ac5548d3467de82f14815edc5e25158b7ad59ce08c007a9c999dd4dec461 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOutOfBounds.java fabc3aee529e0e595b29ccbe1feb936111a57b90699c18977939e399773418af +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapRotation.java 606526a2848eb0cdf4c3fdf81eb2bf24c52f575170de08c5c34480efa6cd58ee +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapSampling.java b715fb9ae345b6efa1ac48e177987cbcb4d62baef29cd8973d7855c6cea5d25d +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapType.java 14d1b9b3f41a6af5916a158a5eb6da8f82ac62b5c645bf390a2831f91cbd57d2 +source core/src/main/java/art/arcane/iris/engine/object/IrisImageMapUnknownColor.java 1b33d4d80239671b95e4ee68f1aea1c48b64fc1b3b627bb27ac6170a3404c435 +source core/src/main/java/art/arcane/iris/engine/object/IrisImportedFeatureControl.java edde159d74e074b0181f32ce518ef94885b78f752e23463859f2004d4d4775ff +source core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java 915392cf65141822915f7695d13006636e118f7e2f343c44f2cbc5394e1a54f9 +source core/src/main/java/art/arcane/iris/engine/object/IrisInlandRiverGrottoConfig.java a3f36f44404dabc02cd8a84f3d5bd727e31e1fd243893c497ff5977b4af2fa12 +source core/src/main/java/art/arcane/iris/engine/object/IrisInterpolator.java 8423df656b2dc0995123a92d3a794dfb130ce2f9a3ba49b0199187025209a009 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawBranchFailurePolicy.java 2b7d09dd7ec092633c05aa1eedd6f4e217641f47c97b2dd34b215bacaccdba1c +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawCompatibility.java fc471a3d2f638d63228d45d0710e85513700476bf47a6b9b8d0a117cc92950ff +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConfiguration.java 7945925e8e8fb8f851dd70da7f8b68c060c391cac746bc882c9962c7e1edb013 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java 65ba86abf90155ea86e4f30167d5ba7d0064eefc6d0ef8234362d267e425da05 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawHeightmap.java c184b95c7e8d198f8d5e045fe0b20c1e94cb29d4234e25ea3290f896c3f654f9 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawLiquidSettings.java 78a05c72b456b8ae0dd9b90155ec17fc62022b53e6d8c16742fa6085448778cd +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawMode.java ef208336caa476abeb97ade1f32e64dd65d189b2a55948aa02af6943e2c122f8 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java 21e45af5faa6b81c350925e69ebebee14d089c66e9d5a0565d37dcc40a00c507 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java 03473b37b1e194337660c4b391ccb5f6d00e7770d8ac0bc8379711ca8dec5a84 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceRules.java ff0fdc1da7b5f84f06d3e148fbbb07809fba5828c5b5f55b0af0fb99e19fa3bf +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java dc3ae79959ac0a464093076753ad518a724d87a8758793318dc933b9217e8853 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawThemeSet.java 00781cffa14975a5a3f94cca3c3c87dd26cf64c58939e8e730c58f3f97dfe7ea +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcell.java d0da640adaa458b4c44cd243e8d207f3c459e659d159950e6d1c70de3281ece2 +source core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcellArchetype.java 869cf45a51743366e4b04ee803ac0c00d5feec5c750f459eb585c1b5350a2c71 +source core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java 35b4cf97deb5e4136127349b5734f10a27e7971f90d33f1b4220648241d58122 +source core/src/main/java/art/arcane/iris/engine/object/IrisLootMode.java 85cde69ebe613f667f8faa4212933fbb3cfccb3a17d244085d1697d1957b0507 +source core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java d4d70fc55603a10c6eed3491917f3553b1b3e3b0588499f5a7aa20e9eca89d38 +source core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java 8f7c9d292addca0fb3c859a41a04f04dd87ab504030925257748b54db8fcc14b +source core/src/main/java/art/arcane/iris/engine/object/IrisMarker.java ec49402a3eb300369a30ae7b85e33c4b12eec5b61550a50347856bfd9bef4506 +source core/src/main/java/art/arcane/iris/engine/object/IrisMaterialPalette.java 41c67bec2b9ff70ca53bd5290136ea7ad1a0c9f971b7b43593ebbcf0b1346119 +source core/src/main/java/art/arcane/iris/engine/object/IrisMod.java a520cd12ff1750c7c2ff310e5569e636065c687c0d003e3d69fa6ee91e1237cc +source core/src/main/java/art/arcane/iris/engine/object/IrisModBiomeInjector.java c3b167161d64a0700533c576ad6219a0464c948722713f69b3e58823e41408d4 +source core/src/main/java/art/arcane/iris/engine/object/IrisModBiomeReplacer.java 5020c12524c5c475fd9fd474f4f30f03c12468f5c1c04bf80abefa5f5089f28f +source core/src/main/java/art/arcane/iris/engine/object/IrisModNoiseStyleReplacer.java 066217f3bd688619d5be39c598632b593e1448330f98827f20bf58ecc2a61f48 +source core/src/main/java/art/arcane/iris/engine/object/IrisModObjectPlacementBiomeInjector.java 9182b958b968a84bcd3d4bcaa129cc3b2b867505d71b0fa58064f30026ba870d +source core/src/main/java/art/arcane/iris/engine/object/IrisModObjectPlacementRegionInjector.java c7c9152464d502bdbe3a683afb26baaddf0ec7ed93019d798a8e4936d1731b14 +source core/src/main/java/art/arcane/iris/engine/object/IrisModObjectReplacer.java 0090708e15a592ac8b97c44529d45416aba390749df86ec80f718c61cbef1a74 +source core/src/main/java/art/arcane/iris/engine/object/IrisModRegionReplacer.java 093fde6b3491b3fd104a627b2ed3d26c4734312789eb97a9b43dfd23fe5f3cae +source core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructure.java 6fd983bd640ad94a22bc9fd8986d18132c3a39f02f092739453b4c41f71246cb +source core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java f0792c1fa4a13f0e826696eafc88c00505f9b91c49b9e0251c7958aa29eecdd3 +source core/src/main/java/art/arcane/iris/engine/object/IrisNoiseGenerator.java 3e638ea7a65852b84617d2ce069e62c7ed03032460eff6e3e17d7e7462b4011e +source core/src/main/java/art/arcane/iris/engine/object/IrisObject.java 48f88cd3652b9308c7f23564b0381ffe7eea8cca85352ad6a80083c6d848e8a4 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectCompat.java 417790a22c3967654c6e2460779376a3bfb9d561a1f5440c574ef84cbf59bd91 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java 1e317a730f43620c91275793fa4e04d67cfe6002526809b26a11d82e8c062dc7 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectLimit.java 1949b1d16676cec7e28c8572a6a4a504a4b3bb17c31f6cb243dc07345dc01731 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectLoot.java 0a2e19df0183baf6e60c6a15386386291c231d4404f907798216cda2b26fa81f +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java dfac232dc147d36e865ed9ff1acf7b0a4da2fe46d81fc95d6449fa52de1d26b6 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java 244133fbd44a297986f64e39b849f3fe15e60057e2bc5183d28b9dce73b0c72d +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java 2e1746d636efd21b66081ccd1936d35fe37f21812f815151d5c91a93d6e0224b +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementScaleInterpolator.java 9d0b2b3928d05c9a4addeb3815cf67f9f407e63c5b5ce1a8ef74d1dcb208ee61 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java f07b155a1239055114666288d941474ba1341623cdcac30998dc56441e913182 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java 42e129047a26439bc1b993e17a802c5ec797eaf31023a193b9002e1fc858a847 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java c4fc4ffeed0073f873daebbfa0ab2bb9b7a0b57656d4159772aed25221891e13 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java 14995204a83b09656e432d7463c2ba858f99522bd2762e3ae4d82f6e8c0a91d8 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectTransforms.java 4665c9580d2181cb27768ad5224c9312cbd23350e92d8fbd09ea220b9ea4e3e9 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectTranslate.java ea183f928e92392948bd8fe2a64650a1828951308e0258b45b7779ff2dca1134 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java 65b351ffdc3bad69faff4ffee039727111cd9a8013980f665337f0790e391cc8 +source core/src/main/java/art/arcane/iris/engine/object/IrisObjectVanillaLoot.java d44c84a06eb62fd86117dfc626bbdca9f4f1364eecb184330f2e4a68b08d021c +source core/src/main/java/art/arcane/iris/engine/object/IrisOreGenerator.java 679bf899c475e331a9f95a49c3aa031b26ac261155df5784daf906ee9860d481 +source core/src/main/java/art/arcane/iris/engine/object/IrisOreGeneratorBounds.java 3e74a0da0d10b5db886adb3903780da4fbba3595964f86fa5ab44e3f58923e4b +source core/src/main/java/art/arcane/iris/engine/object/IrisPosition.java 38c165372170dcdb7f66a534bb84b48ab1a47cfc27466ceb915d0d9f74dcd88c +source core/src/main/java/art/arcane/iris/engine/object/IrisPotionEffect.java 6680513e0e11c40fe71608ecd3f3ba8e00083e7aab9b3ad3732cb53950da49e8 +source core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java b48374bc4e988f4869d20759d3a624b46f803d7e2f64664737c54815e2bdc07a +source core/src/main/java/art/arcane/iris/engine/object/IrisProceduralObjects.java 9ec87c0491818eaf0fe43f9f9d903660de9086ad54ff8623fe38d3e983ec3bfc +source core/src/main/java/art/arcane/iris/engine/object/IrisProceduralPlacement.java 23f850273146dd7e5debf2c216ee208486ac5bbef599d5940dd6bde9085295a8 +source core/src/main/java/art/arcane/iris/engine/object/IrisProceduralTree.java cab26334e539779f272807954470f5a8418a14ee245d68500f249c67a941d71b +source core/src/main/java/art/arcane/iris/engine/object/IrisRange.java 86ebad3ea8789e496fdb0b0b16be61fc0773c76e3f6203b8fe37bead4c28f223 +source core/src/main/java/art/arcane/iris/engine/object/IrisRate.java 2dd62cad7775329fec24308cca1f6878cacf1a7d92a1c5b66c3ca9a3637891c2 +source core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java dfb1a2260bc65f8fc1ff5c695bf66b48b7f9567a1f3c715907d4b64dd1f1384a +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverBedProfile.java 210ece0d816ef18add37a2c1ea42154d8963186f3abd88f2c8179e22e24ab2f0 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverBlendStyle.java e055cfa3adf5090292211b46659c3a9917e3b8792c94b643cc2e540c80e08635 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverChannelShapeConfig.java eca518a2eb1620cd532b2f67c653059b70f4004d6a16478c36749c2e4d28d931 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverDropShapeConfig.java 8cbf11bd685af008a97291ec5c68bc69c87ae0acf436eb97c5f2179444bd5baf +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java 1b04e5c83be25b2ef2a7dfc9b0aee07786092807ccdcda018531475e9f89617c +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverGrottoConfig.java 0646b95b9977e8842e62bc83a9f29a8201fd1e43e305057ae269606f86af38e1 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydrology.java 0e72706022a3fab1532c40cd59587c9cb196b36607960216c119f4c59ec3f1df +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverInlandOutlet.java 876028d782ce40583d0d6a6073241f072a1598eea79d3e094536de71b7797962 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverMaterialConfig.java 742ff0448a5c6df3adbdc4267c38a595d13ecbe04cfd447dc72fea34e5e6e1f6 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverMeanderConfig.java c7ad6ddf041f82c12e24225ace3baade969d3d3cf89196767f48d5de95570d88 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java 7c2497c5b0ffff278a1db13bf852a8ac9cbd6f783ece733f992f571f0f9b3d23 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverPlacementMode.java 2dac7958a9b088dd40c0851baa508d6decb1a63c6a1af5168201b2e1106addb3 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java 48f7303335cc801886e2e5d7e260af88f05ee71b5bda537b3b23f30ff593a9d3 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverProfile.java 0ca093428359b22707dab5832f639dc32d06ea7c9b64cdaee019999dd7d69873 +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java e39718e3a350bb28b4c42a7534f829701b7121e7e4b02a45f118fed881c8438c +source core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingMode.java 7523243c17b0c1e19a8f27cc41d4de563e482c612087ed26ee2251e5fbddc674 +source core/src/main/java/art/arcane/iris/engine/object/IrisRuin.java b4166e50c9a896df12ec703d601869ef613f6fbefdf18410b4bdf23d1feaf3a7 +source core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecorator.java c5a0036561c312f941242d52194abc90d193a2c7de780c42d57c924c84ceeaf0 +source core/src/main/java/art/arcane/iris/engine/object/IrisRuinDecoratorTarget.java 417adb5db3e4a88379947970273b22130b109ea1f05f469dd308beeef9de2dfc +source core/src/main/java/art/arcane/iris/engine/object/IrisRuinForm.java 9570b5d986d5a05af4d02b2d9101cc48c623fea572e55e35df3819c612b1286e +source core/src/main/java/art/arcane/iris/engine/object/IrisSeaCaveConfig.java 888e6a32d952e28f23a76f33b57d8a2bcf1d508212d86057569eb300682b85df +source core/src/main/java/art/arcane/iris/engine/object/IrisShapedGeneratorStyle.java 707b49bc5f9ccad49923a4f084e23bb24bc54114d1bcd519ea48219f132b87f8 +source core/src/main/java/art/arcane/iris/engine/object/IrisSlopeClip.java 750a0cdd661a6bc4aa9da8c5d45834f6906a3105ed5c83f24ce043e080a3ead3 +source core/src/main/java/art/arcane/iris/engine/object/IrisSpawnGroup.java 76a2266a83c209d50bd7602cfc963e7d508c48fdce8148833c2da1edc8a3823d +source core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java 71adbbb8bc462f473c376f10c8d47c7a27003f270a4997a6d2933ce87089a548 +source core/src/main/java/art/arcane/iris/engine/object/IrisSpawnerCooldowns.java e943726ca0addac01ea6b9e41f19f3c57d2b0abbe7e5d1ddf6e73ce5319b65e0 +source core/src/main/java/art/arcane/iris/engine/object/IrisStaticObject.java 9aa979656ba165d43f80dd809568ba82cae4bff7d2b609a760cd4e0bdf031193 +source core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectLayer.java 0adbf222ed7bc09ea551096ad3af0a693d46ad2dcbc7f7f8bf11812ead24039c +source core/src/main/java/art/arcane/iris/engine/object/IrisStaticObjectRotation.java 1bce5d7e4ba804c6c564161be14bb1e8ddc2482bbf5f5338f032b4ba7738cedd +source core/src/main/java/art/arcane/iris/engine/object/IrisStiltSettings.java 92493fe250b2c3e9942a33a554890599cc3c9e7721f306d1a14d56e45876b838 +source core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java 514738f0cb6ced3f1815619a70d2d50caadd4d5a6063dc6ad6d825f32cf4056b +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureAnchorMode.java 109f98c953a17a7890eabd82af2d455fb39626f2a4ac8f50601b6ece520cb05e +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureCarveShape.java 6b6d46ab1b258c5ba667889a9548c69ba7b5b05636dc31b4add8b4d71cf3a708 +source core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java 0810f243920f439d62a94e5c8fea332562cf99abdb8b4aaeeed7a36af8bbd5e2 +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureSetFrequencyOverride.java 4eb597d8dfe58142b44a6bc760e1a89a64d9fbce7e21138271236247ef2d43b3 +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureStiltSettings.java 433a716468fc908dbe7ba89d6d1ccfa31615f42adb4f707f33ae0c19edebbecd +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java 9019db1db1f80ee62e339e658594fa4b5c955ab2595685b1e5a2f63d3f584268 +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java fd353b4362e2bdcac61ec5b79fa2edf9f47904887bedbef4e914807883f182dc +source core/src/main/java/art/arcane/iris/engine/object/IrisStructureYBand.java f9523bcb8bc94fd9edfa1c982ea7b486f199c4263ace5b7e21c42e28d56dfdcc +source core/src/main/java/art/arcane/iris/engine/object/IrisStyledRange.java 1155c6f598676cbc41ecfee2af6697867787200ba26bc64cec4ea1c5b3502b86 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurface.java 35f98a084a8fb91e50678ae668e7d3c8e1723d44bba6d027a0d64d51faf5e3f8 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfacePoolConfig.java 1db61fc2c2b1b851235f15dd8c48ace06f6914df9f6ddd0641e0509ca7850916 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java 9fba00b099f42e433e6d77581eb38418b8f9a3704fb357e8f3c4065a98fdb8d6 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java 577b218d04464313846105aa422d53f65845b78ebd8838e1c71469b2f35eeaaa +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java f146cb85f2c4e332572f4e6a8adecf9ca9f2dc93809f6d8e59877227e5f809b7 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java 3068bf2cada585142704c396ea226e3c31ede31c1596d38c9e09ac168dfc65e9 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverErosionConfig.java 65cce3c6373987c4ec049cc1508f48674f4c130781200681732553bb0e3fa2d8 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverFlowConfig.java 7fb44dbe3999b644a4c5dc1d7df006b07b7f0e2fac38af25e7821ff7713950d6 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondConfig.java c985f07af4bdc872001c79f57c56b0dec532bb65cd6a275f3d2bb297870c14b4 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondsConfig.java 5da7da41d10560c971039fb1318e58ead86ab793fd468dfee0969e1c4a1cb83a +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverShapeConfig.java 9a868eedde1d5e647e2d6bb59ce7842d9a79e42e22976b4f48612961e94eed55 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverSourceConfig.java 1256866ae92b37d7190bad78f534eb6ebab9f3c580b098d4517e01ed50adb717 +source core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceSupport.java 1c8698aa066f0f2fb26b452b5abb63edce99cabc167169de1e0644d5b928479f +source core/src/main/java/art/arcane/iris/engine/object/IrisTimeBlock.java 9cae2c622fe8359a01f1ae7ff30598a6fe044382f6738df8b903ec7b1a331123 +source core/src/main/java/art/arcane/iris/engine/object/IrisTree.java 9f0e170a9e15d597ddbd4d30d1d52d4a6448ebb166ccbf1d5d5b25270b40bbe1 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeAzimuthMode.java 60fa87bcbdecb43685b718b4638bc5624d96ebc4907e0cdcb79ab163cfc8012d +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranchProbability.java f3097dcd786c0c71d648b8aae81ec56c41b87d2a3463c206c262cc6e510a0bd5 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeBranches.java 52490a2a7b724851ed2842ba0deede783af49f9e3b00b61a8d325b6d5cb43f73 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeCanopy.java 1cd2dd2a719050a90534fda53d5ed73d473f1ea313f39476e6ca2483a3d75db9 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecorator.java 07512ba13e6cd3bc91fd82a1ddc66b87d962dd4e28efe7fb261d81b2127d3493 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeDecoratorTarget.java f19121cb3ffdac18aca6a38f6724a8d7694396fa81045335cbe1523df90c6560 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeFunction.java 1e33e8c88d0953c5f52f7210ee4ba769c076e691db317f110350d2dc9cd3b8c5 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeLayer.java 1ab82828fea136849a9e5a4f0bfae4bad1f36364e9689f3f83a2a5386262b129 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeLeafMode.java 35930479f3bd5892b06f54d7865daa946affc199e6b69aba637f5c80bd98fa3c +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeModes.java ce070224488a8b4d02566162475406253d7d11ce990bffde4703401a38cab1d7 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeProfile.java dc2846b836efbc9b935621ff05df1935d3698c3e14880fad98a3cf66fb8b7573 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeRootStyle.java b62b9e6016f5dcb2644d8d324cf74164e565a0ec93c4ca78af4fa13a2f748eb1 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeSecondaryLeaf.java 1a63c61e7a0a723b14fbed4e906217dd023a992eeac2a8488c05ed834bdd2eab +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeSettings.java 22e95abe2290cc8b7f27ffc83522b7f45d90f014ab7cc95a226225d60bf2f70f +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeSize.java e6d078de687cd9ef7b39d4aefee81a6a4b3bca5f4b8dea55cd3eb876b4d696c0 +source core/src/main/java/art/arcane/iris/engine/object/IrisTreeSubBranches.java 9c51c8a0609cfba78a5bcb333ea69b83475bcc3181e9adaaf77964382338b38a +source core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java 0e0a15ffb2bfad94f8537aa11e0164a93e6b2958fe10623a3d8bb8b1dc6a7870 +source core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverSourceConfig.java d898a754f581513cb56d3ed351cb9439179ac1d8110f015e712cd181ede321e4 +source core/src/main/java/art/arcane/iris/engine/object/IrisVacuumSettings.java 5535e0630ffc40494bc5857ea73eba0d9ad4dfb8b44b1203059c053e46e5af7a +source core/src/main/java/art/arcane/iris/engine/object/IrisVanillaBiomeTags.java ab3f3e14ddaea85d00bad70ec87d6471266e695a309767578621559671e1def5 +source core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java d3460a96784b2dd5457f7da16e03a69330852d650952b51413ff30dd85bba732 +source core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java 7b7e05f15065377a6e284775cf059f13cf6c52370e85a33b0e32dfc9fbed2fe7 +source core/src/main/java/art/arcane/iris/engine/object/IrisWeather.java 1007a0c390653664dbc0aad2a6904f065efa55d1ded043366a64f124a68b76b7 +source core/src/main/java/art/arcane/iris/engine/object/IrisWorld.java af2c9dddd7f3f648b4c82545b469bb23aa517a4d1e0a0fe25ecc933397849886 +source core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundary.java a058c0eddfdc5feef81abde73df041b97a8c6b6ac4e4846d31b7c051f0e26589 +source core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundaryCenter.java 700c715324682d69afebfcc27e58594f3cd0515a867e0b7bbb299402170ab309 +source core/src/main/java/art/arcane/iris/engine/object/JigsawJoint.java b4f25e63a4ddfc2f250b885a03f228c19213b76f7aaa48c7ea2959b0f5e66769 +source core/src/main/java/art/arcane/iris/engine/object/LegacyTileData.java 8a80a7b40fe7429be0f796603c6caaeb2c530721505390567cfb9c5a0f15f0d7 +source core/src/main/java/art/arcane/iris/engine/object/NativeFeatureGenerationStatus.java 29e69fff49065096464a67d9bdbb245df8b3a438a4942db9b01b9e0249d6b5bd +source core/src/main/java/art/arcane/iris/engine/object/NativeStructureGenerationStatus.java a2ec06101b36998d94ea6bc5e49401b9943c2e9dc037edcbd4a642b791b523ef +source core/src/main/java/art/arcane/iris/engine/object/NativeStructureSuppression.java 02fd9d1e56fccc92252ab5e352515e736a09bfe391c60d57948f5d45ff5ac813 +source core/src/main/java/art/arcane/iris/engine/object/NoiseStyle.java 1b9fac39f37200d87adede7c36116ff4495eefd19bbc84f8dfb3ef97143f589a +source core/src/main/java/art/arcane/iris/engine/object/ObjectPlaceMode.java 6d5aec1eca0a2b57c34f79b18fe3bc494911caa8bbf14fad9bea6041c4ebbd95 +source core/src/main/java/art/arcane/iris/engine/object/OverrideMode.java ab63c9fd45bdb2bd6a0579d818a12f5f05aedbffd42024adc2fc8c427b09bf43 +source core/src/main/java/art/arcane/iris/engine/object/PotionEffectTypes.java f9dddd9a786d71357acae117e29dc52ed99815b7ce61909fe65e54cd6dc32548 +source core/src/main/java/art/arcane/iris/engine/object/StructureDistribution.java 6cb6675d49e17857a82baa998f866d99100cecb3a3e89ff0d2c25325d5272735 +source core/src/main/java/art/arcane/iris/engine/object/StudioMode.java 1e64934c03d5cca634e19ea44a99ee5b7cba68fda6fbc16aefaeaacbc5a4651d +source core/src/main/java/art/arcane/iris/engine/object/TileData.java f95ecb5098df6c27e99013be6f85cfc57fc8bc995e487f953191eadee9c7ddb0 +source core/src/main/java/art/arcane/iris/engine/object/TopShapeMode.java 305b303d00b187f4da9e36a256c9e9dbe258b909f15fe3f9ba7697efd26f818c +source core/src/main/java/art/arcane/iris/engine/object/annotations/ArrayType.java e5edacb489c4de16c4102401266ce3973083fd522609d44c8d813533f64e854c +source core/src/main/java/art/arcane/iris/engine/object/annotations/DependsOn.java 56e2ad466f31cd0c98418105ea7830208ac261a1be12723fe0cc9237b616228f +source core/src/main/java/art/arcane/iris/engine/object/annotations/Desc.java e640e607958e20063e9383a7cda1fc0feab78f5292ac90674375e4fb26fb27ac +source core/src/main/java/art/arcane/iris/engine/object/annotations/MaxNumber.java 401ea8fb5f76f2c4e9bac14f660212aba746fd6e48794a32fd0fdc02a5948818 +source core/src/main/java/art/arcane/iris/engine/object/annotations/MinNumber.java 00108e553586886ca29b4290f66bd21edfbaa6f19e2dc9dd68427e738b62175b +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListBiome.java 0bd430dfd750c0942d87510a4a841336bc214365d1756f5dc65695c3c1583387 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListBlockType.java 875546db8db492dcff2d07dd4dedd4586f522070e8d7a852a909d17685a6f53f +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListEnchantment.java 2654737786accb978faddf0b407b8bdaafb3a6781e5c37bd325a02adddae4535 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListEntityType.java 2d445bf62bd356c5f398f705796414b0cfd08190757d25319cd6df9cf37b08f2 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListFont.java aac86e9b558e5629bbb82403e16d9c1e008c993f8ac4429f58de1460dbeb037d +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListFunction.java 940e739215d2420d473bedde4e1a459ebf3ef3a7727e8a82861777a656c7d53a +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListItemType.java 65ca1e1b9250b545b51acc17ddb5de17fd79b6f94c86c49cdbd7f2d3fb3c1ba5 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListNativeJigsawPool.java 571c6f313b5ac8025009881358cf709a65f8ff10e2f8ffe61e3afd0343b63090 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListPotionEffect.java 5f9267b2522b07a4bed1488f2c615e373250f740863abc981c9e474a6dbf7547 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListResource.java 48e5fa3f53b85f038f479b14b14c0665af21c3a9f30ca95e2b060908b60a2222 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListSpecialEntity.java d2763229278f3d83641cadea4deb26c580ec5d1c670d8787ef627f2699ab03e7 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListStructure.java 494207ecbde7467de5f82bb2bd5d3cabd61e35d95837842a5218aed0bff631b3 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListVanillaStructure.java d03f9eec68c3a8de2854941337f1992140737e5a78f4e273a851fdf2d643d084 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListVanillaStructureSet.java 3c948e3c5e746789bcc2cf9a8f134196094ddc48b30f9042541d9174d4898e39 +source core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryMapBlockState.java 4c95567aee2dd9521c716f8c708f9503774fc0ecef8683f3be16bdd808a141c2 +source core/src/main/java/art/arcane/iris/engine/object/annotations/Required.java bd06bb734b8bddb7971c82db64f5ae23a8168ad5690dbd598a8b30726dcd2c8a +source core/src/main/java/art/arcane/iris/engine/object/annotations/Snippet.java 5a3f23a4d9955dbb1a3827e31ecb59a27e3a49e4a4cbeef70f15c2c938963f9f +source core/src/main/java/art/arcane/iris/engine/object/annotations/functions/ComponentFlagFunction.java 632fa21a73bf4e6474fe181e090d6a7ad2812b30dbafa5b8c40b4ce62815655f +source core/src/main/java/art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java 5290972c68b26049fc0dc4dd8ecd9009dbbc58369a72033133c73a17468b6295 +source core/src/main/java/art/arcane/iris/engine/object/coral/CoralCanvas.java 54b4981245a8eb4f326929de3f8be2c20197aaa6efe6f8f7e8a7c7c69a141864 +source core/src/main/java/art/arcane/iris/engine/object/coral/CoralGenerator.java 35199093847d5baed1577c2c276064c8c88d28a3fc062df76a1c1f1e817aeac5 +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalBaseBuilder.java ffc52e9664e23cfa3dda220e2d080b2077926b66aa9f84034adc49e7cfe38d6d +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalCanvas.java e6261162be929c50217536ac5197e2cf53bb389700cb34ce60b0d8ac35239d32 +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGenerator.java d3d6b585025407a6e4541fd5871e5986ee4406f1e1ec0fcc53696e0e8a80d884 +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalGeometry.java f4101c62f94247941d3e4fa14fdbd7d27a60d297226abfa389c4bd1dce29cbbd +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalRole.java efe3b4ea13ca24b787c823c46fa6ded2d8d705faff4df34ae822fd82c212ebbd +source core/src/main/java/art/arcane/iris/engine/object/crystal/CrystalShardBuilder.java fcce96cd30192ef729e06b575eae263b4291b986cf5ecc663c7fb13d79757ac7 +source core/src/main/java/art/arcane/iris/engine/object/formation/FormationBlockResolver.java 36038971f58dedd46a888913629dd4ba529e8fa07635e55e5204a7645cbc03b9 +source core/src/main/java/art/arcane/iris/engine/object/formation/FormationCanvas.java ad42efdb50c28b1acce2fce736d5a585d3d56cb7e7bcddce0812c4ac25aaeae4 +source core/src/main/java/art/arcane/iris/engine/object/formation/FormationGenerator.java 14abd7b35404a34ffc92c85143034ad88e0e8544c2d01424d6bd85f0ebcb195c +source core/src/main/java/art/arcane/iris/engine/object/formation/FormationProfiles.java 1b88edf26487c7cd8bbb0d25d256744f70061f53b0cd91e1458300aad880d6cd +source core/src/main/java/art/arcane/iris/engine/object/formation/FormationShapeBuilder.java 5e3cf340c33b9ee161a3730177359746dc88ef4f367c255db54e31a30020ee13 +source core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCapBuilder.java b270399107bb075721b62f51e7eea267359d50079707f9424866c85d5bcdc8ac +source core/src/main/java/art/arcane/iris/engine/object/fungi/FungusCellRole.java 110bb130ed09c858193630f8985fa71c523db77d0ad43481c60c7f1967b60e54 +source core/src/main/java/art/arcane/iris/engine/object/fungi/FungusGenerator.java ce19b708af6355f80c6d49294791f1e2aec86b2cc1b4771b9aad90eb0be59f7f +source core/src/main/java/art/arcane/iris/engine/object/fungi/FungusShelfBuilder.java c8891944dd30234d49dab15ea842936665dab148e627f1154a913fba1b83533e +source core/src/main/java/art/arcane/iris/engine/object/fungi/FungusStemBuilder.java fc3d600fa5341c93116185bf3ad34a65dde2db7a48d3e3e88f1a74103398bb92 +source core/src/main/java/art/arcane/iris/engine/object/matter/IrisMatterObject.java 5934e22c0c145e3d41a6167bd5ae2714f578d0654ae0b8a7b2ef4a4d79fda4a6 +source core/src/main/java/art/arcane/iris/engine/object/ruin/RuinBlockCanvas.java 4f34080210e484a093e32cbc82d8859c84600e48d5f0f5cffd3fc2945d11b418 +source core/src/main/java/art/arcane/iris/engine/object/ruin/RuinDecoratorApplier.java 340aac794784b18654e0e605f1cc2764fa867593be927945340b480c9d08e50b +source core/src/main/java/art/arcane/iris/engine/object/ruin/RuinGenerator.java f81e211499be7869421bc8afc892ec2036ffe169dc0d0752d532598d1dd3ea29 +source core/src/main/java/art/arcane/iris/engine/object/ruin/RuinShapes.java c13c727390e7b4fc6cb61af233199aa270c26e3d4389d4ce8a96da1b3b06313b +source core/src/main/java/art/arcane/iris/engine/object/tree/ProceduralTreeGenerator.java f88f37003efd089707cceb1642bc868ac59bd49a499092774e0f678854dfa4d0 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockCanvas.java e22dbfa8e39db43150baca70476be9626417b899cf7c2a6e672c89818cdfe0ba +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeBlockResolver.java 402727f10a403bad2aba35621464fd0606124a71b46411f53c13c72e746e6409 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeCanopyBuilder.java 1ddf30c73466f9ca9e89df80137c8b5d5fca42539dd5b757e2380372e9b3fe12 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeDecoratorApplier.java d751001eef389ba0264a7434f742106409196d8f950212da84833b538ecf1f14 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeFunctions.java 2dc29ed64041b0bf2202d445878df65b1f5445d73ca0fb774eb472b6adb2b9ed +source core/src/main/java/art/arcane/iris/engine/object/tree/TreePlausibility.java 8d0ae9211fc370d7ca081effcb136e3379fd630627cab8a982b6cce9ae8a2088 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeProfiles.java dc15c4acedc709a2cc73d36aba148899cb3e129cb5d1656f0b6e5a6df8f5363b +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeRootBuilder.java df4248d6dd1508b4856508a2a8363b7cffae0d8db66a10495ffe9593b8127f68 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeSupport.java 11c72c83a632d34945e3032d68ba89f0727ff3e7af3fae64988d17a8e9bf0fd5 +source core/src/main/java/art/arcane/iris/engine/object/tree/TreeTrunkBuilder.java 856d0b46117a485df832cfadd2c31b491fde8609b43799b060110246524527d1 +source core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java 6b036e6e3246df6a8e850344c67b0b5b1f8cc5e36914d304b5e9eaeb3a214243 +source core/src/main/java/art/arcane/iris/engine/platform/DummyBiomeProvider.java 560036f370d550f7916e6ed4b79df907fc0787567c27eabc49df1ef108cf5b13 +source core/src/main/java/art/arcane/iris/engine/platform/DummyChunkGenerator.java 12ecd07d0189672a7c161734bce487f6cb6b4797a71df3692f29fa7ccc34cf32 +source core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java f61b6c069e48b159c875c08df603ac24762969f8342f724d801e6ceed6e18633 +source core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java 5450184e6474e78dac7788c9ef395a9a0fce430a2c6cd97b3f08383b319378bb +source core/src/main/java/art/arcane/iris/engine/platform/studio/EnginedStudioGenerator.java 5e279de24da76030fdfaf8b037bebf375fd35bd3e388fbaa2c4a24942ec1cedb +source core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java a94909957b4a6c60b9ac995312a72ef4208d5fd8862c4c03c2525693e9ebe69f +source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java 5d169970dcd15011a6dfb9a8ddf76b0c03de2bef09b0f7c8ffbe3ce6787194f9 +source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java 65b7b09536bcc9a92b8588f06f0d1e94d1301fd19be8e8e26a17b79ff67e3e86 +source core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java 93d9b05fbbcb579ac898d83aadc4bddc084aa05ffaebd84f72c3e51b5f4115b3 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java 4d6cbcac0788b593616e063ed102dbbd93db57f9354f8b5294f19b8660748889 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiomeWriter.java 8c37ba79a8299a4b05f5e561380b03a54a10cc2f32f7d79b25ffd544211a7d54 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java a032f34aa55fb67d8f8435689f75ae2323edcb80204c93716a8b5fd8cf86b784 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java edecf9a49a5372e63cf3dd04999eb31b14a366ffa7f35d1340c3588edaf5593f +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEntityType.java d1e5704ea5a4a9de73c0a119312f10e8888cd26d373e0c888ae59e1aecbacead +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEnvironment.java 701e3365e2767e387d291de92926698a30f0125392c4d8fa209c6b5cb72d6be8 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitItem.java ed5a7a0d0b1d33f6f83a09ee546018f631676fde47865b6168655b5afc3572c1 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java 66feea66899c8f657b460bc957b399c07e10573bc53495c0336fd5d3a722a124 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java e9ac71f90672b9c69d28124f7a2302cd5510478eb9a8177318440bbc9e774f4e +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitScheduler.java 6e70b1081763190048e6df1271328caa749465d5572db77892ac205dd99d16b7 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitStructureHooks.java 2a6ec7a8ffd682e99476debda475d49273ff5eee2237dfbdc8fda80dc080c6e5 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitWorld.java ac8a80f986ea4c8923a8d18c020902ce174e0bf136d35304963cee09a48bc3a5 +source core/src/main/java/art/arcane/iris/platform/bukkit/BukkitWorldBinding.java cb27266059ea0e2831654bcc38c479345b1faaa8fe19dbdaede80e84370e2e8c +source core/src/main/java/art/arcane/iris/util/common/board/BoardSettings.java 2d4028c4e8d4b84d8709c8da81fa90c446a5e6102b6eebc4816d89d8ed6aea7e +source core/src/main/java/art/arcane/iris/util/common/board/ScoreDirection.java 3389b64fac4546e95d4e343b967cbfd4338ea06b5a9bc9bfb4ac2c053f726eb5 +source core/src/main/java/art/arcane/iris/util/common/data/B.java c4a2ca1949b25d664803a9331271c8cf9ff07cdeb4a390e8827633b079f50662 +source core/src/main/java/art/arcane/iris/util/common/data/DataProvider.java 499f1a1b265fb2f504ac40b35ff5bd0ab2b21abc0bbe2ec4afed09f14e09a029 +source core/src/main/java/art/arcane/iris/util/common/data/IrisCustomData.java 3fb98125822014cd36254ba153e46a12331ba4f2c1fa3e8c23e8da45d83d9dec +source core/src/main/java/art/arcane/iris/util/common/data/VectorMap.java 7120c0caec76c0e29a98d050ff0dac8711ca218d959953f5f8d9d74f1b657844 +source core/src/main/java/art/arcane/iris/util/common/data/registry/Attributes.java 6fc3ab6299d8a75754f48a87270bfeabb063b00529c536d4797f5822d21153e0 +source core/src/main/java/art/arcane/iris/util/common/data/registry/KeyedRegistry.java a0506c114e251bdc76dc6363f2f045bab95aa946c1aee4ee7c899524c958361a +source core/src/main/java/art/arcane/iris/util/common/data/registry/Materials.java 1a6766d5b434de63cb1e6505baa06a3c73ea27e79d2afce0f5834d08c373bc68 +source core/src/main/java/art/arcane/iris/util/common/data/registry/Particles.java 768c2aa7ae16e7c10bc242033b9375777c0cd62a224a9887bc02244bfb156230 +source core/src/main/java/art/arcane/iris/util/common/data/registry/RegistryTypeAdapter.java 3b28af25693dcb6659e508299b136aa4d89a1b24481f8d58293f600ef443d256 +source core/src/main/java/art/arcane/iris/util/common/data/registry/RegistryUtil.java ea0d71132b3bf9a937333c2cefed745b4a966583b5ac038d9247b3884e6265bc +source core/src/main/java/art/arcane/iris/util/common/director/DirectorContext.java 241f1a1694be6d6688add7ccb7d01825768c2c499fe867810703427d6094163d +source core/src/main/java/art/arcane/iris/util/common/director/DirectorContextHandler.java 3190790afee9f33412ef099e7177421f52fbd11e445d9f42e70255dcf3ee0cbb +source core/src/main/java/art/arcane/iris/util/common/director/DirectorExecutor.java 8c617fb719f4051224e7483bb033f2b85ded9797a6954b9c4be278ea501bda9c +source core/src/main/java/art/arcane/iris/util/common/director/DirectorParameterHandler.java b7606427e94cffbd4871f5aad96e20554811ecc8d252e4220388fea57fae1410 +source core/src/main/java/art/arcane/iris/util/common/director/DirectorSystem.java 0ccc9d69a0dd52ec3a301d2b175aa9f9793c156729c993e5cd071af2d5403f3f +source core/src/main/java/art/arcane/iris/util/common/director/context/BiomeContextHandler.java 60d8c2f43c23f5083b97b5d5d042cd6cfcb3328bbaaf13c1fc0c00814cba2892 +source core/src/main/java/art/arcane/iris/util/common/director/context/DimensionContextHandler.java af70356e729654442f1d31ec5ad5764cde485602eaa7977d6a31c9c7465436fb +source core/src/main/java/art/arcane/iris/util/common/director/context/GeneratorContextHandler.java 943912dec8527c95fc629855696f4569c65590756efb6e2dd6a0ec8c1afd329d +source core/src/main/java/art/arcane/iris/util/common/director/context/RegionContextHandler.java 00c602eb42b44fcfd40595e0ca6807916cc058321621f9b773d6b2c4b7fd4876 +source core/src/main/java/art/arcane/iris/util/common/director/context/VectorContextHandler.java df76e6e2c4661a4159e07a49b219700f91968f86c70f004482b2e8ec97135e28 +source core/src/main/java/art/arcane/iris/util/common/director/context/WorldContextHandler.java c52e1d9a0f2229c6944a4370664429ece62e253f4e403b13569738d83aad587d +source core/src/main/java/art/arcane/iris/util/common/director/handlers/BiomeHandler.java d98db91c3957f3130a655353610372953429acfc2ada03497add6a9b260ec75e +source core/src/main/java/art/arcane/iris/util/common/director/handlers/BlockVectorHandler.java 02862855b97c3bc4de662bc0b916084e6b09f073426b24574aca91f40a610c7e +source core/src/main/java/art/arcane/iris/util/common/director/handlers/BooleanHandler.java b5f2384ee4df5bf2e78c46d2b0acbc2f34bff3f02531df5b7d22973849cd3e7d +source core/src/main/java/art/arcane/iris/util/common/director/handlers/ByteHandler.java 3d50e2515854537deef85a74f3d2b43818ef5b679fa4bdd715ef39532c274a73 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/DataVersionHandler.java 25941ce2e039180454e8ed733e2f603f57e4c5527cec94f87de0b35c8acf6466 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/DimensionHandler.java b991f51b4bcdccbb7d561676f4319e88eb875cd990f823bd3fad7d8838718ec0 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/DoubleHandler.java 79188a98a32fd21d3ea65ecf5bedd6f6221e88a093ff08e99b4f325510e2d77b +source core/src/main/java/art/arcane/iris/util/common/director/handlers/EntityHandler.java 0f926d62f1a9ca8fc80a7c15f50f88c2b29368f51ccdfc1487aa4a6b9dce0b76 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/FloatHandler.java eeb0157418834453b87ddd67c591a24b5fb039555c1ef73b140173b1266dc1bb +source core/src/main/java/art/arcane/iris/util/common/director/handlers/GeneratorHandler.java b9c310c27af1b34c1ab1e1154d8c27792c99746b4f8f52b8086ed58766860cff +source core/src/main/java/art/arcane/iris/util/common/director/handlers/IntegerHandler.java df8342c7c9cdc89f8e76cee57ff8ab0020c2a5a1cc6fdca76064e1594aedd783 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/LongHandler.java 9e3ee5b7f1c5e3444d762b471d9485654159d168bdf4e332aec371515778767a +source core/src/main/java/art/arcane/iris/util/common/director/handlers/PlayerHandler.java 05c60b0b4e8d67c97ecbbedbe068d5b920fd930eb1e5726368499a3dd15de98b +source core/src/main/java/art/arcane/iris/util/common/director/handlers/RegionHandler.java e81ba147bd7fd2ce53f1950a393a276dad3b3a9505d39b194b67c363589fa3ba +source core/src/main/java/art/arcane/iris/util/common/director/handlers/ShortHandler.java f8cd415ddb63b0e76c96b5dbeec1071a3d10486ce374357a4d643cc8ae1ec32a +source core/src/main/java/art/arcane/iris/util/common/director/handlers/StringHandler.java 2ba059b3f029bf20198d74297c843c77954e229f2ba8e564bc18ae2a191ec661 +source core/src/main/java/art/arcane/iris/util/common/director/handlers/VectorHandler.java b87879a9bf6977d3e6912e5961765d2a75123dad774ab3f3aba0f16aae56e1af +source core/src/main/java/art/arcane/iris/util/common/director/handlers/WorldHandler.java cbfe0d8abb2d7c854a7d042aa3ae479845096796e501b7e3ce48419b46b6b6b6 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/HydrologyTypeHandler.java 02b535ec317ccbda3293a368266fd2b4f230aafdd86db737b84971358380a0e5 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/IrisStructureHandler.java f0f1175fd202efbacdbafe73ffc81bd0a5cf0b1b8b9e59ae4775f339900d14db +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/LocatableObjectHandler.java 385fb9a2e04f630cb3005a9859a4e16ea37416bb9de1a262111588b612c37c9b +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/NullableDimensionHandler.java 030665aa59347df09c08de416d9a262099e05ddc0f7bb27a0ec170261999aace +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/NullablePlayerHandler.java f914779d13aec15640dec63a88d5a05b43bf093bbc6d305bd0697296a5ce8357 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java 659975224155bf2cc505ffe2ae922f7c71f137ef25d94be62cbf60c32310b65b +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java 02ebb5f35b5b79949373a2a22434592fcb824d97c5cff2f6d8f8caa8137e1f82 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java 41e0516afb968b57740f086faca631bc48a39b589e2e5cd711fe41e5fe5b0203 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableRegionHandler.java c06fe8c3e586429c0c78c4bfc5453912d81a31c089078253034f069f50e36dc6 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java 7b5306f1cce0a4a510320412b59b8fd7c0e0c8c1bdf23448fc8c287964a165a5 +source core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java 0e5e93f4b44c39e491b2ed26b17e076b727171778268d3df7d01f8c926b101be +source core/src/main/java/art/arcane/iris/util/common/format/C.java bf8cddb1a0e2ea10f8ffeca03b9e7b033066b839d01c1f3867776d634b65facd +source core/src/main/java/art/arcane/iris/util/common/inventorygui/UIStaticDecorator.java 3841cbb595929bf67f72a8ac88f7cb6879d1f9ebae25e73147e98bde01b3794e +source core/src/main/java/art/arcane/iris/util/common/inventorygui/Window.java e14517b148a65c9e954a3cc7777445083b98fca2a1b532c2167509f95fdc5344 +source core/src/main/java/art/arcane/iris/util/common/inventorygui/WindowResolution.java 71aea9d541f3afb299e8b74fb2b4fb04aa104b77aa76b35ae76dc3f5e93f4c7f +source core/src/main/java/art/arcane/iris/util/common/math/AxisAlignedBB.java 69dffa2f3b44276d39f146108cca525f2734b54dfd2528b11618380faaa91072 +source core/src/main/java/art/arcane/iris/util/common/math/ChunkSpiral.java 233d36a41d8144437b1023329685f9b5b690f85d5d4c3a7fde16411ae6bce795 +source core/src/main/java/art/arcane/iris/util/common/math/Direction.java 4623f70902bfb1b23f156765575f0e9e33acc24206ef7b92aeeb7ec18bf1de5d +source core/src/main/java/art/arcane/iris/util/common/math/IrisBlockVector.java f91db04a00af69e6a10c05ab98b5dc76db22da629504e11bca61188ac6cabce9 +source core/src/main/java/art/arcane/iris/util/common/math/IrisVector.java 04aeed731584d3e28fcddc0f53b32c07e76091e8ed82e957518ab0aa319e0402 +source core/src/main/java/art/arcane/iris/util/common/math/Vector3i.java 39da42ce65ffac27c0122e354301b4e329350e65ba3b026c9b658af072eaef16 +source core/src/main/java/art/arcane/iris/util/common/math/VectorMath.java 55c5adcc531c4dbd8b0d616c2e7b2ae6a4aa6e4682f644c5d99d8800b57cdd77 +source core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java 4d254794cdc808dd49c2481d06b25b0392416848ab45bf8f491dea15e792f31b +source core/src/main/java/art/arcane/iris/util/common/misc/ServerProperties.java e72647a7ec0ba85454af2799b7ce74d38b4934d38d4c96ecfccabb1aa0b77422 +source core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java a50e26f6f530a6ec588cec8a0ca4bb32c60e31b427e3c1502b486d6d97e76627 +source core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java ac44fd2da7d1ccadfb4ecc795dd167124163b7a219f4ba9213707e1cb1668c64 +source core/src/main/java/art/arcane/iris/util/common/misc/getHardware.java 2f2546acf31f62f490951b93d3b0739b7b032440af9abdaa832815fd36684dcd +source core/src/main/java/art/arcane/iris/util/common/nbt/mca/Chunk.java 9ae7326436498ef5e896d43ef8144b7c02cc89fa5c1a7890d0e0d699b68be4bf +source core/src/main/java/art/arcane/iris/util/common/nbt/mca/MCAFile.java b52cff0c7388c053db3ec5776ec9ddb31e3dc30480da01936a0a9888be0776e2 +source core/src/main/java/art/arcane/iris/util/common/nbt/mca/MCAUtil.java f7b170fd91519e4a55b06f602269ca4836c8148f38c6637e1e1abc3155b44dc7 +source core/src/main/java/art/arcane/iris/util/common/nbt/mca/NBTWorld.java 88dfef5359ad041f3db198fff43a170797714de7ad7d7c78370c42bf07522663 +source core/src/main/java/art/arcane/iris/util/common/nbt/mca/Section.java a36b901345c80768b71ef1d840dfbfa3354e9e63877bb5426455a3f8edd60f77 +source core/src/main/java/art/arcane/iris/util/common/parallel/BurstExecutor.java 7d50b3b577a4c3e50146d600897feec3c64ad6acd00f73fdaced20d11616bb68 +source core/src/main/java/art/arcane/iris/util/common/parallel/GridLock.java 0a9caf4ddd647968a1febfaeb9a9c17663f4226c8fb9034bdfa72edde470aee6 +source core/src/main/java/art/arcane/iris/util/common/parallel/HyperLock.java 6d14685ed41b87ae00c60db4cc9f57921957e5d79d09b774f8cc67b9b04ed4f2 +source core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java 73b81e425a5bed9b51e1bcc1a6f7b4cce2caba9938b57604fa2428c545e9a7ac +source core/src/main/java/art/arcane/iris/util/common/plugin/Chunks.java 0e30c03eee9435b11e5c8317cc63982afc09eb3adb9c73220f58af2bc942a2ad +source core/src/main/java/art/arcane/iris/util/common/plugin/IrisService.java a036c877f9127189b8f5e11bdd75dd898aec87bce99faab9487632580cdcd35e +source core/src/main/java/art/arcane/iris/util/common/plugin/VolmitPlugin.java cb128175a5d2c50395126e19f9794688cfda29abe273a3b815433e0e10fa902c +source core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java 9fe6fdcb380d88580a57b3a20495bd1bfaa890443e9ad6a479a99ba542222bec +source core/src/main/java/art/arcane/iris/util/common/plugin/chunk/ChunkTickets.java d57009b24b8c300bcee1b89dbc28ad3539db4d32f751b0725ea80bb2b67fa2a7 +source core/src/main/java/art/arcane/iris/util/common/plugin/chunk/TicketHolder.java 7fc88f5eac464e543d1b8ea85591b2adeccb93cd1be573af07706f8e1eb54f0a +source core/src/main/java/art/arcane/iris/util/common/reflect/KeyedType.java 8476552092b1fc15868fadbe58eaf298e64b8dae27f2e7559706c5d757ac740d +source core/src/main/java/art/arcane/iris/util/common/reflect/W.java 71aebec938f0486ce46e91ab86690d4b77259282dbbf97b8a2954c34575d0cbd +source core/src/main/java/art/arcane/iris/util/common/reflect/WrappedField.java 1098f887ada2e4ff1a3dec58b5a3cbd344cfc021a29dee4faca85c6209b566a2 +source core/src/main/java/art/arcane/iris/util/common/reflect/WrappedReturningMethod.java f5b5057deb468c9669861f53c4b43636aa88913b121428195393711b6173dc09 +source core/src/main/java/art/arcane/iris/util/common/scheduling/J.java 68e323bc2b948427b736a9d2475439aecb77a73f6e25000a20a7ccb6e857e78e +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/DownloadJob.java b2ac86872f1899149c40ac369e7d222a98175a6d38dd9db2f511d8b4ff639566 +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/Job.java 7d1e01589b0fd4dc0e0ad3b7702c8d8c11db5ec2581ec031c016486cae46bbf2 +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/JobCollection.java f4eb59e9a5d3109ff04a9f440cda444bd0f77080972f29908c44ec545a5c70ce +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/ParallelQueueJob.java 3840e0d11d7453b60c0d93bdbc73f207425022df0c358216c028f4f079305c38 +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/QueueJob.java 178d5535ea3b3e2543641c4177a7e6917062b4b72f871af303c2e7872c289086 +source core/src/main/java/art/arcane/iris/util/common/scheduling/jobs/SingleJob.java 467858e6fb0a287dc40eb62f0d3514adc071d629d6ed4ef3730daf7dfe7a337c +source core/src/main/java/art/arcane/iris/util/project/agent/Agent.java ab3f625bb74e91cbe6e16bc8de000978e5bb6571ae50f74259ef701d5af80874 +source core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java 122023aff0b790d6893e5e63f865b42cd451233079c62b4b6a14ba22a5e53c1d +source core/src/main/java/art/arcane/iris/util/project/context/ChunkedDataCache.java 366c349e9260e4e9f329b5afaa1b7145da47a6946e2fab18515af98ce027a003 +source core/src/main/java/art/arcane/iris/util/project/context/ChunkedDoubleDataCache.java cef35eac773115991933b0a8897f39f2a9b4681c4913e3f5e431b2505bf95ca6 +source core/src/main/java/art/arcane/iris/util/project/context/IrisContext.java 246b419ccb142754c8811cf6dae2cc8c7b9aa6d696a538df64c8aef43430866e +source core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java 2d7e3ce55cc90aee9d2361a89e2759fad8da8a7864f230b1289db9b0ac1c2c8a +source core/src/main/java/art/arcane/iris/util/project/hunk/HunkFace.java 4cd1912f5c88d8debf00620b2e697652be2d9b2457947c71e4c38094c1b28338 +source core/src/main/java/art/arcane/iris/util/project/hunk/SharedHunkAdapter.java 2d15b0446a3d7867616acb9088ae37629d04c9a4f4991a96f19dcea8ef8d6102 +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/ArrayHunk.java f991504f354c1aac494c80d59e09a66f5d860e967a5c2ac944be88ee89019a54 +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicDoubleHunk.java 42fe5835ab8e2441725b8022c01f30d5b61825fa6934870f82f05178f82a9e0f +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicHunk.java 5f91d541d29e0b12dc42410f04588b796e49d3ac85ad506008a7726aa00cc33c +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicIntegerHunk.java f17c9091de3e5f65ab6565411e6688f9d154b987e1b0411c05cfd058908cc6e8 +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/AtomicLongHunk.java 3cfe22a63b6c1d8982d013df2eae3c3282acbe1cd06d1c043d46a33ee2afdffd +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/StorageHunk.java 497ba6a9036495d186f9b6bc51511ff3b00ec3156e25c0ed430f1dc7b670a770 +source core/src/main/java/art/arcane/iris/util/project/hunk/storage/SynchronizedArrayHunk.java d0454878719e6fd549e04ed7c76d8b0d227a4e63e977b621ac612a7339d55afc +source core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkBiomeHunkView.java 267e9bbab55773b5cbdf875b420329a023d39092e7e905dc7341eb24bafb60f6 +source core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java ea6d723c5fe167da92bbea7986f415b5206dcacebee8de7fc9de21d0564ad5fe +source core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkView.java 6dc3f7d0a243a86eeb43071d02b51da4e77c6d61d372a44e1d07ea5897eb810a +source core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkHunkView.java 4142c7bea9702bb5826fc14a4f5045c2dba047b8ab1f32b3a5074e6a2fe94237 +source core/src/main/java/art/arcane/iris/util/project/hunk/view/TerrainChunkBiomeHunkView.java 17b887f1dbd705430071761845d353bd65dccdcfda84122238273cb62f54b336 +source core/src/main/java/art/arcane/iris/util/project/interpolation/Interpolation3D.java c7b7ae82442e6fe49f5c05364f1857ab7f7ffa5b1a5964e6c0b19e796c0c4b4a +source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationMethod.java 8948ead4aeb36e734b32dd7c247c0546c59e41d7792bf913053ee4f5154793e2 +source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationMethod3D.java 4d5831fc15fe506432ec0f747ccd8cadc1f4976c561812d18a53fea3af121e2e +source core/src/main/java/art/arcane/iris/util/project/interpolation/InterpolationType.java 50266ebcd1552f781fee3409f01ff24259f6a33dd7c1e9876075b7462f5bca2e +source core/src/main/java/art/arcane/iris/util/project/interpolation/IrisInterpolation.java b217803f57df1bcff429bbc9319598294ec3f1c68bab3e34b7bc4fd722136aef +source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBounds.java 484d465f6e7ee86c567570a3d044bf871d716728b936ea1909fcc5b6fb8834d8 +source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsProvider.java 5da0eaf2db701a21db2fd3009660449b5c2ee306ac3bfaf0522b15f82890008a +source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseBoundsSampleCache2D.java 7a8928a4e3649c7120c5da17e11eab45bd1f39ed9ebfa4b3a58ff42c5a0f60c5 +source core/src/main/java/art/arcane/iris/util/project/interpolation/NoiseSampleCache2D.java 2a7c13310ec82c1211b73e4925bd0262fab32fc7e474ab4c41c9f803e18e107b +source core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterContext.java b8eca540d775aac3ff015b12e2d1c56967443c186ea8e28678c210ce5a67c0be +source core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java 0ed6c5fceaed9c5f88a6f431e5264707f402a63fc95307b1fee175fea6d50354 +source core/src/main/java/art/arcane/iris/util/project/matter/PreObjectMatterCell.java 341b5359d510650bda08568e9da8d503c1efb8f9b37474e84164b78edb6eb640 +source core/src/main/java/art/arcane/iris/util/project/matter/TileWrapper.java ff216e098814b35677cd177292319160b62fd996cf8b6788f0c11fd94f019448 +source core/src/main/java/art/arcane/iris/util/project/matter/WorldMatter.java 8c3c2dd2bf40737ff8ae6025fddba4fa34a414b9c735332e19732d17f8965580 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/EntityMatter.java a9a3a4113ec4ba13e842b87e51622f1f614072ff871b02c59bbcfc5f50b48b3a +source core/src/main/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatter.java fb79e741e638c13538c48b3cc7eddc1ca03614bfff25aed43c7147a7e8f6e081 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/IdentifierMatter.java 72eccdc8be832810f19460787e787e9a7d6bd4610903309b1a9a2e5dd15c47d9 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/NativeStructureOwnershipMatter.java 629b3784ee52f888987ac6fde90acca09b51268c3a7b01d99d6168f070fb2480 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/PlatformBlockMatter.java 93ab902f5c8970b91c79521c76c181d189bec686daec6cb2a1611d1e05fc4be7 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/PreObjectMatter.java a2afaf996085d4c0eb898ee5028550ec0681e941de8e2da3deb66826a54465ca +source core/src/main/java/art/arcane/iris/util/project/matter/slices/RegistryMatter.java ced506ce57bb218993de707ad766b5cb48f1945d135e463aee1bf631f11a6745 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/SpawnerMatter.java b89275e7fbdf9261122ad8387b3dbbff9990641699054086745ef51bb1afd0a2 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/TileMatter.java 3b004600848805c71ba11992b1f63a0f68c0e6ff2bce7a6b0ace61397deea572 +source core/src/main/java/art/arcane/iris/util/project/matter/slices/TreeBlockMaterialMatter.java 363672ec989ffe185a1bafdb975bafc0cca0c34bc866d202ce75c059edfb1809 +source core/src/main/java/art/arcane/iris/util/project/noise/CNG.java d6dda93ac0c9927664e47b44f86d3aa1d5fba63eecb5986eaf8866943d7d2ff9 +source core/src/main/java/art/arcane/iris/util/project/noise/CNGFactory.java 02814eeaf9cabc6c1105d7c88d63ab4823c2ff57ab2673c32bb2f3b7bd69b78f +source core/src/main/java/art/arcane/iris/util/project/noise/CellGenerator.java 41a9a7372dffe939e59c8e2cacad3f938b886aab73bdb6aa0e271495637f53d9 +source core/src/main/java/art/arcane/iris/util/project/noise/CellHeightNoise.java be8e387a5782ffcb42561bc4e17e6ccd4d0c708debc592a6dbaff0f1329baca2 +source core/src/main/java/art/arcane/iris/util/project/noise/CellularNoise.java adc76fe90c137b936cf16be88a19c21fd6efa6a7f47906c1ecd4ef0770160a20 +source core/src/main/java/art/arcane/iris/util/project/noise/CloverNoise.java 482e45a205e8b151580e39f3f7b07a8c4ac77b33bda6e2a7ad207d775bdd497e +source core/src/main/java/art/arcane/iris/util/project/noise/CubicNoise.java bdb5475a2506148f34bd4120a7f7d321811b1fa2f9cb97488e12609412878aa4 +source core/src/main/java/art/arcane/iris/util/project/noise/ExpressionNoise.java f0a8491b3dd3b073b73d752709c9a2232a71cb5bc592b4cad6cb41cffd8bff84 +source core/src/main/java/art/arcane/iris/util/project/noise/FastNoise.java 7b66688b7a5c62354d3e37921538fc1be4c24b9f0be7e38c296321d5c6787e3f +source core/src/main/java/art/arcane/iris/util/project/noise/FastNoiseDouble.java 2d285201ce056cd650f6a9d9ba84cccad07b566767cdc97ac3466986eceb2b35 +source core/src/main/java/art/arcane/iris/util/project/noise/FlatNoise.java af74540f237cd14757109668ea91875de1b466c15ca13d5df116003bf71a4cdd +source core/src/main/java/art/arcane/iris/util/project/noise/FractalBillowPerlinNoise.java 8d7bee43cee226e41963f78a1c5a79a5bef6ce9541246fcb55c1f3de616f0bc1 +source core/src/main/java/art/arcane/iris/util/project/noise/FractalBillowSimplexNoise.java 7626a6c220460568b900b288891da9a4cc8a70ad1c0d79bd432fe3f2b83c3c52 +source core/src/main/java/art/arcane/iris/util/project/noise/FractalCubicNoise.java 859fc69c64302e8868dae59613d723e3c6ba6149e9b377f9bcfaa4bb811b061a +source core/src/main/java/art/arcane/iris/util/project/noise/FractalFBMSimplexNoise.java f3285abcf6061090e8886f09ba192c0d53eaa19bab2706560442a2d22a906e94 +source core/src/main/java/art/arcane/iris/util/project/noise/FractalRigidMultiSimplexNoise.java be682487079f4f1c5ecd77d881ffb1a1d62a1f96d155cd1478d13d2741a6a34f +source core/src/main/java/art/arcane/iris/util/project/noise/GlobNoise.java f0b38f6c189252cc3f48d545d1715a69d2fbf9bb6149e1fa1bbe1a6439bbd22c +source core/src/main/java/art/arcane/iris/util/project/noise/HexJamesNoise.java 44cd644606ae636c89f5fa89e0542da292b9c8492f7b67235313f093bc04b476 +source core/src/main/java/art/arcane/iris/util/project/noise/HexRandomSizeNoise.java 7d7963ab023ed0743f3c92059cff472608608e5f56e317fdecbe6f527f296917 +source core/src/main/java/art/arcane/iris/util/project/noise/HexSimplexNoise.java d9f5c3fb8944de289381050ae376aed48e8d5252cd9b340ab029f155f0e9c7b7 +source core/src/main/java/art/arcane/iris/util/project/noise/HexagonNoise.java 91c1bf026e06bf6f16c6c1935d8f42df71ca9a963ac89b54d755c4a5d9f44f7b +source core/src/main/java/art/arcane/iris/util/project/noise/ImageNoise.java 20a4d70126b3e62b8a79df4285345a6b2a386d12d60ce5b905a2df71aaf2e384 +source core/src/main/java/art/arcane/iris/util/project/noise/InterpolatedNoise.java b596394e3929252d2423054a9a2c56c0019c34de9961972a6962caab5a3c9ff5 +source core/src/main/java/art/arcane/iris/util/project/noise/NoiseFactory.java 813cd569be725db7d2e5bb5744d0655bfc37dd20f72b41e66bd601f13f0d215f +source core/src/main/java/art/arcane/iris/util/project/noise/NoiseGenerator.java cc088b0ad8cea28405616121e9005d9571e63e718de659a7613d3f975ead44ed +source core/src/main/java/art/arcane/iris/util/project/noise/NoiseType.java 4e20aca06cdde422ad7cd4149fa31432b268c959ecbe483e07a1c470df2b99f6 +source core/src/main/java/art/arcane/iris/util/project/noise/OctaveNoise.java ad9f27127d853ff645c00cd204c0f8064124435ad027b1d279fb05763589259d +source core/src/main/java/art/arcane/iris/util/project/noise/OffsetNoiseGenerator.java 469094b6fff1451a720c4bb3e8611c1247cb8ceb7d80c604c3d7f78e905cf11b +source core/src/main/java/art/arcane/iris/util/project/noise/PerlinNoise.java dead31997bca4ad87a9cf36b7e5180322d172f1c3f84ec5e4da08a3e4608791e +source core/src/main/java/art/arcane/iris/util/project/noise/SierpinskiTriangleNoise.java 7d39b5c23e66f13bfd032abe2492676a1c216ebc80be54fb9f2ddceddb38b9de +source core/src/main/java/art/arcane/iris/util/project/noise/SimplexNoise.java ad148e206fdc560a3511bd2aec3fe88fb44df12673de64da5f7bc7b92bcfa3d0 +source core/src/main/java/art/arcane/iris/util/project/noise/VascularNoise.java 992f445c6143925c39bd3b4e04c7fee99fb4f60482f8aea7a90e09e3774c6b9f +source core/src/main/java/art/arcane/iris/util/project/noise/WhiteNoise.java 7768ed89609a6eca5b1a73a8c4f22d677dda8661d292ae2dac542bc3ad6383a8 +source core/src/main/java/art/arcane/iris/util/project/stream/BasicStream.java 3d248d3800b17cdc53c121aa5fa9df91cc39fbca8ffd3dcf79ae5ebd8e03c8c9 +source core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java 8b6df300b0919c9fe3f38518a2af2bf766e0c815b82efcab55c74ea1793d562a +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/AddingStream.java 77c703257669672f4fea25636e41d408e38f34127c100f6bd377b30b186e1c58 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/ClampedStream.java 6b7f5666323c585776eba0c03b073eff881fbec5a23e180172d020fa9d38a41f +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/CoordinateBitShiftLeftStream.java 2458f0e87a16cdd78997410b28d4f108843ca536001529c7a95ae7151d7b6986 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/CoordinateBitShiftRightStream.java 1ee649bea1b3712f1518b23b8e5eb6a55dbb6cda75303216f86beb80db7c2eab +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/DividingStream.java 166f91f51ad6849c9553492e223219848aa6c4ef65aeb50883f74b3aaf29c019 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/FittedStream.java 9874586f9edcbb5374b37fc72abb88c0aeaadd910e935e9d7178cc8bb9fd9883 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/MaxingStream.java a6ef3010edda88bc354a2e034f49240f8a1887ab8a16c374b32f1fd07ce6ebc1 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/MinningStream.java 5470b60a521b377d0d29bbe07d327c636064dac5574ee5b9681b2ce69fb64368 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/ModuloStream.java b42669c491e6cc41e3aa7a1343d3f9eac8b4237dd76d0b12125ee437eabf4c32 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/MultiplyingStream.java b793f7f827f1c04d418be4afe79378270fe0160c43ad876171a78031ad99bfc0 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/OffsetStream.java 099772511c7bb56af01bb7fa186f6a9760d768df5d4da737d441dab08486bc72 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/RadialStream.java e660fac479b334dd68baa4311460736c5f552a48af9381b1027c4c5a389800c9 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/RoundingDoubleStream.java a9f3603a5beb025bc432273e4d0591ac9add82c7381357e6a5b9722e98c181d0 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/SlopeStream.java fd7db87b17556ee2c06cba7d5a76d0531d534dda14c2c03dca3452a1f7a9ab59 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/SubtractingStream.java e318c3c22348a2b2b7bf2dd2891c69fc3fc87234130cf4eb4ea50d59f1a23aa8 +source core/src/main/java/art/arcane/iris/util/project/stream/arithmetic/ZoomStream.java ae1c378826a92f34741818f723a6956869c40e5e61ae9e75bdb9045e4ace0f77 +source core/src/main/java/art/arcane/iris/util/project/stream/convert/AwareConversionStream2D.java 567e939e218e9e07fb7d015aeaa54c28652ba62da3a0381f1648a983433f22e2 +source core/src/main/java/art/arcane/iris/util/project/stream/convert/AwareConversionStream3D.java 25a9458450d4688a92d492ac20cc12aaee46aa45c1279c0b8a667cc3127e6ecd +source core/src/main/java/art/arcane/iris/util/project/stream/convert/CachedConversionStream.java b2a6ee1d0d30ec2646b9d60bd8dca61340a89fd6869db3c65af6df74021ffe88 +source core/src/main/java/art/arcane/iris/util/project/stream/convert/ConversionStream.java f7a539c7c4225bdf9ed559a5124f0ef8204fcced17fa853224aca7a58c13f7c9 +source core/src/main/java/art/arcane/iris/util/project/stream/convert/ForceDoubleStream.java 469c035c04b7d938a4254e3aa4777a5f0adaee626558fd982eeabb26fadc422e +source core/src/main/java/art/arcane/iris/util/project/stream/convert/RoundingStream.java d599a5cbfbd36f00ca75ad83975b46e0aed73885c548415203afb0e0cc8ac2bd +source core/src/main/java/art/arcane/iris/util/project/stream/convert/SelectionStream.java 9b69c39d182fbd2199370d8ead3b6e1afa00ed9eb6fa18df04377bcd919cde1a +source core/src/main/java/art/arcane/iris/util/project/stream/convert/SignificanceStream.java 8feacf800b54f9adbbdc22f757cb93cbec513bcb895069e58c915ab718314611 +source core/src/main/java/art/arcane/iris/util/project/stream/convert/To3DStream.java cb5d29788fd809c47cbb902e8d6ffca84e5f1f64276570d6623c3c4245a5862d +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/BiHermiteStream.java 6920b2150a31f4c923f109b1d82ffd8fba5df254facb63aa48cbfd3f036078ba +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/BiStarcastStream.java 2b90e49be7a06823ce8962144a2e5e4de1b275aad701019d9277a435ad31e833 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/BicubicStream.java 4709379c135d5b9aaa828aa35674ab282137ea3ad6970ee2299e330abe2d6976 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/BilinearStream.java d51fc68851d7fb7e67102c0c995b8c7eaa21dc2061331c7f1cc9cc6f78236414 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/Interpolated.java aa4e0d928f4908829048103d524c64c51d39967dad785f8e5ad14badec25e5a6 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/InterpolatingStream.java 095892a9c52b1248003e93e7ebb5924930d3ebfd60327851bd3c27c2138c2155 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/Interpolator.java adfc343b5c172d1da145a025c179e3b4406e8f6dc6f992544dc09205ebbadb16 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/InterpolatorFactory.java 0e3424ab3006c2ad65075e1c13785a711f02839cf0843a224fdb22da8cc8b41f +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/TriStarcastStream.java 7b23e16f3dfd9b75a1fd819f3e8df36c7d264c2261af7c27388f0b8f29617fd9 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/TricubicStream.java 01aef030f87acabd621d253c2593d2eb232c70509619d8edd03cb65eb5da7fc5 +source core/src/main/java/art/arcane/iris/util/project/stream/interpolation/TrilinearStream.java 515a3baf44bac1cc067c9d95dc71cba05f21b59972dcf439393809c05b4037c9 +source core/src/main/java/art/arcane/iris/util/project/stream/sources/CNGStream.java e154372335404e7f986eb320ecfa603277a8a4608591dfb4452a58d986459508 +source core/src/main/java/art/arcane/iris/util/project/stream/sources/FunctionStream.java 5833555ca8f0c78c1fe2d4b989a38162dffd096d5f7a2e24d1267a08d8b82bb8 +source core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java e330019adce955e894bc4222c1bfb21b9b059a6017ac1b72e99f88eae066ff8e +source core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java 22291cac0c48f9dbcdea7638c0b1cd98d5b04810a017f69596c544add0666f6d +source core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream3D.java 8c57948b8b336b97917ae9efc8af036a3b0d79bf91fd85ed0f678dbdcf6a3049 +source core/src/main/java/art/arcane/iris/util/project/stream/utility/ChunkFillableDoubleStream2D.java 69ca3858df4418c1e5776d2723bb7e115022c01bbe65b1195355138e04ecf4f0 +source core/src/main/java/art/arcane/iris/util/project/stream/utility/ChunkFillableStream2D.java 5755a03f6baaf5f6062e206c5f566358610411b29cfd059db39fc08634c2876a +source core/src/main/java/art/arcane/iris/util/project/stream/utility/ContextInjectingStream.java 42ff53e2074a1db1924975c6dd2e2ac415d19722b905508c98b3e336ef282aaa +source core/src/main/java/art/arcane/iris/util/project/stream/utility/NullSafeStream.java f1e221b963db32124e996ac28e3fd97945390261084e93ff6cf7bc29f1cf73e6 +source core/src/main/java/art/arcane/iris/util/project/stream/utility/SynchronizedStream.java c3d22d944b6a8ab70f92070998ba3927629948378fdf2317632925afefcdb9c3 +source core/src/main/java/art/arcane/iris/util/simd/NoiseKernels2D.java 40a9fed6a1fedbecb3452c8877274b6ab9d1923ae47f2d38c55c164d244e6f07 +source core/src/main/java/art/arcane/iris/util/simd/ScalarNoiseKernels2D.java f6e4ac5809b5e0a80a1db9c244bc083d29f80fb04e511bb9351efa4deae9f3a5 +source core/src/main/java/art/arcane/iris/util/simd/ScalarSimdKernels.java 594239aa4ab1dc4bae6dcbfd581dea78ea546d879261ffe0a288a10e2298fb04 +source core/src/main/java/art/arcane/iris/util/simd/SimdKernels.java a02e2256e762e1a1ef4318cca3f5fb241bdda76ebf3f561ae9d961b66708c3bf +source core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java 8a1af96feee77b39b38ad050b6bdf5ef86e390095113bec445027af6d32c44b8 +source core/src/main/java/art/arcane/iris/util/simd/VectorNoiseKernels2D.java 00e57c742f31e934dbd6327f97c6892320f192af515e230911010ec4e0887754 +source core/src/main/java/art/arcane/iris/util/simd/VectorSimdKernels.java 7425b052c9f72cc0eae343fd799836d75791cb9efeb908cb8905a3069d44ea4f +source lombok.config 0742cd68410f4004b443c5f07c2962aa803a6fcd5dbb8d43833a72155a07accf +source spi/src/main/java/art/arcane/iris/spi/IrisLogging.java a94ad539296aac3a194391f8062ab004bf0b5d70361d8dc3c4f282ad29cbe663 +source spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java 5177479ec58e2258269db196c4273d06b46d960136d3fba6a491cc3b418312c4 +source spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java 30ae684541a4a5fa1d95e6c497aac9e8e41ae4a9adeedb735b90b72d5fa71285 +source spi/src/main/java/art/arcane/iris/spi/IrisServices.java ac57dcd5f056123c24b46b4f37b990a39e153d188b84639b598407a3490567f1 +source spi/src/main/java/art/arcane/iris/spi/LogLevel.java eaf5dcee19619518911a0c42a14943522bd3db69ccba9d247e0163dd593be523 +source spi/src/main/java/art/arcane/iris/spi/PlatformBiome.java 8b2005ee6c9121d33241733438c6f0a9ceba4c80d5b0bb199974a506407ba2c8 +source spi/src/main/java/art/arcane/iris/spi/PlatformBiomeWriter.java 172bb739a9bcb0966c8ebc355fc63fd190da7220a40cf38dad23b487777ea509 +source spi/src/main/java/art/arcane/iris/spi/PlatformBlockProperty.java 62f214b18f9ea8530b00b66226044b51a66220d3dbbf2fb2d1b898f165ebf113 +source spi/src/main/java/art/arcane/iris/spi/PlatformBlockState.java 3d5c5f030bc597645c9cbce89d931ac39ac3c40f9cba42390284eb90ea5771b9 +source spi/src/main/java/art/arcane/iris/spi/PlatformEntityType.java 12dce3025e3434a28ab58836afdc3c602cd620a923410ba70e465f791227e0bd +source spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java 340a1f75a21feb5e62fe7f9f5263d8cbec3441b79db9f2146e319892909102d5 +source spi/src/main/java/art/arcane/iris/spi/PlatformItem.java 237da8d4c02bac3fc9d4de166d1b2d58df41d220a9eb4f29d550c0fad562ffac +source spi/src/main/java/art/arcane/iris/spi/PlatformNumericRange.java bab334a3652499c6fbce52fde4c268d1852a6c1379100da2f72ae712b4560cc3 +source spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java 9f18016d144ab0582b3a89ce2f40de03971f9d6e91fbdd8652686bcf110f709e +source spi/src/main/java/art/arcane/iris/spi/PlatformScheduler.java ea0e59ba7d4088d74ba12eb6fa497b0cf61be345a4d21327a7ecd3cd7d3b0e0b +source spi/src/main/java/art/arcane/iris/spi/PlatformStructureHooks.java f4a5d70f1ff8dceb0bd425cfe2ab356597c9e19bc0e1a5e418a9b777039cf371 +source spi/src/main/java/art/arcane/iris/spi/PlatformWorld.java b388f9a364dff30a100c01ddab2ea7dfc3ada1b9dfd0f1e6351c6fc3a7d040c5 +source spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessage.java 40e71fa6c7aa7e3cf6157480cc6da3d26b7f0841634f35d68b8f727b4c090280 +source spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessageCodec.java ba0b520028a8e5a2c4712733afccc5f64b48991b41b060afc393ef94c5f91f54 +source spi/src/main/java/art/arcane/iris/spi/protocol/IrisProtocol.java 553e468417c93426d5b5029109604ec365a1883d986a4f7927e72780108182c3 +source spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireReader.java 73ac10e79ece110d81cfb90c3affd95826fbb1a6fd361e82689798af24c4ca45 +source spi/src/main/java/art/arcane/iris/spi/protocol/IrisWireWriter.java 159b4b9b7fcfe31ff8bed288bda439afe963aab26813d62c074757705c75e4f8 +source spi/src/main/java/art/arcane/iris/spi/protocol/ProtocolException.java 8e71f2ee052c35caa923df55e00b3e92ff00f34703a736e4a48d118f8017df2d +dependency at.yawk.lz4:lz4-java c67308251e7b3743bc2a8bd351165ed1e3faa4b7957084f79b2be1ebbd476e60 +dependency com.dfsek:paralithic adaac82e69de20b99e7871436c5792b5b76eec4043008567ff0a95b2e94db872 +dependency com.github.ben-manes.caffeine:caffeine 913092bf9a378180d823f2677c6bb2d585f0968a2d83e090b787a9f051ad91b5 +dependency com.github.oshi:oshi-core ecb17b22ee14bc8726bf3ed90223e5e54031c5126d024c71ca1f93f40cd2703b +dependency com.google.code.gson:gson 43ef9252659feb811b42fa2bba20c70c53883f0917c4d5cc59879721f0cbf79f +dependency com.google.errorprone:error_prone_annotations 329255aeae8684ecc89f2941c32643a0d9e6cdf3e28862c8c99b055649ab2de5 +dependency com.google.guava:failureaccess 07feb13fb715e571deb2c3e951f10ff4eb57b87c2d556f1c34eb88e800a4a24c +dependency com.google.guava:guava 9e6e694038a4e28fb89ef58304f831ea49df920199a5ef2ca61958ca4821b343 +dependency com.google.guava:listenablefuture e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +dependency com.google.j2objc:j2objc-annotations d11db6735c998c26d64d62283b371192e223f4f93168bfaa599ed42ee1dbfcfb +dependency com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru 696dab17679d566ac8f780e4f880a0ebecbc335d746008432e18df05e93b3f96 +dependency commons-io:commons-io 540193a4d84d8b7356cda1819db4e52d7edeef353fe9bd6a594b99f9c70380cb +dependency de.crazydev22.slimjar.helper:spigot 5223dd60dc00e5a4258edb381352a3e9d76289d969f2f52d852809aabf52adff +dependency de.crazydev22.slimjar:runtime 84b58dea2d5054fc4940a5d28a1ad2039e06ac332f8fb33dabf1b1d41755e9c6 +dependency io.papermc:paperlib 1dbb89986f419aaa7ce45dab86846ebb575dd17da0c87298a339c65282a0ac64 +dependency iris-agent 60f191ebe8512f7d9e68e417c66b0148f06cf7d7f5decd072bde5f1a304bdfb6 +dependency it.unimi.dsi:fastutil 0c8bca8fad13026a6c6ebfe0d317e413f3a291dca8e6898cfb8f15fe17db38b7 +dependency jaxen:jaxen 925cd684a108929ae4ae3b6022bde74a9a50c50e71afbe2b8c0165112b9b88e5 +dependency net.bytebuddy:byte-buddy 213e0ff38c1cd2c03824ad8a9b33b20bb3faa9d5912a2629b19ff7cf4bb98da7 +dependency net.bytebuddy:byte-buddy-agent c51f4e08d8f1a89bdb275acf473e8cf43a6f85d7f3c28e25dc4aac64e2bc6904 +dependency net.java.dev.jna:jna 9dff785a156994c2b7fcc986fd8d93f8a602619043e6af4c390418359ab07f75 +dependency net.java.dev.jna:jna-platform 53258fb4209f4e320e84e0ad182d4d27117b80b7b4c1c298ef27358ee70806ae +dependency net.kyori:adventure-api b714cbc92d705ea6d236c734c88aa453bf9d9b72014a6633088f493ae22ed51f +dependency net.kyori:adventure-key b02f15bd36239cfded8a04091f5c576ba468e2b8820984c6869366bbbf4414f5 +dependency net.kyori:adventure-text-minimessage 4aaec48729d5eb33e8b13c65f33ba9507315fd9c176cae015c6aceff5a00c633 +dependency net.kyori:adventure-text-serializer-legacy a2a3a6c4c0655c8a4ff648c46b1dc9714991ed84fd1b69c2e4383a2605a86cf3 +dependency net.kyori:adventure-text-serializer-plain 8771f9bbf6d17af7fb3bcf70660bdc0544d66f46d5b4aa4b96c733c622a02a5f +dependency net.kyori:examination-api ec189e7cf15d05c74967946ac18244518c899505211673ac85bae7e25ed7e29b +dependency net.kyori:examination-string ecdf4c03d397b8e8f7c8169ecfad7c85eb5ca79d8b205f7cc4212bd7d0d552f3 +dependency org.apache.commons:commons-lang3 19f7ddf766a74256fbf93a662001924f931d0f36dd5e507398317e5ff8544b5f +dependency org.bstats:bstats-base 7ba522921bebd6fa0ccb76f8d7173c7616fd0f2d3ec1fa519b4cfa4e41ed9093 +dependency org.bstats:bstats-bukkit 2ddcd4312dbb28185da284420c7251dd7af3c9917ff4e40b74ca083051ac019e +dependency org.dom4j:dom4j 0fd042dea173096d8d3f4142d63b6e2e5c7c7f168ba54e87053fd03b91023937 +dependency org.jetbrains:annotations 3957c697cd9e6ffb3706d1d636191752167822a16ec116728ea727457d82336b +dependency org.jspecify:jspecify 94530fa28be17f84b2cdedf409975168329537c377fc72ee6746ec3d482d6b2b +dependency org.ow2.asm:asm 2598039f7bbf6acf6aa5072984f797f001b1c4a9fc5e12f528f9b405211399a9 +dependency org.projectlombok:lombok a85edb86453d6e0d4cc3f598e227fb2617a7bb38a884ef1f23a6a153e9378da5 +dependency org.slf4j:slf4j-api 5f56cfefb5a20874110d80289351e3382f45f91e988fe6068505f8e3ee684a05 +dependency org.zeroturnaround:zt-zip 3332982ad8688f417d8e7aebd50f33eaa5ba7c29f32fa974eef8c9668e36006a +dependency volmlib 5d80d75be1249e2109b9be5fc5ea06517c1a6cb2d8ad9d0131bfdb26e321cada diff --git a/core/generation-kernels/catalog.tsv b/core/generation-kernels/catalog.tsv new file mode 100644 index 000000000..676a43b9a --- /dev/null +++ b/core/generation-kernels/catalog.tsv @@ -0,0 +1,3 @@ +iris-generation-kernel-catalog-v1 +current 1 1 1 +kernel 1 diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index 3f52fa5d5..e0f10990e 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -9,8 +9,14 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.PackDirectoryResolver; +import art.arcane.iris.engine.history.GenerationEpoch; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContract.PhysicalResourceKey; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisCustomBiomeAliasResolver; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; @@ -40,13 +46,14 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.regex.Pattern; import java.util.stream.Stream; public final class IrisDatapackCompiler { - private static final int INPUT_FINGERPRINT_SCHEMA = 2; + private static final int INPUT_FINGERPRINT_SCHEMA = 3; private static final int INPUT_BUFFER_BYTES = 64 * 1024; private static final int WORLD_PACK_SCAN_DEPTH = 8; private static final Pattern REGISTRY_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); @@ -90,6 +97,7 @@ public final class IrisDatapackCompiler { ) throws IOException { LinkedHashMap roots = new LinkedHashMap<>(); collectInstalledPackRoots(dataDirectory.resolve("packs"), roots, validateWholePack); + collectCanonicalWorldPackRoot(serverRoot, roots, validateWholePack); collectWorldPackRoots(serverRoot.resolve("dimensions"), roots, validateWholePack); return new ArrayList<>(roots.values()); } @@ -179,14 +187,18 @@ public final class IrisDatapackCompiler { Objects.requireNonNull(packRoots, "packRoots"); Objects.requireNonNull(fixer, "fixer"); LinkedHashMap requirements = new LinkedHashMap<>(); - Set installedBiomeKeys = new LinkedHashSet<>(); + Map> biomeTags = new TreeMap<>(); for (File packRoot : packRoots) { PackDirectoryResolver.requireSafePackTree(packRoot); if (!hasDimensions(packRoot.toPath())) { continue; } + BiomeAliasContext aliasContext = biomeAliasContext(packRoot.toPath()); IrisData data = IrisData.openDatapackCompiler(packRoot); try { + if (aliasContext.retainedContract() != null) { + data.bindGenerationRegistryContract(aliasContext.retainedContract()); + } ResourceLoader loader = data.getDimensionLoader(); String[] possibleKeys = loader.getPossibleKeys(); if (possibleKeys == null || possibleKeys.length == 0) { @@ -199,14 +211,16 @@ public final class IrisDatapackCompiler { } collectRegistryRequirements( requirements, - installedBiomeKeys, + biomeTags, dimension, - fixer); + fixer, + aliasContext); } } finally { data.close(); } } + addBiomeTagRequirements(requirements, biomeTags); return Map.copyOf(requirements); } @@ -216,26 +230,41 @@ public final class IrisDatapackCompiler { ) throws IOException { IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + IrisData data = Objects.requireNonNull( + requiredDimension.getLoader(), + "Iris dimension loader" + ); LinkedHashMap requirements = new LinkedHashMap<>(); + Map> biomeTags = new TreeMap<>(); collectRegistryRequirements( requirements, - new LinkedHashSet<>(), + biomeTags, requiredDimension, - requiredFixer); + requiredFixer, + BiomeAliasContext.contentAddressedOnly()); + addBiomeTagRequirements(requirements, biomeTags); return Map.copyOf(requirements); } private static void collectRegistryRequirements( Map requirements, - Set installedBiomeKeys, + Map> biomeTags, IrisDimension dimension, - IDataFixer fixer - ) { - requirements.put( - "dimension_type/iris:" + dimension.getDimensionTypeKey(), - fingerprintContent(dimension.getDimensionType().toJson(fixer))); + IDataFixer fixer, + BiomeAliasContext aliasContext + ) throws IOException { + String dimensionTypeResourceKey = "iris:" + dimension.getDimensionTypeKey(); + String dimensionTypeSource = aliasContext.generatedSource( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + dimensionTypeResourceKey, + dimension.getDimensionType().toJson(fixer), + fixer + ); + putRegistryRequirement( + requirements, + "dimension_type/" + dimensionTypeResourceKey, + fingerprintContent(dimensionTypeSource)); - String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT); ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate(); for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) { if (biome == null || !biome.isCustom()) { @@ -246,13 +275,25 @@ public final class IrisDatapackCompiler { if (customBiome == null) { continue; } - String biomeKey = namespace + ":" + customBiome.getId(); - if (!installedBiomeKeys.add(biomeKey)) { - continue; + String json = customBiome.generateJson(fixer, contentGate); + String currentPhysicalKey = GenerationRegistryContractFactory.customBiomeResourceKey( + dimension.getLoadKey(), + customBiome, + contentGate + ); + String physicalKey = aliasContext.physicalResourceKey( + dimension, + biome, + customBiome, + currentPhysicalKey + ); + LinkedHashSet resourceKeys = new LinkedHashSet<>(); + resourceKeys.add(physicalKey); + Collection aliases = aliasContext.aliases(dimension, biome, customBiome, physicalKey); + if (!aliases.isEmpty() && physicalKey.equals(dimension.getLoader() + .customBiomeResourceKey(dimension, customBiome.getId()))) { + resourceKeys.addAll(aliases); } - requirements.put( - "worldgen/biome/" + biomeKey, - fingerprintContent(customBiome.generateJson(fixer, contentGate))); TreeSet tags = new TreeSet<>(); for (String tag : customBiome.getEffectiveTags(derivativeKey)) { String normalizedTag = normalizeRegistryKey(tag); @@ -260,13 +301,48 @@ public final class IrisDatapackCompiler { tags.add(normalizedTag); } } - requirements.put( - "worldgen/biome_tags/" + biomeKey, - fingerprintContent(String.join("\n", tags))); + for (String resourceKey : resourceKeys) { + String emittedJson = aliasContext.generatedSource( + GenerationRegistryContractFactory.BIOME_REGISTRY, + resourceKey, + json, + fixer + ); + putRegistryRequirement( + requirements, + "worldgen/biome/" + resourceKey, + fingerprintContent(emittedJson) + ); + biomeTags.computeIfAbsent(resourceKey, ignored -> new TreeSet<>()).addAll(tags); + } } } } + private static void addBiomeTagRequirements( + Map requirements, + Map> biomeTags + ) throws IOException { + for (Map.Entry> entry : biomeTags.entrySet()) { + putRegistryRequirement( + requirements, + "worldgen/biome_tags/" + entry.getKey(), + fingerprintContent(String.join("\n", entry.getValue())) + ); + } + } + + private static void putRegistryRequirement( + Map requirements, + String resourceKey, + String contentFingerprint + ) throws IOException { + String previous = requirements.putIfAbsent(resourceKey, contentFingerprint); + if (previous != null && !previous.equals(contentFingerprint)) { + throw new IOException("Conflicting Iris datapack registry resource '" + resourceKey + "'."); + } + } + private static String normalizeRegistryKey(String key) { if (key == null || key.isBlank()) { return null; @@ -324,8 +400,12 @@ public final class IrisDatapackCompiler { if (!hasDimensions(packRoot.toPath())) { continue; } + BiomeAliasContext aliasContext = biomeAliasContext(packRoot.toPath()); IrisData data = IrisData.openDatapackCompiler(packRoot); try { + if (aliasContext.retainedContract() != null) { + data.bindGenerationRegistryContract(aliasContext.retainedContract()); + } ResourceLoader loader = data.getDimensionLoader(); String[] possibleKeys = loader.getPossibleKeys(); if (possibleKeys == null || possibleKeys.length == 0) { @@ -344,11 +424,22 @@ public final class IrisDatapackCompiler { .add(new DimensionCandidate( dimension, packRoot.toPath().toAbsolutePath().normalize(), - dimension.getDimensionType().toJson(fixer) + aliasContext.generatedSource( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + "iris:" + dimension.getDimensionTypeKey(), + dimension.getDimensionType().toJson(fixer), + fixer + ) )); KSet seenBiomes = biomes.computeIfAbsent(dimension.getLoadKey(), ignored -> new KSet<>()); - dimension.installBiomes(fixer, dimension::getLoader, datapackRoots, seenBiomes); - dimension.installDimensionType(fixer, datapackRoots); + dimension.installBiomes( + fixer, + dimension::getLoader, + datapackRoots, + seenBiomes, + aliasContext + ); + dimension.installDimensionType(fixer, datapackRoots, aliasContext); installedDimensions++; dimensionCount++; } @@ -466,6 +557,23 @@ public final class IrisDatapackCompiler { } } + private static void collectCanonicalWorldPackRoot( + Path worldRoot, + Map roots, + boolean validateWholePack + ) throws IOException { + if (Files.isSymbolicLink(worldRoot) + || !Files.isDirectory(worldRoot, LinkOption.NOFOLLOW_LINKS)) { + return; + } + List candidates = new ArrayList<>(); + collectWorldRootPackRoots(worldRoot, candidates); + candidates.sort(Comparator.comparing(Path::toString)); + for (Path candidate : candidates) { + addPackRoot(candidate, roots, validateWholePack); + } + } + private static void collectNamespaceWorldPackRoots( Path namespace, List candidates @@ -475,18 +583,12 @@ public final class IrisDatapackCompiler { public FileVisitResult preVisitDirectory( Path directory, BasicFileAttributes attributes - ) { + ) throws IOException { if (!directory.equals(namespace) && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { return FileVisitResult.SKIP_SUBTREE; } - Path irisRoot = directory.resolve("iris"); - Path candidate = irisRoot.resolve("pack"); - if (!Files.isSymbolicLink(irisRoot) - && !Files.isSymbolicLink(candidate) - && Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS) - && hasDimensions(candidate)) { - candidates.add(candidate); + if (collectWorldRootPackRoots(directory, candidates)) { return FileVisitResult.SKIP_SUBTREE; } return FileVisitResult.CONTINUE; @@ -494,6 +596,35 @@ public final class IrisDatapackCompiler { }); } + private static boolean collectWorldRootPackRoots( + Path worldRoot, + List candidates + ) throws IOException { + Path irisRoot = worldRoot.resolve("iris"); + Path generationRoot = irisRoot.resolve("generation"); + if (!Files.isSymbolicLink(irisRoot) + && (Files.exists(generationRoot, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(generationRoot))) { + GenerationHistory history = GenerationHistory.open(worldRoot); + for (GenerationEpoch epoch : history.manifest().epochs()) { + Path retainedPack = history.paths().packRoot(epoch.epochId()); + if (hasDimensions(retainedPack)) { + candidates.add(retainedPack); + } + } + return true; + } + Path legacyPack = irisRoot.resolve("pack"); + if (!Files.isSymbolicLink(irisRoot) + && !Files.isSymbolicLink(legacyPack) + && Files.isDirectory(legacyPack, LinkOption.NOFOLLOW_LINKS) + && hasDimensions(legacyPack)) { + candidates.add(legacyPack); + return true; + } + return false; + } + private static List visibleDirectories(Path root) throws IOException { try (Stream entries = Files.list(root)) { return entries @@ -527,6 +658,43 @@ public final class IrisDatapackCompiler { roots.putIfAbsent(identity, normalized.toFile()); } + private static BiomeAliasContext biomeAliasContext(Path packRoot) throws IOException { + Path normalizedRoot = packRoot.toAbsolutePath().normalize(); + Path rootParent = normalizedRoot.getParent(); + if (rootParent == null || !"pack".equals(normalizedRoot.getFileName().toString())) { + return BiomeAliasContext.contentAddressedOnly(); + } + if ("iris".equals(rootParent.getFileName().toString())) { + return BiomeAliasContext.legacyPack(); + } + + Path epochsRoot = rootParent.getParent(); + Path generationRoot = epochsRoot == null ? null : epochsRoot.getParent(); + Path irisRoot = generationRoot == null ? null : generationRoot.getParent(); + Path dimensionRoot = irisRoot == null ? null : irisRoot.getParent(); + if (epochsRoot == null + || generationRoot == null + || irisRoot == null + || dimensionRoot == null + || !"epochs".equals(epochsRoot.getFileName().toString()) + || !"generation".equals(generationRoot.getFileName().toString()) + || !"iris".equals(irisRoot.getFileName().toString())) { + return BiomeAliasContext.contentAddressedOnly(); + } + + String epochId = rootParent.getFileName().toString(); + GenerationHistory history = GenerationHistory.open(dimensionRoot); + GenerationEpoch epoch = history.manifest().epoch(epochId).orElseThrow( + () -> new IOException("Retained Iris pack is not referenced by its generation history: " + + normalizedRoot) + ); + Path expectedRoot = history.paths().packRoot(epochId).toAbsolutePath().normalize(); + if (!expectedRoot.equals(normalizedRoot)) { + throw new IOException("Retained Iris pack does not match its generation history: " + normalizedRoot); + } + return BiomeAliasContext.retained(epoch.registryContract()); + } + private static boolean hasDimensions(Path root) { Path dimensions = root.resolve("dimensions"); if (Files.isSymbolicLink(dimensions) @@ -669,6 +837,75 @@ public final class IrisDatapackCompiler { ) { } + private record BiomeAliasContext( + boolean retainAllLegacyAliases, + GenerationRegistryContract retainedContract + ) implements IrisCustomBiomeAliasResolver { + private static BiomeAliasContext contentAddressedOnly() { + return new BiomeAliasContext(false, null); + } + + private static BiomeAliasContext legacyPack() { + return new BiomeAliasContext(true, null); + } + + private static BiomeAliasContext retained(GenerationRegistryContract registryContract) { + return new BiomeAliasContext( + false, + Objects.requireNonNull(registryContract, "registryContract") + ); + } + + @Override + public String physicalResourceKey( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String currentPhysicalResourceKey + ) throws IOException { + if (retainedContract == null) { + return currentPhysicalResourceKey; + } + return dimension.getLoader().customBiomeResourceKey(dimension, customBiome); + } + + @Override + public Collection aliases( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String physicalResourceKey + ) { + String alias = dimension.getLoadKey().toLowerCase(Locale.ROOT) + + ":" + customBiome.getId().toLowerCase(Locale.ROOT); + if (retainAllLegacyAliases) { + return List.of(alias); + } + if (retainedContract == null) { + return List.of(); + } + PhysicalResourceKey key = new PhysicalResourceKey( + GenerationRegistryContractFactory.BIOME_REGISTRY, + alias + ); + return retainedContract.definitions().containsKey(key) ? List.of(alias) : List.of(); + } + + @Override + public String generatedSource( + String registryKey, + String resourceKey, + String currentSource, + IDataFixer fixer + ) throws IOException { + if (retainedContract == null) { + return currentSource; + } + PhysicalResourceKey key = new PhysicalResourceKey(registryKey, resourceKey); + return GenerationRegistryContractFactory.renderGeneratedSource(retainedContract, key, fixer); + } + } + public static final class DimensionHeight { private final IDataFixer fixer; private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3]; diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index da75c4be5..395d2b666 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -349,6 +349,11 @@ public class IrisSettings { public static class IrisSettingsGenerator { public String defaultWorldType = "overworld"; public boolean preventLeafDecay = true; + public int generationTransitionWidthBlocks = 256; + + public int getGenerationTransitionWidthBlocks() { + return Math.max(16, Math.min(generationTransitionWidthBlocks, 8_192)); + } } @Data diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java index b6afd8c50..05c383318 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java @@ -1,5 +1,6 @@ package art.arcane.iris.core; +import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; @@ -404,10 +405,6 @@ public final class IrisWorldStorage { return Optional.ofNullable(NamespacedKey.fromString(namespace + ":" + key)); } - public static File packRoot(NamespacedKey key) { - return new File(dimensionRoot(key), "iris/pack"); - } - public static File requireFrozenDimensionRoot( File worldContainer, File levelRoot, @@ -485,26 +482,28 @@ public final class IrisWorldStorage { return configuredRoot; } - public static File requireFrozenPackRoot(File dimensionRoot) { + public static File requireActiveGenerationPackRoot(File dimensionRoot) { Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") .toPath() .toAbsolutePath() .normalize(); - Path irisRoot = root.resolve("iris"); - Path packRoot = irisRoot.resolve("pack"); - for (Path path : new Path[]{root, irisRoot, packRoot}) { - if (Files.isSymbolicLink(path)) { - throw new IllegalStateException("Frozen Iris pack path contains a symbolic link: " + path); - } - if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) - && !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { - throw new IllegalStateException("Frozen Iris pack path is not a directory: " + path); - } + try { + return GenerationHistory.open(root).activePackRoot().toFile(); + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure); } - if (!Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) { - throw new IllegalStateException("Frozen Iris pack snapshot is missing: " + packRoot); + } + + public static File requireActiveGenerationPackRoot(File dimensionRoot, long expectedWorldSeed) { + Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toPath() + .toAbsolutePath() + .normalize(); + try { + return GenerationHistory.open(root, expectedWorldSeed).activePackRoot().toFile(); + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure); } - return packRoot.toFile(); } private static boolean isExistingSafeDimensionRoot(Path storageRoot, Path dimensionRoot) { diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index 35815be4d..268dd6bf9 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -367,7 +367,7 @@ public class IrisWorlds { configuredWorldName, worldKey ); - return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot); + return dimensionRoot.map(IrisWorldStorage::requireActiveGenerationPackRoot); } private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) { @@ -376,7 +376,7 @@ public class IrisWorlds { } IrisLogging.error("Iris world %s has unusable world storage and is excluded: %s", worldIdentity, failure.getMessage()); - IrisLogging.error("Restore its iris/pack snapshot, or drop the world with /iris remove world=%s delete=true.", + IrisLogging.error("Restore its iris/generation history, or drop the world with /iris remove world=%s delete=true.", worldIdentity); } diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 0444acf8c..ed709bf7d 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -1121,7 +1121,7 @@ public class ServerConfigurator { for (IrisBiome i : dimension.getAllBiomes(dimension::getLoader)) { if (i.isCustom()) { for (IrisBiomeCustom j : i.getCustomDerivitives()) { - keys.add(dimension.getLoadKey() + ":" + j.getId()); + keys.add(dimension.getLoader().customBiomeResourceKey(dimension, j)); } } } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index 4bcacfbfe..0a90e445a 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -3,6 +3,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.engine.IrisEngineMantle; +import art.arcane.iris.engine.history.GenerationHistoryPaths; import org.bukkit.NamespacedKey; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -229,8 +230,20 @@ public final class BukkitWorldConfiguration { } Path resolvedRoot = dimensionRoot.toPath().toAbsolutePath().normalize(); try { - IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); + IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot); } catch (IllegalStateException unusablePack) { + GenerationHistoryPaths historyPaths = GenerationHistoryPaths.forDimension(resolvedRoot); + boolean generationHistoryExists = Files.exists( + historyPaths.generationRoot(), + LinkOption.NOFOLLOW_LINKS + ) || Files.isSymbolicLink(historyPaths.generationRoot()); + boolean adoptableLegacyPack = !generationHistoryExists + && Files.isDirectory(historyPaths.legacyPackRoot(), LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(historyPaths.legacyPackRoot()); + if (adoptableLegacyPack) { + return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot, + IrisWorldStorageState.PRESENT, null); + } // Only a folder that owns something can be corrupted by a vanilla generator writing into it. return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot, hasWorldData(resolvedRoot) diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index c0711aa6c..ec5d028a9 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ExactWorldSlotPathPolicy; import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; +import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.core.pack.PackDirectoryResolver; import java.io.IOException; @@ -94,7 +95,7 @@ public final class WorldReplacementFilesystem { State state = inspect(requiredPaths); if (state.stagePresent()) { requireSafeTree(requiredPaths.stage(), "replacement stage"); - requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.stage()), expectedFingerprint); if (originalTargetPresent) { Path retainedWorld = state.targetPresent() ? requiredPaths.target() @@ -118,7 +119,7 @@ public final class WorldReplacementFilesystem { throw new IOException("Replacement backup state does not match the original target state."); } requireSafeTree(requiredPaths.stage(), "replacement stage"); - requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.stage()), expectedFingerprint); move(requiredPaths.stage(), requiredPaths.target()); state = inspect(requiredPaths); } @@ -130,11 +131,11 @@ public final class WorldReplacementFilesystem { throw new IOException("Replacement publication lost or unexpectedly created its backup."); } requireSafeTree(requiredPaths.target(), "replacement target"); - requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint); if (originalTargetPresent) { preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target()); requireSafeTree(requiredPaths.target(), "replacement target"); - requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint); } } @@ -224,7 +225,7 @@ public final class WorldReplacementFilesystem { throw new IOException("Committed replacement storage does not contain one exact target directory."); } requireSafeTree(requiredPaths.target(), "replacement target"); - requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint); } public static void validatePublishedTarget( @@ -240,7 +241,7 @@ public final class WorldReplacementFilesystem { throw new IOException("Published replacement storage does not match its retained-world contract."); } requireSafeTree(requiredPaths.target(), "replacement target"); - requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint); } public static String fingerprintPack(Path packRoot) throws IOException { @@ -279,6 +280,10 @@ public final class WorldReplacementFilesystem { return HexFormat.of().formatHex(digest.digest()); } + private static Path activePackRoot(Path worldRoot) throws IOException { + return GenerationHistory.open(worldRoot).activePackRoot(); + } + private static FingerprintEntry fingerprintEntry(Path root, Path path) { Path relative = root.relativize(path); return new FingerprintEntry(path, relative, relative.toString()); diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 5ed2351bd..7932c5654 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -39,9 +39,14 @@ import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.core.structure.authoring.StructureRecoveryResult; import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntity; @@ -74,6 +79,7 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.project.context.IrisContext; import lombok.AccessLevel; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import org.jetbrains.annotations.Nullable; @@ -87,7 +93,9 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -137,6 +145,17 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private final transient List engines = new ArrayList<>(); + private final transient Map> dimensionCustomBiomeResourceKeys = + Collections.synchronizedMap(new IdentityHashMap<>()); + private transient volatile String generationPackFingerprint; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + @EqualsAndHashCode.Exclude + private transient volatile GenerationRegistryContract generationRegistryContract; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + @EqualsAndHashCode.Exclude + private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver; private transient volatile Engine soleEngine; private IrisData(File dataFolder) { @@ -170,6 +189,90 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return Optional.ofNullable(dataLoaders.get(dataFolder)); } + public String customBiomeResourceKey(IrisDimension dimension, IrisBiomeCustom customBiome) { + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver = customBiomeResourceResolver; + if (resolver != null) { + try { + return resolver.resolve( + requiredDimension.getLoadKey(), + requiredBiome + ); + } catch (IOException failure) { + throw new IllegalStateException("Immutable Iris epoch has no registry mapping for custom biome '" + + requiredDimension.getLoadKey() + ":" + requiredBiome.getId() + "'.", failure); + } + } + return GenerationRegistryContractFactory.customBiomeResourceKey( + requiredDimension.getLoadKey(), + requiredBiome, + getContentGate() + ); + } + + public String customBiomeResourceKey(IrisDimension dimension, String customBiomeId) { + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + String logicalKey = requiredDimension.getLoadKey().toLowerCase(Locale.ROOT) + + ":" + Objects.requireNonNull(customBiomeId, "customBiomeId").toLowerCase(Locale.ROOT); + String physicalKey = customBiomeResourceKeys(requiredDimension).get(logicalKey); + if (physicalKey == null) { + throw new IllegalArgumentException("Unknown custom biome '" + logicalKey + "'."); + } + return physicalKey; + } + + public String physicalBiomeResourceKey(IrisDimension dimension, String resourceKey) { + if (resourceKey == null || resourceKey.isBlank()) { + return resourceKey; + } + String normalizedKey = resourceKey.trim().toLowerCase(Locale.ROOT); + if (!normalizedKey.contains(":")) { + normalizedKey = "minecraft:" + normalizedKey; + } + return customBiomeResourceKeys(Objects.requireNonNull(dimension, "dimension")) + .getOrDefault(normalizedKey, normalizedKey); + } + + public String generationPackFingerprint() { + String fingerprint = generationPackFingerprint; + if (fingerprint != null) { + return fingerprint; + } + synchronized (this) { + fingerprint = generationPackFingerprint; + if (fingerprint != null) { + return fingerprint; + } + try { + fingerprint = GenerationPackFingerprint.compute( + dataFolder.toPath(), + GenerationPackFingerprint.CURRENT_VERSION + ); + } catch (IOException failure) { + throw new IllegalStateException("Unable to fingerprint Iris generation pack at " + + dataFolder + ".", failure); + } + generationPackFingerprint = fingerprint; + return fingerprint; + } + } + + public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) { + GenerationRegistryContract required = Objects.requireNonNull(contract, "contract"); + if (generationRegistryContract != null && !generationRegistryContract.equals(required)) { + throw new IllegalStateException("Iris data is already bound to another generation registry contract."); + } + generationRegistryContract = required; + customBiomeResourceResolver = new GenerationRegistryContractFactory.CustomBiomeResourceResolver( + required, + PlatformGenerationRegistry::contentAddressedCustomBiomeResourceKey + ); + synchronized (dimensionCustomBiomeResourceKeys) { + dimensionCustomBiomeResourceKeys.clear(); + } + } + public static boolean invalidateLoadedStructureResources(File dataFolder) { Path requested = dataFolderIdentity(Objects.requireNonNull( dataFolder, @@ -273,7 +376,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { try { if (nearest != null) { T t = nearest.load(type, key, false); - if (t != null) { + if (t != null || nearest.generationRegistryContract != null) { return t; } } @@ -516,6 +619,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { StructureGraphCatalog.invalidate(this); IrisObjectScale.invalidate(this); contentGate = null; + generationPackFingerprint = null; + synchronized (dimensionCustomBiomeResourceKeys) { + dimensionCustomBiomeResourceKeys.clear(); + } compatReport.clear(); closed = false; possibleSnippets = new KMap<>(); @@ -831,4 +938,34 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } IrisLogging.debug("Loaded Prefetch Cache to reduce generation disk use."); } + + private Map customBiomeResourceKeys(IrisDimension dimension) { + synchronized (dimensionCustomBiomeResourceKeys) { + Map cached = dimensionCustomBiomeResourceKeys.get(dimension); + if (cached != null) { + return cached; + } + String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT); + Map resolved = new LinkedHashMap<>(); + List registryBiomes = new ArrayList<>(dimension.getAllBiomes(() -> this)); + registryBiomes.addAll(dimension.getReachableBiomes(() -> this)); + for (IrisBiome biome : registryBiomes) { + if (biome == null || !biome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : biome.getCustomDerivitives()) { + if (customBiome == null || customBiome.getId() == null || customBiome.getId().isBlank()) { + continue; + } + String logicalKey = namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT); + String physicalKey = customBiomeResourceKey(dimension, customBiome); + resolved.merge(logicalKey, physicalKey, + (first, second) -> first.compareTo(second) <= 0 ? first : second); + } + } + Map immutable = Map.copyOf(resolved); + dimensionCustomBiomeResourceKeys.put(dimension, immutable); + return immutable; + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java index 3e2d17ff9..1bec08def 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java @@ -172,15 +172,13 @@ public final class BukkitRuntimeMessages { + " (dimension blockFallbacks, block backup)." ); public static final LinesKey COMMAND_DEVELOPER_UPDATE_WORLD_WARNING = LinesKey.of( - "iris.bukkit.runtime.commanddeveloper.update_world_warning", - C.RED + "You should always make a backup before using this", - C.YELLOW + "Issues caused by this can be, but are not limited to:", - C.YELLOW + " - Broken chunks (cut-offs) between old and new chunks (before & after the update)", - C.YELLOW + " - Regenerated chunks that do not fit in with the old chunks", - C.YELLOW + " - Structures not spawning again when regenerating", - C.YELLOW + " - Caves not lining up", - C.YELLOW + " - Terrain layers not lining up", - C.RED + "Now that you are aware of the risks, and have made a back-up:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning", + C.RED + "Back up the complete world before staging this update.", + C.YELLOW + "Existing chunks will not be regenerated or rewritten.", + C.YELLOW + "New chunks use the staged pack after restart and blend across the frozen boundary.", + C.YELLOW + "The world seed, height, environment, and dimension type cannot change.", + C.YELLOW + "Every backup must retain the complete iris/generation directory.", + C.RED + "To stage the update and request a clean restart:", C.RED + "/iris developer update-world {world} {pack} confirm=true" ); public static final TextKey BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW = TextKey.of( diff --git a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java index 41102e678..327e90124 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java @@ -60,9 +60,9 @@ public final class DirectorCommandMessages { "iris.director.commanddeveloper.param.center_chunk_z", "Center chunk Z" ); - public static final TextKey COMMAND_DEVELOPER_DIRECTOR_UPDATE_PACK_WORLD_UNSAFE = TextKey.of( - "iris.director.commanddeveloper.director.update_pack_world_unsafe", - "Update the pack of a world (UNSAFE!)" + public static final TextKey COMMAND_DEVELOPER_DIRECTOR_STAGE_WORLD_GENERATION_UPDATE = TextKey.of( + "iris.director.commanddeveloper.director.stage_world_generation_update", + "Stage a world generation update" ); public static final TextKey COMMAND_DEVELOPER_PARAM_WORLD_UPDATE = TextKey.of( "iris.director.commanddeveloper.param.world_update", @@ -889,7 +889,7 @@ public final class DirectorCommandMessages { COMMAND_DEVELOPER_PARAM_RADIUS_CHUNKS_AROUND_CENTER, COMMAND_DEVELOPER_PARAM_CENTER_CHUNK_X, COMMAND_DEVELOPER_PARAM_CENTER_CHUNK_Z, - COMMAND_DEVELOPER_DIRECTOR_UPDATE_PACK_WORLD_UNSAFE, + COMMAND_DEVELOPER_DIRECTOR_STAGE_WORLD_GENERATION_UPDATE, COMMAND_DEVELOPER_PARAM_WORLD_UPDATE, COMMAND_DEVELOPER_PARAM_PACK_INSTALL_INTO_WORLD, COMMAND_DEVELOPER_PARAM_MAKE_SURE_MAKE_BACKUP_READ_WARNINGS_FIRST, diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 6148d35de..4aacc4309 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -31,6 +31,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.collection.KList; @@ -57,9 +58,14 @@ import java.awt.Color; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; public interface INMSBinding { + default PlatformGenerationRegistry generationRegistry() { + throw new UnsupportedOperationException( + "The active NMS binding does not expose generation registry definitions." + ); + } + boolean hasTile(Material material); boolean hasTile(Location l); @@ -270,8 +276,18 @@ public interface INMSBinding { default void uninjectBukkit() { } - default boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) { - return true; + default void deferPluginClassLoaderClose() { + } + + default void releasePluginClassLoaderClose() { + } + + default boolean isServerStopping() { + return false; + } + + default ServerShutdownBoundary createServerShutdownBoundary() { + return new ServerShutdownBoundary(() -> true, Thread.currentThread()); } KMap> getBlockProperties(); diff --git a/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java b/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java index 0d3acadb5..b253a53cc 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java +++ b/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java @@ -7,33 +7,32 @@ import java.util.function.BooleanSupplier; public final class ServerShutdownBoundary { private static final long MAX_JOIN_SLICE_MILLIS = 1000L; - private ServerShutdownBoundary() { + private final BooleanSupplier boundaryReached; + private final Thread serverThread; + + public ServerShutdownBoundary(BooleanSupplier boundaryReached, Thread serverThread) { + this.boundaryReached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary"); + this.serverThread = Objects.requireNonNull(serverThread, "Server thread"); + this.boundaryReached.getAsBoolean(); } - public static boolean await( - BooleanSupplier boundaryReached, - Thread serverThread, - long timeout, - TimeUnit unit - ) { - BooleanSupplier reached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary"); - Thread activeServerThread = Objects.requireNonNull(serverThread, "Server thread"); + public boolean await(long timeout, TimeUnit unit) { TimeUnit activeUnit = Objects.requireNonNull(unit, "Server shutdown timeout unit"); - if (reached.getAsBoolean()) { + if (boundaryReached.getAsBoolean()) { return true; } - if (activeServerThread == Thread.currentThread()) { + if (serverThread == Thread.currentThread()) { return false; } long timeoutNanos = Math.max(0L, activeUnit.toNanos(timeout)); long started = System.nanoTime(); boolean interrupted = false; - while (!reached.getAsBoolean()) { + while (!boundaryReached.getAsBoolean()) { long remaining = timeoutNanos - (System.nanoTime() - started); - if (remaining <= 0L || !activeServerThread.isAlive()) { + if (remaining <= 0L || !serverThread.isAlive()) { restoreInterrupt(interrupted); - return reached.getAsBoolean(); + return boundaryReached.getAsBoolean(); } long joinMillis = Math.max( @@ -41,7 +40,7 @@ public final class ServerShutdownBoundary { Math.min(MAX_JOIN_SLICE_MILLIS, TimeUnit.NANOSECONDS.toMillis(remaining)) ); try { - activeServerThread.join(joinMillis); + serverThread.join(joinMillis); } catch (InterruptedException e) { interrupted = true; } diff --git a/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java b/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java index 352da58f2..1b6dc15dd 100644 --- a/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/LogFilterSVC.java @@ -7,6 +7,7 @@ import org.apache.logging.log4j.Marker; import org.apache.logging.log4j.core.Filter; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.config.LoggerConfig; import org.apache.logging.log4j.message.Message; import java.util.List; @@ -21,14 +22,15 @@ public class LogFilterSVC implements IrisService, Filter { // so a mutable static here was both a CME hazard and grew by three entries per enable. private static final List FILTERS = List.of(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID); - private boolean installed = false; + private LoggerConfig installedLogger; public void onEnable() { - if (installed) { + if (installedLogger != null) { return; } - ((Logger) LogManager.getRootLogger()).addFilter(this); - installed = true; + LoggerConfig logger = ((Logger) LogManager.getRootLogger()).get(); + logger.addFilter(this); + installedLogger = logger; } public void initialize() { @@ -41,11 +43,12 @@ public class LogFilterSVC implements IrisService, Filter { } public void onDisable() { - if (!installed) { + LoggerConfig logger = installedLogger; + if (logger == null) { return; } - ((Logger) LogManager.getRootLogger()).get().removeFilter(this); - installed = false; + logger.removeFilter(this); + installedLogger = null; } public boolean isStarted() { diff --git a/core/src/main/java/art/arcane/iris/core/service/PreservationSVC.java b/core/src/main/java/art/arcane/iris/core/service/PreservationSVC.java index a830e7a08..6feb29cc9 100644 --- a/core/src/main/java/art/arcane/iris/core/service/PreservationSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/PreservationSVC.java @@ -69,10 +69,27 @@ public class PreservationSVC implements IrisService, PreservationRegistry { public void dereference() { IrisData.dereference(); threads.removeIf((i) -> !i.isAlive()); - services.removeIf(ExecutorService::isShutdown); + services.removeIf(ExecutorService::isTerminated); updateCaches(); } + public boolean hasActiveResources() { + if (dereferencer != null && dereferencer.isAlive()) { + return true; + } + for (Thread thread : threads) { + if (thread.isAlive()) { + return true; + } + } + for (ExecutorService executor : services) { + if (!executor.isTerminated()) { + return true; + } + } + return false; + } + @Override public void onEnable() { /* diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index aac124f8b..876fed1bb 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.service; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisStartupValidation; @@ -46,6 +47,14 @@ import art.arcane.iris.core.runtime.WorldDeletionQueue; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; +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.GenerationHistoryPaths; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.platform.bukkit.BukkitPlatform; @@ -166,18 +175,197 @@ public class StudioSVC implements IrisService { queueStudioWorldDeletionOnStartup(worldNamesToDelete); } - public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) { - return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), false); + public IrisDimension installIntoWorld( + VolmitSender sender, + IrisDimension dimension, + File dimensionRoot, + long worldSeed + ) { + return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, false); } - public IrisDimension replaceIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) { - return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), true); + public IrisDimension replaceIntoWorld( + VolmitSender sender, + IrisDimension dimension, + File dimensionRoot, + long worldSeed + ) { + return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true); + } + + public IrisDimension installIntoTransientWorld( + VolmitSender sender, + IrisDimension dimension, + File dimensionRoot + ) { + return installIntoDirectory(sender, dimension, new File(dimensionRoot, "iris/pack"), false); } public IrisDimension replaceIntoPackDirectory(VolmitSender sender, IrisDimension dimension, File folder) { return installIntoDirectory(sender, dimension, folder, true); } + private IrisDimension publishGenerationHistory( + VolmitSender sender, + IrisDimension dimension, + File dimensionRoot, + long worldSeed, + boolean stageUpdate + ) { + if (J.isPrimaryThread()) { + sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD + )); + return null; + } + String dimensionKey = dimension.getLoadKey(); + try { + Path source = resolveSafePackSource(dimension.getLoader().getDataFolder()); + GenerationCandidate candidate = captureGenerationCandidate(source, dimensionKey); + Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toPath() + .toAbsolutePath() + .normalize(); + if (!stageUpdate && !Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + Path parent = Objects.requireNonNull(root.getParent(), "dimensionRoot parent"); + Files.createDirectories(parent); + Files.createDirectory(root); + } + GenerationHistory history; + long previousActivationId = 0L; + boolean restartRequired = false; + if (stageUpdate) { + history = openOrAdoptGenerationHistory(root, dimensionKey, worldSeed); + previousActivationId = history.activeActivation().activationId(); + GenerationActivation activation = history.stageUpdate( + source, + candidate.packFingerprint(), + candidate.dimensionContract(), + candidate.registryContract(), + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() + ); + restartRequired = activation.activationId() != previousActivationId; + } else { + history = GenerationHistory.create( + root, + source, + candidate.packFingerprint(), + worldSeed, + candidate.dimensionContract(), + candidate.registryContract() + ); + } + Path installedPack = stageUpdate && history.pendingActivation().isPresent() + ? history.packRoot(history.pendingActivation().orElseThrow().activationId()) + : history.activePackRoot(); + IrisDimension installed = loadInstalledDimension(installedPack, dimensionKey); + if (restartRequired) { + ServerConfigurator.restart("An Iris generation epoch update is pending activation."); + } + return installed; + } catch (Throwable failure) { + IrisLogging.reportError("Failed to publish generation history for dimension '" + + dimensionKey + "' into " + dimensionRoot.getPath(), failure); + sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_PACK_INSTALL_FAILED, + MessageArgument.untrusted("dimension", dimensionKey), + MessageArgument.untrusted("error", errorDetail(failure)) + )); + return null; + } + } + + private static GenerationHistory openOrAdoptGenerationHistory( + Path dimensionRoot, + String dimensionKey, + long worldSeed + ) throws IOException { + Optional current = GenerationHistory.openIfPresent(dimensionRoot, worldSeed); + if (current.isPresent()) { + return current.get(); + } + Path legacyPack = GenerationHistoryPaths.forDimension(dimensionRoot).legacyPackRoot(); + PackValidationResult validation = validatePublishedPack(legacyPack); + if (!validation.isLoadable()) { + throw new BrokenPackException(legacyPack.toString(), validation.getBlockingErrors()); + } + GenerationCandidate legacy = captureGenerationCandidate( + legacyPack, + dimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + ); + return GenerationHistory.adoptLegacyPack( + dimensionRoot, + legacy.packFingerprint(), + worldSeed, + legacy.dimensionContract(), + legacy.registryContract() + ); + } + + private static GenerationCandidate captureGenerationCandidate(Path packRoot, String dimensionKey) + throws IOException { + return captureGenerationCandidate( + packRoot, + dimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY + ); + } + + private static GenerationCandidate captureGenerationCandidate( + Path packRoot, + String dimensionKey, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy aliasPolicy + ) throws IOException { + String fingerprint = GenerationPackFingerprint.compute( + packRoot, + GenerationPackFingerprint.CURRENT_VERSION + ); + IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IOException("Generation pack does not contain a loadable dimension '" + + dimensionKey + "'."); + } + String dimensionTypeKey = IrisPlatforms.get() + .registries() + .generationRegistry() + .dimensionTypeResourceKey( + data.getDataFolder().getName(), + dimension.getLoadKey(), + dimension.getDimensionTypeKey() + ); + GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + dimensionTypeKey + ); + GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create( + data, + dimension, + fingerprint, + aliasPolicy + ); + return new GenerationCandidate(fingerprint, dimensionContract, registryContract); + } finally { + data.close(); + } + } + + private static IrisDimension loadInstalledDimension(Path packRoot, String dimensionKey) throws IOException { + PackValidationResult validation = validatePublishedPack(packRoot); + if (!validation.isLoadable()) { + throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); + } + IrisDimension installed = IrisData.get(packRoot.toFile()).getDimensionLoader().load(dimensionKey, false); + if (installed == null) { + throw new IOException("Immutable generation pack does not contain dimension '" + + dimensionKey + "'."); + } + return installed; + } + private IrisDimension installIntoDirectory( VolmitSender sender, IrisDimension dimension, @@ -1534,6 +1722,13 @@ public class StudioSVC implements IrisService { RESTART } + private record GenerationCandidate( + String packFingerprint, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract + ) { + } + public IrisProject getActiveProject() { return activeProject; } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 21aa23752..d6ee9fdb4 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -263,8 +263,10 @@ public class IrisCreator { IrisDimension installedDimension = resolvedDimension; if (!studio() || benchmark) { reportCreationProgress(creationReporter, 0.26D, "prepare_world_pack"); - installedDimension = IrisServices.get(StudioSVC.class) - .installIntoWorld(sender, resolvedDimension, dimensionRoot); + StudioSVC studioService = IrisServices.get(StudioSVC.class); + installedDimension = benchmark + ? studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot) + : studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed); if (installedDimension == null) { throw new IrisException("Failed to install dimension pack for " + dimension()); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index 5a91bed69..28e622278 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -28,6 +28,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.pregenerator.PregenPerformanceProfile; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; @@ -57,7 +58,6 @@ import org.jetbrains.annotations.ApiStatus; import java.io.File; import java.io.IOException; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -74,7 +74,6 @@ import art.arcane.volmlib.util.localization.MessageArgument; public class IrisToolbelt { @ApiStatus.Internal public static Map toolbeltConfiguration = new HashMap<>(); - private static final Method BUKKIT_IS_STOPPING_METHOD = resolveBukkitIsStoppingMethod(); /** * Finds an installed dimension or returns null. @@ -582,26 +581,7 @@ public class IrisToolbelt { } public static boolean isServerStopping() { - Method method = BUKKIT_IS_STOPPING_METHOD; - if (method != null) { - try { - Object value = method.invoke(null); - if (value instanceof Boolean) { - return (Boolean) value; - } - } catch (Throwable ignored) { - } - } - - return !BukkitPlatform.hasPlugin() || !BukkitPlatform.plugin().isEnabled(); - } - - private static Method resolveBukkitIsStoppingMethod() { - try { - return Bukkit.class.getMethod("isStopping"); - } catch (Throwable ignored) { - return null; - } + return INMS.isBound() && INMS.get().isServerStopping(); } public static void beginWorldMaintenance(World world, String reason) { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java index 06f735a8c..030a3445a 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.WorldCreatorCompat; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.platform.bukkit.BukkitEnvironment; import org.bukkit.NamespacedKey; @@ -31,6 +32,7 @@ import org.bukkit.WorldCreator; import org.bukkit.generator.ChunkGenerator; import java.io.File; +import java.io.IOException; public class IrisWorldCreator { private String name; @@ -100,15 +102,42 @@ public class IrisWorldCreator { .seed(seed) .worldFolder(worldFolder) .build(); - ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio - ? dim.getLoader().getDataFolder() : - new File(w.worldFolder(), "iris/pack"), dimensionName); + GenerationHistory generationHistory = persistent + ? requireGenerationHistory(w.worldFolder(), seed) + : null; + File packRoot = generationHistory != null + ? requireActivePack(generationHistory) + : studio + ? dim.getLoader().getDataFolder() + : new File(w.worldFolder(), "iris/pack"); + ChunkGenerator g = new BukkitChunkGenerator( + w, + studio, + packRoot, + dimensionName, + generationHistory); return creator.environment(environment) .generateStructures(true) .generator(g).seed(seed); } + private static GenerationHistory requireGenerationHistory(File dimensionRoot, long seed) { + try { + return GenerationHistory.open(dimensionRoot.toPath(), seed); + } catch (IOException failure) { + throw new IllegalStateException("Iris generation history is unusable at " + dimensionRoot + ".", failure); + } + } + + private static File requireActivePack(GenerationHistory history) { + try { + return history.activePackRoot().toFile(); + } catch (IOException failure) { + throw new IllegalStateException("Iris active generation pack is unusable.", failure); + } + } + private World.Environment findEnvironment() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; return BukkitEnvironment.from(dim == null ? null : dim.getEnvironment()); 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 763325b0f..5116004d9 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -20,7 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.protocol.IrisProtocolServer; -import art.arcane.iris.engine.EngineRuntime.BiomeMaxes; +import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.engine.IrisEngine.LifecycleState; import art.arcane.iris.engine.framework.EngineTarget; @@ -53,25 +53,37 @@ final class EngineHotloader { void hotloadComplex() { synchronized (engine.lifecycleLock) { engine.requireRunning("rebuild the biome complex"); + requireMutableGenerationRuntime("rebuild the biome complex"); engine.awaitNativeStructureBootstrap("complex hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previous = engine.runtime; + GenerationRuntime previousGeneration = previous.generation(); IrisComplex nextComplex = null; try { engine.sealForTransition("complex hotload", false); - RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), previous.target()); + RuntimeAssembly assembly = new RuntimeAssembly( + RuntimeAssembly.nextRuntimeId(), + previousGeneration.target(), + previousGeneration.mantleStorageDirectory(), + previousGeneration.runtimeKernel(), + previousGeneration.transitionPlan()); engine.runtimeAssembly.set(assembly); EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { - assembly.complex = new IrisComplex(engine); + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); nextComplex = assembly.complex; - assembly.upperContext = engine.runtimeBuilder.buildUpperContext(); + assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); - next = previous.withComplex(assembly.cacheId, assembly.complex, assembly.upperContext, biomeMaxes); + GenerationRuntime nextGeneration = previousGeneration.withComplex( + assembly.cacheId, + assembly.complex, + assembly.upperContext, + biomeMaxes); + next = previous.withGeneration(nextGeneration); } finally { engine.runtimeAssembly.remove(); } - Throwable retirementFailure = runCleanup(null, previous.complex()::close); + Throwable retirementFailure = runCleanup(null, previousGeneration.complex()::close); if (retirementFailure != null) { retirementFailure = runCleanup(retirementFailure, nextComplex::close); nextComplex = null; @@ -84,7 +96,7 @@ final class EngineHotloader { engine.getClosing().set(false); engine.backgroundTasks.openBackgroundTaskAdmission(); } catch (Throwable e) { - if (nextComplex != null && nextComplex != previous.complex()) { + if (nextComplex != null && nextComplex != previousGeneration.complex()) { Throwable cleanupFailure = runCleanup(null, nextComplex::close); if (cleanupFailure != null) { e.addSuppressed(cleanupFailure); @@ -101,6 +113,7 @@ final class EngineHotloader { void hotloadSilently() { synchronized (engine.lifecycleLock) { engine.requireRunning("hotload"); + requireMutableGenerationRuntime("hotload"); engine.awaitNativeStructureBootstrap("hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previousRuntime = engine.runtime; @@ -175,6 +188,13 @@ final class EngineHotloader { } } + private void requireMutableGenerationRuntime(String operation) { + if (engine.getGenerationHistoryRuntimeRouter().isPresent()) { + throw new IllegalStateException("Cannot " + operation + + " while immutable Iris generation history is attached; stage a world update and restart instead."); + } + } + /** * Never throws. It is called from the hotload failure handler, where a frame-cap * {@code IllegalStateException} out of the codec (a long pack key or exception message overruns diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java index 1686edc9d..a8517bd7e 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntime.java @@ -19,45 +19,26 @@ package art.arcane.iris.engine; import art.arcane.iris.engine.framework.EngineEffects; -import art.arcane.iris.engine.framework.EngineMode; -import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; -import java.util.concurrent.CompletableFuture; +import java.util.Objects; /** * Immutable snapshot of everything an {@link IrisEngine} publishes as a single unit. * Instances are built by {@link EngineRuntimeBuilder} and retired by {@link EngineShutdownSequence}. */ record EngineRuntime( - int cacheId, - EngineTarget target, - IrisComplex complex, - UpperDimensionContext upperContext, + GenerationRuntime generation, EngineEffects effects, - EngineMode mode, - EngineWorldManager worldManager, - CompletableFuture hash32, - BiomeMaxes biomeMaxes + EngineWorldManager worldManager ) { - EngineRuntime withComplex( - int nextCacheId, - IrisComplex nextComplex, - UpperDimensionContext nextUpperContext, - BiomeMaxes nextBiomeMaxes - ) { - return new EngineRuntime( - nextCacheId, - target, - nextComplex, - nextUpperContext, - effects, - mode, - worldManager, - hash32, - nextBiomeMaxes); + EngineRuntime { + Objects.requireNonNull(generation, "generation runtime"); + Objects.requireNonNull(effects, "engine effects"); + Objects.requireNonNull(worldManager, "engine world manager"); } - record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) { + EngineRuntime withGeneration(GenerationRuntime nextGeneration) { + return new EngineRuntime(nextGeneration, effects, worldManager); } } 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 2ec39f3b8..43432e69a 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -20,7 +20,8 @@ package art.arcane.iris.engine; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; -import art.arcane.iris.engine.EngineRuntime.BiomeMaxes; +import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes; +import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.IrisEngine.LifecycleState; import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EngineEffectsProvider; @@ -28,14 +29,14 @@ import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.history.TransitionGenerationPlan; +import art.arcane.iris.engine.history.GenerationKernelRegistry; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomePaletteLayer; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.IrisDimensionMode; -import art.arcane.iris.engine.object.IrisDimensionModeType; import art.arcane.iris.engine.object.IrisObjectPlacement; -import art.arcane.iris.engine.object.IrisStaticObjectLayer; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.project.context.IrisContext; @@ -43,6 +44,9 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.math.M; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; @@ -62,19 +66,53 @@ final class EngineRuntimeBuilder { } EngineRuntime buildRuntime() { - return buildRuntime(engine.getTarget()); + return buildRuntime( + engine.getTarget(), + engine.getInitialMantleStorageDirectory(), + engine.getInitialKernelVersion(), + engine.getInitialTransitionPlan(), + null); } EngineRuntime buildRuntime(EngineTarget runtimeTarget) { - RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), runtimeTarget); + GenerationRuntime active = requireRuntime("hotload the engine runtime").generation(); + return buildRuntime( + runtimeTarget, + active.mantleStorageDirectory(), + active.kernelVersion(), + active.transitionPlan(), + active.mantle()); + } + + private EngineRuntime buildRuntime( + EngineTarget runtimeTarget, + Path mantleStorageDirectory, + GenerationKernelRegistry.Version kernelVersion, + TransitionGenerationPlan transitionPlan, + EngineMantle transferredMantle + ) { + RuntimeAssembly assembly = new RuntimeAssembly( + RuntimeAssembly.nextRuntimeId(), + runtimeTarget, + mantleStorageDirectory, + selectRuntimeKernel(kernelVersion), + transitionPlan); engine.runtimeAssembly.set(assembly); try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { IrisLogging.debug("Setup Engine " + assembly.cacheId); long started = M.ms(); - assembly.complex = new IrisComplex(engine); + if (transferredMantle == null) { + assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory); + assembly.ownsMantle = true; + } else { + assembly.mantle = transferredMantle; + } + IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - started) + "ms"); + started = M.ms(); + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms"); started = M.ms(); - assembly.upperContext = buildUpperContext(); + assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.effects = IrisServices.get(EngineEffectsProvider.class).create(engine); @@ -84,13 +122,11 @@ final class EngineRuntimeBuilder { IrisLogging.debug("[IrisEngine timing] EngineEffects=" + (M.ms() - started) + "ms"); assembly.hash32 = new CompletableFuture<>(); started = M.ms(); - engine.getMantle().hotload(); + assembly.mantle.hotload(); IrisLogging.debug("[IrisEngine timing] mantle.hotload=" + (M.ms() - started) + "ms"); started = M.ms(); - assembly.mode = createMode(); - IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); - assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) -> - staticObjects.apply(engine, x, z, blocks)); + assembly.mode = assembly.runtimeKernel.createMode(engine); + assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode); IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.worldManager = IrisServices.get(EngineWorldManagerProvider.class).create(engine); @@ -99,7 +135,7 @@ final class EngineRuntimeBuilder { } IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms"); BiomeMaxes biomeMaxes = computeBiomeMaxes(); - return assembly.freeze(biomeMaxes); + return assembly.freezeRuntime(biomeMaxes); } catch (Throwable e) { Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); if (cleanupFailure != e) { @@ -111,8 +147,54 @@ final class EngineRuntimeBuilder { } } + GenerationRuntime buildDetachedGenerationRuntime(EngineTarget runtimeTarget, Path mantleStorageDirectory) { + return buildDetachedGenerationRuntime( + runtimeTarget, + mantleStorageDirectory, + GenerationKernelRegistry.standard().current(), + null); + } + + GenerationRuntime buildDetachedGenerationRuntime( + EngineTarget runtimeTarget, + Path mantleStorageDirectory, + GenerationKernelRegistry.Version kernelVersion, + TransitionGenerationPlan transitionPlan + ) { + RuntimeAssembly assembly = new RuntimeAssembly( + RuntimeAssembly.nextRuntimeId(), + runtimeTarget, + mantleStorageDirectory, + selectRuntimeKernel(kernelVersion), + transitionPlan); + engine.runtimeAssembly.set(assembly); + try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { + IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId); + assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory); + assembly.ownsMantle = true; + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); + assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); + assembly.hash32 = CompletableFuture.completedFuture(computePackHash(runtimeTarget.getData())); + assembly.mantle.hotload(); + assembly.mode = assembly.runtimeKernel.createMode(engine); + assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode); + return assembly.freezeGeneration(computeBiomeMaxes()); + } catch (Throwable e) { + Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); + if (cleanupFailure != e) { + e.addSuppressed(cleanupFailure); + } + throw new IllegalStateException("Failed to build a detached Iris generation runtime.", e); + } finally { + engine.runtimeAssembly.remove(); + } + } + void publishRuntime(EngineRuntime next, EngineRuntime previous) { - Throwable retirementFailure = engine.shutdownSequence.closeRuntime(previous, null); + Throwable retirementFailure = engine.shutdownSequence.closeRuntime( + previous, + next.generation().mantle(), + null); if (retirementFailure != null) { retirementFailure = engine.shutdownSequence.closeRuntime(next, retirementFailure); engine.lifecycleState = LifecycleState.FAILED; @@ -123,7 +205,7 @@ final class EngineRuntimeBuilder { } engine.runtime = next; - engine.publishedTarget = next.target(); + engine.publishedTarget = next.generation().target(); engine.getGenerationSessions().activateNextSession(); engine.lifecycleState = LifecycleState.RUNNING; engine.getClosing().set(false); @@ -150,33 +232,11 @@ final class EngineRuntimeBuilder { throw new IllegalStateException("Failed to start the Iris world manager.", e); } scheduleRuntimeTasks(next); - IrisLogging.debug("Engine Setup Complete " + next.cacheId()); + IrisLogging.debug("Engine Setup Complete " + next.generation().cacheId()); } private void scheduleRuntimeTasks(EngineRuntime engineRuntime) { - try { - if (!engine.backgroundTasks.scheduleTrackedTask(() -> { - try { - File[] roots = engine.getData().getLoaders() - .values() - .stream() - .map(ResourceLoader::getFolderName) - .map(name -> new File(engine.getData().getDataFolder(), name)) - .filter(File::exists) - .filter(File::isDirectory) - .toArray(File[]::new); - engineRuntime.hash32().complete(IO.hashRecursiveMeta(roots)); - } catch (Throwable e) { - engineRuntime.hash32().completeExceptionally(e); - throw propagate(e); - } - })) { - throw new IllegalStateException("Iris background task admission closed before pack hashing."); - } - } catch (Throwable e) { - engineRuntime.hash32().completeExceptionally(e); - IrisLogging.reportError(e); - } + schedulePackHash(engineRuntime.generation()); try { if (!engine.backgroundTasks.scheduleTrackedTask(() -> engine.getPlatformHooks().refreshDatapackWorkspace(engine))) { throw new IllegalStateException("Iris background task admission closed before datapack workspace refresh."); @@ -186,56 +246,34 @@ final class EngineRuntimeBuilder { } } - UpperDimensionContext buildUpperContext() { - IrisDimension dim = engine.getDimension(); - if (!dim.hasUpperDimension()) { - return null; + private void schedulePackHash(GenerationRuntime generationRuntime) { + try { + if (!engine.backgroundTasks.scheduleTrackedTask(() -> { + try { + generationRuntime.hash32().complete(computePackHash(generationRuntime.data())); + } catch (Throwable e) { + generationRuntime.hash32().completeExceptionally(e); + throw propagate(e); + } + })) { + throw new IllegalStateException("Iris background task admission closed before pack hashing."); + } + } catch (Throwable e) { + generationRuntime.hash32().completeExceptionally(e); + IrisLogging.reportError(e); } - String upperKey = dim.getUpperDimension(); - IrisDimension upperDim = upperKey.equals(dim.getLoadKey()) - ? dim - : IrisData.loadAnyDimension(upperKey, engine.getData()); - if (upperDim != null) { - UpperDimensionContext ctx = UpperDimensionContext.create(engine, upperDim); - IrisLogging.info("Upper dimension enabled: " + upperKey - + (ctx.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)")); - return ctx; - } - IrisLogging.warn("Upper dimension '" + upperKey + "' could not be resolved, skipping upper terrain."); - return null; } - private EngineMode createMode() { - Throwable configuredFailure = null; - try { - IrisDimensionMode configuredMode = engine.getDimension().getMode(); - if (configuredMode == null) { - configuredMode = new IrisDimensionMode(); - engine.getDimension().setMode(configuredMode); - } - EngineMode configured = configuredMode.create(engine); - if (configured == null) { - throw new IllegalStateException("Dimension mode factory returned null"); - } - return configured; - } catch (Throwable e) { - configuredFailure = e; - IrisLogging.reportError(e); - if (engine.getModeFallbackLogged().compareAndSet(false, true)) { - IrisLogging.warn("Failed to initialize configured dimension mode for " + engine.getDimension().getLoadKey() + ", falling back to OVERWORLD mode."); - } - } - - try { - EngineMode fallback = IrisDimensionModeType.OVERWORLD.create(engine); - if (fallback == null) { - throw new IllegalStateException("OVERWORLD mode factory returned null"); - } - return fallback; - } catch (Throwable fallbackFailure) { - fallbackFailure.addSuppressed(configuredFailure); - throw new IllegalStateException("Both configured and fallback Iris engine modes failed.", fallbackFailure); - } + private long computePackHash(IrisData data) { + File[] roots = data.getLoaders() + .values() + .stream() + .map(ResourceLoader::getFolderName) + .map(name -> new File(data.getDataFolder(), name)) + .filter(File::exists) + .filter(File::isDirectory) + .toArray(File[]::new); + return IO.hashRecursiveMeta(roots); } BiomeMaxes computeBiomeMaxes() { @@ -289,11 +327,27 @@ final class EngineRuntimeBuilder { return current; } + static GenerationKernelRegistry.RuntimeKernel selectRuntimeKernel( + GenerationKernelRegistry.Version version + ) { + try { + return GenerationKernelRegistry.standard().select(version); + } catch (IOException failure) { + throw new IllegalArgumentException("Unsupported executable Iris generation kernel " + + version + ".", failure); + } + } + static final class RuntimeAssembly { private static final AtomicInteger RUNTIME_IDS = new AtomicInteger(); final int cacheId; final EngineTarget target; + final Path mantleStorageDirectory; + final GenerationKernelRegistry.Version kernelVersion; + final GenerationKernelRegistry.RuntimeKernel runtimeKernel; + final SeedManager seedManager; + final TransitionGenerationPlan transitionPlan; static int nextRuntimeId() { return RUNTIME_IDS.incrementAndGet(); @@ -302,28 +356,75 @@ final class EngineRuntimeBuilder { UpperDimensionContext upperContext; EngineEffects effects; EngineMode mode; + EngineMantle mantle; EngineWorldManager worldManager; CompletableFuture hash32; + boolean ownsMantle; RuntimeAssembly(int cacheId, EngineTarget target) { - this.cacheId = cacheId; - this.target = target; - } - - EngineRuntime freeze(BiomeMaxes biomeMaxes) { - if (complex == null || effects == null || mode == null || worldManager == null || hash32 == null) { - throw new IllegalStateException("Cannot publish an incomplete Iris engine runtime."); - } - return new EngineRuntime( + this( cacheId, target, + null, + selectRuntimeKernel(GenerationKernelRegistry.standard().current()), + null); + } + + RuntimeAssembly(int cacheId, EngineTarget target, Path mantleStorageDirectory) { + this( + cacheId, + target, + mantleStorageDirectory, + selectRuntimeKernel(GenerationKernelRegistry.standard().current()), + null); + } + + RuntimeAssembly( + int cacheId, + EngineTarget target, + Path mantleStorageDirectory, + GenerationKernelRegistry.RuntimeKernel runtimeKernel, + TransitionGenerationPlan transitionPlan + ) { + this.cacheId = cacheId; + this.target = target; + this.mantleStorageDirectory = mantleStorageDirectory == null + ? null + : IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory); + this.runtimeKernel = Objects.requireNonNull(runtimeKernel, "executable generation kernel"); + this.seedManager = this.runtimeKernel.createSeedManager(target.getWorld().getRawWorldSeed()); + this.kernelVersion = this.runtimeKernel.version(); + this.transitionPlan = transitionPlan; + } + + GenerationRuntime freezeGeneration(BiomeMaxes biomeMaxes) { + if (complex == null || mode == null || mantle == null || mantleStorageDirectory == null || hash32 == null) { + throw new IllegalStateException("Cannot publish an incomplete Iris generation runtime."); + } + return new GenerationRuntime( + cacheId, + target, + target.getData(), + target.getDimension(), + kernelVersion, + runtimeKernel, + seedManager, + transitionPlan, complex, upperContext, - effects, mode, - worldManager, + mantle, + mantleStorageDirectory, hash32, biomeMaxes); } + + EngineRuntime freezeRuntime(BiomeMaxes biomeMaxes) { + if (effects == null || worldManager == null) { + throw new IllegalStateException("Cannot publish an incomplete Iris engine runtime."); + } + return new EngineRuntime(freezeGeneration(biomeMaxes), effects, worldManager); + } + } } 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 27b6c417a..a9d6ff669 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -22,8 +22,10 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.engine.IrisEngine.LifecycleState; import art.arcane.iris.engine.framework.GenerationSessionException; +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.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -38,7 +40,6 @@ final class EngineShutdownSequence { private final IrisEngine engine; private boolean runtimeReleased; private boolean targetReleased; - private boolean mantleReleased; private boolean engineDataReleased; private boolean preservationReleased; @@ -47,6 +48,17 @@ final class EngineShutdownSequence { } void close() { + if (!engine.beginShutdown()) { + return; + } + Throwable routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter); + if (routerFailure != null) { + synchronized (engine.lifecycleLock) { + engine.lifecycleState = LifecycleState.FAILED; + } + reportIncompleteClose(routerFailure); + return; + } Throwable failure; synchronized (engine.lifecycleLock) { if (engine.closed) { @@ -122,14 +134,10 @@ final class EngineShutdownSequence { if (runtimeReleased) { failure = releaseTarget(failure); } - if (targetReleased) { - failure = releaseMantle(failure); - } if (prefetchFailure == null && engineDataFailure == null && runtimeReleased - && targetReleased - && mantleReleased) { + && targetReleased) { failure = releaseEngineDataForShutdown(failure); } if (engineDataReleased) { @@ -140,7 +148,6 @@ final class EngineShutdownSequence { if (failure == null && runtimeReleased && targetReleased - && mantleReleased && engineDataReleased && preservationReleased) { engine.closed = true; @@ -185,10 +192,10 @@ 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.getTarget()::close); - cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close); + cleanupFailure = runCleanup(cleanupFailure, engine.publishedTarget::close); cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData); engine.closed = true; cleanupFailure = runCleanup(cleanupFailure, () -> { @@ -232,18 +239,69 @@ final class EngineShutdownSequence { 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); + } return failure; } Throwable closeRuntime(EngineRuntime engineRuntime, Throwable failure) { + return closeRuntime(engineRuntime, null, failure); + } + + Throwable closeRuntime( + EngineRuntime engineRuntime, + EngineMantle retainedMantle, + Throwable failure + ) { if (engineRuntime == null) { return failure; } failure = runCleanup(failure, engineRuntime.worldManager()::close); failure = runCleanup(failure, engineRuntime.effects()::close); - failure = runCleanup(failure, engineRuntime.mode()::close); - failure = runCleanup(failure, engineRuntime.complex()::close); - failure = runCleanup(failure, () -> engineRuntime.hash32().cancel(true)); + return closeGenerationRuntime(engineRuntime.generation(), retainedMantle, failure); + } + + Throwable closeGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) { + return closeGenerationRuntime(generationRuntime, null, failure); + } + + Throwable closeGenerationRuntime( + GenerationRuntime generationRuntime, + EngineMantle retainedMantle, + Throwable failure + ) { + if (generationRuntime == null) { + return failure; + } + failure = runCleanup(failure, generationRuntime.mode()::close); + failure = runCleanup(failure, generationRuntime.complex()::close); + failure = runCleanup(failure, () -> generationRuntime.hash32().cancel(true)); + if (generationRuntime.mantle() != retainedMantle) { + failure = runCleanup(failure, generationRuntime.mantle()::saveAllNow); + failure = runCleanup(failure, generationRuntime.mantle()::close); + } + return failure; + } + + Throwable closeDetachedGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) { + IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding( + engine, + generationRuntime); + try (IrisEngine.GenerationRuntimeScope ignored = engine.generationRuntimeScopes.open(binding)) { + failure = closeGenerationRuntime(generationRuntime, failure); + } catch (Throwable scopeFailure) { + failure = appendFailure(failure, scopeFailure); + } + return closeDetachedTarget(generationRuntime.target(), failure); + } + + Throwable closeDetachedTarget(EngineTarget target, Throwable failure) { + failure = runCleanup(failure, () -> target.getData().unregisterEngine(engine)); + failure = runCleanup(failure, target::close); + failure = runCleanup(failure, target.getData()::close); return failure; } @@ -251,6 +309,10 @@ final class EngineShutdownSequence { if (runtimeReleased) { return failure; } + Throwable detachedFailure = closeDetachedGenerationRuntimes(null); + if (detachedFailure != null) { + return appendFailure(failure, detachedFailure); + } Throwable runtimeFailure = closeRuntime(engine.runtime, null); if (runtimeFailure != null) { return appendFailure(failure, runtimeFailure); @@ -260,11 +322,23 @@ final class EngineShutdownSequence { return failure; } + private Throwable closeDetachedGenerationRuntimes(Throwable failure) { + GenerationRuntime[] detachedRuntimes = engine.detachedGenerationRuntimes.toArray(new GenerationRuntime[0]); + for (GenerationRuntime detached : detachedRuntimes) { + Throwable detachedFailure = closeDetachedGenerationRuntime(detached, null); + failure = appendFailure(failure, detachedFailure); + if (detachedFailure == null) { + engine.detachedGenerationRuntimes.remove(detached); + } + } + return failure; + } + private Throwable releaseTarget(Throwable failure) { if (targetReleased) { return failure; } - Throwable targetFailure = runCleanup(null, engine.getTarget()::close); + Throwable targetFailure = runCleanup(null, engine.publishedTarget::close); if (targetFailure != null) { return appendFailure(failure, targetFailure); } @@ -272,18 +346,6 @@ final class EngineShutdownSequence { return failure; } - private Throwable releaseMantle(Throwable failure) { - if (mantleReleased) { - return failure; - } - Throwable mantleFailure = runCleanup(null, engine.getMantle()::close); - if (mantleFailure != null) { - return appendFailure(failure, mantleFailure); - } - mantleReleased = true; - return failure; - } - private Throwable releaseEngineDataForShutdown(Throwable failure) { if (engineDataReleased) { return failure; diff --git a/core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java b/core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java new file mode 100644 index 000000000..bc7bf13d7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/GenerationRuntime.java @@ -0,0 +1,98 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.history.TransitionGenerationPlan; +import art.arcane.iris.engine.history.GenerationKernelRegistry; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisDimension; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +record GenerationRuntime( + int cacheId, + EngineTarget target, + IrisData data, + IrisDimension dimension, + GenerationKernelRegistry.Version kernelVersion, + GenerationKernelRegistry.RuntimeKernel runtimeKernel, + SeedManager seedManager, + TransitionGenerationPlan transitionPlan, + IrisComplex complex, + UpperDimensionContext upperContext, + EngineMode mode, + EngineMantle mantle, + Path mantleStorageDirectory, + CompletableFuture hash32, + BiomeMaxes biomeMaxes +) { + GenerationRuntime { + Objects.requireNonNull(target, "generation target"); + Objects.requireNonNull(data, "generation data"); + Objects.requireNonNull(dimension, "generation dimension"); + Objects.requireNonNull(kernelVersion, "generation kernel version"); + Objects.requireNonNull(runtimeKernel, "executable generation kernel"); + Objects.requireNonNull(seedManager, "generation seed manager"); + if (!kernelVersion.equals(runtimeKernel.version())) { + throw new IllegalArgumentException("Generation kernel version does not match its executable runtime."); + } + Objects.requireNonNull(complex, "generation complex"); + Objects.requireNonNull(mode, "generation mode"); + Objects.requireNonNull(mantle, "generation mantle"); + mantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory); + Objects.requireNonNull(hash32, "generation pack hash"); + Objects.requireNonNull(biomeMaxes, "generation biome maximums"); + if (target.getData() != data || target.getDimension() != dimension) { + throw new IllegalArgumentException("Generation target, data, and dimension must describe one runtime."); + } + } + + GenerationRuntime withComplex( + int nextCacheId, + IrisComplex nextComplex, + UpperDimensionContext nextUpperContext, + BiomeMaxes nextBiomeMaxes + ) { + return new GenerationRuntime( + nextCacheId, + target, + data, + dimension, + kernelVersion, + runtimeKernel, + seedManager, + transitionPlan, + nextComplex, + nextUpperContext, + mode, + mantle, + mantleStorageDirectory, + hash32, + nextBiomeMaxes); + } + + record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java b/core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java new file mode 100644 index 000000000..17d01da4c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/GenerationRuntimeScopeState.java @@ -0,0 +1,55 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine; + +import art.arcane.iris.engine.IrisEngine.GenerationRuntimeBinding; +import art.arcane.iris.engine.IrisEngine.GenerationRuntimeScope; + +final class GenerationRuntimeScopeState { + private final ThreadLocal current = new ThreadLocal<>(); + + GenerationRuntimeBinding current() { + return current.get(); + } + + GenerationRuntimeScope open(GenerationRuntimeBinding binding) { + Thread owner = Thread.currentThread(); + GenerationRuntimeBinding previous = current.get(); + current.set(binding); + return new GenerationRuntimeScope(this, owner, previous, binding); + } + + void close( + Thread owner, + GenerationRuntimeBinding previous, + GenerationRuntimeBinding installed + ) { + if (Thread.currentThread() != owner) { + throw new IllegalStateException("Iris generation runtime scope closed from a different thread."); + } + if (current.get() != installed) { + throw new IllegalStateException("Iris generation runtime scopes must close in LIFO order."); + } + if (previous == null) { + current.remove(); + return; + } + current.set(previous); + } +} 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 bec080fe9..e38b4af44 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -23,6 +23,8 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationBlend; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; @@ -66,6 +68,7 @@ import lombok.ToString; import java.io.File; import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.EnumMap; @@ -75,6 +78,7 @@ import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @@ -114,6 +118,7 @@ public class IrisComplex implements DataProvider { private final transient IrisGenerator[][] frozenGenerators; @Getter(AccessLevel.NONE) private final transient Map>> inferredBiomeStreams; + private final TransitionGenerationPlan transitionGenerationPlan; private RNG rng; private double fluidHeight; private IrisData data; @@ -130,9 +135,11 @@ public class IrisComplex implements DataProvider { private ProceduralStream baseBiomeStream; private ProceduralStream baseBiomeIDStream; private ProceduralStream naturalTrueBiomeStream; + private ProceduralStream unblendedNaturalTrueBiomeStream; private ProceduralStream trueBiomeStream; private ProceduralStream trueBiomeDerivativeStream; private ProceduralStream naturalHeightStream; + private ProceduralStream unblendedNaturalHeightStream; private ProceduralStream heightStream; private ProceduralStream roundedHeighteightStream; private ProceduralStream maxHeightStream; @@ -168,10 +175,19 @@ public class IrisComplex implements DataProvider { private final Object childSelectionPlanLock = new Object(); public IrisComplex(Engine engine) { - this(engine, false); + this(engine, false, null); } public IrisComplex(Engine engine, boolean simple) { + this(engine, simple, null); + } + + public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan) { + this(engine, false, transitionGenerationPlan); + } + + IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) { + this.transitionGenerationPlan = transitionGenerationPlan; int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize()); IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); @@ -294,10 +310,15 @@ public class IrisComplex implements DataProvider { return mapped == null ? biome : mapped; }) .cache2D("imageMappedBaseBiomeStream", engine, cacheSize); - naturalHeightStream = ProceduralStream.of( - (x, z) -> sampleNaturalTerrainHeight(engine, x, z), + unblendedNaturalHeightStream = ProceduralStream.of( + (x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z), Interpolated.DOUBLE - ).cache2DDouble("naturalHeightStream", engine, cacheSize); + ).cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize); + naturalHeightStream = transitionGenerationPlan == null ? unblendedNaturalHeightStream + : unblendedNaturalHeightStream.convertAware2D((newHeight, x, z) -> blendNaturalTerrainHeight( + newHeight, + transitionGenerationPlan.terrainSampleAt(blockCoordinate(x), blockCoordinate(z)))) + .cache2DDouble("naturalHeightStream", engine, cacheSize); naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream @@ -308,6 +329,14 @@ public class IrisComplex implements DataProvider { : mapped; }) .cache2D("naturalTrueBiomeStream", engine, cacheSize); + unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusBiome != null + ? naturalTrueBiomeStream + : unblendedNaturalHeightStream.convertAware2D((h, x, z) -> { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight) + : mapped; + }).cache2D("unblendedNaturalTrueBiomeStream", engine, cacheSize); IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); if (hydrologyActive(configuredHydrology)) { hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( @@ -451,10 +480,35 @@ public class IrisComplex implements DataProvider { } private double sampleNaturalTerrainHeight(Engine engine, double x, double z) { + double newHeight = sampleUnblendedNaturalTerrainHeight(engine, x, z); + if (transitionGenerationPlan == null) { + return newHeight; + } + TransitionGenerationPlan.TerrainSample sample = transitionGenerationPlan.terrainSampleAt( + blockCoordinate(x), + blockCoordinate(z)); + return blendNaturalTerrainHeight(newHeight, sample); + } + + private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) { double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight()); return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); } + static double blendNaturalTerrainHeight( + double newHeight, + TransitionGenerationPlan.TerrainSample sample + ) { + Objects.requireNonNull(sample, "terrain transition sample"); + if (sample.newEpochWeight() == 1D || !sample.hasHistoricalSignature()) { + return newHeight; + } + double historicalHeight = sample.nearestSignature().fluidHeight().isPresent() + ? sample.historicalOceanFloorHeight() + : sample.historicalSurfaceHeight(); + return GenerationBlend.interpolate(historicalHeight, newHeight, sample.newEpochWeight()); + } + static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) { double deltaX = easternHeight - naturalHeight; double deltaZ = southernHeight - naturalHeight; @@ -687,7 +741,9 @@ public class IrisComplex implements DataProvider { if (layer == null) { return 0D; } - return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D; + return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 + ? 0D + : transitionHydrologyWeight(x, z); } private double resolveHydrologyCarveWeight(double x, double z) { @@ -695,10 +751,11 @@ public class IrisComplex implements DataProvider { if (layer == null) { return 0D; } + double hydrologyWeight = transitionHydrologyWeight(x, z); if (layer.channel()) { - return 1D; + return hydrologyWeight; } - return layer.shore() ? 0.75D : 0.5D; + return hydrologyWeight * (layer.shore() ? 0.75D : 0.5D); } private double resolveHydrologyFluidSurface(double x, double z) { @@ -706,13 +763,144 @@ public class IrisComplex implements DataProvider { return layer == null ? fluidHeight : layer.fluidHeadY(); } + public HydrologyColumnSample sampleHydrologyColumn(double x, double z) { + if (hydrologyRuntime == null) { + return null; + } + if (transitionGenerationPlan == null) { + return hydrologyRuntime.sample(x, z).orElse(null); + } + double hydrologyWeight = transitionHydrologyWeight(x, z); + if (hydrologyWeight == 0D) { + return null; + } + HydrologyColumnSample sample = hydrologyRuntime.sample(x, z).orElse(null); + if (sample == null || hydrologyWeight == 1D) { + return sample; + } + return taperHydrologySample(sample, hydrologyWeight); + } + + static HydrologyColumnSample taperHydrologySample( + HydrologyColumnSample sample, + double hydrologyWeight + ) { + Objects.requireNonNull(sample, "hydrology sample"); + if (hydrologyWeight == 1D) { + return sample; + } + ArrayList taperedLayers = new ArrayList<>(sample.layers().size()); + for (HydrologyColumnLayer layer : sample.layers()) { + taperedLayers.add(taperHydrologyLayer(layer, sample.naturalHeight(), hydrologyWeight)); + } + return new HydrologyColumnSample( + sample.x(), + sample.z(), + sample.naturalHeight(), + sample.seaLevel(), + sample.ocean(), + sample.parentBiomeKey(), + taperedLayers); + } + + private static HydrologyColumnLayer taperHydrologyLayer( + HydrologyColumnLayer layer, + int naturalHeight, + double hydrologyWeight + ) { + return new HydrologyColumnLayer( + layer.feature(), + GenerationBlend.interpolateHeight(naturalHeight, layer.bedY(), hydrologyWeight), + GenerationBlend.interpolateHeight(naturalHeight, layer.fluidHeadY(), hydrologyWeight), + GenerationBlend.interpolateHeight(naturalHeight, layer.ceilingY(), hydrologyWeight), + layer.channel(), + layer.shore(), + layer.grading(), + layer.connectedFluid(), + layer.fallingFluid(), + layer.receivingPool(), + layer.terrainOwned(), + layer.fluidOwned(), + layer.oceanApron(), + layer.profileKey(), + layer.surfaceBiomeKey(), + layer.mouthBiomeKey(), + layer.shoreBiomeKey(), + layer.bankBiomeKey(), + layer.floodedCaveBiomeKey()); + } + private HydrologyColumnSample hydrologySample(double x, double z) { - return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null); + return sampleHydrologyColumn(x, z); + } + + private double transitionHydrologyWeight(double x, double z) { + return transitionGenerationPlan == null + ? 1D + : transitionGenerationPlan.hydrologyWeightAt(blockCoordinate(x), blockCoordinate(z)); } /** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */ public boolean isHydrologyPlanned(int x, int z) { - return hydrologyRuntime == null || hydrologyRuntime.isPlanned(x, z); + return hydrologyRuntime == null + || transitionHydrologyWeight(x, z) == 0D + || hydrologyRuntime.isPlanned(x, z); + } + + public boolean isHistoricalChunk(int chunkX, int chunkZ) { + return transitionGenerationPlan != null + && transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ); + } + + public boolean allowsMantleWrite(int blockX, int blockZ) { + return transitionGenerationPlan == null + || !transitionGenerationPlan.isHistoricalBlock(blockX, blockZ); + } + + public boolean allowsMantleChunkWrite(int chunkX, int chunkZ) { + return transitionGenerationPlan == null + || !transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ); + } + + public boolean allowsNewDiscreteContentAt(int blockX, int blockZ) { + return transitionGenerationPlan == null + || transitionGenerationPlan.allowsNewDiscreteContentAt(blockX, blockZ); + } + + public boolean allowsNewGenerationChunk(int chunkX, int chunkZ) { + if (transitionGenerationPlan == null) { + return true; + } + int minimumX = Math.multiplyExact(chunkX, 16); + int minimumZ = Math.multiplyExact(chunkZ, 16); + return transitionGenerationPlan.allowsNewFootprint( + minimumX, + minimumZ, + Math.addExact(minimumX, 15), + Math.addExact(minimumZ, 15)); + } + + public boolean allowsNewGenerationFootprint( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + return transitionGenerationPlan == null + || transitionGenerationPlan.allowsNewFootprint(minimumX, minimumZ, maximumX, maximumZ); + } + + public Optional historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { + return transitionGenerationPlan == null + ? Optional.empty() + : transitionGenerationPlan.historicalPhysicalBiomeKeyAt(blockX, blockY, blockZ); + } + + private static int blockCoordinate(double coordinate) { + if (!Double.isFinite(coordinate)) { + throw new IllegalArgumentException("Generation coordinate must be finite"); + } + return (int) Math.floor(coordinate); } /** The natural terrain height of a column, from the natural height stream only: never touches hydrology or its caches. */ 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 0e27aaa85..3e53a7a38 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EngineMetrics; @@ -33,10 +34,16 @@ import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.history.GenerationBoundarySignatureSampler; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationKernelRegistry; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.core.compat.PackCompatReport; @@ -59,6 +66,7 @@ import art.arcane.volmlib.util.atomics.AtomicRollingSequence; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; +import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.math.M; @@ -71,17 +79,23 @@ import lombok.Getter; import lombok.Setter; import java.io.File; +import java.io.IOException; import java.nio.file.Path; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.IntConsumer; import java.util.function.Supplier; @Data @@ -96,7 +110,9 @@ public class IrisEngine implements Engine { private final AtomicLong lastGPS; private final EngineTarget target; private final InitializationMode initializationMode; - private final EngineMantle mantle; + private final Path initialMantleStorageDirectory; + private final GenerationKernelRegistry.Version initialKernelVersion; + private final TransitionGenerationPlan initialTransitionPlan; private final ChronoLatch perSecondLatch; private final ChronoLatch perSecondBudLatch; private final EngineMetrics metrics; @@ -108,9 +124,26 @@ public class IrisEngine implements Engine { final Object lifecycleLock = new Object(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) + final Object generationHistoryRuntimeRouterLock = new Object(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) final ThreadLocal runtimeAssembly = new ThreadLocal<>(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) + final GenerationRuntimeScopeState generationRuntimeScopes = new GenerationRuntimeScopeState(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + final Set detachedGenerationRuntimes = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap<>())); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + final Set retiringGenerationRuntimes = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap<>())); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + final Set generationRuntimeRetirementListeners = new CopyOnWriteArraySet<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) final EngineBackgroundTasks backgroundTasks = new EngineBackgroundTasks(); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -147,6 +180,12 @@ public class IrisEngine implements Engine { volatile EngineRuntime runtime; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) + volatile GenerationHistoryRuntimeRouter generationHistoryRuntimeRouter; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private boolean generationHistoryRoutingRequired; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) volatile EngineTarget publishedTarget; private volatile int parallelism; private volatile boolean failing; @@ -176,11 +215,57 @@ public class IrisEngine implements Engine { } public IrisEngine(EngineTarget target, InitializationMode initializationMode) { + this( + target, + initializationMode, + IrisEngineMantle.legacyStorageDirectory(target), + GenerationKernelRegistry.standard().current(), + null); + } + + public IrisEngine( + EngineTarget target, + InitializationMode initializationMode, + Path mantleStorageDirectory + ) { + this( + target, + initializationMode, + mantleStorageDirectory, + GenerationKernelRegistry.standard().current(), + null); + } + + public IrisEngine( + EngineTarget target, + InitializationMode initializationMode, + Path mantleStorageDirectory, + TransitionGenerationPlan transitionPlan + ) { + this( + target, + initializationMode, + mantleStorageDirectory, + GenerationKernelRegistry.standard().current(), + transitionPlan); + } + + public IrisEngine( + EngineTarget target, + InitializationMode initializationMode, + Path mantleStorageDirectory, + GenerationKernelRegistry.Version kernelVersion, + TransitionGenerationPlan transitionPlan + ) { + EngineTarget requiredTarget = Objects.requireNonNull(target, "engine target"); InitializationMode requiredMode = Objects.requireNonNull(initializationMode, "initialization mode"); this.initializationMode = requiredMode; this.studio = requiredMode.studio(); - this.target = target; - this.publishedTarget = target; + this.target = requiredTarget; + this.publishedTarget = requiredTarget; + this.initialMantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory); + this.initialKernelVersion = Objects.requireNonNull(kernelVersion, "generation kernel version"); + this.initialTransitionPlan = transitionPlan; this.platformHooks = IrisServices.get(EnginePlatformHooks.class); this.generationSessions = new GenerationSessionManager(); this.closing = new AtomicBoolean(true); @@ -190,7 +275,8 @@ public class IrisEngine implements Engine { this.failing = false; getEngineData(); verifySeed(); - this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed()); + this.seedManager = EngineRuntimeBuilder.selectRuntimeKernel(initialKernelVersion) + .createSeedManager(requiredTarget.getWorld().getRawWorldSeed()); bud = new AtomicInteger(0); buds = new AtomicInteger(0); metrics = new EngineMetrics(32); @@ -203,9 +289,7 @@ public class IrisEngine implements Engine { wallClock = new AtomicRollingSequence(32); lastGPS = new AtomicLong(M.ms()); generated = new AtomicInteger(0); - long _t0 = M.ms(); - mantle = new IrisEngineMantle(this); - IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - _t0) + "ms"); + long _t0; cleaning = new AtomicBoolean(false); modeFallbackLogged = new AtomicBoolean(false); prefetchSaveStarted = new AtomicBoolean(false); @@ -235,7 +319,7 @@ public class IrisEngine implements Engine { } catch (Throwable e) { IrisLogging.reportError(e); } - IrisLogging.notice("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); + IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); _t0 = M.ms(); phaseStartedAt = System.nanoTime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); @@ -258,7 +342,7 @@ public class IrisEngine implements Engine { EngineTickRegistry.registerTicking(this); } catch (Throwable e) { shutdownSequence.cleanupFailedConstruction(e); - throw new IllegalStateException("Failed to initialize Iris engine for world '" + target.getWorld().name() + "'.", e); + throw new IllegalStateException("Failed to initialize Iris engine for world '" + requiredTarget.getWorld().name() + "'.", e); } IrisLogging.debug("Engine Initialized " + getCacheID()); } @@ -349,7 +433,7 @@ public class IrisEngine implements Engine { IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled."); return; } - runtime.complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot()); + runtime.generation().complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot()); IrisLogging.debug("Enabled shared Studio hydrology cache identity=" + initialIdentity.substring(0, Math.min(12, initialIdentity.length()))); } @@ -451,7 +535,9 @@ public class IrisEngine implements Engine { @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { awaitGenerationCacheWarm(); - try (GenerationSessionLease lease = acquireGenerationLease("matter_generate"); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openGenerationHistoryCoordinateScope(x << 4, z << 4); + GenerationSessionLease lease = acquireGenerationLease("matter_generate"); IrisContext.Scope ignored = IrisContext.open(this, lease.sessionId(), context)) { IrisComplex activeComplex = getComplex(); if (context == null || context.getComplex() != activeComplex) { @@ -463,21 +549,114 @@ public class IrisEngine implements Engine { + context.getGenerationSessionId() + " instead of " + lease.sessionId() + "."); } getMantle().generateMatter(x, z, multicore, context); - } catch (GenerationSessionException e) { + } catch (GenerationSessionException | IOException e) { throw new IllegalStateException("Matter generation was rejected by the Iris lifecycle.", e); } } + @BlockCoordinates @Override - public Set getObjectsAt(int x, int z) { - return getMantle().getObjectComponent().guess(x, z); + public IrisRegion getRegion(int x, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a region")) { + return Engine.super.getRegion(x, z); + } } + @BlockCoordinates @Override - public Set> getPOIsAt(int chunkX, int chunkY) { - Set> pois = new HashSet<>(); - getMantle().getMantle().iterateChunk(chunkX, chunkY, MatterStructurePOI.class, (x, y, z, d) -> pois.add(new Pair<>(d.getType(), new BlockPos(x, y, z)))); - return pois; + public IrisBiome getCaveOrMantleBiome(int x, int y, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave or mantle biome")) { + return Engine.super.getCaveOrMantleBiome(x, y, z); + } + } + + @BlockCoordinates + @Override + public IrisBiome getCaveBiome(int x, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave biome")) { + return Engine.super.getCaveBiome(x, z); + } + } + + @BlockCoordinates + @Override + public IrisBiome getCaveBiome(int x, int y, int z) { + return getCaveBiome(x, y, z, null); + } + + @BlockCoordinates + @Override + public IrisBiome getCaveBiome( + int x, + int y, + int z, + IrisDimensionCarvingResolver.State state + ) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a vertical cave biome")) { + return Engine.super.getCaveBiome(x, y, z, state); + } + } + + @BlockCoordinates + @Override + public IrisBiome getSurfaceBiome(int x, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a surface biome")) { + return Engine.super.getSurfaceBiome(x, z); + } + } + + @BlockCoordinates + @Override + public double getSlope(int x, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve terrain slope")) { + return Engine.super.getSlope(x, z); + } + } + + @BlockCoordinates + @Override + public int getHeight(int x, int z) { + return getHeight(x, z, true); + } + + @BlockCoordinates + @Override + public int getHeight(int x, int z, boolean ignoreFluid) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve terrain height")) { + return Engine.super.getHeight(x, z, ignoreFluid); + } + } + + @ChunkCoordinates + @Override + public Set getObjectsAt(int x, int z) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x << 4, z << 4, "resolve chunk objects")) { + return getMantle().getObjectComponent().guess(x, z); + } + } + + @ChunkCoordinates + @Override + public Set> getPOIsAt(int chunkX, int chunkZ) { + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked( + chunkX << 4, chunkZ << 4, "resolve chunk points of interest")) { + Set> pois = new HashSet<>(); + getMantle().getMantle().iterateChunk( + chunkX, + chunkZ, + MatterStructurePOI.class, + (x, y, z, data) -> pois.add(new Pair<>(data.getType(), new BlockPos(x, y, z)))); + return pois; + } } private void warmupChunk(int x, int z) { @@ -510,7 +689,50 @@ public class IrisEngine implements Engine { if (!nativeStructureVolumeQueriesEnabled.get()) { return NativeStructureVolume.NONE; } - return nativeStructureVolumeMemo.volumes(this, platformHooks, minX, minZ, maxX, maxZ); + int minimumX = Math.min(minX, maxX); + int maximumX = Math.max(minX, maxX); + int minimumZ = Math.min(minZ, maxZ); + int maximumZ = Math.max(minZ, maxZ); + int fromChunkX = minimumX >> 4; + int toChunkX = maximumX >> 4; + int fromChunkZ = minimumZ >> 4; + int toChunkZ = maximumZ >> 4; + KList matches = null; + for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { + for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { + int chunkMinimumX = chunkX << 4; + int chunkMinimumZ = chunkZ << 4; + int queryMinimumX = Math.max(minimumX, chunkMinimumX); + int queryMaximumX = Math.min(maximumX, chunkMinimumX + 15); + int queryMinimumZ = Math.max(minimumZ, chunkMinimumZ); + int queryMaximumZ = Math.min(maximumZ, chunkMinimumZ + 15); + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked( + chunkMinimumX, + chunkMinimumZ, + "resolve native structure volumes")) { + KList resolved = nativeStructureVolumeMemo.volumes( + this, + platformHooks, + queryMinimumX, + queryMinimumZ, + queryMaximumX, + queryMaximumZ); + for (NativeStructureVolume volume : resolved) { + if (!volume.intersectsRect(minimumX, minimumZ, maximumX, maximumZ)) { + continue; + } + if (matches == null) { + matches = new KList<>(); + } + if (!matches.contains(volume)) { + matches.add(volume); + } + } + } + } + } + return matches == null ? NativeStructureVolume.NONE : matches; } public void setNativeStructureVolumeQueriesEnabled(boolean enabled) { @@ -557,6 +779,312 @@ public class IrisEngine implements Engine { shutdownSequence.close(); } + public GenerationHistoryRuntimeRouter attachGenerationHistory( + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler + ) throws IOException { + return GenerationHistoryRuntimeRouter.attachAndPromotePending(this, history, signatureSampler); + } + + public GenerationHistoryRuntimeRouter attachGenerationHistory( + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + int transitionWidthBlocks + ) throws IOException { + return GenerationHistoryRuntimeRouter.attachAndPromotePending( + this, + history, + signatureSampler, + transitionWidthBlocks + ); + } + + public Optional getGenerationHistoryRuntimeRouter() { + synchronized (generationHistoryRuntimeRouterLock) { + return Optional.ofNullable(generationHistoryRuntimeRouter); + } + } + + public GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope( + int blockX, + int blockZ + ) throws IOException { + GenerationHistoryRuntimeRouter router; + synchronized (generationHistoryRuntimeRouterLock) { + router = generationHistoryRuntimeRouter; + if (router == null && generationHistoryRoutingRequired) { + throw new IllegalStateException("Iris generation-history runtime router is detached."); + } + } + return router == null ? null : router.openCoordinateScope(blockX, blockZ); + } + + public boolean hasGenerationRuntimeScope() { + return generationRuntimeScopes.current() != null; + } + + private GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScopeUnchecked( + int blockX, + int blockZ, + String operation + ) { + try { + return openGenerationHistoryCoordinateScope(blockX, blockZ); + } catch (IOException failure) { + throw new IllegalStateException("Unable to " + operation + " through Iris generation history at " + + blockX + "," + blockZ + ".", failure); + } + } + + public void attachGenerationHistoryRuntimeRouter(GenerationHistoryRuntimeRouter router) { + GenerationHistoryRuntimeRouter required = Objects.requireNonNull(router, "generation-history runtime router"); + if (required.engine() != this) { + throw new IllegalArgumentException("Generation-history runtime router belongs to a different Iris engine."); + } + synchronized (lifecycleLock) { + requireRunning("attach a generation-history runtime router"); + synchronized (generationHistoryRuntimeRouterLock) { + if (generationHistoryRuntimeRouter != null) { + throw new IllegalStateException("Iris engine already has a generation-history runtime router."); + } + generationHistoryRuntimeRouter = required; + generationHistoryRoutingRequired = true; + } + } + } + + public void detachGenerationHistoryRuntimeRouter(GenerationHistoryRuntimeRouter router) { + synchronized (generationHistoryRuntimeRouterLock) { + if (generationHistoryRuntimeRouter == router) { + generationHistoryRuntimeRouter = null; + } + } + } + + void closeAttachedGenerationHistoryRuntimeRouter() { + GenerationHistoryRuntimeRouter attached; + synchronized (generationHistoryRuntimeRouterLock) { + attached = generationHistoryRuntimeRouter; + if (attached == null) { + return; + } + } + attached.close(); + synchronized (generationHistoryRuntimeRouterLock) { + if (generationHistoryRuntimeRouter == attached) { + generationHistoryRuntimeRouter = null; + } + } + } + + boolean beginShutdown() { + synchronized (lifecycleLock) { + if (closed) { + return false; + } + lifecycleState = LifecycleState.CLOSING; + closing.set(true); + return true; + } + } + + public GenerationRuntimeBinding getActiveGenerationRuntimeBinding() { + EngineRuntime current = runtimeBuilder.requireRuntime("capture the active generation runtime"); + return new GenerationRuntimeBinding(this, current.generation()); + } + + public GenerationRuntimeBinding captureGenerationRuntimeBinding() { + GenerationRuntimeBinding scoped = generationRuntimeScopes.current(); + return scoped == null ? getActiveGenerationRuntimeBinding() : scoped; + } + + public GenerationRuntimeScope openGenerationRuntimeScope(GenerationRuntimeBinding binding) { + GenerationRuntimeBinding required = Objects.requireNonNull(binding, "generation runtime binding"); + synchronized (lifecycleLock) { + if (required.engine != this) { + throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine."); + } + if (retiringGenerationRuntimes.contains(required.runtime)) { + throw new IllegalStateException("Iris generation runtime binding is retiring."); + } + if (!isGenerationRuntimeBindingLive(required)) { + throw new IllegalStateException("Iris generation runtime binding is closed or no longer owned."); + } + return generationRuntimeScopes.open(required); + } + } + + public GenerationRuntimeBinding buildDetachedGenerationRuntime(EngineTarget runtimeTarget) { + return buildDetachedGenerationRuntime( + runtimeTarget, + IrisEngineMantle.legacyStorageDirectory(runtimeTarget)); + } + + public GenerationRuntimeBinding buildDetachedGenerationRuntime( + EngineTarget runtimeTarget, + Path mantleStorageDirectory + ) { + return buildDetachedGenerationRuntime( + runtimeTarget, + mantleStorageDirectory, + GenerationKernelRegistry.standard().current(), + null); + } + + public GenerationRuntimeBinding buildDetachedGenerationRuntime( + EngineTarget runtimeTarget, + Path mantleStorageDirectory, + TransitionGenerationPlan transitionPlan + ) { + return buildDetachedGenerationRuntime( + runtimeTarget, + mantleStorageDirectory, + GenerationKernelRegistry.standard().current(), + transitionPlan); + } + + public GenerationRuntimeBinding buildDetachedGenerationRuntime( + EngineTarget runtimeTarget, + Path mantleStorageDirectory, + GenerationKernelRegistry.Version kernelVersion, + TransitionGenerationPlan transitionPlan + ) { + EngineTarget requiredTarget = Objects.requireNonNull(runtimeTarget, "detached generation target"); + Path requiredMantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory); + synchronized (lifecycleLock) { + requireRunning("build a detached generation runtime"); + GenerationRuntime active = runtime.generation(); + if (requiredTarget.getWorld() != active.target().getWorld()) { + throw new IllegalArgumentException("Detached generation runtime must target this Iris world."); + } + IrisData detachedData = requiredTarget.getData(); + if (detachedData == active.data()) { + throw new IllegalArgumentException("Detached generation runtime requires a detached Iris data loader."); + } + if (requiredMantleStorageDirectory.equals(active.mantleStorageDirectory())) { + throw new IllegalArgumentException("Detached generation runtime requires an exclusive mantle storage directory."); + } + for (GenerationRuntime detached : detachedGenerationRuntimes) { + if (detached.data() == detachedData) { + throw new IllegalArgumentException("Detached Iris data loader already belongs to a generation runtime."); + } + if (detached.mantleStorageDirectory().equals(requiredMantleStorageDirectory)) { + throw new IllegalArgumentException("Mantle storage directory already belongs to a detached generation runtime."); + } + } + GenerationRuntime detached = null; + try { + detachedData.registerEngine(this); + GenerationKernelRegistry.Version requiredKernelVersion = Objects.requireNonNull( + kernelVersion, + "generation kernel version"); + detached = transitionPlan == null + && requiredKernelVersion.equals(GenerationKernelRegistry.standard().current()) + ? runtimeBuilder.buildDetachedGenerationRuntime( + requiredTarget, + requiredMantleStorageDirectory) + : runtimeBuilder.buildDetachedGenerationRuntime( + requiredTarget, + requiredMantleStorageDirectory, + requiredKernelVersion, + transitionPlan); + detachedGenerationRuntimes.add(detached); + return new GenerationRuntimeBinding(this, detached); + } catch (Throwable e) { + Throwable cleanupFailure = detached == null + ? shutdownSequence.closeDetachedTarget(requiredTarget, null) + : shutdownSequence.closeDetachedGenerationRuntime(detached, null); + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + throw EngineShutdownSequence.propagate(e); + } + } + } + + public void closeDetachedGenerationRuntime(GenerationRuntimeBinding binding) { + GenerationRuntimeBinding required = Objects.requireNonNull(binding, "detached generation runtime binding"); + GenerationRuntime retired; + synchronized (lifecycleLock) { + if (required.engine != this) { + throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine."); + } + EngineRuntime current = runtime; + if (current != null && current.generation() == required.runtime) { + throw new IllegalArgumentException("The active Iris generation runtime cannot be closed as detached."); + } + if (!detachedGenerationRuntimes.contains(required.runtime)) { + return; + } + if (!retiringGenerationRuntimes.add(required.runtime)) { + throw new IllegalStateException("Detached Iris generation runtime is already retiring."); + } + retired = required.runtime; + } + Throwable failure = retireGenerationRuntimeCaches(retired.cacheId(), null); + try { + failure = shutdownSequence.closeDetachedGenerationRuntime(retired, failure); + } finally { + synchronized (lifecycleLock) { + detachedGenerationRuntimes.remove(retired); + retiringGenerationRuntimes.remove(retired); + } + } + if (failure != null) { + throw new IllegalStateException("Failed to close a detached Iris generation runtime.", failure); + } + } + + public void addGenerationRuntimeRetirementListener(IntConsumer listener) { + generationRuntimeRetirementListeners.add(Objects.requireNonNull(listener, "generation runtime retirement listener")); + } + + public void removeGenerationRuntimeRetirementListener(IntConsumer listener) { + generationRuntimeRetirementListeners.remove(listener); + } + + Throwable retireGenerationRuntimeCaches(int runtimeId, Throwable failure) { + nativeStructureVolumeMemo.evictRuntime(runtimeId); + for (IntConsumer listener : generationRuntimeRetirementListeners) { + try { + listener.accept(runtimeId); + } catch (Throwable listenerFailure) { + if (failure == null) { + failure = listenerFailure; + } else if (failure != listenerFailure) { + failure.addSuppressed(listenerFailure); + } + } + } + return failure; + } + + public void setDefaultGenerationRuntime(GenerationRuntimeBinding binding) { + GenerationRuntimeBinding required = Objects.requireNonNull(binding, "generation runtime binding"); + synchronized (lifecycleLock) { + requireRunning("select the default generation runtime"); + if (required.engine != this) { + throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine."); + } + EngineRuntime current = runtime; + GenerationRuntime previous = current.generation(); + if (previous == required.runtime) { + return; + } + if (!detachedGenerationRuntimes.contains(required.runtime)) { + throw new IllegalStateException("Default Iris generation runtime must be live and engine-owned."); + } + if (retiringGenerationRuntimes.contains(required.runtime)) { + throw new IllegalStateException("Default Iris generation runtime cannot be retiring."); + } + detachedGenerationRuntimes.add(previous); + detachedGenerationRuntimes.remove(required.runtime); + runtime = current.withGeneration(required.runtime); + publishedTarget = required.runtime.target(); + nativeStructureVolumeMemo.clear(); + } + } + private boolean isPregeneratorActiveForThisWorld() { return platformHooks.isPregeneratorActive(this); } @@ -572,13 +1100,23 @@ public class IrisEngine implements Engine { } } + @Override + public SeedManager getSeedManager() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.seedManager; + } + GenerationRuntime current = selectedGenerationRuntime(); + return current == null ? seedManager : current.seedManager(); + } + @Override public IrisComplex getComplex() { RuntimeAssembly assembly = runtimeAssembly.get(); if (assembly != null && assembly.complex != null) { return assembly.complex; } - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? null : current.complex(); } @@ -588,17 +1126,47 @@ public class IrisEngine implements Engine { if (assembly != null) { return assembly.target; } - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? publishedTarget : current.target(); } + @Override + public IrisData getData() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.target.getData(); + } + GenerationRuntime current = selectedGenerationRuntime(); + return current == null ? publishedTarget.getData() : current.data(); + } + + @Override + public IrisDimension getDimension() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.target.getDimension(); + } + GenerationRuntime current = selectedGenerationRuntime(); + return current == null ? publishedTarget.getDimension() : current.dimension(); + } + + @Override + public EngineMantle getMantle() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null && assembly.mantle != null) { + return assembly.mantle; + } + GenerationRuntime current = selectedGenerationRuntime(); + return current == null ? null : current.mantle(); + } + @Override public EngineMode getMode() { RuntimeAssembly assembly = runtimeAssembly.get(); if (assembly != null && assembly.mode != null) { return assembly.mode; } - EngineRuntime current = runtimeBuilder.requireRuntime("access the engine mode"); + GenerationRuntime current = requireSelectedGenerationRuntime("access the engine mode"); return current.mode(); } @@ -628,7 +1196,7 @@ public class IrisEngine implements Engine { if (assembly != null) { return assembly.upperContext; } - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? null : current.upperContext(); } @@ -638,25 +1206,25 @@ public class IrisEngine implements Engine { if (assembly != null && assembly.hash32 != null) { return assembly.hash32; } - EngineRuntime current = runtimeBuilder.requireRuntime("access the pack hash"); + GenerationRuntime current = requireSelectedGenerationRuntime("access the pack hash"); return current.hash32(); } @Override public double getMaxBiomeObjectDensity() { - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? 0D : current.biomeMaxes().objectDensity(); } @Override public double getMaxBiomeLayerDensity() { - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? 0D : current.biomeMaxes().layerDensity(); } @Override public double getMaxBiomeDecoratorDensity() { - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? 0D : current.biomeMaxes().decoratorDensity(); } @@ -703,7 +1271,9 @@ public class IrisEngine implements Engine { @Override public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException { awaitGenerationCacheWarm(); - try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate"); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openGenerationHistoryCoordinateScope(x, z); + GenerationSessionLease lease = acquireGenerationLease("chunk_generate"); IrisContext.Scope generationScope = IrisContext.open(this, lease.sessionId(), null)) { getEngineData().getStatistics().generatedChunk(); PrecisionStopwatch p = PrecisionStopwatch.start(); @@ -796,10 +1366,58 @@ public class IrisEngine implements Engine { if (assembly != null) { return assembly.cacheId; } - EngineRuntime current = runtime; + GenerationRuntime current = selectedGenerationRuntime(); return current == null ? -1 : current.cacheId(); } + private GenerationRuntime selectedGenerationRuntime() { + GenerationRuntimeBinding scoped = generationRuntimeScopes.current(); + if (scoped != null && isGenerationRuntimeBindingLive(scoped)) { + return scoped.runtime; + } + EngineRuntime current = runtime; + return current == null ? null : current.generation(); + } + + public GenerationKernelRegistry.Version getGenerationKernelVersion() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.kernelVersion; + } + return requireSelectedGenerationRuntime("resolve the generation kernel version").kernelVersion(); + } + + public GenerationKernelRegistry.RuntimeKernel getGenerationRuntimeKernel() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.runtimeKernel; + } + return requireSelectedGenerationRuntime("resolve the executable generation kernel").runtimeKernel(); + } + + public TransitionGenerationPlan getTransitionGenerationPlan() { + RuntimeAssembly assembly = runtimeAssembly.get(); + if (assembly != null) { + return assembly.transitionPlan; + } + return requireSelectedGenerationRuntime("resolve the generation transition plan").transitionPlan(); + } + + private boolean isGenerationRuntimeBindingLive(GenerationRuntimeBinding binding) { + EngineRuntime current = runtime; + return (current != null && current.generation() == binding.runtime) + || detachedGenerationRuntimes.contains(binding.runtime); + } + + private GenerationRuntime requireSelectedGenerationRuntime(String operation) { + GenerationRuntime current = selectedGenerationRuntime(); + if (current == null) { + throw new IllegalStateException("Cannot " + operation + " without an active Iris runtime for " + + getWorld().name() + "."); + } + return current; + } + void requireRunning(String operation) { if (closed || closing.get() || lifecycleState != LifecycleState.RUNNING || runtime == null) { throw new IllegalStateException("Cannot " + operation + " while Iris engine " + getWorld().name() @@ -853,4 +1471,71 @@ public class IrisEngine implements Engine { } } + public static final class GenerationRuntimeBinding { + private final IrisEngine engine; + private final GenerationRuntime runtime; + + GenerationRuntimeBinding(IrisEngine engine, GenerationRuntime runtime) { + this.engine = Objects.requireNonNull(engine, "Iris engine"); + this.runtime = Objects.requireNonNull(runtime, "generation runtime"); + } + + GenerationRuntime generationRuntime() { + return runtime; + } + + public TransitionGenerationPlan transitionPlan() { + return runtime.transitionPlan(); + } + + public GenerationKernelRegistry.Version kernelVersion() { + return runtime.kernelVersion(); + } + + public GenerationKernelRegistry.RuntimeKernel runtimeKernel() { + return runtime.runtimeKernel(); + } + + public EngineTarget target() { + return runtime.target(); + } + + public Path mantleStorageDirectory() { + return runtime.mantleStorageDirectory(); + } + + public int runtimeId() { + return runtime.cacheId(); + } + } + + public static final class GenerationRuntimeScope implements AutoCloseable { + private final GenerationRuntimeScopeState state; + private final Thread owner; + private final GenerationRuntimeBinding previous; + private final GenerationRuntimeBinding installed; + private boolean closed; + + GenerationRuntimeScope( + GenerationRuntimeScopeState state, + Thread owner, + GenerationRuntimeBinding previous, + GenerationRuntimeBinding installed + ) { + this.state = state; + this.owner = owner; + this.previous = previous; + this.installed = installed; + } + + @Override + public void close() { + if (closed) { + return; + } + state.close(owner, previous, installed); + closed = true; + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index fca4c2fae..37e2a55fe 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.engine.mantle.MantlePass; @@ -63,11 +64,13 @@ import lombok.ToString; import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; @Data @@ -76,6 +79,9 @@ import java.util.function.Supplier; public class IrisEngineMantle implements EngineMantle { public static final String STORAGE_FOLDER_NAME = "mantle-hydrology"; private final Engine engine; + private final Path storageDirectory; + @Getter(AccessLevel.NONE) + private final AtomicReference runtimeData; private final Mantle mantle; @Getter(AccessLevel.NONE) private final KMap> components; @@ -85,15 +91,32 @@ public class IrisEngineMantle implements EngineMantle { private final MantleObjectComponent object; public IrisEngineMantle(Engine engine) { - this.engine = engine; - this.mantle = createMantle(engine); + this(engine, legacyStorageDirectory(engine.getTarget())); + } + + public IrisEngineMantle(Engine engine, Path storageDirectory) { + this.engine = Objects.requireNonNull(engine, "Iris engine"); + this.storageDirectory = normalizeStorageDirectory(storageDirectory); + this.runtimeData = new AtomicReference<>(Objects.requireNonNull(engine.getData(), "Iris mantle data")); + this.mantle = createMantle(engine, this.storageDirectory, runtimeData::get); components = new KMap<>(); - registerComponent(new MantleCarvingComponent(this)); - registerComponent(new MantleHydrologyComponent(this)); - object = new MantleObjectComponent(this); - registerComponent(object); - registerComponent(new MantleFloatingObjectComponent(this)); - registerComponent(new IrisStructureComponent(this)); + MantleObjectComponent createdObject; + try { + registerComponent(new MantleCarvingComponent(this)); + registerComponent(new MantleHydrologyComponent(this)); + createdObject = new MantleObjectComponent(this); + registerComponent(createdObject); + registerComponent(new MantleFloatingObjectComponent(this)); + registerComponent(new IrisStructureComponent(this)); + } catch (Throwable failure) { + try { + mantle.close(); + } catch (Throwable closeFailure) { + failure.addSuppressed(closeFailure); + } + throw EngineShutdownSequence.propagate(failure); + } + object = createdObject; } @Override @@ -163,6 +186,7 @@ public class IrisEngineMantle implements EngineMantle { @Override public void hotload() { + runtimeData.set(Objects.requireNonNull(engine.getData(), "Iris mantle data")); disabledFlags.reset(); for (MantleComponent component : registeredComponents.values()) { component.hotload(); @@ -195,11 +219,24 @@ public class IrisEngineMantle implements EngineMantle { return object; } - private static Mantle createMantle(Engine engine) { + static Path legacyStorageDirectory(EngineTarget target) { + EngineTarget requiredTarget = Objects.requireNonNull(target, "Iris engine target"); + return normalizeStorageDirectory(requiredTarget.getWorld().worldFolder().toPath().resolve(STORAGE_FOLDER_NAME)); + } + + static Path normalizeStorageDirectory(Path storageDirectory) { + return Objects.requireNonNull(storageDirectory, "mantle storage directory").toAbsolutePath().normalize(); + } + + private static Mantle createMantle( + Engine engine, + Path storageDirectory, + Supplier dataSupplier + ) { IrisMatterSupport.ensureRegistered(); - File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME); + File dataFolder = storageDirectory.toFile(); int worldHeight = engine.getTarget().getHeight(); - MantleDataAdapter adapter = createDataAdapter(engine::getData); + MantleDataAdapter adapter = createDataAdapter(dataSupplier); MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name())); art.arcane.volmlib.util.mantle.Mantle.RegionIO> regionIO = createRegionIO(dataFolder, worldHeight, adapter, hooks); diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index 231c6112f..d03266134 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -3,6 +3,8 @@ package art.arcane.iris.engine; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationBlend; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; @@ -31,24 +33,30 @@ public class UpperDimensionContext implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private final IrisDimension dimension; private final IrisData data; - private final int chunkHeight; private final ProceduralStream heightStream; + private final ProceduralStream lowerHeightStream; + private final ProceduralStream unblendedLowerHeightStream; private final ProceduralStream biomeStream; private final ProceduralStream regionStream; private final ProceduralStream rockStream; private final IrisImageMapRuntime imageMapRuntime; private final boolean selfReferencing; + private final TransitionGenerationPlan transitionPlan; + private final CeilingLayout ceilingLayout; private UpperDimensionContext(ContextState state) { this.dimension = state.dimension(); this.data = state.data(); - this.chunkHeight = state.chunkHeight(); this.heightStream = state.heightStream(); + this.lowerHeightStream = state.lowerHeightStream(); + this.unblendedLowerHeightStream = state.unblendedLowerHeightStream(); this.biomeStream = state.biomeStream(); this.regionStream = state.regionStream(); this.rockStream = state.rockStream(); this.imageMapRuntime = state.imageMapRuntime(); this.selfReferencing = state.selfReferencing(); + this.transitionPlan = state.transitionPlan(); + this.ceilingLayout = new CeilingLayout(state.chunkHeight(), state.minimumGap()); } public static UpperDimensionContext create(Engine engine, IrisDimension upperDim) { @@ -66,12 +74,16 @@ public class UpperDimensionContext implements DataProvider { engine.getDimension(), engine.getData(), chunkHeight, - complex.getNaturalHeightStream(), - complex.getNaturalTrueBiomeStream(), + complex.getUnblendedNaturalHeightStream(), + complex.getHeightStream(), + complex.getUnblendedNaturalHeightStream(), + complex.getUnblendedNaturalTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), complex.getImageMapRuntime(), - true + true, + complex.getTransitionGenerationPlan(), + engine.getDimension().getUpperDimensionGap() )); } @@ -285,11 +297,15 @@ public class UpperDimensionContext implements DataProvider { upperData, chunkHeight, heightStream, + engine.getComplex().getHeightStream(), + engine.getComplex().getUnblendedNaturalHeightStream(), finalBiomeStream, regionStream, rockStream, imageMapRuntime, - false + false, + engine.getComplex().getTransitionGenerationPlan(), + engine.getDimension().getUpperDimensionGap() )); } @@ -401,9 +417,35 @@ public class UpperDimensionContext implements DataProvider { remainingDepth - 1); } - public int getUpperSurfaceY(int x, int z) { - double rawHeight = heightStream.get((double) x, (double) z); - return chunkHeight - 1 - (int) Math.round(rawHeight); + public int getEffectiveSurfaceY(int x, int z) { + double depth = Math.max(0D, Math.min(ceilingLayout.height() - 1D, heightStream.getDouble(x, z))); + if (transitionPlan != null) { + TransitionGenerationPlan.TerrainSample sample = transitionPlan.terrainSampleAt(x, z); + if (sample.newEpochWeight() < 1D && sample.hasHistoricalSignature()) { + int targetLowerSurfaceY = (int) Math.min(ceilingLayout.height(), + Math.round(unblendedLowerHeightStream.getDouble(x, z))); + int targetSurfaceY = effectiveSurfaceY(depth, ceilingLayout, targetLowerSurfaceY); + double targetDepth = Math.max(0D, ceilingLayout.height() - 1D - targetSurfaceY); + depth = blendCeilingDepth(targetDepth, sample); + } + } + int lowerSurfaceY = (int) Math.min(ceilingLayout.height(), Math.round(lowerHeightStream.getDouble(x, z))); + return effectiveSurfaceY(depth, ceilingLayout, lowerSurfaceY); + } + + static double blendCeilingDepth(double newDepth, TransitionGenerationPlan.TerrainSample sample) { + if (sample.newEpochWeight() == 1D || !sample.hasHistoricalSignature()) { + return newDepth; + } + return GenerationBlend.interpolate( + sample.historicalUpperCeilingDepth(), newDepth, sample.newEpochWeight()); + } + + static int effectiveSurfaceY(double depth, CeilingLayout layout, int lowerSurfaceY) { + long mirroredSurface = layout.height() - 1L - Math.round(depth); + long gapSurface = (long) lowerSurfaceY + layout.minimumGap(); + long surface = Math.max(0L, Math.max(mirroredSurface, gapSurface)); + return surface >= layout.height() - 1L ? layout.height() : (int) surface; } public IrisBiome getUpperBiome(int x, int z) { @@ -440,11 +482,23 @@ public class UpperDimensionContext implements DataProvider { IrisData data, int chunkHeight, ProceduralStream heightStream, + ProceduralStream lowerHeightStream, + ProceduralStream unblendedLowerHeightStream, ProceduralStream biomeStream, ProceduralStream regionStream, ProceduralStream rockStream, IrisImageMapRuntime imageMapRuntime, - boolean selfReferencing + boolean selfReferencing, + TransitionGenerationPlan transitionPlan, + int minimumGap ) { } + + record CeilingLayout(int height, int minimumGap) { + CeilingLayout { + if (height <= 0 || minimumGap < 0) { + throw new IllegalArgumentException("Upper ceiling height must be positive and gap non-negative"); + } + } + } } 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 0bc50bcec..aaeb6755e 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 @@ -18,6 +18,7 @@ package art.arcane.iris.engine.actuator; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.engine.object.IrisBiome; @@ -38,10 +39,13 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; +import java.util.Optional; + 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<>(); public IrisBiomeActuator(Engine engine) { super(engine, "Biome"); @@ -60,33 +64,63 @@ public class IrisBiomeActuator extends EngineAssignedActuator { Engine engine = getEngine(); Mantle mantle = engine.getMantle().getMantle(); ChunkedDataCache biomeCache = context.getBiome(); + IrisComplex complex = context.getComplex(); for (int xf = 0; xf < width; xf++) { IrisBiome ib; for (int zf = 0; zf < depth; zf++) { + int worldX = x + xf; + int worldZ = z + zf; + if (!complex.allowsNewDiscreteContentAt(worldX, worldZ)) { + Optional historicalKey = complex.historicalPhysicalBiomeKeyAt( + worldX, + 0, + worldZ); + if (historicalKey.isPresent()) { + writeColumn( + h, + mantle, + xf, + zf, + worldX, + worldZ, + height, + resolvePhysicalKey(historicalKey.get())); + } + continue; + } ib = biomeCache.get(xf, zf); String key; if (ib.isCustom()) { - IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf); - key = getDimension().getLoadKey() + ":" + custom.getId(); + IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, worldX, 0, worldZ); + key = engine.getData().customBiomeResourceKey(engine.getDimension(), custom); } else { - key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf); + key = ib.getSkyBiomeKey(rng, engine, worldX, 0, worldZ); } - ResolvedBiome resolved = resolve(key); - PlatformBiome biome = resolved.biome(); - - if (biome != null) { - h.set(xf, 0, zf, xf, height - 1, zf, biome); - } - - mantle.set(x + xf, 0, z + zf, resolved.matter()); + writeColumn(h, mantle, xf, zf, worldX, worldZ, height, resolve(key)); } } engine.getMetrics().getBiome().put(p.getMilliseconds()); } + private void writeColumn( + Hunk output, + Mantle mantle, + int localX, + int localZ, + int worldX, + int worldZ, + int height, + ResolvedBiome resolved + ) { + if (resolved.biome() != null) { + output.set(localX, 0, localZ, localX, height - 1, localZ, resolved.biome()); + } + mantle.set(worldX, 0, worldZ, resolved.matter()); + } + /** * The registry lookup and the biome id only depend on the scatter resolved key, so cache the pair * instead of paying two string keyed lookups per column. Unresolved biomes are never cached, so a @@ -110,6 +144,19 @@ public class IrisBiomeActuator extends EngineAssignedActuator { return resolved; } + private ResolvedBiome resolvePhysicalKey(String key) { + ResolvedBiome cached = resolvedPhysicalBiomes.get(key); + if (cached != null) { + return cached; + } + PlatformBiome biome = IrisPlatforms.get().registries().biome(key); + ResolvedBiome resolved = new ResolvedBiome(biome, BiomeInjectMatter.get(key)); + if (biome != null) { + resolvedPhysicalBiomes.put(key, resolved); + } + return resolved; + } + private record ResolvedBiome(PlatformBiome biome, MatterBiomeInject matter) { } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index c57cd9597..441967091 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -163,12 +163,13 @@ public class IrisDecorantActuator extends EngineAssignedActuator { + IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine ? irisEngine : null; + IrisEngine.GenerationRuntimeBinding generationRuntime = generationEngine == null + ? null + : generationEngine.captureGenerationRuntimeBinding(); BurstExecutor e = burst().burst(stages.length); e.setMulticore(multicore); // BurstExecutor.complete() logs-and-swallows stage failures; without re-propagation @@ -52,7 +57,10 @@ public interface EngineMode extends Staged { if (failure.get() != null) { return; } - try (IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) { + try (IrisEngine.GenerationRuntimeScope runtimeScope = generationEngine == null + ? null + : generationEngine.openGenerationRuntimeScope(generationRuntime); + IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) { i.generate(x, z, blocks, biomes, multicore, ctx); } catch (Throwable t) { failure.compareAndSet(null, t); @@ -98,8 +106,16 @@ public interface EngineMode extends Staged { inline.generate(x, z, blocks, biomes, false, ctx); return; } + IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine ? irisEngine : null; + IrisEngine.GenerationRuntimeBinding generationRuntime = generationEngine == null + ? null + : generationEngine.captureGenerationRuntimeBinding(); java.util.concurrent.CompletableFuture background = burst().completeValueAsync(() -> { - fanOut.generate(x, z, blocks, biomes, true, ctx); + try (IrisEngine.GenerationRuntimeScope runtimeScope = generationEngine == null + ? null + : generationEngine.openGenerationRuntimeScope(generationRuntime)) { + fanOut.generate(x, z, blocks, biomes, true, ctx); + } return null; }); Throwable inlineFailure = null; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index 00bec61ba..45bc6c56d 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -34,6 +34,7 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.ArrayDeque; +import java.util.Comparator; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -106,86 +107,144 @@ public final class HintedLocator implements Locator { return exact.matches(engine, chunk); } + @Override + public boolean matchesForSearch(Engine engine, Position2 chunk) { + return exact.matchesForSearch(engine, chunk); + } + + @Override + public Position2 nearestRecorded(Engine engine, Position2 origin, int maxChunkRadius) { + return exact.nearestRecorded(engine, origin, maxChunkRadius); + } + + @Override + public SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) { + return exact.nearestRecordedCandidate(engine, origin, maxChunkRadius); + } + + @Override + public SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) { + return exact.candidateForMatchedChunk(engine, chunk); + } + @Override public CompletableFuture find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { if (engine.isClosed()) { throw new WrongEngineBroException(); } + SearchCandidate recorded = nearestRecordedCandidate(engine, pos, MAX_SAMPLE_RADIUS_CHUNKS); + int activeRadius = Locator.activeSearchRadius(pos, recorded, MAX_SAMPLE_RADIUS_CHUNKS); + AtomicBoolean stop = new AtomicBoolean(false); CompletableFuture search = MultiBurst.burst.completeValueAsync(() -> { try (GenerationSessionLease lease = engine.acquireGenerationLease("hinted_locator_search"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { SearchPlan plan = planner.apply(engine); - if (!plan.isPossible()) { - return null; - } - - return search(engine, plan, pos, timeout, checks, stop); + SearchCandidate nearest = search(engine, plan, pos, activeRadius, recorded, timeout, checks, stop); + return nearest == null ? null : nearest.chunk(); } }); return LocatorCanceller.requestScoped(search, stop); } - private Position2 search(Engine engine, SearchPlan plan, Position2 pos, long timeout, Consumer checks, AtomicBoolean stop) { + private SearchCandidate search( + Engine engine, + SearchPlan plan, + Position2 pos, + int maximumRadius, + SearchCandidate recorded, + long timeout, + Consumer checks, + AtomicBoolean stop + ) { + if (!plan.isPossible() + && !GenerationLocatorPolicy.hasHistoricalFallbackInSquare(engine, pos, maximumRadius)) { + return recorded; + } Locator verifier = plan.getExactOverride() != null ? plan.getExactOverride() : exact; int stride = Math.max(1, plan.getStrideChunks()); - int batchTarget = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32; - int maxRing = Math.max(1, MAX_SAMPLE_RADIUS_CHUNKS / stride); + int batchTarget = Math.max(1, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32); + int maxRing = Math.max(0, (maximumRadius + stride - 1) / stride + 1); PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); AtomicInteger covered = new AtomicInteger(); + AtomicReference nearest = new AtomicReference<>(recorded); KList batch = new KList<>(); for (int ring = 0; ring <= maxRing; ring++) { if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { - return null; + return nearest.get(); } - appendRing(batch, pos, ring, stride); - - if (batch.size() < batchTarget && ring < maxRing) { - continue; + int cells = ring == 0 ? 1 : ring * 8; + for (int index = 0; index < cells; index++) { + Position2 sample = ringPosition(pos, ring, stride, index); + if (sample == null) { + continue; + } + batch.add(sample); + if (batch.size() < batchTarget) { + continue; + } + processBatch(engine, plan, verifier, batch, pos, maximumRadius, nearest, + stride, timeout, stopwatch, covered, checks, stop); + batch.clear(); + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { + return nearest.get(); + } } - Position2 result = processBatch(engine, plan, verifier, batch, stride, timeout, stopwatch, covered, checks, stop); - batch.clear(); - - if (result != null) { - return result; + int radius = Locator.activeSearchRadius(pos, nearest.get(), maximumRadius); + if (ring == maxRing || nearest.get() != null && (long) ring * stride > radius) { + if (!batch.isEmpty()) { + processBatch(engine, plan, verifier, batch, pos, maximumRadius, nearest, + stride, timeout, stopwatch, covered, checks, stop); + } + return nearest.get(); } } - return null; + return nearest.get(); } - private Position2 processBatch(Engine engine, SearchPlan plan, Locator verifier, KList batch, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicInteger covered, Consumer checks, AtomicBoolean stop) { + private void processBatch(Engine engine, SearchPlan plan, Locator verifier, KList batch, + Position2 origin, int maximumRadius, AtomicReference nearest, + int stride, long timeout, PrecisionStopwatch stopwatch, AtomicInteger covered, + Consumer checks, AtomicBoolean stop) { + batch.sort(Comparator.comparingLong(sample -> chunkDistanceSquared(origin, sample))); int size = batch.size(); boolean[] hits = new boolean[size]; boolean fine = stride <= 1; CoarseSample coarse = plan.getCoarse(); - AtomicBoolean matched = new AtomicBoolean(false); + int radius = Locator.activeSearchRadius(origin, nearest.get(), maximumRadius); BurstExecutor executor = MultiBurst.burst.burst(size); for (int i = 0; i < size; i++) { int index = i; Position2 sample = batch.get(i); executor.queue(() -> { - if (stop.get() || engine.isClosing() || (fine && matched.get())) { + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout + || !intersectsSearchRadius(origin, sample, fine ? 0 : stride, radius)) { return; } int blockX = (sample.getX() << 4) + 8; int blockZ = (sample.getZ() << 4) + 8; + boolean historicalFallback = GenerationLocatorPolicy.hasHistoricalFallbackInSquare( + engine, + sample, + fine ? 0 : stride + ); - if (coarse != null && !coarse.test(blockX, blockZ)) { + if (!historicalFallback && (!plan.isPossible() + || coarse != null && !coarse.test(blockX, blockZ))) { return; } if (fine) { - if (verifier.matches(engine, sample)) { - hits[index] = true; - matched.set(true); + if (verifier.matchesForSearch(engine, sample)) { + recordCandidate(engine, sample, origin, nearest); } return; @@ -196,86 +255,107 @@ public final class HintedLocator implements Locator { } executor.complete(); - covered.addAndGet(size * stride * stride); + covered.updateAndGet(value -> (int) Math.min(Integer.MAX_VALUE, + (long) value + (long) size * stride * stride)); checks.accept(covered.get()); + if (fine) { + return; + } + for (int i = 0; i < size; i++) { if (!hits[i]) { continue; } - if (fine) { - return batch.get(i); - } - if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { - return null; + return; } - Position2 refined = refine(engine, verifier, batch.get(i), stride, timeout, stopwatch, stop); - - if (refined != null) { - return refined; + int searchRadius = Locator.activeSearchRadius(origin, nearest.get(), maximumRadius); + if (intersectsSearchRadius(origin, batch.get(i), stride, searchRadius)) { + refine(engine, verifier, batch.get(i), origin, searchRadius, nearest, + stride, timeout, stopwatch, stop); } } - - return null; } - private Position2 refine(Engine engine, Locator verifier, Position2 candidate, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicBoolean stop) { + private void refine(Engine engine, Locator verifier, Position2 candidate, Position2 origin, + int searchRadius, AtomicReference nearest, + int stride, long timeout, PrecisionStopwatch stopwatch, AtomicBoolean stop) { KList cells = new KList<>(); for (int ring = 0; ring <= stride; ring++) { if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) { - return null; + return; } cells.clear(); appendRing(cells, candidate, ring, 1); - AtomicReference found = new AtomicReference<>(); BurstExecutor executor = MultiBurst.burst.burst(cells.size()); for (Position2 cell : cells) { executor.queue(() -> { - if (stop.get() || engine.isClosing() || found.get() != null) { + if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout + || !intersectsSearchRadius(origin, cell, 0, searchRadius)) { return; } - if (verifier.matches(engine, cell)) { - found.compareAndSet(null, cell); + if (verifier.matchesForSearch(engine, cell)) { + recordCandidate(engine, cell, origin, nearest); } }); } executor.complete(); - if (found.get() != null) { - return found.get(); - } } + } - return null; + private void recordCandidate(Engine engine, Position2 chunk, Position2 origin, + AtomicReference nearest) { + SearchCandidate candidate = candidateForMatchedChunk(engine, chunk); + if (candidate != null) { + Locator.updateNearest(nearest, candidate, origin); + } + } + + private static long chunkDistanceSquared(Position2 origin, Position2 sample) { + long deltaX = (long) origin.getX() - sample.getX(); + long deltaZ = (long) origin.getZ() - sample.getZ(); + return deltaX * deltaX + deltaZ * deltaZ; + } + + private static boolean intersectsSearchRadius(Position2 origin, Position2 sample, int extent, int radius) { + return Math.abs((long) sample.getX() - origin.getX()) <= (long) radius + extent + && Math.abs((long) sample.getZ() - origin.getZ()) <= (long) radius + extent; } static void appendRing(KList batch, Position2 origin, int ring, int stride) { + int cells = ring == 0 ? 1 : ring * 8; + for (int index = 0; index < cells; index++) { + Position2 position = ringPosition(origin, ring, stride, index); + if (position != null) { + batch.add(position); + } + } + } + + private static Position2 ringPosition(Position2 origin, int ring, int stride, int index) { if (ring == 0) { - batch.add(origin); - return; + return origin; } - - int offset = ring * stride; - - for (int dx = -ring; dx <= ring; dx++) { - int x = origin.getX() + (dx * stride); - batch.add(new Position2(x, origin.getZ() - offset)); - batch.add(new Position2(x, origin.getZ() + offset)); - } - - for (int dz = -ring + 1; dz <= ring - 1; dz++) { - int z = origin.getZ() + (dz * stride); - batch.add(new Position2(origin.getX() - offset, z)); - batch.add(new Position2(origin.getX() + offset, z)); + int horizontalCells = (ring * 2 + 1) * 2; + int deltaX = index < horizontalCells ? index / 2 - ring : ((index & 1) == 0 ? -ring : ring); + int deltaZ = index < horizontalCells ? ((index & 1) == 0 ? -ring : ring) + : (index - horizontalCells) / 2 - ring + 1; + long chunkX = (long) origin.getX() + (long) deltaX * stride; + long chunkZ = (long) origin.getZ() + (long) deltaZ * stride; + if (chunkX < (Integer.MIN_VALUE >> 4) || chunkX > (Integer.MAX_VALUE >> 4) + || chunkZ < (Integer.MIN_VALUE >> 4) || chunkZ > (Integer.MAX_VALUE >> 4)) { + return null; } + return new Position2((int) chunkX, (int) chunkZ); } public static SearchPlan biomePlan(Engine engine, String biomeKey) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HistoryAwareLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HistoryAwareLocator.java new file mode 100644 index 000000000..ff7001800 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/HistoryAwareLocator.java @@ -0,0 +1,227 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.history.ChunkGenerationSemantics; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationSemanticIndex; +import art.arcane.volmlib.util.math.Position2; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +final class HistoryAwareLocator implements Locator { + private final GenerationSemanticIndex.SemanticKind kind; + private final String key; + private final Locator active; + + HistoryAwareLocator( + GenerationSemanticIndex.SemanticKind kind, + String key, + Locator active + ) { + this.kind = Objects.requireNonNull(kind, "kind"); + String requiredKey = Objects.requireNonNull(key, "key"); + if (requiredKey.isBlank()) { + throw new IllegalArgumentException("Locator key cannot be blank."); + } + this.key = requiredKey; + this.active = Objects.requireNonNull(active, "active locator"); + } + + @Override + public boolean matches(Engine engine, Position2 chunk) { + Optional recorded = GenerationLocatorPolicy.recorded(engine, chunk); + if (recorded.isPresent()) { + return contains(recorded.get()); + } + if (!GenerationLocatorPolicy.allowsProceduralPrediction(engine, chunk)) { + return false; + } + return GenerationLocatorPolicy.evaluateScoped( + engine, + chunk, + () -> active.matches(engine, chunk) + ); + } + + @Override + public boolean matchesForSearch(Engine engine, Position2 chunk) { + if (GenerationLocatorPolicy.recorded(engine, chunk).isPresent() + || !GenerationLocatorPolicy.allowsProceduralPrediction(engine, chunk)) { + return false; + } + return GenerationLocatorPolicy.evaluateScoped( + engine, + chunk, + () -> active.matches(engine, chunk) + ); + } + + @Override + public SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) { + GenerationHistory history = GenerationLocatorPolicy.history(engine).orElse(null); + if (history == null) { + return null; + } + GenerationSemanticIndex.Match match = history.findRecorded( + GenerationSemanticIndex.Query.acrossActivations( + kind, + key, + new ChunkGenerationSemantics.BlockPosition( + blockCenter(origin.getX()), + 0, + blockCenter(origin.getZ()) + ), + maxChunkRadius + ) + ).orElse(null); + if (match == null) { + return null; + } + ChunkGenerationSemantics.BlockPosition exactPosition = match.exactPosition().orElse(null); + if (exactPosition != null) { + return new SearchCandidate( + new Position2(match.chunk().chunkX(), match.chunk().chunkZ()), + exactPosition.x(), + exactPosition.z() + ); + } + return SearchCandidate.atChunkCenter( + new Position2(match.chunk().chunkX(), match.chunk().chunkZ()) + ); + } + + @Override + public SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) { + if (kind != GenerationSemanticIndex.SemanticKind.STRUCTURE) { + return SearchCandidate.atChunkCenter(chunk); + } + IrisStructureLocator.LocateResult result = GenerationLocatorPolicy.evaluateValueScoped( + engine, + chunk, + () -> IrisStructureLocator.locateInChunk(engine, key, chunk.getX(), chunk.getZ()) + ); + if (!result.found()) { + return null; + } + return new SearchCandidate(chunk, result.originX(), result.originZ()); + } + + private boolean contains(ChunkGenerationSemantics semantics) { + return switch (kind) { + case SURFACE_BIOME -> semantics.surfaceBiomeKeys().contains(key); + case CAVE_BIOME -> semantics.caveBiomeKeys().contains(key); + case REGION -> semantics.regionKeys().contains(key); + case RIVER_PROFILE -> semantics.riverProfileKeys().contains(key); + case OBJECT -> semantics.objectKeys().contains(key); + case STRUCTURE -> semantics.structures().stream() + .anyMatch(occurrence -> occurrence.key().equals(key)); + }; + } + + private static int blockCenter(int chunkCoordinate) { + long center = (long) chunkCoordinate * 16L + 8L; + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, center)); + } +} + +final class GenerationLocatorPolicy { + private GenerationLocatorPolicy() { + } + + static Optional history(Engine engine) { + if (!(engine instanceof IrisEngine irisEngine)) { + return Optional.empty(); + } + return irisEngine.getGenerationHistoryRuntimeRouter() + .map(GenerationHistoryRuntimeRouter::history); + } + + static Optional recorded(Engine engine, Position2 chunk) { + GenerationHistory history = history(engine).orElse(null); + if (history == null) { + return Optional.empty(); + } + return history.semantics(chunk.getX(), chunk.getZ()) + .filter(ChunkGenerationSemantics::sealed); + } + + static boolean allowsActivePrediction(Engine engine, Position2 chunk) { + if (!engine.getComplex().allowsNewGenerationChunk(chunk.getX(), chunk.getZ())) { + return false; + } + GenerationHistory history = history(engine).orElse(null); + if (history == null) { + return true; + } + return history.isActiveUnowned(chunk.getX(), chunk.getZ()) + && recorded(engine, chunk).isEmpty(); + } + + static boolean allowsHistoricalFallback(Engine engine, Position2 chunk) { + GenerationHistory history = history(engine).orElse(null); + return history != null + && history.isHistoricallyOwned(chunk.getX(), chunk.getZ()) + && recorded(engine, chunk).isEmpty(); + } + + static boolean allowsProceduralPrediction(Engine engine, Position2 chunk) { + return allowsHistoricalFallback(engine, chunk) || allowsActivePrediction(engine, chunk); + } + + static boolean hasHistoricalFallbackInSquare( + Engine engine, + Position2 center, + int radius + ) { + if (radius < 0) { + throw new IllegalArgumentException("Historical fallback radius cannot be negative."); + } + GenerationHistory history = history(engine).orElse(null); + return history != null && history.hasHistoricalFallbackInSquare(center.getX(), center.getZ(), radius); + } + + static boolean evaluateScoped( + Engine engine, + Position2 chunk, + BooleanSupplier query + ) { + BooleanSupplier requiredQuery = Objects.requireNonNull(query, "query"); + if (!(engine instanceof IrisEngine irisEngine)) { + return requiredQuery.getAsBoolean(); + } + int blockX = Math.multiplyExact(chunk.getX(), 16); + int blockZ = Math.multiplyExact(chunk.getZ(), 16); + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ)) { + return requiredQuery.getAsBoolean(); + } catch (IOException exception) { + throw new IllegalStateException("Unable to scope an Iris locator query at chunk " + + chunk.getX() + "," + chunk.getZ() + ".", exception); + } + } + + static T evaluateValueScoped( + Engine engine, + Position2 chunk, + Supplier query + ) { + Supplier requiredQuery = Objects.requireNonNull(query, "query"); + if (!(engine instanceof IrisEngine irisEngine)) { + return requiredQuery.get(); + } + int blockX = Math.multiplyExact(chunk.getX(), 16); + int blockZ = Math.multiplyExact(chunk.getZ(), 16); + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ)) { + return requiredQuery.get(); + } catch (IOException exception) { + throw new IllegalStateException("Unable to scope an Iris locator query at chunk " + + chunk.getX() + "," + chunk.getZ() + ".", exception); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 5d99e823b..6ca79faff 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -178,6 +178,22 @@ public final class IrisStructureLocator { return resolveInChunk(engine, key, cx, cz) != null; } + public static LocateResult locateInChunk(Engine engine, String key, int chunkX, int chunkZ) { + if (!isPlaced(engine, key)) { + return NOT_FOUND_RESULT; + } + ResolvedStart resolved = resolveInChunk(engine, key, chunkX, chunkZ); + if (resolved == null) { + return NOT_FOUND_RESULT; + } + return new LocateResult( + LocateStatus.FOUND, + resolved.originX(), + resolved.baseY(), + resolved.originZ() + ); + } + public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) { return locate(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, (chunkX, chunkZ) -> true); } @@ -567,9 +583,17 @@ public final class IrisStructureLocator { "assembled pieces exceed the configured structure or world bounds")) { return null; } + if (!allowsResolvedFootprint(engine, placement, pieces)) { + return null; + } return new ResolvedPlacement(placement, selectedKey, structure, pieces, rng, originX, baseY, originZ, exactY); } + public static boolean allowsResolvedFootprint(Engine engine, ResolvedPlacement resolved) { + Objects.requireNonNull(resolved, "resolved placement"); + return allowsResolvedFootprint(engine, resolved.placement(), resolved.pieces()); + } + public static boolean requirePlacementOutput(IrisStructurePlacement placement, String structureKey, int chunkX, int chunkZ, boolean outputPresent, String failureReason) { @@ -876,6 +900,34 @@ public final class IrisStructureLocator { return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; } + private static boolean allowsResolvedFootprint( + Engine engine, + IrisStructurePlacement placement, + KList pieces + ) { + int[] bounds = computeBounds(pieces); + if (bounds == null) { + return false; + } + IrisStructureTerrain terrain = placement.resolvedTerrain(); + IrisStructureTerrainMode mode = terrain.resolvedMode(); + int padding = mode == IrisStructureTerrainMode.FORCE_CARVE + || mode == IrisStructureTerrainMode.BORE + ? Math.max(0, terrain.getHorizontalPadding()) + : 0; + return engine.getComplex().allowsNewGenerationFootprint( + saturatedOffset(bounds[0], -padding), + saturatedOffset(bounds[2], -padding), + saturatedOffset(bounds[3], padding), + saturatedOffset(bounds[5], padding) + ); + } + + private static int saturatedOffset(int coordinate, int offset) { + long result = (long) coordinate + offset; + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result)); + } + private static int randomInclusive(RNG rng, int minimum, int maximum) { if (minimum == maximum) { return minimum; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java index 56acbcfc6..1bd549ee5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java @@ -18,43 +18,69 @@ package art.arcane.iris.engine.framework; -import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.history.GenerationSemanticIndex; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.math.Position2; -import art.arcane.volmlib.util.math.Spiraler; import art.arcane.volmlib.util.matter.MatterCavern; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import java.math.BigInteger; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @FunctionalInterface public interface Locator { + record SearchCandidate(Position2 chunk, int blockX, int blockZ) { + public SearchCandidate { + if (chunk == null) { + throw new IllegalArgumentException("Locator candidate chunk cannot be null."); + } + } + + public static SearchCandidate atChunkCenter(Position2 chunk) { + long blockX = (long) chunk.getX() * 16L + 8L; + long blockZ = (long) chunk.getZ() * 16L + 8L; + return new SearchCandidate( + chunk, + (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, blockX)), + (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, blockZ)) + ); + } + } + static Locator region(String loadKey) { - Locator exact = (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.REGION, + loadKey, + (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey) + ); return new HintedLocator<>(exact, (engine) -> HintedLocator.regionPlan(engine, loadKey)); } static Locator object(String loadKey) { - Locator exact = (e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey); + Locator exact = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.OBJECT, + loadKey, + (e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey) + ); return new HintedLocator<>(exact, (engine) -> HintedLocator.objectPlan(engine, loadKey)); } static Locator surfaceBiome(String loadKey) { - Locator exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey); + Locator exact = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.SURFACE_BIOME, + loadKey, + (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey) + ); return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey)); } @@ -71,7 +97,11 @@ public interface Locator { } static Locator structure(String key) { - return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ()); + return new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.STRUCTURE, + key, + (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ()) + ); } static Locator poi(String type) { @@ -82,12 +112,16 @@ public interface Locator { } static Locator caveBiome(String loadKey) { - Locator exact = (e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.CAVE_BIOME, + loadKey, + (e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey) + ); return new HintedLocator<>(exact, (engine) -> HintedLocator.caveBiomePlan(engine, loadKey)); } static Locator caveOrMantleBiome(String loadKey) { - return (e, c) -> { + Locator active = (e, c) -> { AtomicBoolean found = new AtomicBoolean(false); try (GenerationSessionLease lease = e.acquireGenerationLease("locator_generate_matter")) { ChunkContext chunkContext = new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), lease.sessionId(), false, ChunkContext.PrefillPlan.NONE, null); @@ -109,58 +143,114 @@ public interface Locator { return found.get(); }; + return new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.CAVE_BIOME, + loadKey, + active + ); } boolean matches(Engine engine, Position2 chunk); - default CompletableFuture find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { - if (engine.isClosed()) { - throw new WrongEngineBroException(); + default boolean matchesForSearch(Engine engine, Position2 chunk) { + if (!GenerationLocatorPolicy.allowsActivePrediction(engine, chunk)) { + return false; } + return GenerationLocatorPolicy.evaluateScoped( + engine, + chunk, + () -> matches(engine, chunk) + ); + } - AtomicBoolean stop = new AtomicBoolean(false); - CompletableFuture search = MultiBurst.burst.completeValueAsync(() -> { - try (GenerationSessionLease lease = engine.acquireGenerationLease("locator_search"); - IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - int tc = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32; - MultiBurst burst = MultiBurst.burst; - AtomicBoolean found = new AtomicBoolean(false); - AtomicInteger searched = new AtomicInteger(); - AtomicReference foundPos = new AtomicReference<>(); - PrecisionStopwatch px = PrecisionStopwatch.start(); - AtomicReference next = new AtomicReference<>(pos); - Spiraler s = new Spiraler(100000, 100000, (x, z) -> next.set(new Position2(x, z))); - s.setOffset(pos.getX(), pos.getZ()); - s.next(); - while (!found.get() && !stop.get() && !engine.isClosing() && px.getMilliseconds() < timeout) { - BurstExecutor e = burst.burst(tc); + default Position2 nearestRecorded(Engine engine, Position2 origin, int maxChunkRadius) { + SearchCandidate candidate = nearestRecordedCandidate(engine, origin, maxChunkRadius); + return candidate == null ? null : candidate.chunk(); + } - for (int i = 0; i < tc; i++) { - Position2 p = next.get(); - s.next(); - e.queue(() -> { - if (found.get() || stop.get() || engine.isClosing()) { - return; - } - if (matches(engine, p)) { - foundPos.compareAndSet(null, p); - found.set(true); - } - searched.incrementAndGet(); - }); - } + default SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) { + return null; + } - e.complete(); - checks.accept(searched.get()); - } + default SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) { + return SearchCandidate.atChunkCenter(chunk); + } - if (found.get() && foundPos.get() != null) { - return foundPos.get(); - } + default CompletableFuture find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { + return new HintedLocator<>(this, ignored -> HintedLocator.SearchPlan.unpruned()) + .find(engine, pos, timeout, checks); + } - return null; + static int activeSearchRadius( + Position2 origin, + SearchCandidate recorded, + int maximumRadius + ) { + if (recorded == null) { + return maximumRadius; + } + BigInteger distance = distanceSquared(origin, recorded).sqrt(); + BigInteger chunks = distance.add(BigInteger.valueOf(15L)).divide(BigInteger.valueOf(16L)); + return Math.min(maximumRadius, chunks.min(BigInteger.valueOf(Integer.MAX_VALUE - 2L)).intValue() + 2); + } + + static void updateNearest( + AtomicReference nearest, + SearchCandidate candidate, + Position2 origin + ) { + SearchCandidate current = nearest.get(); + while (current == null || compareCandidates(origin, candidate, current) < 0) { + if (nearest.compareAndSet(current, candidate)) { + return; } - }); - return LocatorCanceller.requestScoped(search, stop); + current = nearest.get(); + } + } + + static SearchCandidate nearer( + Position2 origin, + SearchCandidate first, + SearchCandidate second + ) { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + return compareCandidates(origin, first, second) <= 0 ? first : second; + } + + private static int compareCandidates( + Position2 origin, + SearchCandidate first, + SearchCandidate second + ) { + int distanceComparison = distanceSquared(origin, first).compareTo(distanceSquared(origin, second)); + if (distanceComparison != 0) { + return distanceComparison; + } + int xComparison = Integer.compare(first.blockX(), second.blockX()); + if (xComparison != 0) { + return xComparison; + } + int zComparison = Integer.compare(first.blockZ(), second.blockZ()); + if (zComparison != 0) { + return zComparison; + } + int chunkXComparison = Integer.compare(first.chunk().getX(), second.chunk().getX()); + if (chunkXComparison != 0) { + return chunkXComparison; + } + return Integer.compare(first.chunk().getZ(), second.chunk().getZ()); + } + + private static BigInteger distanceSquared(Position2 origin, SearchCandidate candidate) { + BigInteger originX = BigInteger.valueOf((long) origin.getX() * 16L + 8L); + BigInteger originZ = BigInteger.valueOf((long) origin.getZ() * 16L + 8L); + BigInteger deltaX = originX.subtract(BigInteger.valueOf(candidate.blockX())); + BigInteger deltaZ = originZ.subtract(BigInteger.valueOf(candidate.blockZ())); + return deltaX.multiply(deltaX).add(deltaZ.multiply(deltaZ)); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java index 7e1febdd6..db42c7d06 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java @@ -1,5 +1,8 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; @@ -8,13 +11,18 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; -import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.IntConsumer; public final class NativeStructureOwnershipStore { private static final int MAX_CACHED_AUTHORITIES = 65_536; @@ -175,6 +183,7 @@ public final class NativeStructureOwnershipStore { } closed = true; authorities.invalidateAll(); + storage.close(); } finally { lifecycleLock.writeLock().unlock(); } @@ -224,21 +233,36 @@ public final class NativeStructureOwnershipStore { void remove(long target, String structureKey, int originChunkX, int originChunkZ); boolean flush(); + + default void close() { + } } - private static final class MantleStorage implements Storage { + static final class MantleStorage implements Storage { private final WeakReference engine; - private final Map pendingBundles; + private final Map pendingBundles; + private final WeakReference retirementEngine; + private final IntConsumer retirementListener; - private MantleStorage(Engine engine) { - this.engine = new WeakReference<>(Objects.requireNonNull(engine, - "Native structure ownership store requires an engine")); + MantleStorage(Engine engine) { + Engine required = Objects.requireNonNull(engine, + "Native structure ownership store requires an engine"); + this.engine = new WeakReference<>(required); this.pendingBundles = new ConcurrentHashMap<>(); + if (required instanceof IrisEngine irisEngine) { + this.retirementEngine = new WeakReference<>(irisEngine); + this.retirementListener = this::flushRuntime; + irisEngine.addGenerationRuntimeRetirementListener(retirementListener); + } else { + this.retirementEngine = null; + this.retirementListener = null; + } } @Override public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) { - MantleChunk chunk = mantle().getChunk(chunkX, chunkZ).use(); + RuntimeMantleOwner owner = owner(); + MantleChunk chunk = owner.mantle().getMantle().getChunk(chunkX, chunkZ).use(); try { synchronized (chunk) { return chunk.get(0, 0, 0, NativeStructureOwnershipBundle.class); @@ -250,9 +274,10 @@ public final class NativeStructureOwnershipStore { @Override public void write(long target, NativeStructureOwnershipRecord record) { + RuntimeMantleOwner owner = owner(); int targetChunkX = unpackX(target); int targetChunkZ = unpackZ(target); - MantleChunk chunk = mantle().getChunk(targetChunkX, targetChunkZ).use(); + MantleChunk chunk = owner.mantle().getMantle().getChunk(targetChunkX, targetChunkZ).use(); try { synchronized (chunk) { Matter section = chunk.getOrCreate(0); @@ -262,7 +287,7 @@ public final class NativeStructureOwnershipStore { NativeStructureOwnershipBundle updated = Objects.requireNonNullElseGet( bundle, NativeStructureOwnershipBundle::empty).with(record); slice.set(0, 0, 0, updated); - pendingBundles.put(target, updated); + pendingBundles.put(new PendingTarget(owner, target), updated); } } finally { chunk.release(); @@ -272,9 +297,10 @@ public final class NativeStructureOwnershipStore { @Override public void remove(long target, String structureKey, int originChunkX, int originChunkZ) { + RuntimeMantleOwner owner = owner(); int targetChunkX = unpackX(target); int targetChunkZ = unpackZ(target); - MantleChunk chunk = mantle().getChunk(targetChunkX, targetChunkZ).use(); + MantleChunk chunk = owner.mantle().getMantle().getChunk(targetChunkX, targetChunkZ).use(); try { synchronized (chunk) { Matter section = chunk.getOrCreate(0); @@ -294,7 +320,7 @@ public final class NativeStructureOwnershipStore { } slice.set(0, 0, 0, updated.records().isEmpty() ? null : updated); section.trimSlices(); - pendingBundles.put(target, updated); + pendingBundles.put(new PendingTarget(owner, target), updated); } } finally { chunk.release(); @@ -303,27 +329,67 @@ public final class NativeStructureOwnershipStore { @Override public boolean flush() { - Map pending = new TreeMap<>(pendingBundles); + return flushMatching(null); + } + + @Override + public void close() { + IrisEngine activeRetirementEngine = retirementEngine == null ? null : retirementEngine.get(); + if (activeRetirementEngine != null) { + activeRetirementEngine.removeGenerationRuntimeRetirementListener(retirementListener); + } + } + + private void flushRuntime(int runtimeId) { + if (!flushMatching(runtimeId)) { + throw new IllegalStateException("Cannot retire generation runtime " + runtimeId + + " while native structure ownership regions are in use."); + } + } + + private boolean flushMatching(Integer runtimeId) { + List> pending = new ArrayList<>(); + for (Map.Entry entry : pendingBundles.entrySet()) { + if (runtimeId == null || entry.getKey().owner().runtimeId() == runtimeId) { + pending.add(Map.entry(entry.getKey(), entry.getValue())); + } + } if (pending.isEmpty()) { return true; } + pending.sort(Comparator + .comparingLong((Map.Entry entry) -> + entry.getKey().owner().activationId()) + .thenComparingInt(entry -> entry.getKey().owner().runtimeId()) + .thenComparingLong(entry -> entry.getKey().target())); - Mantle mantle = mantle(); - Set regions = new TreeSet<>(); - for (Map.Entry entry : pending.entrySet()) { - long target = entry.getKey(); - restorePendingBundle(mantle, target, entry.getValue()); - regions.add(Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5)); + Map> regionsByOwner = new LinkedHashMap<>(); + for (Map.Entry entry : pending) { + PendingTarget pendingTarget = entry.getKey(); + RuntimeMantleOwner owner = pendingTarget.owner(); + restorePendingBundle(owner.mantle().getMantle(), pendingTarget.target(), entry.getValue()); + regionsByOwner.computeIfAbsent(owner, ignored -> new TreeSet<>()).add( + regionKey(pendingTarget.target())); } - Set deferredRegions = mantle.saveIdleTectonicPlates(regions); - for (Map.Entry entry : pending.entrySet()) { - long target = entry.getKey(); - long region = Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5); - if (!deferredRegions.contains(region)) { - pendingBundles.remove(target, entry.getValue()); + + boolean complete = true; + for (Map.Entry> ownerRegions : regionsByOwner.entrySet()) { + RuntimeMantleOwner owner = ownerRegions.getKey(); + Set deferredRegions = owner.mantle().getMantle().saveIdleTectonicPlates( + ownerRegions.getValue()); + if (!deferredRegions.isEmpty()) { + complete = false; + } + for (Map.Entry entry : pending) { + PendingTarget pendingTarget = entry.getKey(); + if (!pendingTarget.owner().equals(owner) + || deferredRegions.contains(regionKey(pendingTarget.target()))) { + continue; + } + pendingBundles.remove(pendingTarget, entry.getValue()); } } - return deferredRegions.isEmpty(); + return complete; } private void restorePendingBundle(Mantle mantle, long target, @@ -342,12 +408,55 @@ public final class NativeStructureOwnershipStore { } } - private Mantle mantle() { + private RuntimeMantleOwner owner() { Engine active = engine.get(); if (active == null) { throw new IllegalStateException("Native structure ownership engine is unavailable"); } - return active.getMantle().getMantle(); + EngineMantle mantle = active.getMantle(); + int runtimeId = active.getCacheID(); + if (!(active instanceof IrisEngine irisEngine)) { + return new RuntimeMantleOwner(0L, runtimeId, null, mantle); + } + Optional attached = irisEngine.getGenerationHistoryRuntimeRouter(); + if (attached.isEmpty()) { + return new RuntimeMantleOwner(0L, runtimeId, null, mantle); + } + GenerationHistoryRuntimeRouter.RuntimeOwnership routed = attached.get() + .currentRuntimeOwnership() + .orElseThrow(() -> new IllegalStateException( + "Native structure ownership requires an active generation-history runtime route.")); + if (routed.binding().runtimeId() != runtimeId) { + throw new IllegalStateException("Native structure ownership runtime route changed during capture."); + } + return new RuntimeMantleOwner(routed.activationId(), runtimeId, routed.binding(), mantle); + } + + private static long regionKey(long target) { + return Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5); + } + } + + private record RuntimeMantleOwner( + long activationId, + int runtimeId, + IrisEngine.GenerationRuntimeBinding binding, + EngineMantle mantle + ) { + private RuntimeMantleOwner { + if (activationId < 0L) { + throw new IllegalArgumentException("Generation activation ID cannot be negative."); + } + Objects.requireNonNull(mantle, "native structure ownership mantle"); + if (activationId > 0L && binding == null) { + throw new IllegalArgumentException("Generation-history ownership requires a runtime binding."); + } + } + } + + private record PendingTarget(RuntimeMantleOwner owner, long target) { + private PendingTarget { + Objects.requireNonNull(owner, "native structure ownership runtime"); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index ed44a5490..f49319f5a 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisStructureAnchorMode; @@ -20,9 +21,13 @@ public final class NativeStructurePlacementPlanner { } public static KList plansAt(Engine engine, int chunkX, int chunkZ) { + Engine activeEngine = Objects.requireNonNull(engine, "Native structure planner requires an engine"); + if (!allowsStart(activeEngine, chunkX, chunkZ)) { + return new KList<>(); + } Map plansByStructure = new LinkedHashMap<>(); - for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(engine, chunkX, chunkZ)) { - NativeStructureStartPlan plan = planAt(engine, placement, chunkX, chunkZ); + for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(activeEngine, chunkX, chunkZ)) { + NativeStructureStartPlan plan = planAt(activeEngine, placement, chunkX, chunkZ); if (plan != null) { String structureKey = normalize(plan.source().getStructure()); NativeStructureStartPlan current = plansByStructure.get(structureKey); @@ -41,6 +46,9 @@ public final class NativeStructurePlacementPlanner { return null; } Engine activeEngine = Objects.requireNonNull(engine, "Native structure planner requires an engine"); + if (!allowsStart(activeEngine, chunkX, chunkZ)) { + return null; + } if (activeEngine.getSeedManager() == null) { throw new IllegalStateException("Native structure planner requires a bound seed manager"); } @@ -163,6 +171,11 @@ public final class NativeStructurePlacementPlanner { return engine.getHeight(blockX, blockZ, true) < localFluidHeight; } + private static boolean allowsStart(Engine engine, int chunkX, int chunkZ) { + IrisComplex complex = engine.getComplex(); + return complex == null || complex.allowsNewGenerationChunk(chunkX, chunkZ); + } + private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) { long leftIdentity = StructurePlacementGrid.placementIdentity(left); long rightIdentity = StructurePlacementGrid.placementIdentity(right); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java index c48ac3357..5cdc7e3cc 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemo.java @@ -21,6 +21,9 @@ package art.arcane.iris.engine.framework; import art.arcane.volmlib.util.collection.KList; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import java.util.HashMap; +import java.util.Map; + /** * Chunk keyed memo in front of the platform volume hook. One chunk's object pass fires the same rect query dozens of * times, so the hook is asked once per chunk column and every later object reuses that answer. Long keyed so the @@ -29,8 +32,8 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; public final class NativeStructureVolumeMemo { private static final int MAX_CACHED_CHUNKS = 2_048; - private final Long2ObjectLinkedOpenHashMap> chunks = - new Long2ObjectLinkedOpenHashMap<>(); + private final Map>> runtimes = + new HashMap<>(); public KList volumes(Engine engine, EnginePlatformHooks hooks, int minX, int minZ, int maxX, int maxZ) { @@ -62,15 +65,27 @@ public final class NativeStructureVolumeMemo { } public void clear() { - synchronized (chunks) { - chunks.clear(); + synchronized (runtimes) { + runtimes.clear(); + } + } + + public void evictRuntime(int runtimeId) { + synchronized (runtimes) { + runtimes.remove(runtimeId); } } private KList chunkVolumes(Engine engine, EnginePlatformHooks hooks, int chunkX, int chunkZ) { long key = ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); - synchronized (chunks) { + int runtimeId = engine.getCacheID(); + synchronized (runtimes) { + Long2ObjectLinkedOpenHashMap> chunks = runtimes.get(runtimeId); + if (chunks == null) { + chunks = new Long2ObjectLinkedOpenHashMap<>(); + runtimes.put(runtimeId, chunks); + } KList hit = chunks.getAndMoveToFirst(key); if (hit != null) { return hit; @@ -83,7 +98,10 @@ public final class NativeStructureVolumeMemo { if (resolved == null) { resolved = NativeStructureVolume.NONE; } - synchronized (chunks) { + synchronized (runtimes) { + Long2ObjectLinkedOpenHashMap> chunks = runtimes.computeIfAbsent( + runtimeId, + ignored -> new Long2ObjectLinkedOpenHashMap<>()); chunks.putAndMoveToFirst(key, resolved); while (chunks.size() > MAX_CACHED_CHUNKS) { chunks.removeLast(); diff --git a/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationOwnership.java b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationOwnership.java new file mode 100644 index 000000000..fc4425dfd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationOwnership.java @@ -0,0 +1,467 @@ +package art.arcane.iris.engine.history; + +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public final class ChunkGenerationOwnership { + private static final int MAXIMUM_CACHED_REGIONS = 64; + + private final Path directory; + private final LinkedHashMap regions; + private final Long2IntOpenHashMap regionAssignmentCounts; + private final ReentrantReadWriteLock lock; + private int explicitChunkCount; + + private ChunkGenerationOwnership(Path directory) { + this.directory = directory.toAbsolutePath().normalize(); + regions = new LinkedHashMap<>(MAXIMUM_CACHED_REGIONS, 0.75F, true); + regionAssignmentCounts = new Long2IntOpenHashMap(); + regionAssignmentCounts.defaultReturnValue(-1); + lock = new ReentrantReadWriteLock(); + } + + public static ChunkGenerationOwnership load(Path directory) throws IOException { + ChunkGenerationOwnership ownership = new ChunkGenerationOwnership(directory); + ownership.loadRegionCatalog(); + return ownership; + } + + public long resolve(int chunkX, int chunkZ, long currentActivation) { + requireActivation(currentActivation); + lock.writeLock().lock(); + try { + RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5); + return region == null ? currentActivation : region.resolve(chunkX, chunkZ, currentActivation); + } catch (IOException error) { + throw lookupFailure(chunkX, chunkZ, error); + } finally { + lock.writeLock().unlock(); + } + } + + public boolean isExplicitlyAssigned(int chunkX, int chunkZ) { + lock.writeLock().lock(); + try { + RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5); + return region != null && region.hasAssignment(chunkX, chunkZ); + } catch (IOException error) { + throw lookupFailure(chunkX, chunkZ, error); + } finally { + lock.writeLock().unlock(); + } + } + + public boolean anyMatchingInSquare(int centerX, int centerZ, int radius, AssignmentPredicate predicate) { + if (radius < 0) { + throw new IllegalArgumentException("Ownership search radius cannot be negative."); + } + Objects.requireNonNull(predicate, "predicate"); + int minimumX = (int) Math.max(Integer.MIN_VALUE, (long) centerX - radius); + int maximumX = (int) Math.min(Integer.MAX_VALUE, (long) centerX + radius); + int minimumZ = (int) Math.max(Integer.MIN_VALUE, (long) centerZ - radius); + int maximumZ = (int) Math.min(Integer.MAX_VALUE, (long) centerZ + radius); + int minimumRegionX = minimumX >> 5; + int maximumRegionX = maximumX >> 5; + int minimumRegionZ = minimumZ >> 5; + int maximumRegionZ = maximumZ >> 5; + long regionCount = ((long) maximumRegionX - minimumRegionX + 1L) + * ((long) maximumRegionZ - minimumRegionZ + 1L); + lock.writeLock().lock(); + try { + if (regionCount > regionAssignmentCounts.size()) { + for (long key : regionAssignmentCounts.keySet()) { + int x = regionX(key); + int z = regionZ(key); + if (x >= minimumRegionX && x <= maximumRegionX + && z >= minimumRegionZ && z <= maximumRegionZ + && matchesRegion(x, z, minimumX, minimumZ, maximumX, maximumZ, predicate)) { + return true; + } + } + return false; + } + for (int x = minimumRegionX; x <= maximumRegionX; x++) { + for (int z = minimumRegionZ; z <= maximumRegionZ; z++) { + if (matchesRegion(x, z, minimumX, minimumZ, maximumX, maximumZ, predicate)) { + return true; + } + } + } + return false; + } catch (IOException error) { + throw new IllegalStateException("Unable to search generation ownership near chunk " + + centerX + "," + centerZ + ".", error); + } finally { + lock.writeLock().unlock(); + } + } + + public long explicitActivation(int chunkX, int chunkZ) { + lock.writeLock().lock(); + try { + RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5); + if (region == null) { + throw new IllegalStateException( + "Chunk " + chunkX + "," + chunkZ + " has no explicit generation activation" + ); + } + return region.explicitActivation(chunkX, chunkZ); + } catch (IOException error) { + throw lookupFailure(chunkX, chunkZ, error); + } finally { + lock.writeLock().unlock(); + } + } + + public boolean assign(int chunkX, int chunkZ, long activation) throws IOException { + requireActivation(activation); + lock.writeLock().lock(); + try { + RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ); + boolean assigned = region.assign(chunkX, chunkZ, activation); + if (assigned) { + recordAssignment(region); + } + return assigned; + } finally { + lock.writeLock().unlock(); + } + } + + public int assignAll(WorldChunkInventory inventory, long activation) throws IOException { + requireActivation(activation); + lock.writeLock().lock(); + try { + validateAssignments(inventory, activation); + int[] assigned = new int[1]; + inventory.forEach((chunkX, chunkZ) -> { + RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ); + if (region.assign(chunkX, chunkZ, activation)) { + recordAssignment(region); + assigned[0]++; + } + }); + return assigned[0]; + } finally { + lock.writeLock().unlock(); + } + } + + public int assignUnassigned(WorldChunkInventory inventory, long activation) throws IOException { + requireActivation(activation); + lock.writeLock().lock(); + try { + int[] assigned = new int[1]; + inventory.forEach((chunkX, chunkZ) -> { + RegionGenerationOwnership existing = loadExistingRegion(chunkX >> 5, chunkZ >> 5); + if (existing != null && existing.hasAssignment(chunkX, chunkZ)) { + return; + } + RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ); + if (region.assign(chunkX, chunkZ, activation)) { + recordAssignment(region); + assigned[0]++; + } + }); + return assigned[0]; + } finally { + lock.writeLock().unlock(); + } + } + + public int persist() throws IOException { + lock.writeLock().lock(); + try { + requireSafeParent(); + int persisted = 0; + for (RegionGenerationOwnership region : regions.values()) { + if (region.persist(directory)) { + persisted++; + } + } + return persisted; + } finally { + lock.writeLock().unlock(); + } + } + + public int explicitChunkCount() { + lock.readLock().lock(); + try { + return explicitChunkCount; + } finally { + lock.readLock().unlock(); + } + } + + public void forEachAssignment(AssignmentConsumer consumer) throws IOException { + lock.writeLock().lock(); + try { + for (long regionKey : orderedRegionKeys()) { + RegionGenerationOwnership region = loadExistingRegion(regionX(regionKey), regionZ(regionKey)); + if (region != null) { + region.forEachAssignment(consumer::accept); + } + } + } finally { + lock.writeLock().unlock(); + } + } + + public long[] snapshotExplicitChunkKeys() { + lock.writeLock().lock(); + try { + long[] chunks = new long[explicitChunkCount]; + int[] cursor = new int[1]; + try { + forEachAssignment((chunkX, chunkZ, activationId) -> chunks[cursor[0]++] = packChunk(chunkX, chunkZ)); + } catch (IOException error) { + throw new IllegalStateException("Unable to materialize generation ownership", error); + } + if (cursor[0] != chunks.length) { + throw new IllegalStateException( + "Generation ownership changed while creating a snapshot: expected " + + chunks.length + " chunks but found " + cursor[0] + ); + } + Arrays.sort(chunks); + return chunks; + } finally { + lock.writeLock().unlock(); + } + } + + int cachedRegionCount() { + lock.readLock().lock(); + try { + return regions.size(); + } finally { + lock.readLock().unlock(); + } + } + + int regionCount() { + lock.readLock().lock(); + try { + return regionAssignmentCounts.size(); + } finally { + lock.readLock().unlock(); + } + } + + public static long packChunk(int chunkX, int chunkZ) { + return ((long) chunkX << Integer.SIZE) | (chunkZ & 0xFFFFFFFFL); + } + + public static int chunkX(long packedChunk) { + return (int) (packedChunk >> Integer.SIZE); + } + + public static int chunkZ(long packedChunk) { + return (int) packedChunk; + } + + private void loadRegionCatalog() throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(directory)) { + Path parent = directory.getParent(); + if (parent != null + && !Files.exists(parent, LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(parent)) { + return; + } + } + requireSafeParent(); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership path is not a directory: " + directory); + } + + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream( + directory, + "*" + RegionGenerationOwnership.FILE_SUFFIX + )) { + for (Path file : stream) { + files.add(file); + } + } + files.sort(Comparator.comparing(path -> path.getFileName().toString())); + for (Path file : files) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership shard is not a regular file: " + file); + } + RegionGenerationOwnership.Metadata metadata = RegionGenerationOwnership.readMetadata(file); + String expectedName = RegionGenerationOwnership.fileName(metadata.regionX(), metadata.regionZ()); + if (!file.getFileName().toString().equals(expectedName)) { + throw new IOException("Generation ownership shard name does not match its coordinates: " + file); + } + long regionKey = packRegion(metadata.regionX(), metadata.regionZ()); + if (regionAssignmentCounts.putIfAbsent(regionKey, metadata.assignmentCount()) != -1) { + throw new IOException("Duplicate generation ownership shard for region " + + metadata.regionX() + "," + metadata.regionZ()); + } + try { + explicitChunkCount = Math.addExact(explicitChunkCount, metadata.assignmentCount()); + } catch (ArithmeticException error) { + throw new IOException("Generation ownership contains too many assigned chunks", error); + } + } + } + + private void requireSafeParent() throws IOException { + Path parent = directory.getParent(); + if (parent == null || !Files.exists(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership parent directory is missing: " + parent); + } + BasicFileAttributes attributes = Files.readAttributes( + parent, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation ownership parent path is not a safe directory: " + parent); + } + } + + private void validateAssignments(WorldChunkInventory inventory, long activation) throws IOException { + inventory.forEach((chunkX, chunkZ) -> { + RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5); + if (region == null || !region.hasAssignment(chunkX, chunkZ)) { + return; + } + long existing = region.explicitActivation(chunkX, chunkZ); + if (existing != activation) { + throw new IllegalStateException( + "Chunk " + chunkX + "," + chunkZ + " already belongs to activation " + existing + + " and cannot be reassigned to " + activation + ); + } + }); + } + + private RegionGenerationOwnership regionForAssignment(int chunkX, int chunkZ) throws IOException { + int regionX = chunkX >> 5; + int regionZ = chunkZ >> 5; + long regionKey = packRegion(regionX, regionZ); + RegionGenerationOwnership region = regions.get(regionKey); + if (region != null) { + return region; + } + region = regionAssignmentCounts.containsKey(regionKey) + ? readRegion(regionX, regionZ) + : new RegionGenerationOwnership(regionX, regionZ); + cacheRegion(regionKey, region); + return region; + } + + private RegionGenerationOwnership loadExistingRegion(int regionX, int regionZ) throws IOException { + long regionKey = packRegion(regionX, regionZ); + RegionGenerationOwnership region = regions.get(regionKey); + if (region != null) { + return region; + } + if (!regionAssignmentCounts.containsKey(regionKey)) { + return null; + } + region = readRegion(regionX, regionZ); + cacheRegion(regionKey, region); + return region; + } + + private RegionGenerationOwnership readRegion(int regionX, int regionZ) throws IOException { + Path file = directory.resolve(RegionGenerationOwnership.fileName(regionX, regionZ)); + RegionGenerationOwnership region = RegionGenerationOwnership.read(file); + if (region.regionX() != regionX || region.regionZ() != regionZ) { + throw new IOException("Generation ownership shard name does not match its coordinates: " + file); + } + return region; + } + + private boolean matchesRegion(int regionX, int regionZ, int minimumX, int minimumZ, + int maximumX, int maximumZ, AssignmentPredicate predicate) throws IOException { + RegionGenerationOwnership region = loadExistingRegion(regionX, regionZ); + return region != null && region.anyMatchingInBounds(minimumX, minimumZ, maximumX, maximumZ, predicate); + } + + private void cacheRegion(long regionKey, RegionGenerationOwnership region) throws IOException { + regions.put(regionKey, region); + while (regions.size() > MAXIMUM_CACHED_REGIONS) { + Iterator> entries = regions.entrySet().iterator(); + Map.Entry eldest = entries.next(); + RegionGenerationOwnership evicted = eldest.getValue(); + if (evicted.persist(directory)) { + regionAssignmentCounts.put(eldest.getKey().longValue(), evicted.assignmentCount()); + } + entries.remove(); + } + } + + private void recordAssignment(RegionGenerationOwnership region) { + explicitChunkCount++; + regionAssignmentCounts.put(packRegion(region.regionX(), region.regionZ()), region.assignmentCount()); + } + + private List orderedRegionKeys() { + ArrayList ordered = new ArrayList<>(regionAssignmentCounts.keySet()); + ordered.sort(ChunkGenerationOwnership::compareRegionKeys); + return ordered; + } + + private static IllegalStateException lookupFailure(int chunkX, int chunkZ, IOException error) { + return new IllegalStateException( + "Unable to load generation ownership for chunk " + chunkX + "," + chunkZ, + error + ); + } + + private static long packRegion(int regionX, int regionZ) { + return ((long) regionX << Integer.SIZE) | (regionZ & 0xFFFFFFFFL); + } + + private static int regionX(long regionKey) { + return (int) (regionKey >> Integer.SIZE); + } + + private static int regionZ(long regionKey) { + return (int) regionKey; + } + + private static int compareRegionKeys(long first, long second) { + int xComparison = Integer.compare(regionX(first), regionX(second)); + return xComparison != 0 ? xComparison : Integer.compare(regionZ(first), regionZ(second)); + } + + private static void requireActivation(long activation) { + if (activation <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activation); + } + } + + @FunctionalInterface + public interface AssignmentPredicate { + boolean test(int chunkX, int chunkZ, long activationId); + } + + @FunctionalInterface + public interface AssignmentConsumer { + void accept(int chunkX, int chunkZ, long activationId) throws IOException; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java new file mode 100644 index 000000000..176317ccd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java @@ -0,0 +1,479 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +public final class ChunkGenerationSemantics { + static final int MAX_KEY_BYTES = 1_024; + static final int MAX_KEYS_PER_KIND = 2_048; + static final int MAX_RIVER_FEATURES = 16_384; + static final int MAX_STRUCTURES = 4_096; + + private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_][a-z0-9._:/#@-]*"); + private static final Comparator STRUCTURE_COMPARATOR = Comparator + .comparing(StructureOccurrence::key) + .thenComparingInt(occurrence -> occurrence.position().x()) + .thenComparingInt(occurrence -> occurrence.position().z()) + .thenComparingInt(occurrence -> occurrence.position().y()); + private static final Comparator RIVER_FEATURE_COMPARATOR = Comparator + .comparing(RiverFeatureOccurrence::profileKey) + .thenComparing(RiverFeatureOccurrence::type) + .thenComparingInt(occurrence -> occurrence.position().x()) + .thenComparingInt(occurrence -> occurrence.position().z()) + .thenComparingInt(occurrence -> occurrence.position().y()) + .thenComparingLong(RiverFeatureOccurrence::featureId); + + private final int chunkX; + private final int chunkZ; + private final long activationId; + private final boolean sealed; + private final Set surfaceBiomeKeys; + private final Set caveBiomeKeys; + private final Set regionKeys; + private final Set riverProfileKeys; + private final Set objectKeys; + private final Set riverFeatures; + private final Set structures; + + private ChunkGenerationSemantics(Builder builder) { + chunkX = builder.chunkX; + chunkZ = builder.chunkZ; + activationId = builder.activationId; + sealed = builder.sealed; + surfaceBiomeKeys = immutableKeys(builder.surfaceBiomeKeys, "surface biome"); + caveBiomeKeys = immutableKeys(builder.caveBiomeKeys, "cave biome"); + regionKeys = immutableKeys(builder.regionKeys, "region"); + riverProfileKeys = immutableKeys(builder.riverProfileKeys, "river profile"); + objectKeys = immutableKeys(builder.objectKeys, "object"); + riverFeatures = immutableRiverFeatures(builder.riverFeatures); + structures = immutableStructures(builder.structures); + } + + public static Builder builder(int chunkX, int chunkZ, long activationId) { + return new Builder(chunkX, chunkZ, activationId); + } + + public int chunkX() { + return chunkX; + } + + public int chunkZ() { + return chunkZ; + } + + public long activationId() { + return activationId; + } + + public boolean sealed() { + return sealed; + } + + public Set surfaceBiomeKeys() { + return surfaceBiomeKeys; + } + + public Set caveBiomeKeys() { + return caveBiomeKeys; + } + + public Set regionKeys() { + return regionKeys; + } + + public Set riverProfileKeys() { + return riverProfileKeys; + } + + public Set objectKeys() { + return objectKeys; + } + + public Set riverFeatures() { + return riverFeatures; + } + + public Set structures() { + return structures; + } + + public ChunkGenerationSemantics merge(ChunkGenerationSemantics update) { + ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update"); + if (chunkX != requiredUpdate.chunkX || chunkZ != requiredUpdate.chunkZ) { + throw new IllegalArgumentException( + "Generation semantic updates must address the same chunk: " + + chunkX + "," + chunkZ + " and " + + requiredUpdate.chunkX + "," + requiredUpdate.chunkZ + ); + } + if (activationId != requiredUpdate.activationId) { + throw new IllegalStateException( + "Chunk " + chunkX + "," + chunkZ + + " belongs to generation activation " + activationId + + ", not " + requiredUpdate.activationId + ); + } + + boolean addsFacts = !surfaceBiomeKeys.containsAll(requiredUpdate.surfaceBiomeKeys) + || !caveBiomeKeys.containsAll(requiredUpdate.caveBiomeKeys) + || !regionKeys.containsAll(requiredUpdate.regionKeys) + || !riverProfileKeys.containsAll(requiredUpdate.riverProfileKeys) + || !objectKeys.containsAll(requiredUpdate.objectKeys) + || !riverFeatures.containsAll(requiredUpdate.riverFeatures) + || !structures.containsAll(requiredUpdate.structures); + if (sealed && addsFacts) { + throw new IllegalStateException( + "Chunk " + chunkX + "," + chunkZ + " has sealed generation semantics" + ); + } + if (!addsFacts && (sealed || !requiredUpdate.sealed)) { + return this; + } + + Builder merged = builder(chunkX, chunkZ, activationId) + .addSurfaceBiomes(surfaceBiomeKeys) + .addSurfaceBiomes(requiredUpdate.surfaceBiomeKeys) + .addCaveBiomes(caveBiomeKeys) + .addCaveBiomes(requiredUpdate.caveBiomeKeys) + .addRegions(regionKeys) + .addRegions(requiredUpdate.regionKeys) + .addRiverProfiles(riverProfileKeys) + .addRiverProfiles(requiredUpdate.riverProfileKeys) + .addObjects(objectKeys) + .addObjects(requiredUpdate.objectKeys) + .addRiverFeatures(riverFeatures) + .addRiverFeatures(requiredUpdate.riverFeatures) + .addStructures(structures) + .addStructures(requiredUpdate.structures); + if (sealed || requiredUpdate.sealed) { + merged.seal(); + } + return merged.build(); + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof ChunkGenerationSemantics semantics)) { + return false; + } + return chunkX == semantics.chunkX + && chunkZ == semantics.chunkZ + && activationId == semantics.activationId + && sealed == semantics.sealed + && surfaceBiomeKeys.equals(semantics.surfaceBiomeKeys) + && caveBiomeKeys.equals(semantics.caveBiomeKeys) + && regionKeys.equals(semantics.regionKeys) + && riverProfileKeys.equals(semantics.riverProfileKeys) + && objectKeys.equals(semantics.objectKeys) + && riverFeatures.equals(semantics.riverFeatures) + && structures.equals(semantics.structures); + } + + @Override + public int hashCode() { + return Objects.hash( + chunkX, + chunkZ, + activationId, + sealed, + surfaceBiomeKeys, + caveBiomeKeys, + regionKeys, + riverProfileKeys, + objectKeys, + riverFeatures, + structures + ); + } + + @Override + public String toString() { + return "ChunkGenerationSemantics[chunkX=" + chunkX + + ", chunkZ=" + chunkZ + + ", activationId=" + activationId + + ", sealed=" + sealed + + ", surfaceBiomeKeys=" + surfaceBiomeKeys + + ", caveBiomeKeys=" + caveBiomeKeys + + ", regionKeys=" + regionKeys + + ", riverProfileKeys=" + riverProfileKeys + + ", objectKeys=" + objectKeys + + ", riverFeatures=" + riverFeatures + + ", structures=" + structures + + "]"; + } + + static Comparator structureComparator() { + return STRUCTURE_COMPARATOR; + } + + static Comparator riverFeatureComparator() { + return RIVER_FEATURE_COMPARATOR; + } + + static String requireResourceKey(String key) { + String requiredKey = Objects.requireNonNull(key, "key"); + if (requiredKey.isBlank()) { + throw new IllegalArgumentException("Generation semantic resource keys must not be blank"); + } + if (!requiredKey.equals(requiredKey.trim())) { + throw new IllegalArgumentException("Generation semantic resource keys must not contain surrounding whitespace: " + requiredKey); + } + if (!requiredKey.equals(requiredKey.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Generation semantic resource keys must be lowercase: " + requiredKey); + } + if (!RESOURCE_KEY_PATTERN.matcher(requiredKey).matches()) { + throw new IllegalArgumentException("Generation semantic resource key is not normalized: " + requiredKey); + } + if (requiredKey.getBytes(StandardCharsets.UTF_8).length > MAX_KEY_BYTES) { + throw new IllegalArgumentException("Generation semantic resource key is too long"); + } + return requiredKey; + } + + private static Set immutableKeys(Collection keys, String kind) { + if (keys.size() > MAX_KEYS_PER_KIND) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_KEYS_PER_KIND + " " + kind + " keys" + ); + } + TreeSet validated = new TreeSet<>(); + for (String key : keys) { + validated.add(requireResourceKey(key)); + } + return Collections.unmodifiableSet(validated); + } + + private static Set immutableStructures(Collection structures) { + if (structures.size() > MAX_STRUCTURES) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_STRUCTURES + " structure occurrences" + ); + } + TreeSet validated = new TreeSet<>(STRUCTURE_COMPARATOR); + for (StructureOccurrence occurrence : structures) { + validated.add(Objects.requireNonNull(occurrence, "structure occurrence")); + } + return Collections.unmodifiableSet(validated); + } + + private static Set immutableRiverFeatures( + Collection occurrences + ) { + if (occurrences.size() > MAX_RIVER_FEATURES) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_RIVER_FEATURES + " river feature occurrences" + ); + } + TreeSet validated = new TreeSet<>(RIVER_FEATURE_COMPARATOR); + for (RiverFeatureOccurrence occurrence : occurrences) { + validated.add(Objects.requireNonNull(occurrence, "river feature occurrence")); + } + return Collections.unmodifiableSet(validated); + } + + public record BlockPosition(int x, int y, int z) { + } + + public record StructureOccurrence(String key, BlockPosition position) { + public StructureOccurrence { + key = requireResourceKey(key); + position = Objects.requireNonNull(position, "position"); + } + } + + public record RiverFeatureOccurrence( + String profileKey, + HydrologyFeatureType type, + long featureId, + BlockPosition position + ) { + public RiverFeatureOccurrence { + profileKey = requireResourceKey(profileKey); + type = Objects.requireNonNull(type, "type"); + position = Objects.requireNonNull(position, "position"); + } + } + + public static final class Builder { + private final int chunkX; + private final int chunkZ; + private final long activationId; + private final Set surfaceBiomeKeys; + private final Set caveBiomeKeys; + private final Set regionKeys; + private final Set riverProfileKeys; + private final Set objectKeys; + private final Set riverFeatures; + private final Set structures; + private boolean sealed; + + private Builder(int chunkX, int chunkZ, long activationId) { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId); + } + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.activationId = activationId; + surfaceBiomeKeys = new TreeSet<>(); + caveBiomeKeys = new TreeSet<>(); + regionKeys = new TreeSet<>(); + riverProfileKeys = new TreeSet<>(); + objectKeys = new TreeSet<>(); + riverFeatures = new TreeSet<>(RIVER_FEATURE_COMPARATOR); + structures = new TreeSet<>(STRUCTURE_COMPARATOR); + } + + public Builder addSurfaceBiome(String key) { + addKey(surfaceBiomeKeys, key, "surface biome"); + return this; + } + + public Builder addSurfaceBiomes(Collection keys) { + addKeys(surfaceBiomeKeys, keys, "surface biome"); + return this; + } + + public Builder addCaveBiome(String key) { + addKey(caveBiomeKeys, key, "cave biome"); + return this; + } + + public Builder addCaveBiomes(Collection keys) { + addKeys(caveBiomeKeys, keys, "cave biome"); + return this; + } + + public Builder addRegion(String key) { + addKey(regionKeys, key, "region"); + return this; + } + + public Builder addRegions(Collection keys) { + addKeys(regionKeys, keys, "region"); + return this; + } + + public Builder addRiverProfile(String key) { + addKey(riverProfileKeys, key, "river profile"); + return this; + } + + public Builder addRiverProfiles(Collection keys) { + addKeys(riverProfileKeys, keys, "river profile"); + return this; + } + + public Builder addRiverFeature( + String profileKey, + HydrologyFeatureType type, + long featureId, + int blockX, + int blockY, + int blockZ + ) { + return addRiverFeature(new RiverFeatureOccurrence( + profileKey, + type, + featureId, + new BlockPosition(blockX, blockY, blockZ) + )); + } + + public Builder addRiverFeature(RiverFeatureOccurrence occurrence) { + RiverFeatureOccurrence required = Objects.requireNonNull( + occurrence, + "river feature occurrence" + ); + if (riverFeatures.size() >= MAX_RIVER_FEATURES && !riverFeatures.contains(required)) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_RIVER_FEATURES + + " river feature occurrences" + ); + } + riverFeatures.add(required); + addRiverProfile(required.profileKey()); + return this; + } + + public Builder addRiverFeatures(Collection occurrences) { + Collection requiredOccurrences = Objects.requireNonNull( + occurrences, + "river feature occurrences" + ); + for (RiverFeatureOccurrence occurrence : requiredOccurrences) { + addRiverFeature(occurrence); + } + return this; + } + + public Builder addObject(String key) { + addKey(objectKeys, key, "object"); + return this; + } + + public Builder addObjects(Collection keys) { + addKeys(objectKeys, keys, "object"); + return this; + } + + public Builder addStructure(String key, int blockX, int blockY, int blockZ) { + return addStructure(new StructureOccurrence(key, new BlockPosition(blockX, blockY, blockZ))); + } + + public Builder addStructure(StructureOccurrence occurrence) { + if (structures.size() >= MAX_STRUCTURES && !structures.contains(occurrence)) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_STRUCTURES + " structure occurrences" + ); + } + structures.add(Objects.requireNonNull(occurrence, "structure occurrence")); + return this; + } + + public Builder addStructures(Collection occurrences) { + Collection requiredOccurrences = Objects.requireNonNull(occurrences, "occurrences"); + for (StructureOccurrence occurrence : requiredOccurrences) { + addStructure(occurrence); + } + return this; + } + + public Builder seal() { + sealed = true; + return this; + } + + public ChunkGenerationSemantics build() { + return new ChunkGenerationSemantics(this); + } + + private static void addKeys(Set target, Collection keys, String kind) { + Collection requiredKeys = Objects.requireNonNull(keys, "keys"); + for (String key : requiredKeys) { + addKey(target, key, kind); + } + } + + private static void addKey(Set target, String key, String kind) { + String validated = requireResourceKey(key); + if (target.size() >= MAX_KEYS_PER_KIND && !target.contains(validated)) { + throw new IllegalArgumentException( + "A chunk cannot contain more than " + MAX_KEYS_PER_KIND + " " + kind + " keys" + ); + } + target.add(validated); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationActivation.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationActivation.java new file mode 100644 index 000000000..6f9bb9ed5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationActivation.java @@ -0,0 +1,207 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonObject; +import com.google.gson.JsonNull; + +import java.util.Objects; +import java.util.regex.Pattern; + +public final class GenerationActivation { + private static final Pattern EPOCH_ID_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + private final long activationId; + private final String epochId; + private final Long parentActivationId; + private final long createdAtEpochMillis; + private final GenerationTransition transition; + + private GenerationActivation(State state) { + State requiredState = Objects.requireNonNull(state, "state"); + this.activationId = requiredState.activationId(); + this.epochId = requiredState.epochId(); + this.parentActivationId = requiredState.parentActivationId(); + this.createdAtEpochMillis = requiredState.createdAtEpochMillis(); + this.transition = requiredState.transition(); + } + + static GenerationActivation initial(String epochId, long createdAtEpochMillis) { + return new GenerationActivation(new State(1L, epochId, null, createdAtEpochMillis, null)); + } + + static GenerationActivation next( + long activationId, + String epochId, + long parentActivationId, + long createdAtEpochMillis, + int transitionWidthBlocks + ) { + return new GenerationActivation(new State( + activationId, + epochId, + parentActivationId, + createdAtEpochMillis, + GenerationTransition.pending(transitionWidthBlocks) + )); + } + + static GenerationActivation restore(State state) { + return new GenerationActivation(state); + } + + public long activationId() { + return activationId; + } + + public String epochId() { + return epochId; + } + + public Long parentActivationId() { + return parentActivationId; + } + + public long createdAtEpochMillis() { + return createdAtEpochMillis; + } + + public GenerationTransition transition() { + return transition; + } + + public boolean isInitial() { + return parentActivationId == null; + } + + GenerationActivation completeTransition(String boundaryIdentity, String terrainSignatureIdentity) { + if (transition == null) { + throw new IllegalStateException("The initial activation has no generation transition."); + } + return new GenerationActivation(new State( + activationId, + epochId, + parentActivationId, + createdAtEpochMillis, + transition.complete(boundaryIdentity, terrainSignatureIdentity) + )); + } + + State state() { + return new State(activationId, epochId, parentActivationId, createdAtEpochMillis, transition); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("activationId", activationId); + json.addProperty("epochId", epochId); + if (parentActivationId == null) { + json.add("parentActivationId", JsonNull.INSTANCE); + } else { + json.addProperty("parentActivationId", parentActivationId); + } + json.addProperty("createdAtEpochMillis", createdAtEpochMillis); + if (transition == null) { + json.add("transition", JsonNull.INSTANCE); + } else { + json.add("transition", transition.toJson()); + } + return json; + } + + static GenerationActivation fromJson(JsonObject json) { + GenerationManifest.JsonSchema.requireFields( + json, + "activation", + "activationId", + "epochId", + "parentActivationId", + "createdAtEpochMillis", + "transition" + ); + long activationId = GenerationManifest.JsonSchema.requireLong(json, "activationId", "activation"); + String epochId = GenerationManifest.JsonSchema.requireString(json, "epochId", "activation"); + Long parentActivationId = GenerationManifest.JsonSchema.requireNullableLong( + json, + "parentActivationId", + "activation" + ); + long createdAtEpochMillis = GenerationManifest.JsonSchema.requireLong( + json, + "createdAtEpochMillis", + "activation" + ); + JsonObject transitionJson = GenerationManifest.JsonSchema.requireNullableObject( + json, + "transition", + "activation" + ); + GenerationTransition transition = transitionJson == null + ? null + : GenerationTransition.fromJson(transitionJson); + return restore(new State( + activationId, + epochId, + parentActivationId, + createdAtEpochMillis, + transition + )); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GenerationActivation activation)) { + return false; + } + return activationId == activation.activationId + && createdAtEpochMillis == activation.createdAtEpochMillis + && epochId.equals(activation.epochId) + && Objects.equals(parentActivationId, activation.parentActivationId) + && Objects.equals(transition, activation.transition); + } + + @Override + public int hashCode() { + return Objects.hash(activationId, epochId, parentActivationId, createdAtEpochMillis, transition); + } + + @Override + public String toString() { + return "GenerationActivation[activationId=" + activationId + + ", epochId=" + epochId + + ", parentActivationId=" + parentActivationId + + ", createdAtEpochMillis=" + createdAtEpochMillis + + ", transition=" + transition + "]"; + } + + record State( + long activationId, + String epochId, + Long parentActivationId, + long createdAtEpochMillis, + GenerationTransition transition + ) { + State { + if (activationId < 1L) { + throw new IllegalArgumentException("Activation ID must be positive."); + } + epochId = Objects.requireNonNull(epochId, "epochId"); + if (!EPOCH_ID_PATTERN.matcher(epochId).matches()) { + throw new IllegalArgumentException("Activation epoch ID must be a lowercase SHA-256 value."); + } + if (parentActivationId != null + && (parentActivationId < 1L || parentActivationId >= activationId)) { + throw new IllegalArgumentException("Parent activation ID must identify an earlier activation."); + } + if (createdAtEpochMillis < 0L) { + throw new IllegalArgumentException("Activation creation time cannot be negative."); + } + if ((parentActivationId == null) != (transition == null)) { + throw new IllegalArgumentException( + "Only the initial activation may omit a generation transition." + ); + } + } + } +} 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 new file mode 100644 index 000000000..7cbb0c065 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java @@ -0,0 +1,147 @@ +package art.arcane.iris.engine.history; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public final class GenerationAdmission { + private static final ConcurrentHashMap STATES = new ConcurrentHashMap<>(); + + private final State state; + + public GenerationAdmission(Path dimensionRoot) { + Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toAbsolutePath() + .normalize(); + this.state = STATES.computeIfAbsent(root, ignored -> new State()); + } + + public StageLease enterStage() { + state.enterStage(); + return new StageLease(state); + } + + public CutoverLease beginCutover() { + state.beginCutover(false); + return new CutoverLease(state); + } + + CutoverLease beginStartupCutover() { + state.beginCutover(true); + return new CutoverLease(state); + } + + public static final class StageLease implements AutoCloseable { + private final State state; + private final AtomicBoolean closed; + + private StageLease(State state) { + this.state = state; + this.closed = new AtomicBoolean(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + state.leaveStage(); + } + } + } + + public static final class CutoverLease implements AutoCloseable { + private final State state; + private final AtomicBoolean closed; + + private CutoverLease(State state) { + this.state = state; + this.closed = new AtomicBoolean(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + state.endCutover(); + } + } + } + + private static final class State { + private final ReentrantLock lock; + private final Condition changed; + private int activeStages; + private int waitingCutovers; + private boolean cutoverActive; + private boolean generationAdmissionOpened; + + private State() { + this.lock = new ReentrantLock(true); + this.changed = lock.newCondition(); + } + + private void enterStage() { + lock.lock(); + try { + generationAdmissionOpened = true; + while (cutoverActive || waitingCutovers > 0) { + changed.awaitUninterruptibly(); + } + activeStages++; + } finally { + lock.unlock(); + } + } + + private void leaveStage() { + lock.lock(); + try { + if (activeStages < 1) { + throw new IllegalStateException("No generation stage is active."); + } + activeStages--; + if (activeStages == 0) { + changed.signalAll(); + } + } finally { + lock.unlock(); + } + } + + private void beginCutover(boolean startupOnly) { + lock.lock(); + try { + if (startupOnly && generationAdmissionOpened) { + throw new IllegalStateException( + "Generation activation promotion is only allowed before generation admission opens." + ); + } + waitingCutovers++; + try { + while (cutoverActive || activeStages > 0) { + changed.awaitUninterruptibly(); + } + cutoverActive = true; + } finally { + waitingCutovers--; + } + } finally { + lock.unlock(); + } + } + + private void endCutover() { + lock.lock(); + try { + if (!cutoverActive) { + throw new IllegalStateException("No generation cutover is active."); + } + cutoverActive = false; + changed.signalAll(); + } finally { + lock.unlock(); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationBlend.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationBlend.java new file mode 100644 index 000000000..ed5a8f531 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationBlend.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.history; + +public final class GenerationBlend { + private GenerationBlend() { + } + + public static double newEpochWeight(double distanceToHistoricalBlocks, int widthBlocks) { + if (!Double.isFinite(distanceToHistoricalBlocks) || distanceToHistoricalBlocks < 0D) { + throw new IllegalArgumentException("Distance must be finite and non-negative"); + } + if (widthBlocks <= 0) { + throw new IllegalArgumentException("Blend width must be positive"); + } + if (distanceToHistoricalBlocks <= 0D) { + return 0D; + } + if (distanceToHistoricalBlocks >= widthBlocks) { + return 1D; + } + return smootherstep(distanceToHistoricalBlocks / widthBlocks); + } + + public static double smootherstep(double progress) { + if (!Double.isFinite(progress)) { + throw new IllegalArgumentException("Progress must be finite"); + } + if (progress <= 0D) { + return 0D; + } + if (progress >= 1D) { + return 1D; + } + return progress * progress * progress * (progress * (progress * 6D - 15D) + 10D); + } + + public static double interpolate(double historicalValue, double newValue, double newEpochWeight) { + if (!Double.isFinite(historicalValue) || !Double.isFinite(newValue)) { + throw new IllegalArgumentException("Blend values must be finite"); + } + validateWeight(newEpochWeight); + if (newEpochWeight == 0D) { + return historicalValue; + } + if (newEpochWeight == 1D) { + return newValue; + } + return historicalValue + (newValue - historicalValue) * newEpochWeight; + } + + public static int interpolateHeight(int historicalHeight, int newHeight, double newEpochWeight) { + validateWeight(newEpochWeight); + if (newEpochWeight == 0D) { + return historicalHeight; + } + if (newEpochWeight == 1D) { + return newHeight; + } + return (int) Math.round(historicalHeight + ((double) newHeight - historicalHeight) * newEpochWeight); + } + + private static void validateWeight(double weight) { + if (!Double.isFinite(weight) || weight < 0D || weight > 1D) { + throw new IllegalArgumentException("Blend weight must be finite and between zero and one"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundary.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundary.java new file mode 100644 index 000000000..fb10a3b90 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundary.java @@ -0,0 +1,435 @@ +package art.arcane.iris.engine.history; + +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class GenerationBoundary { + public static final int CHUNK_SIZE = 16; + + private final String identity; + private final int historicalChunkCount; + private final HistoricalChunkSource historicalChunks; + + private GenerationBoundary( + String identity, + int historicalChunkCount, + HistoricalChunkSource historicalChunks + ) { + this.identity = requireIdentity(identity); + if (historicalChunkCount < 0) { + throw new IllegalArgumentException("Historical chunk count cannot be negative"); + } + this.historicalChunkCount = historicalChunkCount; + this.historicalChunks = Objects.requireNonNull(historicalChunks, "Historical chunks"); + } + + public static GenerationBoundary freeze(String identity, Collection historicalChunks) { + Collection requiredChunks = Objects.requireNonNull( + historicalChunks, + "Historical chunks" + ); + long[] packedChunks = new long[requiredChunks.size()]; + int index = 0; + for (ChunkCoordinate chunk : requiredChunks) { + ChunkCoordinate coordinate = Objects.requireNonNull(chunk, "Historical chunk"); + packedChunks[index++] = packChunk(coordinate.chunkX(), coordinate.chunkZ()); + } + return freezePacked(identity, packedChunks); + } + + public static GenerationBoundary freezePacked(String identity, long[] historicalChunks) { + long[] normalized = normalize(Objects.requireNonNull(historicalChunks, "Historical chunks")); + return new GenerationBoundary(identity, normalized.length, new ArraySource(normalized)); + } + + static GenerationBoundary backed( + String identity, + int historicalChunkCount, + HistoricalChunkSource historicalChunks + ) { + return new GenerationBoundary(identity, historicalChunkCount, historicalChunks); + } + + public String identity() { + return identity; + } + + public int historicalChunkCount() { + return historicalChunkCount; + } + + public boolean isHistoricalChunk(int chunkX, int chunkZ) { + try { + return historicalChunks.contains(chunkX, chunkZ); + } catch (IOException error) { + throw new IllegalStateException("Unable to read the frozen generation boundary", error); + } + } + + public boolean isHistoricalBlock(int blockX, int blockZ) { + return isHistoricalChunk(blockToChunk(blockX), blockToChunk(blockZ)); + } + + public boolean intersectsHistoricalBlocks( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + if (minimumX > maximumX || minimumZ > maximumZ) { + throw new IllegalArgumentException("Generation footprint bounds are inverted"); + } + if (historicalChunkCount == 0) { + return false; + } + int minimumChunkX = blockToChunk(minimumX); + int maximumChunkX = blockToChunk(maximumX); + int minimumChunkZ = blockToChunk(minimumZ); + int maximumChunkZ = blockToChunk(maximumZ); + long candidateColumns = (long) maximumChunkX - minimumChunkX + 1L; + long candidateRows = (long) maximumChunkZ - minimumChunkZ + 1L; + long candidateCount = multiplySaturated(candidateColumns, candidateRows); + if (candidateCount <= historicalChunkCount) { + for (long chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + for (long chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + if (isHistoricalChunk((int) chunkX, (int) chunkZ)) { + return true; + } + } + } + return false; + } + boolean[] found = new boolean[1]; + try { + historicalChunks.forEach((chunkX, chunkZ) -> { + if (!found[0] + && chunkX >= minimumChunkX && chunkX <= maximumChunkX + && chunkZ >= minimumChunkZ && chunkZ <= maximumChunkZ) { + found[0] = true; + } + }); + } catch (IOException error) { + throw new IllegalStateException("Unable to scan the frozen generation boundary", error); + } + return found[0]; + } + + public double distanceToHistoricalChunks(int blockX, int blockZ, int maximumDistanceBlocks) { + if (maximumDistanceBlocks <= 0) { + throw new IllegalArgumentException("Maximum distance must be positive"); + } + if (historicalChunkCount == 0) { + return maximumDistanceBlocks; + } + if (isHistoricalBlock(blockX, blockZ)) { + return 0D; + } + + long minimumChunkX = blockToChunk((long) blockX - maximumDistanceBlocks); + long maximumChunkX = blockToChunk((long) blockX + maximumDistanceBlocks); + long minimumChunkZ = blockToChunk((long) blockZ - maximumDistanceBlocks); + long maximumChunkZ = blockToChunk((long) blockZ + maximumDistanceBlocks); + long candidateColumns = maximumChunkX - minimumChunkX + 1L; + long candidateRows = maximumChunkZ - minimumChunkZ + 1L; + long candidateCount = multiplySaturated(candidateColumns, candidateRows); + + double distanceSquared = candidateCount <= historicalChunkCount + ? nearestByNeighborhood( + blockX, + blockZ, + minimumChunkX, + maximumChunkX, + minimumChunkZ, + maximumChunkZ, + maximumDistanceBlocks + ) + : nearestBySource(blockX, blockZ, maximumDistanceBlocks); + return Math.sqrt(distanceSquared); + } + + public long[] packedHistoricalChunks() { + long[] chunks = new long[historicalChunkCount]; + int[] cursor = new int[1]; + try { + historicalChunks.forEach((chunkX, chunkZ) -> { + if (cursor[0] >= chunks.length) { + throw new IOException("Frozen boundary contains more chunks than its catalog declares"); + } + chunks[cursor[0]++] = packChunk(chunkX, chunkZ); + }); + } catch (IOException error) { + throw new IllegalStateException("Unable to materialize the frozen generation boundary", error); + } + if (cursor[0] != chunks.length) { + throw new IllegalStateException("Frozen boundary contains fewer chunks than its catalog declares"); + } + Arrays.sort(chunks); + return chunks; + } + + public void forEachHistoricalChunk(ChunkConsumer consumer) throws IOException { + historicalChunks.forEach(Objects.requireNonNull(consumer, "Historical chunk consumer")); + } + + int cachedRegionCount() { + return historicalChunks.cachedRegionCount(); + } + + public void requireCompleteTerrainSignatures( + Collection signatures + ) { + Collection requiredSignatures = Objects.requireNonNull( + signatures, + "Terrain signatures" + ); + LongOpenHashSet actual = new LongOpenHashSet(requiredSignatures.size()); + for (TerrainBoundarySignature signature : requiredSignatures) { + TerrainBoundarySignature requiredSignature = Objects.requireNonNull( + signature, + "Terrain signature" + ); + if (!isExposedBlockColumn(requiredSignature.blockX(), requiredSignature.blockZ())) { + throw new IllegalArgumentException("Terrain signature is not on the frozen generation boundary: " + + requiredSignature.blockX() + "," + requiredSignature.blockZ()); + } + long column = packBlock(requiredSignature.blockX(), requiredSignature.blockZ()); + if (!actual.add(column)) { + throw new IllegalArgumentException("Duplicate terrain signature column: " + + requiredSignature.blockX() + "," + requiredSignature.blockZ()); + } + } + long expectedCount = exposedBlockColumnCount(); + if (actual.size() != expectedCount) { + throw new IllegalArgumentException("Terrain signatures contain " + actual.size() + + " columns but the frozen boundary requires " + expectedCount); + } + } + + public List exposedBlockColumns() { + ArrayList columns = new ArrayList<>(); + try { + forEachExposedBlockColumn((blockX, blockZ) -> columns.add(new BlockColumn(blockX, blockZ))); + } catch (IOException error) { + throw new IllegalStateException("Unable to enumerate frozen boundary columns", error); + } + columns.sort(Comparator + .comparingInt(BlockColumn::blockX) + .thenComparingInt(BlockColumn::blockZ)); + return List.copyOf(columns); + } + + public long exposedBlockColumnCount() { + long[] count = new long[1]; + try { + forEachExposedBlockColumn((blockX, blockZ) -> count[0]++); + } catch (IOException error) { + throw new IllegalStateException("Unable to count frozen boundary columns", error); + } + return count[0]; + } + + public void forEachExposedBlockColumn(BlockColumnConsumer consumer) throws IOException { + BlockColumnConsumer requiredConsumer = Objects.requireNonNull(consumer, "Boundary column consumer"); + historicalChunks.forEach((chunkX, chunkZ) -> emitExposedColumns(chunkX, chunkZ, requiredConsumer)); + } + + public static long packChunk(int chunkX, int chunkZ) { + return ((long) chunkX << Integer.SIZE) ^ (chunkZ & 0xffffffffL); + } + + public static ChunkCoordinate unpackChunk(long packedChunk) { + return new ChunkCoordinate((int) (packedChunk >> Integer.SIZE), (int) packedChunk); + } + + private void emitExposedColumns( + int chunkX, + int chunkZ, + BlockColumnConsumer consumer + ) throws IOException { + int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE); + int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); + boolean north = chunkZ == Integer.MIN_VALUE || !historicalChunks.contains(chunkX, chunkZ - 1); + boolean south = chunkZ == Integer.MAX_VALUE || !historicalChunks.contains(chunkX, chunkZ + 1); + boolean west = chunkX == Integer.MIN_VALUE || !historicalChunks.contains(chunkX - 1, chunkZ); + boolean east = chunkX == Integer.MAX_VALUE || !historicalChunks.contains(chunkX + 1, chunkZ); + for (int localX = 0; localX < CHUNK_SIZE; localX++) { + for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { + if (localZ == 0 && north + || localZ == CHUNK_SIZE - 1 && south + || localX == 0 && west + || localX == CHUNK_SIZE - 1 && east) { + consumer.accept(minimumX + localX, minimumZ + localZ); + } + } + } + } + + private boolean isExposedBlockColumn(int blockX, int blockZ) { + int chunkX = blockToChunk(blockX); + int chunkZ = blockToChunk(blockZ); + if (!isHistoricalChunk(chunkX, chunkZ)) { + return false; + } + int localX = Math.floorMod(blockX, CHUNK_SIZE); + int localZ = Math.floorMod(blockZ, CHUNK_SIZE); + return localZ == 0 && (chunkZ == Integer.MIN_VALUE || !isHistoricalChunk(chunkX, chunkZ - 1)) + || localZ == CHUNK_SIZE - 1 + && (chunkZ == Integer.MAX_VALUE || !isHistoricalChunk(chunkX, chunkZ + 1)) + || localX == 0 && (chunkX == Integer.MIN_VALUE || !isHistoricalChunk(chunkX - 1, chunkZ)) + || localX == CHUNK_SIZE - 1 + && (chunkX == Integer.MAX_VALUE || !isHistoricalChunk(chunkX + 1, chunkZ)); + } + + private double nearestByNeighborhood( + int blockX, + int blockZ, + long minimumChunkX, + long maximumChunkX, + long minimumChunkZ, + long maximumChunkZ, + int maximumDistanceBlocks + ) { + double nearestSquared = square(maximumDistanceBlocks); + for (long chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + for (long chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + if (!isHistoricalChunk((int) chunkX, (int) chunkZ)) { + continue; + } + nearestSquared = Math.min( + nearestSquared, + distanceSquaredToChunk(blockX, blockZ, (int) chunkX, (int) chunkZ) + ); + } + } + return nearestSquared; + } + + private double nearestBySource(int blockX, int blockZ, int maximumDistanceBlocks) { + double[] nearestSquared = {square(maximumDistanceBlocks)}; + try { + historicalChunks.forEach((chunkX, chunkZ) -> nearestSquared[0] = Math.min( + nearestSquared[0], + distanceSquaredToChunk(blockX, blockZ, chunkX, chunkZ) + )); + } catch (IOException error) { + throw new IllegalStateException("Unable to scan the frozen generation boundary", error); + } + return nearestSquared[0]; + } + + private static double distanceSquaredToChunk(int blockX, int blockZ, int chunkX, int chunkZ) { + long minimumX = (long) chunkX * CHUNK_SIZE; + long maximumX = minimumX + CHUNK_SIZE - 1L; + long minimumZ = (long) chunkZ * CHUNK_SIZE; + long maximumZ = minimumZ + CHUNK_SIZE - 1L; + long distanceX = distanceToRange(blockX, minimumX, maximumX); + long distanceZ = distanceToRange(blockZ, minimumZ, maximumZ); + return square(distanceX) + square(distanceZ); + } + + private static long distanceToRange(long value, long minimum, long maximum) { + if (value < minimum) { + return minimum - value; + } + return value > maximum ? value - maximum : 0L; + } + + private static double square(long value) { + return (double) value * value; + } + + private static long multiplySaturated(long left, long right) { + if (left == 0L || right == 0L) { + return 0L; + } + return left > Long.MAX_VALUE / right ? Long.MAX_VALUE : left * right; + } + + private static int blockToChunk(int blockCoordinate) { + return Math.floorDiv(blockCoordinate, CHUNK_SIZE); + } + + private static long blockToChunk(long blockCoordinate) { + return Math.floorDiv(blockCoordinate, CHUNK_SIZE); + } + + private static long packBlock(int blockX, int blockZ) { + return ((long) blockX << Integer.SIZE) ^ (blockZ & 0xffffffffL); + } + + private static String requireIdentity(String identity) { + String requiredIdentity = Objects.requireNonNull(identity, "Boundary identity"); + if (requiredIdentity.isBlank()) { + throw new IllegalArgumentException("Boundary identity cannot be blank"); + } + return requiredIdentity; + } + + private static long[] normalize(long[] historicalChunks) { + long[] normalized = historicalChunks.clone(); + Arrays.sort(normalized); + if (normalized.length < 2) { + return normalized; + } + int uniqueCount = 1; + for (int index = 1; index < normalized.length; index++) { + if (normalized[index] != normalized[uniqueCount - 1]) { + normalized[uniqueCount++] = normalized[index]; + } + } + return Arrays.copyOf(normalized, uniqueCount); + } + + public record ChunkCoordinate(int chunkX, int chunkZ) { + } + + public record BlockColumn(int blockX, int blockZ) { + } + + @FunctionalInterface + public interface ChunkConsumer { + void accept(int chunkX, int chunkZ) throws IOException; + } + + @FunctionalInterface + public interface BlockColumnConsumer { + void accept(int blockX, int blockZ) throws IOException; + } + + interface HistoricalChunkSource { + boolean contains(int chunkX, int chunkZ) throws IOException; + + void forEach(ChunkConsumer consumer) throws IOException; + + default int cachedRegionCount() { + return 0; + } + } + + private static final class ArraySource implements HistoricalChunkSource { + private final long[] chunks; + + private ArraySource(long[] chunks) { + this.chunks = chunks; + } + + @Override + public boolean contains(int chunkX, int chunkZ) { + return Arrays.binarySearch(chunks, packChunk(chunkX, chunkZ)) >= 0; + } + + @Override + public void forEach(ChunkConsumer consumer) throws IOException { + for (long chunk : chunks) { + consumer.accept((int) (chunk >> Integer.SIZE), (int) chunk); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundarySignatureSampler.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundarySignatureSampler.java new file mode 100644 index 000000000..ee3dfc61e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundarySignatureSampler.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisEngine; + +import java.io.IOException; + +@FunctionalInterface +public interface GenerationBoundarySignatureSampler { + TerrainBoundarySignature sample(IrisEngine engine, int blockX, int blockZ) throws IOException; +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java new file mode 100644 index 000000000..8afebe1ef --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java @@ -0,0 +1,733 @@ +package art.arcane.iris.engine.history; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrays; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.DigestOutputStream; +import java.util.Collection; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.zip.CRC32; + +public final class GenerationBoundaryStore { + public static final String SNAPSHOT_FILE_SUFFIX = ".irgb"; + + private static final String MASK_FILE_SUFFIX = ".irbm"; + private static final int MAGIC = 0x49524742; + private static final int SCHEMA_VERSION = 2; + private static final int MASK_MAGIC = 0x4952424D; + private static final int MASK_SCHEMA_VERSION = 1; + private static final int IDENTITY_BYTES = 32; + private static final int CATALOG_FIXED_BODY_BYTES = 56; + private static final int CATALOG_ENTRY_BYTES = 40; + private static final int CHECKSUM_BYTES = Integer.BYTES; + private static final int REGION_SIDE = 32; + private static final int CHUNKS_PER_REGION = REGION_SIDE * REGION_SIDE; + private static final int MASK_BYTES = CHUNKS_PER_REGION / Byte.SIZE; + private static final int MASK_BODY_BYTES = 8 + MASK_BYTES; + private static final int MAXIMUM_CACHED_MASKS = 64; + private static final int MAX_REGION_COUNT = 8_388_608; + private static final long MAX_CATALOG_BYTES = CATALOG_FIXED_BODY_BYTES + + (long) MAX_REGION_COUNT * CATALOG_ENTRY_BYTES + + CHECKSUM_BYTES; + private static final int TRANSFER_BYTES = 65_536; + private static final byte[] IDENTITY_DOMAIN = { + 0x49, 0x72, 0x69, 0x73, 0x42, 0x6F, 0x75, 0x6E, + 0x64, 0x61, 0x72, 0x79, 0x02 + }; + + private final Path dimensionRoot; + private final Path directory; + + public GenerationBoundaryStore(Path dimensionRoot) { + Path root = Objects.requireNonNull(dimensionRoot, "Dimension root").toAbsolutePath().normalize(); + this.dimensionRoot = root; + this.directory = root.resolve("iris").resolve("generation").resolve("boundaries").normalize(); + if (!directory.startsWith(root)) { + throw new IllegalArgumentException("Generation boundary path escapes the dimension root"); + } + } + + public Path directory() { + return directory; + } + + public Path snapshotPath(long activationId) { + requireActivation(activationId); + return directory.resolve(fileName(activationId)); + } + + public synchronized GenerationBoundary publish( + long activationId, + Collection historicalChunks + ) throws IOException { + Collection requiredChunks = Objects.requireNonNull( + historicalChunks, + "Historical chunks" + ); + MaskCatalogBuilder builder = new MaskCatalogBuilder(); + for (GenerationBoundary.ChunkCoordinate chunk : requiredChunks) { + GenerationBoundary.ChunkCoordinate requiredChunk = Objects.requireNonNull( + chunk, + "Historical chunk" + ); + builder.add(requiredChunk.chunkX(), requiredChunk.chunkZ()); + } + return publishCatalog(activationId, builder); + } + + public synchronized GenerationBoundary publishPacked(long activationId, long[] historicalChunks) + throws IOException { + long[] requiredChunks = Objects.requireNonNull(historicalChunks, "Historical chunks"); + MaskCatalogBuilder builder = new MaskCatalogBuilder(); + for (long chunk : requiredChunks) { + builder.add((int) (chunk >> Integer.SIZE), (int) chunk); + } + return publishCatalog(activationId, builder); + } + + synchronized GenerationBoundary publishOwnership( + long activationId, + ChunkGenerationOwnership ownership + ) throws IOException { + ChunkGenerationOwnership requiredOwnership = Objects.requireNonNull(ownership, "Generation ownership"); + MaskCatalogBuilder builder = new MaskCatalogBuilder(); + requiredOwnership.forEachAssignment((chunkX, chunkZ, owner) -> builder.add(chunkX, chunkZ)); + return publishCatalog(activationId, builder); + } + + public GenerationBoundary load(long activationId) throws IOException { + requireActivation(activationId); + validateStorageAncestors(); + Path snapshot = snapshotPath(activationId); + requireRegularFile(snapshot, "Generation boundary snapshot"); + long fileSize = Files.size(snapshot); + if (fileSize < CATALOG_FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(snapshot, "truncated data"); + } + if (fileSize > MAX_CATALOG_BYTES) { + throw invalid(snapshot, "catalog exceeds the maximum size"); + } + verifyChecksum(snapshot, fileSize, "generation boundary snapshot"); + + long bodyBytes = fileSize - CHECKSUM_BYTES; + try (InputStream fileInput = Files.newInputStream(snapshot); + LimitedInputStream limitedInput = new LimitedInputStream(fileInput, bodyBytes); + DataInputStream input = new DataInputStream(limitedInput)) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(snapshot, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != SCHEMA_VERSION) { + throw invalid(snapshot, "unsupported schema version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(snapshot, "unsupported format flags " + flags); + } + long storedActivation = input.readLong(); + if (storedActivation != activationId) { + throw invalid(snapshot, "activation ID does not match the snapshot path"); + } + int chunkCount = input.readInt(); + if (chunkCount < 0) { + throw invalid(snapshot, "invalid chunk count " + chunkCount); + } + byte[] storedIdentity = input.readNBytes(IDENTITY_BYTES); + if (storedIdentity.length != IDENTITY_BYTES) { + throw invalid(snapshot, "truncated snapshot identity"); + } + int regionCount = input.readInt(); + if (regionCount < 0 || regionCount > MAX_REGION_COUNT) { + throw invalid(snapshot, "invalid region count " + regionCount); + } + long expectedSize = CATALOG_FIXED_BODY_BYTES + + (long) regionCount * CATALOG_ENTRY_BYTES + + CHECKSUM_BYTES; + if (fileSize != expectedSize) { + throw invalid(snapshot, "catalog length does not match the region count"); + } + + long[] regionKeys = new long[regionCount]; + Long2ObjectOpenHashMap shardHashes = new Long2ObjectOpenHashMap<>(regionCount); + int countedChunks = 0; + long previousRegionKey = 0L; + for (int index = 0; index < regionCount; index++) { + int regionX = input.readInt(); + int regionZ = input.readInt(); + long regionKey = packRegion(regionX, regionZ); + if (index > 0 && compareRegions(previousRegionKey, regionKey) >= 0) { + throw invalid(snapshot, "region entries are not in canonical order"); + } + byte[] hashBytes = input.readNBytes(IDENTITY_BYTES); + if (hashBytes.length != IDENTITY_BYTES) { + throw invalid(snapshot, "truncated mask hash"); + } + String hash = HexFormat.of().formatHex(hashBytes); + byte[] mask = readMask(hash); + countedChunks = Math.addExact(countedChunks, countBits(mask)); + regionKeys[index] = regionKey; + shardHashes.put(regionKey, hash); + previousRegionKey = regionKey; + } + if (limitedInput.remaining() != 0L) { + throw invalid(snapshot, "unexpected trailing catalog data"); + } + if (countedChunks != chunkCount) { + throw invalid(snapshot, "chunk count does not match the referenced masks"); + } + byte[] computedIdentity = computeIdentity(activationId, chunkCount, regionKeys, shardHashes); + if (!MessageDigest.isEqual(storedIdentity, computedIdentity)) { + throw invalid(snapshot, "boundary identity mismatch"); + } + MaskSource source = new MaskSource(directory, regionKeys, shardHashes); + return GenerationBoundary.backed(toHex(computedIdentity), chunkCount, source); + } catch (ArithmeticException error) { + throw invalid(snapshot, "chunk count overflows the supported range", error); + } catch (EOFException error) { + throw invalid(snapshot, "truncated data", error); + } + } + + private GenerationBoundary publishCatalog(long activationId, MaskCatalogBuilder builder) throws IOException { + requireActivation(activationId); + ensureDirectory(); + try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire( + directory, + ".activation-" + activationId + SNAPSHOT_FILE_SUFFIX + ".lock" + )) { + ensureDirectory(); + PublishedCatalog catalog = builder.publish(directory, activationId); + Path target = snapshotPath(activationId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + GenerationBoundary existing = load(activationId); + if (!existing.identity().equals(catalog.identity())) { + throw new FileAlreadyExistsException( + "Generation boundary activation " + activationId + + " already has a different snapshot: " + target + ); + } + return existing; + } + publishAtomic(target, catalog.encoded(), "generation boundary catalog"); + return load(activationId); + } + } + + private byte[] readMask(String hash) throws IOException { + Path file = directory.resolve(maskFileName(hash)); + requireRegularFile(file, "Generation boundary mask"); + return MaskShard.read(file, hash); + } + + private void ensureDirectory() throws IOException { + requireSafeDirectory(dimensionRoot); + Path irisDirectory = ensureChildDirectory(dimensionRoot, "iris"); + Path generationDirectory = ensureChildDirectory(irisDirectory, "generation"); + Path boundaryDirectory = ensureChildDirectory(generationDirectory, "boundaries"); + if (!boundaryDirectory.equals(directory)) { + throw new IOException("Generation boundary resolved outside its storage path: " + boundaryDirectory); + } + } + + private void validateStorageAncestors() throws IOException { + requireSafeDirectory(dimensionRoot); + requireSafeDirectory(dimensionRoot.resolve("iris")); + requireSafeDirectory(dimensionRoot.resolve("iris").resolve("generation")); + requireSafeDirectory(directory); + } + + private static Path ensureChildDirectory(Path parent, String name) throws IOException { + Path child = parent.resolve(name); + if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) { + requireSafeDirectory(child); + return child; + } + try { + Files.createDirectory(child); + } catch (FileAlreadyExistsException race) { + requireSafeDirectory(child); + return child; + } + forceDirectory(parent); + return child; + } + + private static void requireSafeDirectory(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(path.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + path, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation boundary path is not a safe directory: " + path); + } + } + + private static void requireRegularFile(Path path, String label) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(path.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + path, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw new IOException(label + " is not a safe regular file: " + path); + } + } + + private static byte[] computeIdentity( + long activationId, + int chunkCount, + long[] regionKeys, + Long2ObjectOpenHashMap shardHashes + ) throws IOException { + MessageDigest digest = sha256(); + digest.update(IDENTITY_DOMAIN); + try (DigestOutputStream digestOutput = new DigestOutputStream(OutputStream.nullOutputStream(), digest); + DataOutputStream output = new DataOutputStream(digestOutput)) { + output.writeLong(activationId); + output.writeInt(chunkCount); + output.writeInt(regionKeys.length); + for (long regionKey : regionKeys) { + output.writeInt(regionX(regionKey)); + output.writeInt(regionZ(regionKey)); + output.write(HexFormat.of().parseHex(shardHashes.get(regionKey))); + } + } + return digest.digest(); + } + + private static byte[] encodeCatalog( + long activationId, + int chunkCount, + long[] regionKeys, + Long2ObjectOpenHashMap shardHashes, + byte[] identity + ) throws IOException { + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream( + CATALOG_FIXED_BODY_BYTES + regionKeys.length * CATALOG_ENTRY_BYTES + ); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MAGIC); + output.writeShort(SCHEMA_VERSION); + output.writeShort(0); + output.writeLong(activationId); + output.writeInt(chunkCount); + output.write(identity); + output.writeInt(regionKeys.length); + for (long regionKey : regionKeys) { + output.writeInt(regionX(regionKey)); + output.writeInt(regionZ(regionKey)); + output.write(HexFormat.of().parseHex(shardHashes.get(regionKey))); + } + } + return withChecksum(bodyBytes.toByteArray()); + } + + private static void publishAtomic(Path target, byte[] encoded, String kind) throws IOException { + Path directory = target.getParent(); + Path temporary = Files.createTempFile(directory, ".boundary-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException error) { + throw new IOException("Generation boundary " + kind + " publication requires an atomic move", error); + } + forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static byte[] withChecksum(byte[] body) throws IOException { + CRC32 checksum = new CRC32(); + checksum.update(body); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); + encoded.write(body); + try (DataOutputStream output = new DataOutputStream(encoded)) { + output.writeInt((int) checksum.getValue()); + } + return encoded.toByteArray(); + } + + private static void verifyChecksum(Path file, long fileSize, String kind) throws IOException { + long bodyBytes = fileSize - CHECKSUM_BYTES; + CRC32 checksum = new CRC32(); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { + ByteBuffer buffer = ByteBuffer.allocate(TRANSFER_BYTES); + long remaining = bodyBytes; + while (remaining > 0L) { + buffer.clear(); + buffer.limit((int) Math.min(buffer.capacity(), remaining)); + readFully(channel, buffer); + buffer.flip(); + checksum.update(buffer); + remaining -= buffer.limit(); + } + ByteBuffer stored = ByteBuffer.allocate(CHECKSUM_BYTES); + readFully(channel, stored); + stored.flip(); + if (stored.getInt() != (int) checksum.getValue()) { + throw new IOException("Invalid " + kind + " " + file + ": checksum mismatch"); + } + } + } + + private static int countBits(byte[] mask) { + int count = 0; + for (byte value : mask) { + count += Integer.bitCount(value & 0xff); + } + return count; + } + + private static long packRegion(int regionX, int regionZ) { + return ((long) regionX << Integer.SIZE) | (regionZ & 0xffffffffL); + } + + private static int regionX(long regionKey) { + return (int) (regionKey >> Integer.SIZE); + } + + private static int regionZ(long regionKey) { + return (int) regionKey; + } + + private static int compareRegions(long first, long second) { + int xComparison = Integer.compare(regionX(first), regionX(second)); + return xComparison != 0 ? xComparison : Integer.compare(regionZ(first), regionZ(second)); + } + + private static String fileName(long activationId) { + return "activation-" + activationId + SNAPSHOT_FILE_SUFFIX; + } + + private static String maskFileName(String hash) { + return "chunk-mask-" + hash + MASK_FILE_SUFFIX; + } + + private static void requireActivation(long activationId) { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static String sha256(byte[] encoded) { + return HexFormat.of().formatHex(sha256().digest(encoded)); + } + + private static String toHex(byte[] bytes) { + return HexFormat.of().formatHex(bytes); + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static void readFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + if (channel.read(buffer) < 0) { + throw new EOFException("Unexpected end of generation boundary data"); + } + } + } + + private static void forceDirectory(Path path) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException error) { + throw new IOException("Generation boundary directory cannot be durability-synced: " + path, error); + } + } + + private static IOException invalid(Path path, String reason) { + return new IOException("Invalid generation boundary snapshot " + path + ": " + reason); + } + + private static IOException invalid(Path path, String reason, Throwable cause) { + return new IOException("Invalid generation boundary snapshot " + path + ": " + reason, cause); + } + + private static final class MaskCatalogBuilder { + private final Long2ObjectOpenHashMap masks = new Long2ObjectOpenHashMap<>(); + private int chunkCount; + + private void add(int chunkX, int chunkZ) { + int regionX = chunkX >> 5; + int regionZ = chunkZ >> 5; + long regionKey = packRegion(regionX, regionZ); + byte[] mask = masks.computeIfAbsent(regionKey, ignored -> new byte[MASK_BYTES]); + int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5) + | Math.floorMod(chunkX, REGION_SIDE); + int byteIndex = localIndex >>> 3; + int bit = 1 << (localIndex & 7); + if ((mask[byteIndex] & bit) == 0) { + mask[byteIndex] = (byte) (mask[byteIndex] | bit); + chunkCount = Math.addExact(chunkCount, 1); + } + } + + private PublishedCatalog publish(Path directory, long activationId) throws IOException { + if (masks.size() > MAX_REGION_COUNT) { + throw new IOException("Generation boundary exceeds the maximum region count"); + } + long[] regionKeys = masks.keySet().toLongArray(); + sortRegionKeys(regionKeys); + Long2ObjectOpenHashMap shardHashes = new Long2ObjectOpenHashMap<>(regionKeys.length); + for (long regionKey : regionKeys) { + String hash = MaskShard.publish(directory, masks.get(regionKey)); + shardHashes.put(regionKey, hash); + } + byte[] identity = computeIdentity(activationId, chunkCount, regionKeys, shardHashes); + byte[] encoded = encodeCatalog(activationId, chunkCount, regionKeys, shardHashes, identity); + return new PublishedCatalog(toHex(identity), encoded); + } + + private static void sortRegionKeys(long[] regionKeys) { + LongArrays.quickSort(regionKeys, GenerationBoundaryStore::compareRegions); + } + } + + private record PublishedCatalog(String identity, byte[] encoded) { + } + + private static final class MaskShard { + private static String publish(Path directory, byte[] mask) throws IOException { + byte[] encoded = encode(mask); + String hash = sha256(encoded); + Path target = directory.resolve(maskFileName(hash)); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireRegularFile(target, "Generation boundary mask"); + read(target, hash); + return hash; + } + try { + publishAtomic(target, encoded, "mask shard"); + } catch (FileAlreadyExistsException race) { + requireRegularFile(target, "Generation boundary mask"); + read(target, hash); + } + return hash; + } + + private static byte[] encode(byte[] mask) throws IOException { + if (mask.length != MASK_BYTES || countBits(mask) == 0) { + throw new IOException("Generation boundary mask is empty or malformed"); + } + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(MASK_BODY_BYTES); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MASK_MAGIC); + output.writeShort(MASK_SCHEMA_VERSION); + output.writeShort(0); + output.write(mask); + } + return withChecksum(bodyBytes.toByteArray()); + } + + private static byte[] read(Path file, String expectedHash) throws IOException { + byte[] encoded = Files.readAllBytes(file); + if (encoded.length != MASK_BODY_BYTES + CHECKSUM_BYTES) { + throw new IOException("Invalid generation boundary mask " + file + ": invalid length"); + } + if (!sha256(encoded).equals(expectedHash)) { + throw new IOException("Invalid generation boundary mask " + file + ": content hash mismatch"); + } + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, MASK_BODY_BYTES); + if (ByteBuffer.wrap(encoded, MASK_BODY_BYTES, CHECKSUM_BYTES).getInt() + != (int) checksum.getValue()) { + throw new IOException("Invalid generation boundary mask " + file + ": checksum mismatch"); + } + try (DataInputStream input = new DataInputStream( + new ByteArrayInputStream(encoded, 0, MASK_BODY_BYTES) + )) { + if (input.readInt() != MASK_MAGIC) { + throw new IOException("Invalid generation boundary mask " + file + ": invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != MASK_SCHEMA_VERSION) { + throw new IOException("Invalid generation boundary mask " + file + + ": unsupported schema version " + version); + } + if (input.readUnsignedShort() != 0) { + throw new IOException("Invalid generation boundary mask " + file + ": unsupported flags"); + } + byte[] mask = input.readNBytes(MASK_BYTES); + if (mask.length != MASK_BYTES || countBits(mask) == 0 || input.available() != 0) { + throw new IOException("Invalid generation boundary mask " + file + ": malformed mask"); + } + return mask; + } + } + } + + private static final class MaskSource implements GenerationBoundary.HistoricalChunkSource { + private final Path directory; + private final long[] regionKeys; + private final Long2ObjectOpenHashMap shardHashes; + private final LinkedHashMap masks; + + private MaskSource( + Path directory, + long[] regionKeys, + Long2ObjectOpenHashMap shardHashes + ) { + this.directory = directory; + this.regionKeys = regionKeys; + this.shardHashes = shardHashes; + masks = new LinkedHashMap<>(MAXIMUM_CACHED_MASKS, 0.75F, true); + } + + @Override + public synchronized boolean contains(int chunkX, int chunkZ) throws IOException { + long regionKey = packRegion(chunkX >> 5, chunkZ >> 5); + String hash = shardHashes.get(regionKey); + if (hash == null) { + return false; + } + byte[] mask = load(regionKey, hash); + int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5) + | Math.floorMod(chunkX, REGION_SIDE); + return (mask[localIndex >>> 3] & (1 << (localIndex & 7))) != 0; + } + + @Override + public synchronized void forEach(GenerationBoundary.ChunkConsumer consumer) throws IOException { + for (long regionKey : regionKeys) { + byte[] mask = load(regionKey, shardHashes.get(regionKey)); + int minimumChunkX = regionX(regionKey) << 5; + int minimumChunkZ = regionZ(regionKey) << 5; + for (int localIndex = 0; localIndex < CHUNKS_PER_REGION; localIndex++) { + if ((mask[localIndex >>> 3] & (1 << (localIndex & 7))) == 0) { + continue; + } + consumer.accept( + minimumChunkX + (localIndex & 31), + minimumChunkZ + (localIndex >>> 5) + ); + } + } + } + + @Override + public synchronized int cachedRegionCount() { + return masks.size(); + } + + private byte[] load(long regionKey, String hash) throws IOException { + byte[] cached = masks.get(regionKey); + if (cached != null) { + return cached; + } + Path file = directory.resolve(maskFileName(hash)); + requireRegularFile(file, "Generation boundary mask"); + byte[] loaded = MaskShard.read(file, hash); + masks.put(regionKey, loaded); + while (masks.size() > MAXIMUM_CACHED_MASKS) { + Iterator> entries = masks.entrySet().iterator(); + entries.next(); + entries.remove(); + } + return loaded; + } + } + + private static final class LimitedInputStream extends InputStream { + private final InputStream input; + private long remaining; + + private LimitedInputStream(InputStream input, long remaining) { + this.input = input; + this.remaining = remaining; + } + + @Override + public int read() throws IOException { + if (remaining == 0L) { + return -1; + } + int value = input.read(); + if (value < 0) { + throw new EOFException("Unexpected end of bounded generation boundary input"); + } + remaining--; + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + Objects.checkFromIndexSize(offset, length, buffer.length); + if (remaining == 0L) { + return -1; + } + int read = input.read(buffer, offset, (int) Math.min(length, remaining)); + if (read < 0) { + throw new EOFException("Unexpected end of bounded generation boundary input"); + } + remaining -= read; + return read; + } + + @Override + public void close() throws IOException { + input.close(); + } + + private long remaining() { + return remaining; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationEpoch.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpoch.java new file mode 100644 index 000000000..04245be6c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpoch.java @@ -0,0 +1,449 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonObject; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class GenerationEpoch { + public static final int SEED_DERIVATION_VERSION_ONE = 1; + public static final int CURRENT_SEED_DERIVATION_VERSION = SEED_DERIVATION_VERSION_ONE; + + private static final int IDENTITY_SCHEMA = 1; + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + private static final Pattern DIMENSION_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*"); + private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); + private static final Pattern ENVIRONMENT_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]+"); + + private final String epochId; + private final Spec spec; + + private GenerationEpoch(String epochId, Spec spec) { + this.spec = Objects.requireNonNull(spec, "spec"); + this.epochId = requireSha256(epochId, "Epoch ID"); + String expectedEpochId = identity(spec); + if (!this.epochId.equals(expectedEpochId)) { + throw new IllegalArgumentException("Epoch ID does not match its generation inputs."); + } + } + + public static GenerationEpoch create(Spec spec) { + Spec requiredSpec = Objects.requireNonNull(spec, "spec"); + return new GenerationEpoch(identity(requiredSpec), requiredSpec); + } + + static GenerationEpoch restore(String epochId, Spec spec) { + return new GenerationEpoch(epochId, spec); + } + + public String epochId() { + return epochId; + } + + public String packFingerprint() { + return spec.packFingerprint(); + } + + public int generatorAbi() { + return spec.generatorAbi(); + } + + public int packFingerprintVersion() { + return spec.packFingerprintVersion(); + } + + public int rngVersion() { + return spec.rngVersion(); + } + + public String kernelImplementationFingerprint() { + return spec.kernelImplementationFingerprint(); + } + + public long worldSeed() { + return spec.worldSeed(); + } + + public int seedDerivationVersion() { + return spec.seedDerivationVersion(); + } + + public GenerationKernelRegistry.Version kernelVersion() { + return new GenerationKernelRegistry.Version( + generatorAbi(), + rngVersion(), + seedDerivationVersion() + ); + } + + public DimensionContract dimensionContract() { + return spec.dimensionContract(); + } + + public GenerationRegistryContract registryContract() { + return spec.registryContract(); + } + + public Spec spec() { + return spec; + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("epochId", epochId); + json.addProperty("packFingerprint", packFingerprint()); + json.addProperty("packFingerprintVersion", packFingerprintVersion()); + json.addProperty("worldSeed", worldSeed()); + json.addProperty("seedDerivationVersion", seedDerivationVersion()); + json.addProperty("generatorAbi", generatorAbi()); + json.addProperty("rngVersion", rngVersion()); + json.addProperty("kernelImplementationFingerprint", kernelImplementationFingerprint()); + json.add("dimensionContract", dimensionContract().toJson()); + json.add("registryContract", registryContract().toJson()); + return json; + } + + static GenerationEpoch fromJson(JsonObject json) { + GenerationManifest.JsonSchema.requireFields( + json, + "epoch", + "epochId", + "packFingerprint", + "packFingerprintVersion", + "worldSeed", + "seedDerivationVersion", + "generatorAbi", + "rngVersion", + "kernelImplementationFingerprint", + "dimensionContract", + "registryContract" + ); + String epochId = GenerationManifest.JsonSchema.requireString(json, "epochId", "epoch"); + String packFingerprint = GenerationManifest.JsonSchema.requireString(json, "packFingerprint", "epoch"); + int packFingerprintVersion = GenerationManifest.JsonSchema.requireInt( + json, + "packFingerprintVersion", + "epoch" + ); + long worldSeed = GenerationManifest.JsonSchema.requireLong(json, "worldSeed", "epoch"); + int seedDerivationVersion = GenerationManifest.JsonSchema.requireInt( + json, + "seedDerivationVersion", + "epoch" + ); + int generatorAbi = GenerationManifest.JsonSchema.requireInt(json, "generatorAbi", "epoch"); + int rngVersion = GenerationManifest.JsonSchema.requireInt(json, "rngVersion", "epoch"); + String kernelImplementationFingerprint = GenerationManifest.JsonSchema.requireString( + json, + "kernelImplementationFingerprint", + "epoch" + ); + JsonObject contractJson = GenerationManifest.JsonSchema.requireObject(json, "dimensionContract", "epoch"); + DimensionContract contract = DimensionContract.fromJson(contractJson); + JsonObject registryContractJson = GenerationManifest.JsonSchema.requireObject( + json, + "registryContract", + "epoch" + ); + GenerationRegistryContract registryContract = GenerationRegistryContract.fromJson(registryContractJson); + return restore(epochId, new Spec( + packFingerprint, + packFingerprintVersion, + worldSeed, + seedDerivationVersion, + generatorAbi, + rngVersion, + kernelImplementationFingerprint, + contract, + registryContract + )); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GenerationEpoch epoch)) { + return false; + } + return epochId.equals(epoch.epochId) && spec.equals(epoch.spec); + } + + @Override + public int hashCode() { + return Objects.hash(epochId, spec); + } + + @Override + public String toString() { + return "GenerationEpoch[epochId=" + epochId + ", spec=" + spec + "]"; + } + + private static String identity(Spec spec) { + MessageDigest digest = sha256(); + updateString(digest, "iris-generation-epoch"); + updateInt(digest, IDENTITY_SCHEMA); + updateString(digest, spec.packFingerprint()); + updateInt(digest, spec.packFingerprintVersion()); + updateLong(digest, spec.worldSeed()); + updateInt(digest, spec.seedDerivationVersion()); + updateInt(digest, spec.generatorAbi()); + updateInt(digest, spec.rngVersion()); + updateString(digest, spec.kernelImplementationFingerprint()); + DimensionContract contract = spec.dimensionContract(); + updateString(digest, contract.dimensionKey()); + updateString(digest, contract.dimensionTypeKey()); + updateString(digest, contract.environment()); + updateString(digest, contract.generationMode()); + updateInt(digest, contract.internalFluidHeight()); + updateInt(digest, contract.minHeight()); + updateInt(digest, contract.height()); + updateInt(digest, contract.logicalHeight()); + updateLong(digest, Double.doubleToLongBits(contract.coordinateScale())); + digest.update((byte) (contract.upperTerrainEnabled() ? 1 : 0)); + updateString(digest, contract.upperDimensionKey()); + updateInt(digest, contract.upperDimensionGap()); + updateString(digest, contract.upperTerrainPackFingerprint()); + updateInt(digest, contract.dimensionTypeFingerprintSchema()); + updateString(digest, contract.dimensionTypeFingerprint()); + GenerationRegistryContract registryContract = spec.registryContract(); + updateInt(digest, registryContract.fingerprintSchema()); + updateString(digest, registryContract.fingerprint()); + return HexFormat.of().formatHex(digest.digest()); + } + + private static String requireSha256(String value, String label) { + String requiredValue = Objects.requireNonNull(value, label); + if (!SHA_256_PATTERN.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value."); + } + return requiredValue; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private static void updateString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + private static void updateLong(MessageDigest digest, long value) { + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + public record Spec( + String packFingerprint, + int packFingerprintVersion, + long worldSeed, + int seedDerivationVersion, + int generatorAbi, + int rngVersion, + String kernelImplementationFingerprint, + DimensionContract dimensionContract, + GenerationRegistryContract registryContract + ) { + public Spec { + packFingerprint = requireSha256(packFingerprint, "Pack fingerprint"); + if (packFingerprintVersion < 1) { + throw new IllegalArgumentException("Pack fingerprint version must be positive."); + } + if (seedDerivationVersion < 1) { + throw new IllegalArgumentException("Seed derivation version must be positive."); + } + if (generatorAbi < 1) { + throw new IllegalArgumentException("Generator ABI must be positive."); + } + if (rngVersion < 1) { + throw new IllegalArgumentException("RNG version must be positive."); + } + kernelImplementationFingerprint = requireSha256( + kernelImplementationFingerprint, + "Generation kernel implementation fingerprint" + ); + dimensionContract = Objects.requireNonNull(dimensionContract, "dimensionContract"); + registryContract = Objects.requireNonNull(registryContract, "registryContract"); + } + } + + public record DimensionContract( + String dimensionKey, + String dimensionTypeKey, + String environment, + String generationMode, + int internalFluidHeight, + int minHeight, + int height, + int logicalHeight, + double coordinateScale, + boolean upperTerrainEnabled, + String upperDimensionKey, + int upperDimensionGap, + String upperTerrainPackFingerprint, + int dimensionTypeFingerprintSchema, + String dimensionTypeFingerprint + ) { + public DimensionContract { + dimensionKey = requirePattern(dimensionKey, "Dimension key", DIMENSION_KEY_PATTERN); + dimensionTypeKey = requirePattern(dimensionTypeKey, "Dimension type key", RESOURCE_KEY_PATTERN); + environment = requirePattern(environment, "Dimension environment", ENVIRONMENT_PATTERN); + generationMode = requirePattern(generationMode, "Generation mode", ENVIRONMENT_PATTERN); + if (height < 1) { + throw new IllegalArgumentException("Dimension height must be positive."); + } + Math.addExact(minHeight, height); + if (logicalHeight < 0 || logicalHeight > height) { + throw new IllegalArgumentException("Logical height must be inside the dimension height."); + } + if (!Double.isFinite(coordinateScale) || coordinateScale <= 0D || coordinateScale > 30000000D) { + throw new IllegalArgumentException("Coordinate scale must be finite and inside (0, 30000000]."); + } + upperDimensionKey = requirePattern( + upperDimensionKey, + "Upper dimension key", + DIMENSION_KEY_PATTERN + ); + if (upperDimensionGap < 0) { + throw new IllegalArgumentException("Upper dimension gap must not be negative."); + } + upperTerrainPackFingerprint = requireSha256( + upperTerrainPackFingerprint, + "Upper terrain pack fingerprint" + ); + if (upperTerrainEnabled == upperDimensionKey.equals("none")) { + throw new IllegalArgumentException("Upper terrain enablement and dimension key disagree."); + } + if (upperTerrainEnabled == upperTerrainPackFingerprint.equals("0".repeat(64))) { + throw new IllegalArgumentException("Upper terrain enablement and pack fingerprint disagree."); + } + if (!upperTerrainEnabled && upperDimensionGap != 0) { + throw new IllegalArgumentException("Disabled upper terrain must have a zero gap."); + } + GenerationEpochContractFactory.requireSupportedDimensionTypeFingerprintSchema( + dimensionTypeFingerprintSchema + ); + dimensionTypeFingerprint = requireSha256(dimensionTypeFingerprint, "Dimension type fingerprint"); + } + + public boolean hasSameLayout(DimensionContract other) { + Objects.requireNonNull(other, "Dimension contract"); + return dimensionKey.equals(other.dimensionKey) + && dimensionTypeKey.equals(other.dimensionTypeKey) + && environment.equals(other.environment) + && generationMode.equals(other.generationMode) + && internalFluidHeight == other.internalFluidHeight + && minHeight == other.minHeight + && height == other.height + && logicalHeight == other.logicalHeight + && Double.compare(coordinateScale, other.coordinateScale) == 0 + && upperTerrainEnabled == other.upperTerrainEnabled + && upperDimensionKey.equals(other.upperDimensionKey) + && upperDimensionGap == other.upperDimensionGap + && dimensionTypeFingerprintSchema == other.dimensionTypeFingerprintSchema + && dimensionTypeFingerprint.equals(other.dimensionTypeFingerprint); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("dimensionKey", dimensionKey); + json.addProperty("dimensionTypeKey", dimensionTypeKey); + json.addProperty("environment", environment); + json.addProperty("generationMode", generationMode); + json.addProperty("internalFluidHeight", internalFluidHeight); + json.addProperty("minHeight", minHeight); + json.addProperty("height", height); + json.addProperty("logicalHeight", logicalHeight); + json.addProperty("coordinateScale", coordinateScale); + json.addProperty("upperTerrainEnabled", upperTerrainEnabled); + json.addProperty("upperDimensionKey", upperDimensionKey); + json.addProperty("upperDimensionGap", upperDimensionGap); + json.addProperty("upperTerrainPackFingerprint", upperTerrainPackFingerprint); + json.addProperty("dimensionTypeFingerprintSchema", dimensionTypeFingerprintSchema); + json.addProperty("dimensionTypeFingerprint", dimensionTypeFingerprint); + return json; + } + + static DimensionContract fromJson(JsonObject json) { + GenerationManifest.JsonSchema.requireFields( + json, + "dimension contract", + "dimensionKey", + "dimensionTypeKey", + "environment", + "generationMode", + "internalFluidHeight", + "minHeight", + "height", + "logicalHeight", + "coordinateScale", + "upperTerrainEnabled", + "upperDimensionKey", + "upperDimensionGap", + "upperTerrainPackFingerprint", + "dimensionTypeFingerprintSchema", + "dimensionTypeFingerprint" + ); + return new DimensionContract( + GenerationManifest.JsonSchema.requireString(json, "dimensionKey", "dimension contract"), + GenerationManifest.JsonSchema.requireString(json, "dimensionTypeKey", "dimension contract"), + GenerationManifest.JsonSchema.requireString(json, "environment", "dimension contract"), + GenerationManifest.JsonSchema.requireString(json, "generationMode", "dimension contract"), + GenerationManifest.JsonSchema.requireInt(json, "internalFluidHeight", "dimension contract"), + GenerationManifest.JsonSchema.requireInt(json, "minHeight", "dimension contract"), + GenerationManifest.JsonSchema.requireInt(json, "height", "dimension contract"), + GenerationManifest.JsonSchema.requireInt(json, "logicalHeight", "dimension contract"), + GenerationManifest.JsonSchema.requireDouble(json, "coordinateScale", "dimension contract"), + GenerationManifest.JsonSchema.requireBoolean( + json, + "upperTerrainEnabled", + "dimension contract" + ), + GenerationManifest.JsonSchema.requireString(json, "upperDimensionKey", "dimension contract"), + GenerationManifest.JsonSchema.requireInt(json, "upperDimensionGap", "dimension contract"), + GenerationManifest.JsonSchema.requireString( + json, + "upperTerrainPackFingerprint", + "dimension contract" + ), + GenerationManifest.JsonSchema.requireInt( + json, + "dimensionTypeFingerprintSchema", + "dimension contract" + ), + GenerationManifest.JsonSchema.requireString( + json, + "dimensionTypeFingerprint", + "dimension contract" + ) + ); + } + + public int maxHeight() { + return Math.addExact(minHeight, height); + } + + private static String requirePattern(String value, String label, Pattern pattern) { + String requiredValue = Objects.requireNonNull(value, label); + if (!pattern.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " is invalid: " + requiredValue); + } + return requiredValue; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java new file mode 100644 index 000000000..ca8c3fe15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationEpochContractFactory.java @@ -0,0 +1,211 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionMode; +import art.arcane.iris.engine.object.IrisDimensionModeType; +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.engine.object.IrisEnvironment; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Objects; + +public final class GenerationEpochContractFactory { + public static final int DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE = 1; + public static final int CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA = + DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE; + + private static final String FINGERPRINT_DOMAIN = "iris-dimension-type-authored-semantics"; + private static final String NO_UPPER_TERRAIN_FINGERPRINT = "0".repeat(64); + private static final Gson SEMANTIC_JSON = new GsonBuilder().serializeNulls().create(); + + private GenerationEpochContractFactory() { + } + + public static GenerationEpoch.DimensionContract create( + IrisDimension dimension, + String dimensionKey, + String dimensionTypeKey + ) { + return create( + dimension, + dimensionKey, + dimensionTypeKey, + CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA + ); + } + + public static GenerationEpoch.DimensionContract create( + IrisDimension dimension, + String dimensionKey, + String dimensionTypeKey, + int dimensionTypeFingerprintSchema + ) { + requireSupportedDimensionTypeFingerprintSchema(dimensionTypeFingerprintSchema); + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + IrisEnvironment environment = Objects.requireNonNull( + requiredDimension.getEnvironment(), + "Dimension environment" + ); + IrisDimensionType dimensionType = requiredDimension.getDimensionType(); + requireDimensionAgreement(requiredDimension, dimensionType); + IrisDimensionMode configuredMode = requiredDimension.getMode(); + IrisDimensionModeType mode = configuredMode == null || configuredMode.getType() == null + ? IrisDimensionModeType.OVERWORLD + : configuredMode.getType(); + boolean upperTerrainEnabled = requiredDimension.hasUpperDimension(); + String upperDimensionKey = upperTerrainEnabled + ? requireUpperDimensionKey(requiredDimension) + : "none"; + + return new GenerationEpoch.DimensionContract( + dimensionKey, + dimensionTypeKey, + environment.name(), + mode.name(), + requiredDimension.getFluidHeight(), + dimensionType.minY(), + dimensionType.height(), + dimensionType.logicalHeight(), + effectiveCoordinateScale(dimensionType), + upperTerrainEnabled, + upperDimensionKey, + upperTerrainEnabled ? requiredDimension.getUpperDimensionGap() : 0, + upperTerrainEnabled + ? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey) + : NO_UPPER_TERRAIN_FINGERPRINT, + dimensionTypeFingerprintSchema, + fingerprintDimensionType(dimensionType, dimensionTypeFingerprintSchema) + ); + } + + public static String fingerprintDimensionType(IrisDimensionType dimensionType) { + return fingerprintDimensionType( + dimensionType, + CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA + ); + } + + public static String dimensionTypeSemanticJson(IrisDimensionType dimensionType) { + IrisDimensionType requiredType = Objects.requireNonNull(dimensionType, "dimensionType"); + JsonObject semantic = new JsonObject(); + semantic.addProperty("base", requiredType.base().name()); + semantic.addProperty("logicalHeight", requiredType.logicalHeight()); + semantic.addProperty("height", requiredType.height()); + semantic.addProperty("minY", requiredType.minY()); + semantic.add("options", SEMANTIC_JSON.toJsonTree(requiredType.options())); + return semantic.toString(); + } + + public static String fingerprintDimensionType( + IrisDimensionType dimensionType, + int fingerprintSchema + ) { + requireSupportedDimensionTypeFingerprintSchema(fingerprintSchema); + IrisDimensionType requiredType = Objects.requireNonNull(dimensionType, "dimensionType"); + MessageDigest digest = sha256(); + updateString(digest, FINGERPRINT_DOMAIN); + updateInt(digest, fingerprintSchema); + updateString(digest, dimensionTypeSemanticJson(requiredType)); + return HexFormat.of().formatHex(digest.digest()); + } + + public static void requireSupportedDimensionTypeFingerprintSchema(int fingerprintSchema) { + if (fingerprintSchema != DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE) { + throw new IllegalArgumentException( + "Unsupported dimension-type fingerprint schema " + fingerprintSchema + "." + ); + } + } + + private static double effectiveCoordinateScale(IrisDimensionType dimensionType) { + IrisDimensionTypeOptions options = dimensionType.options(); + double configured = options.coordinateScale(); + if (configured != -1D) { + return configured; + } + return dimensionType.base() == IDataFixer.Dimension.NETHER ? 8D : 1D; + } + + private static String requireUpperDimensionKey(IrisDimension dimension) { + String key = Objects.requireNonNull( + dimension.getUpperDimension(), + "Upper dimension key" + ).trim().toLowerCase(Locale.ROOT); + if (key.isEmpty() || key.equals("none")) { + throw new IllegalStateException("Enabled upper terrain requires a dimension key."); + } + return key; + } + + private static String requireUpperTerrainPackFingerprint( + IrisDimension dimension, + String upperDimensionKey + ) { + IrisData data = Objects.requireNonNull( + dimension.getLoader(), + "Upper terrain dimension loader" + ); + if (!upperDimensionKey.equals(dimension.getLoadKey())) { + IrisDimension upperDimension = data.getDimensionLoader().load(upperDimensionKey, false); + if (upperDimension == null || upperDimension.getLoader() != data) { + throw new IllegalStateException("Upper dimension '" + upperDimensionKey + + "' must exist in the same immutable Iris pack."); + } + } + try { + return GenerationPackFingerprint.compute( + data.getDataFolder().toPath(), + GenerationPackFingerprint.CURRENT_VERSION + ); + } catch (IOException failure) { + throw new IllegalStateException("Unable to fingerprint the immutable upper terrain pack.", failure); + } + } + + private static void requireDimensionAgreement( + IrisDimension dimension, + IrisDimensionType dimensionType + ) { + int declaredMinHeight = dimension.getMinHeight(); + int declaredHeight = Math.subtractExact(dimension.getMaxHeight(), declaredMinHeight); + int declaredLogicalHeight = dimension.getLogicalHeight(); + if (dimensionType.minY() == declaredMinHeight + && dimensionType.height() == declaredHeight + && dimensionType.logicalHeight() == declaredLogicalHeight) { + return; + } + throw new IllegalStateException( + "Iris dimension-type semantics disagree with the dimension contract." + ); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private static void updateString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationFindCatalog.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationFindCatalog.java new file mode 100644 index 000000000..790cfab7b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationFindCatalog.java @@ -0,0 +1,221 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.volmlib.util.collection.KList; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; + +public final class GenerationFindCatalog { + private static final Map RETAINED = new WeakHashMap<>(); + private static final RetainedCatalog EMPTY = new RetainedCatalog(0L); + + private GenerationFindCatalog() { + } + + public static KList biomes(Engine engine) { + return merge(engine.getAllBiomes(), retained(engine).biomes); + } + + public static IrisBiome biome(Engine engine, String key) { + return find(biomes(engine), key); + } + + public static KList regions(Engine engine) { + return merge(engine.getDimension().getAllRegions(engine), retained(engine).regions); + } + + public static IrisRegion region(Engine engine, String key) { + return find(regions(engine), key); + } + + public static KList objectKeys(Engine engine) { + Set keys = new LinkedHashSet<>(); + for (String key : engine.getData().getObjectLoader().getPossibleKeys()) { + keys.add(key); + } + keys.addAll(retained(engine).objects); + return new KList<>(keys); + } + + public static boolean hasObjectPlacement(Engine engine, String key) { + return engine.hasObjectPlacement(key) || retained(engine).objects.contains(normalizeObjectKey(key)); + } + + public static KList retainedStructureKeys(Engine engine) { + return new KList<>(retained(engine).structures); + } + + public static boolean hasRetainedStructurePlacement(Engine engine, String key) { + return retained(engine).structures.contains(normalizeKey(key)); + } + + private static RetainedCatalog retained(Engine engine) { + if (!(engine instanceof IrisEngine irisEngine)) { + return EMPTY; + } + return irisEngine.getGenerationHistoryRuntimeRouter() + .map(router -> retained(router.history())) + .orElse(EMPTY); + } + + private static RetainedCatalog retained(GenerationHistory history) { + GenerationManifest manifest = history.manifest(); + synchronized (RETAINED) { + RetainedCatalog catalog = RETAINED.get(history); + if (catalog != null && catalog.activeActivationId == manifest.activeActivation().activationId()) { + return catalog; + } + catalog = loadRetained(history, manifest); + RETAINED.put(history, catalog); + return catalog; + } + } + + private static RetainedCatalog loadRetained(GenerationHistory history, GenerationManifest manifest) { + RetainedCatalog catalog = new RetainedCatalog(manifest.activeActivation().activationId()); + Set loadedPacks = new HashSet<>(); + Long activationId = manifest.activeActivation().parentActivationId(); + while (activationId != null) { + GenerationActivation activation = manifest.activation(activationId).orElseThrow(); + GenerationEpoch epoch = manifest.epoch(activation.epochId()).orElseThrow(); + String dimensionKey = epoch.dimensionContract().dimensionKey(); + if (loadedPacks.add(epoch.packFingerprint() + ":" + dimensionKey)) { + try { + loadPack(catalog, history.packRoot(activationId), dimensionKey); + } catch (IOException error) { + throw new UncheckedIOException("Unable to read retained Iris find catalog for activation " + + activationId, error); + } + } + activationId = activation.parentActivationId(); + } + return catalog; + } + + private static void loadPack(RetainedCatalog catalog, Path packRoot, String dimensionKey) { + IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + throw new IllegalStateException("Retained Iris dimension is absent: " + dimensionKey); + } + addStructures(catalog.structures, dimension.getStructures()); + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + catalog.biomes.putIfAbsent(normalizeKey(biome.getLoadKey()), biome); + addObjects(catalog.objects, biome.getObjects()); + addStructures(catalog.structures, biome.getStructures()); + } + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + catalog.regions.putIfAbsent(normalizeKey(region.getLoadKey()), region); + addObjects(catalog.objects, region.getObjects()); + addStructures(catalog.structures, region.getStructures()); + } + if (dimension.hasUpperDimension() && !dimensionKey.equals(dimension.getUpperDimension())) { + IrisDimension upper = data.getDimensionLoader().load(dimension.getUpperDimension(), false); + if (upper == null) { + throw new IllegalStateException("Retained Iris upper dimension is absent: " + + dimension.getUpperDimension()); + } + for (IrisBiome biome : upper.getReachableBiomes(() -> data)) { + addObjects(catalog.objects, biome.getSurfaceObjects()); + } + for (IrisRegion region : upper.getAllRegions(() -> data)) { + addObjects(catalog.objects, region.getSurfaceObjects()); + } + } + } finally { + data.close(); + } + } + + private static void addStructures(Set structures, Iterable placements) { + for (IrisStructurePlacement placement : placements) { + if (placement == null) { + continue; + } + for (String key : placement.getStructures()) { + String normalized = normalizeKey(key); + if (!normalized.isEmpty()) { + structures.add(normalized); + } + } + } + } + + private static void addObjects(Set objects, Iterable placements) { + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getPlace() == null) { + continue; + } + for (String key : placement.getPlace()) { + String normalized = normalizeObjectKey(key); + if (!normalized.isEmpty()) { + objects.add(normalized); + } + } + } + } + + private static KList merge(Iterable active, Map historical) { + Map merged = new LinkedHashMap<>(); + for (T registrant : active) { + merged.putIfAbsent(normalizeKey(registrant.getLoadKey()), registrant); + } + historical.forEach(merged::putIfAbsent); + return new KList<>(merged.values()); + } + + private static T find(Iterable entries, String key) { + String normalized = normalizeKey(key); + for (T entry : entries) { + if (normalizeKey(entry.getLoadKey()).equals(normalized)) { + return entry; + } + } + return null; + } + + private static String normalizeKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); + } + + private static String normalizeObjectKey(String key) { + String normalized = normalizeKey(key).replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized.endsWith(".iob") ? normalized.substring(0, normalized.length() - 4) : normalized; + } + + private static final class RetainedCatalog { + private final long activeActivationId; + private final Map biomes = new LinkedHashMap<>(); + private final Map regions = new LinkedHashMap<>(); + private final Set objects = new LinkedHashSet<>(); + private final Set structures = new LinkedHashSet<>(); + + private RetainedCatalog(long activeActivationId) { + this.activeActivationId = activeActivationId; + } + } +} 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 new file mode 100644 index 000000000..53e92fdb3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -0,0 +1,1127 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +public final class GenerationHistory { + private static final int MAXIMUM_CACHED_BOUNDARIES = 4; + private static final int MAXIMUM_CACHED_TERRAIN_SIGNATURES = 4; + + private final GenerationHistoryPaths paths; + private final GenerationPackRepository packs; + private final GenerationHistoryStore store; + private final ChunkGenerationOwnership ownership; + private final GenerationBoundaryStore boundaries; + private final TerrainBoundarySignatureStore terrainSignatures; + private final GenerationSemanticIndex semantics; + private final GenerationKernelRegistry kernels; + private final GenerationAdmission admission; + private final Map boundaryCache; + private final Map terrainSignatureCache; + + private GenerationHistory( + GenerationHistoryPaths paths, + GenerationPackRepository packs, + GenerationHistoryStore store, + ChunkGenerationOwnership ownership, + GenerationKernelRegistry kernels + ) throws IOException { + this.paths = Objects.requireNonNull(paths, "paths"); + this.packs = Objects.requireNonNull(packs, "packs"); + this.store = Objects.requireNonNull(store, "store"); + this.ownership = Objects.requireNonNull(ownership, "ownership"); + this.kernels = Objects.requireNonNull(kernels, "kernels"); + this.boundaries = new GenerationBoundaryStore(paths.dimensionRoot()); + this.terrainSignatures = new TerrainBoundarySignatureStore(paths.dimensionRoot()); + this.semantics = GenerationSemanticIndex.loadRequired(paths.dimensionRoot()); + this.admission = new GenerationAdmission(paths.dimensionRoot()); + this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES); + this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES); + validateReferencedState(); + } + + public static GenerationHistory create( + Path dimensionRoot, + Path packSource, + String packFingerprint, + long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract + ) throws IOException { + return create( + dimensionRoot, + packSource, + packFingerprint, + worldSeed, + dimensionContract, + registryContract, + GenerationKernelRegistry.standard().current(), + GenerationKernelRegistry.standard() + ); + } + + static GenerationHistory create( + Path dimensionRoot, + Path packSource, + String packFingerprint, + long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + GenerationKernelRegistry.Version version, + GenerationKernelRegistry kernels + ) throws IOException { + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); + requireSafeStatePaths(paths); + if (Files.exists(paths.manifest(), LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(paths.manifest())) { + throw new FileAlreadyExistsException(paths.manifest().toString()); + } + + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(paths.ownershipRoot()); + if (ownership.explicitChunkCount() != 0) { + throw new IOException("Generation ownership exists without a generation manifest: " + + paths.ownershipRoot()); + } + + GenerationEpoch epoch = epoch( + packFingerprint, + worldSeed, + dimensionContract, + registryContract, + version, + kernels + ); + requireSourceFingerprint(packSource, epoch.packFingerprint(), epoch.packFingerprintVersion()); + GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot()); + packs.publish( + epoch.epochId(), + epoch.packFingerprint(), + epoch.packFingerprintVersion(), + packSource + ); + forceEpochPublication(paths, epoch.epochId()); + GenerationSemanticIndex.initialize(paths.dimensionRoot()); + GenerationHistoryStore store = GenerationHistoryStore.initialize(paths.generationRoot(), epoch); + return new GenerationHistory(paths, packs, store, ownership, kernels); + } + + public static GenerationHistory open(Path dimensionRoot) throws IOException { + return open(dimensionRoot, GenerationKernelRegistry.standard()); + } + + public static GenerationHistory open(Path dimensionRoot, long expectedWorldSeed) throws IOException { + GenerationHistory history = open(dimensionRoot, GenerationKernelRegistry.standard()); + history.requireWorldSeed(expectedWorldSeed); + return history; + } + + static GenerationHistory open( + Path dimensionRoot, + GenerationKernelRegistry kernels + ) throws IOException { + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); + requireSafeStatePaths(paths); + GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot()); + GenerationHistoryStore store = GenerationHistoryStore.open(paths.generationRoot()); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(paths.ownershipRoot()); + return new GenerationHistory(paths, packs, store, ownership, kernels); + } + + public static Optional openIfPresent(Path dimensionRoot) throws IOException { + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); + requireSafeStatePaths(paths); + if (!Files.exists(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(paths.generationRoot())) { + return Optional.empty(); + } + return Optional.of(open(paths.dimensionRoot())); + } + + public static Optional openIfPresent( + Path dimensionRoot, + long expectedWorldSeed + ) throws IOException { + Optional history = openIfPresent(dimensionRoot); + if (history.isPresent()) { + history.get().requireWorldSeed(expectedWorldSeed); + } + return history; + } + + public static GenerationHistory adoptLegacyPack( + Path dimensionRoot, + String packFingerprint, + long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract + ) throws IOException { + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); + requireSafeStatePaths(paths); + GenerationEpoch expectedEpoch = epoch( + packFingerprint, + worldSeed, + dimensionContract, + registryContract, + GenerationKernelRegistry.standard().current(), + GenerationKernelRegistry.standard() + ); + boolean legacyExists = Files.exists(paths.legacyPackRoot(), LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(paths.legacyPackRoot()); + + GenerationHistory history; + if (Files.exists(paths.manifest(), LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(paths.manifest())) { + history = open(paths.dimensionRoot()); + } else { + if (!legacyExists) { + throw new NoSuchFileException(paths.legacyPackRoot().toString()); + } + requireSafeLegacyPack(paths.legacyPackRoot()); + requireSourceFingerprint( + paths.legacyPackRoot(), + expectedEpoch.packFingerprint(), + expectedEpoch.packFingerprintVersion() + ); + history = create( + paths.dimensionRoot(), + paths.legacyPackRoot(), + expectedEpoch.packFingerprint(), + expectedEpoch.worldSeed(), + expectedEpoch.dimensionContract(), + expectedEpoch.registryContract() + ); + history = open(paths.dimensionRoot()); + } + + requireInitialAdoptionState(history, expectedEpoch); + if (legacyExists) { + requireSafeLegacyPack(paths.legacyPackRoot()); + requireSourceFingerprint( + paths.legacyPackRoot(), + expectedEpoch.packFingerprint(), + expectedEpoch.packFingerprintVersion() + ); + history.packs.requireExactPack( + expectedEpoch.epochId(), + expectedEpoch.packFingerprint(), + expectedEpoch.packFingerprintVersion() + ); + forceEpochPublication(paths, expectedEpoch.epochId()); + } + migrateLegacyMantle(paths, history.activeActivation().activationId()); + if (!legacyExists) { + return history; + } + AtomicDirectoryPublisher.deleteTree(paths.legacyPackRoot()); + forceDirectory(paths.irisRoot()); + return history; + } + + public synchronized GenerationActivation stageUpdate( + Path packSource, + String packFingerprint, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + int transitionWidthBlocks + ) throws IOException { + return stageUpdate( + packSource, + packFingerprint, + dimensionContract, + registryContract, + transitionWidthBlocks, + kernels.current() + ); + } + + synchronized GenerationActivation stageUpdate( + Path packSource, + String packFingerprint, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + int transitionWidthBlocks, + GenerationKernelRegistry.Version version + ) throws IOException { + return stageUpdate( + packSource, + packFingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + dimensionContract, + registryContract, + transitionWidthBlocks, + version + ); + } + + private GenerationActivation stageUpdate( + Path packSource, + String packFingerprint, + int packFingerprintVersion, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + int transitionWidthBlocks, + GenerationKernelRegistry.Version version + ) throws IOException { + GenerationEpoch candidate = epoch( + packFingerprint, + packFingerprintVersion, + store.activeEpoch().worldSeed(), + dimensionContract, + registryContract, + version, + kernels + ); + kernels.requireSupported( + new GenerationKernelRegistry.Version( + candidate.generatorAbi(), + candidate.rngVersion(), + candidate.seedDerivationVersion() + ), + candidate.kernelImplementationFingerprint() + ); + if (!store.activeEpoch().dimensionContract().hasSameLayout(candidate.dimensionContract())) { + throw new IllegalArgumentException("Generation update changes the immutable dimension contract."); + } + + Optional pending = store.pendingActivation(); + if (pending.isPresent()) { + GenerationActivation activation = pending.get(); + if (!activation.epochId().equals(candidate.epochId())) { + throw new IllegalStateException("A different generation activation is already pending."); + } + packs.requireExactPack( + candidate.epochId(), + candidate.packFingerprint(), + candidate.packFingerprintVersion() + ); + return activation; + } + + if (store.activeEpoch().equals(candidate)) { + requireSourceFingerprint( + packSource, + candidate.packFingerprint(), + candidate.packFingerprintVersion() + ); + packs.requireExactPack( + candidate.epochId(), + candidate.packFingerprint(), + candidate.packFingerprintVersion() + ); + return store.activeActivation(); + } + + requireSourceFingerprint( + packSource, + candidate.packFingerprint(), + candidate.packFingerprintVersion() + ); + packs.publish( + candidate.epochId(), + candidate.packFingerprint(), + candidate.packFingerprintVersion(), + packSource + ); + forceEpochPublication(paths, candidate.epochId()); + return store.preparePendingActivation(candidate, transitionWidthBlocks); + } + + GenerationActivation promotePending( + Collection signatures + ) throws IOException { + Collection requiredSignatures = Objects.requireNonNull( + signatures, + "signatures" + ); + return promotePending(boundary -> { + boundary.requireCompleteTerrainSignatures(requiredSignatures); + Long2ObjectOpenHashMap indexed = new Long2ObjectOpenHashMap<>( + requiredSignatures.size() + ); + for (TerrainBoundarySignature signature : requiredSignatures) { + indexed.put(GenerationBoundary.packChunk(signature.blockX(), signature.blockZ()), signature); + } + return (blockX, blockZ) -> indexed.get(GenerationBoundary.packChunk(blockX, blockZ)); + }); + } + + GenerationActivation promotePending( + BoundarySignatureCapture signatureCapture + ) throws IOException { + BoundarySignatureCapture requiredCapture = Objects.requireNonNull( + signatureCapture, + "signatureCapture" + ); + try (GenerationAdmission.CutoverLease ignored = admission.beginStartupCutover()) { + synchronized (this) { + return promotePendingLocked(requiredCapture); + } + } + } + + private GenerationActivation promotePendingLocked( + BoundarySignatureCapture signatureCapture + ) throws IOException { + validateReferencedState(); + Optional pending = store.pendingActivation(); + if (pending.isEmpty()) { + return store.activeActivation(); + } + + long outgoingActivationId = store.activeActivation().activationId(); + semantics.compactJournals(); + WorldChunkInventory inventory = WorldChunkInventory.scan(paths.dimensionRoot()); + ownership.assignUnassigned(inventory, outgoingActivationId); + semantics.forEachSealedClaim( + outgoingActivationId, + (chunkX, chunkZ) -> ownership.assign(chunkX, chunkZ, outgoingActivationId) + ); + ownership.persist(); + requireExplicitOwnership(inventory); + requireExplicitSemanticOwnership(outgoingActivationId); + GenerationBoundary boundary = boundaries.publishOwnership( + pending.get().activationId(), + ownership + ); + TerrainBoundarySignatureStore.Snapshot terrainSnapshot; + try (TerrainBoundarySignatureStore.SignatureSampler sampler = Objects.requireNonNull( + signatureCapture.capture(boundary), "boundary signature sampler" + )) { + terrainSnapshot = terrainSignatures.publish(pending.get().activationId(), boundary, sampler); + } + GenerationActivation completed = store.completePendingTransition( + pending.get().activationId(), + boundary.identity(), + terrainSnapshot.identity() + ); + GenerationActivation activated = store.activatePending(completed.activationId()); + validateOwnershipReferences(); + return activated; + } + + public GenerationHistoryPaths paths() { + return paths; + } + + public synchronized GenerationManifest manifest() { + return store.manifest(); + } + + public synchronized GenerationActivation activeActivation() { + return store.activeActivation(); + } + + public synchronized GenerationEpoch activeEpoch() { + return store.activeEpoch(); + } + + public synchronized GenerationKernelRegistry.Version currentKernelVersion() { + return kernels.current(); + } + + public synchronized GenerationActivation stageCurrentKernel( + int transitionWidthBlocks + ) throws IOException { + GenerationEpoch active = store.activeEpoch(); + return stageUpdate( + activePackRoot(), + active.packFingerprint(), + active.packFingerprintVersion(), + active.dimensionContract(), + active.registryContract(), + transitionWidthBlocks, + kernels.current() + ); + } + + public synchronized Optional pendingActivation() { + return store.pendingActivation(); + } + + public synchronized Path activePackRoot() throws IOException { + GenerationEpoch epoch = store.activeEpoch(); + return packs.requireExactPack( + epoch.epochId(), + epoch.packFingerprint(), + epoch.packFingerprintVersion() + ); + } + + public synchronized Path packRoot(long activationId) throws IOException { + GenerationActivation activation = requireActivation(activationId); + GenerationEpoch epoch = requireEpoch(activation.epochId()); + return packs.requireExactPack( + epoch.epochId(), + epoch.packFingerprint(), + epoch.packFingerprintVersion() + ); + } + + public GenerationStage openStage(int chunkX, int chunkZ) throws IOException { + GenerationAdmission.StageLease lease = admission.enterStage(); + try { + synchronized (this) { + GenerationActivation activation = resolveActivation(chunkX, chunkZ); + GenerationEpoch epoch = requireEpoch(activation.epochId()); + return new GenerationStage( + this, + chunkX, + chunkZ, + activation, + epoch, + paths.packRoot(epoch.epochId()), + lease + ); + } + } catch (Throwable failure) { + lease.close(); + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + throw new IOException("Unable to open Iris generation stage.", failure); + } + } + + public synchronized boolean claimGeneratedSemantics( + GenerationStage stage, + ChunkGenerationSemantics update + ) throws IOException { + GenerationStage requiredStage = Objects.requireNonNull(stage, "generation stage"); + ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update"); + requiredStage.requireOpen(this); + if (!requiredUpdate.sealed()) { + throw new IllegalArgumentException("Generated semantics must be sealed."); + } + if (requiredUpdate.chunkX() != requiredStage.chunkX() + || requiredUpdate.chunkZ() != requiredStage.chunkZ()) { + throw new IllegalArgumentException("Generated semantics coordinates do not match the open stage."); + } + if (requiredUpdate.activationId() != requiredStage.activation().activationId()) { + throw new IllegalArgumentException("Generated semantics activation does not match the open stage."); + } + return semantics.claimAndPersist(requiredUpdate); + } + + public synchronized Optional semantics(int chunkX, int chunkZ) { + return semantics.get(chunkX, chunkZ); + } + + public synchronized Optional findRecorded( + GenerationSemanticIndex.Query query + ) { + return semantics.findNearest(query, ChunkGenerationSemantics::sealed); + } + + public synchronized Optional findRecordedRiver( + GenerationSemanticIndex.RiverQuery query + ) { + return semantics.findNearestRiver(query, ChunkGenerationSemantics::sealed); + } + + public synchronized boolean isActiveUnowned(int chunkX, int chunkZ) { + return !ownership.isExplicitlyAssigned(chunkX, chunkZ); + } + + public synchronized boolean isHistoricallyOwned(int chunkX, int chunkZ) { + return ownership.isExplicitlyAssigned(chunkX, chunkZ); + } + + public synchronized boolean hasHistoricalFallbackInSquare(int centerX, int centerZ, int radius) { + return ownership.anyMatchingInSquare(centerX, centerZ, radius, + (chunkX, chunkZ, activation) -> semantics.get(chunkX, chunkZ) + .filter(ChunkGenerationSemantics::sealed).isEmpty()); + } + + public synchronized void requireWorldSeed(long expectedWorldSeed) throws IOException { + long recordedWorldSeed = store.activeEpoch().worldSeed(); + if (recordedWorldSeed != expectedWorldSeed) { + throw new IOException("Iris generation history seed mismatch: expected " + + recordedWorldSeed + " but the platform supplied " + expectedWorldSeed + "."); + } + } + + public synchronized void requireRegistryDefinitions(GenerationRegistryContract available) throws IOException { + GenerationRegistryContract requiredAvailable = Objects.requireNonNull(available, "available"); + for (GenerationEpoch epoch : store.manifest().epochs()) { + epoch.registryContract().requireDefinitionsAvailableIn(requiredAvailable); + } + } + + public synchronized GenerationActivation resolveActivation(int chunkX, int chunkZ) { + long activationId = ownership.resolve( + chunkX, + chunkZ, + store.activeActivation().activationId() + ); + return requireActivation(activationId); + } + + public synchronized GenerationEpoch resolveEpoch(int chunkX, int chunkZ) { + return requireEpoch(resolveActivation(chunkX, chunkZ).epochId()); + } + + public synchronized GenerationBoundary boundary(long activationId) throws IOException { + GenerationActivation activation = requireActivation(activationId); + if (activation.parentActivationId() == null) { + throw new IllegalArgumentException("The initial generation activation has no transition boundary."); + } + GenerationBoundary cached = boundaryCache.get(activationId); + if (cached != null) { + return cached; + } + GenerationBoundary loaded = boundaries.load(activationId); + boundaryCache.put(activationId, loaded); + return loaded; + } + + public synchronized TerrainBoundarySignatureStore.Snapshot terrainSignatures( + long activationId + ) throws IOException { + GenerationActivation activation = requireActivation(activationId); + if (activation.isInitial()) { + throw new IllegalArgumentException("The initial generation activation has no terrain signatures."); + } + TerrainBoundarySignatureStore.Snapshot snapshot = terrainSignatureCache.get(activationId); + if (snapshot == null) { + snapshot = terrainSignatures.load(activationId); + terrainSignatureCache.put(activationId, snapshot); + } + requireTransitionSnapshotIdentities(activation, boundary(activationId), snapshot); + return snapshot; + } + + public synchronized TransitionGenerationPlan transitionPlan(long activationId) throws IOException { + GenerationActivation activation = requireActivation(activationId); + if (activation.isInitial()) { + throw new IllegalArgumentException("The initial generation activation has no transition plan."); + } + GenerationTransition transition = Objects.requireNonNull( + activation.transition(), + "activation transition" + ); + if (!transition.isComplete()) { + throw new IllegalStateException("Generation transition is not complete for activation " + + activationId + "."); + } + GenerationActivation parent = requireActivation(activation.parentActivationId()); + GenerationBoundary boundary = boundary(activationId); + TerrainBoundarySignatureStore.Snapshot terrainSnapshot = terrainSignatures(activationId); + requireTransitionSnapshotIdentities(activation, boundary, terrainSnapshot); + return new TransitionGenerationPlan( + new TransitionGenerationPlan.Specification( + activation.activationId(), + requireEpoch(parent.epochId()).epochId(), + requireEpoch(activation.epochId()).epochId(), + transition.algorithmVersion(), + transition.widthBlocks(), + transition.boundaryIdentity(), + transition.terrainSignatureIdentity() + ), + boundary, + terrainSnapshot + ); + } + + public synchronized int explicitChunkCount() { + return ownership.explicitChunkCount(); + } + + private void validateReferencedState() throws IOException { + validateRuntimeVersions(); + Collection epochs = store.manifest().epochs(); + for (GenerationEpoch epoch : epochs) { + packs.requireExactPack( + epoch.epochId(), + epoch.packFingerprint(), + epoch.packFingerprintVersion() + ); + } + for (GenerationActivation activation : store.manifest().activations()) { + requireSafeDirectoryIfPresent(paths.activationRoot(activation.activationId())); + requireSafeDirectoryIfPresent(paths.activationMantleRoot(activation.activationId())); + if (activation.parentActivationId() != null + && activation.activationId() <= store.activeActivation().activationId()) { + GenerationBoundary boundary = boundary(activation.activationId()); + TerrainBoundarySignatureStore.Snapshot terrainSnapshot = terrainSignatures( + activation.activationId() + ); + requireTransitionSnapshotIdentities(activation, boundary, terrainSnapshot); + } + } + validateOwnershipReferences(); + validateSemanticReferences(); + } + + private static void requireTransitionSnapshotIdentities( + GenerationActivation activation, + GenerationBoundary boundary, + TerrainBoundarySignatureStore.Snapshot terrainSnapshot + ) throws IOException { + GenerationTransition transition = activation.transition(); + if (transition == null || !transition.isComplete()) { + throw new IOException("Generation activation " + activation.activationId() + + " has an incomplete transition recipe."); + } + if (!transition.boundaryIdentity().equals(boundary.identity())) { + throw new IOException("Generation boundary identity does not match activation " + + activation.activationId() + "."); + } + if (!transition.terrainSignatureIdentity().equals(terrainSnapshot.identity())) { + throw new IOException("Terrain signature identity does not match activation " + + activation.activationId() + "."); + } + } + + private void validateRuntimeVersions() throws IOException { + for (GenerationEpoch epoch : store.manifest().epochs()) { + kernels.requireSupported( + new GenerationKernelRegistry.Version( + epoch.generatorAbi(), + epoch.rngVersion(), + epoch.seedDerivationVersion() + ), + epoch.kernelImplementationFingerprint() + ); + GenerationPackFingerprint.requireSupported(epoch.packFingerprintVersion()); + } + } + + private void validateOwnershipReferences() throws IOException { + long activeActivationId = store.activeActivation().activationId(); + GenerationActivation active = store.activeActivation(); + GenerationBoundary expectedBoundary = active.isInitial() ? null : boundary(activeActivationId); + boolean pendingCutover = store.pendingActivation().isPresent(); + int[] assignedCount = new int[1]; + WorldChunkInventory[] inventory = new WorldChunkInventory[1]; + ownership.forEachAssignment((chunkX, chunkZ, activationId) -> { + assignedCount[0] = Math.addExact(assignedCount[0], 1); + if (store.activation(activationId).isEmpty()) { + throw new IOException("Chunk " + chunkX + "," + chunkZ + + " references missing generation activation " + activationId + "."); + } + if (activationId > activeActivationId) { + throw new IOException("Chunk " + chunkX + "," + chunkZ + + " references unpublished generation activation " + activationId + "."); + } + if (expectedBoundary != null && expectedBoundary.isHistoricalChunk(chunkX, chunkZ)) { + return; + } + if (!pendingCutover) { + throw new IOException("Generation ownership contains chunk " + chunkX + "," + chunkZ + + " outside the active immutable boundary."); + } + if (activationId != activeActivationId) { + throw new IOException("Pending generation cutover contains an invalid outgoing claim for chunk " + + chunkX + "," + chunkZ + ": expected activation " + + activeActivationId + " but found " + activationId + "."); + } + if (inventory[0] == null) { + inventory[0] = WorldChunkInventory.scan(paths.dimensionRoot()); + } + if (!inventory[0].contains(chunkX, chunkZ) + && !semantics.hasSealedClaim(chunkX, chunkZ, activationId)) { + throw new IOException("Pending generation cutover claims an ungenerated chunk " + + chunkX + "," + chunkZ + " without matching sealed semantics."); + } + }); + + int expectedCount = expectedBoundary == null ? 0 : expectedBoundary.historicalChunkCount(); + if (!pendingCutover && assignedCount[0] != expectedCount) { + throw ownershipCoverageMismatch(expectedCount, assignedCount[0]); + } + if (expectedBoundary == null) { + return; + } + expectedBoundary.forEachHistoricalChunk((chunkX, chunkZ) -> { + if (!ownership.isExplicitlyAssigned(chunkX, chunkZ)) { + throw new IOException("Generation ownership is missing historical chunk " + + chunkX + "," + chunkZ + "."); + } + }); + } + + private static IOException ownershipCoverageMismatch(int expectedCount, int actualCount) { + return new IOException("Generation ownership coverage mismatch: expected " + + expectedCount + " historical chunks but found " + actualCount + "."); + } + + private void validateSemanticReferences() throws IOException { + semantics.forEachRecord(record -> { + if (store.activation(record.activationId()).isEmpty()) { + throw new IOException("Chunk " + record.chunkX() + "," + record.chunkZ() + + " records semantics for missing generation activation " + + record.activationId() + "."); + } + GenerationActivation expected = resolveActivation(record.chunkX(), record.chunkZ()); + if (record.activationId() != expected.activationId()) { + throw new IOException("Chunk " + record.chunkX() + "," + record.chunkZ() + + " records semantics for activation " + record.activationId() + + " but ownership resolves activation " + expected.activationId() + "."); + } + }); + } + + private void requireExplicitOwnership(WorldChunkInventory inventory) throws IOException { + boolean[] missing = new boolean[1]; + long[] firstMissing = new long[1]; + inventory.forEach((chunkX, chunkZ) -> { + if (!missing[0] && !ownership.isExplicitlyAssigned(chunkX, chunkZ)) { + missing[0] = true; + firstMissing[0] = ChunkGenerationOwnership.packChunk(chunkX, chunkZ); + } + }); + if (!missing[0]) { + return; + } + long first = firstMissing[0]; + throw new IOException("Generated chunk " + ChunkGenerationOwnership.chunkX(first) + + "," + ChunkGenerationOwnership.chunkZ(first) + + " was not durably assigned before generation activation promotion."); + } + + private void requireExplicitSemanticOwnership(long activationId) throws IOException { + boolean[] missing = new boolean[1]; + long[] firstMissing = new long[1]; + semantics.forEachSealedClaim(activationId, (chunkX, chunkZ) -> { + if (!missing[0] && !ownership.isExplicitlyAssigned(chunkX, chunkZ)) { + missing[0] = true; + firstMissing[0] = ChunkGenerationOwnership.packChunk(chunkX, chunkZ); + } + }); + if (!missing[0]) { + return; + } + long first = firstMissing[0]; + throw new IOException("Generated chunk " + ChunkGenerationOwnership.chunkX(first) + + "," + ChunkGenerationOwnership.chunkZ(first) + + " was not durably assigned before generation activation promotion."); + } + + private GenerationActivation requireActivation(long activationId) { + return store.activation(activationId).orElseThrow(() -> new IllegalStateException( + "Generation ownership references missing activation " + activationId + "." + )); + } + + private GenerationEpoch requireEpoch(String epochId) { + return store.epoch(epochId).orElseThrow(() -> new IllegalStateException( + "Generation activation references missing epoch " + epochId + "." + )); + } + + private static GenerationEpoch epoch( + String packFingerprint, + long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + GenerationKernelRegistry.Version version, + GenerationKernelRegistry kernels + ) throws IOException { + return epoch( + packFingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + worldSeed, + dimensionContract, + registryContract, + version, + kernels + ); + } + + private static GenerationEpoch epoch( + String packFingerprint, + int packFingerprintVersion, + long worldSeed, + GenerationEpoch.DimensionContract dimensionContract, + GenerationRegistryContract registryContract, + GenerationKernelRegistry.Version version, + GenerationKernelRegistry kernels + ) throws IOException { + GenerationKernelRegistry.Version requiredVersion = Objects.requireNonNull(version, "version"); + String implementationFingerprint = Objects.requireNonNull(kernels, "kernels") + .select(requiredVersion) + .implementationFingerprint(); + return GenerationEpoch.create(new GenerationEpoch.Spec( + packFingerprint, + packFingerprintVersion, + worldSeed, + requiredVersion.seedDerivationVersion(), + requiredVersion.generatorAbi(), + requiredVersion.rngVersion(), + implementationFingerprint, + Objects.requireNonNull(dimensionContract, "dimensionContract"), + Objects.requireNonNull(registryContract, "registryContract") + )); + } + + private static void requireInitialAdoptionState( + GenerationHistory history, + GenerationEpoch expectedEpoch + ) throws IOException { + GenerationManifest manifest = history.store.manifest(); + if (manifest.epochs().size() != 1 + || manifest.activations().size() != 1 + || manifest.pendingActivation().isPresent() + || !manifest.activeEpoch().equals(expectedEpoch)) { + throw new IOException("Legacy pack adoption does not match the initialized generation history."); + } + } + + private static void requireSourceFingerprint( + Path source, + String expected, + int packFingerprintVersion + ) throws IOException { + Path requiredSource = Objects.requireNonNull(source, "packSource") + .toAbsolutePath() + .normalize(); + String actual = GenerationPackFingerprint.compute(requiredSource, packFingerprintVersion); + if (!expected.equals(actual)) { + throw new IOException("Generation pack fingerprint mismatch at " + requiredSource + + ": expected " + expected + " but found " + actual + "."); + } + } + + private static void requireSafeLegacyPack(Path legacyPack) throws IOException { + if (!Files.exists(legacyPack, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(legacyPack.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + legacyPack, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Legacy Iris pack is not a safe directory: " + legacyPack); + } + } + + private static void requireSafeStatePaths(GenerationHistoryPaths paths) throws IOException { + requireSafeDirectoryIfPresent(paths.dimensionRoot()); + requireSafeDirectoryIfPresent(paths.irisRoot()); + requireSafeDirectoryIfPresent(paths.generationRoot()); + requireSafeDirectoryIfPresent(paths.epochsRoot()); + requireSafeDirectoryIfPresent(paths.activationsRoot()); + requireSafeDirectoryIfPresent(paths.ownershipRoot()); + } + + private static void migrateLegacyMantle( + GenerationHistoryPaths paths, + long activationId + ) throws IOException { + Path source = paths.legacyMantleRoot(); + Path target = paths.activationMantleRoot(activationId); + boolean sourceExists = Files.exists(source, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(source); + boolean targetExists = Files.exists(target, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(target); + if (!sourceExists && !targetExists) { + return; + } + + requireSafeMantleRootIfPresent(source, "Legacy"); + requireSafeMantleRootIfPresent(target, "Activation"); + if (sourceExists && targetExists) { + String conflict = Files.isSameFile(source, target) ? "alias" : "conflict"; + throw new IOException("Legacy and activation Iris mantle roots " + conflict + ": " + + source + " and " + target + "."); + } + if (targetExists) { + forceMantleMigration(paths, target.getParent()); + return; + } + + Path activationRoot = target.getParent(); + Files.createDirectories(activationRoot); + requireSafeDirectoryIfPresent(paths.activationsRoot()); + requireSafeDirectoryIfPresent(activationRoot); + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + throw new IOException("Legacy Iris mantle cannot be migrated atomically from " + + source + " to " + target + ".", exception); + } + forceMantleMigration(paths, activationRoot); + } + + private static void requireSafeMantleRootIfPresent(Path root, String label) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(root)) { + return; + } + BasicFileAttributes attributes = Files.readAttributes( + root, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException(label + " Iris mantle root is not a safe directory: " + root); + } + } + + private static void forceMantleMigration( + GenerationHistoryPaths paths, + Path activationRoot + ) throws IOException { + forceDirectory(activationRoot); + forceDirectory(paths.activationsRoot()); + forceDirectory(paths.generationRoot()); + forceDirectory(paths.dimensionRoot()); + } + + private static void requireSafeDirectoryIfPresent(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(directory)) { + return; + } + BasicFileAttributes attributes = Files.readAttributes( + directory, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation history path is not a safe directory: " + directory); + } + } + + private static void forceEpochPublication( + GenerationHistoryPaths paths, + String epochId + ) throws IOException { + Path epochRoot = paths.epochRoot(epochId); + if (!Files.isDirectory(epochRoot, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(epochRoot)) { + throw new IOException("Generation epoch is not a safe directory: " + epochRoot); + } + if (File.separatorChar != '\\') { + List directories; + try (Stream entries = Files.walk(epochRoot)) { + directories = entries + .filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) + .sorted(Comparator.comparingInt(Path::getNameCount).reversed()) + .toList(); + } + for (Path directory : directories) { + forceDirectory(directory); + } + forceDirectory(paths.epochsRoot()); + forceDirectory(paths.generationRoot()); + } + } + + private static void forceDirectory(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException exception) { + throw new IOException("Generation history directory cannot be durability-synced: " + + directory, exception); + } + } + + private static Map boundedCache(int maximumSize) { + return new LinkedHashMap<>(maximumSize, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maximumSize; + } + }; + } + + public static final class GenerationStage implements AutoCloseable { + private final GenerationHistory owner; + private final int chunkX; + private final int chunkZ; + private final GenerationActivation activation; + private final GenerationEpoch epoch; + private final Path packRoot; + private final GenerationAdmission.StageLease lease; + private boolean closed; + + private GenerationStage( + GenerationHistory owner, + int chunkX, + int chunkZ, + GenerationActivation activation, + GenerationEpoch epoch, + Path packRoot, + GenerationAdmission.StageLease lease + ) { + this.owner = owner; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.activation = activation; + this.epoch = epoch; + this.packRoot = packRoot; + this.lease = lease; + } + + public int chunkX() { + return chunkX; + } + + public int chunkZ() { + return chunkZ; + } + + public GenerationActivation activation() { + return activation; + } + + public GenerationEpoch epoch() { + return epoch; + } + + public Path packRoot() { + return packRoot; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + lease.close(); + } + + private synchronized void requireOpen(GenerationHistory expectedOwner) { + if (owner != expectedOwner) { + throw new IllegalArgumentException("Generation stage belongs to a different history."); + } + if (closed) { + throw new IllegalStateException("Generation stage is closed."); + } + } + } + + @FunctionalInterface + public interface BoundarySignatureCapture { + TerrainBoundarySignatureStore.SignatureSampler capture(GenerationBoundary boundary) throws IOException; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryPaths.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryPaths.java new file mode 100644 index 000000000..663dae137 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryPaths.java @@ -0,0 +1,111 @@ +package art.arcane.iris.engine.history; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class GenerationHistoryPaths { + public static final String IRIS_DIRECTORY_NAME = "iris"; + public static final String GENERATION_DIRECTORY_NAME = "generation"; + public static final String EPOCHS_DIRECTORY_NAME = "epochs"; + public static final String ACTIVATIONS_DIRECTORY_NAME = "activations"; + public static final String OWNERSHIP_DIRECTORY_NAME = "ownership"; + public static final String LEGACY_PACK_DIRECTORY_NAME = "pack"; + public static final String LEGACY_MANTLE_DIRECTORY_NAME = "mantle-hydrology"; + public static final String REGION_DIRECTORY_NAME = "region"; + + private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}"); + + private final Path dimensionRoot; + private final Path irisRoot; + private final Path generationRoot; + private final Path epochsRoot; + private final Path activationsRoot; + private final Path ownershipRoot; + private final Path manifest; + private final Path legacyPackRoot; + private final Path legacyMantleRoot; + private final Path regionRoot; + + private GenerationHistoryPaths(Path dimensionRoot) { + this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toAbsolutePath() + .normalize(); + irisRoot = this.dimensionRoot.resolve(IRIS_DIRECTORY_NAME); + generationRoot = irisRoot.resolve(GENERATION_DIRECTORY_NAME); + epochsRoot = generationRoot.resolve(EPOCHS_DIRECTORY_NAME); + activationsRoot = generationRoot.resolve(ACTIVATIONS_DIRECTORY_NAME); + ownershipRoot = generationRoot.resolve(OWNERSHIP_DIRECTORY_NAME); + manifest = generationRoot.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME); + legacyPackRoot = irisRoot.resolve(LEGACY_PACK_DIRECTORY_NAME); + legacyMantleRoot = this.dimensionRoot.resolve(LEGACY_MANTLE_DIRECTORY_NAME); + regionRoot = this.dimensionRoot.resolve(REGION_DIRECTORY_NAME); + } + + public static GenerationHistoryPaths forDimension(Path dimensionRoot) { + return new GenerationHistoryPaths(dimensionRoot); + } + + public Path dimensionRoot() { + return dimensionRoot; + } + + public Path irisRoot() { + return irisRoot; + } + + public Path generationRoot() { + return generationRoot; + } + + public Path epochsRoot() { + return epochsRoot; + } + + public Path epochRoot(String epochId) { + String requiredEpochId = Objects.requireNonNull(epochId, "epochId"); + if (!SHA_256.matcher(requiredEpochId).matches()) { + throw new IllegalArgumentException("epochId must be a lowercase SHA-256 digest"); + } + return epochsRoot.resolve(requiredEpochId); + } + + public Path packRoot(String epochId) { + return epochRoot(epochId).resolve(LEGACY_PACK_DIRECTORY_NAME); + } + + public Path activationsRoot() { + return activationsRoot; + } + + public Path activationRoot(long activationId) { + if (activationId <= 0L) { + throw new IllegalArgumentException("activationId must be positive"); + } + return activationsRoot.resolve(Long.toString(activationId)); + } + + public Path activationMantleRoot(long activationId) { + return activationRoot(activationId).resolve(LEGACY_MANTLE_DIRECTORY_NAME); + } + + public Path ownershipRoot() { + return ownershipRoot; + } + + public Path manifest() { + return manifest; + } + + public Path legacyPackRoot() { + return legacyPackRoot; + } + + public Path legacyMantleRoot() { + return legacyMantleRoot; + } + + public Path regionRoot() { + return regionRoot; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java new file mode 100644 index 000000000..f2a8790c6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java @@ -0,0 +1,1295 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisDimension; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public final class GenerationHistoryRuntimeRouter implements AutoCloseable { + public static final int DEFAULT_TRANSITION_WIDTH_BLOCKS = 256; + public static final int DEFAULT_RUNTIME_CACHE_CAPACITY = 4; + + private final IrisEngine engine; + private final GenerationHistory history; + private final GenerationBoundarySignatureSampler signatureSampler; + private final ActivationRuntimeFactory runtimeFactory; + private final LinkedHashMap bindings; + private final Map retiringBindings; + private final int runtimeCacheCapacity; + private final ReentrantLock stateLock; + private final Condition inactive; + private final ThreadLocal operationDepth; + private final ThreadLocal scopedRoute; + private int activeOperations; + private int activeLoads; + private boolean closed; + private boolean closeComplete; + private boolean closeCleanupInProgress; + private Throwable closeFailure; + + private GenerationHistoryRuntimeRouter( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + ActivationRuntimeFactory runtimeFactory, + int runtimeCacheCapacity + ) throws IOException { + this.engine = Objects.requireNonNull(engine, "Iris engine"); + this.history = Objects.requireNonNull(history, "generation history"); + this.signatureSampler = Objects.requireNonNull(signatureSampler, "boundary signature sampler"); + this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "activation runtime factory"); + if (runtimeCacheCapacity < 1) { + throw new IllegalArgumentException("Generation runtime cache capacity must be positive."); + } + this.runtimeCacheCapacity = runtimeCacheCapacity; + this.bindings = new LinkedHashMap<>(runtimeCacheCapacity, 0.75F, true); + this.retiringBindings = new LinkedHashMap<>(); + this.stateLock = new ReentrantLock(true); + this.inactive = stateLock.newCondition(); + this.operationDepth = ThreadLocal.withInitial(() -> 0); + this.scopedRoute = new ThreadLocal<>(); + + GenerationActivation active = history.activeActivation(); + GenerationEpoch epoch = requireEpoch(active); + IrisEngine.GenerationRuntimeBinding base = engine.getActiveGenerationRuntimeBinding(); + runtimeFactory.validateBase(engine, history, active, epoch, base); + requireBindingContract(active, epoch, base); + RuntimeCacheEntry baseEntry = new RuntimeCacheEntry(active.activationId()); + baseEntry.binding = base; + baseEntry.defaultRuntime = true; + bindings.put(active.activationId(), baseEntry); + try { + engine.attachGenerationHistoryRuntimeRouter(this); + } catch (Throwable failure) { + stateLock.lock(); + try { + bindings.clear(); + closed = true; + closeComplete = true; + } finally { + stateLock.unlock(); + } + throw propagate(failure, "Unable to attach the generation-history runtime router."); + } + } + + public static GenerationHistoryRuntimeRouter attach( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler + ) throws IOException { + return attach(engine, history, signatureSampler, new DefaultActivationRuntimeFactory()); + } + + public static GenerationHistoryRuntimeRouter attachAndPromotePending( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler + ) throws IOException { + return attachAndPromotePending( + engine, + history, + signatureSampler, + DEFAULT_TRANSITION_WIDTH_BLOCKS + ); + } + + public static GenerationHistoryRuntimeRouter attachAndPromotePending( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + int transitionWidthBlocks + ) throws IOException { + return attachAndPromotePending( + engine, + history, + signatureSampler, + transitionWidthBlocks, + new DefaultActivationRuntimeFactory(), + DEFAULT_RUNTIME_CACHE_CAPACITY + ); + } + + static GenerationHistoryRuntimeRouter attachAndPromotePending( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + int transitionWidthBlocks, + ActivationRuntimeFactory runtimeFactory + ) throws IOException { + return attachAndPromotePending( + engine, + history, + signatureSampler, + transitionWidthBlocks, + runtimeFactory, + DEFAULT_RUNTIME_CACHE_CAPACITY + ); + } + + static GenerationHistoryRuntimeRouter attachAndPromotePending( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + int transitionWidthBlocks, + ActivationRuntimeFactory runtimeFactory, + int runtimeCacheCapacity + ) throws IOException { + GenerationHistoryRuntimeRouter router = attach( + engine, + history, + signatureSampler, + runtimeFactory, + runtimeCacheCapacity + ); + try { + router.promotePendingAndReconcileCurrentKernel(transitionWidthBlocks); + return router; + } catch (Throwable failure) { + try { + router.close(); + } catch (Throwable closeFailure) { + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw propagate(failure, "Unable to promote the pending generation activation."); + } + } + + static GenerationHistoryRuntimeRouter attach( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + ActivationRuntimeFactory runtimeFactory + ) throws IOException { + return attach( + engine, + history, + signatureSampler, + runtimeFactory, + DEFAULT_RUNTIME_CACHE_CAPACITY + ); + } + + static GenerationHistoryRuntimeRouter attach( + IrisEngine engine, + GenerationHistory history, + GenerationBoundarySignatureSampler signatureSampler, + ActivationRuntimeFactory runtimeFactory, + int runtimeCacheCapacity + ) throws IOException { + return new GenerationHistoryRuntimeRouter( + engine, + history, + signatureSampler, + runtimeFactory, + runtimeCacheCapacity + ); + } + + public IrisEngine engine() { + return engine; + } + + public GenerationHistory history() { + return history; + } + + public Optional currentRuntimeOwnership() { + RuntimeRoute current = scopedRoute.get(); + return current == null ? Optional.empty() : Optional.of(current.ownership); + } + + public RuntimeRoute openRoute(int chunkX, int chunkZ) throws IOException { + enterRouteOperation(); + try { + GenerationHistory.GenerationStage stage = history.openStage(chunkX, chunkZ); + try { + RuntimeLease lease = acquireRuntime(stage.activation(), stage.epoch()); + return new RuntimeRoute(this, stage, lease); + } catch (Throwable failure) { + stage.close(); + throw failure; + } + } catch (Throwable failure) { + leaveRouteOperation(); + throw propagate(failure, "Unable to open an Iris generation-history stage."); + } + } + + public RuntimeStage openStage(int chunkX, int chunkZ) throws IOException { + RuntimeRoute route = openRoute(chunkX, chunkZ); + try { + RuntimeRoute.RuntimeScope runtimeScope = route.openRuntimeScope(); + return new RuntimeStage(route, runtimeScope, Thread.currentThread()); + } catch (Throwable failure) { + route.close(); + throw propagate(failure, "Unable to scope an Iris generation-history stage."); + } + } + + public CoordinateScope openCoordinateScope(int blockX, int blockZ) throws IOException { + int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE); + int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE); + RuntimeRoute current = scopedRoute.get(); + if (current != null) { + if (current.chunkX() == chunkX && current.chunkZ() == chunkZ) { + return new CoordinateScope(blockX, blockZ, current, null); + } + RuntimeStage stage = openStage(chunkX, chunkZ); + return new CoordinateScope(blockX, blockZ, stage.route, stage); + } + if (engine.hasGenerationRuntimeScope()) { + return new CoordinateScope(blockX, blockZ, null, null); + } + RuntimeStage stage = openStage(chunkX, chunkZ); + return new CoordinateScope(blockX, blockZ, stage.route, stage); + } + + public void preloadActiveRuntimes() throws IOException { + enterOperation(); + try { + GenerationActivation active = history.activeActivation(); + try (RuntimeLease ignored = acquireRuntime(active, requireEpoch(active))) { + } + } finally { + leaveOperation(); + } + } + + private GenerationActivation promotePending() throws IOException { + enterOperation(); + try { + GenerationActivation activated = history.promotePending(this::captureBoundarySignatures); + GenerationEpoch epoch = requireEpoch(activated); + try (RuntimeLease lease = acquireRuntime(activated, epoch)) { + engine.setDefaultGenerationRuntime(lease.binding()); + retireBindings(pinDefault(lease.entry)); + return activated; + } + } finally { + leaveOperation(); + } + } + + private GenerationActivation promotePendingAndReconcileCurrentKernel( + int transitionWidthBlocks + ) throws IOException { + GenerationActivation activated = promotePending(); + while (!history.activeEpoch().kernelVersion().equals(history.currentKernelVersion())) { + history.stageCurrentKernel(transitionWidthBlocks); + activated = promotePending(); + } + return activated; + } + + public boolean isClosed() { + stateLock.lock(); + try { + return closed; + } finally { + stateLock.unlock(); + } + } + + @Override + public void close() { + if (operationDepth.get() > 0) { + throw new IllegalStateException("Cannot close the generation-history router from an active route."); + } + List retired = null; + stateLock.lock(); + try { + closed = true; + while (true) { + if (closeComplete) { + rethrowCloseFailure(closeFailure); + return; + } + if (closeCleanupInProgress || activeOperations > 0 || activeLoads > 0) { + inactive.awaitUninterruptibly(); + continue; + } + closeCleanupInProgress = true; + retired = new ArrayList<>(bindings.size()); + for (RuntimeCacheEntry entry : bindings.values()) { + if (!entry.defaultRuntime && entry.binding != null) { + retired.add(scheduleRetirementLocked(entry)); + } + } + bindings.clear(); + break; + } + } finally { + stateLock.unlock(); + } + + Throwable failure = null; + try { + engine.detachGenerationHistoryRuntimeRouter(this); + } catch (Throwable detachFailure) { + failure = detachFailure; + } + try { + retireBindings(retired); + } catch (Throwable retirementFailure) { + failure = appendFailure(failure, retirementFailure); + } + + stateLock.lock(); + try { + closeFailure = failure; + retiringBindings.clear(); + closeCleanupInProgress = false; + closeComplete = true; + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + rethrowCloseFailure(failure); + } + + private TerrainBoundarySignatureStore.SignatureSampler captureBoundarySignatures( + GenerationBoundary boundary + ) { + return new BoundaryCapture(); + } + + private RuntimeLease acquireRuntime( + GenerationActivation activation, + GenerationEpoch epoch + ) throws IOException { + RuntimeCacheEntry entry; + boolean loader = false; + stateLock.lock(); + try { + awaitRetirementLocked(activation.activationId()); + entry = bindings.get(activation.activationId()); + if (entry == null) { + entry = new RuntimeCacheEntry(activation.activationId()); + entry.loading = true; + bindings.put(activation.activationId(), entry); + activeLoads++; + loader = true; + } + entry.leases++; + } finally { + stateLock.unlock(); + } + + if (loader) { + loadRuntime(entry, activation, epoch); + } + return awaitRuntime(entry, activation.activationId()); + } + + private void awaitRetirementLocked(long activationId) throws IOException { + while (true) { + if (closed) { + throw new IllegalStateException("Generation-history runtime router is closed."); + } + RuntimeRetirement retirement = retiringBindings.get(activationId); + if (retirement == null) { + return; + } + if (retirement.failure != null) { + throw new IOException("Generation runtime for activation " + activationId + + " could not retire safely.", retirement.failure); + } + try { + inactive.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting generation runtime retirement for activation " + + activationId + ".", interrupted); + } + } + } + + private void loadRuntime( + RuntimeCacheEntry entry, + GenerationActivation activation, + GenerationEpoch epoch + ) { + IrisEngine.GenerationRuntimeBinding loaded = null; + Throwable failure = null; + try { + loaded = Objects.requireNonNull( + runtimeFactory.load(engine, history, activation, epoch), + "activation runtime binding" + ); + requireBindingContract(activation, epoch, loaded); + } catch (Throwable loadFailure) { + failure = loadFailure; + } + if (failure != null && loaded != null) { + try { + engine.closeDetachedGenerationRuntime(loaded); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } + + stateLock.lock(); + try { + entry.binding = failure == null ? loaded : null; + entry.loadFailure = failure; + entry.loading = false; + activeLoads--; + if (failure != null && bindings.get(entry.activationId) == entry) { + bindings.remove(entry.activationId); + } + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + } + + private RuntimeLease awaitRuntime(RuntimeCacheEntry entry, long activationId) throws IOException { + IrisEngine.GenerationRuntimeBinding binding; + Throwable failure; + stateLock.lock(); + try { + while (entry.loading) { + try { + inactive.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + entry.leases--; + inactive.signalAll(); + throw new IOException("Interrupted while loading generation runtime for activation " + + activationId + ".", interrupted); + } + } + failure = entry.loadFailure; + binding = entry.binding; + if (failure != null || binding == null) { + entry.leases--; + inactive.signalAll(); + } + } finally { + stateLock.unlock(); + } + if (failure != null || binding == null) { + throw propagate(failure == null + ? new IllegalStateException("Generation runtime load returned no binding.") + : failure, + "Unable to load generation runtime for activation " + activationId + "."); + } + return new RuntimeLease(this, entry, binding); + } + + private List pinDefault(RuntimeCacheEntry nextDefault) { + stateLock.lock(); + try { + for (RuntimeCacheEntry entry : bindings.values()) { + entry.defaultRuntime = entry == nextDefault; + } + return collectEvictionsLocked(); + } finally { + stateLock.unlock(); + } + } + + private void releaseRuntime(RuntimeCacheEntry entry) { + List retired; + stateLock.lock(); + try { + if (entry.leases <= 0) { + throw new IllegalStateException("Generation runtime cache entry has no active lease."); + } + entry.leases--; + retired = collectEvictionsLocked(); + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + retireBindings(retired); + } + + private List collectEvictionsLocked() { + ArrayList retired = new ArrayList<>(); + while (bindings.size() > runtimeCacheCapacity) { + RuntimeCacheEntry candidate = null; + Iterator> iterator = bindings.entrySet().iterator(); + while (iterator.hasNext()) { + RuntimeCacheEntry entry = iterator.next().getValue(); + if (entry.defaultRuntime || entry.loading || entry.leases > 0 || entry.binding == null) { + continue; + } + candidate = entry; + iterator.remove(); + break; + } + if (candidate == null) { + break; + } + retired.add(scheduleRetirementLocked(candidate)); + } + return retired; + } + + private RuntimeRetirement scheduleRetirementLocked(RuntimeCacheEntry entry) { + RuntimeRetirement retirement = new RuntimeRetirement(entry.activationId, entry.binding); + if (retiringBindings.putIfAbsent(entry.activationId, retirement) != null) { + throw new IllegalStateException("Generation runtime already retiring for activation " + + entry.activationId + "."); + } + return retirement; + } + + private void retireBindings(List retired) { + Throwable failure = null; + for (RuntimeRetirement retirement : retired) { + Throwable retirementFailure = null; + try { + engine.closeDetachedGenerationRuntime(retirement.binding); + } catch (Throwable closeFailure) { + retirementFailure = closeFailure; + failure = appendFailure(failure, closeFailure); + } finally { + stateLock.lock(); + try { + retirement.failure = retirementFailure; + if (retirementFailure == null) { + retiringBindings.remove(retirement.activationId, retirement); + } + inactive.signalAll(); + } finally { + stateLock.unlock(); + } + } + } + if (failure != null) { + throw new IllegalStateException("Failed to retire cached generation runtimes.", failure); + } + } + + private void requireBindingContract( + GenerationActivation activation, + GenerationEpoch epoch, + IrisEngine.GenerationRuntimeBinding binding + ) throws IOException { + if (!epoch.kernelVersion().equals(binding.kernelVersion())) { + throw new IOException("Generation runtime for activation " + activation.activationId() + + " uses kernel " + binding.kernelVersion() + " instead of " + epoch.kernelVersion() + "."); + } + GenerationKernelRegistry.RuntimeKernel runtimeKernel = binding.runtimeKernel(); + if (runtimeKernel == null || !epoch.kernelVersion().equals(runtimeKernel.version())) { + throw new IOException("Generation runtime for activation " + activation.activationId() + + " has no matching executable kernel for " + epoch.kernelVersion() + "."); + } + if (!epoch.kernelImplementationFingerprint().equals(runtimeKernel.implementationFingerprint())) { + throw new IOException("Generation runtime for activation " + activation.activationId() + + " uses kernel implementation " + runtimeKernel.implementationFingerprint() + + " instead of " + epoch.kernelImplementationFingerprint() + "."); + } + Path expectedMantle = history.paths().activationMantleRoot(activation.activationId()); + if (!expectedMantle.equals(binding.mantleStorageDirectory())) { + throw new IOException("Generation runtime for activation " + activation.activationId() + + " uses mantle " + binding.mantleStorageDirectory() + " instead of " + expectedMantle + "."); + } + TransitionGenerationPlan actualPlan = binding.transitionPlan(); + if (activation.isInitial()) { + if (actualPlan != null) { + throw new IOException("Initial generation activation cannot use a transition plan."); + } + return; + } + TransitionGenerationPlan expectedPlan = history.transitionPlan(activation.activationId()); + if (actualPlan == null || !expectedPlan.specification().equals(actualPlan.specification())) { + throw new IOException("Generation runtime transition plan does not match activation " + + activation.activationId() + "."); + } + } + + private GenerationEpoch requireEpoch(GenerationActivation activation) throws IOException { + return history.manifest().epoch(activation.epochId()).orElseThrow(() -> new IOException( + "Generation activation " + activation.activationId() + " references missing epoch " + + activation.epochId() + "." + )); + } + + private void enterOperation() { + stateLock.lock(); + try { + if (closed) { + throw new IllegalStateException("Generation-history runtime router is closed."); + } + activeOperations++; + operationDepth.set(operationDepth.get() + 1); + } finally { + stateLock.unlock(); + } + } + + private void enterRouteOperation() { + stateLock.lock(); + try { + if (closed) { + throw new IllegalStateException("Generation-history runtime router is closed."); + } + activeOperations++; + } finally { + stateLock.unlock(); + } + } + + private void leaveOperation() { + stateLock.lock(); + try { + int depth = operationDepth.get(); + if (depth <= 0 || activeOperations <= 0) { + throw new IllegalStateException("No generation-history route is active."); + } + if (depth == 1) { + operationDepth.remove(); + } else { + operationDepth.set(depth - 1); + } + activeOperations--; + if (activeOperations == 0) { + inactive.signalAll(); + } + } finally { + stateLock.unlock(); + } + } + + private void leaveRouteOperation() { + stateLock.lock(); + try { + if (activeOperations <= 0) { + throw new IllegalStateException("No generation-history route is active."); + } + activeOperations--; + if (activeOperations == 0) { + inactive.signalAll(); + } + } finally { + stateLock.unlock(); + } + } + + private void enterRuntimeScope() { + operationDepth.set(operationDepth.get() + 1); + } + + private RuntimeRoute installScopedRoute(RuntimeRoute route) { + RuntimeRoute previous = scopedRoute.get(); + scopedRoute.set(route); + return previous; + } + + private void restoreScopedRoute(RuntimeRoute route, RuntimeRoute previous) { + if (scopedRoute.get() != route) { + throw new IllegalStateException("Generation-history runtime scopes closed out of order."); + } + if (previous == null) { + scopedRoute.remove(); + } else { + scopedRoute.set(previous); + } + } + + private void leaveRuntimeScope() { + int depth = operationDepth.get(); + if (depth <= 0) { + throw new IllegalStateException("No generation-history runtime scope is active."); + } + if (depth == 1) { + operationDepth.remove(); + return; + } + operationDepth.set(depth - 1); + } + + private static IOException propagate(Throwable failure, String message) { + if (failure instanceof IOException ioFailure) { + return ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + return new IOException(message, failure); + } + + private static Throwable appendFailure(Throwable existing, Throwable added) { + if (added == null) { + return existing; + } + if (existing == null) { + return added; + } + if (existing != added) { + existing.addSuppressed(added); + } + return existing; + } + + private static void rethrowCloseFailure(Throwable failure) { + if (failure == null) { + return; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Failed to close the generation-history runtime router.", failure); + } + + private static final class RuntimeCacheEntry { + private final long activationId; + private IrisEngine.GenerationRuntimeBinding binding; + private Throwable loadFailure; + private int leases; + private boolean loading; + private boolean defaultRuntime; + + private RuntimeCacheEntry(long activationId) { + this.activationId = activationId; + } + } + + private static final class RuntimeRetirement { + private final long activationId; + private final IrisEngine.GenerationRuntimeBinding binding; + private Throwable failure; + + private RuntimeRetirement(long activationId, IrisEngine.GenerationRuntimeBinding binding) { + this.activationId = activationId; + this.binding = binding; + } + } + + private static final class RuntimeLease implements AutoCloseable { + private final GenerationHistoryRuntimeRouter router; + private final RuntimeCacheEntry entry; + private final IrisEngine.GenerationRuntimeBinding binding; + private boolean closed; + + private RuntimeLease( + GenerationHistoryRuntimeRouter router, + RuntimeCacheEntry entry, + IrisEngine.GenerationRuntimeBinding binding + ) { + this.router = router; + this.entry = entry; + this.binding = binding; + } + + private IrisEngine.GenerationRuntimeBinding binding() { + return binding; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + router.releaseRuntime(entry); + } + } + + private final class BoundaryCapture implements TerrainBoundarySignatureStore.SignatureSampler { + private RuntimeLease lease; + private IrisEngine.GenerationRuntimeScope scope; + private long activationId; + + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException { + GenerationActivation activation = history.resolveActivation( + Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE), + Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE) + ); + if (lease == null || activationId != activation.activationId()) { + close(); + lease = acquireRuntime(activation, requireEpoch(activation)); + try { + scope = engine.openGenerationRuntimeScope(lease.binding()); + activationId = activation.activationId(); + } catch (Throwable failure) { + close(); + throw propagate(failure, "Unable to scope boundary capture."); + } + } + return signatureSampler.sample(engine, blockX, blockZ); + } + + @Override + public void close() { + try { + if (scope != null) { + IrisEngine.GenerationRuntimeScope closing = scope; + scope = null; + closing.close(); + } + } finally { + if (lease != null) { + RuntimeLease closing = lease; + lease = null; + closing.close(); + } + } + } + } + + interface ActivationRuntimeFactory { + void validateBase( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch, + IrisEngine.GenerationRuntimeBinding binding + ) throws IOException; + + IrisEngine.GenerationRuntimeBinding load( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch + ) throws IOException; + } + + private static final class DefaultActivationRuntimeFactory implements ActivationRuntimeFactory { + @Override + public void validateBase( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch, + IrisEngine.GenerationRuntimeBinding binding + ) throws IOException { + requireWorldSeed(epoch, binding.target().getWorld().getRawWorldSeed()); + Path expectedPack = history.packRoot(activation.activationId()); + Path actualPack = binding.target().getData().getDataFolder().toPath().toAbsolutePath().normalize(); + if (!expectedPack.equals(actualPack)) { + throw new IOException("Base Iris runtime does not use active activation " + + activation.activationId() + " pack " + expectedPack + "."); + } + binding.target().getData().bindGenerationRegistryContract(epoch.registryContract()); + requireDimensionContract(binding.target().getDimension(), epoch.dimensionContract(), actualPack); + } + + @Override + public IrisEngine.GenerationRuntimeBinding load( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch + ) throws IOException { + requireWorldSeed(epoch, engine.getWorld().getRawWorldSeed()); + Path packRoot = history.packRoot(activation.activationId()); + IrisData data = IrisData.openRuntime(packRoot.toFile()); + try { + data.bindGenerationRegistryContract(epoch.registryContract()); + IrisDimension dimension = data.getDimensionLoader().load(epoch.dimensionContract().dimensionKey()); + if (dimension == null) { + throw new IOException("Immutable generation pack does not contain dimension '" + + epoch.dimensionContract().dimensionKey() + "': " + packRoot); + } + requireDimensionContract(dimension, epoch.dimensionContract(), packRoot); + TransitionGenerationPlan plan = activation.isInitial() + ? null + : history.transitionPlan(activation.activationId()); + EngineTarget target = new EngineTarget(engine.getWorld(), dimension, data); + return engine.buildDetachedGenerationRuntime( + target, + history.paths().activationMantleRoot(activation.activationId()), + epoch.kernelVersion(), + plan + ); + } catch (Throwable failure) { + if (!data.isClosed()) { + data.unregisterEngine(engine); + data.close(); + } + throw propagate(failure, "Unable to build generation runtime for activation " + + activation.activationId() + "."); + } + } + + private static void requireWorldSeed(GenerationEpoch epoch, long actualSeed) throws IOException { + if (epoch.worldSeed() != actualSeed) { + throw new IOException("Generation activation epoch requires world seed " + + epoch.worldSeed() + " but the Iris engine uses " + actualSeed + "."); + } + } + + private static void requireDimensionContract( + IrisDimension dimension, + GenerationEpoch.DimensionContract recorded, + Path packRoot + ) throws IOException { + GenerationEpoch.DimensionContract loaded; + try { + loaded = GenerationEpochContractFactory.create( + dimension, + dimension.getLoadKey(), + recorded.dimensionTypeKey() + ); + } catch (RuntimeException failure) { + throw new IOException("Unable to validate immutable generation dimension at " + packRoot + ".", failure); + } + if (!recorded.equals(loaded)) { + throw new IOException("Immutable generation pack no longer matches its dimension contract: " + + packRoot); + } + } + } + + public static final class RuntimeRoute implements AutoCloseable { + private final GenerationHistoryRuntimeRouter router; + private final GenerationHistory.GenerationStage stage; + private final RuntimeLease lease; + private final IrisEngine.GenerationRuntimeBinding binding; + private final RuntimeOwnership ownership; + private int activeScopes; + private boolean closed; + + private RuntimeRoute( + GenerationHistoryRuntimeRouter router, + GenerationHistory.GenerationStage stage, + RuntimeLease lease + ) { + this.router = router; + this.stage = stage; + this.lease = lease; + this.binding = lease.binding(); + this.ownership = new RuntimeOwnership(stage.activation().activationId(), binding); + } + + public GenerationHistory.GenerationStage generationStage() { + return stage; + } + + public int chunkX() { + return stage.chunkX(); + } + + public int chunkZ() { + return stage.chunkZ(); + } + + public GenerationActivation activation() { + return stage.activation(); + } + + public GenerationEpoch epoch() { + return stage.epoch(); + } + + public TransitionGenerationPlan transitionPlan() { + return binding.transitionPlan(); + } + + public boolean claimGeneratedSemantics() throws IOException { + if (router.scopedRoute.get() != this) { + throw new IllegalStateException( + "Generation semantics must be captured inside this route's runtime scope." + ); + } + ChunkGenerationSemantics semantics = GenerationSemanticCapture.capture( + router.engine, + stage + ); + return router.history.claimGeneratedSemantics(stage, semantics); + } + + public RuntimeScope openRuntimeScope() { + synchronized (this) { + if (closed) { + throw new IllegalStateException("Generation-history runtime route is closed."); + } + activeScopes++; + } + router.enterRuntimeScope(); + RuntimeRoute previousRoute = router.installScopedRoute(this); + try { + IrisEngine.GenerationRuntimeScope opened = router.engine.openGenerationRuntimeScope(binding); + return new RuntimeScope(this, opened, Thread.currentThread(), previousRoute); + } catch (Throwable failure) { + try { + router.restoreScopedRoute(this, previousRoute); + } catch (Throwable closeFailure) { + appendFailure(failure, closeFailure); + } + try { + router.leaveRuntimeScope(); + } catch (Throwable closeFailure) { + appendFailure(failure, closeFailure); + } + try { + releaseRuntimeScope(); + } catch (Throwable closeFailure) { + appendFailure(failure, closeFailure); + } + throw failure; + } + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + if (activeScopes > 0) { + throw new IllegalStateException("Generation-history runtime route still has active scopes."); + } + closed = true; + Throwable failure = null; + try { + stage.close(); + } catch (Throwable closeFailure) { + failure = closeFailure; + } + try { + lease.close(); + } catch (Throwable closeFailure) { + if (failure == null) { + failure = closeFailure; + } else if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + try { + router.leaveRouteOperation(); + } catch (Throwable closeFailure) { + if (failure == null) { + failure = closeFailure; + } else if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IllegalStateException("Failed to close generation-history runtime route.", failure); + } + } + + private synchronized void releaseRuntimeScope() { + if (activeScopes <= 0) { + throw new IllegalStateException("Generation-history runtime route has no active scope."); + } + activeScopes--; + } + + public static final class RuntimeScope implements AutoCloseable { + private final RuntimeRoute route; + private final IrisEngine.GenerationRuntimeScope runtimeScope; + private final Thread owner; + private final RuntimeRoute previousRoute; + private boolean closed; + + private RuntimeScope( + RuntimeRoute route, + IrisEngine.GenerationRuntimeScope runtimeScope, + Thread owner, + RuntimeRoute previousRoute + ) { + this.route = route; + this.runtimeScope = runtimeScope; + this.owner = owner; + this.previousRoute = previousRoute; + } + + @Override + public void close() { + if (closed) { + return; + } + if (Thread.currentThread() != owner) { + throw new IllegalStateException("Generation-history runtime scope closed from a different thread."); + } + closed = true; + Throwable failure = null; + try { + runtimeScope.close(); + } catch (Throwable closeFailure) { + failure = closeFailure; + } + try { + route.router.restoreScopedRoute(route, previousRoute); + } catch (Throwable closeFailure) { + if (failure == null) { + failure = closeFailure; + } else if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + try { + route.router.leaveRuntimeScope(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + try { + route.releaseRuntimeScope(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IllegalStateException("Failed to close generation-history runtime scope.", failure); + } + } + } + } + + public record RuntimeOwnership( + long activationId, + IrisEngine.GenerationRuntimeBinding binding + ) { + public RuntimeOwnership { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation ID must be positive."); + } + Objects.requireNonNull(binding, "generation runtime binding"); + } + } + + public static final class RuntimeStage implements AutoCloseable { + private final RuntimeRoute route; + private final RuntimeRoute.RuntimeScope runtimeScope; + private final Thread owner; + private boolean closed; + + private RuntimeStage( + RuntimeRoute route, + RuntimeRoute.RuntimeScope runtimeScope, + Thread owner + ) { + this.route = route; + this.runtimeScope = runtimeScope; + this.owner = owner; + } + + public GenerationHistory.GenerationStage generationStage() { + return route.generationStage(); + } + + public GenerationActivation activation() { + return route.activation(); + } + + public GenerationEpoch epoch() { + return route.epoch(); + } + + @Override + public void close() { + if (closed) { + return; + } + if (Thread.currentThread() != owner) { + throw new IllegalStateException("Generation-history stage closed from a different thread."); + } + Throwable failure = null; + try { + runtimeScope.close(); + } catch (Throwable closeFailure) { + failure = closeFailure; + } + try { + route.close(); + } catch (Throwable closeFailure) { + failure = appendFailure(failure, closeFailure); + } finally { + closed = true; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IllegalStateException("Failed to close generation-history stage.", failure); + } + } + } + + public static final class CoordinateScope implements AutoCloseable { + private final int blockX; + private final int blockZ; + private final RuntimeRoute route; + private final RuntimeStage stage; + + private CoordinateScope(int blockX, int blockZ, RuntimeRoute route, RuntimeStage stage) { + this.blockX = blockX; + this.blockZ = blockZ; + this.route = route; + this.stage = stage; + } + + public int blockX() { + return blockX; + } + + public int blockZ() { + return blockZ; + } + + public int chunkX() { + return route == null + ? Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE) + : route.chunkX(); + } + + public int chunkZ() { + return route == null + ? Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE) + : route.chunkZ(); + } + + public GenerationActivation activation() { + if (route == null) { + throw new IllegalStateException("Borrowed runtime coordinate scope has no history route metadata."); + } + return route.activation(); + } + + public GenerationEpoch epoch() { + if (route == null) { + throw new IllegalStateException("Borrowed runtime coordinate scope has no history route metadata."); + } + return route.epoch(); + } + + public boolean claimGeneratedSemantics() throws IOException { + return stage != null && route.claimGeneratedSemantics(); + } + + @Override + public void close() { + if (stage != null) { + stage.close(); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java new file mode 100644 index 000000000..675867e69 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryStore.java @@ -0,0 +1,383 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; + +public final class GenerationHistoryStore { + public static final String MANIFEST_FILE_NAME = "manifest.json"; + private static final int MAX_MANIFEST_BYTES = 16 * 1_024 * 1_024; + + private static final Gson GSON = new GsonBuilder() + .disableHtmlEscaping() + .serializeNulls() + .setPrettyPrinting() + .create(); + + private final Path generationDirectory; + private final Path manifestPath; + private final Clock clock; + private final DirectorySync directorySync; + private GenerationManifest manifest; + private IOException failure; + + private GenerationHistoryStore( + Path generationDirectory, + GenerationManifest manifest, + Clock clock, + DirectorySync directorySync + ) { + this.generationDirectory = generationDirectory; + this.manifestPath = generationDirectory.resolve(MANIFEST_FILE_NAME); + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.directorySync = Objects.requireNonNull(directorySync, "directorySync"); + } + + public static GenerationHistoryStore initialize( + Path generationDirectory, + GenerationEpoch initialEpoch + ) throws IOException { + return initialize(generationDirectory, initialEpoch, Clock.systemUTC()); + } + + static GenerationHistoryStore initialize( + Path generationDirectory, + GenerationEpoch initialEpoch, + Clock clock + ) throws IOException { + return initialize(generationDirectory, initialEpoch, clock, GenerationHistoryStore::forceDirectory); + } + + static GenerationHistoryStore initialize( + Path generationDirectory, + GenerationEpoch initialEpoch, + Clock clock, + DirectorySync directorySync + ) throws IOException { + Path directory = normalizeDirectory(generationDirectory); + requireSafeParent(directory); + Path parent = Objects.requireNonNull(directory.getParent(), "Generation history parent"); + String lockName = "." + Objects.requireNonNull(directory.getFileName(), "Generation history directory name") + + ".manifest.lock"; + try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire(parent, lockName)) { + ensureDirectory(directory); + Path manifestPath = directory.resolve(MANIFEST_FILE_NAME); + if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new FileAlreadyExistsException(manifestPath.toString()); + } + Clock requiredClock = Objects.requireNonNull(clock, "clock"); + GenerationManifest manifest = GenerationManifest.initial( + Objects.requireNonNull(initialEpoch, "initialEpoch"), + requiredClock.millis() + ); + DirectorySync requiredDirectorySync = Objects.requireNonNull(directorySync, "directorySync"); + writeManifestAtomically(directory, manifestPath, manifest, false, requiredDirectorySync); + return new GenerationHistoryStore(directory, manifest, requiredClock, requiredDirectorySync); + } + } + + public static GenerationHistoryStore open(Path generationDirectory) throws IOException { + return open(generationDirectory, Clock.systemUTC()); + } + + static GenerationHistoryStore open(Path generationDirectory, Clock clock) throws IOException { + Path directory = normalizeDirectory(generationDirectory); + requireDirectory(directory); + Path manifestPath = directory.resolve(MANIFEST_FILE_NAME); + requireRegularFile(manifestPath); + GenerationManifest manifest = readManifest(manifestPath); + return new GenerationHistoryStore( + directory, + manifest, + clock, + GenerationHistoryStore::forceDirectory + ); + } + + public Path generationDirectory() { + return generationDirectory; + } + + public synchronized GenerationManifest manifest() { + requireUsable(); + return manifest; + } + + public synchronized GenerationActivation activeActivation() { + requireUsable(); + return manifest.activeActivation(); + } + + public synchronized GenerationEpoch activeEpoch() { + requireUsable(); + return manifest.activeEpoch(); + } + + public synchronized Optional pendingActivation() { + requireUsable(); + return manifest.pendingActivation(); + } + + public synchronized Optional pendingEpoch() { + requireUsable(); + return manifest.pendingEpoch(); + } + + public synchronized Optional epoch(String epochId) { + requireUsable(); + return manifest.epoch(epochId); + } + + public synchronized Optional activation(long activationId) { + requireUsable(); + return manifest.activation(activationId); + } + + public synchronized GenerationActivation preparePendingActivation( + GenerationEpoch epoch, + int transitionWidthBlocks + ) throws IOException { + requireUsable(); + GenerationManifest updated = manifest.preparePending( + epoch, + clock.millis(), + transitionWidthBlocks + ); + if (updated == manifest) { + return manifest.pendingActivation().orElseThrow(); + } + writeUpdatedManifest(updated); + manifest = updated; + return updated.pendingActivation().orElseThrow(); + } + + public synchronized GenerationActivation completePendingTransition( + long expectedActivationId, + String boundaryIdentity, + String terrainSignatureIdentity + ) throws IOException { + requireUsable(); + GenerationManifest updated = manifest.completePendingTransition( + expectedActivationId, + boundaryIdentity, + terrainSignatureIdentity + ); + if (updated != manifest) { + writeUpdatedManifest(updated); + manifest = updated; + } + return manifest.pendingActivation().orElseThrow(); + } + + public synchronized GenerationActivation activatePending( + long expectedActivationId + ) throws IOException { + requireUsable(); + GenerationManifest updated = manifest.activatePending(expectedActivationId); + writeUpdatedManifest(updated); + manifest = updated; + return updated.activeActivation(); + } + + private void writeUpdatedManifest(GenerationManifest updated) throws IOException { + try { + writeManifestAtomically(generationDirectory, manifestPath, updated, true, directorySync); + } catch (IOException error) { + reconcileAfterWriteFailure(error); + throw error; + } + } + + private static GenerationManifest readManifest(Path manifestPath) throws IOException { + long size = Files.size(manifestPath); + if (size < 2L || size > MAX_MANIFEST_BYTES) { + throw new IOException("Generation manifest size is invalid: " + manifestPath); + } + byte[] encoded = Files.readAllBytes(manifestPath); + if (encoded.length < 2 || encoded.length > MAX_MANIFEST_BYTES) { + throw new IOException("Generation manifest size changed while reading: " + manifestPath); + } + try (Reader input = new StringReader(new String(encoded, StandardCharsets.UTF_8)); + JsonReader reader = new JsonReader(input)) { + reader.setStrictness(Strictness.STRICT); + JsonElement parsed = JsonParser.parseReader(reader); + if (parsed == null || !parsed.isJsonObject()) { + throw new IllegalArgumentException("Generation manifest must be a JSON object."); + } + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw new IllegalArgumentException("Generation manifest contains trailing content."); + } + return GenerationManifest.fromJson(parsed.getAsJsonObject()); + } catch (JsonParseException | IllegalArgumentException | ArithmeticException exception) { + throw new IOException("Invalid generation manifest: " + manifestPath, exception); + } + } + + private static void writeManifestAtomically( + Path generationDirectory, + Path manifestPath, + GenerationManifest manifest, + boolean replaceExisting, + DirectorySync directorySync + ) throws IOException { + requireSafeParent(generationDirectory); + requireDirectory(generationDirectory); + byte[] content = GSON.toJson(manifest.toJson()).getBytes(StandardCharsets.UTF_8); + Path temporary = Files.createTempFile(generationDirectory, ".manifest-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + ByteBuffer buffer = ByteBuffer.wrap(content); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + if (replaceExisting) { + Files.move( + temporary, + manifestPath, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } else { + Files.move(temporary, manifestPath, StandardCopyOption.ATOMIC_MOVE); + } + } catch (AtomicMoveNotSupportedException exception) { + throw new IOException("Generation history requires atomic manifest publication.", exception); + } + directorySync.force(generationDirectory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static Path normalizeDirectory(Path directory) { + return Objects.requireNonNull(directory, "generationDirectory").toAbsolutePath().normalize(); + } + + private static void ensureDirectory(Path directory) throws IOException { + requireSafeParent(directory); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + requireDirectory(directory); + forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent")); + return; + } + try { + Files.createDirectory(directory); + } catch (FileAlreadyExistsException race) { + requireDirectory(directory); + forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent")); + return; + } + forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent")); + requireDirectory(directory); + } + + private static void requireDirectory(Path directory) throws IOException { + requireSafeParent(directory); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(directory.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + directory, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation history path is not a safe directory: " + directory); + } + } + + private static void requireSafeParent(Path directory) throws IOException { + Path parent = directory.getParent(); + if (parent == null || !Files.exists(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation history parent directory is missing: " + parent); + } + BasicFileAttributes attributes = Files.readAttributes( + parent, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation history parent path is not a safe directory: " + parent); + } + } + + private static void requireRegularFile(Path file) throws IOException { + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(file.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + file, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw new IOException("Generation manifest is not a safe regular file: " + file); + } + } + + private static void forceDirectory(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException exception) { + throw new IOException("Generation history directory cannot be durability-synced: " + directory, exception); + } + } + + private void reconcileAfterWriteFailure(IOException writeFailure) { + try { + requireDirectory(generationDirectory); + requireRegularFile(manifestPath); + manifest = readManifest(manifestPath); + } catch (IOException reconciliationFailure) { + writeFailure.addSuppressed(reconciliationFailure); + failure = writeFailure; + } + } + + private void requireUsable() { + if (failure != null) { + throw new IllegalStateException("Generation history store is unusable after a failed manifest write", failure); + } + } + + @FunctionalInterface + interface DirectorySync { + void force(Path directory) throws IOException; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelCatalog.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelCatalog.java new file mode 100644 index 000000000..f51831eff --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelCatalog.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.history; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; + +final class GenerationKernelCatalog { + private static final int MAXIMUM_BYTES = 16 * 1024; + + private GenerationKernelCatalog() { + } + + static GenerationKernelRegistry load() { + try (InputStream input = GenerationKernelCatalog.class.getResourceAsStream( + "/META-INF/iris/generation-kernels/catalog.tsv")) { + if (input == null) { + throw new IOException("Missing Iris generation kernel catalog."); + } + Catalog catalog = parse(input.readNBytes(MAXIMUM_BYTES + 1)); + List kernels = new ArrayList<>(); + for (int abi : catalog.abis()) { + GenerationKernelSourceSeal.Descriptor seal = GenerationKernelSourceSeal.load(abi); + Class factoryType = Class.forName( + seal.factoryClass(), true, GenerationKernelCatalog.class.getClassLoader()) + .asSubclass(GenerationKernelRegistry.RuntimeFactory.class); + Map factories = new LinkedHashMap<>(); + for (GenerationKernelRegistry.AlgorithmVersion algorithm : seal.algorithms()) { + factories.put(algorithm, factoryType.getDeclaredConstructor().newInstance()); + } + kernels.add(new GenerationKernelRegistry.Kernel(abi, seal.fingerprint(), factories)); + } + return new GenerationKernelRegistry(catalog.current(), kernels); + } catch (IOException | ReflectiveOperationException | IllegalArgumentException failure) { + throw new IllegalStateException("Cannot load the sealed Iris generation kernel catalog.", failure); + } + } + + static Catalog parse(byte[] bytes) throws IOException { + if (bytes.length == 0 || bytes.length > MAXIMUM_BYTES) { + throw new IOException("Invalid generation kernel catalog size."); + } + String text = new String(bytes, StandardCharsets.UTF_8); + String[] lines = text.split("\n", -1); + if (!text.endsWith("\n") || text.indexOf('\r') >= 0 || lines.length < 4 || lines.length > 128 + || !lines[0].equals("iris-generation-kernel-catalog-v1")) { + throw new IOException("Invalid generation kernel catalog format."); + } + try { + String[] current = lines[1].split("\t", -1); + if (current.length != 4 || !current[0].equals("current")) { + throw new IllegalArgumentException("Missing current generation version."); + } + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version( + positive(current[1]), positive(current[2]), positive(current[3])); + List abis = new ArrayList<>(); + int previous = 0; + for (int index = 2; index < lines.length - 1; index++) { + String[] kernel = lines[index].split("\t", -1); + if (kernel.length != 2 || !kernel[0].equals("kernel")) { + throw new IllegalArgumentException("Invalid generation kernel registration."); + } + int abi = positive(kernel[1]); + if (abi <= previous) { + throw new IllegalArgumentException("Generation kernel registrations must be unique and sorted."); + } + previous = abi; + abis.add(abi); + } + if (!abis.contains(version.generatorAbi())) { + throw new IllegalArgumentException("Current generation ABI is not registered."); + } + return new Catalog(version, List.copyOf(abis)); + } catch (IllegalArgumentException failure) { + throw new IOException("Invalid generation kernel catalog.", failure); + } + } + + private static int positive(String value) { + if (!value.matches("[1-9][0-9]{0,8}")) { + throw new IllegalArgumentException("Invalid generation version."); + } + return Integer.parseInt(value); + } + + record Catalog(GenerationKernelRegistry.Version current, List abis) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java new file mode 100644 index 000000000..35d1511fa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java @@ -0,0 +1,253 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.mantle.EngineMantle; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.regex.Pattern; + +public final class GenerationKernelRegistry { + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + private static final GenerationKernelRegistry STANDARD = GenerationKernelCatalog.load(); + + private final Version current; + private final Map kernels; + + public GenerationKernelRegistry(Version current, Collection kernels) { + this.current = Objects.requireNonNull(current, "current"); + Collection requiredKernels = Objects.requireNonNull(kernels, "kernels"); + TreeMap indexed = new TreeMap<>(); + for (Kernel kernel : requiredKernels) { + Kernel requiredKernel = Objects.requireNonNull(kernel, "kernel"); + if (indexed.putIfAbsent(requiredKernel.generatorAbi(), requiredKernel) != null) { + throw new IllegalArgumentException("Duplicate generation ABI " + + requiredKernel.generatorAbi() + "."); + } + } + this.kernels = Map.copyOf(indexed); + if (!supports(current.generatorAbi(), current.rngVersion(), current.seedDerivationVersion())) { + throw new IllegalArgumentException("Current generation version is not registered."); + } + } + + public static GenerationKernelRegistry standard() { + return STANDARD; + } + + public Version current() { + return current; + } + + public boolean supports(int generatorAbi, int rngVersion, int seedDerivationVersion) { + Kernel kernel = kernels.get(generatorAbi); + return kernel != null && kernel.runtimeFactories().containsKey( + new AlgorithmVersion(rngVersion, seedDerivationVersion) + ); + } + + public Kernel requireSupported( + int generatorAbi, + int rngVersion, + int seedDerivationVersion + ) throws IOException { + Kernel kernel = kernels.get(generatorAbi); + if (kernel == null) { + throw new IOException("Unsupported Iris generation ABI " + generatorAbi + "."); + } + AlgorithmVersion version = new AlgorithmVersion(rngVersion, seedDerivationVersion); + if (!kernel.runtimeFactories().containsKey(version)) { + throw new IOException("Unsupported Iris RNG/seed derivation version " + + rngVersion + "/" + seedDerivationVersion + + " for generation ABI " + generatorAbi + "."); + } + return kernel; + } + + public Kernel requireSupported(Version version) throws IOException { + Version requiredVersion = Objects.requireNonNull(version, "version"); + return requireSupported( + requiredVersion.generatorAbi(), + requiredVersion.rngVersion(), + requiredVersion.seedDerivationVersion() + ); + } + + public Kernel requireSupported(Version version, String implementationFingerprint) throws IOException { + Version requiredVersion = Objects.requireNonNull(version, "version"); + Kernel kernel = requireSupported(requiredVersion); + String requiredFingerprint = requireFingerprint( + implementationFingerprint, + "Generation kernel implementation fingerprint" + ); + if (!kernel.implementationFingerprint().equals(requiredFingerprint)) { + throw new IOException("Iris generation kernel " + requiredVersion + + " has implementation fingerprint " + kernel.implementationFingerprint() + + " instead of the epoch's required fingerprint " + requiredFingerprint + "."); + } + return kernel; + } + + public RuntimeKernel select(Version version) throws IOException { + Version requiredVersion = Objects.requireNonNull(version, "version"); + Kernel kernel = requireSupported(requiredVersion); + AlgorithmVersion algorithmVersion = new AlgorithmVersion( + requiredVersion.rngVersion(), + requiredVersion.seedDerivationVersion() + ); + RuntimeFactory factory = kernel.runtimeFactories().get(algorithmVersion); + if (factory == null) { + throw new IOException("No executable Iris generation kernel is registered for " + + requiredVersion + "."); + } + return new RuntimeKernel(requiredVersion, kernel.implementationFingerprint(), factory); + } + + public record Version(int generatorAbi, int rngVersion, int seedDerivationVersion) { + public Version { + if (generatorAbi < 1) { + throw new IllegalArgumentException("Generator ABI must be positive."); + } + if (rngVersion < 1) { + throw new IllegalArgumentException("RNG version must be positive."); + } + if (seedDerivationVersion < 1) { + throw new IllegalArgumentException("Seed derivation version must be positive."); + } + } + } + + private static String requireFingerprint(String value, String label) { + String requiredValue = Objects.requireNonNull(value, label); + if (!SHA_256_PATTERN.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value."); + } + return requiredValue; + } + + public record Kernel( + int generatorAbi, + String implementationFingerprint, + Map runtimeFactories + ) { + public Kernel { + if (generatorAbi < 1) { + throw new IllegalArgumentException("Generator ABI must be positive."); + } + implementationFingerprint = requireFingerprint( + implementationFingerprint, + "Generation kernel implementation fingerprint" + ); + Map requiredFactories = Map.copyOf(Objects.requireNonNull( + runtimeFactories, + "runtimeFactories" + )); + if (requiredFactories.isEmpty()) { + throw new IllegalArgumentException("A generation kernel must provide an executable runtime factory."); + } + for (Map.Entry entry : requiredFactories.entrySet()) { + Objects.requireNonNull(entry.getKey(), "algorithmVersion"); + Objects.requireNonNull(entry.getValue(), "runtimeFactory"); + } + runtimeFactories = requiredFactories; + } + } + + @FunctionalInterface + public interface RuntimeFactory { + IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan); + + default SeedManager createSeedManager(long worldSeed) { + throw new UnsupportedOperationException("Generation kernel does not own seed derivation."); + } + + default EngineMantle createMantle(IrisEngine engine, Path storageDirectory) { + throw new UnsupportedOperationException("Generation kernel does not own mantle construction."); + } + + default UpperDimensionContext createUpperContext(IrisEngine engine) { + throw new UnsupportedOperationException("Generation kernel does not own upper-dimension construction."); + } + + default EngineMode createMode(IrisEngine engine) { + throw new UnsupportedOperationException("Generation kernel does not own generation-mode construction."); + } + + default void registerStaticObjects(IrisEngine engine, EngineMode mode) { + throw new UnsupportedOperationException("Generation kernel does not own static-object stages."); + } + } + + public record RuntimeKernel( + Version version, + String implementationFingerprint, + RuntimeFactory factory + ) { + public RuntimeKernel { + Objects.requireNonNull(version, "version"); + implementationFingerprint = requireFingerprint( + implementationFingerprint, + "Generation kernel implementation fingerprint" + ); + Objects.requireNonNull(factory, "factory"); + } + + public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan) { + return Objects.requireNonNull( + factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan), + "generation kernel complex" + ); + } + + public SeedManager createSeedManager(long worldSeed) { + return Objects.requireNonNull(factory.createSeedManager(worldSeed), "generation kernel seed manager"); + } + + public EngineMantle createMantle(IrisEngine engine, Path storageDirectory) { + return Objects.requireNonNull( + factory.createMantle( + Objects.requireNonNull(engine, "engine"), + Objects.requireNonNull(storageDirectory, "storageDirectory") + ), + "generation kernel mantle" + ); + } + + public UpperDimensionContext createUpperContext(IrisEngine engine) { + return factory.createUpperContext(Objects.requireNonNull(engine, "engine")); + } + + public EngineMode createMode(IrisEngine engine) { + return Objects.requireNonNull( + factory.createMode(Objects.requireNonNull(engine, "engine")), + "generation kernel mode" + ); + } + + public void registerStaticObjects(IrisEngine engine, EngineMode mode) { + factory.registerStaticObjects( + Objects.requireNonNull(engine, "engine"), + Objects.requireNonNull(mode, "mode") + ); + } + } + + public record AlgorithmVersion(int rngVersion, int seedDerivationVersion) { + public AlgorithmVersion { + if (rngVersion < 1) { + throw new IllegalArgumentException("RNG version must be positive."); + } + if (seedDerivationVersion < 1) { + throw new IllegalArgumentException("Seed derivation version must be positive."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelSourceSeal.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelSourceSeal.java new file mode 100644 index 000000000..81c34d481 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelSourceSeal.java @@ -0,0 +1,133 @@ +package art.arcane.iris.engine.history; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +final class GenerationKernelSourceSeal { + private static final int MAXIMUM_BYTES = 8 * 1024 * 1024; + private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}"); + + private GenerationKernelSourceSeal() { + } + + static String requireFingerprint(int abi, Class factory) { + Descriptor descriptor = load(abi); + if (!descriptor.factoryClass().equals(factory.getName())) { + throw new IllegalStateException("Generation ABI " + abi + " is sealed for " + descriptor.factoryClass() + "."); + } + return descriptor.fingerprint(); + } + + static Descriptor load(int abi) { + String resource = "/META-INF/iris/generation-kernels/abi-" + abi + ".seal"; + try (InputStream input = GenerationKernelSourceSeal.class.getResourceAsStream(resource)) { + if (input == null) { + throw new IOException("Missing generation kernel source seal " + resource + "."); + } + byte[] bytes = input.readNBytes(MAXIMUM_BYTES + 1); + String[] lines = new String(bytes, StandardCharsets.UTF_8).split("\n", -1); + if (lines.length < 4 || !lines[2].startsWith("factory\t")) { + throw new IOException("Generation kernel seal has no factory identity."); + } + String factoryClass = lines[2].substring("factory\t".length()); + if (!factoryClass.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)+")) { + throw new IOException("Invalid generation kernel factory identity."); + } + String fingerprint = fingerprint(abi, factoryClass, bytes); + List algorithms = new ArrayList<>(); + for (String line : lines) { + if (line.startsWith("algorithm\t")) { + String[] fields = line.split("\t", -1); + algorithms.add(new GenerationKernelRegistry.AlgorithmVersion( + Integer.parseInt(fields[1]), Integer.parseInt(fields[2]))); + } + } + return new Descriptor(abi, factoryClass, List.copyOf(algorithms), fingerprint); + } catch (IOException failure) { + throw new IllegalStateException("Cannot load sealed Iris generation ABI " + abi + ".", failure); + } + } + + static String fingerprint(int abi, String factoryClass, byte[] bytes) throws IOException { + Objects.requireNonNull(factoryClass, "factoryClass"); + Objects.requireNonNull(bytes, "bytes"); + if (abi < 1 || bytes.length == 0 || bytes.length > MAXIMUM_BYTES) { + throw new IOException("Invalid generation kernel seal size or ABI."); + } + String source = new String(bytes, StandardCharsets.UTF_8); + String prefix = "iris-generation-kernel-seal-v1\nabi\t" + abi + "\nfactory\t" + factoryClass + "\n"; + if (!source.startsWith(prefix) || !source.endsWith("\n") || source.indexOf('\r') >= 0 + || !source.contains("\nsource\t") || !source.contains("\ndependency\t")) { + throw new IOException("Generation kernel seal does not match ABI " + abi + " and factory " + factoryClass + "."); + } + requireCanonicalEntries(source.substring(prefix.length())); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException(failure); + } + } + + private static void requireCanonicalEntries(String source) throws IOException { + String[] entries = source.split("\n", -1); + if (entries.length > 30_000) { + throw new IOException("Generation kernel seal has too many entries."); + } + int previousType = -1; + String previousKey = ""; + boolean hasScope = false; + boolean hasAlgorithm = false; + int previousRng = 0; + int previousSeed = 0; + for (int index = 0; index < entries.length - 1; index++) { + String[] fields = entries[index].split("\t", -1); + if (fields[0].equals("algorithm")) { + if (previousType != -1 || fields.length != 3 || !fields[1].matches("[1-9][0-9]{0,8}") + || !fields[2].matches("[1-9][0-9]{0,8}")) { + throw new IOException("Generation kernel seal contains an invalid algorithm version."); + } + int rng = Integer.parseInt(fields[1]); + int seed = Integer.parseInt(fields[2]); + if (rng < previousRng || (rng == previousRng && seed <= previousSeed)) { + throw new IOException("Generation kernel algorithm versions are not canonical."); + } + previousRng = rng; + previousSeed = seed; + hasAlgorithm = true; + continue; + } + int type = switch (fields[0]) { + case "scope" -> 0; + case "exclude" -> 1; + case "source" -> 2; + case "dependency" -> 3; + default -> -1; + }; + int expectedFields = type < 2 ? 2 : 3; + if (type < previousType || type < 0 || fields.length != expectedFields || fields[1].isBlank() + || fields[1].chars().anyMatch(Character::isWhitespace) + || (type == previousType && fields[1].compareTo(previousKey) <= 0) + || (type >= 2 && !SHA256.matcher(fields[2]).matches())) { + throw new IOException("Generation kernel seal contains an invalid or noncanonical entry."); + } + previousType = type; + previousKey = fields[1]; + hasScope |= type == 0; + } + if (!hasScope || !hasAlgorithm) { + throw new IOException("Generation kernel seal has no source scope or algorithm version."); + } + } + + record Descriptor(int abi, String factoryClass, List algorithms, + String fingerprint) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java new file mode 100644 index 000000000..d00916daf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java @@ -0,0 +1,98 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.IrisEngineMantle; +import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionMode; +import art.arcane.iris.engine.object.IrisDimensionModeType; +import art.arcane.iris.engine.object.IrisStaticObjectLayer; +import art.arcane.iris.spi.IrisLogging; + +import java.nio.file.Path; + +final class GenerationKernelV1 implements GenerationKernelRegistry.RuntimeFactory { + static final String IMPLEMENTATION_FINGERPRINT = + GenerationKernelSourceSeal.requireFingerprint(1, GenerationKernelV1.class); + + @Override + public SeedManager createSeedManager(long worldSeed) { + return new SeedManager(worldSeed); + } + + @Override + public EngineMantle createMantle(IrisEngine engine, Path storageDirectory) { + return new IrisEngineMantle(engine, storageDirectory); + } + + @Override + public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) { + return new IrisComplex(engine, transitionPlan); + } + + @Override + public UpperDimensionContext createUpperContext(IrisEngine engine) { + IrisDimension dimension = engine.getDimension(); + if (!dimension.hasUpperDimension()) { + return null; + } + String upperKey = dimension.getUpperDimension(); + IrisDimension upperDimension = upperKey.equals(dimension.getLoadKey()) + ? dimension + : engine.getData().getDimensionLoader().load(upperKey, false); + if (upperDimension == null) { + throw new IllegalStateException("Upper dimension '" + upperKey + + "' is absent from the immutable Iris epoch pack."); + } + UpperDimensionContext context = UpperDimensionContext.create(engine, upperDimension); + IrisLogging.info("Upper dimension enabled: " + upperKey + + (context.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)")); + return context; + } + + @Override + public EngineMode createMode(IrisEngine engine) { + Throwable configuredFailure = null; + try { + IrisDimensionMode configuredMode = engine.getDimension().getMode(); + if (configuredMode == null) { + configuredMode = new IrisDimensionMode(); + engine.getDimension().setMode(configuredMode); + } + EngineMode configured = configuredMode.create(engine); + if (configured == null) { + throw new IllegalStateException("Dimension mode factory returned null"); + } + return configured; + } catch (Throwable failure) { + configuredFailure = failure; + IrisLogging.reportError(failure); + if (engine.getModeFallbackLogged().compareAndSet(false, true)) { + IrisLogging.warn("Failed to initialize configured dimension mode for " + + engine.getDimension().getLoadKey() + ", falling back to OVERWORLD mode."); + } + } + + try { + EngineMode fallback = IrisDimensionModeType.OVERWORLD.create(engine); + if (fallback == null) { + throw new IllegalStateException("OVERWORLD mode factory returned null"); + } + return fallback; + } catch (Throwable fallbackFailure) { + fallbackFailure.addSuppressed(configuredFailure); + throw new IllegalStateException("Both configured and fallback Iris engine modes failed.", fallbackFailure); + } + } + + @Override + public void registerStaticObjects(IrisEngine engine, EngineMode mode) { + IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); + mode.registerStage((x, z, blocks, biomes, multicore, context) -> + staticObjects.apply(engine, x, z, blocks)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationManifest.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationManifest.java new file mode 100644 index 000000000..b1e11f1fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationManifest.java @@ -0,0 +1,560 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +public final class GenerationManifest { + public static final int CURRENT_SCHEMA_VERSION = 1; + + private final int schemaVersion; + private final NavigableMap epochs; + private final NavigableMap activations; + private final long activeActivationId; + private final Long pendingActivationId; + + private GenerationManifest(State state) { + State requiredState = Objects.requireNonNull(state, "state"); + this.schemaVersion = requiredState.schemaVersion(); + this.epochs = immutableEpochs(requiredState.epochs()); + this.activations = immutableActivations(requiredState.activations()); + this.activeActivationId = requiredState.activeActivationId(); + this.pendingActivationId = requiredState.pendingActivationId(); + validate(); + } + + public static GenerationManifest initial(GenerationEpoch epoch, long createdAtEpochMillis) { + GenerationEpoch requiredEpoch = Objects.requireNonNull(epoch, "epoch"); + GenerationActivation activation = GenerationActivation.initial( + requiredEpoch.epochId(), + createdAtEpochMillis + ); + return new GenerationManifest(new State( + CURRENT_SCHEMA_VERSION, + Map.of(requiredEpoch.epochId(), requiredEpoch), + Map.of(activation.activationId(), activation), + activation.activationId(), + null + )); + } + + static GenerationManifest restore(State state) { + return new GenerationManifest(state); + } + + public int schemaVersion() { + return schemaVersion; + } + + public Collection epochs() { + return epochs.values(); + } + + public Collection activations() { + return activations.values(); + } + + public Optional epoch(String epochId) { + return Optional.ofNullable(epochs.get(epochId)); + } + + public Optional activation(long activationId) { + return Optional.ofNullable(activations.get(activationId)); + } + + public GenerationActivation activeActivation() { + return activations.get(activeActivationId); + } + + public GenerationEpoch activeEpoch() { + return epochs.get(activeActivation().epochId()); + } + + public Optional pendingActivation() { + if (pendingActivationId == null) { + return Optional.empty(); + } + return Optional.of(activations.get(pendingActivationId)); + } + + public Optional pendingEpoch() { + return pendingActivation().map(activation -> epochs.get(activation.epochId())); + } + + GenerationManifest preparePending( + GenerationEpoch epoch, + long createdAtEpochMillis, + int transitionWidthBlocks + ) { + GenerationEpoch requiredEpoch = Objects.requireNonNull(epoch, "epoch"); + Optional pending = pendingActivation(); + if (pending.isPresent()) { + if (pending.get().epochId().equals(requiredEpoch.epochId())) { + GenerationEpoch storedEpoch = epochs.get(requiredEpoch.epochId()); + if (!requiredEpoch.equals(storedEpoch)) { + throw new IllegalStateException("Pending epoch identity is inconsistent."); + } + return this; + } + throw new IllegalStateException("A different generation activation is already pending."); + } + requireCompatibleDimension(requiredEpoch); + + TreeMap nextEpochs = new TreeMap<>(epochs); + GenerationEpoch storedEpoch = nextEpochs.putIfAbsent(requiredEpoch.epochId(), requiredEpoch); + if (storedEpoch != null && !storedEpoch.equals(requiredEpoch)) { + throw new IllegalStateException("Epoch identity collision detected."); + } + + long activationId = Math.addExact(activations.lastKey(), 1L); + GenerationActivation activation = GenerationActivation.next( + activationId, + requiredEpoch.epochId(), + activeActivationId, + createdAtEpochMillis, + transitionWidthBlocks + ); + TreeMap nextActivations = new TreeMap<>(activations); + nextActivations.put(activationId, activation); + return new GenerationManifest(new State( + schemaVersion, + nextEpochs, + nextActivations, + activeActivationId, + activationId + )); + } + + GenerationManifest activatePending(long expectedActivationId) { + GenerationActivation pending = pendingActivation() + .orElseThrow(() -> new IllegalStateException("No generation activation is pending.")); + if (pending.activationId() != expectedActivationId) { + throw new IllegalStateException("Pending activation changed before publication."); + } + return new GenerationManifest(new State( + schemaVersion, + epochs, + activations, + pending.activationId(), + null + )); + } + + GenerationManifest completePendingTransition( + long expectedActivationId, + String boundaryIdentity, + String terrainSignatureIdentity + ) { + GenerationActivation pending = pendingActivation() + .orElseThrow(() -> new IllegalStateException("No generation activation is pending.")); + if (pending.activationId() != expectedActivationId) { + throw new IllegalStateException("Pending activation changed before transition publication."); + } + GenerationActivation completed = pending.completeTransition( + boundaryIdentity, + terrainSignatureIdentity + ); + if (completed == pending) { + return this; + } + TreeMap nextActivations = new TreeMap<>(activations); + nextActivations.put(completed.activationId(), completed); + return new GenerationManifest(new State( + schemaVersion, + epochs, + nextActivations, + activeActivationId, + pendingActivationId + )); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", schemaVersion); + + JsonArray epochArray = new JsonArray(epochs.size()); + for (GenerationEpoch epoch : epochs.values()) { + epochArray.add(epoch.toJson()); + } + json.add("epochs", epochArray); + + JsonArray activationArray = new JsonArray(activations.size()); + for (GenerationActivation activation : activations.values()) { + activationArray.add(activation.toJson()); + } + json.add("activations", activationArray); + json.addProperty("activeActivationId", activeActivationId); + if (pendingActivationId == null) { + json.add("pendingActivationId", JsonNull.INSTANCE); + } else { + json.addProperty("pendingActivationId", pendingActivationId); + } + return json; + } + + static GenerationManifest fromJson(JsonObject json) { + JsonSchema.requireFields( + json, + "generation manifest", + "schemaVersion", + "epochs", + "activations", + "activeActivationId", + "pendingActivationId" + ); + int schemaVersion = JsonSchema.requireInt(json, "schemaVersion", "generation manifest"); + JsonArray epochArray = JsonSchema.requireArray(json, "epochs", "generation manifest"); + JsonArray activationArray = JsonSchema.requireArray(json, "activations", "generation manifest"); + long activeActivationId = JsonSchema.requireLong( + json, + "activeActivationId", + "generation manifest" + ); + Long pendingActivationId = JsonSchema.requireNullableLong( + json, + "pendingActivationId", + "generation manifest" + ); + + TreeMap epochs = new TreeMap<>(); + for (int index = 0; index < epochArray.size(); index++) { + JsonObject epochJson = JsonSchema.requireObject(epochArray.get(index), "epochs[" + index + "]"); + GenerationEpoch epoch = GenerationEpoch.fromJson(epochJson); + if (epochs.putIfAbsent(epoch.epochId(), epoch) != null) { + throw new IllegalArgumentException("Generation manifest contains duplicate epoch IDs."); + } + } + + TreeMap activations = new TreeMap<>(); + for (int index = 0; index < activationArray.size(); index++) { + JsonObject activationJson = JsonSchema.requireObject( + activationArray.get(index), + "activations[" + index + "]" + ); + GenerationActivation activation = GenerationActivation.fromJson(activationJson); + if (activations.putIfAbsent(activation.activationId(), activation) != null) { + throw new IllegalArgumentException("Generation manifest contains duplicate activation IDs."); + } + } + + return restore(new State( + schemaVersion, + epochs, + activations, + activeActivationId, + pendingActivationId + )); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GenerationManifest manifest)) { + return false; + } + return schemaVersion == manifest.schemaVersion + && activeActivationId == manifest.activeActivationId + && epochs.equals(manifest.epochs) + && activations.equals(manifest.activations) + && Objects.equals(pendingActivationId, manifest.pendingActivationId); + } + + @Override + public int hashCode() { + return Objects.hash(schemaVersion, epochs, activations, activeActivationId, pendingActivationId); + } + + private static NavigableMap immutableEpochs( + Map source + ) { + Objects.requireNonNull(source, "epochs"); + TreeMap copy = new TreeMap<>(); + for (Map.Entry entry : source.entrySet()) { + String epochId = Objects.requireNonNull(entry.getKey(), "epoch ID"); + GenerationEpoch epoch = Objects.requireNonNull(entry.getValue(), "epoch"); + if (!epochId.equals(epoch.epochId())) { + throw new IllegalArgumentException("Epoch map key does not match the epoch ID."); + } + copy.put(epochId, epoch); + } + return Collections.unmodifiableNavigableMap(copy); + } + + private static NavigableMap immutableActivations( + Map source + ) { + Objects.requireNonNull(source, "activations"); + TreeMap copy = new TreeMap<>(); + for (Map.Entry entry : source.entrySet()) { + Long activationId = Objects.requireNonNull(entry.getKey(), "activation ID"); + GenerationActivation activation = Objects.requireNonNull(entry.getValue(), "activation"); + if (activationId.longValue() != activation.activationId()) { + throw new IllegalArgumentException("Activation map key does not match the activation ID."); + } + copy.put(activationId, activation); + } + return Collections.unmodifiableNavigableMap(copy); + } + + private void validate() { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported generation manifest schema: " + schemaVersion); + } + if (epochs.isEmpty()) { + throw new IllegalArgumentException("Generation manifest must contain an epoch."); + } + if (activations.isEmpty()) { + throw new IllegalArgumentException("Generation manifest must contain an activation."); + } + + GenerationEpoch.DimensionContract contract = epochs.firstEntry().getValue().dimensionContract(); + long worldSeed = epochs.firstEntry().getValue().worldSeed(); + for (GenerationEpoch epoch : epochs.values()) { + if (!contract.hasSameLayout(epoch.dimensionContract())) { + throw new IllegalArgumentException("Generation epochs use incompatible dimension contracts."); + } + if (worldSeed != epoch.worldSeed()) { + throw new IllegalArgumentException("Generation epochs use different world seeds."); + } + } + + long expectedActivationId = 1L; + Set referencedEpochs = new LinkedHashSet<>(); + for (GenerationActivation activation : activations.values()) { + if (activation.activationId() != expectedActivationId) { + throw new IllegalArgumentException("Generation activation IDs must be contiguous and monotonic."); + } + Long expectedParent = expectedActivationId == 1L ? null : expectedActivationId - 1L; + if (!Objects.equals(activation.parentActivationId(), expectedParent)) { + throw new IllegalArgumentException("Generation activation history is not a linear chain."); + } + if (!epochs.containsKey(activation.epochId())) { + throw new IllegalArgumentException("Generation activation references a missing epoch."); + } + referencedEpochs.add(activation.epochId()); + if (activation.parentActivationId() == null) { + if (activation.transition() != null) { + throw new IllegalArgumentException("Initial activation must not define a transition."); + } + } else if (activation.transition() == null) { + throw new IllegalArgumentException("Generation activation is missing its transition policy."); + } + expectedActivationId = Math.addExact(expectedActivationId, 1L); + } + if (!referencedEpochs.equals(epochs.keySet())) { + throw new IllegalArgumentException("Generation manifest contains an unreferenced epoch."); + } + if (!activations.containsKey(activeActivationId)) { + throw new IllegalArgumentException("Active generation activation is missing."); + } + + long latestActivationId = activations.lastKey(); + if (pendingActivationId == null) { + if (activeActivationId != latestActivationId) { + throw new IllegalArgumentException("Active generation activation is not the latest activation."); + } + requireCompletedTransitions(); + return; + } + if (!activations.containsKey(pendingActivationId)) { + throw new IllegalArgumentException("Pending generation activation is missing."); + } + if (pendingActivationId != latestActivationId) { + throw new IllegalArgumentException("Pending generation activation is not the latest activation."); + } + GenerationActivation pending = activations.get(pendingActivationId); + if (!Objects.equals(pending.parentActivationId(), activeActivationId)) { + throw new IllegalArgumentException("Pending generation activation does not follow the active activation."); + } + for (GenerationActivation activation : activations.values()) { + if (activation.activationId() != pendingActivationId + && activation.transition() != null + && !activation.transition().isComplete()) { + throw new IllegalArgumentException("Published generation transition is incomplete."); + } + } + } + + private void requireCompletedTransitions() { + for (GenerationActivation activation : activations.values()) { + if (activation.transition() != null && !activation.transition().isComplete()) { + throw new IllegalArgumentException("Published generation transition is incomplete."); + } + } + } + + private void requireCompatibleDimension(GenerationEpoch epoch) { + if (!activeEpoch().dimensionContract().hasSameLayout(epoch.dimensionContract())) { + throw new IllegalArgumentException("Generation epoch changes the immutable dimension contract."); + } + if (activeEpoch().worldSeed() != epoch.worldSeed()) { + throw new IllegalArgumentException("Generation epoch changes the immutable world seed."); + } + } + + record State( + int schemaVersion, + Map epochs, + Map activations, + long activeActivationId, + Long pendingActivationId + ) { + } + + static final class JsonSchema { + private JsonSchema() { + } + + static void requireFields(JsonObject json, String context, String... expectedNames) { + Objects.requireNonNull(json, context); + Set expected = Set.of(expectedNames); + Set actual = json.keySet(); + if (!actual.equals(expected)) { + ArrayList missing = new ArrayList<>(expected); + missing.removeAll(actual); + ArrayList unknown = new ArrayList<>(actual); + unknown.removeAll(expected); + Collections.sort(missing); + Collections.sort(unknown); + throw new IllegalArgumentException( + "Invalid " + context + " fields. Missing=" + missing + ", unknown=" + unknown + "." + ); + } + } + + static JsonArray requireArray(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null || !value.isJsonArray()) { + throw new IllegalArgumentException(context + "." + name + " must be an array."); + } + return value.getAsJsonArray(); + } + + static JsonObject requireObject(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null || !value.isJsonObject()) { + throw new IllegalArgumentException(context + "." + name + " must be an object."); + } + return value.getAsJsonObject(); + } + + static JsonObject requireNullableObject(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null) { + throw new IllegalArgumentException(context + "." + name + " is required."); + } + if (value.isJsonNull()) { + return null; + } + if (!value.isJsonObject()) { + throw new IllegalArgumentException(context + "." + name + " must be an object or null."); + } + return value.getAsJsonObject(); + } + + static JsonObject requireObject(JsonElement value, String context) { + if (value == null || !value.isJsonObject()) { + throw new IllegalArgumentException(context + " must be an object."); + } + return value.getAsJsonObject(); + } + + static String requireString(JsonObject json, String name, String context) { + JsonPrimitive value = requirePrimitive(json, name, context); + if (!value.isString()) { + throw new IllegalArgumentException(context + "." + name + " must be a string."); + } + return value.getAsString(); + } + + static int requireInt(JsonObject json, String name, String context) { + try { + return requireNumber(json, name, context).intValueExact(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException(context + "." + name + " must be an exact integer.", exception); + } + } + + static long requireLong(JsonObject json, String name, String context) { + try { + return requireNumber(json, name, context).longValueExact(); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException(context + "." + name + " must be an exact long.", exception); + } + } + + static Long requireNullableLong(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null) { + throw new IllegalArgumentException(context + "." + name + " is required."); + } + if (value.isJsonNull()) { + return null; + } + return requireLong(json, name, context); + } + + static String requireNullableString(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null) { + throw new IllegalArgumentException(context + "." + name + " is required."); + } + if (value.isJsonNull()) { + return null; + } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException(context + "." + name + " must be a string or null."); + } + return value.getAsString(); + } + + static double requireDouble(JsonObject json, String name, String context) { + return requireNumber(json, name, context).doubleValue(); + } + + static boolean requireBoolean(JsonObject json, String name, String context) { + JsonPrimitive value = requirePrimitive(json, name, context); + if (!value.isBoolean()) { + throw new IllegalArgumentException(context + "." + name + " must be a boolean."); + } + return value.getAsBoolean(); + } + + private static BigDecimal requireNumber(JsonObject json, String name, String context) { + JsonPrimitive value = requirePrimitive(json, name, context); + if (!value.isNumber()) { + throw new IllegalArgumentException(context + "." + name + " must be a number."); + } + try { + return new BigDecimal(value.getAsString()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(context + "." + name + " is not a valid number.", exception); + } + } + + private static JsonPrimitive requirePrimitive(JsonObject json, String name, String context) { + JsonElement value = json.get(name); + if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) { + throw new IllegalArgumentException(context + "." + name + " must be a primitive value."); + } + return value.getAsJsonPrimitive(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java new file mode 100644 index 000000000..1c7a0c9b9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackFingerprint.java @@ -0,0 +1,142 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.pack.PackDirectoryResolver; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +public final class GenerationPackFingerprint { + public static final int CURRENT_VERSION = 1; + private static final int BUFFER_BYTES = 64 * 1_024; + + private GenerationPackFingerprint() { + } + + public static String compute(Path packRoot, int version) throws IOException { + requireSupported(version); + return computeVersionOne(packRoot); + } + + public static void requireSupported(int version) throws IOException { + if (version != 1) { + throw new IOException("Unsupported Iris generation pack fingerprint version " + version + "."); + } + } + + private static String computeVersionOne(Path packRoot) throws IOException { + Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { + throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root); + } + Path realRoot = root.toRealPath(LinkOption.NOFOLLOW_LINKS); + List entries = new ArrayList<>(); + collectTree(realRoot, entries); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + MessageDigest digest = sha256(); + byte[] buffer = new byte[BUFFER_BYTES]; + for (FingerprintEntry entry : entries) { + updateEntry(digest, entry.relativePath(), entry.size()); + long readBytes = 0L; + try (InputStream input = Files.newInputStream( + entry.source(), + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS + )) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + readBytes += read; + } + } + } + if (readBytes != entry.size()) { + throw new IOException("Iris generation pack changed while fingerprinting: " + + entry.source()); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static void collectTree(Path root, List entries) throws IOException { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory( + Path directory, + BasicFileAttributes attributes + ) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(directory)) { + throw new IOException("Generation pack fingerprint rejected symbolic link: " + directory); + } + if (!directory.equals(root) + && root.relativize(directory).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Path relativePath = root.relativize(file); + String fileName = file.getFileName().toString(); + if ((relativePath.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName)) + || fileName.endsWith(".code-workspace")) { + return FileVisitResult.CONTINUE; + } + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Generation pack fingerprint rejected symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Generation pack fingerprint rejected unsupported entry: " + file); + } + entries.add(new FingerprintEntry( + file, + relativePath.toString().replace(File.separatorChar, '/'), + attributes.size() + )); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect Iris generation pack entry: " + file, failure); + } + }); + } + + private static void updateEntry(MessageDigest digest, String relativePath, long size) { + byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(relativeBytes.length).array()); + digest.update(relativeBytes); + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(size).array()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private record FingerprintEntry(Path source, String relativePath, long size) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java new file mode 100644 index 000000000..315965a0c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java @@ -0,0 +1,275 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.pack.PackDirectoryResolver; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class GenerationPackRepository { + private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}"); + + private final Path dimensionRoot; + private final Path generationRoot; + private final Path epochsRoot; + + public GenerationPackRepository(Path dimensionRoot) { + Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toAbsolutePath() + .normalize(); + this.dimensionRoot = root; + generationRoot = root.resolve("iris").resolve("generation"); + epochsRoot = generationRoot.resolve("epochs"); + } + + public Path generationRoot() { + return generationRoot; + } + + public Path epochRoot(String epochId) { + return epochsRoot.resolve(requireDigest(epochId, "epochId")); + } + + public Path packRoot(String epochId) { + return epochRoot(epochId).resolve("pack"); + } + + public synchronized Path publish( + String epochId, + String packFingerprint, + int packFingerprintVersion, + Path source + ) throws IOException { + String requiredEpochId = requireDigest(epochId, "epochId"); + String requiredFingerprint = requireDigest(packFingerprint, "packFingerprint"); + ensureEpochsRoot(); + try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire( + epochsRoot, + ".epoch-" + requiredEpochId + ".lock" + )) { + ensureEpochsRoot(); + return publishLocked(requiredEpochId, requiredFingerprint, packFingerprintVersion, source); + } + } + + private Path publishLocked( + String epochId, + String packFingerprint, + int packFingerprintVersion, + Path source + ) throws IOException { + Path target = epochsRoot.resolve(epochId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireSafeDirectory(target, "Generation epoch path is not a safe directory"); + return requireExactPack(epochId, packFingerprint, packFingerprintVersion); + } + + Path stage = Files.createTempDirectory(epochsRoot, ".epoch-" + epochId.substring(0, 12) + "-"); + boolean published = false; + try { + Path stagedPack = stage.resolve("pack"); + copyPackTree(source, stagedPack); + requireFingerprint(stagedPack, packFingerprint, packFingerprintVersion); + try (AtomicDirectoryPublisher.Publication publication = + AtomicDirectoryPublisher.publishAbsent(stage, target)) { + publication.commit(); + } catch (FileAlreadyExistsException race) { + requireSafeDirectory(target, "Generation epoch path is not a safe directory"); + return requireExactPack(epochId, packFingerprint, packFingerprintVersion); + } + forceDirectory(epochsRoot); + published = true; + return requireExactPack(epochId, packFingerprint, packFingerprintVersion); + } finally { + if (!published && Files.exists(stage, LinkOption.NOFOLLOW_LINKS)) { + AtomicDirectoryPublisher.deleteTree(stage); + } + } + } + + public Path requireExactPack( + String epochId, + String packFingerprint, + int packFingerprintVersion + ) throws IOException { + String requiredEpochId = requireDigest(epochId, "epochId"); + validateExistingAncestors(requiredEpochId); + Path epoch = epochRoot(requiredEpochId); + requireSafeDirectory(epoch, "Generation epoch path is not a safe directory"); + Path pack = epoch.resolve("pack"); + requireSafeDirectory(pack, "Generation epoch pack is missing or unsafe"); + requireFingerprint( + pack, + requireDigest(packFingerprint, "packFingerprint"), + packFingerprintVersion + ); + return pack; + } + + private void ensureEpochsRoot() throws IOException { + requireSafeDirectory(dimensionRoot, "Dimension root is not a safe directory"); + Path irisRoot = ensureChildDirectory(dimensionRoot, "iris"); + Path ensuredGenerationRoot = ensureChildDirectory(irisRoot, "generation"); + Path ensuredEpochsRoot = ensureChildDirectory(ensuredGenerationRoot, "epochs"); + if (!ensuredEpochsRoot.equals(epochsRoot)) { + throw new IOException("Generation epoch repository resolved outside its storage path: " + ensuredEpochsRoot); + } + } + + private void validateExistingAncestors(String epochId) throws IOException { + requireSafeDirectory(dimensionRoot, "Dimension root is not a safe directory"); + Path irisRoot = dimensionRoot.resolve("iris"); + requireSafeDirectory(irisRoot, "Generation epoch ancestor is not a safe directory"); + requireSafeDirectory(generationRoot, "Generation epoch ancestor is not a safe directory"); + requireSafeDirectory(epochsRoot, "Generation epoch ancestor is not a safe directory"); + Path epoch = epochsRoot.resolve(epochId); + requireSafeDirectory(epoch, "Generation epoch path is not a safe directory"); + } + + private static Path ensureChildDirectory(Path parent, String name) throws IOException { + Path child = parent.resolve(name); + if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) { + requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory"); + forceDirectory(parent); + return child; + } + try { + Files.createDirectory(child); + } catch (FileAlreadyExistsException race) { + requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory"); + forceDirectory(parent); + return child; + } + forceDirectory(parent); + requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory"); + return child; + } + + private static void requireSafeDirectory(Path directory, String message) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(message + ": " + directory); + } + BasicFileAttributes attributes = Files.readAttributes( + directory, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException(message + ": " + directory); + } + } + + private static void forceDirectory(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException exception) { + throw new IOException("Generation epoch directory cannot be durability-synced: " + directory, exception); + } + } + + private static void copyPackTree(Path source, Path target) throws IOException { + Path sourcePath = Objects.requireNonNull(source, "source").toAbsolutePath().normalize(); + if (Files.isSymbolicLink(sourcePath)) { + throw new IOException("Pack source is a symbolic link: " + sourcePath); + } + Path normalizedSource = sourcePath.toRealPath(); + if (!Files.isDirectory(normalizedSource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack source is not a directory: " + normalizedSource); + } + Path normalizedTarget = Objects.requireNonNull(target, "target").toAbsolutePath().normalize(); + if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(normalizedTarget)) { + throw new IOException("Pack publication stage already exists: " + normalizedTarget); + } + if (normalizedTarget.startsWith(normalizedSource) || normalizedSource.startsWith(normalizedTarget)) { + throw new IOException("Pack source and publication stage overlap: " + + normalizedSource + " and " + normalizedTarget); + } + + Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(directory)) { + throw new IOException("Pack contains a symbolic link: " + directory); + } + if (!directory.equals(normalizedSource) + && normalizedSource.relativize(directory).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + Files.createDirectories(copyDestination(normalizedSource, normalizedTarget, directory)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Pack contains a symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Pack contains an unsupported entry: " + file); + } + Path relative = normalizedSource.relativize(file); + String fileName = file.getFileName().toString(); + if ((relative.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName)) + || fileName.endsWith(".code-workspace")) { + return FileVisitResult.CONTINUE; + } + Path destination = copyDestination(normalizedSource, normalizedTarget, file); + Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent")); + Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); + try (FileChannel channel = FileChannel.open(destination, StandardOpenOption.WRITE)) { + channel.force(true); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to copy pack entry: " + file, failure); + } + }); + } + + private static Path copyDestination(Path source, Path target, Path entry) throws IOException { + Path destination = target.resolve(source.relativize(entry)).normalize(); + if (!destination.startsWith(target)) { + throw new IOException("Pack entry escapes its publication stage: " + entry); + } + return destination; + } + + private static void requireFingerprint( + Path pack, + String expected, + int packFingerprintVersion + ) throws IOException { + String actual = GenerationPackFingerprint.compute(pack, packFingerprintVersion); + if (!expected.equals(actual)) { + throw new IOException("Generation epoch pack fingerprint mismatch at " + pack + + ": expected " + expected + " but found " + actual); + } + } + + private static String requireDigest(String value, String field) { + String requiredValue = Objects.requireNonNull(value, field); + if (!SHA_256.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest"); + } + return requiredValue; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPublicationLock.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPublicationLock.java new file mode 100644 index 000000000..f441475e1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPublicationLock.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.history; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +final class GenerationPublicationLock implements AutoCloseable { + private static final ConcurrentHashMap PROCESS_LOCKS = new ConcurrentHashMap<>(); + + private final ReentrantLock processLock; + private final FileChannel channel; + private final FileLock fileLock; + + private GenerationPublicationLock(ReentrantLock processLock, FileChannel channel, FileLock fileLock) { + this.processLock = processLock; + this.channel = channel; + this.fileLock = fileLock; + } + + static GenerationPublicationLock acquire(Path directory, String fileName) throws IOException { + Path requiredDirectory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Path lockPath = requiredDirectory.resolve(Objects.requireNonNull(fileName, "fileName")).normalize(); + if (!Objects.equals(lockPath.getParent(), requiredDirectory)) { + throw new IOException("Generation publication lock escapes its storage directory: " + lockPath); + } + requireSafeDirectory(requiredDirectory); + ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock()); + processLock.lock(); + FileChannel channel = null; + FileLock fileLock = null; + try { + channel = FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS + ); + requireSafeRegularFile(lockPath); + fileLock = channel.lock(); + return new GenerationPublicationLock(processLock, channel, fileLock); + } catch (IOException | RuntimeException error) { + if (fileLock != null) { + fileLock.close(); + } + if (channel != null) { + channel.close(); + } + processLock.unlock(); + throw error; + } + } + + @Override + public void close() throws IOException { + IOException failure = null; + try { + fileLock.close(); + } catch (IOException error) { + failure = error; + } + try { + channel.close(); + } catch (IOException error) { + if (failure == null) { + failure = error; + } else { + failure.addSuppressed(error); + } + } finally { + processLock.unlock(); + } + if (failure != null) { + throw failure; + } + } + + private static void requireSafeDirectory(Path directory) throws IOException { + BasicFileAttributes attributes = Files.readAttributes( + directory, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Generation publication lock directory is unsafe: " + directory); + } + } + + private static void requireSafeRegularFile(Path file) throws IOException { + BasicFileAttributes attributes = Files.readAttributes( + file, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw new IOException("Generation publication lock file is unsafe: " + file); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContract.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContract.java new file mode 100644 index 000000000..7a6b793d8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContract.java @@ -0,0 +1,605 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.regex.Pattern; + +public final class GenerationRegistryContract { + public static final int FINGERPRINT_SCHEMA_VERSION_ONE = 1; + public static final int CURRENT_FINGERPRINT_SCHEMA = FINGERPRINT_SCHEMA_VERSION_ONE; + + private static final String FINGERPRINT_DOMAIN = "iris-generation-registry-contract"; + private static final int MAX_DEFINITIONS = 65_535; + private static final int MAX_RENDERER_IDENTITY_BYTES = 512; + private static final int MAX_GENERATED_SEMANTIC_BYTES = 1024 * 1024; + private static final int MAX_GENERATED_SOURCE_BYTES = 1024 * 1024; + private static final int MAX_TOTAL_GENERATED_SOURCE_BYTES = 8 * 1024 * 1024; + private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + private final int fingerprintSchema; + private final String fingerprint; + private final NavigableMap definitions; + private final NavigableMap generatedSources; + + private GenerationRegistryContract( + int fingerprintSchema, + String fingerprint, + NavigableMap definitions, + NavigableMap generatedSources + ) { + requireSupportedFingerprintSchema(fingerprintSchema); + this.fingerprintSchema = fingerprintSchema; + this.definitions = immutableDefinitions(definitions); + this.generatedSources = immutableGeneratedSources(generatedSources, this.definitions); + this.fingerprint = requireSha256(fingerprint, "Registry contract fingerprint"); + String expectedFingerprint = fingerprint(fingerprintSchema, this.definitions, this.generatedSources); + if (!this.fingerprint.equals(expectedFingerprint)) { + throw new IllegalArgumentException("Registry contract fingerprint does not match its definitions."); + } + } + + public static GenerationRegistryContract empty() { + return fromDefinitions(CURRENT_FINGERPRINT_SCHEMA, Map.of()); + } + + public static GenerationRegistryContract fromDefinitions(Map definitions) { + return fromDefinitions(CURRENT_FINGERPRINT_SCHEMA, definitions); + } + + public static GenerationRegistryContract fromDefinitions( + int fingerprintSchema, + Map definitions + ) { + return fromDefinitionsAndGeneratedSources(fingerprintSchema, definitions, Map.of()); + } + + public static GenerationRegistryContract fromDefinitionsAndGeneratedSources( + Map definitions, + Map generatedSources + ) { + return fromDefinitionsAndGeneratedSources( + CURRENT_FINGERPRINT_SCHEMA, + definitions, + generatedSources + ); + } + + public static GenerationRegistryContract fromDefinitionsAndGeneratedSources( + int fingerprintSchema, + Map definitions, + Map generatedSources + ) { + requireSupportedFingerprintSchema(fingerprintSchema); + NavigableMap canonicalDefinitions = canonicalizeDefinitions(definitions); + NavigableMap canonicalSources = canonicalizeGeneratedSources( + generatedSources, + canonicalDefinitions + ); + return new GenerationRegistryContract( + fingerprintSchema, + fingerprint(fingerprintSchema, canonicalDefinitions, canonicalSources), + canonicalDefinitions, + canonicalSources + ); + } + + public int fingerprintSchema() { + return fingerprintSchema; + } + + public String fingerprint() { + return fingerprint; + } + + public NavigableMap definitions() { + return definitions; + } + + public NavigableMap generatedSources() { + return generatedSources; + } + + public void requireDefinitionsAvailableIn(GenerationRegistryContract available) throws IOException { + GenerationRegistryContract requiredAvailable = Objects.requireNonNull(available, "available"); + for (Map.Entry entry : definitions.entrySet()) { + String availableFingerprint = requiredAvailable.definitions.get(entry.getKey()); + if (availableFingerprint == null) { + throw new IOException("Missing historical registry definition " + + entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + "."); + } + if (!entry.getValue().equals(availableFingerprint)) { + throw new IOException("Historical registry definition changed for " + + entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + "."); + } + } + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("fingerprintSchema", fingerprintSchema); + json.addProperty("fingerprint", fingerprint); + JsonArray definitionArray = new JsonArray(); + for (Map.Entry entry : definitions.entrySet()) { + JsonObject definitionJson = new JsonObject(); + definitionJson.addProperty("registryKey", entry.getKey().registryKey()); + definitionJson.addProperty("resourceKey", entry.getKey().resourceKey()); + definitionJson.addProperty("definitionSha256", entry.getValue()); + definitionArray.add(definitionJson); + } + json.add("definitions", definitionArray); + JsonArray generatedSourceArray = new JsonArray(); + for (Map.Entry entry : generatedSources.entrySet()) { + JsonObject sourceJson = new JsonObject(); + sourceJson.addProperty("registryKey", entry.getKey().registryKey()); + sourceJson.addProperty("resourceKey", entry.getKey().resourceKey()); + sourceJson.addProperty("sourceSchema", entry.getValue().sourceSchema()); + sourceJson.addProperty("semanticSha256", entry.getValue().semanticSha256()); + sourceJson.addProperty("semanticJson", entry.getValue().semanticJson()); + sourceJson.addProperty("rendererIdentity", entry.getValue().rendererIdentity()); + sourceJson.addProperty( + "renderedDefinitionSha256", + entry.getValue().renderedDefinitionSha256() + ); + sourceJson.addProperty("sourceJson", entry.getValue().sourceJson()); + generatedSourceArray.add(sourceJson); + } + json.add("generatedSources", generatedSourceArray); + return json; + } + + static GenerationRegistryContract fromJson(JsonObject json) { + GenerationManifest.JsonSchema.requireFields( + json, + "registry contract", + "fingerprintSchema", + "fingerprint", + "definitions", + "generatedSources" + ); + int fingerprintSchema = GenerationManifest.JsonSchema.requireInt( + json, + "fingerprintSchema", + "registry contract" + ); + String fingerprint = GenerationManifest.JsonSchema.requireString( + json, + "fingerprint", + "registry contract" + ); + JsonArray definitionArray = GenerationManifest.JsonSchema.requireArray( + json, + "definitions", + "registry contract" + ); + NavigableMap definitions = parseCanonicalDefinitions(definitionArray); + JsonArray generatedSourceArray = GenerationManifest.JsonSchema.requireArray( + json, + "generatedSources", + "registry contract" + ); + NavigableMap generatedSources = parseCanonicalGeneratedSources( + generatedSourceArray, + definitions + ); + return new GenerationRegistryContract( + fingerprintSchema, + fingerprint, + definitions, + generatedSources + ); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof GenerationRegistryContract contract)) { + return false; + } + return fingerprintSchema == contract.fingerprintSchema + && fingerprint.equals(contract.fingerprint) + && definitions.equals(contract.definitions) + && generatedSources.equals(contract.generatedSources); + } + + @Override + public int hashCode() { + return Objects.hash(fingerprintSchema, fingerprint, definitions, generatedSources); + } + + @Override + public String toString() { + return "GenerationRegistryContract[fingerprintSchema=" + fingerprintSchema + + ", fingerprint=" + fingerprint + + ", definitions=" + definitions + + ", generatedSourceKeys=" + generatedSources.keySet() + "]"; + } + + public static void requireSupportedFingerprintSchema(int fingerprintSchema) { + if (fingerprintSchema != FINGERPRINT_SCHEMA_VERSION_ONE) { + throw new IllegalArgumentException( + "Unsupported generation registry fingerprint schema " + fingerprintSchema + "." + ); + } + } + + private static NavigableMap canonicalizeDefinitions( + Map definitions + ) { + Map requiredDefinitions = Objects.requireNonNull(definitions, "definitions"); + if (requiredDefinitions.size() > MAX_DEFINITIONS) { + throw new IllegalArgumentException("Registry contract contains too many definitions."); + } + TreeMap canonicalDefinitions = new TreeMap<>(); + for (Map.Entry entry : requiredDefinitions.entrySet()) { + PhysicalResourceKey key = Objects.requireNonNull(entry.getKey(), "registry definition key"); + String definitionSha256 = requireSha256(entry.getValue(), "Registry definition fingerprint"); + canonicalDefinitions.put(key, definitionSha256); + } + return canonicalDefinitions; + } + + private static NavigableMap parseCanonicalDefinitions(JsonArray definitionArray) { + if (definitionArray.size() > MAX_DEFINITIONS) { + throw new IllegalArgumentException("Registry contract contains too many definitions."); + } + TreeMap definitions = new TreeMap<>(); + PhysicalResourceKey previousKey = null; + for (int index = 0; index < definitionArray.size(); index++) { + JsonElement element = definitionArray.get(index); + JsonObject definitionJson = GenerationManifest.JsonSchema.requireObject( + element, + "registry contract.definitions[" + index + "]" + ); + GenerationManifest.JsonSchema.requireFields( + definitionJson, + "registry contract definition", + "registryKey", + "resourceKey", + "definitionSha256" + ); + PhysicalResourceKey key = new PhysicalResourceKey( + GenerationManifest.JsonSchema.requireString( + definitionJson, + "registryKey", + "registry contract definition" + ), + GenerationManifest.JsonSchema.requireString( + definitionJson, + "resourceKey", + "registry contract definition" + ) + ); + if (previousKey != null && previousKey.compareTo(key) >= 0) { + throw new IllegalArgumentException( + "Registry contract definitions must be strictly sorted by physical resource key." + ); + } + String definitionSha256 = requireSha256( + GenerationManifest.JsonSchema.requireString( + definitionJson, + "definitionSha256", + "registry contract definition" + ), + "Registry definition fingerprint" + ); + definitions.put(key, definitionSha256); + previousKey = key; + } + return definitions; + } + + private static NavigableMap immutableDefinitions( + NavigableMap definitions + ) { + NavigableMap canonicalDefinitions = canonicalizeDefinitions(definitions); + return Collections.unmodifiableNavigableMap(canonicalDefinitions); + } + + private static NavigableMap canonicalizeGeneratedSources( + Map generatedSources, + NavigableMap definitions + ) { + Map requiredSources = Objects.requireNonNull( + generatedSources, + "generatedSources" + ); + if (requiredSources.size() > MAX_DEFINITIONS) { + throw new IllegalArgumentException("Registry contract contains too many generated sources."); + } + TreeMap canonicalSources = new TreeMap<>(); + long totalBytes = 0L; + for (Map.Entry entry : requiredSources.entrySet()) { + PhysicalResourceKey key = Objects.requireNonNull(entry.getKey(), "generated source key"); + if (!definitions.containsKey(key)) { + throw new IllegalArgumentException("Generated registry source has no matching definition: " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + GeneratedSource source = Objects.requireNonNull(entry.getValue(), "generated source"); + int sourceBytes = source.sourceJson().getBytes(StandardCharsets.UTF_8).length; + if (sourceBytes > MAX_GENERATED_SOURCE_BYTES) { + throw new IllegalArgumentException("Generated registry source is too large: " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + totalBytes += sourceBytes; + totalBytes += source.semanticJson().getBytes(StandardCharsets.UTF_8).length; + totalBytes += source.rendererIdentity().getBytes(StandardCharsets.UTF_8).length; + totalBytes += source.sourceSchema().getBytes(StandardCharsets.UTF_8).length; + if (totalBytes > MAX_TOTAL_GENERATED_SOURCE_BYTES) { + throw new IllegalArgumentException("Registry contract generated sources are too large."); + } + canonicalSources.put(key, source); + } + return canonicalSources; + } + + private static NavigableMap parseCanonicalGeneratedSources( + JsonArray generatedSourceArray, + NavigableMap definitions + ) { + if (generatedSourceArray.size() > MAX_DEFINITIONS) { + throw new IllegalArgumentException("Registry contract contains too many generated sources."); + } + LinkedHashMap sources = new LinkedHashMap<>(); + PhysicalResourceKey previousKey = null; + for (int index = 0; index < generatedSourceArray.size(); index++) { + JsonObject sourceJson = GenerationManifest.JsonSchema.requireObject( + generatedSourceArray.get(index), + "registry contract.generatedSources[" + index + "]" + ); + GenerationManifest.JsonSchema.requireFields( + sourceJson, + "registry contract generated source", + "registryKey", + "resourceKey", + "sourceSchema", + "semanticSha256", + "semanticJson", + "rendererIdentity", + "renderedDefinitionSha256", + "sourceJson" + ); + PhysicalResourceKey key = new PhysicalResourceKey( + GenerationManifest.JsonSchema.requireString( + sourceJson, + "registryKey", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "resourceKey", + "registry contract generated source" + ) + ); + if (previousKey != null && previousKey.compareTo(key) >= 0) { + throw new IllegalArgumentException( + "Registry contract generated sources must be strictly sorted by physical resource key." + ); + } + sources.put( + key, + new GeneratedSource( + GenerationManifest.JsonSchema.requireString( + sourceJson, + "sourceSchema", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "semanticSha256", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "semanticJson", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "rendererIdentity", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "renderedDefinitionSha256", + "registry contract generated source" + ), + GenerationManifest.JsonSchema.requireString( + sourceJson, + "sourceJson", + "registry contract generated source" + ) + ) + ); + previousKey = key; + } + return canonicalizeGeneratedSources(sources, definitions); + } + + private static NavigableMap immutableGeneratedSources( + NavigableMap generatedSources, + NavigableMap definitions + ) { + return Collections.unmodifiableNavigableMap( + canonicalizeGeneratedSources(generatedSources, definitions) + ); + } + + private static String fingerprint( + int fingerprintSchema, + NavigableMap definitions, + NavigableMap generatedSources + ) { + return switch (fingerprintSchema) { + case FINGERPRINT_SCHEMA_VERSION_ONE -> fingerprintVersionOne(definitions, generatedSources); + default -> throw new IllegalArgumentException( + "Unsupported generation registry fingerprint schema " + fingerprintSchema + "." + ); + }; + } + + private static String fingerprintVersionOne( + NavigableMap definitions, + NavigableMap generatedSources + ) { + MessageDigest digest = sha256(); + updateString(digest, FINGERPRINT_DOMAIN); + updateInt(digest, FINGERPRINT_SCHEMA_VERSION_ONE); + updateInt(digest, definitions.size()); + for (Map.Entry entry : definitions.entrySet()) { + updateString(digest, entry.getKey().registryKey()); + updateString(digest, entry.getKey().resourceKey()); + updateString(digest, entry.getValue()); + } + updateInt(digest, generatedSources.size()); + for (Map.Entry entry : generatedSources.entrySet()) { + updateString(digest, entry.getKey().registryKey()); + updateString(digest, entry.getKey().resourceKey()); + updateString(digest, entry.getValue().sourceSchema()); + updateString(digest, entry.getValue().semanticSha256()); + updateString(digest, entry.getValue().semanticJson()); + updateString(digest, entry.getValue().rendererIdentity()); + updateString(digest, entry.getValue().renderedDefinitionSha256()); + updateString(digest, entry.getValue().sourceJson()); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static String requireGeneratedJson(String value, String label, int maximumBytes) { + String source = Objects.requireNonNull(value, label); + if (source.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + if (source.getBytes(StandardCharsets.UTF_8).length > maximumBytes) { + throw new IllegalArgumentException(label + " is too large."); + } + JsonElement parsed; + try { + parsed = JsonParser.parseString(source); + } catch (RuntimeException exception) { + throw new IllegalArgumentException(label + " must be valid JSON.", exception); + } + if (!parsed.isJsonObject()) { + throw new IllegalArgumentException(label + " must be a JSON object."); + } + return source; + } + + private static String requireGeneratedSemanticJson(String value) { + String semantic = requireGeneratedJson( + value, + "Generated registry semantic JSON", + MAX_GENERATED_SEMANTIC_BYTES + ); + return JsonParser.parseString(semantic).toString(); + } + + private static String requireRendererIdentity(String value) { + return requireBoundedIdentity(value, "Generated registry renderer identity"); + } + + private static String requireBoundedIdentity(String value, String label) { + String identity = Objects.requireNonNull(value, label).trim(); + if (identity.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + if (identity.getBytes(StandardCharsets.UTF_8).length > MAX_RENDERER_IDENTITY_BYTES) { + throw new IllegalArgumentException(label + " is too large."); + } + return identity; + } + + private static String requireSha256(String value, String label) { + String requiredValue = Objects.requireNonNull(value, label); + if (!SHA_256_PATTERN.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value."); + } + return requiredValue; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private static void updateString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + public record PhysicalResourceKey(String registryKey, String resourceKey) + implements Comparable { + public PhysicalResourceKey { + registryKey = requireResourceKey(registryKey, "Registry key"); + resourceKey = requireResourceKey(resourceKey, "Resource key"); + } + + @Override + public int compareTo(PhysicalResourceKey other) { + PhysicalResourceKey requiredOther = Objects.requireNonNull(other, "other"); + int registryComparison = registryKey.compareTo(requiredOther.registryKey); + if (registryComparison != 0) { + return registryComparison; + } + return resourceKey.compareTo(requiredOther.resourceKey); + } + + private static String requireResourceKey(String value, String label) { + String requiredValue = Objects.requireNonNull(value, label); + if (!RESOURCE_KEY_PATTERN.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " is invalid: " + requiredValue); + } + return requiredValue; + } + } + + public record GeneratedSource( + String sourceSchema, + String semanticSha256, + String semanticJson, + String rendererIdentity, + String renderedDefinitionSha256, + String sourceJson + ) { + public GeneratedSource { + sourceSchema = requireBoundedIdentity(sourceSchema, "Generated registry source schema"); + semanticSha256 = requireSha256(semanticSha256, "Generated registry semantic fingerprint"); + semanticJson = requireGeneratedSemanticJson(semanticJson); + rendererIdentity = requireRendererIdentity(rendererIdentity); + renderedDefinitionSha256 = requireSha256( + renderedDefinitionSha256, + "Rendered registry definition fingerprint" + ); + sourceJson = requireGeneratedJson( + sourceJson, + "Generated registry source JSON", + MAX_GENERATED_SOURCE_BYTES + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContractFactory.java new file mode 100644 index 000000000..9e2f17cee --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationRegistryContractFactory.java @@ -0,0 +1,1742 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.KeyStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.core.pack.PackDirectoryResolver; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisBiomeCustomCategory; +import art.arcane.iris.engine.object.IrisBiomeCustomParticle; +import art.arcane.iris.engine.object.IrisBiomeCustomPrecipType; +import art.arcane.iris.engine.object.IrisBiomeCustomSpawn; +import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisObjectIO; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonArray; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import java.awt.Color; +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public final class GenerationRegistryContractFactory { + public static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + public static final String STRUCTURE_REGISTRY = "minecraft:worldgen/structure"; + public static final String DIMENSION_TYPE_REGISTRY = "minecraft:dimension_type"; + public static final String BLOCK_REGISTRY = "minecraft:block"; + public static final String ENTITY_TYPE_REGISTRY = "minecraft:entity_type"; + + private static final int DEFINITION_FINGERPRINT_SCHEMA = 1; + private static final String DEFINITION_FINGERPRINT_DOMAIN = "iris-generation-registry-definition"; + private static final String GENERATED_SEMANTIC_FINGERPRINT_DOMAIN = + "iris-generation-registry-semantic-definition"; + private static final int CUSTOM_BIOME_DEFINITION_FINGERPRINT_SCHEMA = 1; + private static final String CUSTOM_BIOME_DEFINITION_FINGERPRINT_DOMAIN = + "iris-custom-biome-definition"; + private static final int CUSTOM_BIOME_AUTHORED_DEFINITION_FINGERPRINT_SCHEMA = 1; + private static final String CUSTOM_BIOME_AUTHORED_DEFINITION_FINGERPRINT_DOMAIN = + "iris-custom-biome-authored-definition"; + private static final int CUSTOM_BIOME_IDENTITY_FINGERPRINT_SCHEMA = 1; + private static final String CUSTOM_BIOME_IDENTITY_FINGERPRINT_DOMAIN = + "iris-custom-biome-physical-identity"; + public static final String CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA = + "iris-custom-biome-effective-v1"; + public static final String DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA = + "iris-dimension-type-effective-v1"; + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + private GenerationRegistryContractFactory() { + } + + public static GenerationRegistryContract create( + IrisData data, + IrisDimension dimension, + String packFingerprint + ) throws IOException { + return create(data, dimension, packFingerprint, CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY); + } + + public static GenerationRegistryContract create( + IrisData data, + IrisDimension dimension, + String packFingerprint, + CustomBiomeAliasPolicy aliasPolicy + ) throws IOException { + PlatformRegistries registries = IrisPlatforms.get().registries(); + return create( + data, + dimension, + packFingerprint, + DataVersion.getLatest().get(), + registries, + registries.generationRegistry(), + aliasPolicy + ); + } + + public static GenerationRegistryContract create( + IrisData data, + IrisDimension dimension, + String packFingerprint, + IDataFixer fixer, + PlatformRegistries registries, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + return create( + data, + dimension, + packFingerprint, + fixer, + registries, + generationRegistry, + CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY + ); + } + + public static GenerationRegistryContract create( + IrisData data, + IrisDimension dimension, + String packFingerprint, + IDataFixer fixer, + PlatformRegistries registries, + PlatformGenerationRegistry generationRegistry, + CustomBiomeAliasPolicy aliasPolicy + ) throws IOException { + IrisData requiredData = Objects.requireNonNull(data, "data"); + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + String requiredPackFingerprint = requireSha256(packFingerprint, "Pack fingerprint"); + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + PlatformRegistries requiredRegistries = Objects.requireNonNull(registries, "registries"); + PlatformGenerationRegistry requiredGenerationRegistry = Objects.requireNonNull( + generationRegistry, + "generationRegistry" + ); + CustomBiomeAliasPolicy requiredAliasPolicy = Objects.requireNonNull(aliasPolicy, "aliasPolicy"); + requireRuntimeIdentity(requiredGenerationRegistry); + Path packRoot = requiredData.getDataFolder().toPath().toAbsolutePath().normalize(); + String actualPackFingerprint = GenerationPackFingerprint.compute( + packRoot, + GenerationPackFingerprint.CURRENT_VERSION + ); + if (!requiredPackFingerprint.equals(actualPackFingerprint)) { + throw new IOException("Iris generation registry capture rejected a changed pack."); + } + + Map definitions = + new LinkedHashMap<>(); + Map + generatedSources = new LinkedHashMap<>(); + String packName = requiredData.getDataFolder().getName(); + List reachableBiomes = new ArrayList<>(requiredDimension.getReachableBiomes(() -> requiredData)); + for (IrisDimension registryDimension : registryDimensions(requiredData, requiredDimension)) { + String dimensionKey = requireText(registryDimension.getLoadKey(), "Dimension load key"); + addGeneratedDimensionType( + definitions, + generatedSources, + registryDimension, + packName, + dimensionKey, + requiredFixer, + requiredGenerationRegistry + ); + List referencedBiomes = registryDimension == requiredDimension ? reachableBiomes : List.of(); + List registryBiomes = new ArrayList<>(registryDimension.getAllBiomes(() -> requiredData)); + registryBiomes.addAll(referencedBiomes); + addBiomeDefinitions( + definitions, + generatedSources, + registryBiomes, + referencedBiomes, + registryDimension, + packName, + dimensionKey, + requiredData, + requiredFixer, + requiredGenerationRegistry, + requiredAliasPolicy + ); + } + addNativeStructures( + definitions, + requiredDimension, + reachableBiomes, + requiredData, + requiredGenerationRegistry + ); + ReferencedPlatformKeys referenced = referencedPlatformKeys(packRoot, requiredRegistries); + addPlatformDefinitions(definitions, BLOCK_REGISTRY, referenced.blockKeys(), requiredGenerationRegistry); + addPlatformDefinitions(definitions, ENTITY_TYPE_REGISTRY, referenced.entityKeys(), requiredGenerationRegistry); + return contract(definitions, generatedSources, requiredGenerationRegistry); + } + + public static CustomBiomeDefinition customBiomeDefinition( + IrisData data, + IrisDimension dimension, + IrisBiomeCustom customBiome + ) { + PlatformGenerationRegistry registry = IrisPlatforms.get().registries().generationRegistry(); + return customBiomeDefinition( + data, + dimension, + customBiome, + DataVersion.getLatest().get(), + registry + ); + } + + public static CustomBiomeDefinition customBiomeDefinition( + IrisData data, + IrisDimension dimension, + IrisBiomeCustom customBiome, + IDataFixer fixer, + PlatformGenerationRegistry registry + ) { + IrisData requiredData = Objects.requireNonNull(data, "data"); + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + PlatformGenerationRegistry requiredRegistry = Objects.requireNonNull(registry, "registry"); + String sourceJson = requiredBiome.generateJson(requiredFixer, requiredData.getContentGate()); + String semanticJson = customBiomeEffectiveSemanticJson( + requiredBiome, + requiredData.getContentGate() + ); + String authoredDefinitionFingerprint = fingerprintCustomBiomeAuthoredDefinition(semanticJson); + String identityFingerprint = fingerprintCustomBiomeIdentity( + authoredDefinitionFingerprint, + requiredDimension.getLoadKey(), + requiredBiome.getId() + ); + String resourceKey = requiredRegistry.customBiomeResourceKey(identityFingerprint); + GenerationRegistryContract.PhysicalResourceKey physicalKey = key(BIOME_REGISTRY, resourceKey); + PlatformGenerationRegistry.Definition canonicalDefinition = requiredRegistry.canonicalDefinition( + BIOME_REGISTRY, + resourceKey, + sourceJson + ); + requireGeneratedDefinition(canonicalDefinition, "canonical custom biome definition"); + String canonicalSource = canonicalDefinition.value(); + return new CustomBiomeDefinition( + physicalKey, + identityFingerprint, + authoredDefinitionFingerprint, + semanticJson, + fingerprintCustomBiomeDefinition(canonicalSource), + canonicalSource, + canonicalDefinition + ); + } + + public static String customBiomeResourceKey( + String dimensionKey, + IrisBiomeCustom customBiome + ) { + return customBiomeResourceKey(dimensionKey, customBiome, (ContentGate) null); + } + + public static String customBiomeResourceKey( + String dimensionKey, + IrisBiomeCustom customBiome, + ContentGate contentGate + ) { + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey( + fingerprintCustomBiomeIdentity( + fingerprintCustomBiomeEffectiveDefinition(requiredBiome, contentGate), + dimensionKey, + requiredBiome.getId() + ) + ); + } + + public static String customBiomeResourceKey( + String dimensionKey, + IrisBiomeCustom customBiome, + PlatformGenerationRegistry registry + ) { + return customBiomeResourceKey(dimensionKey, customBiome, (ContentGate) null, registry); + } + + public static String customBiomeResourceKey( + String dimensionKey, + IrisBiomeCustom customBiome, + ContentGate contentGate, + PlatformGenerationRegistry registry + ) { + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + PlatformGenerationRegistry requiredRegistry = Objects.requireNonNull(registry, "registry"); + return requiredRegistry.customBiomeResourceKey( + fingerprintCustomBiomeIdentity( + fingerprintCustomBiomeEffectiveDefinition(requiredBiome, contentGate), + dimensionKey, + requiredBiome.getId() + ) + ); + } + + public static String fingerprintCustomBiomeIdentity( + String authoredDefinitionFingerprint, + String dimensionKey, + String customBiomeId + ) { + String requiredDefinitionFingerprint = requireSha256( + authoredDefinitionFingerprint, + "Custom biome authored definition fingerprint" + ); + String requiredDimensionKey = requireText(dimensionKey, "Dimension load key").toLowerCase(Locale.ROOT); + String requiredCustomBiomeId = requireText(customBiomeId, "Custom biome ID").toLowerCase(Locale.ROOT); + MessageDigest digest = sha256(); + updateString(digest, CUSTOM_BIOME_IDENTITY_FINGERPRINT_DOMAIN); + updateInt(digest, CUSTOM_BIOME_IDENTITY_FINGERPRINT_SCHEMA); + updateString(digest, requiredDefinitionFingerprint); + updateString(digest, requiredDimensionKey); + updateString(digest, requiredCustomBiomeId); + return HexFormat.of().formatHex(digest.digest()); + } + + public static String requireCustomBiomeResourceKey( + GenerationRegistryContract contract, + String dimensionKey, + String customBiomeId + ) throws IOException { + return requireCustomBiomeResourceKey( + contract, + dimensionKey, + customBiomeId, + IrisPlatforms.get().registries().generationRegistry() + ); + } + + public static String requireCustomBiomeResourceKey( + GenerationRegistryContract contract, + String dimensionKey, + String customBiomeId, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "contract"); + PlatformGenerationRegistry registry = Objects.requireNonNull( + generationRegistry, + "generationRegistry" + ); + String selectedKey = null; + for (Map.Entry + entry : requiredContract.generatedSources().entrySet()) { + GenerationRegistryContract.GeneratedSource source = entry.getValue(); + if (!entry.getKey().registryKey().equals(BIOME_REGISTRY) + || !source.sourceSchema().equals(CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA)) { + continue; + } + String identity = fingerprintCustomBiomeIdentity( + source.semanticSha256(), + dimensionKey, + customBiomeId + ); + String expectedResourceKey = registry.customBiomeResourceKey(identity); + if (!entry.getKey().resourceKey().equals(expectedResourceKey)) { + continue; + } + String expectedDefinition = requiredContract.definitions().get(entry.getKey()); + requireGeneratedSemanticFingerprint(entry.getKey(), source, expectedDefinition); + if (selectedKey == null || expectedResourceKey.compareTo(selectedKey) < 0) { + selectedKey = expectedResourceKey; + } + } + if (selectedKey != null) { + return selectedKey; + } + throw new IOException("Historical generation registry contract has no custom biome mapping for " + + requireText(dimensionKey, "Dimension load key") + ":" + + requireText(customBiomeId, "Custom biome ID") + "."); + } + + public static String fingerprintCustomBiomeAuthoredDefinition(IrisBiomeCustom customBiome) { + return fingerprintCustomBiomeEffectiveDefinition(customBiome, null); + } + + public static String customBiomeAuthoredDefinitionJson(IrisBiomeCustom customBiome) { + return customBiomeEffectiveSemanticJson(customBiome, null); + } + + public static String fingerprintCustomBiomeEffectiveDefinition( + IrisBiomeCustom customBiome, + ContentGate contentGate + ) { + return fingerprintCustomBiomeAuthoredDefinition( + customBiomeEffectiveSemanticJson(customBiome, contentGate) + ); + } + + public static String customBiomeEffectiveSemanticJson( + IrisBiomeCustom customBiome, + ContentGate contentGate + ) { + return customBiomeSemanticJson( + customBiome, + entityKey -> contentGate == null || contentGate.entity(entityKey) != KeyStatus.MISSING + ); + } + + private static String customBiomeSemanticJson( + IrisBiomeCustom customBiome, + Predicate includeEntity + ) { + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + JsonObject authored = new JsonObject(); + authored.addProperty("temperature", requiredBiome.getTemperature()); + authored.addProperty("humidity", requiredBiome.getHumidity()); + authored.addProperty("downfallType", Objects.requireNonNull( + requiredBiome.getDownfallType(), + "Custom biome downfall type" + ).name()); + authored.addProperty("category", Objects.requireNonNull( + requiredBiome.getCategory(), + "Custom biome category" + ).name()); + authored.addProperty("spawnRarity", requiredBiome.getSpawnRarity()); + authored.addProperty("skyColor", customBiomeColor(requiredBiome.getSkyColor())); + authored.addProperty("fogColor", customBiomeColor(requiredBiome.getFogColor())); + authored.addProperty("waterColor", customBiomeColor(requiredBiome.getWaterColor())); + authored.addProperty("waterFogColor", customBiomeColor(requiredBiome.getWaterFogColor())); + if (requiredBiome.getGrassColor() != null && !requiredBiome.getGrassColor().isEmpty()) { + authored.addProperty("grassColor", customBiomeColor(requiredBiome.getGrassColor())); + } + if (requiredBiome.getFoliageColor() != null && !requiredBiome.getFoliageColor().isEmpty()) { + authored.addProperty("foliageColor", customBiomeColor(requiredBiome.getFoliageColor())); + } + IrisBiomeCustomParticle particle = requiredBiome.getAmbientParticle(); + if (particle != null && particle.getParticleKey() != null) { + JsonObject authoredParticle = new JsonObject(); + authoredParticle.addProperty("type", particle.getParticleKey()); + authoredParticle.addProperty("rarity", particle.getRarity()); + authored.add("ambientParticle", authoredParticle); + } + Map spawnGroups = new TreeMap<>(); + List spawns = requiredBiome.getSpawns(); + if (spawns != null) { + for (IrisBiomeCustomSpawn spawn : spawns) { + if (spawn == null || spawn.getTypeKey() == null) { + continue; + } + if (!includeEntity.test(spawn.getTypeKey())) { + continue; + } + IrisBiomeCustomSpawnType group = spawn.getGroup() == null + ? IrisBiomeCustomSpawnType.MISC + : spawn.getGroup(); + JsonObject authoredSpawn = new JsonObject(); + authoredSpawn.addProperty("type", spawn.getTypeKey()); + authoredSpawn.addProperty("weight", spawn.getWeight()); + authoredSpawn.addProperty("minCount", spawn.getMinCount()); + authoredSpawn.addProperty("maxCount", spawn.getMaxCount()); + spawnGroups.computeIfAbsent( + group.name().toLowerCase(Locale.ROOT), + ignored -> new JsonArray() + ).add(authoredSpawn); + } + } + JsonObject authoredSpawns = new JsonObject(); + for (Map.Entry entry : spawnGroups.entrySet()) { + authoredSpawns.add(entry.getKey(), entry.getValue()); + } + authored.add("spawners", authoredSpawns); + return authored.toString(); + } + + public static String fingerprintCustomBiomeAuthoredDefinition(String semanticJson) { + MessageDigest digest = sha256(); + updateString(digest, CUSTOM_BIOME_AUTHORED_DEFINITION_FINGERPRINT_DOMAIN); + updateInt(digest, CUSTOM_BIOME_AUTHORED_DEFINITION_FINGERPRINT_SCHEMA); + updateJson(digest, parseJson(Objects.requireNonNull(semanticJson, "semanticJson"))); + return HexFormat.of().formatHex(digest.digest()); + } + + public static String fingerprintCustomBiomeDefinition(String sourceJson) { + MessageDigest digest = sha256(); + updateString(digest, CUSTOM_BIOME_DEFINITION_FINGERPRINT_DOMAIN); + updateInt(digest, CUSTOM_BIOME_DEFINITION_FINGERPRINT_SCHEMA); + updateJson(digest, parseJson(Objects.requireNonNull(sourceJson, "sourceJson"))); + return HexFormat.of().formatHex(digest.digest()); + } + + public static GenerationRegistryContract captureRequiredDefinitions( + Collection requiredContracts + ) throws IOException { + return captureRequiredDefinitions( + requiredContracts, + DataVersion.getLatest().get(), + IrisPlatforms.get().registries().generationRegistry() + ); + } + + public static GenerationRegistryContract captureRequiredDefinitions( + Collection requiredContracts, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + return captureRequiredDefinitions( + requiredContracts, + DataVersion.getLatest().get(), + generationRegistry + ); + } + + public static GenerationRegistryContract captureRequiredDefinitions( + Collection requiredContracts, + IDataFixer fixer, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + Collection required = Objects.requireNonNull( + requiredContracts, + "requiredContracts" + ); + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + PlatformGenerationRegistry registry = Objects.requireNonNull(generationRegistry, "generationRegistry"); + requireRuntimeIdentity(registry); + TreeMap expected = new TreeMap<>(); + TreeMap + generatedSources = new TreeMap<>(); + for (GenerationRegistryContract contract : required) { + GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "required registry contract"); + for (Map.Entry entry + : requiredContract.definitions().entrySet()) { + String previous = expected.putIfAbsent(entry.getKey(), entry.getValue()); + if (previous != null && !previous.equals(entry.getValue())) { + throw new IOException("Retained generation epochs require conflicting definitions for " + + entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + "."); + } + } + for (Map.Entry + entry + : requiredContract.generatedSources().entrySet()) { + GenerationRegistryContract.GeneratedSource previous = generatedSources.get(entry.getKey()); + if (previous != null && !sameGeneratedSemantic(previous, entry.getValue())) { + throw new IOException("Retained generation epochs require conflicting generated sources for " + + entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + "."); + } + generatedSources.put( + entry.getKey(), + preferredGeneratedSource(previous, entry.getValue(), registry) + ); + } + } + Map captured = new TreeMap<>(); + for (Map.Entry entry : expected.entrySet()) { + GenerationRegistryContract.PhysicalResourceKey key = entry.getKey(); + GenerationRegistryContract.GeneratedSource generatedSource = generatedSources.get(key); + PlatformGenerationRegistry.Definition definition; + if (generatedSource == null) { + definition = requireDefinition(registry, key); + String fingerprint = fingerprintDefinition(key, definition, registry.runtimeIdentity()); + if (!entry.getValue().equals(fingerprint)) { + throw new IOException("Historical registry definition changed for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + } else { + requireGeneratedSemanticFingerprint(key, generatedSource, entry.getValue()); + PlatformGenerationRegistry.Definition expectedDefinition = canonicalGeneratedDefinition( + key, + generatedSource, + requiredFixer, + registry + ); + definition = requireGeneratedDefinition(registry, key); + String expectedRenderedFingerprint = fingerprintDefinition(key, expectedDefinition, "generated"); + String actualRenderedFingerprint = fingerprintDefinition(key, definition, "generated"); + if (!expectedRenderedFingerprint.equals(actualRenderedFingerprint)) { + throw new IOException("Historical generated registry definition changed for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + } + captured.put(key, entry.getValue()); + } + return GenerationRegistryContract.fromDefinitions(captured); + } + + public static String requireGeneratedSource( + GenerationRegistryContract contract, + GenerationRegistryContract.PhysicalResourceKey key + ) throws IOException { + return requireGeneratedSource( + contract, + key, + DataVersion.getLatest().get(), + IrisPlatforms.get().registries().generationRegistry() + ); + } + + public static String renderGeneratedSource( + GenerationRegistryContract contract, + GenerationRegistryContract.PhysicalResourceKey key, + IDataFixer fixer + ) throws IOException { + GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "contract"); + GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key"); + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + GenerationRegistryContract.GeneratedSource source = requiredContract.generatedSources().get(requiredKey); + if (source == null) { + throw new IOException("Historical generated registry source is missing for " + + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); + } + requireGeneratedSemanticFingerprint(requiredKey, source, requiredContract.definitions().get(requiredKey)); + String sourceFingerprint = fingerprintDefinition(requiredKey, + PlatformGenerationRegistry.Definition.exactJson(source.sourceJson()), "generated"); + if (!source.renderedDefinitionSha256().equals(sourceFingerprint)) { + throw new IOException("Historical generated registry source changed for " + + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); + } + return renderGeneratedSemantic(requiredKey, source, requiredFixer); + } + + public static String requireGeneratedSource( + GenerationRegistryContract contract, + GenerationRegistryContract.PhysicalResourceKey key, + IDataFixer fixer + ) throws IOException { + return requireGeneratedSource( + contract, + key, + fixer, + IrisPlatforms.get().registries().generationRegistry() + ); + } + + public static String requireGeneratedSource( + GenerationRegistryContract contract, + GenerationRegistryContract.PhysicalResourceKey key, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + return requireGeneratedSource( + contract, + key, + DataVersion.getLatest().get(), + generationRegistry + ); + } + + public static String requireGeneratedSource( + GenerationRegistryContract contract, + GenerationRegistryContract.PhysicalResourceKey key, + IDataFixer fixer, + PlatformGenerationRegistry generationRegistry + ) throws IOException { + GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "contract"); + GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key"); + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + PlatformGenerationRegistry registry = Objects.requireNonNull( + generationRegistry, + "generationRegistry" + ); + GenerationRegistryContract.GeneratedSource source = requiredContract.generatedSources().get(requiredKey); + if (source == null) { + throw new IOException("Historical generated registry source is missing for " + + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); + } + String expectedFingerprint = requiredContract.definitions().get(requiredKey); + if (expectedFingerprint == null) { + throw new IOException("Historical generated registry definition is missing for " + + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); + } + requireGeneratedSemanticFingerprint(requiredKey, source, expectedFingerprint); + return canonicalGeneratedDefinition(requiredKey, source, requiredFixer, registry).value(); + } + + public static String fingerprintDefinition( + GenerationRegistryContract.PhysicalResourceKey key, + PlatformGenerationRegistry.Definition definition, + String runtimeIdentity + ) { + GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key"); + PlatformGenerationRegistry.Definition requiredDefinition = Objects.requireNonNull( + definition, + "definition" + ); + Objects.requireNonNull(runtimeIdentity, "runtimeIdentity"); + MessageDigest digest = sha256(); + updateString(digest, DEFINITION_FINGERPRINT_DOMAIN); + updateInt(digest, DEFINITION_FINGERPRINT_SCHEMA); + updateString(digest, requiredKey.registryKey()); + updateString(digest, requiredKey.resourceKey()); + updateString(digest, requiredDefinition.encoding().name()); + switch (requiredDefinition.encoding()) { + case JSON -> updateJson(digest, parseJson(requiredDefinition.value())); + case CONSERVATIVE_IDENTITY -> { + updateString(digest, requiredDefinition.value()); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + public static String fingerprintGeneratedSemantic( + GenerationRegistryContract.PhysicalResourceKey key, + GenerationRegistryContract.GeneratedSource source + ) { + GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key"); + GenerationRegistryContract.GeneratedSource requiredSource = Objects.requireNonNull(source, "source"); + MessageDigest digest = sha256(); + updateString(digest, GENERATED_SEMANTIC_FINGERPRINT_DOMAIN); + updateInt(digest, DEFINITION_FINGERPRINT_SCHEMA); + updateString(digest, requiredKey.registryKey()); + updateString(digest, requiredKey.resourceKey()); + updateString(digest, requiredSource.sourceSchema()); + updateString(digest, requiredSource.semanticSha256()); + updateJson(digest, parseJson(requiredSource.semanticJson())); + return HexFormat.of().formatHex(digest.digest()); + } + + private static List registryDimensions(IrisData data, IrisDimension owner) throws IOException { + ResourceLoader loader = data.getDimensionLoader(); + String[] possibleKeys = loader.getPossibleKeys(); + if (possibleKeys == null || possibleKeys.length == 0) { + throw new IOException("Iris pack has no dimension definitions: " + data.getDataFolder()); + } + String ownerKey = requireText(owner.getLoadKey(), "Dimension load key"); + List dimensions = new ArrayList<>(possibleKeys.length); + boolean foundOwner = false; + for (String possibleKey : possibleKeys) { + IrisDimension dimension; + if (ownerKey.equals(possibleKey)) { + dimension = owner; + foundOwner = true; + } else { + dimension = loader.load(possibleKey); + } + if (dimension == null) { + throw new IOException("Unable to load Iris dimension '" + possibleKey + "' from " + data.getDataFolder()); + } + dimensions.add(dimension); + } + if (!foundOwner) { + throw new IOException("Iris pack does not contain its generation dimension '" + ownerKey + "'."); + } + return dimensions; + } + + private static void addGeneratedDimensionType( + Map definitions, + Map + generatedSources, + IrisDimension dimension, + String packName, + String dimensionKey, + IDataFixer fixer, + PlatformGenerationRegistry registry + ) throws IOException { + String resourceKey = registry.dimensionTypeResourceKey( + packName, + dimensionKey, + dimension.getDimensionTypeKey() + ); + PlatformGenerationRegistry.Definition canonicalDefinition = registry.canonicalDefinition( + DIMENSION_TYPE_REGISTRY, + resourceKey, + dimension.getDimensionType().toJson(fixer) + ); + requireGeneratedDefinition(canonicalDefinition, "canonical dimension type definition"); + putGeneratedDefinition( + definitions, + generatedSources, + key(DIMENSION_TYPE_REGISTRY, resourceKey), + DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA, + GenerationEpochContractFactory.fingerprintDimensionType(dimension.getDimensionType()), + GenerationEpochContractFactory.dimensionTypeSemanticJson(dimension.getDimensionType()), + registry.generatedDefinitionRendererIdentity(), + canonicalDefinition + ); + } + + private static void addBiomeDefinitions( + Map definitions, + Map + generatedSources, + List biomes, + List referencedBiomes, + IrisDimension dimension, + String packName, + String dimensionKey, + IrisData data, + IDataFixer fixer, + PlatformGenerationRegistry registry, + CustomBiomeAliasPolicy aliasPolicy + ) throws IOException { + TreeSet platformBiomeKeys = new TreeSet<>(); + Map customResourceKeys = new LinkedHashMap<>(); + Map legacyDefinitions = new TreeMap<>(); + for (IrisBiome biome : biomes) { + if (biome == null) { + continue; + } + List customBiomes = biome.getCustomDerivitives(); + if (customBiomes == null) { + continue; + } + for (IrisBiomeCustom customBiome : customBiomes) { + if (customBiome == null) { + continue; + } + String customBiomeId = requireText(customBiome.getId(), "Custom biome ID"); + CustomBiomeDefinition generated = customBiomeDefinition( + data, + dimension, + customBiome, + fixer, + registry + ); + putGeneratedDefinition( + definitions, + generatedSources, + generated.physicalKey(), + CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + generated.semanticFingerprint(), + generated.semanticJson(), + registry.generatedDefinitionRendererIdentity(), + generated.definition() + ); + String logicalKey = dimensionKey.toLowerCase(Locale.ROOT) + + ":" + customBiomeId.toLowerCase(Locale.ROOT); + customResourceKeys.merge( + logicalKey, + generated.physicalKey().resourceKey(), + (first, second) -> first.compareTo(second) <= 0 ? first : second + ); + if (aliasPolicy == CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES) { + for (String alias : registry.legacyCustomBiomeResourceKeys( + packName, + dimensionKey, + customBiomeId + )) { + legacyDefinitions.merge( + alias, + generated, + (first, second) -> first.physicalKey().resourceKey() + .compareTo(second.physicalKey().resourceKey()) <= 0 ? first : second + ); + } + } + } + } + for (Map.Entry entry : legacyDefinitions.entrySet()) { + CustomBiomeDefinition generated = entry.getValue(); + putGeneratedDefinition( + definitions, + generatedSources, + key(BIOME_REGISTRY, entry.getKey()), + CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + generated.semanticFingerprint(), + generated.semanticJson(), + registry.generatedDefinitionRendererIdentity(), + generated.definition() + ); + } + for (IrisBiome biome : referencedBiomes) { + if (biome == null) { + continue; + } + addMappedBiomeResourceKey(platformBiomeKeys, customResourceKeys, biome.getDerivativeKey()); + addMappedBiomeResourceKey(platformBiomeKeys, customResourceKeys, biome.getVanillaDerivativeKey()); + addMappedBiomeResourceKey(platformBiomeKeys, customResourceKeys, biome.getStructureDerivativeKey()); + addMappedBiomeResourceKeys(platformBiomeKeys, customResourceKeys, biome.getBiomeScatter()); + addMappedBiomeResourceKeys(platformBiomeKeys, customResourceKeys, biome.getBiomeSkyScatter()); + } + addPlatformDefinitions(definitions, BIOME_REGISTRY, platformBiomeKeys, registry); + } + + private static void addNativeStructures( + Map definitions, + IrisDimension dimension, + List biomes, + IrisData data, + PlatformGenerationRegistry registry + ) throws IOException { + TreeSet structureKeys = new TreeSet<>(); + collectNativeStructures(structureKeys, dimension.getStructures()); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + if (region != null) { + collectNativeStructures(structureKeys, region.getStructures()); + } + } + for (IrisBiome biome : biomes) { + if (biome != null) { + collectNativeStructures(structureKeys, biome.getStructures()); + } + } + addPlatformDefinitions(definitions, STRUCTURE_REGISTRY, structureKeys, registry); + } + + private static void collectNativeStructures( + Set keys, + List placements + ) { + if (placements == null) { + return; + } + for (IrisStructurePlacement placement : placements) { + if (placement == null || placement.getNativeStructures() == null) { + continue; + } + for (IrisNativeStructure structure : placement.getNativeStructures()) { + if (structure != null) { + addResourceKey(keys, structure.getStructure()); + } + } + } + } + + private static void addPlatformDefinitions( + Map definitions, + String registryKey, + Collection resourceKeys, + PlatformGenerationRegistry registry + ) throws IOException { + TreeSet sorted = new TreeSet<>(resourceKeys); + for (String resourceKey : sorted) { + GenerationRegistryContract.PhysicalResourceKey physicalKey = key(registryKey, resourceKey); + if (definitions.containsKey(physicalKey)) { + continue; + } + putDefinition(definitions, physicalKey, requireDefinition(registry, physicalKey)); + } + } + + private static void addMappedBiomeResourceKeys( + Set keys, + Map customResourceKeys, + List values + ) { + if (values == null) { + return; + } + for (String value : values) { + addMappedBiomeResourceKey(keys, customResourceKeys, value); + } + } + + private static void addMappedBiomeResourceKey( + Set keys, + Map customResourceKeys, + String value + ) { + if (value == null || value.isBlank()) { + return; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (!normalized.contains(":")) { + normalized = "minecraft:" + normalized; + } + keys.add(customResourceKeys.getOrDefault(normalized, normalized)); + } + + private static GenerationRegistryContract contract( + Map definitions, + Map + generatedSources, + PlatformGenerationRegistry registry + ) { + TreeMap fingerprints = new TreeMap<>(); + for (Map.Entry entry + : definitions.entrySet()) { + GenerationRegistryContract.GeneratedSource generatedSource = generatedSources.get(entry.getKey()); + fingerprints.put( + entry.getKey(), + generatedSource == null + ? fingerprintDefinition(entry.getKey(), entry.getValue(), registry.runtimeIdentity()) + : fingerprintGeneratedSemantic(entry.getKey(), generatedSource) + ); + } + return GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + fingerprints, + generatedSources + ); + } + + private static PlatformGenerationRegistry.Definition requireDefinition( + PlatformGenerationRegistry registry, + GenerationRegistryContract.PhysicalResourceKey key + ) throws IOException { + try { + PlatformGenerationRegistry.Definition definition = registry.definition( + key.registryKey(), + key.resourceKey() + ); + if (definition == null) { + throw new IOException("Missing generation registry definition for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + return definition; + } catch (IOException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IOException("Unable to capture generation registry definition for " + + key.registryKey() + " / " + key.resourceKey() + ".", exception); + } + } + + private static PlatformGenerationRegistry.Definition requireGeneratedDefinition( + PlatformGenerationRegistry registry, + GenerationRegistryContract.PhysicalResourceKey key + ) throws IOException { + try { + PlatformGenerationRegistry.Definition definition = registry.generatedDefinition( + key.registryKey(), + key.resourceKey() + ); + requireGeneratedDefinition(definition, "generated registry definition"); + return definition; + } catch (RuntimeException exception) { + throw new IOException("Unable to capture generated registry definition for " + + key.registryKey() + " / " + key.resourceKey() + ".", exception); + } + } + + private static void putDefinition( + Map definitions, + GenerationRegistryContract.PhysicalResourceKey key, + PlatformGenerationRegistry.Definition definition + ) throws IOException { + PlatformGenerationRegistry.Definition previous = definitions.putIfAbsent(key, definition); + if (previous == null || previous.equals(definition)) { + return; + } + throw new IOException("Generation pack defines conflicting registry resources for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + + private static void putGeneratedDefinition( + Map definitions, + Map + generatedSources, + GenerationRegistryContract.PhysicalResourceKey key, + String sourceSchema, + String semanticFingerprint, + String semanticJson, + String rendererIdentity, + PlatformGenerationRegistry.Definition definition + ) throws IOException { + requireGeneratedDefinition(definition, "generated registry definition"); + putDefinition(definitions, key, definition); + GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource( + sourceSchema, + semanticFingerprint, + semanticJson, + rendererIdentity, + fingerprintDefinition(key, definition, "generated"), + definition.value() + ); + GenerationRegistryContract.GeneratedSource previousSource = generatedSources.putIfAbsent(key, source); + if (previousSource != null && !previousSource.equals(source)) { + throw new IOException("Generation pack defines conflicting generated registry sources for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + } + + private static GenerationRegistryContract.GeneratedSource preferredGeneratedSource( + GenerationRegistryContract.GeneratedSource previous, + GenerationRegistryContract.GeneratedSource candidate, + PlatformGenerationRegistry registry + ) { + if (previous == null) { + return candidate; + } + String currentRenderer = requireText( + registry.generatedDefinitionRendererIdentity(), + "Generated registry renderer identity" + ); + boolean previousMatches = previous.rendererIdentity().equals(currentRenderer); + boolean candidateMatches = candidate.rendererIdentity().equals(currentRenderer); + if (previousMatches != candidateMatches) { + return candidateMatches ? candidate : previous; + } + return generatedSourceOrder(candidate).compareTo(generatedSourceOrder(previous)) < 0 + ? candidate + : previous; + } + + private static String generatedSourceOrder(GenerationRegistryContract.GeneratedSource source) { + return source.rendererIdentity() + '\u0000' + + source.renderedDefinitionSha256() + '\u0000' + + source.sourceJson(); + } + + private static boolean sameGeneratedSemantic( + GenerationRegistryContract.GeneratedSource left, + GenerationRegistryContract.GeneratedSource right + ) { + return left.sourceSchema().equals(right.sourceSchema()) + && left.semanticSha256().equals(right.semanticSha256()) + && parseJson(left.semanticJson()).equals(parseJson(right.semanticJson())); + } + + private static PlatformGenerationRegistry.Definition canonicalGeneratedDefinition( + GenerationRegistryContract.PhysicalResourceKey key, + GenerationRegistryContract.GeneratedSource source, + IDataFixer fixer, + PlatformGenerationRegistry registry + ) throws IOException { + String currentRenderer = requireText( + registry.generatedDefinitionRendererIdentity(), + "Generated registry renderer identity" + ); + String renderedSource; + if (source.rendererIdentity().equals(currentRenderer)) { + renderedSource = source.sourceJson(); + } else { + renderedSource = renderGeneratedSemantic(key, source, fixer); + } + PlatformGenerationRegistry.Definition definition; + try { + definition = registry.canonicalDefinition( + key.registryKey(), + key.resourceKey(), + renderedSource + ); + requireGeneratedDefinition(definition, "historical generated registry definition"); + } catch (RuntimeException failure) { + throw new IOException("Historical generated registry source is incompatible for " + + key.registryKey() + " / " + key.resourceKey() + ".", failure); + } + if (source.rendererIdentity().equals(currentRenderer)) { + String renderedFingerprint = fingerprintDefinition(key, definition, "generated"); + if (!source.renderedDefinitionSha256().equals(renderedFingerprint)) { + throw new IOException("Historical generated registry source changed for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + } + return definition; + } + + private static String renderGeneratedSemantic( + GenerationRegistryContract.PhysicalResourceKey key, + GenerationRegistryContract.GeneratedSource source, + IDataFixer fixer + ) throws IOException { + try { + return switch (source.sourceSchema()) { + case CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA -> customBiomeFromSemantic( + key, + source.semanticJson() + ).generateJson( + fixer, + new ContentGate(null, Map.of(), null) + ); + case DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA -> dimensionTypeFromSemantic( + source.semanticJson() + ).toJson(fixer); + default -> throw new IOException("Unsupported historical generated registry source schema '" + + source.sourceSchema() + "'."); + }; + } catch (IOException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new IOException("Unable to render historical generated registry semantics for " + + key.registryKey() + " / " + key.resourceKey() + ".", failure); + } + } + + private static void requireGeneratedSemanticFingerprint( + GenerationRegistryContract.PhysicalResourceKey key, + GenerationRegistryContract.GeneratedSource source, + String expectedDefinitionFingerprint + ) throws IOException { + String semanticFingerprint; + try { + semanticFingerprint = switch (source.sourceSchema()) { + case CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA -> fingerprintCustomBiomeAuthoredDefinition( + source.semanticJson() + ); + case DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA -> GenerationEpochContractFactory.fingerprintDimensionType( + dimensionTypeFromSemantic(source.semanticJson()) + ); + default -> throw new IOException("Unsupported historical generated registry source schema '" + + source.sourceSchema() + "'."); + }; + } catch (IOException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new IOException("Invalid historical generated registry semantics for " + + key.registryKey() + " / " + key.resourceKey() + ".", failure); + } + if (!source.semanticSha256().equals(semanticFingerprint)) { + throw new IOException("Historical generated registry semantic fingerprint changed for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + String contractFingerprint = fingerprintGeneratedSemantic(key, source); + if (!expectedDefinitionFingerprint.equals(contractFingerprint)) { + throw new IOException("Historical generated registry contract changed for " + + key.registryKey() + " / " + key.resourceKey() + "."); + } + } + + private static IrisDimensionType dimensionTypeFromSemantic(String semanticJson) { + JsonObject semantic = requireJsonObject(semanticJson, "Dimension type semantic source"); + GenerationManifest.JsonSchema.requireFields( + semantic, + "dimension type semantic source", + "base", "logicalHeight", "height", "minY", "options" + ); + JsonObject optionsJson = GenerationManifest.JsonSchema.requireObject( + semantic, "options", "dimension type semantic source" + ); + GenerationManifest.JsonSchema.requireFields( + optionsJson, "dimension type options", + "coordinateScale", "ambientLight", "fixedTime", "cloudHeight", "monsterSpawnBlockLightLimit", + "ultrawarm", "natural", "piglinSafe", "respawnAnchorWorks", "bedWorks", "raids", "skylight", "ceiling" + ); + IrisDimensionTypeOptions options = new Gson().fromJson(optionsJson, IrisDimensionTypeOptions.class).copy(); + return new IrisDimensionType( + IDataFixer.Dimension.valueOf(GenerationManifest.JsonSchema.requireString( + semantic, "base", "dimension type semantic source" + )), + options, + GenerationManifest.JsonSchema.requireInt(semantic, "logicalHeight", "dimension type semantic source"), + GenerationManifest.JsonSchema.requireInt(semantic, "height", "dimension type semantic source"), + GenerationManifest.JsonSchema.requireInt(semantic, "minY", "dimension type semantic source") + ); + } + + private static IrisBiomeCustom customBiomeFromSemantic( + GenerationRegistryContract.PhysicalResourceKey key, + String semanticJson + ) { + JsonObject semantic = requireJsonObject(semanticJson, "Custom biome semantic source"); + Set requiredFields = Set.of( + "temperature", + "humidity", + "downfallType", + "category", + "spawnRarity", + "skyColor", + "fogColor", + "waterColor", + "waterFogColor", + "spawners" + ); + Set optionalFields = Set.of("grassColor", "foliageColor", "ambientParticle"); + requireSemanticFields(semantic, requiredFields, optionalFields, "custom biome semantic source"); + IrisBiomeCustom biome = new IrisBiomeCustom() + .setId(key.resourceKey()) + .setTemperature(GenerationManifest.JsonSchema.requireDouble( + semantic, + "temperature", + "custom biome semantic source" + )) + .setHumidity(GenerationManifest.JsonSchema.requireDouble( + semantic, + "humidity", + "custom biome semantic source" + )) + .setDownfallType(IrisBiomeCustomPrecipType.valueOf( + GenerationManifest.JsonSchema.requireString( + semantic, + "downfallType", + "custom biome semantic source" + ) + )) + .setCategory(IrisBiomeCustomCategory.valueOf( + GenerationManifest.JsonSchema.requireString( + semantic, + "category", + "custom biome semantic source" + ) + )) + .setSpawnRarity(GenerationManifest.JsonSchema.requireInt( + semantic, + "spawnRarity", + "custom biome semantic source" + )) + .setSkyColor(requireColor(semantic, "skyColor")) + .setFogColor(requireColor(semantic, "fogColor")) + .setWaterColor(requireColor(semantic, "waterColor")) + .setWaterFogColor(requireColor(semantic, "waterFogColor")) + .setGrassColor(optionalColor(semantic, "grassColor")) + .setFoliageColor(optionalColor(semantic, "foliageColor")) + .setSpawns(spawnsFromSemantic(semantic)); + JsonElement particleElement = semantic.get("ambientParticle"); + if (particleElement != null) { + JsonObject particle = GenerationManifest.JsonSchema.requireObject( + particleElement, + "custom biome semantic source.ambientParticle" + ); + GenerationManifest.JsonSchema.requireFields( + particle, + "custom biome semantic source.ambientParticle", + "type", + "rarity" + ); + biome.setAmbientParticle(new IrisBiomeCustomParticle() + .setParticle(GenerationManifest.JsonSchema.requireString( + particle, + "type", + "custom biome semantic source.ambientParticle" + )) + .setRarity(GenerationManifest.JsonSchema.requireInt( + particle, + "rarity", + "custom biome semantic source.ambientParticle" + ))); + } + return biome; + } + + private static KList spawnsFromSemantic(JsonObject semantic) { + JsonObject spawners = GenerationManifest.JsonSchema.requireObject( + semantic, + "spawners", + "custom biome semantic source" + ); + KList result = new KList<>(); + for (Map.Entry groupEntry : spawners.entrySet()) { + IrisBiomeCustomSpawnType group = IrisBiomeCustomSpawnType.valueOf( + groupEntry.getKey().toUpperCase(Locale.ROOT) + ); + if (!groupEntry.getValue().isJsonArray()) { + throw new IllegalArgumentException("custom biome semantic source.spawners." + + groupEntry.getKey() + " must be an array."); + } + for (JsonElement spawnElement : groupEntry.getValue().getAsJsonArray()) { + JsonObject spawn = GenerationManifest.JsonSchema.requireObject( + spawnElement, + "custom biome semantic source spawn" + ); + GenerationManifest.JsonSchema.requireFields( + spawn, + "custom biome semantic source spawn", + "type", + "weight", + "minCount", + "maxCount" + ); + result.add(new IrisBiomeCustomSpawn() + .setType(GenerationManifest.JsonSchema.requireString( + spawn, + "type", + "custom biome semantic source spawn" + )) + .setWeight(GenerationManifest.JsonSchema.requireInt( + spawn, + "weight", + "custom biome semantic source spawn" + )) + .setMinCount(GenerationManifest.JsonSchema.requireInt( + spawn, + "minCount", + "custom biome semantic source spawn" + )) + .setMaxCount(GenerationManifest.JsonSchema.requireInt( + spawn, + "maxCount", + "custom biome semantic source spawn" + )) + .setGroup(group)); + } + } + return result; + } + + private static String requireColor(JsonObject semantic, String field) { + int color = GenerationManifest.JsonSchema.requireInt( + semantic, + field, + "custom biome semantic source" + ); + if (color < 0 || color > 0xFFFFFF) { + throw new IllegalArgumentException("custom biome semantic source." + + field + " must be an RGB color."); + } + return String.format(Locale.ROOT, "#%06x", color); + } + + private static String optionalColor(JsonObject semantic, String field) { + return semantic.has(field) ? requireColor(semantic, field) : ""; + } + + private static void requireSemanticFields( + JsonObject semantic, + Set requiredFields, + Set optionalFields, + String context + ) { + Set missing = new TreeSet<>(requiredFields); + missing.removeAll(semantic.keySet()); + Set unknown = new TreeSet<>(semantic.keySet()); + unknown.removeAll(requiredFields); + unknown.removeAll(optionalFields); + if (!missing.isEmpty() || !unknown.isEmpty()) { + throw new IllegalArgumentException( + "Invalid " + context + " fields. Missing=" + missing + ", unknown=" + unknown + "." + ); + } + } + + private static JsonObject requireJsonObject(String json, String label) { + JsonElement element = parseJson(Objects.requireNonNull(json, label)); + if (!element.isJsonObject()) { + throw new IllegalArgumentException(label + " must be an object."); + } + return element.getAsJsonObject(); + } + + private static void requireGeneratedDefinition( + PlatformGenerationRegistry.Definition definition, + String label + ) { + PlatformGenerationRegistry.Definition requiredDefinition = Objects.requireNonNull(definition, label); + if (requiredDefinition.encoding() != PlatformGenerationRegistry.Encoding.JSON) { + throw new IllegalArgumentException(label + " must use canonical JSON encoding."); + } + parseJson(requiredDefinition.value()); + } + + private static ReferencedPlatformKeys referencedPlatformKeys( + Path packRoot, + PlatformRegistries registries + ) throws IOException { + Set knownBlocks = normalizedKeys(registries.blockTypeKeys()); + Set knownEntities = normalizedKeys(registries.entityKeys()); + Set blocks = new TreeSet<>(); + Set entities = new TreeSet<>(); + try (Stream stream = Files.walk(packRoot)) { + List files = stream.filter(path -> isVisibleRegularFile(packRoot, path)) + .sorted(Comparator.comparing(path -> packRoot.relativize(path).toString())) + .toList(); + for (Path file : files) { + String fileName = file.getFileName().toString(); + if (fileName.endsWith(".json")) { + JsonElement json = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)); + collectPlatformKeys(json, knownBlocks, knownEntities, blocks, entities); + } else if (fileName.endsWith(".iob")) { + for (String blockState : IrisObjectIO.readPaletteKeys(file.toFile())) { + addKnownKey(blocks, blockState, knownBlocks); + } + } + } + } catch (RuntimeException exception) { + throw new IOException("Unable to inspect Iris pack platform registry references.", exception); + } + return new ReferencedPlatformKeys(Set.copyOf(blocks), Set.copyOf(entities)); + } + + private static boolean isVisibleRegularFile(Path root, Path path) { + if (path.equals(root) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + Path relative = root.relativize(path); + if (relative.getNameCount() > 0 + && PackDirectoryResolver.isHiddenName(relative.getName(0).toString())) { + return false; + } + return !Files.isSymbolicLink(path); + } + + private static void collectPlatformKeys( + JsonElement element, + Set knownBlocks, + Set knownEntities, + Set blocks, + Set entities + ) { + if (element == null || element.isJsonNull()) { + return; + } + if (element.isJsonObject()) { + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + collectPlatformKeys(entry.getValue(), knownBlocks, knownEntities, blocks, entities); + } + return; + } + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + collectPlatformKeys(child, knownBlocks, knownEntities, blocks, entities); + } + return; + } + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (!primitive.isString()) { + return; + } + String value = primitive.getAsString(); + addKnownKey(blocks, value, knownBlocks); + addKnownKey(entities, value, knownEntities); + } + + private static void addKnownKey(Set destination, String value, Set known) { + if (value == null) { + return; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + int stateStart = normalized.indexOf('['); + if (stateStart >= 0) { + normalized = normalized.substring(0, stateStart); + } + if (!normalized.contains(":")) { + normalized = "minecraft:" + normalized; + } + if (known.contains(normalized)) { + destination.add(normalized); + } + } + + private static Set normalizedKeys(List keys) throws IOException { + if (keys == null) { + throw new IOException("Platform registry key enumeration returned null."); + } + Set normalized = new HashSet<>(Math.max(16, keys.size() * 2)); + for (String key : keys) { + if (key != null && !key.isBlank()) { + String value = key.toLowerCase(Locale.ROOT); + int stateStart = value.indexOf('['); + normalized.add(stateStart < 0 ? value : value.substring(0, stateStart)); + } + } + return normalized; + } + + private static void addResourceKey(Set keys, String value) { + if (value == null || value.isBlank()) { + return; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + keys.add(normalized.contains(":") ? normalized : "minecraft:" + normalized); + } + + private static GenerationRegistryContract.PhysicalResourceKey key( + String registryKey, + String resourceKey + ) { + return new GenerationRegistryContract.PhysicalResourceKey(registryKey, resourceKey); + } + + private static void requireRuntimeIdentity(PlatformGenerationRegistry registry) { + requireText(registry.runtimeIdentity(), "Platform generation runtime identity"); + } + + private static String requireSha256(String value, String label) { + String required = Objects.requireNonNull(value, label); + if (!SHA_256_PATTERN.matcher(required).matches()) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value."); + } + return required; + } + + private static String requireText(String value, String label) { + String required = Objects.requireNonNull(value, label).trim(); + if (required.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return required; + } + + private static int customBiomeColor(String value) { + String required = Objects.requireNonNull(value, "Custom biome color"); + String normalized = (required.startsWith("#") ? required : "#" + required).trim(); + try { + return Color.decode(normalized).getRGB() & 0x00FFFFFF; + } catch (NumberFormatException exception) { + return 0; + } + } + + private static JsonElement parseJson(String value) { + try { + return JsonParser.parseString(value); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Platform registry definition is not valid JSON.", exception); + } + } + + private static void updateJson(MessageDigest digest, JsonElement element) { + if (element == null || element.isJsonNull()) { + digest.update((byte) 0); + return; + } + if (element.isJsonObject()) { + digest.update((byte) 1); + JsonObject object = element.getAsJsonObject(); + List> entries = new ArrayList<>(object.entrySet()); + entries.sort(Map.Entry.comparingByKey()); + updateInt(digest, entries.size()); + for (Map.Entry entry : entries) { + updateString(digest, entry.getKey()); + updateJson(digest, entry.getValue()); + } + return; + } + if (element.isJsonArray()) { + digest.update((byte) 2); + JsonArray array = element.getAsJsonArray(); + updateInt(digest, array.size()); + for (JsonElement child : array) { + updateJson(digest, child); + } + return; + } + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + digest.update((byte) 3); + digest.update(primitive.getAsBoolean() ? (byte) 1 : (byte) 0); + } else if (primitive.isString()) { + digest.update((byte) 4); + updateString(digest, primitive.getAsString()); + } else if (primitive.isNumber()) { + digest.update((byte) 5); + BigDecimal number = primitive.getAsBigDecimal().stripTrailingZeros(); + updateString(digest, number.signum() == 0 ? "0" : number.toString()); + } else { + throw new IllegalArgumentException("Platform registry definition contains an unsupported JSON value."); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private static void updateString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + public enum CustomBiomeAliasPolicy { + CONTENT_ADDRESSED_ONLY, + RETAIN_LEGACY_ALIASES + } + + public static final class CustomBiomeResourceResolver { + private final GenerationRegistryContract contract; + private final UnaryOperator resourceKeyFactory; + private final Set includedEntities; + private final Map> resolved = new LinkedHashMap<>(); + + public CustomBiomeResourceResolver( + GenerationRegistryContract contract, + UnaryOperator resourceKeyFactory + ) { + this.contract = Objects.requireNonNull(contract, "contract"); + this.resourceKeyFactory = Objects.requireNonNull(resourceKeyFactory, "resourceKeyFactory"); + Set entities = new HashSet<>(); + for (Map.Entry + entry : contract.generatedSources().entrySet()) { + GenerationRegistryContract.GeneratedSource source = entry.getValue(); + if (!BIOME_REGISTRY.equals(entry.getKey().registryKey()) + || !CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA.equals(source.sourceSchema())) { + continue; + } + IrisBiomeCustom frozenBiome = customBiomeFromSemantic(entry.getKey(), source.semanticJson()); + for (IrisBiomeCustomSpawn spawn : frozenBiome.getSpawns()) { + entities.add(spawn.getTypeKey()); + } + } + includedEntities = Set.copyOf(entities); + } + + public synchronized String resolve(String dimensionKey, IrisBiomeCustom customBiome) throws IOException { + String dimension = requireText(dimensionKey, "Dimension load key").toLowerCase(Locale.ROOT); + IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); + Map dimensionKeys = resolved.computeIfAbsent( + dimension, + ignored -> new IdentityHashMap<>() + ); + String existing = dimensionKeys.get(requiredBiome); + if (existing != null) { + return existing; + } + String semanticFingerprint = fingerprintCustomBiomeAuthoredDefinition( + customBiomeSemanticJson(requiredBiome, includedEntities::contains) + ); + String resourceKey = resourceKeyFactory.apply(fingerprintCustomBiomeIdentity( + semanticFingerprint, + dimension, + requiredBiome.getId() + )); + GenerationRegistryContract.PhysicalResourceKey physicalKey = key(BIOME_REGISTRY, resourceKey); + GenerationRegistryContract.GeneratedSource source = contract.generatedSources().get(physicalKey); + if (source == null || !semanticFingerprint.equals(source.semanticSha256())) { + throw new IOException("Historical generation registry contract has no exact custom biome definition for " + + dimension + ":" + requiredBiome.getId() + "."); + } + requireGeneratedSemanticFingerprint(physicalKey, source, contract.definitions().get(physicalKey)); + dimensionKeys.put(requiredBiome, resourceKey); + return resourceKey; + } + } + + public record CustomBiomeDefinition( + GenerationRegistryContract.PhysicalResourceKey physicalKey, + String identityFingerprint, + String semanticFingerprint, + String semanticJson, + String contentFingerprint, + String sourceJson, + PlatformGenerationRegistry.Definition definition + ) { + public CustomBiomeDefinition { + physicalKey = Objects.requireNonNull(physicalKey, "physicalKey"); + if (!BIOME_REGISTRY.equals(physicalKey.registryKey())) { + throw new IllegalArgumentException("Custom biome definition must use the biome registry."); + } + identityFingerprint = requireSha256( + identityFingerprint, + "Custom biome identity fingerprint" + ); + semanticFingerprint = requireSha256( + semanticFingerprint, + "Custom biome semantic fingerprint" + ); + semanticJson = requireText(semanticJson, "Custom biome semantic JSON"); + contentFingerprint = requireSha256(contentFingerprint, "Custom biome content fingerprint"); + sourceJson = requireText(sourceJson, "Custom biome source JSON"); + definition = Objects.requireNonNull(definition, "definition"); + } + } + + private record ReferencedPlatformKeys(Set blockKeys, Set entityKeys) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java new file mode 100644 index 000000000..4d2fb1b78 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java @@ -0,0 +1,224 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.StructurePlacementMarker; +import art.arcane.iris.engine.framework.StructurePlacementScope; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public final class GenerationSemanticCapture { + private static final int CHUNK_SIZE = 16; + + private GenerationSemanticCapture() { + } + + public static ChunkGenerationSemantics capture( + Engine engine, + GenerationHistory.GenerationStage stage + ) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + GenerationHistory.GenerationStage requiredStage = Objects.requireNonNull(stage, "stage"); + return capture( + requiredEngine, + requiredStage.chunkX(), + requiredStage.chunkZ(), + requiredStage.activation().activationId() + ); + } + + public static ChunkGenerationSemantics capture( + Engine engine, + int chunkX, + int chunkZ, + long activationId + ) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + ChunkGenerationSemantics.Builder semantics = ChunkGenerationSemantics.builder( + chunkX, + chunkZ, + activationId + ); + captureColumns(requiredEngine, chunkX, chunkZ, semantics); + captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics); + captureStructures(requiredEngine, chunkX, chunkZ, semantics); + return semantics.seal().build(); + } + + private static void captureColumns( + Engine engine, + int chunkX, + int chunkZ, + ChunkGenerationSemantics.Builder semantics + ) { + IrisComplex complex = engine.getComplex(); + int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE); + int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); + for (int localX = 0; localX < CHUNK_SIZE; localX++) { + int blockX = minimumX + localX; + for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { + int blockZ = minimumZ + localZ; + if (complex.allowsNewDiscreteContentAt(blockX, blockZ)) { + IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ); + if (biome != null && biome.getLoadKey() != null) { + semantics.addSurfaceBiome(biome.getLoadKey()); + } + IrisRegion region = complex.getRegionStream().get(blockX, blockZ); + if (region != null && region.getLoadKey() != null) { + semantics.addRegion(region.getLoadKey()); + } + } + if (!hasFullHydrologyWeight(complex, blockX, blockZ)) { + continue; + } + HydrologyColumnSample hydrology = complex.sampleHydrologyColumn(blockX, blockZ); + if (hydrology == null) { + continue; + } + for (HydrologyColumnLayer layer : hydrology.layers()) { + semantics.addRiverProfile(layer.profileKey()); + semantics.addRiverFeature( + layer.profileKey(), + layer.feature().type(), + layer.feature().id(), + layer.feature().x(), + Math.addExact( + layer.feature().y(), + engine.getDimension().getMinHeight() + ), + layer.feature().z() + ); + } + } + } + } + + private static void captureMantleFacts( + Engine engine, + int chunkX, + int chunkZ, + ChunkGenerationSemantics.Builder semantics + ) { + Mantle mantle = engine.getMantle().getMantle(); + int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE); + int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); + Set resolvedCaves = new HashSet<>(); + mantle.iterateChunk(chunkX, chunkZ, MatterCavern.class, (localX, y, localZ, cavern) -> { + captureCave( + engine, + semantics, + resolvedCaves, + minimumX + localX, + y, + minimumZ + localZ, + cavern == null ? null : cavern.getCustomBiome() + ); + }); + mantle.iterateChunk(chunkX, chunkZ, HydrologyCaveCell.class, (localX, y, localZ, cell) -> { + int blockX = minimumX + localX; + int blockZ = minimumZ + localZ; + if (!hasFullHydrologyWeight(engine.getComplex(), blockX, blockZ)) { + return; + } + captureCave( + engine, + semantics, + resolvedCaves, + blockX, + y, + blockZ, + cell == null ? null : cell.floodedBiomeKey() + ); + if (cell != null && cell.fluidProfileKey() != null && !cell.fluidProfileKey().isBlank()) { + semantics.addRiverProfile(cell.fluidProfileKey()); + } + }); + mantle.iterateChunk(chunkX, chunkZ, String.class, (localX, y, localZ, marker) -> { + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + if (decoded != null) { + semantics.addObject(decoded.objectKey()); + } + }); + } + + private static boolean hasFullHydrologyWeight(IrisComplex complex, int blockX, int blockZ) { + TransitionGenerationPlan transition = complex.getTransitionGenerationPlan(); + return transition == null || transition.hydrologyWeightAt(blockX, blockZ) == 1D; + } + + private static void captureCave( + Engine engine, + ChunkGenerationSemantics.Builder semantics, + Set resolvedCaves, + int blockX, + int y, + int blockZ, + String explicitBiomeKey + ) { + CavePosition position = new CavePosition(blockX, y, blockZ); + if (!resolvedCaves.add(position)) { + return; + } + if (explicitBiomeKey != null && !explicitBiomeKey.isBlank()) { + semantics.addCaveBiome(explicitBiomeKey); + return; + } + IrisBiome caveBiome = engine.getCaveBiome(blockX, y, blockZ); + if (caveBiome != null && caveBiome.getLoadKey() != null) { + semantics.addCaveBiome(caveBiome.getLoadKey()); + } + } + + private static void captureStructures( + Engine engine, + int chunkX, + int chunkZ, + ChunkGenerationSemantics.Builder semantics + ) { + IrisComplex complex = engine.getComplex(); + Mantle mantle = engine.getMantle().getMantle(); + if (!complex.allowsNewGenerationChunk(chunkX, chunkZ) + || !mantle.hasFlag(chunkX, chunkZ, ReservedFlag.JIGSAW)) { + return; + } + for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt( + engine, + chunkX, + chunkZ)) { + if (placement == null || !placement.hasIrisStructures()) { + continue; + } + IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement( + engine, + placement, + chunkX, + chunkZ + ); + if (resolved == null || !IrisStructureLocator.allowsResolvedFootprint(engine, resolved)) { + continue; + } + semantics.addStructure( + resolved.structureKey(), + resolved.originX(), + resolved.baseY(), + resolved.originZ() + ); + } + } + + private record CavePosition(int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java new file mode 100644 index 000000000..7e86834b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java @@ -0,0 +1,2681 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.CRC32; + +public final class GenerationSemanticIndex { + private static final String FILE_SUFFIX = ".isem"; + private static final String JOURNAL_SUFFIX = ".iswal"; + private static final String CATALOG_FILE_NAME = "index.isix"; + private static final int MAGIC = 0x4953454D; + private static final int FORMAT_VERSION = 5; + private static final int CATALOG_MAGIC = 0x49534958; + private static final int CATALOG_FORMAT_VERSION = 1; + private static final int CATALOG_FIXED_BODY_BYTES = 12; + private static final int FIXED_BODY_BYTES = 28; + private static final int CHECKSUM_BYTES = Integer.BYTES; + private static final int MAX_FILE_BYTES = 64 * 1_024 * 1_024; + private static final long MAX_JOURNAL_BYTES = 512L * 1_024L * 1_024L; + private static final long JOURNAL_COMPACTION_BYTES = 4L * 1_024L * 1_024L; + private static final int JOURNAL_COMPACTION_ENTRIES = 256; + private static final int MAXIMUM_CACHED_REGIONS = 64; + private static final int MAXIMUM_CACHED_SUMMARIES = 256; + private static final int MAX_CATALOG_BYTES = 64 * 1_024 * 1_024; + private static final int MAX_PALETTE_KEYS = 65_535; + private static final int CHUNKS_PER_REGION_SIDE = 32; + private static final int CHUNKS_PER_REGION = CHUNKS_PER_REGION_SIDE * CHUNKS_PER_REGION_SIDE; + private static final int MINIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MIN_VALUE, CHUNKS_PER_REGION_SIDE); + private static final int MAXIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MAX_VALUE, CHUNKS_PER_REGION_SIDE); + private static final Pattern SHARD_FILE_PATTERN = Pattern.compile( + "r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.([0-9a-f]{64})\\.isem" + ); + private static final Pattern POINTER_FILE_PATTERN = Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.isix"); + private static final Pattern JOURNAL_FILE_PATTERN = Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.iswal"); + + private final Path dimensionRoot; + private final Path directory; + private final LinkedHashMap regions; + private final LinkedHashMap summaries; + private final Long2ObjectOpenHashMap shardHashes; + private final Long2IntOpenHashMap journalEntries; + private final Long2IntOpenHashMap regionRecordCounts; + private final ReentrantReadWriteLock lock; + private final ShardPublisher publisher; + private final PointerPublisher pointerPublisher; + private final CatalogPublisher catalogPublisher; + private long regionDecodeCount; + + private GenerationSemanticIndex( + Path dimensionRoot, + ShardPublisher publisher, + PointerPublisher pointerPublisher, + CatalogPublisher catalogPublisher + ) { + this.dimensionRoot = dimensionRoot.toAbsolutePath().normalize(); + directory = this.dimensionRoot.resolve("iris").resolve("generation").resolve("semantics"); + regions = new LinkedHashMap<>(MAXIMUM_CACHED_REGIONS, 0.75F, true); + summaries = new LinkedHashMap<>(MAXIMUM_CACHED_SUMMARIES, 0.75F, true); + shardHashes = new Long2ObjectOpenHashMap<>(); + journalEntries = new Long2IntOpenHashMap(); + regionRecordCounts = new Long2IntOpenHashMap(); + lock = new ReentrantReadWriteLock(); + this.publisher = publisher; + this.pointerPublisher = pointerPublisher; + this.catalogPublisher = catalogPublisher; + } + + public static GenerationSemanticIndex load(Path dimensionRoot) throws IOException { + return load(dimensionRoot, RegionShard::publish, ShardPointer::publish, Catalog::publish); + } + + public static GenerationSemanticIndex initialize(Path dimensionRoot) throws IOException { + GenerationSemanticIndex index = new GenerationSemanticIndex( + Objects.requireNonNull(dimensionRoot, "dimensionRoot"), + RegionShard::publish, + ShardPointer::publish, + Catalog::publish + ); + index.ensureStorageDirectory(); + Path catalog = index.directory.resolve(CATALOG_FILE_NAME); + if (!Files.exists(catalog, LinkOption.NOFOLLOW_LINKS)) { + Catalog.publish(index.directory, Set.of()); + } + index.loadShards(); + return index; + } + + public static GenerationSemanticIndex loadRequired(Path dimensionRoot) throws IOException { + GenerationSemanticIndex index = load(dimensionRoot); + Path catalog = index.directory.resolve(CATALOG_FILE_NAME); + if (!Files.isRegularFile(catalog, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic shard catalog is missing: " + catalog); + } + return index; + } + + static GenerationSemanticIndex load(Path dimensionRoot, ShardPublisher publisher) throws IOException { + return load(dimensionRoot, publisher, ShardPointer::publish, Catalog::publish); + } + + static GenerationSemanticIndex loadWithCatalogPublisher( + Path dimensionRoot, + CatalogPublisher catalogPublisher + ) throws IOException { + return load(dimensionRoot, RegionShard::publish, ShardPointer::publish, catalogPublisher); + } + + static GenerationSemanticIndex load( + Path dimensionRoot, + ShardPublisher publisher, + PointerPublisher pointerPublisher, + CatalogPublisher catalogPublisher + ) throws IOException { + GenerationSemanticIndex index = new GenerationSemanticIndex( + Objects.requireNonNull(dimensionRoot, "dimensionRoot"), + Objects.requireNonNull(publisher, "publisher"), + Objects.requireNonNull(pointerPublisher, "pointerPublisher"), + Objects.requireNonNull(catalogPublisher, "catalogPublisher") + ); + index.loadShards(); + return index; + } + + public Path storageDirectory() { + return directory; + } + + public boolean claimAndPersist(ChunkGenerationSemantics claim) throws IOException { + ChunkGenerationSemantics requiredClaim = Objects.requireNonNull(claim, "claim"); + if (!requiredClaim.sealed()) { + throw new IllegalArgumentException("Generation semantic claims must be sealed."); + } + lock.writeLock().lock(); + try { + ChunkGenerationSemantics existing = getRecordLocked( + requiredClaim.chunkX(), + requiredClaim.chunkZ() + ); + ChunkGenerationSemantics merged = existing == null + ? requiredClaim + : existing.merge(requiredClaim); + if (merged == existing || merged.equals(existing)) { + return false; + } + int regionX = requiredClaim.chunkX() >> 5; + int regionZ = requiredClaim.chunkZ() >> 5; + long regionKey = packRegion(regionX, regionZ); + RegionShard baseRegion = loadRegionLocked(regionKey, regionX, regionZ); + RegionShard nextRegion = baseRegion.withRecord(merged); + ensureStorageDirectory(); + SemanticJournal.append(directory, regionX, regionZ, merged); + cacheRegion(regionKey, nextRegion); + if (existing == null) { + regionRecordCounts.addTo(regionKey, 1); + } + journalEntries.addTo(regionKey, 1); + return true; + } finally { + lock.writeLock().unlock(); + } + } + + public void compactJournals() throws IOException { + lock.writeLock().lock(); + try { + ArrayList regionKeys = new ArrayList<>(journalEntries.keySet()); + regionKeys.sort(GenerationSemanticIndex::compareRegionKeys); + for (long regionKey : regionKeys) { + compactJournal(regionKey); + } + } finally { + lock.writeLock().unlock(); + } + } + + public void forEachSealedClaim(long activationId, SealedClaimConsumer consumer) throws IOException { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive."); + } + SealedClaimConsumer requiredConsumer = Objects.requireNonNull(consumer, "sealed claim consumer"); + lock.writeLock().lock(); + try { + for (long regionKey : allRegionKeys()) { + if (!loadSummaryLocked(regionKey).hasSealedActivation(activationId)) { + continue; + } + RegionShard region = loadRegionLocked( + regionKey, + regionX(regionKey), + regionZ(regionKey) + ); + for (ChunkGenerationSemantics semantics : region.records()) { + if (semantics.sealed() && semantics.activationId() == activationId) { + requiredConsumer.accept(semantics.chunkX(), semantics.chunkZ()); + } + } + } + } finally { + lock.writeLock().unlock(); + } + } + + public boolean hasSealedClaim(int chunkX, int chunkZ, long activationId) { + lock.writeLock().lock(); + try { + ChunkGenerationSemantics semantics = getRecordLocked(chunkX, chunkZ); + return semantics != null + && semantics.sealed() + && semantics.activationId() == activationId; + } catch (IOException error) { + throw new IllegalStateException("Unable to resolve a sealed generation semantic claim.", error); + } finally { + lock.writeLock().unlock(); + } + } + + public boolean recordAndPersist(ChunkGenerationSemantics update) throws IOException { + ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update"); + lock.writeLock().lock(); + try { + ChunkGenerationSemantics existing = getRecordLocked( + requiredUpdate.chunkX(), + requiredUpdate.chunkZ() + ); + ChunkGenerationSemantics merged = existing == null ? requiredUpdate : existing.merge(requiredUpdate); + if (merged == existing || merged.equals(existing)) { + return false; + } + + int regionX = requiredUpdate.chunkX() >> 5; + int regionZ = requiredUpdate.chunkZ() >> 5; + long regionKey = packRegion(regionX, regionZ); + RegionShard baseRegion = loadRegionLocked(regionKey, regionX, regionZ); + RegionShard nextRegion = baseRegion.withRecord(merged); + byte[] encoded = nextRegion.encode(); + String shardHash = sha256(encoded); + ensureStorageDirectory(); + publisher.publish(directory, regionX, regionZ, encoded); + pointerPublisher.publish(directory, regionX, regionZ, shardHash); + String previousHash = shardHashes.get(regionKey); + if (previousHash == null) { + TreeSet nextRegions = new TreeSet<>(GenerationSemanticIndex::compareRegionKeys); + nextRegions.addAll(shardHashes.keySet()); + nextRegions.add(regionKey); + catalogPublisher.publish(directory, nextRegions); + } + + cacheRegion(regionKey, nextRegion); + if (existing == null) { + regionRecordCounts.addTo(regionKey, 1); + } + shardHashes.put(regionKey, shardHash); + deleteUnreferencedShard(regionX, regionZ, previousHash, shardHash); + return true; + } finally { + lock.writeLock().unlock(); + } + } + + public Optional get(int chunkX, int chunkZ) { + lock.writeLock().lock(); + try { + return Optional.ofNullable(getRecordLocked(chunkX, chunkZ)); + } catch (IOException error) { + throw new IllegalStateException("Unable to load generation semantics for chunk " + + chunkX + "," + chunkZ + ".", error); + } finally { + lock.writeLock().unlock(); + } + } + + public int recordCount() { + lock.readLock().lock(); + try { + return totalRecordCountLocked(); + } finally { + lock.readLock().unlock(); + } + } + + public List recordsSnapshot() { + lock.writeLock().lock(); + try { + List snapshot = new ArrayList<>(totalRecordCountLocked()); + for (long regionKey : allRegionKeys()) { + RegionShard region = loadRegionLocked( + regionKey, + regionX(regionKey), + regionZ(regionKey) + ); + snapshot.addAll(region.records()); + } + snapshot.sort(Comparator.comparingInt(ChunkGenerationSemantics::chunkX) + .thenComparingInt(ChunkGenerationSemantics::chunkZ)); + return List.copyOf(snapshot); + } catch (IOException error) { + throw new IllegalStateException("Unable to load generation semantic snapshot.", error); + } finally { + lock.writeLock().unlock(); + } + } + + public void forEachRecord(RecordConsumer consumer) throws IOException { + RecordConsumer requiredConsumer = Objects.requireNonNull(consumer, "consumer"); + lock.writeLock().lock(); + try { + for (long regionKey : allRegionKeys()) { + RegionShard region = loadRegionLocked( + regionKey, + regionX(regionKey), + regionZ(regionKey) + ); + for (ChunkGenerationSemantics semantics : region.records()) { + requiredConsumer.accept(semantics); + } + } + } finally { + lock.writeLock().unlock(); + } + } + + int cachedRegionCount() { + lock.readLock().lock(); + try { + return regions.size(); + } finally { + lock.readLock().unlock(); + } + } + + long regionDecodeCount() { + lock.readLock().lock(); + try { + return regionDecodeCount; + } finally { + lock.readLock().unlock(); + } + } + + int cachedSummaryCount() { + lock.readLock().lock(); + try { + return summaries.size(); + } finally { + lock.readLock().unlock(); + } + } + + public Optional findNearest(Query query) { + return findNearest(query, semantics -> true); + } + + public Optional findNearest( + Query query, + Predicate eligibility + ) { + Query requiredQuery = Objects.requireNonNull(query, "query"); + Predicate requiredEligibility = Objects.requireNonNull( + eligibility, + "eligibility" + ); + lock.writeLock().lock(); + try { + if (requiredQuery.kind() == SemanticKind.STRUCTURE) { + return findNearestStructure(requiredQuery, requiredEligibility); + } + return findNearestChunk(requiredQuery, requiredEligibility); + } finally { + lock.writeLock().unlock(); + } + } + + public Optional findNearestRiver( + RiverQuery query, + Predicate eligibility + ) { + RiverQuery requiredQuery = Objects.requireNonNull(query, "query"); + Predicate requiredEligibility = Objects.requireNonNull( + eligibility, + "eligibility" + ); + lock.writeLock().lock(); + try { + RiverMatch best = null; + BigInteger bestDistance = null; + int originChunkX = Math.floorDiv(requiredQuery.origin().x(), 16); + int originChunkZ = Math.floorDiv(requiredQuery.origin().z(), 16); + List candidateRegions = orderedCandidateRegions( + requiredQuery.origin(), + requiredQuery.maxChunkRadius(), + true + ); + for (RegionCandidate candidateRegion : candidateRegions) { + if (best != null && candidateRegion.blockDistanceSquared().compareTo(bestDistance) > 0) { + break; + } + RegionSummary summary = loadSummaryLocked(candidateRegion.regionKey()); + if (!summary.hasAnyRiverType(requiredQuery.types()) + || requiredQuery.profileKey() != null + && !summary.keys(SemanticKind.RIVER_PROFILE).contains(requiredQuery.profileKey()) + || !summary.mayContainActivation(requiredQuery.activationFilter())) { + continue; + } + RegionShard region = loadRegionLocked( + candidateRegion.regionKey(), + regionX(candidateRegion.regionKey()), + regionZ(candidateRegion.regionKey()) + ); + for (ChunkGenerationSemantics semantics : region.records()) { + if (!requiredEligibility.test(semantics) + || !matchesActivation(semantics.activationId(), requiredQuery.activationFilter())) { + continue; + } + for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : semantics.riverFeatures()) { + if (!requiredQuery.types().contains(occurrence.type()) + || requiredQuery.profileKey() != null + && !requiredQuery.profileKey().equals(occurrence.profileKey())) { + continue; + } + ChunkGenerationSemantics.BlockPosition position = occurrence.position(); + long deltaChunkX = (long) Math.floorDiv(position.x(), 16) - originChunkX; + long deltaChunkZ = (long) Math.floorDiv(position.z(), 16) - originChunkZ; + if (!withinRadius(deltaChunkX, deltaChunkZ, requiredQuery.maxChunkRadius())) { + continue; + } + BigInteger distance = horizontalDistanceSquared(requiredQuery.origin(), position); + RiverMatch candidate = new RiverMatch( + occurrence, + new ChunkReference( + semantics.chunkX(), + semantics.chunkZ(), + semantics.activationId() + ) + ); + if (best == null || compareRiverCandidate( + candidate, + distance, + best, + bestDistance + ) < 0) { + best = candidate; + bestDistance = distance; + } + } + } + } + return Optional.ofNullable(best); + } catch (IOException error) { + throw new IllegalStateException("Unable to search recorded river semantics.", error); + } finally { + lock.writeLock().unlock(); + } + } + + private Optional findNearestChunk( + Query query, + Predicate eligibility + ) { + int originChunkX = Math.floorDiv(query.origin().x(), 16); + int originChunkZ = Math.floorDiv(query.origin().z(), 16); + ChunkCandidate best = null; + BigInteger bestDistance = null; + try { + List candidateRegions = orderedCandidateRegions( + query.origin(), + query.maxChunkRadius(), + false + ); + for (RegionCandidate candidateRegion : candidateRegions) { + if (best != null && candidateRegion.chunkDistanceSquared().compareTo(bestDistance) > 0) { + break; + } + RegionSummary summary = loadSummaryLocked(candidateRegion.regionKey()); + if (!summary.keys(query.kind()).contains(query.key()) + || !summary.mayContainActivation(query.activationFilter())) { + continue; + } + RegionShard region = loadRegionLocked( + candidateRegion.regionKey(), + regionX(candidateRegion.regionKey()), + regionZ(candidateRegion.regionKey()) + ); + for (ChunkGenerationSemantics semantics : region.records()) { + if (!matchesActivation(semantics.activationId(), query.activationFilter()) + || !eligibility.test(semantics) + || !keys(semantics, query.kind()).contains(query.key())) { + continue; + } + long deltaX = (long) semantics.chunkX() - originChunkX; + long deltaZ = (long) semantics.chunkZ() - originChunkZ; + if (!withinRadius(deltaX, deltaZ, query.maxChunkRadius())) { + continue; + } + BigInteger distance = squared(deltaX).add(squared(deltaZ)); + if (best == null || compareChunkCandidate( + semantics.chunkX(), + semantics.chunkZ(), + semantics.activationId(), + distance, + best, + bestDistance + ) < 0) { + best = new ChunkCandidate( + semantics.chunkX(), + semantics.chunkZ(), + semantics.activationId() + ); + bestDistance = distance; + } + } + } + } catch (IOException error) { + throw new IllegalStateException("Unable to search recorded chunk semantics.", error); + } + if (best == null) { + return Optional.empty(); + } + ChunkReference chunk = new ChunkReference( + best.chunkX(), + best.chunkZ(), + best.activationId() + ); + return Optional.of(new Match(query.kind(), query.key(), chunk, Optional.empty())); + } + + private Optional findNearestStructure( + Query query, + Predicate eligibility + ) { + int originChunkX = Math.floorDiv(query.origin().x(), 16); + int originChunkZ = Math.floorDiv(query.origin().z(), 16); + StructureCandidate best = null; + BigInteger bestDistance = null; + try { + List candidateRegions = orderedCandidateRegions( + query.origin(), + query.maxChunkRadius(), + true + ); + for (RegionCandidate candidateRegion : candidateRegions) { + if (best != null && candidateRegion.blockDistanceSquared().compareTo(bestDistance) > 0) { + break; + } + RegionSummary summary = loadSummaryLocked(candidateRegion.regionKey()); + if (!summary.keys(SemanticKind.STRUCTURE).contains(query.key()) + || !summary.mayContainActivation(query.activationFilter())) { + continue; + } + RegionShard region = loadRegionLocked( + candidateRegion.regionKey(), + regionX(candidateRegion.regionKey()), + regionZ(candidateRegion.regionKey()) + ); + for (ChunkGenerationSemantics semantics : region.records()) { + if (!eligibility.test(semantics) + || !matchesActivation(semantics.activationId(), query.activationFilter())) { + continue; + } + for (ChunkGenerationSemantics.StructureOccurrence occurrence : semantics.structures()) { + if (!query.key().equals(occurrence.key())) { + continue; + } + ChunkGenerationSemantics.BlockPosition position = occurrence.position(); + long deltaChunkX = (long) Math.floorDiv(position.x(), 16) - originChunkX; + long deltaChunkZ = (long) Math.floorDiv(position.z(), 16) - originChunkZ; + if (!withinRadius(deltaChunkX, deltaChunkZ, query.maxChunkRadius())) { + continue; + } + StructureCandidate candidate = new StructureCandidate( + semantics.chunkX(), + semantics.chunkZ(), + semantics.activationId(), + occurrence + ); + BigInteger distance = horizontalDistanceSquared(query.origin(), position); + if (best == null || compareStructureCandidate(candidate, distance, best, bestDistance) < 0) { + best = candidate; + bestDistance = distance; + } + } + } + } + } catch (IOException error) { + throw new IllegalStateException("Unable to search recorded structure semantics.", error); + } + if (best == null) { + return Optional.empty(); + } + ChunkReference chunk = new ChunkReference( + best.chunkX(), + best.chunkZ(), + best.activationId() + ); + return Optional.of(new Match( + SemanticKind.STRUCTURE, + query.key(), + chunk, + Optional.of(best.occurrence().position()) + )); + } + + private void loadShards() throws IOException { + validateDimensionRoot(); + validateStorageAncestors(); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic index path is not a safe directory: " + directory); + } + + List shardFiles = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*" + FILE_SUFFIX)) { + for (Path file : stream) { + shardFiles.add(file); + } + } + shardFiles.sort(Comparator.comparing(path -> path.getFileName().toString())); + for (Path file : shardFiles) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic shard is not a regular file: " + file); + } + validateShardFileName(file); + } + List pointerFiles = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "r.*.isix")) { + for (Path file : stream) { + pointerFiles.add(file); + } + } + pointerFiles.sort(Comparator.comparing(path -> path.getFileName().toString())); + for (Path file : pointerFiles) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic shard pointer is not a regular file: " + file); + } + validatePointerFileName(file); + } + + Long2ObjectOpenHashMap journals = discoverJournals(); + Path catalogFile = directory.resolve(CATALOG_FILE_NAME); + if (!Files.exists(catalogFile, LinkOption.NOFOLLOW_LINKS)) { + if (!shardFiles.isEmpty() || !pointerFiles.isEmpty()) { + throw new IOException("Generation semantic shard catalog is missing: " + catalogFile); + } + loadRegions(journals); + return; + } + if (!Files.isRegularFile(catalogFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic shard catalog is not a regular file: " + catalogFile); + } + + Set catalog = Catalog.read(catalogFile); + for (long regionKey : catalog) { + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + Path pointerFile = directory.resolve(pointerFileName(regionX, regionZ)); + if (!Files.isRegularFile(pointerFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic catalog references a missing shard pointer: " + pointerFile); + } + String shardHash = ShardPointer.read(pointerFile, regionX, regionZ); + Path file = directory.resolve(fileName(regionX, regionZ, shardHash)); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic catalog references a missing shard: " + file); + } + RegionShard.Metadata metadata = RegionShard.readMetadata(file, shardHash); + if (metadata.regionX() != regionX || metadata.regionZ() != regionZ) { + throw new IOException("Generation semantic shard name does not match its coordinates: " + file); + } + shardHashes.put(regionKey, shardHash); + regionRecordCounts.put(regionKey, metadata.recordCount()); + } + loadRegions(journals); + } + + private Long2ObjectOpenHashMap discoverJournals() throws IOException { + Long2ObjectOpenHashMap journals = new Long2ObjectOpenHashMap<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) { + for (Path file : stream) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic journal is not a regular file: " + file); + } + Matcher matcher = JOURNAL_FILE_PATTERN.matcher(file.getFileName().toString()); + if (!matcher.matches()) { + throw new IOException("Generation semantic journal has a noncanonical file name: " + file); + } + int regionX; + int regionZ; + try { + regionX = Integer.parseInt(matcher.group(1)); + regionZ = Integer.parseInt(matcher.group(2)); + } catch (NumberFormatException error) { + throw new IOException("Generation semantic journal has invalid coordinates: " + file, error); + } + RegionShard.validateRegionCoordinate(file, regionX); + RegionShard.validateRegionCoordinate(file, regionZ); + long regionKey = packRegion(regionX, regionZ); + if (journals.putIfAbsent(regionKey, file) != null) { + throw new IOException("Duplicate generation semantic journal region: " + file); + } + } + } + return journals; + } + + private void loadRegions(Long2ObjectOpenHashMap journals) throws IOException { + ArrayList compact = new ArrayList<>(); + ArrayList ordered = new ArrayList<>(journals.keySet()); + ordered.sort(GenerationSemanticIndex::compareRegionKeys); + for (long regionKey : ordered) { + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + RegionShard region = readBaseRegion(regionKey, regionX, regionZ); + Path journal = journals.get(regionKey); + if (journal != null) { + SemanticJournal.Replay replay = SemanticJournal.replay(journal, regionX, regionZ); + region = replayClaims(journal, region, replay.claims()); + if (replay.entryCount() > 0) { + journalEntries.put(regionKey, replay.entryCount()); + } + if (replay.entryCount() >= JOURNAL_COMPACTION_ENTRIES + || replay.validBytes() >= JOURNAL_COMPACTION_BYTES) { + compact.add(regionKey); + } + } + installLoadedRegion(regionKey, region); + } + for (long regionKey : compact) { + compactJournal(regionKey); + } + regionDecodeCount = 0L; + } + + private RegionShard replayClaims( + Path file, + RegionShard base, + List claims + ) throws IOException { + RegionShard region = base; + for (ChunkGenerationSemantics claim : claims) { + ChunkGenerationSemantics existing = region.get(claim.chunkX(), claim.chunkZ()); + ChunkGenerationSemantics merged; + try { + merged = existing == null ? claim : existing.merge(claim); + } catch (IllegalStateException | IllegalArgumentException error) { + throw new IOException("Generation semantic journal conflicts with existing truth: " + file, error); + } + region = region.withRecord(merged); + } + return region; + } + + private void installLoadedRegion(long regionKey, RegionShard region) throws IOException { + regionRecordCounts.put(regionKey, region.recordCount()); + cacheRegion(regionKey, region); + } + + private void compactJournal(long regionKey) throws IOException { + if (!journalEntries.containsKey(regionKey)) { + return; + } + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + RegionShard region = loadRegionLocked(regionKey, regionX, regionZ); + byte[] encoded = region.encode(); + String shardHash = sha256(encoded); + publisher.publish(directory, regionX, regionZ, encoded); + pointerPublisher.publish(directory, regionX, regionZ, shardHash); + String previousHash = shardHashes.get(regionKey); + if (previousHash == null) { + TreeSet nextRegions = new TreeSet<>(GenerationSemanticIndex::compareRegionKeys); + nextRegions.addAll(shardHashes.keySet()); + nextRegions.add(regionKey); + catalogPublisher.publish(directory, nextRegions); + } + shardHashes.put(regionKey, shardHash); + Path journal = directory.resolve(journalFileName(regionX, regionZ)); + if (Files.deleteIfExists(journal)) { + RegionShard.forceDirectory(directory); + } + journalEntries.remove(regionKey); + deleteUnreferencedShard(regionX, regionZ, previousHash, shardHash); + } + + private ChunkGenerationSemantics getRecordLocked(int chunkX, int chunkZ) throws IOException { + long regionKey = packRegion(chunkX >> 5, chunkZ >> 5); + if (!regionRecordCounts.containsKey(regionKey)) { + return null; + } + return loadRegionLocked(regionKey, chunkX >> 5, chunkZ >> 5).get(chunkX, chunkZ); + } + + private RegionShard loadRegionLocked(long regionKey, int regionX, int regionZ) throws IOException { + RegionShard cached = regions.get(regionKey); + if (cached != null) { + return cached; + } + RegionShard region = readBaseRegion(regionKey, regionX, regionZ); + if (journalEntries.containsKey(regionKey)) { + Path journal = directory.resolve(journalFileName(regionX, regionZ)); + SemanticJournal.Replay replay = SemanticJournal.replay(journal, regionX, regionZ); + region = replayClaims(journal, region, replay.claims()); + } + regionDecodeCount++; + cacheRegion(regionKey, region); + return region; + } + + private RegionSummary loadSummaryLocked(long regionKey) throws IOException { + RegionSummary cached = summaries.get(regionKey); + if (cached != null) { + return cached; + } + RegionShard cachedRegion = regions.get(regionKey); + if (cachedRegion != null) { + RegionSummary summary = RegionSummary.from(cachedRegion); + cacheSummary(regionKey, summary); + return summary; + } + if (journalEntries.containsKey(regionKey)) { + RegionShard region = loadRegionLocked(regionKey, regionX(regionKey), regionZ(regionKey)); + return RegionSummary.from(region); + } + String shardHash = shardHashes.get(regionKey); + if (shardHash == null) { + return RegionSummary.empty(); + } + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + Path file = directory.resolve(fileName(regionX, regionZ, shardHash)); + RegionShard.Summary stored = RegionShard.readSummary(file, shardHash); + if (stored.regionX() != regionX || stored.regionZ() != regionZ) { + throw new IOException("Generation semantic shard name does not match its coordinates: " + file); + } + cacheSummary(regionKey, stored.semantics()); + return stored.semantics(); + } + + private RegionShard readBaseRegion(long regionKey, int regionX, int regionZ) throws IOException { + String shardHash = shardHashes.get(regionKey); + if (shardHash == null) { + return new RegionShard(regionX, regionZ); + } + Path file = directory.resolve(fileName(regionX, regionZ, shardHash)); + RegionShard region = RegionShard.read(file, shardHash); + if (region.regionX() != regionX || region.regionZ() != regionZ) { + throw new IOException("Generation semantic shard name does not match its coordinates: " + file); + } + return region; + } + + private void cacheRegion(long regionKey, RegionShard region) { + regions.put(regionKey, region); + cacheSummary(regionKey, RegionSummary.from(region)); + while (regions.size() > MAXIMUM_CACHED_REGIONS) { + Iterator> entries = regions.entrySet().iterator(); + entries.next(); + entries.remove(); + } + } + + private void cacheSummary(long regionKey, RegionSummary summary) { + summaries.put(regionKey, summary); + while (summaries.size() > MAXIMUM_CACHED_SUMMARIES) { + Iterator> entries = summaries.entrySet().iterator(); + entries.next(); + entries.remove(); + } + } + + private List allRegionKeys() { + ArrayList ordered = new ArrayList<>(regionRecordCounts.keySet()); + ordered.sort(GenerationSemanticIndex::compareRegionKeys); + return ordered; + } + + private int totalRecordCountLocked() { + int total = 0; + for (int count : regionRecordCounts.values()) { + total = Math.addExact(total, count); + } + return total; + } + + private static void validateShardFileName(Path file) throws IOException { + String name = file.getFileName().toString(); + Matcher matcher = SHARD_FILE_PATTERN.matcher(name); + if (!matcher.matches()) { + throw new IOException("Generation semantic shard has a noncanonical file name: " + file); + } + try { + int regionX = Integer.parseInt(matcher.group(1)); + int regionZ = Integer.parseInt(matcher.group(2)); + RegionShard.validateRegionCoordinate(file, regionX); + RegionShard.validateRegionCoordinate(file, regionZ); + if (!name.equals(fileName(regionX, regionZ, matcher.group(3)))) { + throw new IOException("Generation semantic shard has a noncanonical file name: " + file); + } + } catch (NumberFormatException error) { + throw new IOException("Generation semantic shard has a noncanonical file name: " + file, error); + } + } + + private static void validatePointerFileName(Path file) throws IOException { + String name = file.getFileName().toString(); + Matcher matcher = POINTER_FILE_PATTERN.matcher(name); + if (!matcher.matches()) { + throw new IOException("Generation semantic shard pointer has a noncanonical file name: " + file); + } + try { + int regionX = Integer.parseInt(matcher.group(1)); + int regionZ = Integer.parseInt(matcher.group(2)); + RegionShard.validateRegionCoordinate(file, regionX); + RegionShard.validateRegionCoordinate(file, regionZ); + if (!name.equals(pointerFileName(regionX, regionZ))) { + throw new IOException("Generation semantic shard pointer has a noncanonical file name: " + file); + } + } catch (NumberFormatException error) { + throw new IOException("Generation semantic shard pointer has a noncanonical file name: " + file, error); + } + } + + private static Set keys(ChunkGenerationSemantics semantics, SemanticKind kind) { + return switch (kind) { + case SURFACE_BIOME -> semantics.surfaceBiomeKeys(); + case CAVE_BIOME -> semantics.caveBiomeKeys(); + case REGION -> semantics.regionKeys(); + case RIVER_PROFILE -> semantics.riverProfileKeys(); + case OBJECT -> semantics.objectKeys(); + case STRUCTURE -> throw new IllegalArgumentException("Structure keys require exact positions"); + }; + } + + private void validateDimensionRoot() throws IOException { + if (!Files.isDirectory(dimensionRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Dimension root is not a safe directory: " + dimensionRoot); + } + } + + private void validateStorageAncestors() throws IOException { + Path irisDirectory = dimensionRoot.resolve("iris"); + if (!validateExistingDirectory(irisDirectory)) { + return; + } + Path generationDirectory = irisDirectory.resolve("generation"); + if (!validateExistingDirectory(generationDirectory)) { + return; + } + validateExistingDirectory(generationDirectory.resolve("semantics")); + } + + private static boolean validateExistingDirectory(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic index path is not a safe directory: " + path); + } + return true; + } + + private void ensureStorageDirectory() throws IOException { + validateDimensionRoot(); + Path irisDirectory = ensureChildDirectory(dimensionRoot, "iris"); + Path generationDirectory = ensureChildDirectory(irisDirectory, "generation"); + Path semanticsDirectory = ensureChildDirectory(generationDirectory, "semantics"); + if (!semanticsDirectory.equals(directory)) { + throw new IOException("Generation semantic index resolved outside its storage path: " + semanticsDirectory); + } + } + + private static Path ensureChildDirectory(Path parent, String name) throws IOException { + Path child = parent.resolve(name); + if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic index path is not a safe directory: " + child); + } + return child; + } + Files.createDirectory(child); + if (!Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic index path is not a safe directory: " + child); + } + return child; + } + + private static int compareChunkCandidate( + int candidateChunkX, + int candidateChunkZ, + long candidateActivationId, + BigInteger candidateDistance, + ChunkCandidate current, + BigInteger currentDistance + ) { + int distanceComparison = candidateDistance.compareTo(currentDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + int xComparison = Integer.compare(candidateChunkX, current.chunkX()); + if (xComparison != 0) { + return xComparison; + } + int zComparison = Integer.compare(candidateChunkZ, current.chunkZ()); + if (zComparison != 0) { + return zComparison; + } + return Long.compare(candidateActivationId, current.activationId()); + } + + private static int compareStructureCandidate( + StructureCandidate candidate, + BigInteger candidateDistance, + StructureCandidate current, + BigInteger currentDistance + ) { + int distanceComparison = candidateDistance.compareTo(currentDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + ChunkGenerationSemantics.BlockPosition candidatePosition = candidate.occurrence().position(); + ChunkGenerationSemantics.BlockPosition currentPosition = current.occurrence().position(); + int xComparison = Integer.compare(candidatePosition.x(), currentPosition.x()); + if (xComparison != 0) { + return xComparison; + } + int zComparison = Integer.compare(candidatePosition.z(), currentPosition.z()); + if (zComparison != 0) { + return zComparison; + } + int yComparison = Integer.compare(candidatePosition.y(), currentPosition.y()); + if (yComparison != 0) { + return yComparison; + } + int chunkXComparison = Integer.compare(candidate.chunkX(), current.chunkX()); + if (chunkXComparison != 0) { + return chunkXComparison; + } + int chunkZComparison = Integer.compare(candidate.chunkZ(), current.chunkZ()); + if (chunkZComparison != 0) { + return chunkZComparison; + } + return Long.compare(candidate.activationId(), current.activationId()); + } + + private static int compareRiverCandidate( + RiverMatch candidate, + BigInteger candidateDistance, + RiverMatch current, + BigInteger currentDistance + ) { + int distanceComparison = candidateDistance.compareTo(currentDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + int occurrenceComparison = ChunkGenerationSemantics.riverFeatureComparator().compare( + candidate.occurrence(), + current.occurrence() + ); + if (occurrenceComparison != 0) { + return occurrenceComparison; + } + int chunkXComparison = Integer.compare(candidate.chunk().chunkX(), current.chunk().chunkX()); + if (chunkXComparison != 0) { + return chunkXComparison; + } + int chunkZComparison = Integer.compare(candidate.chunk().chunkZ(), current.chunk().chunkZ()); + if (chunkZComparison != 0) { + return chunkZComparison; + } + return Long.compare(candidate.chunk().activationId(), current.chunk().activationId()); + } + + private List orderedCandidateRegions( + ChunkGenerationSemantics.BlockPosition origin, + int maximumChunkRadius, + boolean orderByBlockDistance + ) { + int originChunkX = Math.floorDiv(origin.x(), 16); + int originChunkZ = Math.floorDiv(origin.z(), 16); + long minimumChunkX = (long) originChunkX - maximumChunkRadius; + long maximumChunkX = (long) originChunkX + maximumChunkRadius; + long minimumChunkZ = (long) originChunkZ - maximumChunkRadius; + long maximumChunkZ = (long) originChunkZ + maximumChunkRadius; + List regionKeys = allRegionKeys(); + ArrayList candidates = new ArrayList<>(regionKeys.size()); + for (long regionKey : regionKeys) { + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + long regionMinimumChunkX = (long) regionX * CHUNKS_PER_REGION_SIDE; + long regionMaximumChunkX = regionMinimumChunkX + CHUNKS_PER_REGION_SIDE - 1L; + long regionMinimumChunkZ = (long) regionZ * CHUNKS_PER_REGION_SIDE; + long regionMaximumChunkZ = regionMinimumChunkZ + CHUNKS_PER_REGION_SIDE - 1L; + if (regionMaximumChunkX < minimumChunkX || regionMinimumChunkX > maximumChunkX + || regionMaximumChunkZ < minimumChunkZ || regionMinimumChunkZ > maximumChunkZ) { + continue; + } + candidates.add(new RegionCandidate( + regionKey, + distanceSquaredToRegion( + originChunkX, + originChunkZ, + regionMinimumChunkX, + regionMaximumChunkX, + regionMinimumChunkZ, + regionMaximumChunkZ + ), + distanceSquaredToRegion( + origin.x(), + origin.z(), + regionMinimumChunkX * 16L, + regionMaximumChunkX * 16L + 15L, + regionMinimumChunkZ * 16L, + regionMaximumChunkZ * 16L + 15L + ) + )); + } + Comparator distanceComparator = orderByBlockDistance + ? Comparator.comparing(RegionCandidate::blockDistanceSquared) + : Comparator.comparing(RegionCandidate::chunkDistanceSquared); + candidates.sort(distanceComparator + .thenComparingInt(candidate -> regionX(candidate.regionKey())) + .thenComparingInt(candidate -> regionZ(candidate.regionKey()))); + return candidates; + } + + private static BigInteger distanceSquaredToRegion( + long originX, + long originZ, + long minimumX, + long maximumX, + long minimumZ, + long maximumZ + ) { + long distanceX = distanceToRange(originX, minimumX, maximumX); + long distanceZ = distanceToRange(originZ, minimumZ, maximumZ); + return squared(distanceX).add(squared(distanceZ)); + } + + private static long distanceToRange(long value, long minimum, long maximum) { + if (value < minimum) { + return minimum - value; + } + return value > maximum ? value - maximum : 0L; + } + + private static BigInteger squared(long value) { + BigInteger integer = BigInteger.valueOf(value); + return integer.multiply(integer); + } + + private static BigInteger horizontalDistanceSquared( + ChunkGenerationSemantics.BlockPosition first, + ChunkGenerationSemantics.BlockPosition second + ) { + BigInteger deltaX = BigInteger.valueOf((long) first.x() - second.x()); + BigInteger deltaZ = BigInteger.valueOf((long) first.z() - second.z()); + return deltaX.multiply(deltaX).add(deltaZ.multiply(deltaZ)); + } + + private static boolean matchesActivation(long activationId, OptionalLong activationFilter) { + return activationFilter.isEmpty() || activationFilter.getAsLong() == activationId; + } + + private static boolean withinRadius(long deltaX, long deltaZ, int maxChunkRadius) { + return Math.abs(deltaX) <= maxChunkRadius && Math.abs(deltaZ) <= maxChunkRadius; + } + + private static long packRegion(int regionX, int regionZ) { + return ((long) regionX << Integer.SIZE) | (regionZ & 0xFFFFFFFFL); + } + + private static int regionX(long regionKey) { + return (int) (regionKey >> Integer.SIZE); + } + + private static int regionZ(long regionKey) { + return (int) regionKey; + } + + private static int compareRegions(int firstX, int firstZ, int secondX, int secondZ) { + int xComparison = Integer.compare(firstX, secondX); + if (xComparison != 0) { + return xComparison; + } + return Integer.compare(firstZ, secondZ); + } + + private static int compareRegionKeys(long first, long second) { + return compareRegions(regionX(first), regionZ(first), regionX(second), regionZ(second)); + } + + private static String fileName(int regionX, int regionZ, String hash) { + return "r." + regionX + "." + regionZ + "." + hash + FILE_SUFFIX; + } + + private static String pointerFileName(int regionX, int regionZ) { + return "r." + regionX + "." + regionZ + ".isix"; + } + + private static String journalFileName(int regionX, int regionZ) { + return "r." + regionX + "." + regionZ + JOURNAL_SUFFIX; + } + + private static String sha256(byte[] encoded) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(encoded)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static byte[] withChecksum(byte[] body) throws IOException { + CRC32 checksum = new CRC32(); + checksum.update(body); + ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); + encodedBytes.write(body); + try (DataOutputStream output = new DataOutputStream(encodedBytes)) { + output.writeInt((int) checksum.getValue()); + } + return encodedBytes.toByteArray(); + } + + private static boolean checksumMatches(byte[] encoded) { + int bodyLength = encoded.length - CHECKSUM_BYTES; + int expected = ByteBuffer.wrap(encoded, bodyLength, CHECKSUM_BYTES).getInt(); + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + return (int) checksum.getValue() == expected; + } + + private static void publishAtomic(Path target, byte[] encoded, String kind) throws IOException { + Path directory = target.getParent(); + Path temporary = Files.createTempFile(directory, "." + target.getFileName() + "-", ".tmp"); + try { + RegionShard.writeForced(temporary, encoded); + try { + Files.move( + temporary, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException error) { + throw new IOException("Generation semantics require atomic " + kind + " publication", error); + } + RegionShard.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private void deleteUnreferencedShard( + int regionX, + int regionZ, + String previousHash, + String currentHash + ) { + if (previousHash == null || previousHash.equals(currentHash)) { + return; + } + try { + Files.deleteIfExists(directory.resolve(fileName(regionX, regionZ, previousHash))); + } catch (IOException ignored) { + } + } + + public enum SemanticKind { + SURFACE_BIOME, + CAVE_BIOME, + REGION, + RIVER_PROFILE, + OBJECT, + STRUCTURE + } + + public record Query( + SemanticKind kind, + String key, + ChunkGenerationSemantics.BlockPosition origin, + int maxChunkRadius, + OptionalLong activationFilter + ) { + public Query { + kind = Objects.requireNonNull(kind, "kind"); + key = ChunkGenerationSemantics.requireResourceKey(key); + origin = Objects.requireNonNull(origin, "origin"); + if (maxChunkRadius < 0) { + throw new IllegalArgumentException("Maximum chunk radius must not be negative: " + maxChunkRadius); + } + activationFilter = Objects.requireNonNull(activationFilter, "activationFilter"); + if (activationFilter.isPresent() && activationFilter.getAsLong() <= 0L) { + throw new IllegalArgumentException( + "Generation activation IDs must be positive: " + activationFilter.getAsLong() + ); + } + } + + public static Query acrossActivations( + SemanticKind kind, + String key, + ChunkGenerationSemantics.BlockPosition origin, + int maxChunkRadius + ) { + return new Query(kind, key, origin, maxChunkRadius, OptionalLong.empty()); + } + + public static Query forActivation( + SemanticKind kind, + String key, + ChunkGenerationSemantics.BlockPosition origin, + int maxChunkRadius, + long activationId + ) { + return new Query(kind, key, origin, maxChunkRadius, OptionalLong.of(activationId)); + } + } + + public record ChunkReference(int chunkX, int chunkZ, long activationId) { + public ChunkReference { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId); + } + } + } + + public record Match( + SemanticKind kind, + String key, + ChunkReference chunk, + Optional exactPosition + ) { + public Match { + kind = Objects.requireNonNull(kind, "kind"); + key = ChunkGenerationSemantics.requireResourceKey(key); + chunk = Objects.requireNonNull(chunk, "chunk"); + exactPosition = Objects.requireNonNull(exactPosition, "exactPosition"); + if ((kind == SemanticKind.STRUCTURE) != exactPosition.isPresent()) { + throw new IllegalArgumentException("Only structure matches have an exact block position"); + } + } + } + + public record RiverQuery( + Set types, + String profileKey, + ChunkGenerationSemantics.BlockPosition origin, + int maxChunkRadius, + OptionalLong activationFilter + ) { + public RiverQuery { + types = Set.copyOf(Objects.requireNonNull(types, "types")); + if (types.isEmpty()) { + throw new IllegalArgumentException("River semantic queries require a feature type."); + } + profileKey = profileKey == null || profileKey.isBlank() + ? null + : ChunkGenerationSemantics.requireResourceKey(profileKey); + origin = Objects.requireNonNull(origin, "origin"); + if (maxChunkRadius < 0) { + throw new IllegalArgumentException("Maximum chunk radius must not be negative."); + } + activationFilter = Objects.requireNonNull(activationFilter, "activationFilter"); + } + + public static RiverQuery acrossActivations( + Set types, + String profileKey, + ChunkGenerationSemantics.BlockPosition origin, + int maxChunkRadius + ) { + return new RiverQuery( + types, + profileKey, + origin, + maxChunkRadius, + OptionalLong.empty() + ); + } + } + + public record RiverMatch( + ChunkGenerationSemantics.RiverFeatureOccurrence occurrence, + ChunkReference chunk + ) { + public RiverMatch { + occurrence = Objects.requireNonNull(occurrence, "occurrence"); + chunk = Objects.requireNonNull(chunk, "chunk"); + } + } + + private record ChunkCandidate(int chunkX, int chunkZ, long activationId) { + } + + private record StructureCandidate( + int chunkX, + int chunkZ, + long activationId, + ChunkGenerationSemantics.StructureOccurrence occurrence + ) { + } + + private record RegionCandidate( + long regionKey, + BigInteger chunkDistanceSquared, + BigInteger blockDistanceSquared + ) { + } + + @FunctionalInterface + public interface RecordConsumer { + void accept(ChunkGenerationSemantics semantics) throws IOException; + } + + @FunctionalInterface + public interface SealedClaimConsumer { + void accept(int chunkX, int chunkZ) throws IOException; + } + + private static final class RegionSummary { + private final EnumMap> keys; + private final EnumSet riverTypes; + private final LongOpenHashSet activations; + private final LongOpenHashSet sealedActivations; + + private RegionSummary( + EnumMap> keys, + EnumSet riverTypes, + LongOpenHashSet activations, + LongOpenHashSet sealedActivations + ) { + this.keys = keys; + this.riverTypes = riverTypes; + this.activations = activations; + this.sealedActivations = sealedActivations; + } + + private static RegionSummary empty() { + return new RegionSummary( + emptyKeySets(), + EnumSet.noneOf(HydrologyFeatureType.class), + new LongOpenHashSet(), + new LongOpenHashSet() + ); + } + + private static RegionSummary from(RegionShard region) { + EnumMap> keys = emptyKeySets(); + EnumSet riverTypes = EnumSet.noneOf(HydrologyFeatureType.class); + LongOpenHashSet activations = new LongOpenHashSet(); + LongOpenHashSet sealedActivations = new LongOpenHashSet(); + for (ChunkGenerationSemantics semantics : region.records()) { + activations.add(semantics.activationId()); + mutableKeys(keys, SemanticKind.SURFACE_BIOME).addAll(semantics.surfaceBiomeKeys()); + mutableKeys(keys, SemanticKind.CAVE_BIOME).addAll(semantics.caveBiomeKeys()); + mutableKeys(keys, SemanticKind.REGION).addAll(semantics.regionKeys()); + mutableKeys(keys, SemanticKind.RIVER_PROFILE).addAll(semantics.riverProfileKeys()); + mutableKeys(keys, SemanticKind.OBJECT).addAll(semantics.objectKeys()); + for (ChunkGenerationSemantics.StructureOccurrence occurrence : semantics.structures()) { + mutableKeys(keys, SemanticKind.STRUCTURE).add(occurrence.key()); + } + for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : semantics.riverFeatures()) { + riverTypes.add(occurrence.type()); + } + if (semantics.sealed()) { + sealedActivations.add(semantics.activationId()); + } + } + return new RegionSummary(keys, riverTypes, activations, sealedActivations); + } + + private static RegionSummary decode(Path source, byte[] encoded) throws IOException { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + EnumMap> keys = emptyKeySets(); + for (SemanticKind kind : SemanticKind.values()) { + int count = input.readUnsignedShort(); + if (count > MAX_PALETTE_KEYS) { + throw invalid(source, "invalid summary key count " + count); + } + TreeSet values = mutableKeys(keys, kind); + String previous = null; + for (int index = 0; index < count; index++) { + int byteLength = input.readUnsignedShort(); + if (byteLength <= 0 || byteLength > ChunkGenerationSemantics.MAX_KEY_BYTES) { + throw invalid(source, "invalid summary resource key length " + byteLength); + } + byte[] keyBytes = new byte[byteLength]; + input.readFully(keyBytes); + String key = ChunkGenerationSemantics.requireResourceKey( + new String(keyBytes, StandardCharsets.UTF_8) + ); + if (previous != null && previous.compareTo(key) >= 0) { + throw invalid(source, "summary resource keys are not in canonical order"); + } + values.add(key); + previous = key; + } + } + int riverTypeCount = input.readUnsignedByte(); + if (riverTypeCount > HydrologyFeatureType.values().length) { + throw invalid(source, "invalid summary river type count " + riverTypeCount); + } + EnumSet riverTypes = EnumSet.noneOf(HydrologyFeatureType.class); + int previousOrdinal = -1; + for (int index = 0; index < riverTypeCount; index++) { + int ordinal = input.readUnsignedByte(); + if (ordinal <= previousOrdinal || ordinal >= HydrologyFeatureType.values().length) { + throw invalid(source, "invalid summary river type ordinal " + ordinal); + } + riverTypes.add(HydrologyFeatureType.values()[ordinal]); + previousOrdinal = ordinal; + } + LongOpenHashSet activations = readActivations(source, input, "summary activation"); + LongOpenHashSet sealedActivations = readActivations(source, input, "sealed summary activation"); + if (input.available() != 0) { + throw invalid(source, "unexpected trailing summary data"); + } + if (!activations.containsAll(sealedActivations)) { + throw invalid(source, "sealed summary activations are missing from the activation set"); + } + return new RegionSummary(keys, riverTypes, activations, sealedActivations); + } catch (EOFException error) { + throw invalid(source, "truncated summary data", error); + } catch (IllegalArgumentException | NullPointerException error) { + throw invalid(source, "invalid summary data", error); + } + } + + private byte[] encode() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + for (SemanticKind kind : SemanticKind.values()) { + Set values = keys(kind); + if (values.size() > MAX_PALETTE_KEYS) { + throw new IOException("Generation semantic region summary has too many keys"); + } + output.writeShort(values.size()); + for (String key : new TreeSet<>(values)) { + byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); + output.writeShort(keyBytes.length); + output.write(keyBytes); + } + } + output.writeByte(riverTypes.size()); + for (HydrologyFeatureType type : riverTypes) { + output.writeByte(type.ordinal()); + } + writeActivations(output, activations); + writeActivations(output, sealedActivations); + } + return bytes.toByteArray(); + } + + private Set keys(SemanticKind kind) { + Set values = keys.get(kind); + return values == null ? Set.of() : values; + } + + private Set riverTypes() { + return riverTypes; + } + + private boolean hasAnyRiverType(Set requestedTypes) { + for (HydrologyFeatureType type : requestedTypes) { + if (riverTypes.contains(type)) { + return true; + } + } + return false; + } + + private boolean mayContainActivation(OptionalLong activationFilter) { + return activationFilter.isEmpty() || activations.contains(activationFilter.getAsLong()); + } + + private boolean hasSealedActivation(long activationId) { + return sealedActivations.contains(activationId); + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof RegionSummary summary)) { + return false; + } + return keys.equals(summary.keys) + && riverTypes.equals(summary.riverTypes) + && activations.equals(summary.activations) + && sealedActivations.equals(summary.sealedActivations); + } + + @Override + public int hashCode() { + return Objects.hash(keys, riverTypes, activations, sealedActivations); + } + + private static EnumMap> emptyKeySets() { + EnumMap> keys = new EnumMap<>(SemanticKind.class); + for (SemanticKind kind : SemanticKind.values()) { + keys.put(kind, new TreeSet<>()); + } + return keys; + } + + private static TreeSet mutableKeys( + EnumMap> keys, + SemanticKind kind + ) { + return keys.get(kind); + } + + private static LongOpenHashSet readActivations( + Path source, + DataInputStream input, + String label + ) throws IOException { + int activationCount = input.readUnsignedShort(); + if (activationCount > CHUNKS_PER_REGION) { + throw invalid(source, "invalid " + label + " count " + activationCount); + } + LongOpenHashSet activations = new LongOpenHashSet(activationCount); + long previousActivation = 0L; + for (int index = 0; index < activationCount; index++) { + long activationId = input.readLong(); + if (activationId <= 0L || activationId <= previousActivation) { + throw invalid(source, label + " IDs are not in canonical order"); + } + activations.add(activationId); + previousActivation = activationId; + } + return activations; + } + + private static void writeActivations( + DataOutputStream output, + LongOpenHashSet activationSet + ) throws IOException { + long[] activations = activationSet.toLongArray(); + Arrays.sort(activations); + output.writeShort(activations.length); + for (long activationId : activations) { + output.writeLong(activationId); + } + } + + private static IOException invalid(Path source, String reason) { + return new IOException("Invalid generation semantic shard " + source + ": " + reason); + } + + private static IOException invalid(Path source, String reason, Throwable cause) { + return new IOException("Invalid generation semantic shard " + source + ": " + reason, cause); + } + } + + @FunctionalInterface + interface ShardPublisher { + void publish(Path directory, int regionX, int regionZ, byte[] encoded) throws IOException; + } + + @FunctionalInterface + interface PointerPublisher { + void publish(Path directory, int regionX, int regionZ, String hash) throws IOException; + } + + @FunctionalInterface + interface CatalogPublisher { + void publish(Path directory, Set regionKeys) throws IOException; + } + + private static final class SemanticJournal { + private static final int ENTRY_MAGIC = 0x4953574C; + private static final int HEADER_BYTES = Integer.BYTES * 2; + + private static void append( + Path directory, + int regionX, + int regionZ, + ChunkGenerationSemantics claim + ) throws IOException { + RegionShard single = new RegionShard(regionX, regionZ).withRecord(claim); + byte[] payload = single.encode(); + CRC32 checksum = new CRC32(); + checksum.update(payload); + ByteBuffer entry = ByteBuffer.allocate(HEADER_BYTES + payload.length + CHECKSUM_BYTES); + entry.putInt(ENTRY_MAGIC); + entry.putInt(payload.length); + entry.put(payload); + entry.putInt((int) checksum.getValue()); + entry.flip(); + + Path file = directory.resolve(journalFileName(regionX, regionZ)); + boolean created = !Files.exists(file, LinkOption.NOFOLLOW_LINKS); + if (!created && !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw invalid(file, "path is not a regular file"); + } + try (FileChannel channel = FileChannel.open( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + )) { + long size = channel.size(); + if (size > MAX_JOURNAL_BYTES - entry.remaining()) { + throw invalid(file, "journal exceeds its size limit"); + } + channel.position(size); + while (entry.hasRemaining()) { + channel.write(entry); + } + channel.force(true); + } + if (created) { + RegionShard.forceDirectory(directory); + } + } + + private static Replay replay(Path file, int expectedRegionX, int expectedRegionZ) throws IOException { + long size = Files.size(file); + if (size > MAX_JOURNAL_BYTES) { + throw invalid(file, "journal exceeds its size limit"); + } + ArrayList claims = new ArrayList<>(); + long validBytes = 0L; + try (RandomAccessFile input = new RandomAccessFile(file.toFile(), "rw")) { + while (validBytes < size) { + long entryStart = validBytes; + long remaining = size - entryStart; + if (remaining < HEADER_BYTES) { + truncateTail(input, entryStart); + break; + } + input.seek(entryStart); + int magic = input.readInt(); + int payloadLength = input.readInt(); + if (magic != ENTRY_MAGIC || payloadLength <= 0 || payloadLength > MAX_FILE_BYTES) { + if (hasLaterEntryMagic(input, entryStart + 1L, size)) { + throw invalid(file, "corrupt interior entry header at byte " + entryStart); + } + truncateTail(input, entryStart); + break; + } + long entryLength = HEADER_BYTES + (long) payloadLength + CHECKSUM_BYTES; + if (entryLength > remaining) { + if (hasLaterEntryMagic(input, entryStart + HEADER_BYTES, size)) { + throw invalid(file, "corrupt interior entry length at byte " + entryStart); + } + truncateTail(input, entryStart); + break; + } + byte[] payload = new byte[payloadLength]; + input.readFully(payload); + int expectedChecksum = input.readInt(); + CRC32 checksum = new CRC32(); + checksum.update(payload); + long entryEnd = entryStart + entryLength; + if ((int) checksum.getValue() != expectedChecksum) { + if (entryEnd < size) { + throw invalid(file, "checksum mismatch in interior entry at byte " + entryStart); + } + truncateTail(input, entryStart); + break; + } + RegionShard decoded; + try { + decoded = RegionShard.decode(file, payload); + } catch (IOException error) { + if (entryEnd < size) { + throw invalid(file, "invalid interior entry at byte " + entryStart, error); + } + truncateTail(input, entryStart); + break; + } + if (decoded.regionX() != expectedRegionX + || decoded.regionZ() != expectedRegionZ + || decoded.recordCount != 1) { + throw invalid(file, "entry belongs to a different region"); + } + claims.add(decoded.records().getFirst()); + validBytes = entryEnd; + } + } + return new Replay(List.copyOf(claims), claims.size(), validBytes); + } + + private static boolean hasLaterEntryMagic( + RandomAccessFile input, + long start, + long fileSize + ) throws IOException { + if (fileSize - start < Integer.BYTES) { + return false; + } + input.seek(start); + int window = 0; + int loaded = 0; + for (long position = start; position < fileSize; position++) { + window = (window << Byte.SIZE) | input.readUnsignedByte(); + loaded++; + if (loaded >= Integer.BYTES && window == ENTRY_MAGIC) { + return true; + } + } + return false; + } + + private static void truncateTail(RandomAccessFile file, long length) throws IOException { + file.setLength(length); + file.getFD().sync(); + } + + private static IOException invalid(Path file, String reason) { + return new IOException("Invalid generation semantic journal " + file + ": " + reason); + } + + private static IOException invalid(Path file, String reason, Throwable cause) { + return new IOException("Invalid generation semantic journal " + file + ": " + reason, cause); + } + + private record Replay( + List claims, + int entryCount, + long validBytes + ) { + } + } + + private static final class ShardPointer { + private static final int MAGIC = 0x49535054; + private static final int FORMAT_VERSION = 1; + private static final int BODY_BYTES = 48; + + private static String read(Path file, int expectedRegionX, int expectedRegionZ) throws IOException { + byte[] encoded = Files.readAllBytes(file); + if (encoded.length != BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(file, "invalid length " + encoded.length); + } + validateChecksum(file, encoded); + try (DataInputStream input = new DataInputStream( + new ByteArrayInputStream(encoded, 0, BODY_BYTES) + )) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + int regionX = input.readInt(); + int regionZ = input.readInt(); + if (regionX != expectedRegionX || regionZ != expectedRegionZ) { + throw invalid(file, "coordinates do not match the pointer file name"); + } + byte[] hash = new byte[32]; + input.readFully(hash); + if (input.available() != 0) { + throw invalid(file, "unexpected trailing data"); + } + return HexFormat.of().formatHex(hash); + } + } + + private static void publish( + Path directory, + int regionX, + int regionZ, + String hash + ) throws IOException { + byte[] encoded = encode(regionX, regionZ, hash); + Path target = directory.resolve(pointerFileName(regionX, regionZ)); + publishAtomic(target, encoded, "shard pointer"); + } + + private static byte[] encode(int regionX, int regionZ, String hash) throws IOException { + if (hash == null || !hash.matches("[0-9a-f]{64}")) { + throw new IOException("Generation semantic shard pointer contains an invalid hash"); + } + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(BODY_BYTES); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MAGIC); + output.writeShort(FORMAT_VERSION); + output.writeShort(0); + output.writeInt(regionX); + output.writeInt(regionZ); + output.write(HexFormat.of().parseHex(hash)); + } + return withChecksum(bodyBytes.toByteArray()); + } + + private static void validateChecksum(Path file, byte[] encoded) throws IOException { + if (!checksumMatches(encoded)) { + throw invalid(file, "checksum mismatch"); + } + } + + private static IOException invalid(Path file, String reason) { + return new IOException("Invalid generation semantic shard pointer " + file + ": " + reason); + } + } + + private static final class Catalog { + private static Set read(Path file) throws IOException { + byte[] encoded = readEncoded(file); + validateChecksum(file, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != CATALOG_MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != CATALOG_FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + int count = input.readInt(); + int maximumEntries = (MAX_CATALOG_BYTES - CATALOG_FIXED_BODY_BYTES - CHECKSUM_BYTES) / 8; + if (count < 0 || count > maximumEntries) { + throw invalid(file, "invalid shard count " + count); + } + TreeSet entries = new TreeSet<>(GenerationSemanticIndex::compareRegionKeys); + int previousRegionX = 0; + int previousRegionZ = 0; + boolean first = true; + for (int index = 0; index < count; index++) { + int regionX = input.readInt(); + int regionZ = input.readInt(); + RegionShard.validateRegionCoordinate(file, regionX); + RegionShard.validateRegionCoordinate(file, regionZ); + if (!first && compareRegions(previousRegionX, previousRegionZ, regionX, regionZ) >= 0) { + throw invalid(file, "shard entries are not in canonical order"); + } + entries.add(packRegion(regionX, regionZ)); + previousRegionX = regionX; + previousRegionZ = regionZ; + first = false; + } + if (input.available() != 0) { + throw invalid(file, "unexpected trailing data"); + } + return entries; + } catch (EOFException error) { + throw invalid(file, "truncated data", error); + } + } + + private static void publish(Path directory, Set regionKeys) throws IOException { + byte[] encoded = encode(regionKeys); + Path target = directory.resolve(CATALOG_FILE_NAME); + publishAtomic(target, encoded, "catalog"); + } + + private static byte[] encode(Set regionKeys) throws IOException { + List entries = new ArrayList<>(regionKeys); + entries.sort(GenerationSemanticIndex::compareRegionKeys); + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(CATALOG_FIXED_BODY_BYTES + entries.size() * 8); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(CATALOG_MAGIC); + output.writeShort(CATALOG_FORMAT_VERSION); + output.writeShort(0); + output.writeInt(entries.size()); + for (long entry : entries) { + output.writeInt(regionX(entry)); + output.writeInt(regionZ(entry)); + } + } + byte[] body = bodyBytes.toByteArray(); + if (body.length > MAX_CATALOG_BYTES - CHECKSUM_BYTES) { + throw new IOException("Generation semantic catalog exceeds its size limit"); + } + return withChecksum(body); + } + + private static byte[] readEncoded(Path file) throws IOException { + long size = Files.size(file); + if (size < CATALOG_FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(file, "truncated data"); + } + if (size > MAX_CATALOG_BYTES) { + throw invalid(file, "file is too large"); + } + return Files.readAllBytes(file); + } + + private static void validateChecksum(Path file, byte[] encoded) throws IOException { + if (!checksumMatches(encoded)) { + throw invalid(file, "checksum mismatch"); + } + } + + private static IOException invalid(Path file, String reason) { + return new IOException("Invalid generation semantic catalog " + file + ": " + reason); + } + + private static IOException invalid(Path file, String reason, Throwable cause) { + return new IOException("Invalid generation semantic catalog " + file + ": " + reason, cause); + } + } + + private static final class RegionShard { + private final int regionX; + private final int regionZ; + private final ChunkGenerationSemantics[] records; + private int recordCount; + + private RegionShard(int regionX, int regionZ) { + this.regionX = regionX; + this.regionZ = regionZ; + records = new ChunkGenerationSemantics[CHUNKS_PER_REGION]; + } + + private static RegionShard read(Path file, String expectedHash) throws IOException { + byte[] encoded = readEncoded(file); + String actualHash = sha256(encoded); + if (!actualHash.equals(expectedHash)) { + throw invalid(file, "content hash mismatch"); + } + return decode(file, encoded); + } + + private static Metadata readMetadata(Path file, String expectedHash) throws IOException { + byte[] encoded = readEncoded(file); + String actualHash = sha256(encoded); + if (!actualHash.equals(expectedHash)) { + throw invalid(file, "content hash mismatch"); + } + validateChecksum(file, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + int regionX = input.readInt(); + int regionZ = input.readInt(); + validateRegionCoordinate(file, regionX); + validateRegionCoordinate(file, regionZ); + int recordCount = input.readInt(); + if (recordCount <= 0 || recordCount > CHUNKS_PER_REGION) { + throw invalid(file, "invalid record count " + recordCount); + } + int paletteSize = input.readInt(); + if (paletteSize < 0 || paletteSize > MAX_PALETTE_KEYS) { + throw invalid(file, "invalid key palette size " + paletteSize); + } + int summaryLength = input.readInt(); + if (summaryLength <= 0 || summaryLength > input.available()) { + throw invalid(file, "invalid summary length " + summaryLength); + } + return new Metadata(regionX, regionZ, recordCount); + } catch (EOFException error) { + throw invalid(file, "truncated data", error); + } + } + + private static Summary readSummary(Path file, String expectedHash) throws IOException { + byte[] encoded = readEncoded(file); + String actualHash = sha256(encoded); + if (!actualHash.equals(expectedHash)) { + throw invalid(file, "content hash mismatch"); + } + validateChecksum(file, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + int regionX = input.readInt(); + int regionZ = input.readInt(); + validateRegionCoordinate(file, regionX); + validateRegionCoordinate(file, regionZ); + int recordCount = input.readInt(); + if (recordCount <= 0 || recordCount > CHUNKS_PER_REGION) { + throw invalid(file, "invalid record count " + recordCount); + } + int paletteSize = input.readInt(); + if (paletteSize < 0 || paletteSize > MAX_PALETTE_KEYS) { + throw invalid(file, "invalid key palette size " + paletteSize); + } + int summaryLength = input.readInt(); + if (summaryLength <= 0 || summaryLength > input.available()) { + throw invalid(file, "invalid summary length " + summaryLength); + } + byte[] summaryBytes = new byte[summaryLength]; + input.readFully(summaryBytes); + return new Summary( + regionX, + regionZ, + recordCount, + RegionSummary.decode(file, summaryBytes) + ); + } catch (EOFException error) { + throw invalid(file, "truncated data", error); + } catch (IllegalArgumentException | NullPointerException error) { + throw invalid(file, "invalid semantic summary", error); + } + } + + private static RegionShard decode(Path source, byte[] encoded) throws IOException { + if (encoded.length < FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(source, "truncated data"); + } + if (encoded.length > MAX_FILE_BYTES) { + throw invalid(source, "file is too large"); + } + validateChecksum(source, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(source, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(source, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(source, "unsupported format flags " + flags); + } + int regionX = input.readInt(); + int regionZ = input.readInt(); + validateRegionCoordinate(source, regionX); + validateRegionCoordinate(source, regionZ); + int recordCount = input.readInt(); + if (recordCount <= 0 || recordCount > CHUNKS_PER_REGION) { + throw invalid(source, "invalid record count " + recordCount); + } + int paletteSize = input.readInt(); + if (paletteSize < 0 || paletteSize > MAX_PALETTE_KEYS) { + throw invalid(source, "invalid key palette size " + paletteSize); + } + int summaryLength = input.readInt(); + if (summaryLength <= 0 || summaryLength > input.available()) { + throw invalid(source, "invalid summary length " + summaryLength); + } + byte[] summaryBytes = new byte[summaryLength]; + input.readFully(summaryBytes); + RegionSummary storedSummary = RegionSummary.decode(source, summaryBytes); + String[] palette = readPalette(source, input, paletteSize); + boolean[] paletteUsed = new boolean[paletteSize]; + RegionShard region = new RegionShard(regionX, regionZ); + int previousIndex = -1; + for (int index = 0; index < recordCount; index++) { + int localIndex = input.readUnsignedShort(); + if (localIndex >= CHUNKS_PER_REGION || localIndex <= previousIndex) { + throw invalid(source, "chunk records are not in canonical order"); + } + previousIndex = localIndex; + long activationId = input.readLong(); + if (activationId <= 0L) { + throw invalid(source, "generation activation IDs must be positive"); + } + int recordFlags = input.readUnsignedByte(); + if ((recordFlags & ~1) != 0) { + throw invalid(source, "unsupported semantic record flags " + recordFlags); + } + int chunkX = (regionX << 5) + (localIndex & 31); + int chunkZ = (regionZ << 5) + (localIndex >>> 5); + ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder( + chunkX, + chunkZ, + activationId + ); + readKeys(source, input, palette, paletteUsed, builder, SemanticKind.SURFACE_BIOME); + readKeys(source, input, palette, paletteUsed, builder, SemanticKind.CAVE_BIOME); + readKeys(source, input, palette, paletteUsed, builder, SemanticKind.REGION); + readKeys(source, input, palette, paletteUsed, builder, SemanticKind.RIVER_PROFILE); + readKeys(source, input, palette, paletteUsed, builder, SemanticKind.OBJECT); + readRiverFeatures(source, input, palette, paletteUsed, builder); + readStructures(source, input, palette, paletteUsed, builder); + if ((recordFlags & 1) != 0) { + builder.seal(); + } + region.addLoaded(localIndex, builder.build()); + } + for (boolean used : paletteUsed) { + if (!used) { + throw invalid(source, "unused resource key palette entry"); + } + } + if (input.available() != 0) { + throw invalid(source, "unexpected trailing data"); + } + if (!RegionSummary.from(region).equals(storedSummary)) { + throw invalid(source, "summary does not match semantic records"); + } + return region; + } catch (EOFException error) { + throw invalid(source, "truncated data", error); + } catch (IllegalArgumentException | NullPointerException error) { + throw invalid(source, "invalid semantic record", error); + } + } + + private int regionX() { + return regionX; + } + + private int regionZ() { + return regionZ; + } + + private int recordCount() { + return recordCount; + } + + private ChunkGenerationSemantics get(int chunkX, int chunkZ) { + if ((chunkX >> 5) != regionX || (chunkZ >> 5) != regionZ) { + return null; + } + return records[localIndex(chunkX, chunkZ)]; + } + + private RegionShard withRecord(ChunkGenerationSemantics semantics) { + if ((semantics.chunkX() >> 5) != regionX || (semantics.chunkZ() >> 5) != regionZ) { + throw new IllegalArgumentException("Chunk does not belong to generation semantic shard"); + } + int index = localIndex(semantics.chunkX(), semantics.chunkZ()); + RegionShard replacement = new RegionShard(regionX, regionZ); + System.arraycopy(records, 0, replacement.records, 0, records.length); + replacement.recordCount = recordCount; + if (replacement.records[index] == null) { + replacement.recordCount++; + } + replacement.records[index] = semantics; + return replacement; + } + + private void addLoaded(int index, ChunkGenerationSemantics semantics) { + if (records[index] != null) { + throw new IllegalArgumentException("Duplicate generation semantic chunk record"); + } + records[index] = semantics; + recordCount++; + } + + private List records() { + List snapshot = new ArrayList<>(recordCount); + for (ChunkGenerationSemantics semantics : records) { + if (semantics != null) { + snapshot.add(semantics); + } + } + return snapshot; + } + + private static void publish(Path directory, int regionX, int regionZ, byte[] encoded) throws IOException { + String hash = sha256(encoded); + Path target = directory.resolve(fileName(regionX, regionZ, hash)); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation semantic shard is not a regular file: " + target); + } + byte[] existing = readEncoded(target); + if (!MessageDigest.isEqual(existing, encoded)) { + throw new IOException("Generation semantic shard content conflicts with its hash: " + target); + } + return; + } + Path temporary = Files.createTempFile(directory, "." + target.getFileName() + "-", ".tmp"); + try { + writeForced(temporary, encoded); + try { + Files.move( + temporary, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException error) { + throw new IOException("Generation semantics require atomic shard publication", error); + } + forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private byte[] encode() throws IOException { + TreeSet paletteKeys = new TreeSet<>(); + for (ChunkGenerationSemantics semantics : records) { + if (semantics == null) { + continue; + } + addPaletteKeys(paletteKeys, semantics.surfaceBiomeKeys()); + addPaletteKeys(paletteKeys, semantics.caveBiomeKeys()); + addPaletteKeys(paletteKeys, semantics.regionKeys()); + addPaletteKeys(paletteKeys, semantics.riverProfileKeys()); + addPaletteKeys(paletteKeys, semantics.objectKeys()); + for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : semantics.riverFeatures()) { + addPaletteKey(paletteKeys, occurrence.profileKey()); + } + for (ChunkGenerationSemantics.StructureOccurrence occurrence : semantics.structures()) { + addPaletteKey(paletteKeys, occurrence.key()); + } + } + List palette = new ArrayList<>(paletteKeys); + Map paletteIndexes = new HashMap<>(palette.size()); + for (int index = 0; index < palette.size(); index++) { + paletteIndexes.put(palette.get(index), index); + } + + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(8_192); + byte[] summary = RegionSummary.from(this).encode(); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MAGIC); + output.writeShort(FORMAT_VERSION); + output.writeShort(0); + output.writeInt(regionX); + output.writeInt(regionZ); + output.writeInt(recordCount); + output.writeInt(palette.size()); + output.writeInt(summary.length); + output.write(summary); + for (String key : palette) { + byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); + output.writeShort(keyBytes.length); + output.write(keyBytes); + if (bodyBytes.size() > MAX_FILE_BYTES - CHECKSUM_BYTES) { + throw new IOException("Generation semantic shard exceeds its size limit"); + } + } + for (int index = 0; index < records.length; index++) { + ChunkGenerationSemantics semantics = records[index]; + if (semantics == null) { + continue; + } + output.writeShort(index); + output.writeLong(semantics.activationId()); + output.writeByte(semantics.sealed() ? 1 : 0); + writeKeys(output, semantics.surfaceBiomeKeys(), paletteIndexes); + writeKeys(output, semantics.caveBiomeKeys(), paletteIndexes); + writeKeys(output, semantics.regionKeys(), paletteIndexes); + writeKeys(output, semantics.riverProfileKeys(), paletteIndexes); + writeKeys(output, semantics.objectKeys(), paletteIndexes); + output.writeShort(semantics.riverFeatures().size()); + for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : semantics.riverFeatures()) { + output.writeShort(paletteIndexes.get(occurrence.profileKey())); + output.writeByte(occurrence.type().ordinal()); + output.writeLong(occurrence.featureId()); + output.writeInt(occurrence.position().x()); + output.writeInt(occurrence.position().y()); + output.writeInt(occurrence.position().z()); + } + output.writeShort(semantics.structures().size()); + for (ChunkGenerationSemantics.StructureOccurrence occurrence : semantics.structures()) { + output.writeShort(paletteIndexes.get(occurrence.key())); + output.writeInt(occurrence.position().x()); + output.writeInt(occurrence.position().y()); + output.writeInt(occurrence.position().z()); + } + if (bodyBytes.size() > MAX_FILE_BYTES - CHECKSUM_BYTES) { + throw new IOException("Generation semantic shard exceeds its size limit"); + } + } + } + byte[] body = bodyBytes.toByteArray(); + CRC32 checksum = new CRC32(); + checksum.update(body); + ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); + encodedBytes.write(body); + try (DataOutputStream output = new DataOutputStream(encodedBytes)) { + output.writeInt((int) checksum.getValue()); + } + return encodedBytes.toByteArray(); + } + + private record Summary( + int regionX, + int regionZ, + int recordCount, + RegionSummary semantics + ) { + } + + private record Metadata(int regionX, int regionZ, int recordCount) { + } + + private static byte[] readEncoded(Path file) throws IOException { + long size = Files.size(file); + if (size < FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(file, "truncated data"); + } + if (size > MAX_FILE_BYTES) { + throw invalid(file, "file is too large"); + } + byte[] encoded = Files.readAllBytes(file); + if (encoded.length > MAX_FILE_BYTES) { + throw invalid(file, "file is too large"); + } + return encoded; + } + + private static void validateChecksum(Path file, byte[] encoded) throws IOException { + int bodyLength = encoded.length - CHECKSUM_BYTES; + int expected = ByteBuffer.wrap(encoded, bodyLength, CHECKSUM_BYTES).getInt(); + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + if ((int) checksum.getValue() != expected) { + throw invalid(file, "checksum mismatch"); + } + } + + private static String[] readPalette(Path file, DataInputStream input, int paletteSize) throws IOException { + String[] palette = new String[paletteSize]; + String previous = null; + for (int index = 0; index < paletteSize; index++) { + int byteLength = input.readUnsignedShort(); + if (byteLength <= 0 || byteLength > ChunkGenerationSemantics.MAX_KEY_BYTES) { + throw invalid(file, "invalid resource key length " + byteLength); + } + byte[] keyBytes = new byte[byteLength]; + input.readFully(keyBytes); + String key = ChunkGenerationSemantics.requireResourceKey( + new String(keyBytes, StandardCharsets.UTF_8) + ); + if (previous != null && previous.compareTo(key) >= 0) { + throw invalid(file, "resource key palette is not in canonical order"); + } + palette[index] = key; + previous = key; + } + return palette; + } + + private static void readKeys( + Path file, + DataInputStream input, + String[] palette, + boolean[] paletteUsed, + ChunkGenerationSemantics.Builder builder, + SemanticKind kind + ) throws IOException { + int count = input.readUnsignedShort(); + if (count > ChunkGenerationSemantics.MAX_KEYS_PER_KIND) { + throw invalid(file, "too many " + kind.name().toLowerCase() + " keys"); + } + int previousIndex = -1; + for (int index = 0; index < count; index++) { + int paletteIndex = input.readUnsignedShort(); + if (paletteIndex >= palette.length || paletteIndex <= previousIndex) { + throw invalid(file, "semantic keys are not in canonical palette order"); + } + previousIndex = paletteIndex; + paletteUsed[paletteIndex] = true; + addDecodedKey(builder, kind, palette[paletteIndex]); + } + } + + private static void readStructures( + Path file, + DataInputStream input, + String[] palette, + boolean[] paletteUsed, + ChunkGenerationSemantics.Builder builder + ) throws IOException { + int count = input.readUnsignedShort(); + if (count > ChunkGenerationSemantics.MAX_STRUCTURES) { + throw invalid(file, "too many structure occurrences"); + } + ChunkGenerationSemantics.StructureOccurrence previous = null; + for (int index = 0; index < count; index++) { + int paletteIndex = input.readUnsignedShort(); + if (paletteIndex >= palette.length) { + throw invalid(file, "structure key palette index is out of range"); + } + paletteUsed[paletteIndex] = true; + ChunkGenerationSemantics.StructureOccurrence occurrence = + new ChunkGenerationSemantics.StructureOccurrence( + palette[paletteIndex], + new ChunkGenerationSemantics.BlockPosition( + input.readInt(), + input.readInt(), + input.readInt() + ) + ); + if (previous != null + && ChunkGenerationSemantics.structureComparator().compare(previous, occurrence) >= 0) { + throw invalid(file, "structure occurrences are not in canonical order"); + } + builder.addStructure(occurrence); + previous = occurrence; + } + } + + private static void readRiverFeatures( + Path file, + DataInputStream input, + String[] palette, + boolean[] paletteUsed, + ChunkGenerationSemantics.Builder builder + ) throws IOException { + int count = input.readUnsignedShort(); + if (count > ChunkGenerationSemantics.MAX_RIVER_FEATURES) { + throw invalid(file, "too many river feature occurrences"); + } + ChunkGenerationSemantics.RiverFeatureOccurrence previous = null; + HydrologyFeatureType[] types = HydrologyFeatureType.values(); + for (int index = 0; index < count; index++) { + int paletteIndex = input.readUnsignedShort(); + if (paletteIndex >= palette.length) { + throw invalid(file, "river profile key palette index is out of range"); + } + int typeOrdinal = input.readUnsignedByte(); + if (typeOrdinal >= types.length) { + throw invalid(file, "river feature type ordinal is out of range"); + } + paletteUsed[paletteIndex] = true; + ChunkGenerationSemantics.RiverFeatureOccurrence occurrence = + new ChunkGenerationSemantics.RiverFeatureOccurrence( + palette[paletteIndex], + types[typeOrdinal], + input.readLong(), + new ChunkGenerationSemantics.BlockPosition( + input.readInt(), + input.readInt(), + input.readInt() + ) + ); + if (previous != null + && ChunkGenerationSemantics.riverFeatureComparator() + .compare(previous, occurrence) >= 0) { + throw invalid(file, "river feature occurrences are not in canonical order"); + } + builder.addRiverFeature(occurrence); + previous = occurrence; + } + } + + private static void addDecodedKey( + ChunkGenerationSemantics.Builder builder, + SemanticKind kind, + String key + ) { + switch (kind) { + case SURFACE_BIOME -> builder.addSurfaceBiome(key); + case CAVE_BIOME -> builder.addCaveBiome(key); + case REGION -> builder.addRegion(key); + case RIVER_PROFILE -> builder.addRiverProfile(key); + case OBJECT -> builder.addObject(key); + case STRUCTURE -> throw new IllegalArgumentException("Structure keys require exact positions"); + } + } + + private static void writeKeys( + DataOutputStream output, + Set keys, + Map paletteIndexes + ) throws IOException { + output.writeShort(keys.size()); + for (String key : keys) { + output.writeShort(paletteIndexes.get(key)); + } + } + + private static void addPaletteKeys(TreeSet palette, Set keys) throws IOException { + for (String key : keys) { + addPaletteKey(palette, key); + } + } + + private static void addPaletteKey(TreeSet palette, String key) throws IOException { + palette.add(key); + if (palette.size() > MAX_PALETTE_KEYS) { + throw new IOException("Generation semantic shard has too many unique resource keys"); + } + } + + private static void validateRegionCoordinate(Path file, int regionCoordinate) throws IOException { + if (regionCoordinate < MINIMUM_REGION_COORDINATE || regionCoordinate > MAXIMUM_REGION_COORDINATE) { + throw invalid(file, "region coordinate is outside the chunk coordinate range"); + } + } + + private static int localIndex(int chunkX, int chunkZ) { + return (chunkX & 31) + ((chunkZ & 31) << 5); + } + + private static void writeForced(Path file, byte[] encoded) throws IOException { + try (FileChannel channel = FileChannel.open( + file, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + } + + private static void forceDirectory(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException error) { + throw new IOException("Generation semantic directory cannot be durability-synced", error); + } + } + + private static IOException invalid(Path file, String reason) { + return new IOException("Invalid generation semantic shard " + file + ": " + reason); + } + + private static IOException invalid(Path file, String reason, Throwable cause) { + return new IOException("Invalid generation semantic shard " + file + ": " + reason, cause); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticQueries.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticQueries.java new file mode 100644 index 000000000..6022aab1c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticQueries.java @@ -0,0 +1,261 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; + +import java.math.BigInteger; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.function.IntConsumer; +import java.util.function.Predicate; + +public final class GenerationSemanticQueries { + private GenerationSemanticQueries() { + } + + public static IrisStructureLocator.LocateResult nearestStructure( + Engine engine, + String key, + int blockX, + int blockZ, + int maximumChunkRadius + ) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + String requiredKey = ChunkGenerationSemantics.requireResourceKey( + Objects.requireNonNull(key, "key").trim().toLowerCase(Locale.ROOT)); + int radius = Math.max(0, Math.min(maximumChunkRadius, 2048)); + GenerationHistory history = history(requiredEngine).orElse(null); + if (history == null) { + return IrisStructureLocator.locate(requiredEngine, requiredKey, blockX, blockZ, radius); + } + IrisStructureLocator.LocateResult active = IrisStructureLocator.locate( + requiredEngine, requiredKey, blockX, blockZ, radius, + (chunkX, chunkZ) -> allowsActivePrediction(requiredEngine, chunkX, chunkZ)); + if (active.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + return active; + } + GenerationSemanticIndex.Match recorded = history.findRecorded( + GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.STRUCTURE, + requiredKey, + new ChunkGenerationSemantics.BlockPosition(blockX, 0, blockZ), + radius + ) + ).orElse(null); + if (recorded == null) { + return active; + } + ChunkGenerationSemantics.BlockPosition position = recorded.exactPosition().orElseThrow(); + if (active.found() && horizontalDistanceSquared(blockX, blockZ, active.originX(), active.originZ()) + .compareTo(horizontalDistanceSquared(blockX, blockZ, position.x(), position.z())) < 0) { + return active; + } + return new IrisStructureLocator.LocateResult( + IrisStructureLocator.LocateStatus.FOUND, position.x(), position.y(), position.z()); + } + + public static Optional nearestRiver( + Engine engine, + HydrologyFeatureQuery query, + int blockX, + int blockZ, + int maximumDistance, + IntConsumer progress + ) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + HydrologyFeatureQuery requiredQuery = Objects.requireNonNull(query, "query"); + IntConsumer requiredProgress = Objects.requireNonNull(progress, "progress"); + if (maximumDistance < 0) { + throw new IllegalArgumentException("Maximum river search distance cannot be negative."); + } + GenerationHistory history = history(requiredEngine).orElse(null); + RiverResult recorded = recordedRiver( + history, + requiredQuery, + blockX, + blockZ, + maximumDistance + ); + RiverResult active = activeRiver( + requiredEngine, + history, + requiredQuery, + blockX, + blockZ, + maximumDistance, + requiredProgress + ); + if (recorded == null) { + return Optional.ofNullable(active); + } + if (active == null) { + return Optional.of(recorded); + } + int comparison = horizontalDistanceSquared(blockX, blockZ, recorded.x(), recorded.z()) + .compareTo(horizontalDistanceSquared(blockX, blockZ, active.x(), active.z())); + return Optional.of(comparison <= 0 ? recorded : active); + } + + public static boolean allowsActivePrediction(Engine engine, int chunkX, int chunkZ) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + if (!requiredEngine.getComplex().allowsNewGenerationChunk(chunkX, chunkZ)) { + return false; + } + GenerationHistory history = history(requiredEngine).orElse(null); + if (history == null) { + return true; + } + return history.isActiveUnowned(chunkX, chunkZ) + && history.semantics(chunkX, chunkZ) + .filter(ChunkGenerationSemantics::sealed) + .isEmpty(); + } + + private static RiverResult recordedRiver( + GenerationHistory history, + HydrologyFeatureQuery query, + int blockX, + int blockZ, + int maximumDistance + ) { + if (history == null) { + return null; + } + int maxChunkRadius = Math.addExact(Math.floorDiv(maximumDistance, 16), 1); + GenerationSemanticIndex.RiverMatch match = history.findRecordedRiver( + GenerationSemanticIndex.RiverQuery.acrossActivations( + query.types(), + query.profileKey(), + new ChunkGenerationSemantics.BlockPosition(blockX, 0, blockZ), + maxChunkRadius + ) + ).orElse(null); + if (match == null) { + return null; + } + ChunkGenerationSemantics.RiverFeatureOccurrence occurrence = match.occurrence(); + ChunkGenerationSemantics.BlockPosition position = occurrence.position(); + BigInteger maximumDistanceSquared = BigInteger.valueOf(maximumDistance) + .multiply(BigInteger.valueOf(maximumDistance)); + if (horizontalDistanceSquared(blockX, blockZ, position.x(), position.z()) + .compareTo(maximumDistanceSquared) > 0) { + return null; + } + return new RiverResult( + RiverSource.RECORDED, + occurrence.type(), + Optional.of(occurrence.profileKey()), + occurrence.featureId(), + position.x(), + position.y(), + position.z(), + OptionalLong.of(match.chunk().activationId()) + ); + } + + private static RiverResult activeRiver( + Engine engine, + GenerationHistory history, + HydrologyFeatureQuery query, + int blockX, + int blockZ, + int maximumDistance, + IntConsumer progress + ) { + IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); + if (runtime == null) { + return null; + } + Predicate eligibility = feature -> allowsActiveHydrologyPrediction( + engine, + feature.x(), + feature.z() + ); + HydrologyFeatureRef feature = runtime.nearestFeature( + query.types(), + query.profileKey(), + blockX, + blockZ, + maximumDistance, + eligibility, + progress + ).orElse(null); + if (feature == null) { + return null; + } + OptionalLong activationId = history == null + ? OptionalLong.empty() + : OptionalLong.of(history.activeActivation().activationId()); + return new RiverResult( + RiverSource.ACTIVE_PREDICTION, + feature.type(), + Optional.ofNullable(query.profileKey()), + feature.id(), + feature.x(), + Math.addExact(feature.y(), engine.getDimension().getMinHeight()), + feature.z(), + activationId + ); + } + + private static boolean allowsActiveHydrologyPrediction(Engine engine, int blockX, int blockZ) { + TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan(); + if (transition != null && transition.hydrologyWeightAt(blockX, blockZ) != 1D) { + return false; + } + return allowsActivePrediction( + engine, + Math.floorDiv(blockX, 16), + Math.floorDiv(blockZ, 16) + ); + } + + private static Optional history(Engine engine) { + if (!(engine instanceof IrisEngine irisEngine)) { + return Optional.empty(); + } + return irisEngine.getGenerationHistoryRuntimeRouter() + .map(GenerationHistoryRuntimeRouter::history); + } + + private static BigInteger horizontalDistanceSquared( + int firstX, + int firstZ, + int secondX, + int secondZ + ) { + BigInteger deltaX = BigInteger.valueOf((long) firstX - secondX); + BigInteger deltaZ = BigInteger.valueOf((long) firstZ - secondZ); + return deltaX.multiply(deltaX).add(deltaZ.multiply(deltaZ)); + } + + public enum RiverSource { + RECORDED, + ACTIVE_PREDICTION + } + + public record RiverResult( + RiverSource source, + HydrologyFeatureType type, + Optional profileKey, + long featureId, + int x, + int y, + int z, + OptionalLong activationId + ) { + public RiverResult { + source = Objects.requireNonNull(source, "source"); + type = Objects.requireNonNull(type, "type"); + profileKey = Objects.requireNonNull(profileKey, "profileKey"); + activationId = Objects.requireNonNull(activationId, "activationId"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationTransition.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationTransition.java new file mode 100644 index 000000000..c287323fd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationTransition.java @@ -0,0 +1,123 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; + +import java.util.Objects; +import java.util.regex.Pattern; + +public record GenerationTransition( + int algorithmVersion, + int widthBlocks, + String boundaryIdentity, + String terrainSignatureIdentity +) { + public static final int CURRENT_ALGORITHM_VERSION = 1; + public static final int MINIMUM_WIDTH_BLOCKS = 16; + public static final int MAXIMUM_WIDTH_BLOCKS = 16_384; + + private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}"); + + public GenerationTransition { + if (algorithmVersion < 1) { + throw new IllegalArgumentException("Transition algorithm version must be positive."); + } + if (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS) { + throw new IllegalArgumentException("Transition width must be between " + + MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks."); + } + if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) { + throw new IllegalArgumentException("Transition snapshot identities must be completed together."); + } + if (boundaryIdentity != null) { + boundaryIdentity = requireIdentity(boundaryIdentity, "Boundary identity"); + terrainSignatureIdentity = requireIdentity( + terrainSignatureIdentity, + "Terrain signature identity" + ); + } + } + + public static GenerationTransition pending(int widthBlocks) { + return new GenerationTransition(CURRENT_ALGORITHM_VERSION, widthBlocks, null, null); + } + + public boolean isComplete() { + return boundaryIdentity != null; + } + + public GenerationTransition complete(String boundaryIdentity, String terrainSignatureIdentity) { + if (isComplete()) { + if (this.boundaryIdentity.equals(boundaryIdentity) + && this.terrainSignatureIdentity.equals(terrainSignatureIdentity)) { + return this; + } + throw new IllegalStateException("Generation transition snapshots are already immutable."); + } + return new GenerationTransition( + algorithmVersion, + widthBlocks, + boundaryIdentity, + terrainSignatureIdentity + ); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("algorithmVersion", algorithmVersion); + json.addProperty("widthBlocks", widthBlocks); + if (boundaryIdentity == null) { + json.add("boundaryIdentity", JsonNull.INSTANCE); + json.add("terrainSignatureIdentity", JsonNull.INSTANCE); + } else { + json.addProperty("boundaryIdentity", boundaryIdentity); + json.addProperty("terrainSignatureIdentity", terrainSignatureIdentity); + } + return json; + } + + static GenerationTransition fromJson(JsonObject json) { + GenerationManifest.JsonSchema.requireFields( + json, + "generation transition", + "algorithmVersion", + "widthBlocks", + "boundaryIdentity", + "terrainSignatureIdentity" + ); + int algorithmVersion = GenerationManifest.JsonSchema.requireInt( + json, + "algorithmVersion", + "generation transition" + ); + int widthBlocks = GenerationManifest.JsonSchema.requireInt( + json, + "widthBlocks", + "generation transition" + ); + String boundaryIdentity = GenerationManifest.JsonSchema.requireNullableString( + json, + "boundaryIdentity", + "generation transition" + ); + String terrainSignatureIdentity = GenerationManifest.JsonSchema.requireNullableString( + json, + "terrainSignatureIdentity", + "generation transition" + ); + return new GenerationTransition( + algorithmVersion, + widthBlocks, + boundaryIdentity, + terrainSignatureIdentity + ); + } + + private static String requireIdentity(String value, String label) { + String requiredValue = Objects.requireNonNull(value, label); + if (!SHA_256.matcher(requiredValue).matches()) { + throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value."); + } + return requiredValue; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/IrisBoundarySignatureSampler.java b/core/src/main/java/art/arcane/iris/engine/history/IrisBoundarySignatureSampler.java new file mode 100644 index 000000000..d2ae5cf9f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/IrisBoundarySignatureSampler.java @@ -0,0 +1,157 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.volmlib.util.math.RNG; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalInt; + +public final class IrisBoundarySignatureSampler implements GenerationBoundarySignatureSampler { + public static final IrisBoundarySignatureSampler INSTANCE = new IrisBoundarySignatureSampler(); + + static final int VERTICAL_SAMPLE_STEP = 4; + private IrisBoundarySignatureSampler() { + } + + @Override + public TerrainBoundarySignature sample(IrisEngine engine, int blockX, int blockZ) throws IOException { + IrisEngine requiredEngine = Objects.requireNonNull(engine, "Iris engine"); + try { + IrisComplex complex = Objects.requireNonNull(requiredEngine.getComplex(), "Iris complex"); + int height = requiredEngine.getHeight(); + if (height <= 0) { + throw new IOException("Iris boundary sampling requires a positive world height."); + } + + int minimumY = requiredEngine.getMinHeight(); + int terrainHeight = clampHeight( + (int) Math.round(complex.getHeightStream().getDouble(blockX, blockZ)), + height + ); + int fluidHeight = clampHeight( + (int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(blockX, blockZ)), + height + ); + OptionalInt fluid = fluidHeight > terrainHeight + ? OptionalInt.of(fluidHeight) + : OptionalInt.empty(); + int sampleCount = Math.ceilDiv(height, VERTICAL_SAMPLE_STEP); + TerrainBoundarySignature.VerticalLayout layout = new TerrainBoundarySignature.VerticalLayout( + minimumY, + VERTICAL_SAMPLE_STEP, + sampleCount + ); + short[] biomeIndices = new short[sampleCount]; + LinkedHashMap paletteIndices = new LinkedHashMap<>(); + + for (int sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) { + int worldY = layout.sampleY(sampleIndex); + int internalY = worldY - minimumY; + String biomeKey = physicalBiomeKey( + requiredEngine, + complex, + blockX, + worldY, + blockZ, + internalY, + terrainHeight + ); + biomeIndices[sampleIndex] = paletteIndex(paletteIndices, biomeKey); + } + + int visibleSurfaceHeight = Math.max(terrainHeight, fluidHeight); + UpperDimensionContext upperContext = requiredEngine.getUpperContext(); + int upperDepth = upperContext == null ? 0 + : height - 1 - upperContext.getEffectiveSurfaceY(blockX, blockZ); + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column( + blockX, + blockZ, + visibleSurfaceHeight, + terrainHeight, + fluid, + upperDepth > 0 ? OptionalInt.of(upperDepth) : OptionalInt.empty() + ), + new TerrainBoundarySignature.Samples( + layout, + new TerrainBoundarySignature.BiomeEncoding( + List.copyOf(paletteIndices.keySet()), + biomeIndices + ) + ) + ); + } catch (IOException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IOException("Unable to sample Iris terrain boundary at " + + blockX + "," + blockZ + ".", exception); + } + } + + private static int clampHeight(int value, int height) { + return Math.max(0, Math.min(height - 1, value)); + } + + private static short paletteIndex(Map indices, String biomeKey) { + Short existing = indices.get(biomeKey); + if (existing != null) { + return existing; + } + int next = indices.size(); + if (next > Short.MAX_VALUE) { + throw new IllegalStateException("Iris boundary biome palette exceeds compact index capacity."); + } + short index = (short) next; + indices.put(biomeKey, index); + return index; + } + + private static String physicalBiomeKey( + IrisEngine engine, + IrisComplex complex, + int blockX, + int worldY, + int blockZ, + int internalY, + int terrainHeight + ) { + String historicalKey = complex.historicalPhysicalBiomeKeyAt(blockX, worldY, blockZ).orElse(null); + if (historicalKey != null) { + return historicalKey; + } + int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); + boolean underground = internalY <= caveSwitchY && internalY <= terrainHeight - 8; + IrisBiome biome = underground + ? engine.getCaveBiome(blockX, internalY, blockZ) + : complex.getTrueBiomeStream().get(blockX, blockZ); + if (biome == null && underground) { + biome = complex.getTrueBiomeStream().get(blockX, blockZ); + } + if (biome == null) { + return "minecraft:plains"; + } + + RNG rng = new RNG(engine.getWorld().getRawWorldSeed() + ^ ((long) blockX * 341873128712L) + ^ ((long) worldY * 132897987541L) + ^ ((long) blockZ * 42317861L)); + if (biome.isCustom()) { + IrisBiomeCustom custom = biome.getCustomBiome(rng, engine, blockX, worldY, blockZ); + return custom == null + ? "minecraft:plains" + : engine.getData().customBiomeResourceKey(engine.getDimension(), custom); + } + String key = underground + ? biome.getGroundBiomeKey(rng, engine, blockX, worldY, blockZ) + : biome.getSkyBiomeKey(rng, engine, blockX, worldY, blockZ); + return key == null || key.isBlank() ? "minecraft:plains" : key; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/RegionGenerationOwnership.java b/core/src/main/java/art/arcane/iris/engine/history/RegionGenerationOwnership.java new file mode 100644 index 000000000..7a3aa7d3c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/RegionGenerationOwnership.java @@ -0,0 +1,555 @@ +package art.arcane.iris.engine.history; + +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.zip.CRC32; + +final class RegionGenerationOwnership { + static final int CHUNKS_PER_SIDE = 32; + static final int CHUNK_COUNT = CHUNKS_PER_SIDE * CHUNKS_PER_SIDE; + static final String FILE_SUFFIX = ".irow"; + + private static final int MAGIC = 0x49524F57; + private static final int FORMAT_VERSION = 1; + private static final int FIXED_BODY_BYTES = 32; + private static final int CHECKSUM_BYTES = Integer.BYTES; + private static final int MAX_FILE_BYTES = 16_384; + private static final int MINIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MIN_VALUE, CHUNKS_PER_SIDE); + private static final int MAXIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MAX_VALUE, CHUNKS_PER_SIDE); + + private final int regionX; + private final int regionZ; + private final long[] activations; + private int assignmentCount; + private boolean dirty; + + RegionGenerationOwnership(int regionX, int regionZ) { + this.regionX = regionX; + this.regionZ = regionZ; + this.activations = new long[CHUNK_COUNT]; + } + + static Metadata readMetadata(Path file) throws IOException { + byte[] encoded = readEncoded(file); + validateChecksum(file, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + int regionX = input.readInt(); + int regionZ = input.readInt(); + validateRegionCoordinate(file, regionX); + validateRegionCoordinate(file, regionZ); + int assignmentCount = input.readInt(); + int paletteSize = input.readInt(); + int bitsPerEntry = input.readInt(); + int wordCount = input.readInt(); + validateHeader(file, assignmentCount, paletteSize, bitsPerEntry, wordCount); + long expectedLength = FIXED_BODY_BYTES + + (long) paletteSize * Long.BYTES + + (long) wordCount * Long.BYTES + + CHECKSUM_BYTES; + if (encoded.length != expectedLength) { + throw invalid(file, "length does not match the ownership header"); + } + return new Metadata(regionX, regionZ, assignmentCount); + } catch (EOFException error) { + throw invalid(file, "truncated data", error); + } + } + + static RegionGenerationOwnership read(Path file) throws IOException { + byte[] encoded = readEncoded(file); + validateChecksum(file, encoded); + int bodyLength = encoded.length - CHECKSUM_BYTES; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) { + int magic = input.readInt(); + if (magic != MAGIC) { + throw invalid(file, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != FORMAT_VERSION) { + throw invalid(file, "unsupported format version " + version); + } + int flags = input.readUnsignedShort(); + if (flags != 0) { + throw invalid(file, "unsupported format flags " + flags); + } + + int regionX = input.readInt(); + int regionZ = input.readInt(); + validateRegionCoordinate(file, regionX); + validateRegionCoordinate(file, regionZ); + int assignmentCount = input.readInt(); + int paletteSize = input.readInt(); + int bitsPerEntry = input.readInt(); + int wordCount = input.readInt(); + validateHeader(file, assignmentCount, paletteSize, bitsPerEntry, wordCount); + + long[] palette = readPalette(file, input, paletteSize); + long[] packed = readPacked(input, wordCount); + if (input.available() != 0) { + throw invalid(file, "unexpected trailing data"); + } + validateUnusedBits(file, packed, bitsPerEntry); + + RegionGenerationOwnership ownership = new RegionGenerationOwnership(regionX, regionZ); + int decodedAssignments = ownership.decode(file, palette, packed, bitsPerEntry); + if (decodedAssignments != assignmentCount) { + throw invalid(file, "assignment count mismatch"); + } + ownership.assignmentCount = decodedAssignments; + return ownership; + } catch (EOFException error) { + throw invalid(file, "truncated data", error); + } + } + + int regionX() { + return regionX; + } + + int regionZ() { + return regionZ; + } + + int assignmentCount() { + synchronized (this) { + return assignmentCount; + } + } + + boolean hasAssignment(int chunkX, int chunkZ) { + return activations[chunkIndex(chunkX, chunkZ)] != 0L; + } + + boolean anyMatchingInBounds(int minimumX, int minimumZ, int maximumX, int maximumZ, + ChunkGenerationOwnership.AssignmentPredicate predicate) { + int baseX = regionX << 5; + int baseZ = regionZ << 5; + int minimumLocalX = Math.max(minimumX, baseX) & 31; + int maximumLocalX = Math.min(maximumX, baseX + 31) & 31; + int minimumLocalZ = Math.max(minimumZ, baseZ) & 31; + int maximumLocalZ = Math.min(maximumZ, baseZ + 31) & 31; + for (int z = minimumLocalZ; z <= maximumLocalZ; z++) { + for (int x = minimumLocalX; x <= maximumLocalX; x++) { + long activation = activations[chunkIndex(x, z)]; + if (activation != 0L && predicate.test(baseX + x, baseZ + z, activation)) { + return true; + } + } + } + return false; + } + + long explicitActivation(int chunkX, int chunkZ) { + long activation = activations[chunkIndex(chunkX, chunkZ)]; + if (activation == 0L) { + throw new IllegalStateException("Chunk " + chunkX + "," + chunkZ + " has no explicit generation activation"); + } + return activation; + } + + long resolve(int chunkX, int chunkZ, long currentActivation) { + requireActivation(currentActivation); + long activation = activations[chunkIndex(chunkX, chunkZ)]; + return activation == 0L ? currentActivation : activation; + } + + synchronized boolean assign(int chunkX, int chunkZ, long activation) { + requireActivation(activation); + int index = chunkIndex(chunkX, chunkZ); + long existing = activations[index]; + if (existing == activation) { + return false; + } + if (existing != 0L) { + throw new IllegalStateException( + "Chunk " + chunkX + "," + chunkZ + " already belongs to activation " + existing + + " and cannot be reassigned to " + activation + ); + } + activations[index] = activation; + assignmentCount++; + dirty = true; + return true; + } + + synchronized boolean persist(Path directory) throws IOException { + if (!dirty) { + return false; + } + ensureDirectory(directory); + Path target = directory.resolve(fileName(regionX, regionZ)); + Path temporary = Files.createTempFile(directory, "." + target.getFileName() + "-", ".tmp"); + try { + byte[] encoded = encode(); + writeForced(temporary, encoded); + try { + Files.move( + temporary, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (AtomicMoveNotSupportedException error) { + throw new IOException("Generation ownership requires atomic shard publication", error); + } + forceDirectory(directory); + dirty = false; + return true; + } finally { + Files.deleteIfExists(temporary); + } + } + + int fillPackedChunkKeys(long[] target, int offset) { + int baseX = regionX << 5; + int baseZ = regionZ << 5; + int cursor = offset; + for (int localZ = 0; localZ < CHUNKS_PER_SIDE; localZ++) { + for (int localX = 0; localX < CHUNKS_PER_SIDE; localX++) { + int index = chunkIndex(localX, localZ); + if (activations[index] != 0L) { + target[cursor++] = ChunkGenerationOwnership.packChunk(baseX + localX, baseZ + localZ); + } + } + } + return cursor; + } + + void forEachAssignment(AssignmentConsumer consumer) throws IOException { + int baseX = regionX << 5; + int baseZ = regionZ << 5; + for (int localZ = 0; localZ < CHUNKS_PER_SIDE; localZ++) { + for (int localX = 0; localX < CHUNKS_PER_SIDE; localX++) { + long activation = activations[chunkIndex(localX, localZ)]; + if (activation != 0L) { + consumer.accept(baseX + localX, baseZ + localZ, activation); + } + } + } + } + + static String fileName(int regionX, int regionZ) { + return "r." + regionX + "." + regionZ + FILE_SUFFIX; + } + + private synchronized byte[] encode() throws IOException { + long[] activationSnapshot = new long[CHUNK_COUNT]; + Long2IntOpenHashMap paletteIndexes = new Long2IntOpenHashMap(); + paletteIndexes.defaultReturnValue(-1); + long[] palette = new long[assignmentCount]; + int paletteSize = 0; + for (int index = 0; index < CHUNK_COUNT; index++) { + long activation = activations[index]; + activationSnapshot[index] = activation; + if (activation == 0L || paletteIndexes.get(activation) >= 0) { + continue; + } + palette[paletteSize] = activation; + paletteIndexes.put(activation, paletteSize + 1); + paletteSize++; + } + + int bitsPerEntry = bitsRequired(paletteSize + 1); + long[] packed = pack(activationSnapshot, paletteIndexes, bitsPerEntry); + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(MAX_FILE_BYTES); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MAGIC); + output.writeShort(FORMAT_VERSION); + output.writeShort(0); + output.writeInt(regionX); + output.writeInt(regionZ); + output.writeInt(assignmentCount); + output.writeInt(paletteSize); + output.writeInt(bitsPerEntry); + output.writeInt(packed.length); + for (int index = 0; index < paletteSize; index++) { + output.writeLong(palette[index]); + } + for (long word : packed) { + output.writeLong(word); + } + } + + byte[] body = bodyBytes.toByteArray(); + CRC32 checksum = new CRC32(); + checksum.update(body); + ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); + encodedBytes.write(body); + try (DataOutputStream output = new DataOutputStream(encodedBytes)) { + output.writeInt((int) checksum.getValue()); + } + return encodedBytes.toByteArray(); + } + + private int decode(Path file, long[] palette, long[] packed, int bitsPerEntry) throws IOException { + int decodedAssignments = 0; + boolean[] paletteUsed = new boolean[palette.length]; + for (int index = 0; index < CHUNK_COUNT; index++) { + int paletteIndex = unpack(packed, index, bitsPerEntry); + if (paletteIndex == 0) { + continue; + } + if (paletteIndex > palette.length) { + throw invalid(file, "palette index out of range"); + } + activations[index] = palette[paletteIndex - 1]; + paletteUsed[paletteIndex - 1] = true; + decodedAssignments++; + } + for (boolean used : paletteUsed) { + if (!used) { + throw invalid(file, "unused activation palette entry"); + } + } + return decodedAssignments; + } + + private static byte[] readEncoded(Path file) throws IOException { + long size = Files.size(file); + if (size < FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(file, "truncated data"); + } + if (size > MAX_FILE_BYTES) { + throw invalid(file, "file is too large"); + } + byte[] encoded = Files.readAllBytes(file); + if (encoded.length > MAX_FILE_BYTES) { + throw invalid(file, "file is too large"); + } + return encoded; + } + + private static void validateChecksum(Path file, byte[] encoded) throws IOException { + int bodyLength = encoded.length - CHECKSUM_BYTES; + int expected = ByteBuffer.wrap(encoded, bodyLength, CHECKSUM_BYTES).getInt(); + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + if ((int) checksum.getValue() != expected) { + throw invalid(file, "checksum mismatch"); + } + } + + private static void validateHeader( + Path file, + int assignmentCount, + int paletteSize, + int bitsPerEntry, + int wordCount + ) throws IOException { + if (assignmentCount < 0 || assignmentCount > CHUNK_COUNT) { + throw invalid(file, "invalid assignment count " + assignmentCount); + } + if (paletteSize < 0 || paletteSize > assignmentCount) { + throw invalid(file, "invalid palette size " + paletteSize); + } + if ((assignmentCount == 0) != (paletteSize == 0)) { + throw invalid(file, "assignment and palette counts disagree"); + } + int expectedBits = bitsRequired(paletteSize + 1); + if (bitsPerEntry != expectedBits) { + throw invalid(file, "invalid bits per entry " + bitsPerEntry); + } + int expectedWords = packedWordCount(bitsPerEntry); + if (wordCount != expectedWords) { + throw invalid(file, "invalid packed word count " + wordCount); + } + } + + private static long[] readPalette(Path file, DataInputStream input, int paletteSize) throws IOException { + long[] palette = new long[paletteSize]; + LongOpenHashSet unique = new LongOpenHashSet(paletteSize); + for (int index = 0; index < paletteSize; index++) { + long activation = input.readLong(); + if (activation <= 0L) { + throw invalid(file, "activation IDs must be positive"); + } + if (!unique.add(activation)) { + throw invalid(file, "duplicate activation in palette"); + } + palette[index] = activation; + } + return palette; + } + + private static long[] readPacked(DataInputStream input, int wordCount) throws IOException { + long[] packed = new long[wordCount]; + for (int index = 0; index < wordCount; index++) { + packed[index] = input.readLong(); + } + return packed; + } + + private static void validateUnusedBits(Path file, long[] packed, int bitsPerEntry) throws IOException { + if (packed.length == 0) { + return; + } + int usedBits = (CHUNK_COUNT * bitsPerEntry) & 63; + if (usedBits == 0) { + return; + } + long unusedMask = -1L << usedBits; + if ((packed[packed.length - 1] & unusedMask) != 0L) { + throw invalid(file, "nonzero unused packed bits"); + } + } + + private static void validateRegionCoordinate(Path file, int regionCoordinate) throws IOException { + if (regionCoordinate < MINIMUM_REGION_COORDINATE || regionCoordinate > MAXIMUM_REGION_COORDINATE) { + throw invalid(file, "region coordinate is outside the chunk coordinate range"); + } + } + + private static long[] pack( + long[] activations, + Long2IntOpenHashMap paletteIndexes, + int bitsPerEntry + ) { + long[] packed = new long[packedWordCount(bitsPerEntry)]; + if (bitsPerEntry == 0) { + return packed; + } + for (int index = 0; index < CHUNK_COUNT; index++) { + long activation = activations[index]; + int paletteIndex = activation == 0L ? 0 : paletteIndexes.get(activation); + int bitIndex = index * bitsPerEntry; + int wordIndex = bitIndex >>> 6; + int bitOffset = bitIndex & 63; + packed[wordIndex] |= (long) paletteIndex << bitOffset; + if (bitOffset + bitsPerEntry > Long.SIZE) { + packed[wordIndex + 1] |= (long) paletteIndex >>> (Long.SIZE - bitOffset); + } + } + return packed; + } + + private static int unpack(long[] packed, int index, int bitsPerEntry) { + if (bitsPerEntry == 0) { + return 0; + } + int bitIndex = index * bitsPerEntry; + int wordIndex = bitIndex >>> 6; + int bitOffset = bitIndex & 63; + long value = packed[wordIndex] >>> bitOffset; + if (bitOffset + bitsPerEntry > Long.SIZE) { + value |= packed[wordIndex + 1] << (Long.SIZE - bitOffset); + } + long mask = (1L << bitsPerEntry) - 1L; + return (int) (value & mask); + } + + private static int bitsRequired(int values) { + if (values <= 1) { + return 0; + } + return Integer.SIZE - Integer.numberOfLeadingZeros(values - 1); + } + + private static int packedWordCount(int bitsPerEntry) { + return (CHUNK_COUNT * bitsPerEntry + Long.SIZE - 1) / Long.SIZE; + } + + private static int chunkIndex(int chunkX, int chunkZ) { + return (chunkX & 31) + ((chunkZ & 31) << 5); + } + + private static void requireActivation(long activation) { + if (activation <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activation); + } + } + + private static void writeForced(Path file, byte[] encoded) throws IOException { + try (FileChannel channel = FileChannel.open( + file, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + } + + private static void ensureDirectory(Path directory) throws IOException { + Path parent = directory.getParent(); + if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership parent directory is missing or unsafe: " + parent); + } + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership path is not a safe directory: " + directory); + } + return; + } + try { + Files.createDirectory(directory); + } catch (FileAlreadyExistsException race) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership path is not a safe directory: " + directory, race); + } + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Generation ownership path is not a safe directory: " + directory); + } + } + + private static void forceDirectory(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException error) { + throw new IOException("Generation ownership directory cannot be durability-synced", error); + } + } + + private static IOException invalid(Path file, String reason) { + return new IOException("Invalid generation ownership file " + file + ": " + reason); + } + + private static IOException invalid(Path file, String reason, Throwable cause) { + return new IOException("Invalid generation ownership file " + file + ": " + reason, cause); + } + + record Metadata(int regionX, int regionZ, int assignmentCount) { + } + + @FunctionalInterface + interface AssignmentConsumer { + void accept(int chunkX, int chunkZ, long activationId) throws IOException; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignature.java b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignature.java new file mode 100644 index 000000000..83f5ea070 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignature.java @@ -0,0 +1,170 @@ +package art.arcane.iris.engine.history; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.OptionalInt; + +public final class TerrainBoundarySignature { + private final Column column; + private final Samples samples; + + public TerrainBoundarySignature(Column column, Samples samples) { + this.column = Objects.requireNonNull(column, "Boundary column"); + this.samples = Objects.requireNonNull(samples, "Boundary samples"); + } + + public Column column() { + return column; + } + + public Samples samples() { + return samples; + } + + public int blockX() { + return column.blockX(); + } + + public int blockZ() { + return column.blockZ(); + } + + public int surfaceHeight() { + return column.surfaceHeight(); + } + + public int oceanFloorHeight() { + return column.oceanFloorHeight(); + } + + public OptionalInt fluidHeight() { + return column.fluidHeight(); + } + + public OptionalInt upperCeilingDepth() { + return column.upperCeilingDepth(); + } + + public int sampleCount() { + return samples.layout().sampleCount(); + } + + public int sampleY(int sampleIndex) { + return samples.layout().sampleY(sampleIndex); + } + + public String biomeAtSample(int sampleIndex) { + return samples.biomes().biomeAtSample(sampleIndex); + } + + public record Column( + int blockX, + int blockZ, + int surfaceHeight, + int oceanFloorHeight, + OptionalInt fluidHeight, + OptionalInt upperCeilingDepth + ) { + public Column { + Objects.requireNonNull(fluidHeight, "Fluid height"); + Objects.requireNonNull(upperCeilingDepth, "Upper ceiling depth"); + if (upperCeilingDepth.isPresent() && upperCeilingDepth.getAsInt() <= 0) { + throw new IllegalArgumentException("Present upper ceiling depth must be positive"); + } + } + } + + public record Samples(VerticalLayout layout, BiomeEncoding biomes) { + public Samples { + Objects.requireNonNull(layout, "Vertical layout"); + Objects.requireNonNull(biomes, "Biome samples"); + if (biomes.sampleCount() != layout.sampleCount()) { + throw new IllegalArgumentException("Biome sample count must match the vertical layout"); + } + } + } + + public record VerticalLayout(int minimumY, int sampleStep, int sampleCount) { + public VerticalLayout { + if (sampleStep <= 0) { + throw new IllegalArgumentException("Sample step must be positive"); + } + if (sampleCount < 0) { + throw new IllegalArgumentException("Sample count cannot be negative"); + } + if (sampleCount > 0) { + Math.toIntExact((long) minimumY + (long) (sampleCount - 1) * sampleStep); + } + } + + public int sampleY(int sampleIndex) { + validateSampleIndex(sampleIndex, sampleCount); + return Math.toIntExact((long) minimumY + (long) sampleIndex * sampleStep); + } + } + + public static final class BiomeEncoding { + private final List palette; + private final short[] paletteIndices; + + public BiomeEncoding(List palette, short[] paletteIndices) { + this.palette = List.copyOf(Objects.requireNonNull(palette, "Biome palette")); + this.paletteIndices = Objects.requireNonNull(paletteIndices, "Biome palette indices").clone(); + validatePalette(); + } + + public List palette() { + return palette; + } + + public short[] paletteIndices() { + return paletteIndices.clone(); + } + + public int sampleCount() { + return paletteIndices.length; + } + + public String biomeAtSample(int sampleIndex) { + validateSampleIndex(sampleIndex, paletteIndices.length); + return palette.get(paletteIndices[sampleIndex]); + } + + @Override + public boolean equals(Object compared) { + if (this == compared) { + return true; + } + if (!(compared instanceof BiomeEncoding encoding)) { + return false; + } + return palette.equals(encoding.palette) && Arrays.equals(paletteIndices, encoding.paletteIndices); + } + + @Override + public int hashCode() { + return 31 * palette.hashCode() + Arrays.hashCode(paletteIndices); + } + + private void validatePalette() { + if (paletteIndices.length > 0 && palette.isEmpty()) { + throw new IllegalArgumentException("Biome palette is required when samples exist"); + } + if (palette.size() > Short.MAX_VALUE + 1) { + throw new IllegalArgumentException("Biome palette exceeds compact index capacity"); + } + for (short paletteIndex : paletteIndices) { + if (paletteIndex < 0 || paletteIndex >= palette.size()) { + throw new IllegalArgumentException("Biome palette index is outside the palette"); + } + } + } + } + + private static void validateSampleIndex(int sampleIndex, int sampleCount) { + if (sampleIndex < 0 || sampleIndex >= sampleCount) { + throw new IndexOutOfBoundsException("Sample index " + sampleIndex + " outside sample count " + sampleCount); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java new file mode 100644 index 000000000..ae66e762c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java @@ -0,0 +1,1397 @@ +package art.arcane.iris.engine.history; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongArrays; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.PriorityQueue; +import java.util.TreeSet; +import java.util.zip.CRC32; + +public final class TerrainBoundarySignatureStore { + public static final String SNAPSHOT_FILE_SUFFIX = ".irts"; + + private static final String SHARD_FILE_SUFFIX = ".irtm"; + private static final int MAGIC = 0x49525453; + private static final int SCHEMA_VERSION = 3; + private static final int SHARD_MAGIC = 0x4952544D; + private static final int SHARD_SCHEMA_VERSION = 2; + private static final int IDENTITY_BYTES = 32; + private static final int CATALOG_FIXED_BODY_BYTES = 56; + private static final int CATALOG_ENTRY_BYTES = 44; + private static final int SHARD_FIXED_BODY_BYTES = 12; + private static final int CHECKSUM_BYTES = Integer.BYTES; + private static final int CELL_SIZE = 32; + private static final int SUPER_CELL_SIZE = 32; + private static final int MAXIMUM_CACHED_SHARDS = 64; + private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE; + private static final int MAX_CELL_COUNT = 8_388_608; + private static final int MAX_SAMPLE_COUNT = 65_536; + private static final int MAX_PALETTE_COUNT = Short.MAX_VALUE + 1; + private static final int MAX_BIOME_BYTES = 65_536; + private static final int MAX_SHARD_BYTES = 64 * 1_024 * 1_024; + private static final int TRANSFER_BYTES = 65_536; + private static final long MAX_CATALOG_BYTES = CATALOG_FIXED_BODY_BYTES + + (long) MAX_CELL_COUNT * CATALOG_ENTRY_BYTES + + CHECKSUM_BYTES; + private static final byte[] IDENTITY_DOMAIN = { + 0x49, 0x72, 0x69, 0x73, 0x54, 0x65, 0x72, 0x72, + 0x61, 0x69, 0x6E, 0x53, 0x69, 0x67, 0x03 + }; + + private final Path dimensionRoot; + private final Path directory; + + public TerrainBoundarySignatureStore(Path dimensionRoot) { + Path root = Objects.requireNonNull(dimensionRoot, "Dimension root").toAbsolutePath().normalize(); + this.dimensionRoot = root; + this.directory = root.resolve("iris").resolve("generation").resolve("boundaries").normalize(); + if (!directory.startsWith(root)) { + throw new IllegalArgumentException("Terrain boundary path escapes the dimension root"); + } + } + + public Path directory() { + return directory; + } + + public Path snapshotPath(long activationId) { + requireActivation(activationId); + return directory.resolve(fileName(activationId)); + } + + public synchronized Snapshot publish( + long activationId, + Collection signatures + ) throws IOException { + requireActivation(activationId); + List normalized = normalize(signatures); + ensureDirectory(); + try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire( + directory, + ".activation-" + activationId + SNAPSHOT_FILE_SUFFIX + ".lock" + )) { + ensureDirectory(); + PublishedCatalog catalog = publishShards(activationId, normalized); + return publishCatalog(activationId, catalog); + } + } + + public synchronized Snapshot publish( + long activationId, + GenerationBoundary boundary, + SignatureSampler sampler + ) throws IOException { + requireActivation(activationId); + Objects.requireNonNull(boundary, "boundary"); + Objects.requireNonNull(sampler, "sampler"); + ensureDirectory(); + try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire( + directory, + ".activation-" + activationId + SNAPSHOT_FILE_SUFFIX + ".lock" + )) { + ensureDirectory(); + Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(); + boundary.forEachExposedBlockColumn((blockX, blockZ) -> { + long cellKey = pack(Math.floorDiv(blockX, CELL_SIZE), Math.floorDiv(blockZ, CELL_SIZE)); + columns.computeIfAbsent(cellKey, unused -> new BitSet(MAX_CELL_ENTRIES)) + .set(Math.floorMod(blockX, CELL_SIZE) * CELL_SIZE + Math.floorMod(blockZ, CELL_SIZE)); + if (columns.size() > MAX_CELL_COUNT) { + throw new IOException("Terrain boundary exceeds the maximum cell count"); + } + }); + long[] cellKeys = columns.keySet().toLongArray(); + LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates); + Long2ObjectOpenHashMap cells = new Long2ObjectOpenHashMap<>(cellKeys.length); + int count = 0; + for (long cellKey : cellKeys) { + BitSet mask = columns.remove(cellKey); + List signatures = new ArrayList<>(mask.cardinality()); + for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) { + int blockX = Math.addExact(Math.multiplyExact(cellX(cellKey), CELL_SIZE), index / CELL_SIZE); + int blockZ = Math.addExact(Math.multiplyExact(cellZ(cellKey), CELL_SIZE), index % CELL_SIZE); + TerrainBoundarySignature signature = Objects.requireNonNull( + sampler.sample(blockX, blockZ), "boundary signature" + ); + if (signature.blockX() != blockX || signature.blockZ() != blockZ) { + throw new IOException("Boundary signature coordinates changed while sampling " + + blockX + "," + blockZ + "."); + } + signatures.add(canonicalizeSignature(signature)); + } + String hash = CellShard.publish(directory, signatures, cellX(cellKey), cellZ(cellKey)); + cells.put(cellKey, new CellReference(hash, signatures.size())); + count = Math.addExact(count, signatures.size()); + } + byte[] identity = computeIdentity(activationId, count, cellKeys, cells); + return publishCatalog(activationId, new PublishedCatalog( + toHex(identity), encodeCatalog(activationId, count, cellKeys, cells, identity) + )); + } + } + + private Snapshot publishCatalog(long activationId, PublishedCatalog catalog) throws IOException { + Path target = snapshotPath(activationId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + Snapshot existing = load(activationId); + if (!existing.identity().equals(catalog.identity())) { + throw new FileAlreadyExistsException( + "Terrain boundary activation " + activationId + + " already has a different snapshot: " + target + ); + } + return existing; + } + publishAtomic(target, catalog.encoded(), "terrain signature catalog"); + return load(activationId); + } + + public Snapshot load(long activationId) throws IOException { + requireActivation(activationId); + validateStorageAncestors(); + Path snapshot = snapshotPath(activationId); + requireRegularFile(snapshot, "Terrain boundary catalog"); + long fileSize = Files.size(snapshot); + if (fileSize < CATALOG_FIXED_BODY_BYTES + CHECKSUM_BYTES) { + throw invalid(snapshot, "truncated data"); + } + if (fileSize > MAX_CATALOG_BYTES) { + throw invalid(snapshot, "catalog exceeds the maximum size"); + } + verifyChecksum(snapshot, fileSize, "terrain boundary catalog"); + long bodyBytes = fileSize - CHECKSUM_BYTES; + try (InputStream fileInput = Files.newInputStream(snapshot); + LimitedInputStream limitedInput = new LimitedInputStream(fileInput, bodyBytes); + DataInputStream input = new DataInputStream(limitedInput)) { + if (input.readInt() != MAGIC) { + throw invalid(snapshot, "invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != SCHEMA_VERSION) { + throw invalid(snapshot, "unsupported schema version " + version); + } + if (input.readUnsignedShort() != 0) { + throw invalid(snapshot, "unsupported format flags"); + } + long storedActivation = input.readLong(); + if (storedActivation != activationId) { + throw invalid(snapshot, "activation ID does not match the snapshot path"); + } + int entryCount = input.readInt(); + if (entryCount < 0) { + throw invalid(snapshot, "invalid signature count " + entryCount); + } + byte[] storedIdentity = input.readNBytes(IDENTITY_BYTES); + if (storedIdentity.length != IDENTITY_BYTES) { + throw invalid(snapshot, "truncated snapshot identity"); + } + int cellCount = input.readInt(); + if (cellCount < 0 || cellCount > MAX_CELL_COUNT) { + throw invalid(snapshot, "invalid cell count " + cellCount); + } + long expectedSize = CATALOG_FIXED_BODY_BYTES + + (long) cellCount * CATALOG_ENTRY_BYTES + + CHECKSUM_BYTES; + if (fileSize != expectedSize) { + throw invalid(snapshot, "catalog length does not match the cell count"); + } + + long[] cellKeys = new long[cellCount]; + Long2ObjectOpenHashMap cells = new Long2ObjectOpenHashMap<>(cellCount); + LinkedHashMap> validated = new LinkedHashMap<>( + MAXIMUM_CACHED_SHARDS, + 0.75F, + true + ); + int countedEntries = 0; + long previousCellKey = 0L; + for (int index = 0; index < cellCount; index++) { + int cellX = input.readInt(); + int cellZ = input.readInt(); + int count = input.readInt(); + if (count <= 0 || count > MAX_CELL_ENTRIES) { + throw invalid(snapshot, "invalid terrain signature cell count " + count); + } + long cellKey = pack(cellX, cellZ); + if (index > 0 && compareCoordinates(previousCellKey, cellKey) >= 0) { + throw invalid(snapshot, "cell entries are not in canonical order"); + } + byte[] hashBytes = input.readNBytes(IDENTITY_BYTES); + if (hashBytes.length != IDENTITY_BYTES) { + throw invalid(snapshot, "truncated shard hash"); + } + String hash = HexFormat.of().formatHex(hashBytes); + List signatures = validated.get(hash); + if (signatures == null) { + Path shard = directory.resolve(shardFileName(hash)); + requireRegularFile(shard, "Terrain boundary shard"); + signatures = CellShard.read(shard, hash, cellX, cellZ); + validated.put(hash, signatures); + trim(validated); + } else { + signatures = relocate(signatures, cellX, cellZ); + } + if (signatures.size() != count) { + throw invalid(snapshot, "terrain signature shard count does not match its catalog entry"); + } + countedEntries = Math.addExact(countedEntries, count); + cellKeys[index] = cellKey; + cells.put(cellKey, new CellReference(hash, count)); + previousCellKey = cellKey; + } + if (limitedInput.remaining() != 0L) { + throw invalid(snapshot, "unexpected trailing catalog data"); + } + if (countedEntries != entryCount) { + throw invalid(snapshot, "signature count does not match the referenced shards"); + } + byte[] computedIdentity = computeIdentity(activationId, entryCount, cellKeys, cells); + if (!MessageDigest.isEqual(storedIdentity, computedIdentity)) { + throw invalid(snapshot, "terrain signature identity mismatch"); + } + CellSource source = new CellSource(directory, cellKeys, cells); + return new Snapshot(activationId, toHex(computedIdentity), entryCount, source); + } catch (ArithmeticException error) { + throw invalid(snapshot, "signature count overflows the supported range", error); + } catch (EOFException error) { + throw invalid(snapshot, "truncated data", error); + } + } + + private PublishedCatalog publishShards( + long activationId, + List signatures + ) throws IOException { + Long2ObjectOpenHashMap> grouped = new Long2ObjectOpenHashMap<>(); + for (TerrainBoundarySignature signature : signatures) { + int cellX = Math.floorDiv(signature.blockX(), CELL_SIZE); + int cellZ = Math.floorDiv(signature.blockZ(), CELL_SIZE); + grouped.computeIfAbsent(pack(cellX, cellZ), ignored -> new ArrayList<>()).add(signature); + } + if (grouped.size() > MAX_CELL_COUNT) { + throw new IOException("Terrain boundary exceeds the maximum cell count"); + } + long[] cellKeys = grouped.keySet().toLongArray(); + LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates); + Long2ObjectOpenHashMap cells = new Long2ObjectOpenHashMap<>(cellKeys.length); + for (long cellKey : cellKeys) { + List cell = List.copyOf(grouped.get(cellKey)); + String hash = CellShard.publish(directory, cell, cellX(cellKey), cellZ(cellKey)); + cells.put(cellKey, new CellReference(hash, cell.size())); + } + byte[] identity = computeIdentity(activationId, signatures.size(), cellKeys, cells); + byte[] encoded = encodeCatalog(activationId, signatures.size(), cellKeys, cells, identity); + return new PublishedCatalog(toHex(identity), encoded); + } + + private static List normalize( + Collection signatures + ) throws IOException { + Collection requiredSignatures = Objects.requireNonNull( + signatures, + "Terrain boundary signatures" + ); + ArrayList normalized = new ArrayList<>(requiredSignatures.size()); + for (TerrainBoundarySignature signature : requiredSignatures) { + normalized.add(canonicalizeSignature(Objects.requireNonNull( + signature, + "Terrain boundary signature" + ))); + } + normalized.sort(Comparator + .comparingInt(TerrainBoundarySignature::blockX) + .thenComparingInt(TerrainBoundarySignature::blockZ)); + for (int index = 1; index < normalized.size(); index++) { + TerrainBoundarySignature previous = normalized.get(index - 1); + TerrainBoundarySignature current = normalized.get(index); + if (previous.blockX() == current.blockX() && previous.blockZ() == current.blockZ()) { + throw new IllegalArgumentException( + "Duplicate terrain boundary signature at " + current.blockX() + "," + current.blockZ() + ); + } + } + return List.copyOf(normalized); + } + + private static TerrainBoundarySignature canonicalizeSignature(TerrainBoundarySignature signature) + throws IOException { + TerrainBoundarySignature.BiomeEncoding biomes = signature.samples().biomes(); + List sourcePalette = biomes.palette(); + short[] sourceIndices = biomes.paletteIndices(); + TreeSet usedBiomes = new TreeSet<>(); + for (short sourceIndex : sourceIndices) { + usedBiomes.add(sourcePalette.get(sourceIndex)); + } + List palette = List.copyOf(usedBiomes); + HashMap paletteIndexes = new HashMap<>(palette.size()); + for (int index = 0; index < palette.size(); index++) { + paletteIndexes.put(palette.get(index), index); + } + short[] paletteIndices = new short[sourceIndices.length]; + for (int index = 0; index < sourceIndices.length; index++) { + paletteIndices[index] = paletteIndexes.get(sourcePalette.get(sourceIndices[index])).shortValue(); + } + TerrainBoundarySignature canonical = new TerrainBoundarySignature( + signature.column(), + new TerrainBoundarySignature.Samples( + signature.samples().layout(), + new TerrainBoundarySignature.BiomeEncoding(palette, paletteIndices) + ) + ); + validateSignature(canonical); + return canonical; + } + + private static void validateSignature(TerrainBoundarySignature signature) throws IOException { + if (signature.sampleCount() < 0 || signature.sampleCount() > MAX_SAMPLE_COUNT) { + throw new IllegalArgumentException("Invalid terrain boundary sample count: " + signature.sampleCount()); + } + if (signature.samples().biomes().palette().size() > MAX_PALETTE_COUNT) { + throw new IllegalArgumentException("Terrain boundary biome palette exceeds compact index capacity"); + } + for (String biome : signature.samples().biomes().palette()) { + encodeBiome(Objects.requireNonNull(biome, "Terrain boundary biome")); + } + } + + private static byte[] computeIdentity( + long activationId, + int entryCount, + long[] cellKeys, + Long2ObjectOpenHashMap cells + ) throws IOException { + MessageDigest digest = sha256(); + digest.update(IDENTITY_DOMAIN); + try (DigestOutputStream digestOutput = new DigestOutputStream(OutputStream.nullOutputStream(), digest); + DataOutputStream output = new DataOutputStream(digestOutput)) { + output.writeLong(activationId); + output.writeInt(entryCount); + output.writeInt(cellKeys.length); + for (long cellKey : cellKeys) { + CellReference cell = cells.get(cellKey); + output.writeInt(cellX(cellKey)); + output.writeInt(cellZ(cellKey)); + output.writeInt(cell.count()); + output.write(HexFormat.of().parseHex(cell.hash())); + } + } + return digest.digest(); + } + + private static byte[] encodeCatalog( + long activationId, + int entryCount, + long[] cellKeys, + Long2ObjectOpenHashMap cells, + byte[] identity + ) throws IOException { + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream( + CATALOG_FIXED_BODY_BYTES + cellKeys.length * CATALOG_ENTRY_BYTES + ); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(MAGIC); + output.writeShort(SCHEMA_VERSION); + output.writeShort(0); + output.writeLong(activationId); + output.writeInt(entryCount); + output.write(identity); + output.writeInt(cellKeys.length); + for (long cellKey : cellKeys) { + CellReference cell = cells.get(cellKey); + output.writeInt(cellX(cellKey)); + output.writeInt(cellZ(cellKey)); + output.writeInt(cell.count()); + output.write(HexFormat.of().parseHex(cell.hash())); + } + } + return withChecksum(bodyBytes.toByteArray()); + } + + private void ensureDirectory() throws IOException { + requireSafeDirectory(dimensionRoot); + Path irisDirectory = ensureChildDirectory(dimensionRoot, "iris"); + Path generationDirectory = ensureChildDirectory(irisDirectory, "generation"); + Path boundaryDirectory = ensureChildDirectory(generationDirectory, "boundaries"); + if (!boundaryDirectory.equals(directory)) { + throw new IOException("Terrain boundary resolved outside its storage path: " + boundaryDirectory); + } + } + + private void validateStorageAncestors() throws IOException { + requireSafeDirectory(dimensionRoot); + requireSafeDirectory(dimensionRoot.resolve("iris")); + requireSafeDirectory(dimensionRoot.resolve("iris").resolve("generation")); + requireSafeDirectory(directory); + } + + private static Path ensureChildDirectory(Path parent, String name) throws IOException { + Path child = parent.resolve(name); + if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) { + requireSafeDirectory(child); + return child; + } + try { + Files.createDirectory(child); + } catch (FileAlreadyExistsException race) { + requireSafeDirectory(child); + return child; + } + forceDirectory(parent); + return child; + } + + private static void requireSafeDirectory(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(path.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + path, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Terrain boundary path is not a safe directory: " + path); + } + } + + private static void requireRegularFile(Path path, String label) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new NoSuchFileException(path.toString()); + } + BasicFileAttributes attributes = Files.readAttributes( + path, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) { + throw new IOException(label + " is not a safe regular file: " + path); + } + } + + private static void publishAtomic(Path target, byte[] encoded, String kind) throws IOException { + Path directory = target.getParent(); + Path temporary = Files.createTempFile(directory, ".terrain-boundary-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException error) { + throw new IOException("Terrain boundary " + kind + " publication requires an atomic move", error); + } + forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static byte[] withChecksum(byte[] body) throws IOException { + CRC32 checksum = new CRC32(); + checksum.update(body); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES); + encoded.write(body); + try (DataOutputStream output = new DataOutputStream(encoded)) { + output.writeInt((int) checksum.getValue()); + } + return encoded.toByteArray(); + } + + private static void verifyChecksum(Path file, long fileSize, String label) throws IOException { + long bodyBytes = fileSize - CHECKSUM_BYTES; + CRC32 checksum = new CRC32(); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { + ByteBuffer buffer = ByteBuffer.allocate(TRANSFER_BYTES); + long remaining = bodyBytes; + while (remaining > 0L) { + buffer.clear(); + buffer.limit((int) Math.min(buffer.capacity(), remaining)); + readFully(channel, buffer); + buffer.flip(); + checksum.update(buffer); + remaining -= buffer.limit(); + } + ByteBuffer stored = ByteBuffer.allocate(CHECKSUM_BYTES); + readFully(channel, stored); + stored.flip(); + if (stored.getInt() != (int) checksum.getValue()) { + throw new IOException("Invalid " + label + " " + file + ": checksum mismatch"); + } + } + } + + private static byte[] encodeBiome(String biome) throws IOException { + try { + ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(biome)); + if (encoded.remaining() > MAX_BIOME_BYTES) { + throw new IllegalArgumentException("Terrain boundary biome key exceeds maximum encoded length"); + } + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } catch (CharacterCodingException error) { + throw new IOException("Terrain boundary biome key is not valid UTF-8", error); + } + } + + private static String decodeBiome(Path source, byte[] encoded) throws IOException { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(encoded)) + .toString(); + } catch (CharacterCodingException error) { + throw invalid(source, "biome key is not valid UTF-8", error); + } + } + + private static List relocate( + List source, + int cellX, + int cellZ + ) { + int minimumX = Math.multiplyExact(cellX, CELL_SIZE); + int minimumZ = Math.multiplyExact(cellZ, CELL_SIZE); + ArrayList relocated = new ArrayList<>(source.size()); + for (TerrainBoundarySignature signature : source) { + int localX = Math.floorMod(signature.blockX(), CELL_SIZE); + int localZ = Math.floorMod(signature.blockZ(), CELL_SIZE); + relocated.add(new TerrainBoundarySignature( + new TerrainBoundarySignature.Column( + minimumX + localX, + minimumZ + localZ, + signature.surfaceHeight(), + signature.oceanFloorHeight(), + signature.fluidHeight(), + signature.upperCeilingDepth() + ), + signature.samples() + )); + } + return List.copyOf(relocated); + } + + private static void trim(LinkedHashMap cache) { + while (cache.size() > MAXIMUM_CACHED_SHARDS) { + Iterator entries = cache.entrySet().iterator(); + entries.next(); + entries.remove(); + } + } + + private static long pack(int x, int z) { + return ((long) x << Integer.SIZE) | (z & 0xffffffffL); + } + + private static int cellX(long key) { + return (int) (key >> Integer.SIZE); + } + + private static int cellZ(long key) { + return (int) key; + } + + private static int compareCoordinates(long first, long second) { + int xComparison = Integer.compare(cellX(first), cellX(second)); + return xComparison != 0 ? xComparison : Integer.compare(cellZ(first), cellZ(second)); + } + + private static String fileName(long activationId) { + return "activation-" + activationId + SNAPSHOT_FILE_SUFFIX; + } + + private static String shardFileName(String hash) { + return "terrain-cell-" + hash + SHARD_FILE_SUFFIX; + } + + private static void requireActivation(long activationId) { + if (activationId <= 0L) { + throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static String sha256(byte[] encoded) { + return HexFormat.of().formatHex(sha256().digest(encoded)); + } + + private static String toHex(byte[] encoded) { + return HexFormat.of().formatHex(encoded); + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static void readFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + if (channel.read(buffer) < 0) { + throw new EOFException("Unexpected end of terrain boundary data"); + } + } + } + + private static void forceDirectory(Path path) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException error) { + throw new IOException("Terrain boundary directory cannot be durability-synced: " + path, error); + } + } + + private static IOException invalid(Path path, String reason) { + return new IOException("Invalid terrain boundary snapshot " + path + ": " + reason); + } + + private static IOException invalid(Path path, String reason, Throwable cause) { + return new IOException("Invalid terrain boundary snapshot " + path + ": " + reason, cause); + } + + @FunctionalInterface + public interface SignatureSampler extends AutoCloseable { + TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException; + + @Override + default void close() { + } + } + + public static final class Snapshot { + private final long activationId; + private final String identity; + private final int size; + private final CellSource source; + + private Snapshot(long activationId, String identity, int size, CellSource source) { + this.activationId = activationId; + this.identity = Objects.requireNonNull(identity, "Terrain boundary identity"); + this.size = size; + this.source = Objects.requireNonNull(source, "Terrain boundary source"); + } + + public long activationId() { + return activationId; + } + + public String identity() { + return identity; + } + + public int size() { + return size; + } + + public Optional signatureAt(int blockX, int blockZ) { + try { + return Optional.ofNullable(source.signatureAt(blockX, blockZ)); + } catch (IOException error) { + throw new IllegalStateException("Unable to load terrain boundary signature", error); + } + } + + public List signatures() { + try { + return source.all(); + } catch (IOException error) { + throw new IllegalStateException("Unable to materialize terrain boundary signatures", error); + } + } + + List nearestCandidatesForChunk(int chunkX, int chunkZ, int searchWidth) { + try { + return source.nearestCandidatesForChunk(chunkX, chunkZ, searchWidth); + } catch (IOException error) { + throw new IllegalStateException("Unable to query terrain boundary signatures", error); + } + } + + boolean intersectsTerrainBand(BlockBounds footprint, int width) { + try { + return source.intersectsTerrainBand(footprint, width); + } catch (IOException error) { + throw new IllegalStateException("Unable to query terrain boundary signatures", error); + } + } + + long catalogProbeCount() { + return source.catalogProbeCount(); + } + + long shardLoadCount() { + return source.shardLoadCount(); + } + + int cachedShardCount() { + return source.cachedShardCount(); + } + } + + private record CellReference(String hash, int count) { + } + + private record PublishedCatalog(String identity, byte[] encoded) { + } + + record BlockBounds(long minimumX, long minimumZ, long maximumX, long maximumZ) { + private static BlockBounds cell(long key, int size) { + long minimumX = (long) cellX(key) * size; + long minimumZ = (long) cellZ(key) * size; + return new BlockBounds(minimumX, minimumZ, minimumX + size - 1L, minimumZ + size - 1L); + } + + private BlockBounds expanded(long width) { + return new BlockBounds(minimumX - width, minimumZ - width, maximumX + width, maximumZ + width); + } + + private double distanceSquared(BlockBounds other) { + long x = Math.max(0L, Math.max(minimumX - other.maximumX, other.minimumX - maximumX)); + long z = Math.max(0L, Math.max(minimumZ - other.maximumZ, other.minimumZ - maximumZ)); + return (double) x * x + (double) z * z; + } + + private double distanceSquared(long blockX, long blockZ) { + long x = Math.max(0L, Math.max(minimumX - blockX, blockX - maximumX)); + long z = Math.max(0L, Math.max(minimumZ - blockZ, blockZ - maximumZ)); + return (double) x * x + (double) z * z; + } + } + + private record CellCandidate(long key, boolean superCell, double distanceSquared) { + } + + @FunctionalInterface + private interface SuperCellVisitor { + boolean visit(long key) throws IOException; + } + + private static final class ChunkNearest { + private static final int NEIGHBOURS = 4; + private static final int COLUMNS = GenerationBoundary.CHUNK_SIZE * GenerationBoundary.CHUNK_SIZE; + + private final BlockBounds chunk; + private final TerrainBoundarySignature[][] signatures = new TerrainBoundarySignature[COLUMNS][NEIGHBOURS]; + private final double[][] distancesSquared = new double[COLUMNS][NEIGHBOURS]; + private final int[] counts = new int[COLUMNS]; + private final double maximumDistanceSquared; + private double limitSquared; + + private ChunkNearest(BlockBounds chunk, int searchWidth) { + this.chunk = chunk; + this.maximumDistanceSquared = (double) searchWidth * searchWidth; + this.limitSquared = maximumDistanceSquared; + } + + private double limitSquared() { + return limitSquared; + } + + private double limitSquared(int column) { + return counts[column] < NEIGHBOURS ? maximumDistanceSquared : distancesSquared[column][NEIGHBOURS - 1]; + } + + private boolean canImprove(BlockBounds bounds) { + for (int column = 0; column < COLUMNS; column++) { + if (bounds.distanceSquared(blockX(column), blockZ(column)) <= limitSquared(column)) { + return true; + } + } + return false; + } + + private void offer(BlockBounds bounds, List candidates) { + limitSquared = 0D; + for (int column = 0; column < COLUMNS; column++) { + long blockX = blockX(column); + long blockZ = blockZ(column); + if (bounds.distanceSquared(blockX, blockZ) <= limitSquared(column)) { + for (TerrainBoundarySignature candidate : candidates) { + long x = candidate.blockX() - blockX; + long z = candidate.blockZ() - blockZ; + offer(column, candidate, (double) x * x + (double) z * z); + } + } + limitSquared = Math.max(limitSquared, limitSquared(column)); + } + } + + private void offer(int column, TerrainBoundarySignature candidate, double distanceSquared) { + if (distanceSquared > limitSquared(column)) { + return; + } + int count = counts[column]; + int insertion = 0; + while (insertion < count) { + int comparison = Double.compare(distancesSquared[column][insertion], distanceSquared); + TerrainBoundarySignature existing = signatures[column][insertion]; + if (comparison > 0 || comparison == 0 && compareCoordinates( + pack(existing.blockX(), existing.blockZ()), pack(candidate.blockX(), candidate.blockZ()) + ) > 0) { + break; + } + insertion++; + } + if (insertion >= NEIGHBOURS) { + return; + } + int copyLength = Math.min(count, NEIGHBOURS - 1) - insertion; + if (copyLength > 0) { + System.arraycopy(signatures[column], insertion, signatures[column], insertion + 1, copyLength); + System.arraycopy(distancesSquared[column], insertion, distancesSquared[column], insertion + 1, copyLength); + } + signatures[column][insertion] = candidate; + distancesSquared[column][insertion] = distanceSquared; + counts[column] = Math.min(NEIGHBOURS, count + 1); + } + + private List candidates() { + Long2ObjectOpenHashMap candidates = new Long2ObjectOpenHashMap<>(); + for (int column = 0; column < COLUMNS; column++) { + for (int index = 0; index < counts[column]; index++) { + TerrainBoundarySignature signature = signatures[column][index]; + candidates.put(pack(signature.blockX(), signature.blockZ()), signature); + } + } + return List.copyOf(candidates.values()); + } + + private long blockX(int column) { + return chunk.minimumX() + column / GenerationBoundary.CHUNK_SIZE; + } + + private long blockZ(int column) { + return chunk.minimumZ() + column % GenerationBoundary.CHUNK_SIZE; + } + } + + private static final class CellSource { + private final Path directory; + private final long[] cellKeys; + private final Long2ObjectOpenHashMap cells; + private final Long2ObjectOpenHashMap cellsBySuperCell; + private final LinkedHashMap> cache; + private long catalogProbes; + private long shardLoads; + + private CellSource( + Path directory, + long[] cellKeys, + Long2ObjectOpenHashMap cells + ) { + this.directory = directory; + this.cellKeys = cellKeys; + this.cells = cells; + this.cellsBySuperCell = indexSuperCells(cellKeys); + this.cache = new LinkedHashMap<>(MAXIMUM_CACHED_SHARDS, 0.75F, true); + } + + private synchronized TerrainBoundarySignature signatureAt(int blockX, int blockZ) throws IOException { + int cellX = Math.floorDiv(blockX, CELL_SIZE); + int cellZ = Math.floorDiv(blockZ, CELL_SIZE); + long cellKey = pack(cellX, cellZ); + CellReference reference = cells.get(cellKey); + if (reference == null) { + return null; + } + for (TerrainBoundarySignature signature : load(cellKey, reference)) { + if (signature.blockX() == blockX && signature.blockZ() == blockZ) { + return signature; + } + } + return null; + } + + private synchronized List all() throws IOException { + ArrayList signatures = new ArrayList<>(); + for (long cellKey : cellKeys) { + signatures.addAll(load(cellKey, cells.get(cellKey))); + } + signatures.sort(Comparator + .comparingInt(TerrainBoundarySignature::blockX) + .thenComparingInt(TerrainBoundarySignature::blockZ)); + return List.copyOf(signatures); + } + + private synchronized List nearestCandidatesForChunk( + int chunkX, + int chunkZ, + int searchWidth + ) throws IOException { + BlockBounds chunk = BlockBounds.cell(pack(chunkX, chunkZ), GenerationBoundary.CHUNK_SIZE); + ChunkNearest nearest = new ChunkNearest(chunk, searchWidth); + PriorityQueue candidates = new PriorityQueue<>(Comparator + .comparingDouble(CellCandidate::distanceSquared) + .thenComparingLong(CellCandidate::key) + .thenComparing(CellCandidate::superCell)); + visitSuperCells(chunk.expanded(searchWidth), key -> { + BlockBounds bounds = BlockBounds.cell(key, CELL_SIZE * SUPER_CELL_SIZE); + double distanceSquared = chunk.distanceSquared(bounds); + if (distanceSquared <= nearest.limitSquared()) { + candidates.add(new CellCandidate(key, true, distanceSquared)); + } + return false; + }); + while (!candidates.isEmpty()) { + CellCandidate candidate = candidates.remove(); + if (candidate.distanceSquared() > nearest.limitSquared()) { + break; + } + if (candidate.superCell()) { + for (long key : cellsBySuperCell.get(candidate.key())) { + double distanceSquared = chunk.distanceSquared(BlockBounds.cell(key, CELL_SIZE)); + if (distanceSquared <= nearest.limitSquared()) { + candidates.add(new CellCandidate(key, false, distanceSquared)); + } + } + continue; + } + BlockBounds bounds = BlockBounds.cell(candidate.key(), CELL_SIZE); + if (nearest.canImprove(bounds)) { + nearest.offer(bounds, load(candidate.key(), cells.get(candidate.key()))); + } + } + return nearest.candidates(); + } + + private synchronized boolean intersectsTerrainBand(BlockBounds footprint, int width) throws IOException { + double distanceSquared = (double) width * width; + return visitSuperCells(footprint.expanded(width - 1L), superKey -> { + if (footprint.distanceSquared(BlockBounds.cell(superKey, CELL_SIZE * SUPER_CELL_SIZE)) + >= distanceSquared) { + return false; + } + for (long cellKey : cellsBySuperCell.get(superKey)) { + if (footprint.distanceSquared(BlockBounds.cell(cellKey, CELL_SIZE)) >= distanceSquared) { + continue; + } + for (TerrainBoundarySignature signature : load(cellKey, cells.get(cellKey))) { + if (footprint.distanceSquared(signature.blockX(), signature.blockZ()) < distanceSquared) { + return true; + } + } + } + return false; + }); + } + + private boolean visitSuperCells(BlockBounds bounds, SuperCellVisitor visitor) throws IOException { + int size = CELL_SIZE * SUPER_CELL_SIZE; + int minimumX = saturatedFloorDiv(bounds.minimumX(), size); + int minimumZ = saturatedFloorDiv(bounds.minimumZ(), size); + int maximumX = saturatedFloorDiv(bounds.maximumX(), size); + int maximumZ = saturatedFloorDiv(bounds.maximumZ(), size); + long width = (long) maximumX - minimumX + 1L; + long height = (long) maximumZ - minimumZ + 1L; + if (width > cellsBySuperCell.size() / height) { + for (long key : cellsBySuperCell.keySet()) { + catalogProbes++; + if (cellX(key) >= minimumX && cellX(key) <= maximumX + && cellZ(key) >= minimumZ && cellZ(key) <= maximumZ + && visitor.visit(key)) { + return true; + } + } + return false; + } + for (long x = minimumX; x <= maximumX; x++) { + for (long z = minimumZ; z <= maximumZ; z++) { + catalogProbes++; + long key = pack((int) x, (int) z); + if (cellsBySuperCell.containsKey(key) && visitor.visit(key)) { + return true; + } + } + } + return false; + } + + private List load(long cellKey, CellReference reference) throws IOException { + List cached = cache.get(cellKey); + if (cached != null) { + return cached; + } + Path shard = directory.resolve(shardFileName(reference.hash())); + requireRegularFile(shard, "Terrain boundary shard"); + List loaded = CellShard.read( + shard, + reference.hash(), + cellX(cellKey), + cellZ(cellKey) + ); + if (loaded.size() != reference.count()) { + throw new IOException("Terrain boundary shard count does not match its catalog entry"); + } + shardLoads++; + cache.put(cellKey, loaded); + trim(cache); + return loaded; + } + + private synchronized long catalogProbeCount() { + return catalogProbes; + } + + private synchronized long shardLoadCount() { + return shardLoads; + } + + private synchronized int cachedShardCount() { + return cache.size(); + } + + private static Long2ObjectOpenHashMap indexSuperCells(long[] cellKeys) { + Long2ObjectOpenHashMap mutable = new Long2ObjectOpenHashMap<>(); + for (long cellKey : cellKeys) { + int superX = Math.floorDiv(cellX(cellKey), SUPER_CELL_SIZE); + int superZ = Math.floorDiv(cellZ(cellKey), SUPER_CELL_SIZE); + mutable.computeIfAbsent(pack(superX, superZ), ignored -> new LongArrayList()).add(cellKey); + } + Long2ObjectOpenHashMap indexed = new Long2ObjectOpenHashMap<>(mutable.size()); + for (Map.Entry entry : mutable.long2ObjectEntrySet()) { + indexed.put(entry.getKey(), entry.getValue().toLongArray()); + } + return indexed; + } + + private static int saturatedFloorDiv(long value, int divisor) { + long divided = Math.floorDiv(value, divisor); + if (divided < Integer.MIN_VALUE) { + return Integer.MIN_VALUE; + } + return divided > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) divided; + } + } + + private static final class CellShard { + private static String publish( + Path directory, + List signatures, + int cellX, + int cellZ + ) throws IOException { + byte[] encoded = encode(signatures, cellX, cellZ); + String hash = sha256(encoded); + Path target = directory.resolve(shardFileName(hash)); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireRegularFile(target, "Terrain boundary shard"); + read(target, hash, cellX, cellZ); + return hash; + } + try { + publishAtomic(target, encoded, "terrain signature shard"); + } catch (FileAlreadyExistsException race) { + requireRegularFile(target, "Terrain boundary shard"); + read(target, hash, cellX, cellZ); + } + return hash; + } + + private static byte[] encode( + List signatures, + int cellX, + int cellZ + ) throws IOException { + if (signatures.isEmpty() || signatures.size() > MAX_CELL_ENTRIES) { + throw new IOException("Terrain boundary cell is empty or exceeds its entry limit"); + } + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bodyBytes)) { + output.writeInt(SHARD_MAGIC); + output.writeShort(SHARD_SCHEMA_VERSION); + output.writeShort(0); + output.writeInt(signatures.size()); + for (TerrainBoundarySignature signature : signatures) { + int localX = Math.subtractExact(signature.blockX(), Math.multiplyExact(cellX, CELL_SIZE)); + int localZ = Math.subtractExact(signature.blockZ(), Math.multiplyExact(cellZ, CELL_SIZE)); + if (localX < 0 || localX >= CELL_SIZE || localZ < 0 || localZ >= CELL_SIZE) { + throw new IOException("Terrain signature does not belong to its cell"); + } + output.writeByte(localX); + output.writeByte(localZ); + writeSignature(output, signature); + if (bodyBytes.size() > MAX_SHARD_BYTES - CHECKSUM_BYTES) { + throw new IOException("Terrain boundary shard exceeds its size limit"); + } + } + } + return withChecksum(bodyBytes.toByteArray()); + } + + private static List read( + Path file, + String expectedHash, + int cellX, + int cellZ + ) throws IOException { + long size = Files.size(file); + if (size < SHARD_FIXED_BODY_BYTES + CHECKSUM_BYTES || size > MAX_SHARD_BYTES) { + throw invalid(file, "terrain signature shard has an invalid length"); + } + byte[] encoded = Files.readAllBytes(file); + if (!sha256(encoded).equals(expectedHash)) { + throw invalid(file, "terrain signature shard content hash mismatch"); + } + int bodyLength = encoded.length - CHECKSUM_BYTES; + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + if (ByteBuffer.wrap(encoded, bodyLength, CHECKSUM_BYTES).getInt() + != (int) checksum.getValue()) { + throw invalid(file, "terrain signature shard checksum mismatch"); + } + try (DataInputStream input = new DataInputStream( + new ByteArrayInputStream(encoded, 0, bodyLength) + )) { + if (input.readInt() != SHARD_MAGIC) { + throw invalid(file, "terrain signature shard has invalid magic"); + } + int version = input.readUnsignedShort(); + if (version != SHARD_SCHEMA_VERSION) { + throw invalid(file, "terrain signature shard has unsupported schema version " + version); + } + if (input.readUnsignedShort() != 0) { + throw invalid(file, "terrain signature shard has unsupported flags"); + } + int count = input.readInt(); + if (count <= 0 || count > MAX_CELL_ENTRIES) { + throw invalid(file, "terrain signature shard has invalid entry count " + count); + } + ArrayList signatures = new ArrayList<>(count); + int previousLocalX = -1; + int previousLocalZ = -1; + for (int index = 0; index < count; index++) { + int localX = input.readUnsignedByte(); + int localZ = input.readUnsignedByte(); + if (localX >= CELL_SIZE || localZ >= CELL_SIZE + || index > 0 && compareLocal(previousLocalX, previousLocalZ, localX, localZ) >= 0) { + throw invalid(file, "terrain signature shard coordinates are not canonical"); + } + int blockX = Math.addExact(Math.multiplyExact(cellX, CELL_SIZE), localX); + int blockZ = Math.addExact(Math.multiplyExact(cellZ, CELL_SIZE), localZ); + signatures.add(readSignature(file, input, blockX, blockZ)); + previousLocalX = localX; + previousLocalZ = localZ; + } + if (input.available() != 0) { + throw invalid(file, "unexpected trailing terrain signature shard data"); + } + return List.copyOf(signatures); + } catch (IllegalArgumentException | ArithmeticException error) { + throw invalid(file, "invalid terrain signature content", error); + } + } + + private static int compareLocal(int firstX, int firstZ, int secondX, int secondZ) { + int xComparison = Integer.compare(firstX, secondX); + return xComparison != 0 ? xComparison : Integer.compare(firstZ, secondZ); + } + + private static void writeSignature( + DataOutputStream output, + TerrainBoundarySignature signature + ) throws IOException { + output.writeInt(signature.surfaceHeight()); + output.writeInt(signature.oceanFloorHeight()); + OptionalInt fluidHeight = signature.fluidHeight(); + output.writeByte(fluidHeight.isPresent() ? 1 : 0); + if (fluidHeight.isPresent()) { + output.writeInt(fluidHeight.getAsInt()); + } + OptionalInt upperCeilingDepth = signature.upperCeilingDepth(); + output.writeByte(upperCeilingDepth.isPresent() ? 1 : 0); + if (upperCeilingDepth.isPresent()) { + output.writeInt(upperCeilingDepth.getAsInt()); + } + TerrainBoundarySignature.VerticalLayout layout = signature.samples().layout(); + output.writeInt(layout.minimumY()); + output.writeInt(layout.sampleStep()); + output.writeInt(layout.sampleCount()); + TerrainBoundarySignature.BiomeEncoding biomes = signature.samples().biomes(); + output.writeInt(biomes.palette().size()); + for (String biome : biomes.palette()) { + byte[] encodedBiome = encodeBiome(biome); + output.writeInt(encodedBiome.length); + output.write(encodedBiome); + } + for (short paletteIndex : biomes.paletteIndices()) { + output.writeShort(paletteIndex); + } + } + + private static TerrainBoundarySignature readSignature( + Path source, + DataInputStream input, + int blockX, + int blockZ + ) throws IOException { + int surfaceHeight = input.readInt(); + int oceanFloorHeight = input.readInt(); + int fluidFlag = input.readUnsignedByte(); + OptionalInt fluidHeight; + if (fluidFlag == 0) { + fluidHeight = OptionalInt.empty(); + } else if (fluidFlag == 1) { + fluidHeight = OptionalInt.of(input.readInt()); + } else { + throw invalid(source, "invalid fluid-height flag " + fluidFlag); + } + int upperCeilingFlag = input.readUnsignedByte(); + OptionalInt upperCeilingDepth; + if (upperCeilingFlag == 0) { + upperCeilingDepth = OptionalInt.empty(); + } else if (upperCeilingFlag == 1) { + upperCeilingDepth = OptionalInt.of(input.readInt()); + } else { + throw invalid(source, "invalid upper-ceiling flag " + upperCeilingFlag); + } + int minimumY = input.readInt(); + int sampleStep = input.readInt(); + int sampleCount = input.readInt(); + if (sampleCount < 0 || sampleCount > MAX_SAMPLE_COUNT) { + throw invalid(source, "invalid sample count " + sampleCount); + } + int paletteCount = input.readInt(); + if (paletteCount < 0 || paletteCount > MAX_PALETTE_COUNT) { + throw invalid(source, "invalid biome palette count " + paletteCount); + } + ArrayList palette = new ArrayList<>(paletteCount); + for (int index = 0; index < paletteCount; index++) { + int encodedLength = input.readInt(); + if (encodedLength < 0 || encodedLength > MAX_BIOME_BYTES || encodedLength > input.available()) { + throw invalid(source, "invalid biome key length " + encodedLength); + } + byte[] encoded = input.readNBytes(encodedLength); + if (encoded.length != encodedLength) { + throw invalid(source, "truncated biome key"); + } + palette.add(decodeBiome(source, encoded)); + } + if ((long) sampleCount * Short.BYTES > input.available()) { + throw invalid(source, "truncated compact sample data"); + } + short[] paletteIndices = new short[sampleCount]; + for (int index = 0; index < sampleCount; index++) { + paletteIndices[index] = input.readShort(); + } + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column( + blockX, + blockZ, + surfaceHeight, + oceanFloorHeight, + fluidHeight, + upperCeilingDepth + ), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(minimumY, sampleStep, sampleCount), + new TerrainBoundarySignature.BiomeEncoding(palette, paletteIndices) + ) + ); + } + } + + private static final class LimitedInputStream extends InputStream { + private final InputStream input; + private long remaining; + + private LimitedInputStream(InputStream input, long remaining) { + this.input = input; + this.remaining = remaining; + } + + @Override + public int read() throws IOException { + if (remaining == 0L) { + return -1; + } + int value = input.read(); + if (value < 0) { + throw new EOFException("Unexpected end of bounded terrain boundary input"); + } + remaining--; + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + Objects.checkFromIndexSize(offset, length, buffer.length); + if (remaining == 0L) { + return -1; + } + int read = input.read(buffer, offset, (int) Math.min(length, remaining)); + if (read < 0) { + throw new EOFException("Unexpected end of bounded terrain boundary input"); + } + remaining -= read; + return read; + } + + @Override + public void close() throws IOException { + input.close(); + } + + private long remaining() { + return remaining; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java b/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java new file mode 100644 index 000000000..43a3119b1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java @@ -0,0 +1,367 @@ +package art.arcane.iris.engine.history; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class TransitionBoundarySampler { + private static final int NEIGHBOUR_COUNT = 4; + private static final int MAXIMUM_CACHED_CHUNKS = 8; + + private final int terrainWidth; + private final int searchWidth; + private final TerrainBoundarySignatureStore.Snapshot signatures; + private final LinkedHashMap chunkCandidates; + private final ThreadLocal cache; + private long candidateBuildCount; + + TransitionBoundarySampler( + int terrainWidth, + TerrainBoundarySignatureStore.Snapshot signatures + ) { + if (terrainWidth <= 0) { + throw new IllegalArgumentException("Terrain transition width must be positive"); + } + this.terrainWidth = terrainWidth; + this.searchWidth = Math.multiplyExact(terrainWidth, 2); + this.signatures = Objects.requireNonNull(signatures, "Terrain boundary signatures"); + this.chunkCandidates = new LinkedHashMap<>(MAXIMUM_CACHED_CHUNKS, 0.75F, true); + this.cache = ThreadLocal.withInitial(ChunkCache::new); + } + + TransitionGenerationPlan.TerrainSample sample(int blockX, int blockZ) { + ChunkCache chunkCache = cache.get(); + int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE); + int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE); + if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) { + chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ)); + } + int localX = Math.floorMod(blockX, GenerationBoundary.CHUNK_SIZE); + int localZ = Math.floorMod(blockZ, GenerationBoundary.CHUNK_SIZE); + int index = localX * GenerationBoundary.CHUNK_SIZE + localZ; + TransitionGenerationPlan.TerrainSample existing = chunkCache.samples[index]; + if (existing != null) { + return existing; + } + TransitionGenerationPlan.TerrainSample sampled = sampleUncached( + blockX, + blockZ, + chunkCache.candidates + ); + chunkCache.samples[index] = sampled; + return sampled; + } + + boolean intersectsTerrainBand(int minimumX, int minimumZ, int maximumX, int maximumZ) { + return signatures.intersectsTerrainBand( + new TerrainBoundarySignatureStore.BlockBounds(minimumX, minimumZ, maximumX, maximumZ), + terrainWidth + ); + } + + long catalogProbeCount() { + return signatures.catalogProbeCount(); + } + + long shardLoadCount() { + return signatures.shardLoadCount(); + } + + synchronized long candidateBuildCount() { + return candidateBuildCount; + } + + private TransitionGenerationPlan.TerrainSample sampleUncached( + int blockX, + int blockZ, + CandidateIndex candidates + ) { + Nearest nearest = candidates.nearest(blockX, blockZ, square(searchWidth)); + if (nearest.count == 0) { + return TransitionGenerationPlan.TerrainSample.newTerrain(searchWidth); + } + double distance = Math.sqrt(nearest.distancesSquared[0]); + double newEpochWeight = GenerationBlend.newEpochWeight( + Math.min(distance, terrainWidth), + terrainWidth + ); + double hydrologyDistance = Math.max(0D, distance - terrainWidth); + double hydrologyWeight = GenerationBlend.newEpochWeight( + Math.min(hydrologyDistance, terrainWidth), + terrainWidth + ); + return new TransitionGenerationPlan.TerrainSample( + Math.min(distance, searchWidth), + newEpochWeight, + hydrologyWeight, + nearest.signatures[0], + weightedHeight(nearest, true), + weightedHeight(nearest, false), + weightedUpperCeilingDepth(nearest) + ); + } + + private CandidateIndex candidatesForChunk(int chunkX, int chunkZ) { + long chunkKey = pack(chunkX, chunkZ); + synchronized (this) { + CandidateIndex existing = chunkCandidates.get(chunkKey); + if (existing != null) { + return existing; + } + } + CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk( + chunkX, + chunkZ, + searchWidth + )); + synchronized (this) { + CandidateIndex raced = chunkCandidates.get(chunkKey); + if (raced != null) { + return raced; + } + chunkCandidates.put(chunkKey, built); + candidateBuildCount++; + while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS) { + Iterator> entries = chunkCandidates.entrySet().iterator(); + entries.next(); + entries.remove(); + } + return built; + } + } + + private static double weightedHeight(Nearest nearest, boolean surface) { + if (nearest.distancesSquared[0] <= 1D) { + return surface + ? nearest.signatures[0].surfaceHeight() + : nearest.signatures[0].oceanFloorHeight(); + } + double weighted = 0D; + double totalWeight = 0D; + for (int index = 0; index < nearest.count; index++) { + double weight = 1D / nearest.distancesSquared[index]; + int height = surface + ? nearest.signatures[index].surfaceHeight() + : nearest.signatures[index].oceanFloorHeight(); + weighted += height * weight; + totalWeight += weight; + } + return weighted / totalWeight; + } + + private static double weightedUpperCeilingDepth(Nearest nearest) { + if (nearest.distancesSquared[0] <= 1D) { + return nearest.signatures[0].upperCeilingDepth().orElse(0); + } + double weighted = 0D; + double totalWeight = 0D; + for (int index = 0; index < nearest.count; index++) { + double weight = 1D / nearest.distancesSquared[index]; + weighted += nearest.signatures[index].upperCeilingDepth().orElse(0) * weight; + totalWeight += weight; + } + return weighted / totalWeight; + } + + private static long distanceToRange(long value, long minimum, long maximum) { + if (value < minimum) { + return minimum - value; + } + return value > maximum ? value - maximum : 0L; + } + + private static double square(long value) { + return (double) value * value; + } + + private static long pack(int x, int z) { + return ((long) x << Integer.SIZE) | (z & 0xffffffffL); + } + + private static final class CandidateIndex { + private static final CandidateIndex EMPTY = new CandidateIndex(null); + + private final Node root; + + private CandidateIndex(Node root) { + this.root = root; + } + + private static CandidateIndex build(List candidates) { + if (candidates.isEmpty()) { + return EMPTY; + } + TerrainBoundarySignature[] values = candidates.toArray(new TerrainBoundarySignature[0]); + return new CandidateIndex(build(values, 0, values.length, 0)); + } + + private Nearest nearest(int blockX, int blockZ, double maximumDistanceSquared) { + Nearest nearest = new Nearest(maximumDistanceSquared); + search(root, blockX, blockZ, nearest); + return nearest; + } + + private static Node build( + TerrainBoundarySignature[] values, + int from, + int to, + int depth + ) { + if (from >= to) { + return null; + } + Comparator comparator = depth % 2 == 0 + ? Comparator.comparingInt(TerrainBoundarySignature::blockX) + .thenComparingInt(TerrainBoundarySignature::blockZ) + : Comparator.comparingInt(TerrainBoundarySignature::blockZ) + .thenComparingInt(TerrainBoundarySignature::blockX); + Arrays.sort(values, from, to, comparator); + int middle = from + (to - from) / 2; + Node left = build(values, from, middle, depth + 1); + Node right = build(values, middle + 1, to, depth + 1); + return new Node(values[middle], left, right); + } + + private static void search(Node node, int blockX, int blockZ, Nearest nearest) { + if (node == null || node.distanceSquaredToBounds(blockX, blockZ) > nearest.limitSquared()) { + return; + } + long deltaX = (long) node.signature.blockX() - blockX; + long deltaZ = (long) node.signature.blockZ() - blockZ; + nearest.offer(node.signature, square(deltaX) + square(deltaZ)); + + Node first = node.left; + Node second = node.right; + if (first == null || second != null + && second.distanceSquaredToBounds(blockX, blockZ) + < first.distanceSquaredToBounds(blockX, blockZ)) { + first = node.right; + second = node.left; + } + search(first, blockX, blockZ, nearest); + search(second, blockX, blockZ, nearest); + } + } + + private static final class Node { + private final TerrainBoundarySignature signature; + private final Node left; + private final Node right; + private final int minimumX; + private final int maximumX; + private final int minimumZ; + private final int maximumZ; + + private Node(TerrainBoundarySignature signature, Node left, Node right) { + this.signature = signature; + this.left = left; + this.right = right; + minimumX = Math.min(signature.blockX(), Math.min(minimumX(left), minimumX(right))); + maximumX = Math.max(signature.blockX(), Math.max(maximumX(left), maximumX(right))); + minimumZ = Math.min(signature.blockZ(), Math.min(minimumZ(left), minimumZ(right))); + maximumZ = Math.max(signature.blockZ(), Math.max(maximumZ(left), maximumZ(right))); + } + + private double distanceSquaredToBounds(int blockX, int blockZ) { + long distanceX = distanceToRange(blockX, minimumX, maximumX); + long distanceZ = distanceToRange(blockZ, minimumZ, maximumZ); + return square(distanceX) + square(distanceZ); + } + + private static int minimumX(Node node) { + return node == null ? Integer.MAX_VALUE : node.minimumX; + } + + private static int maximumX(Node node) { + return node == null ? Integer.MIN_VALUE : node.maximumX; + } + + private static int minimumZ(Node node) { + return node == null ? Integer.MAX_VALUE : node.minimumZ; + } + + private static int maximumZ(Node node) { + return node == null ? Integer.MIN_VALUE : node.maximumZ; + } + } + + private static final class Nearest { + private final TerrainBoundarySignature[] signatures = new TerrainBoundarySignature[NEIGHBOUR_COUNT]; + private final double[] distancesSquared = new double[NEIGHBOUR_COUNT]; + private final double maximumDistanceSquared; + private int count; + + private Nearest(double maximumDistanceSquared) { + this.maximumDistanceSquared = maximumDistanceSquared; + } + + private void offer(TerrainBoundarySignature signature, double distanceSquared) { + if (distanceSquared > maximumDistanceSquared) { + return; + } + int insertion = 0; + while (insertion < count && compare( + distancesSquared[insertion], + signatures[insertion], + distanceSquared, + signature + ) <= 0) { + insertion++; + } + if (insertion >= NEIGHBOUR_COUNT) { + return; + } + int copyLength = Math.min(count, NEIGHBOUR_COUNT - 1) - insertion; + if (copyLength > 0) { + System.arraycopy(signatures, insertion, signatures, insertion + 1, copyLength); + System.arraycopy(distancesSquared, insertion, distancesSquared, insertion + 1, copyLength); + } + signatures[insertion] = signature; + distancesSquared[insertion] = distanceSquared; + count = Math.min(NEIGHBOUR_COUNT, count + 1); + } + + private double limitSquared() { + return count < NEIGHBOUR_COUNT + ? maximumDistanceSquared + : Math.min(maximumDistanceSquared, distancesSquared[NEIGHBOUR_COUNT - 1]); + } + + private static int compare( + double firstDistance, + TerrainBoundarySignature first, + double secondDistance, + TerrainBoundarySignature second + ) { + int distanceComparison = Double.compare(firstDistance, secondDistance); + if (distanceComparison != 0) { + return distanceComparison; + } + int xComparison = Integer.compare(first.blockX(), second.blockX()); + return xComparison != 0 + ? xComparison + : Integer.compare(first.blockZ(), second.blockZ()); + } + } + + private static final class ChunkCache { + private final TransitionGenerationPlan.TerrainSample[] samples = + new TransitionGenerationPlan.TerrainSample[ + GenerationBoundary.CHUNK_SIZE * GenerationBoundary.CHUNK_SIZE + ]; + private int chunkX = Integer.MIN_VALUE; + private int chunkZ = Integer.MIN_VALUE; + private CandidateIndex candidates = CandidateIndex.EMPTY; + + private void reset(int nextChunkX, int nextChunkZ, CandidateIndex nextCandidates) { + Arrays.fill(samples, null); + chunkX = nextChunkX; + chunkZ = nextChunkZ; + candidates = nextCandidates; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/TransitionGenerationPlan.java b/core/src/main/java/art/arcane/iris/engine/history/TransitionGenerationPlan.java new file mode 100644 index 000000000..0dc14c9ba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/TransitionGenerationPlan.java @@ -0,0 +1,221 @@ +package art.arcane.iris.engine.history; + +import java.util.Objects; +import java.util.Optional; + +public final class TransitionGenerationPlan { + private final Specification specification; + private final GenerationBoundary boundary; + private final TerrainBoundarySignatureStore.Snapshot terrainSignatures; + private final TransitionBoundarySampler terrainSampler; + + public TransitionGenerationPlan( + Specification specification, + GenerationBoundary boundary, + TerrainBoundarySignatureStore.Snapshot terrainSignatures + ) { + this.specification = Objects.requireNonNull(specification, "Transition specification"); + this.boundary = Objects.requireNonNull(boundary, "Generation boundary"); + this.terrainSignatures = Objects.requireNonNull(terrainSignatures, "Terrain signatures"); + if (!specification.boundaryIdentity().equals(boundary.identity())) { + throw new IllegalArgumentException("Transition boundary identity does not match the frozen boundary"); + } + if (specification.activationId() != terrainSignatures.activationId()) { + throw new IllegalArgumentException("Terrain signatures belong to a different activation"); + } + if (!specification.terrainSignatureIdentity().equals(terrainSignatures.identity())) { + throw new IllegalArgumentException("Transition terrain signature identity does not match the snapshot"); + } + if (specification.algorithmVersion() != GenerationTransition.CURRENT_ALGORITHM_VERSION) { + throw new IllegalArgumentException("Unsupported generation transition algorithm version " + + specification.algorithmVersion()); + } + terrainSampler = new TransitionBoundarySampler( + specification.widthBlocks(), + terrainSignatures + ); + } + + public long activationId() { + return specification.activationId(); + } + + public String oldEpochId() { + return specification.oldEpochId(); + } + + public String newEpochId() { + return specification.newEpochId(); + } + + public int widthBlocks() { + return specification.widthBlocks(); + } + + public int algorithmVersion() { + return specification.algorithmVersion(); + } + + public String boundaryIdentity() { + return specification.boundaryIdentity(); + } + + public Specification specification() { + return specification; + } + + public GenerationBoundary boundary() { + return boundary; + } + + public TerrainBoundarySignatureStore.Snapshot terrainSignatures() { + return terrainSignatures; + } + + public boolean isHistoricalBlock(int blockX, int blockZ) { + return boundary.isHistoricalBlock(blockX, blockZ); + } + + public double distanceToHistoricalChunks(int blockX, int blockZ) { + return Math.min(terrainSampleAt(blockX, blockZ).distanceToHistoricalTerrain(), widthBlocks()); + } + + public double newEpochWeightAt(int blockX, int blockZ) { + return terrainSampleAt(blockX, blockZ).newEpochWeight(); + } + + public double hydrologyWeightAt(int blockX, int blockZ) { + return terrainSampleAt(blockX, blockZ).hydrologyWeight(); + } + + public boolean allowsNewDiscreteContentAt(int blockX, int blockZ) { + return terrainSampleAt(blockX, blockZ).newEpochWeight() == 1D; + } + + public TerrainSample terrainSampleAt(int blockX, int blockZ) { + if (boundary.isHistoricalBlock(blockX, blockZ)) { + return TerrainSample.historicalTerrain(); + } + return terrainSampler.sample(blockX, blockZ); + } + + public Optional historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { + TerrainSample sample = terrainSampleAt(blockX, blockZ); + if (sample.newEpochWeight() == 1D) { + return Optional.empty(); + } + return sample.historicalPhysicalBiomeKeyAt(blockY); + } + + public boolean allowsNewFootprint(int minimumX, int minimumZ, int maximumX, int maximumZ) { + if (minimumX > maximumX || minimumZ > maximumZ) { + throw new IllegalArgumentException("Generation footprint bounds are inverted"); + } + return !boundary.intersectsHistoricalBlocks(minimumX, minimumZ, maximumX, maximumZ) + && !terrainSampler.intersectsTerrainBand(minimumX, minimumZ, maximumX, maximumZ); + } + + long terrainCatalogProbeCount() { + return terrainSampler.catalogProbeCount(); + } + + long terrainShardLoadCount() { + return terrainSampler.shardLoadCount(); + } + + long terrainCandidateBuildCount() { + return terrainSampler.candidateBuildCount(); + } + + public record Specification( + long activationId, + String oldEpochId, + String newEpochId, + int algorithmVersion, + int widthBlocks, + String boundaryIdentity, + String terrainSignatureIdentity + ) { + public Specification { + if (activationId <= 0L) { + throw new IllegalArgumentException("Activation ID must be positive"); + } + oldEpochId = requireIdentifier(oldEpochId, "Old epoch ID"); + newEpochId = requireIdentifier(newEpochId, "New epoch ID"); + boundaryIdentity = requireIdentifier(boundaryIdentity, "Boundary identity"); + terrainSignatureIdentity = requireIdentifier( + terrainSignatureIdentity, + "Terrain signature identity" + ); + if (oldEpochId.equals(newEpochId)) { + throw new IllegalArgumentException("Transition epoch IDs must differ"); + } + if (widthBlocks <= 0) { + throw new IllegalArgumentException("Transition width must be positive"); + } + if (algorithmVersion < 1) { + throw new IllegalArgumentException("Transition algorithm version must be positive"); + } + } + + private static String requireIdentifier(String identifier, String label) { + String requiredIdentifier = Objects.requireNonNull(identifier, label); + if (requiredIdentifier.isBlank()) { + throw new IllegalArgumentException(label + " cannot be blank"); + } + return requiredIdentifier; + } + } + + public record TerrainSample( + double distanceToHistoricalTerrain, + double newEpochWeight, + double hydrologyWeight, + TerrainBoundarySignature nearestSignature, + double historicalSurfaceHeight, + double historicalOceanFloorHeight, + double historicalUpperCeilingDepth + ) { + public TerrainSample { + if (!Double.isFinite(distanceToHistoricalTerrain) || distanceToHistoricalTerrain < 0D) { + throw new IllegalArgumentException("Historical terrain distance must be finite and non-negative"); + } + if (!Double.isFinite(newEpochWeight) || newEpochWeight < 0D || newEpochWeight > 1D) { + throw new IllegalArgumentException("New epoch weight must be between zero and one"); + } + if (!Double.isFinite(hydrologyWeight) || hydrologyWeight < 0D || hydrologyWeight > 1D) { + throw new IllegalArgumentException("Hydrology weight must be between zero and one"); + } + if (nearestSignature != null + && (!Double.isFinite(historicalSurfaceHeight) + || !Double.isFinite(historicalOceanFloorHeight) + || !Double.isFinite(historicalUpperCeilingDepth) + || historicalUpperCeilingDepth < 0D)) { + throw new IllegalArgumentException("Historical terrain heights must be finite"); + } + } + + static TerrainSample newTerrain(double distance) { + return new TerrainSample(distance, 1D, 1D, null, 0D, 0D, 0D); + } + + static TerrainSample historicalTerrain() { + return new TerrainSample(0D, 0D, 0D, null, 0D, 0D, 0D); + } + + public boolean hasHistoricalSignature() { + return nearestSignature != null; + } + + public Optional historicalPhysicalBiomeKeyAt(int blockY) { + if (nearestSignature == null || nearestSignature.sampleCount() == 0) { + return Optional.empty(); + } + TerrainBoundarySignature.VerticalLayout layout = nearestSignature.samples().layout(); + long offset = (long) blockY - layout.minimumY(); + long roundedIndex = Math.round(offset / (double) layout.sampleStep()); + int sampleIndex = (int) Math.max(0L, Math.min(roundedIndex, layout.sampleCount() - 1L)); + return Optional.of(nearestSignature.biomeAtSample(sampleIndex)); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/history/WorldChunkInventory.java b/core/src/main/java/art/arcane/iris/engine/history/WorldChunkInventory.java new file mode 100644 index 000000000..1f2b25b23 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/history/WorldChunkInventory.java @@ -0,0 +1,238 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.util.nbt.common.mca.MCAFile; +import art.arcane.iris.util.nbt.common.mca.MCAUtil; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class WorldChunkInventory { + private static final int REGION_SIDE = 32; + private static final int SECTOR_BYTES = 4_096; + private static final int HEADER_SECTORS = 2; + private static final int HEADER_BYTES = SECTOR_BYTES * HEADER_SECTORS; + private static final Pattern REGION_FILE = Pattern.compile("^r\\.(-?\\d+)\\.(-?\\d+)\\.mca$"); + + private final Long2ObjectOpenHashMap regionMasks; + private final long[] regionKeys; + private final int chunkCount; + + private WorldChunkInventory(Long2ObjectOpenHashMap regionMasks) { + this.regionMasks = regionMasks; + this.regionKeys = regionMasks.keySet().toLongArray(); + Arrays.sort(regionKeys); + int count = 0; + for (BitSet mask : regionMasks.values()) { + count = Math.addExact(count, mask.cardinality()); + } + this.chunkCount = count; + } + + public static WorldChunkInventory empty() { + return new WorldChunkInventory(new Long2ObjectOpenHashMap<>()); + } + + public static WorldChunkInventory ofPackedChunks(long... packedChunks) { + Long2ObjectOpenHashMap masks = new Long2ObjectOpenHashMap<>(); + for (long packed : packedChunks) { + int chunkX = ChunkGenerationOwnership.chunkX(packed); + int chunkZ = ChunkGenerationOwnership.chunkZ(packed); + long regionKey = ChunkGenerationOwnership.packChunk(chunkX >> 5, chunkZ >> 5); + masks.computeIfAbsent(regionKey, ignored -> new BitSet(REGION_SIDE * REGION_SIDE)) + .set(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31)); + } + return new WorldChunkInventory(masks); + } + + public static WorldChunkInventory scan(Path worldDirectory) throws IOException { + return scanRegionDirectory(worldDirectory.resolve("region")); + } + + public static boolean isDurablyAllocated(Path worldDirectory, int chunkX, int chunkZ) throws IOException { + Path regionDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory") + .toAbsolutePath() + .normalize() + .resolve("region"); + if (!Files.exists(regionDirectory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(regionDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("World region path is not a directory: " + regionDirectory); + } + int regionX = Math.floorDiv(chunkX, REGION_SIDE); + int regionZ = Math.floorDiv(chunkZ, REGION_SIDE); + Path regionFile = regionDirectory.resolve(MCAUtil.createNameFromRegionLocation(regionX, regionZ)); + if (!Files.exists(regionFile, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isRegularFile(regionFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("World region entry is not a regular file: " + regionFile); + } + BitSet mask = scanAllocationTable(regionFile); + return mask.get(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31)); + } + + public static WorldChunkInventory scanRegionDirectory(Path regionDirectory) throws IOException { + Path normalized = regionDirectory.toAbsolutePath().normalize(); + if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) { + return empty(); + } + if (!Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("World region path is not a directory: " + normalized); + } + + List regionFiles = collectRegionFiles(normalized); + Long2ObjectOpenHashMap masks = new Long2ObjectOpenHashMap<>(regionFiles.size()); + for (Path regionFile : regionFiles) { + RegionPosition position = parseRegionPosition(regionFile); + long regionKey = ChunkGenerationOwnership.packChunk(position.x(), position.z()); + if (masks.containsKey(regionKey)) { + throw new IOException( + "Duplicate world region file for " + position.x() + "," + position.z() + ); + } + masks.put(regionKey, scanAllocationTable(regionFile)); + } + return new WorldChunkInventory(masks); + } + + public int size() { + return chunkCount; + } + + public boolean isEmpty() { + return chunkCount == 0; + } + + public boolean contains(int chunkX, int chunkZ) { + BitSet mask = regionMasks.get(ChunkGenerationOwnership.packChunk(chunkX >> 5, chunkZ >> 5)); + return mask != null && mask.get(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31)); + } + + public void forEach(ChunkConsumer consumer) throws IOException { + Objects.requireNonNull(consumer, "consumer"); + for (long regionKey : regionKeys) { + int baseX = MCAUtil.regionToChunk(ChunkGenerationOwnership.chunkX(regionKey)); + int baseZ = MCAUtil.regionToChunk(ChunkGenerationOwnership.chunkZ(regionKey)); + BitSet mask = regionMasks.get(regionKey); + for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) { + consumer.accept(baseX + (index & 31), baseZ + (index >> 5)); + } + } + } + + int retainedAllocationBytes() { + return Math.multiplyExact(regionKeys.length, REGION_SIDE * REGION_SIDE / Byte.SIZE); + } + + private static List collectRegionFiles(Path directory) throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.mca")) { + for (Path file : stream) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("World region entry is not a regular file: " + file); + } + files.add(file); + } + } + files.sort(Comparator.comparing(path -> path.getFileName().toString())); + return files; + } + + private static RegionPosition parseRegionPosition(Path file) throws IOException { + String name = file.getFileName().toString(); + Matcher matcher = REGION_FILE.matcher(name); + if (!matcher.matches()) { + throw new IOException("Invalid world region filename: " + file); + } + try { + int regionX = Integer.parseInt(matcher.group(1)); + int regionZ = Integer.parseInt(matcher.group(2)); + if (!MCAUtil.createNameFromRegionLocation(regionX, regionZ).equals(name)) { + throw new IOException("Noncanonical world region filename: " + file); + } + validateRegionCoordinate(file, regionX); + validateRegionCoordinate(file, regionZ); + return new RegionPosition(regionX, regionZ); + } catch (NumberFormatException error) { + throw new IOException("World region coordinate is outside the supported range: " + file, error); + } + } + + private static BitSet scanAllocationTable(Path file) throws IOException { + try (RandomAccessFile input = new RandomAccessFile(file.toFile(), "r")) { + long length = input.length(); + if (length < HEADER_BYTES || length % SECTOR_BYTES != 0L) { + throw new IOException("Truncated world region file: " + file); + } + long sectorCapacity = length / SECTOR_BYTES; + BitSet claimedSectors = new BitSet(Math.toIntExact(sectorCapacity)); + claimedSectors.set(0, HEADER_SECTORS); + BitSet mask = new BitSet(REGION_SIDE * REGION_SIDE); + for (int localZ = 0; localZ < REGION_SIDE; localZ++) { + for (int localX = 0; localX < REGION_SIDE; localX++) { + int index = MCAFile.getChunkIndex(localX, localZ); + input.seek((long) index * Integer.BYTES); + int location = input.readInt(); + if (location == 0) { + continue; + } + int sectorOffset = location >>> Byte.SIZE; + int sectorCount = location & 0xFF; + validateAllocation(file, sectorOffset, sectorCount, sectorCapacity, claimedSectors); + mask.set(index); + } + } + return mask; + } catch (ArithmeticException error) { + throw new IOException("World region file is too large to scan safely: " + file, error); + } + } + + private static void validateAllocation( + Path file, + int sectorOffset, + int sectorCount, + long sectorCapacity, + BitSet claimedSectors + ) throws IOException { + long allocationEnd = (long) sectorOffset + sectorCount; + if (sectorOffset < HEADER_SECTORS || sectorCount == 0 || allocationEnd > sectorCapacity) { + throw new IOException("Invalid chunk allocation in world region file: " + file); + } + int overlap = claimedSectors.nextSetBit(sectorOffset); + if (overlap >= 0 && overlap < allocationEnd) { + throw new IOException("Overlapping chunk allocations in world region file: " + file); + } + claimedSectors.set(sectorOffset, Math.toIntExact(allocationEnd)); + } + + private static void validateRegionCoordinate(Path file, int regionCoordinate) throws IOException { + int minimum = Math.floorDiv(Integer.MIN_VALUE, REGION_SIDE); + int maximum = Math.floorDiv(Integer.MAX_VALUE, REGION_SIDE); + if (regionCoordinate < minimum || regionCoordinate > maximum) { + throw new IOException("World region coordinate is outside the chunk coordinate range: " + file); + } + } + + @FunctionalInterface + public interface ChunkConsumer { + void accept(int chunkX, int chunkZ) throws IOException; + } + + private record RegionPosition(int x, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java index 77c1d59d0..74c3d7579 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java @@ -15,6 +15,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; public final class HydrologyTile { private final HydrologyTileKey key; @@ -172,8 +173,23 @@ public final class HydrologyTile { int x, int z, int maximumDistance + ) { + return nearestFeature(types, profileKey, x, z, maximumDistance, feature -> true); + } + + public Optional nearestFeature( + Set types, + String profileKey, + int x, + int z, + int maximumDistance, + Predicate eligibility ) { Objects.requireNonNull(types, "types"); + Predicate requiredEligibility = Objects.requireNonNull( + eligibility, + "eligibility" + ); if (maximumDistance < 0) { throw new IllegalArgumentException("maximumDistance cannot be negative."); } @@ -196,7 +212,7 @@ public final class HydrologyTile { continue; } HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point); - if (feature == null) { + if (feature == null || !requiredEligibility.test(feature)) { continue; } if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) { 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 448f8407a..9ac6649b7 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 @@ -72,6 +72,7 @@ import java.util.Set; import java.nio.file.Path; import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; +import java.util.function.Predicate; public final class IrisHydrologyRuntime implements AutoCloseable { private static final int MAXIMUM_CACHE_TILES = 64; @@ -351,7 +352,15 @@ public final class IrisHydrologyRuntime implements AutoCloseable { int z, int maximumDistance ) { - return nearestFeature(types, profileKey, x, z, maximumDistance, (int visited) -> { }); + return nearestFeature( + types, + profileKey, + x, + z, + maximumDistance, + feature -> true, + (int visited) -> { } + ); } /** @@ -366,8 +375,32 @@ public final class IrisHydrologyRuntime implements AutoCloseable { int z, int maximumDistance, IntConsumer progress + ) { + return nearestFeature( + types, + profileKey, + x, + z, + maximumDistance, + feature -> true, + progress + ); + } + + public Optional nearestFeature( + Set types, + String profileKey, + int x, + int z, + int maximumDistance, + Predicate eligibility, + IntConsumer progress ) { Objects.requireNonNull(types); + Predicate requiredEligibility = Objects.requireNonNull( + eligibility, + "eligibility" + ); Objects.requireNonNull(progress); if (types.isEmpty() || maximumDistance < 0) { return Optional.empty(); @@ -393,7 +426,13 @@ public final class IrisHydrologyRuntime implements AutoCloseable { for (HydrologyTile tile : cache.tiles(HydrologyFeatureSearch.ring(originTileX, originTileZ, ring))) { visited++; HydrologyFeatureRef feature = tile.nearestFeature( - types, profileKey, x, z, maximumDistance).orElse(null); + types, + profileKey, + x, + z, + maximumDistance, + requiredEligibility + ).orElse(null); if (feature == null) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 091b7c940..f48527f2b 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -209,16 +209,13 @@ public interface EngineMantle extends MatterGenerator { if (protectUpper) { int chunkBlockX = x << 4; int chunkBlockZ = z << 4; - int gap = getEngine().getDimension().getUpperDimensionGap(); int[] upperYs = new int[256]; for (int i = 0; i < 256; i++) { int lx = i >> 4; int lz = i & 15; int worldX = chunkBlockX + lx; int worldZ = chunkBlockZ + lz; - int he = (int) Math.round(getEngine().getComplex().getHeightStream().get((double) worldX, (double) worldZ)); - int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ); - upperYs[i] = Math.max(rawUpper, he + gap); + upperYs[i] = upperCtx.getEffectiveSurfaceY(worldX, worldZ); } chunk.iterate(PlatformBlockState.class, (lx, y, lz, value) -> { int colIdx = (lx << 4) | (lz & 15); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 99715b6d1..3953b5528 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -28,6 +28,8 @@ import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.object.IrisGeneratorStyle; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.TileData; @@ -73,6 +75,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { private final int z; private final int windowSide; private final AtomicReferenceArray> window; + private final TransitionGenerationPlan transitionGenerationPlan; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @EqualsAndHashCode.Exclude @@ -97,6 +100,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { this.radius = accessRadius; this.x = x; this.z = z; + IrisComplex complex = engineMantle.getComplex(); + this.transitionGenerationPlan = complex == null ? null : complex.getTransitionGenerationPlan(); // Every coordinate acquireChunk accepts lives in this window, so a flat array replaces the // boxed per-block map lookup on the placement and carve hot paths. this.windowSide = (this.radius * 2) + 1; @@ -230,7 +235,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public void setData(int x, int y, int z, T t) { - if (t == null) { + if (t == null || !allowsWrite(x, z)) { return; } @@ -264,7 +269,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public boolean setDataIfAbsent(int x, int y, int z, MatterCavern value) { - if (value == null) { + if (value == null || !allowsWrite(x, z)) { return false; } @@ -305,7 +310,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public boolean carveDataIfAbsent(int x, int y, int z, MatterCavern value) { - if (value == null || y < 0 || y >= mantle.getWorldHeight()) { + if (value == null || !allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return false; } @@ -341,7 +346,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public void setForcedCarve(int x, int y, int z, MatterCavern value) { - if (value == null || y < 0 || y >= mantle.getWorldHeight()) { + if (value == null || !allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return; } MantleChunk chunk = acquireChunk(x >> 4, z >> 4); @@ -366,7 +371,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public void clearBlock(int x, int y, int z) { - if (y < 0 || y >= mantle.getWorldHeight()) { + if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return; } MantleChunk chunk = acquireChunk(x >> 4, z >> 4); @@ -402,6 +407,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return null; } + if (!allowsWrite(x, z)) { + return getDataIfPresent(x, y, z, type); + } + MantleChunk chunk = acquireChunk(cx, cz); if (chunk == null) { return null; @@ -526,7 +535,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { public boolean restorePrerequisiteData(int x, int y, int z, Class type) { Objects.requireNonNull(type, "type"); - if (y < 0 || y >= mantle.getWorldHeight() || !isPreObjectType(type)) { + if (!allowsWrite(x, z) + || y < 0 + || y >= mantle.getWorldHeight() + || !isPreObjectType(type)) { return false; } @@ -551,7 +563,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public boolean restorePrerequisiteCell(int x, int y, int z) { - if (y < 0 || y >= mantle.getWorldHeight()) { + if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return false; } @@ -584,7 +596,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public void clearData(int x, int y, int z, Class type) { - if (y < 0 || y >= mantle.getWorldHeight()) { + if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return; } @@ -624,7 +636,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } private void setCustomBlock(int x, int y, int z, PlatformBlockState baseState, Identifier identifier) { - if (y < 0 || y >= mantle.getWorldHeight()) { + if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { return; } if (y == 0 && engineMantle.getEngine().getDimension().isBedrock()) { @@ -711,6 +723,11 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return matter.getSlice(type).get(x, y, z); } + private boolean allowsWrite(int blockX, int blockZ) { + return transitionGenerationPlan == null + || !transitionGenerationPlan.isHistoricalBlock(blockX, blockZ); + } + @SuppressWarnings("unchecked") private static void restoreRaw(Matter matter, int x, int y, int z, Class type, Object value) { MatterSlice slice; 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 10fb954d0..045af2ce5 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 @@ -1,5 +1,6 @@ package art.arcane.iris.engine.mantle; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.spi.IrisLogging; @@ -45,10 +46,23 @@ public interface MatterGenerator { if (!getEngine().getDimension().isUseMantle()) { return; } - generateMatterWindow(x, z, multicore, context); + IrisComplex complex = context == null ? null : context.getComplex(); + if (complex == null) { + complex = getEngine().getComplex(); + } + if (complex != null && !complex.allowsMantleChunkWrite(x, z)) { + return; + } + generateMatterWindow(x, z, multicore, context, complex); } - private void generateMatterWindow(int x, int z, boolean multicore, ChunkContext context) { + private void generateMatterWindow( + int x, + int z, + boolean multicore, + ChunkContext context, + IrisComplex complex + ) { MatterGenerationPlan generationPlan = resolveGenerationPlan(x, z, context); MatterPassPlan[] passPlans = generationPlan.passPlans(); @@ -128,6 +142,11 @@ public interface MatterGenerator { continue; } + if (complex != null && !complex.allowsMantleChunkWrite(passX, passZ)) { + partialChunks.add(passKey); + continue; + } + if (partial) { partialChunks.add(passKey); } @@ -207,6 +226,9 @@ public interface MatterGenerator { if (partialChunks.contains(realKey)) { continue; } + if (complex != null && !complex.allowsMantleChunkWrite(realX, realZ)) { + continue; + } writer.acquireChunk(realX, realZ).flag(MantleFlag.PLANNED, true); } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java index 6b0be485b..f55301f7b 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java @@ -21,6 +21,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; @@ -67,7 +68,13 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { return CommitResult.EMPTY; } + IrisComplex complex = engine == null ? null : engine.getComplex(); for (BufferedMutation mutation : mutations) { + if (complex != null + && !complex.allowsNewDiscreteContentAt(mutation.x(), mutation.z())) { + discard(); + return CommitResult.REJECTED_TRANSITION; + } if (!isWithinBounds(mutation.x(), mutation.y(), mutation.z())) { discard(); return CommitResult.REJECTED_BOUNDS; @@ -252,6 +259,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { enum CommitResult { COMMITTED, EMPTY, + REJECTED_TRANSITION, REJECTED_BOUNDS, REJECTED_HYDROLOGY } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java index 6d703de1d..38b84eb4c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.spi.PlatformBlockState; @@ -47,7 +48,14 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.EMPTY; } + Engine engine = delegate.getEngine(); + IrisComplex complex = engine == null ? null : engine.getComplex(); for (BufferedMutation mutation : mutations) { + if (complex != null + && !complex.allowsNewDiscreteContentAt(mutation.x(), mutation.z())) { + discard(); + return CommitResult.REJECTED_TRANSITION; + } if (!delegate.canWriteObjectBlock(mutation.x(), mutation.y(), mutation.z())) { discard(); return CommitResult.REJECTED_SUPPORT; @@ -170,6 +178,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { enum CommitResult { COMMITTED, EMPTY, + REJECTED_TRANSITION, REJECTED_SUPPORT } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index b497ede03..3a4c409ae 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -21,6 +21,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructurePlacementScope; @@ -76,17 +77,27 @@ public class IrisStructureComponent extends IrisMantleComponent { @Override @ChunkCoordinates public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + IrisComplex complex = context.getComplex(); + if (!complex.allowsNewGenerationChunk(x, z)) { + return; + } for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt( getEngineMantle().getEngine(), x, z)) { if (!placement.hasIrisStructures()) { continue; } - placeFromPlacement(writer, placement, x, z); + placeFromPlacement(writer, placement, x, z, complex); } } @ChunkCoordinates - private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz) { + private void placeFromPlacement( + MantleWriter writer, + IrisStructurePlacement placement, + int cx, + int cz, + IrisComplex complex + ) { IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement( getEngineMantle().getEngine(), placement, cx, cz); if (resolved == null) { @@ -105,6 +116,19 @@ public class IrisStructureComponent extends IrisMantleComponent { if (pieces == null) { return; } + IrisStructureTerrain terrain = placement.resolvedTerrain(); + int[] bounds = computePieceBounds(pieces); + int horizontalPadding = terrain.resolvedMode() == IrisStructureTerrainMode.FORCE_CARVE + || terrain.resolvedMode() == IrisStructureTerrainMode.BORE + ? Math.max(0, terrain.getHorizontalPadding()) + : 0; + if (bounds == null || !complex.allowsNewGenerationFootprint( + saturatedOffset(bounds[0], -horizontalPadding), + saturatedOffset(bounds[2], -horizontalPadding), + saturatedOffset(bounds[3], horizontalPadding), + saturatedOffset(bounds[5], horizontalPadding))) { + return; + } RNG rng = resolved.rng(); int baseY = resolved.baseY(); if (trace) { @@ -115,7 +139,6 @@ public class IrisStructureComponent extends IrisMantleComponent { clearIntersectingObjectTrees(writer, resolved); } - IrisStructureTerrain terrain = placement.resolvedTerrain(); IrisStructureTerrainMode terrainMode = terrain.resolvedMode(); if (terrainMode == IrisStructureTerrainMode.FORCE_CARVE) { forceCarveStructure(writer, pieces, terrain); @@ -529,6 +552,11 @@ public class IrisStructureComponent extends IrisMantleComponent { return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; } + private static int saturatedOffset(int coordinate, int offset) { + long result = (long) coordinate + offset; + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result)); + } + private int placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng, Long2IntOpenHashMap foundationColumns, boolean supportNonOccluding) { 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 139da6364..9fe4eb392 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 @@ -98,6 +98,9 @@ public class MantleCarvingComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); + if (!complex.allowsNewGenerationChunk(x, z)) { + return; + } IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State(); BlendScratch blendScratch = BLEND_SCRATCH.get(); int[] chunkSurfaceHeights = prepareChunkSurfaceHeights(x, z, context, blendScratch.chunkSurfaceHeights); @@ -121,7 +124,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); if (upperCtx != null && getDimension().isUpperDimensionCarving()) { - carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, fluidSupportPlan); + carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, fluidSupportPlan); } fluidSupportPlan.resolve(writer.acquireChunk(x, z)); @@ -154,11 +157,10 @@ public class MantleCarvingComponent extends IrisMantleComponent { } private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, - MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights, + MantleWriter writer, int cx, int cz, CaveFluidSupportPlan fluidSupportPlan) { int chunkHeight = getEngineMantle().getEngine().getHeight(); int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight(); - int gap = getDimension().getUpperDimensionGap(); int baseX = PowerOfTwoCoordinates.chunkToBlock(cx); int baseZ = PowerOfTwoCoordinates.chunkToBlock(cz); @@ -167,9 +169,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { int worldX = baseX + localX; for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { int worldZ = baseZ + localZ; - int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); - int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ); - int upperY = Math.max(rawUpper, lowerSurfaceHeights[columnIndex] + gap); + int upperY = upperCtx.getEffectiveSurfaceY(worldX, worldZ); if (upperY < minUpperSurfaceY) { minUpperSurfaceY = upperY; } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index 192ed44f3..90106eccb 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -65,6 +65,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); + if (!complex.allowsNewGenerationChunk(x, z)) { + return; + } IrisData data = getData(); int minX = x << 4; int minZ = z << 4; @@ -201,6 +204,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int xx = minX + (key & 15); int zz = minZ + (key >> 4); IrisObjectPlacement floatingPlacement = placement.toPlacement(obj.getLoadKey()); + if (!MantleObjectComponent.allowsObjectPlacement( + complex, + obj, + floatingPlacement, + xx, + zz)) { + continue; + } int id = rng.i(0, Integer.MAX_VALUE); try { @@ -279,6 +290,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { anchored.setRotation(ROTATION_NONE); anchored.setForcePlace(true); anchored.setBottom(false); + if (!MantleObjectComponent.allowsObjectPlacement(complex, obj, anchored, wx, wz)) { + continue; + } int yv = pickTopY + 1 - fp.getLowestSolidKeyY(); @@ -385,6 +399,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { inverted.setRotation(invertedRotation); inverted.setForcePlace(true); inverted.setBottom(false); + if (!MantleObjectComponent.allowsObjectPlacement(complex, obj, inverted, wx, wz)) { + continue; + } int yv = invertedBaseY(pickBottomY, fp, invertedRotation); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java index e20548f64..cf51f3dcd 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java @@ -11,6 +11,7 @@ import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; @@ -71,8 +72,9 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { - IrisHydrologyRuntime runtime = context.getComplex().getHydrologyRuntime(); - if (runtime == null) { + IrisComplex complex = context.getComplex(); + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); + if (runtime == null || !complex.allowsNewGenerationChunk(chunkX, chunkZ)) { return; } int minimumX = chunkX << 4; @@ -88,14 +90,15 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { CaveVoxelView caveView = new MantleHydrologyCaveVoxelView( writer.getMantle(), writer.getMantle().getWorldHeight(), - (x, z) -> runtime.sample(x, z) - .map(HydrologyColumnSample::terrainHeight) - .orElseGet(() -> (int) Math.round( - context.getComplex().getNaturalHeightStream().getDouble(x, z) - )), + (x, z) -> { + HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z); + return sample == null + ? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) + : sample.terrainHeight(); + }, (inputChunkX, inputChunkZ) -> MantleHydrologyCaveVoxelView.generateCarvingInput( getEngineMantle(), - context.getComplex(), + complex, inputChunkX, inputChunkZ ) @@ -104,7 +107,7 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { chunkX, chunkZ, writer.getMantle().getWorldHeight(), - runtime::sample, + (x, z) -> Optional.ofNullable(complex.sampleHydrologyColumn(x, z)), cavePlans, caveView ); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index a49bd7d8a..12107c557 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -183,6 +183,9 @@ public class MantleObjectComponent extends IrisMantleComponent { void generateOrigin(ObjectPassPlacer writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); + if (!complex.allowsNewGenerationChunk(x, z)) { + return; + } boolean traceRegen = isRegenTraceThread(); RNG rng = applyNoise(x, z, Cache.key(x, z) + seed()); int xxx = 8 + (x << 4); @@ -585,6 +588,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return; } + IrisComplex complex = getComplex(); int blockX = x << 4; int blockZ = z << 4; boolean golden = GoldenDebugObjectPlacer.isGoldenDebugChunk(x, z); @@ -693,6 +697,9 @@ public class MantleObjectComponent extends IrisMantleComponent { placeResult = contained.resultY(); commitResult = contained.commitResult(); } else { + if (!allowsObjectPlacement(complex, variant, placement, xx, zz)) { + continue; + } String marker = placementMarker(variant, id, "procedural"); placeResult = variant.place(xx, -1, zz, placer, placement, rng, (b, data) -> { if (marker != null) { @@ -813,6 +820,12 @@ public class MantleObjectComponent extends IrisMantleComponent { boolean treePlacement, RNG rng ) { + IrisComplex complex = placer.getEngine() == null ? null : placer.getEngine().getComplex(); + if (!allowsObjectPlacement(complex, object, placement, x, z)) { + return new ContainedPlacementResult( + -1, + CaveObjectPlacementTransaction.CommitResult.REJECTED_TRANSITION); + } CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(placer, anchorY, minDepthBelowSurface); int placeY = anchorY; if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) { @@ -875,6 +888,10 @@ public class MantleObjectComponent extends IrisMantleComponent { int xx = rng.i(x, x + 16); int zz = rng.i(z, z + 16); IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); + if (!allowsObjectPlacement(complex, v, effectivePlacement, xx, zz)) { + rejected++; + continue; + } boolean treePlacement = isTreePlacement(v, effectivePlacement); int id = rng.i(0, Integer.MAX_VALUE); IObjectPlacer placePlacer = golden ? new GoldenDebugObjectPlacer(writer, scope + "/" + v.getLoadKey()) : writer; @@ -1186,7 +1203,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } try { - placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, dimension, complex, forcePlace, traceRegen, "upper-biome-surface"); + placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-biome-surface"); } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.error("Failed to place upper-dimension objects in biome " + upperBiome.getName() @@ -1201,7 +1218,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } try { - placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, dimension, complex, forcePlace, traceRegen, "upper-region-surface"); + placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-region-surface"); } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.error("Failed to place upper-dimension objects in region " + upperRegion.getName() @@ -1219,14 +1236,12 @@ public class MantleObjectComponent extends IrisMantleComponent { int chunkZ, IrisObjectPlacement objectPlacement, UpperDimensionContext upperCtx, - IrisDimension dimension, IrisComplex complex, boolean forcePlace, boolean traceRegen, String scope ) { int chunkHeight = getEngineMantle().getEngine().getHeight(); - int upperGap = dimension.getUpperDimensionGap(); int minX = chunkX << 4; int minZ = chunkZ << 4; int density = objectPlacement.getDensity(rng, minX, minZ, getData()); @@ -1239,9 +1254,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int xx = rng.i(minX, minX + 16); int zz = rng.i(minZ, minZ + 16); - int columnLowerSurfaceY = getEngineMantle().getEngine().getHeight(xx, zz, true); - int rawUpperSurface = upperCtx.getUpperSurfaceY(xx, zz); - int upperSurfaceY = Math.max(rawUpperSurface, columnLowerSurfaceY + upperGap); + int upperSurfaceY = upperCtx.getEffectiveSurfaceY(xx, zz); if (upperSurfaceY >= chunkHeight - 2) { continue; } @@ -1260,6 +1273,9 @@ public class MantleObjectComponent extends IrisMantleComponent { if (forcePlace) { placement.setForcePlace(true); } + if (!allowsObjectPlacement(complex, v, placement, xx, zz)) { + continue; + } boolean treePlacement = isTreePlacement(v, objectPlacement); String marker = placementMarker(v, id, "upper"); @@ -1779,6 +1795,31 @@ public class MantleObjectComponent extends IrisMantleComponent { return reach > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) reach; } + static boolean allowsObjectPlacement( + IrisComplex complex, + IrisObject object, + IrisObjectPlacement placement, + int anchorX, + int anchorZ + ) { + if (complex == null || object == null) { + return true; + } + int reach = calculatePlacementReach( + new IrisBlockVector(object.getW(), object.getH(), object.getD()), + placement); + return complex.allowsNewGenerationFootprint( + saturatedOffset(anchorX, -reach), + saturatedOffset(anchorZ, -reach), + saturatedOffset(anchorX, reach), + saturatedOffset(anchorZ, reach)); + } + + private static int saturatedOffset(int coordinate, int offset) { + long result = (long) coordinate + offset; + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result)); + } + private static int scaledDimension(int dimension, double scale) { int absoluteDimension = Math.abs(dimension); if (scale <= 1D) { diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 7d0f3f072..598545eee 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -100,9 +100,7 @@ public class IrisCarveModifier extends EngineAssignedModifier biomeSkyScatter = biome.getBiomeSkyScatter(); + String logicalKey; if (biomeSkyScatter.size() == 1) { - return namespacedBiomeKey(biomeSkyScatter.get(0)); - } - - if (biomeSkyScatter.isEmpty()) { + logicalKey = namespacedBiomeKey(biomeSkyScatter.get(0)); + } else if (biomeSkyScatter.isEmpty()) { return getGroundBiomeKey(biome, rng, engine, x, y, z); + } else { + logicalKey = namespacedBiomeKey(biomeSkyScatter.get( + biome.getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z) + )); } - - return namespacedBiomeKey(biomeSkyScatter.get(biome.getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z))); + return physicalBiomeKey(engine, logicalKey); } static String getGroundBiomeKey(IrisBiome biome, RNG rng, Engine engine, double x, double y, double z) { KList biomeScatter = biome.getBiomeScatter(); + String logicalKey; if (biomeScatter.isEmpty()) { - return biome.getDerivativeKey(); + logicalKey = biome.getDerivativeKey(); + } else if (biomeScatter.size() == 1) { + logicalKey = namespacedBiomeKey(biomeScatter.get(0)); + } else { + logicalKey = namespacedBiomeKey(biomeScatter.get( + biome.getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z) + )); } - - if (biomeScatter.size() == 1) { - return namespacedBiomeKey(biomeScatter.get(0)); - } - - return namespacedBiomeKey(biomeScatter.get(biome.getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z))); + return physicalBiomeKey(engine, logicalKey); } static IrisBiomeCustom getCustomBiome(IrisBiome biome, RNG rng, Engine engine, double x, double y, double z) { @@ -149,4 +153,11 @@ final class IrisBiomeDerivatives { return customDerivitives.get(biome.getBiomeGenerator(rng, engine).fit(0, customDerivitives.size() - 1, x, y, z)); } + + private static String physicalBiomeKey(Engine engine, String logicalKey) { + if (engine == null || logicalKey == null) { + return logicalKey; + } + return engine.getData().physicalBiomeResourceKey(engine.getDimension(), logicalKey); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCustomBiomeAliasResolver.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCustomBiomeAliasResolver.java new file mode 100644 index 000000000..3ad56ef24 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCustomBiomeAliasResolver.java @@ -0,0 +1,39 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.nms.datapack.IDataFixer; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +@FunctionalInterface +public interface IrisCustomBiomeAliasResolver { + Collection aliases( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String physicalResourceKey + ); + + default String physicalResourceKey( + IrisDimension dimension, + IrisBiome biome, + IrisBiomeCustom customBiome, + String currentPhysicalResourceKey + ) throws IOException { + return currentPhysicalResourceKey; + } + + default String generatedSource( + String registryKey, + String resourceKey, + String currentSource, + IDataFixer fixer + ) throws IOException { + return currentSource; + } + + static IrisCustomBiomeAliasResolver none() { + return (dimension, biome, customBiome, physicalResourceKey) -> List.of(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 81320574d..cfd627581 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -30,6 +30,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -64,6 +65,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collection; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -71,6 +73,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.regex.Pattern; @@ -721,16 +724,21 @@ public class IrisDimension extends IrisRegistrant { } public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, KSet biomes) throws IOException { - String namespace = getLoadKey().toLowerCase(Locale.ROOT); - installBiomes(fixer, data, datapackRoots, namespace, "", biomes); + installBiomes(fixer, data, datapackRoots, biomes, IrisCustomBiomeAliasResolver.none()); } - public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, - String namespace, String pathPrefix, KSet biomes) throws IOException { + public void installBiomes( + IDataFixer fixer, + DataProvider data, + KList datapackRoots, + KSet biomes, + IrisCustomBiomeAliasResolver aliasResolver + ) throws IOException { // Tag membership is accumulated in memory and flushed once per tag at the end of the walk. Writing a // tag file per (biome, tag) pair made staging quadratic: every write re-read, re-parsed and re-emitted // a file that grows with every biome added to that tag. KMap> tagMembership = new KMap<>(); + IrisData packData = Objects.requireNonNull(data.getData(), "Iris biome datapack data"); for (IrisBiome irisBiome : getAllBiomes(data)) { if (!irisBiome.isCustom()) { @@ -743,31 +751,58 @@ public class IrisDimension extends IrisRegistrant { // comes from the chunk generator's generation-settings getter, not from the datapack. String derivativeKey = irisBiome.getVanillaDerivativeKey(); - ContentGate contentGate = getLoader() == null ? null : getLoader().getContentGate(); + ContentGate contentGate = packData.getContentGate(); for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - String customBiomeId = customBiome.getId(); String json = customBiome.generateJson(fixer, contentGate); + String currentPhysicalKey = GenerationRegistryContractFactory.customBiomeResourceKey( + getLoadKey(), + customBiome, + contentGate + ); + String physicalKey = aliasResolver.physicalResourceKey( + this, + irisBiome, + customBiome, + currentPhysicalKey + ); + String logicalKey = getLoadKey().toLowerCase(Locale.ROOT) + + ":" + customBiome.getId().toLowerCase(Locale.ROOT); synchronized (biomes) { - if (!biomes.add(customBiomeId)) { - IrisLogging.debug("Duplicate Data Pack Biome: " + getLoadKey() + "/" + customBiomeId); - continue; + biomes.add(physicalKey); + } + + LinkedHashSet resourceKeys = new LinkedHashSet<>(); + resourceKeys.add(physicalKey); + Collection aliases = aliasResolver.aliases(this, irisBiome, customBiome, physicalKey); + if (aliases == null) { + throw new IOException("Custom biome alias resolver returned null for '" + logicalKey + "'."); + } + if (!aliases.isEmpty() + && physicalKey.equals(packData.customBiomeResourceKey(this, customBiome.getId()))) { + resourceKeys.addAll(aliases); + } + for (String resourceKey : resourceKeys) { + String normalizedKey = normalizeResourceKey(resourceKey); + if (normalizedKey == null) { + throw new IOException("Invalid custom biome resource key '" + resourceKey + "'."); } + String emittedJson = aliasResolver.generatedSource( + GenerationRegistryContractFactory.BIOME_REGISTRY, + normalizedKey, + json, + fixer + ); + for (File datapackRoot : datapackRoots) { + writeCustomBiomeDefinition(datapackRoot.toPath(), normalizedKey, emittedJson); + } + collectBiomeTags( + tagMembership, + normalizedKey, + customBiome.getEffectiveTags(derivativeKey) + ); } - - String biomePath = pathPrefix.isBlank() - ? customBiomeId - : pathPrefix + "/" + customBiomeId; - for (File datapackRoot : datapackRoots) { - File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + biomePath + ".json"); - - IrisLogging.debug(" Installing Data Pack Biome: " + output.getPath()); - output.getParentFile().mkdirs(); - IO.writeAll(output, json); - } - collectBiomeTags(tagMembership, namespace + ":" + biomePath, - customBiome.getEffectiveTags(derivativeKey)); } } @@ -776,6 +811,39 @@ public class IrisDimension extends IrisRegistrant { } } + private static void writeCustomBiomeDefinition( + Path datapackRoot, + String resourceKey, + String json + ) throws IOException { + int separator = resourceKey.indexOf(':'); + String namespace = resourceKey.substring(0, separator); + String path = resourceKey.substring(separator + 1); + Path biomeRoot = datapackRoot.resolve("data") + .resolve(namespace) + .resolve("worldgen") + .resolve("biome") + .toAbsolutePath() + .normalize(); + Path output = biomeRoot.resolve(path + ".json").normalize(); + if (!output.startsWith(biomeRoot)) { + throw new IOException("Unsafe custom biome resource key '" + resourceKey + "'."); + } + if (Files.isRegularFile(output)) { + String existing = Files.readString(output, StandardCharsets.UTF_8); + String existingFingerprint = GenerationRegistryContractFactory + .fingerprintCustomBiomeDefinition(existing); + String candidateFingerprint = GenerationRegistryContractFactory + .fingerprintCustomBiomeDefinition(json); + if (!existingFingerprint.equals(candidateFingerprint)) { + throw new IOException("Conflicting custom biome output for '" + resourceKey + "'."); + } + return; + } + IrisLogging.debug(" Installing Data Pack Biome: " + output); + writeAtomic(output, json); + } + public static void clearGeneratedBiomeTags(KList datapackRoots) { for (File datapackRoot : datapackRoots) { File dataFolder = new File(datapackRoot, "data"); @@ -931,11 +999,25 @@ public class IrisDimension extends IrisRegistrant { } public void installDimensionType(IDataFixer fixer, KList datapackRoots) throws IOException { - IrisDimensionType type = getDimensionType(); - String json = type.toJson(fixer); - String dimensionTypeKey = getDimensionTypeKey(); + installDimensionType(fixer, datapackRoots, IrisCustomBiomeAliasResolver.none()); + } - IrisLogging.debug(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"'); + public void installDimensionType( + IDataFixer fixer, + KList datapackRoots, + IrisCustomBiomeAliasResolver sourceResolver + ) throws IOException { + IrisDimensionType type = getDimensionType(); + String dimensionTypeKey = getDimensionTypeKey(); + String resourceKey = "iris:" + dimensionTypeKey; + String json = Objects.requireNonNull(sourceResolver, "sourceResolver").generatedSource( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + resourceKey, + type.toJson(fixer), + fixer + ); + + IrisLogging.debug(" Installing Data Pack Dimension Type: \"" + resourceKey + '"'); for (File datapackRoot : datapackRoots) { File output = new File(datapackRoot, "data/iris/dimension_type/" + dimensionTypeKey + ".json"); output.getParentFile().mkdirs(); 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 67fca364d..01fe75b41 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 @@ -45,6 +45,12 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; 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.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.object.IrisDimensionContractException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; @@ -84,7 +90,9 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.File; +import java.io.IOException; import java.io.PrintWriter; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -111,6 +119,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final IrisWorld world; private final File dataLocation; private final String dimensionKey; + private final @Nullable GenerationHistory generationHistory; private final ReactiveFolder folder; private final ReentrantLock lock = new ReentrantLock(); private final KList populators; @@ -137,7 +146,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Setter private volatile StudioGenerator studioGenerator; - public BukkitChunkGenerator(IrisWorld world, boolean studio, File dataLocation, String dimensionKey) { + public BukkitChunkGenerator( + IrisWorld world, + boolean studio, + File dataLocation, + String dimensionKey, + @Nullable GenerationHistory generationHistory + ) { setup = new AtomicBoolean(false); studioGenerator = null; dummyBiomeProvider = new DummyBiomeProvider(); @@ -149,6 +164,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun this.studioEntryBootstrapActive = new AtomicBoolean(studio); this.dataLocation = dataLocation; this.dimensionKey = dimensionKey; + this.generationHistory = generationHistory; this.folder = new ReactiveFolder( dataLocation, (_a, _b, _c) -> hotloadFromWatcher(), @@ -385,9 +401,37 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun jigsawStudioActive = request != null && session != null && session.sessionId().equals(request.requestId()); - IrisEngine createdEngine = new IrisEngine( - engineTarget, - selectInitializationMode(studio, jigsawStudioActive)); + 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(); + 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); + } + } createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( jigsawStudioActive, studioEntryBootstrapActive.get(), @@ -397,6 +441,66 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun targetCache.reset(); } + private EngineTarget loadActiveGenerationHistoryTarget() throws IOException { + Path packRoot = generationHistory.activePackRoot(); + IrisData data = IrisData.openRuntime(packRoot.toFile()); + try { + data.dump(); + data.clearLists(); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IOException("Active Iris generation pack does not contain dimension '" + + dimensionKey + "': " + packRoot); + } + return new EngineTarget(world, dimension, data); + } catch (Throwable failure) { + if (!data.isClosed()) { + data.close(); + } + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + throw new IOException("Unable to load the active Iris generation pack.", failure); + } + } + + private IrisEngine createEngine(EngineTarget engineTarget) { + IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive); + if (generationHistory == null) { + return new IrisEngine(engineTarget, mode); + } + GenerationActivation active = generationHistory.activeActivation(); + GenerationEpoch epoch = generationHistory.activeEpoch(); + TransitionGenerationPlan plan; + try { + plan = active.isInitial() ? null : generationHistory.transitionPlan(active.activationId()); + } catch (IOException failure) { + throw new IllegalStateException("Unable to load the active Iris generation transition.", failure); + } + return new IrisEngine( + engineTarget, + mode, + generationHistory.paths().activationMantleRoot(active.activationId()), + epoch.kernelVersion(), + plan); + } + + private static RuntimeException propagateEngineSetupFailure(Throwable failure) { + if (failure instanceof RuntimeException runtimeFailure) { + return runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + return new IllegalStateException("Unable to attach Iris generation history.", failure); + } + static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive) { if (!studio) { return IrisEngine.InitializationMode.RUNTIME; @@ -882,10 +986,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } else { ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d); Hunk biomes = Hunk.viewBiomes(tc); - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); - IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openGenerationHistoryCoordinateScope(engine, x << 4, z << 4); + GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore()); blocks.apply(); + if (historyScope != null) { + historyScope.claimGeneratedSemantics(); + } } } @@ -909,6 +1018,23 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + private @Nullable GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope( + Engine currentEngine, + int blockX, + int blockZ + ) throws IOException { + if (generationHistory == null) { + return null; + } + if (!(currentEngine instanceof IrisEngine irisEngine)) { + throw new IllegalStateException("Persistent Iris generation requires an IrisEngine runtime."); + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter() + .orElseThrow(() -> new IllegalStateException( + "Persistent Iris generation has no generation-history runtime router.")); + return router.openCoordinateScope(blockX, blockZ); + } + private boolean isExpectedTeardown(Engine currentEngine, Throwable throwable) { if (throwable instanceof GenerationSessionException generationSessionException && generationSessionException.isExpectedTeardown()) { return true; @@ -967,10 +1093,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun default -> false; }; - try (GenerationSessionLease lease = currentEngine.acquireGenerationLease("bukkit_base_height"); + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = + openGenerationHistoryCoordinateScope(currentEngine, x, z); + GenerationSessionLease lease = currentEngine.acquireGenerationLease("bukkit_base_height"); IrisContext.Scope ignored = IrisContext.open(currentEngine, lease.sessionId(), null)) { return currentEngine.getMinHeight() + currentEngine.getHeight(x, z, ignoreFluid) + 1; - } catch (GenerationSessionException e) { + } catch (GenerationSessionException | IOException e) { throw new IllegalStateException("Iris base height query was rejected for world '" + worldInfo.getName() + "'.", e); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 7970d600a..57c5e0709 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.platform; +import art.arcane.iris.engine.history.GenerationFindCatalog; + import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.events.IrisLootEvent; import art.arcane.iris.core.link.Identifier; @@ -577,7 +579,7 @@ public final class EngineBukkitOps { } public static void gotoRegion(Engine engine, IrisRegion r, Player player, boolean teleport) { - if (!engine.getDimension().getRegions().contains(r.getLoadKey())) { + if (GenerationFindCatalog.region(engine, r.getLoadKey()) == null) { ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("name", String.valueOf(r.getName())))); return; } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java index f12a978be..44511d927 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java @@ -28,6 +28,7 @@ import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockProperty; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformNumericRange; import art.arcane.iris.spi.PlatformRegistries; @@ -54,6 +55,11 @@ import java.util.Map; * Bukkit adapter resolving namespaced keys against the live Bukkit registries. */ public final class BukkitRegistries implements PlatformRegistries { + @Override + public PlatformGenerationRegistry generationRegistry() { + return INMS.get().generationRegistry(); + } + @Override public PlatformBlockState block(String key) { BlockData data = BukkitBlockResolution.get(key); diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/LocatableObjectHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/LocatableObjectHandler.java new file mode 100644 index 000000000..c36229318 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/LocatableObjectHandler.java @@ -0,0 +1,13 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationFindCatalog; +import art.arcane.volmlib.util.collection.KList; + +public class LocatableObjectHandler extends ObjectHandler { + @Override + public KList getPossibilities() { + Engine activeEngine = engine(); + return activeEngine == null ? super.getPossibilities() : GenerationFindCatalog.objectKeys(activeEngine); + } +} diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java index bae1c4628..1eaa50993 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java @@ -1,6 +1,7 @@ package art.arcane.iris.util.common.director.specialhandlers; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationFindCatalog; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.volmlib.util.collection.KList; @@ -14,7 +15,7 @@ public class ReachableBiomeHandler extends RegistrantHandler { Engine activeEngine = engine(); return activeEngine == null ? super.getPossibilities() - : activeEngine.getAllBiomes(); + : GenerationFindCatalog.biomes(activeEngine); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableRegionHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableRegionHandler.java new file mode 100644 index 000000000..4425520ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableRegionHandler.java @@ -0,0 +1,23 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.GenerationFindCatalog; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.volmlib.util.collection.KList; + +public class ReachableRegionHandler extends RegistrantHandler { + public ReachableRegionHandler() { + super(IrisRegion.class, true); + } + + @Override + public KList getPossibilities() { + Engine activeEngine = engine(); + return activeEngine == null ? super.getPossibilities() : GenerationFindCatalog.regions(activeEngine); + } + + @Override + public String getRandomDefault() { + return "region"; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java index 3484f99c3..f0005f62e 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.history.GenerationFindCatalog; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.spi.IrisPlatforms; @@ -67,6 +68,9 @@ public class StructureHandler implements DirectorParameterHandler { } } + for (String key : GenerationFindCatalog.retainedStructureKeys(activeEngine)) { + suggestions.putIfAbsent(normalizeKey(key), key); + } return new KList<>(suggestions.values()); } diff --git a/core/src/main/java/art/arcane/iris/util/project/agent/Agent.java b/core/src/main/java/art/arcane/iris/util/project/agent/Agent.java index a02243ac4..ee9a777cf 100644 --- a/core/src/main/java/art/arcane/iris/util/project/agent/Agent.java +++ b/core/src/main/java/art/arcane/iris/util/project/agent/Agent.java @@ -8,6 +8,7 @@ import net.bytebuddy.dynamic.loading.ClassReloadingStrategy; import java.io.File; import java.io.InputStream; import java.lang.instrument.Instrumentation; +import java.lang.reflect.InvocationTargetException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; @@ -23,6 +24,32 @@ public class Agent { return doGetInstrumentation() != null; } + public static void requireClassLoaderCloseDeferral() throws ReflectiveOperationException { + Class installer = Class.forName(NAME, true, ClassLoader.getSystemClassLoader()); + installer.getMethod("retainClassLoader", ClassLoader.class); + installer.getMethod("deferClassLoaderClose", ClassLoader.class); + installer.getMethod("releaseClassLoader", ClassLoader.class); + } + + public static void retainClassLoader(ClassLoader loader) { + invokeClassLoaderLifecycle("retainClassLoader", loader); + } + + public static void releaseClassLoader(ClassLoader loader) { + invokeClassLoaderLifecycle("releaseClassLoader", loader); + } + + private static void invokeClassLoaderLifecycle(String method, ClassLoader loader) { + try { + Class.forName(NAME, true, ClassLoader.getSystemClassLoader()) + .getMethod(method, ClassLoader.class).invoke(null, loader); + } catch (InvocationTargetException exception) { + throw new IllegalStateException("Iris plugin class loader lifecycle failed", exception.getCause()); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Iris plugin class loader lifecycle is unavailable", exception); + } + } + public static Instrumentation getInstrumentation() { Instrumentation instrumentation = doGetInstrumentation(); if (instrumentation == null) throw new IllegalStateException("The agent is not initialized or unavailable"); diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index c8d23c6d2..1c2383d76 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Radius in Chunks um die Mitte", "iris.director.commanddeveloper.param.center_chunk_x": "X-Koordinate des zentralen Chunk", "iris.director.commanddeveloper.param.center_chunk_z": "Z-Koordinate des zentralen Chunk", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Pack einer Welt aktualisieren (UNSICHER!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Aktualisierung der Weltgenerierung vormerken", "iris.director.commanddeveloper.param.world_update": "Die Welt aktualisiert", "iris.director.commanddeveloper.param.pack_install_into_world": "Das in der Welt zu installierende Pack", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Stellen Sie sicher, dass Sie ein Backup erstellen & Lesen Sie zuerst die Warnungen!", @@ -1053,15 +1053,13 @@ "other": "§7 ... und {count} weitere Warnungen.", "one": "§7 ... und {count} weitere Warnung." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cErstelle vor der Verwendung immer eine Sicherungskopie.", - "§eMögliche Probleme sind unter anderem:", - "§e - Beschädigte Chunk-Übergänge zwischen alten und neuen Chunks (vor und nach dem Update)", - "§e - Regenerierte Chunks, die nicht zu den alten Chunks passen", - "§e - Strukturen, die bei der Regeneration nicht erneut erzeugt werden", - "§e - Nicht zusammenpassende Höhlengänge", - "§e - Nicht zusammenpassende Geländeschichten", - "§cWenn du die Risiken verstanden und eine Sicherungskopie erstellt hast:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cSichere die gesamte Welt, bevor du diese Aktualisierung vormerkst.", + "§eVorhandene Chunks werden weder neu generiert noch überschrieben.", + "§eNeue Chunks verwenden nach dem Neustart das vorgemerkte Pack und gehen an der gespeicherten Grenze ineinander über.", + "§eSeed, Höhe, Umgebung und Dimensionstyp der Welt dürfen sich nicht ändern.", + "§eJede Sicherung muss das vollständige Verzeichnis iris/generation enthalten.", + "§cSo merkst du die Aktualisierung vor und forderst einen sauberen Neustart an:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportiere §f{total}§a Strukturen aus vanilla und Datapacks (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 66e29faf2..0d0a72523 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Radio en chunks alrededor del centro", "iris.director.commanddeveloper.param.center_chunk_x": "Chunk central X", "iris.director.commanddeveloper.param.center_chunk_z": "Chunk central Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Actualizar el pack de un mundo (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Preparar una actualización de la generación del mundo", "iris.director.commanddeveloper.param.world_update": "El mundo que se actualizará", "iris.director.commanddeveloper.param.pack_install_into_world": "El pack que se instalará en el mundo", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "¡Asegúrate de crear una copia de seguridad y leer primero las advertencias!", @@ -1053,15 +1053,13 @@ "one": "§7 ... y {count} advertencia más.", "other": "§7 ... y {count} advertencias más." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cSiempre debes crear una copia de seguridad antes de usar esto", - "§eLos problemas que puede causar incluyen, entre otros:", - "§e - Chunks rotos (cortes) entre chunks antiguos y nuevos (antes y después de la actualización)", - "§e - Chunks regenerados que no encajan con los chunks antiguos", - "§e - Estructuras que no vuelven a generarse al regenerar", - "§e - Cuevas que no coinciden", - "§e - Capas de terreno que no coinciden", - "§cAhora que conoces los riesgos y has creado una copia de seguridad:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cHaz una copia de seguridad de todo el mundo antes de preparar esta actualización.", + "§eLos chunks existentes no se regenerarán ni sobrescribirán.", + "§eTras reiniciar, los nuevos chunks usarán el pack preparado y se unirán gradualmente al borde guardado.", + "§eNo se pueden cambiar la semilla, la altura, el entorno ni el tipo de dimensión del mundo.", + "§eCada copia de seguridad debe conservar todo el directorio iris/generation.", + "§cPara preparar la actualización y solicitar un reinicio limpio:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportando §f{total}§a estructuras vanilla y de datapacks (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index a7dff80e9..c0a6afc25 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Radius palasina keskellä", "iris.director.commanddeveloper.param.center_chunk_x": "Keskiosa X", "iris.director.commanddeveloper.param.center_chunk_z": "Keskiosa Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Päivitä maailman paketti (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Valmistele maailman generoinnin päivitys", "iris.director.commanddeveloper.param.world_update": "Päivitettävä maailma", "iris.director.commanddeveloper.param.pack_install_into_world": "Maailmaan asennettava pakkaus", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Muista tehdä varmuuskopio & Lue varoitukset ensin!", @@ -1053,15 +1053,13 @@ "other": "§7 ... sekä {count} Lisää varoituksia.", "one": "§7 ... sekä {count} Enemmän varoitusta." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cTee aina varmuuskopio ennen tämän käyttämistä", - "§eTästä voi aiheutua muun muassa seuraavia ongelmia:", - "§e - Rikkinäisiä chunkeja (katkoksia) vanhojen ja uusien chunkien välille (ennen päivitystä & sen jälkeen)", - "§e - Uudelleen luodut chunkit eivät sovi vanhoihin chunkeihin", - "§e - Rakenteet eivät ilmesty uudelleen regeneroinnin yhteydessä", - "§e - Luolat eivät kohtaa toisiaan", - "§e - Maastokerrokset eivät kohtaa toisiaan", - "§cKun ymmärrät riskit ja olet tehnyt varmuuskopion:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cVarmuuskopioi koko maailma ennen tämän päivityksen valmistelua.", + "§eOlemassa olevia chunkeja ei generoida uudelleen eikä korvata.", + "§eUudelleenkäynnistyksen jälkeen uudet chunkit käyttävät valmisteltua pakettia ja liittyvät asteittain tallennettuun rajaan.", + "§eMaailman siementä, korkeutta, ympäristöä tai ulottuvuustyyppiä ei voi muuttaa.", + "§eJokaisen varmuuskopion on sisällettävä koko iris/generation-hakemisto.", + "§cValmistele päivitys ja pyydä hallittua uudelleenkäynnistystä:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aTuo §f{total}§a vanilla & datapakettirakenteet (tila ={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 485f54cef..308c86bd5 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Rayon en chunks autour du centre", "iris.director.commanddeveloper.param.center_chunk_x": "Chunk central X", "iris.director.commanddeveloper.param.center_chunk_z": "Chunk central Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Mettre à jour le pack d'un monde (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Préparer une mise à jour de la génération du monde", "iris.director.commanddeveloper.param.world_update": "Le monde à mettre à jour", "iris.director.commanddeveloper.param.pack_install_into_world": "Le pack à installer dans le monde", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Assurez-vous de créer une sauvegarde et de lire d'abord les avertissements !", @@ -1053,15 +1053,13 @@ "one": "§7 ... et {count} avertissement de plus.", "other": "§7 ... et {count} avertissements de plus." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cVous devez toujours créer une sauvegarde avant d'utiliser ceci", - "§eLes problèmes possibles comprennent notamment :", - "§e - Des chunks cassés (coupures) entre les anciens et les nouveaux chunks (avant et après la mise à jour)", - "§e - Des chunks régénérés qui ne s'alignent pas avec les anciens chunks", - "§e - Des structures qui ne réapparaissent pas lors de la régénération", - "§e - Des grottes qui ne s'alignent pas", - "§e - Des couches de terrain qui ne s'alignent pas", - "§cMaintenant que vous connaissez les risques et avez créé une sauvegarde :", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cSauvegardez le monde entier avant de préparer cette mise à jour.", + "§eLes chunks existants ne seront ni régénérés ni réécrits.", + "§eAprès le redémarrage, les nouveaux chunks utiliseront le pack préparé avec une transition progressive à la limite enregistrée.", + "§eLa graine, la hauteur, l'environnement et le type de dimension du monde ne peuvent pas changer.", + "§eChaque sauvegarde doit conserver le répertoire iris/generation complet.", + "§cPour préparer la mise à jour et demander un redémarrage propre :", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportation de §f{total}§a structures vanilla et de datapacks (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 07c704b22..18847253a 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "רדינוס בחלקים סביב המרכז", "iris.director.commanddeveloper.param.center_chunk_x": "מרכז X", "iris.director.commanddeveloper.param.center_chunk_z": "מרכז Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "עדכון חבילת העולם (לא בטוח!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "הכנת עדכון ליצירת העולם", "iris.director.commanddeveloper.param.world_update": "העולם לעדכן", "iris.director.commanddeveloper.param.pack_install_into_world": "החבילה להתקנה לעולם", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "הקפד לעשות גיבוי & קראו קודם כל את האזהרות!", @@ -1053,15 +1053,13 @@ "other": "§7 ... ו {count} עוד התראות.", "one": "§7 ... ו {count} עוד אזהרה." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cיש ליצור תמיד גיבוי לפני השימוש באפשרות זו", - "§eהבעיות שעלולות להיגרם כוללות, בין היתר:", - "§e - צ'אנקים שבורים (קטיעות) בין צ'אנקים ישנים לחדשים (לפני העדכון & אחריו)", - "§e - צ'אנקים שנוצרו מחדש ואינם מתאימים לצ'אנקים הישנים", - "§e - מבנים שאינם נוצרים שוב בזמן יצירה מחדש", - "§e - מערות שאינן מתחברות זו לזו", - "§e - שכבות שטח שאינן מתחברות זו לזו", - "§cלאחר שהבנת את הסיכונים ויצרת גיבוי:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cיש לגבות את העולם כולו לפני הכנת העדכון.", + "§eמקטעים קיימים לא ייווצרו מחדש ולא יידרסו.", + "§eלאחר הפעלה מחדש, מקטעים חדשים ישתמשו בחבילה שהוכנה וישתלבו בהדרגה בגבול שנשמר.", + "§eאי אפשר לשנות את הזרע, הגובה, הסביבה או סוג הממד של העולם.", + "§eכל גיבוי חייב לכלול את כל התיקייה iris/generation.", + "§cלהכנת העדכון ולבקשת הפעלה מחדש מסודרת:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aיבוא §f{total}§a vanilla & מאגרי מידע (Mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index ab6135f0e..4fc6bb62b 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Radio in chunk intorno al centro", "iris.director.commanddeveloper.param.center_chunk_x": "Centro chunk X", "iris.director.commanddeveloper.param.center_chunk_z": "Coordinata Z del Chunk centrale", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Aggiorna il Pack di un mondo (NON SICURO!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Prepara un aggiornamento della generazione del mondo", "iris.director.commanddeveloper.param.world_update": "Il mondo per aggiornare", "iris.director.commanddeveloper.param.pack_install_into_world": "Il Pack da installare nel mondo", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Assicurarsi di fare un backup & leggere prima gli avvertimenti!", @@ -1053,15 +1053,13 @@ "other": "§7 ... e altri {count} avvisi.", "one": "§7 ... e {count} altro avviso." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cCrea sempre un backup prima di usare questa funzione.", - "§eI possibili problemi includono, tra gli altri:", - "§e - Transizioni Chunk danneggiate tra Chunk vecchi e nuovi (prima e dopo l'aggiornamento)", - "§e - Chunk rigenerati che non si raccordano con i vecchi Chunk", - "§e - Strutture che non vengono generate di nuovo durante la rigenerazione", - "§e - Grotte che non si raccordano", - "§e - Strati del terreno che non si raccordano", - "§cDopo aver compreso i rischi e creato un backup:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cEsegui un backup dell'intero mondo prima di preparare questo aggiornamento.", + "§eI chunk esistenti non saranno rigenerati né riscritti.", + "§eDopo il riavvio, i nuovi chunk useranno il pacchetto preparato con una transizione graduale al confine salvato.", + "§eIl seed, l'altezza, l'ambiente e il tipo di dimensione del mondo non possono cambiare.", + "§eOgni backup deve conservare l'intera cartella iris/generation.", + "§cPer preparare l'aggiornamento e richiedere un riavvio regolare:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportazione di §f{total}§a strutture vanilla e datapack (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index ec4438d75..cddcef348 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "中心からの半径(チャンク単位)", "iris.director.commanddeveloper.param.center_chunk_x": "中心チャンクの X", "iris.director.commanddeveloper.param.center_chunk_z": "中心チャンクの Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "ワールドのパックを更新します(危険!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "ワールド生成の更新を準備", "iris.director.commanddeveloper.param.world_update": "更新するワールド", "iris.director.commanddeveloper.param.pack_install_into_world": "ワールドへインストールするパック", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "必ずバックアップを作成し、先に警告を読んでください!", @@ -1053,15 +1053,13 @@ "other": "§7 ...ほか {count} 件の警告。", "one": "§7 ...ほか {count} 件の警告。" }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cこの機能を使用する前に、必ずバックアップを作成してください", - "§e発生する可能性がある問題には、次のものがあります:", - "§e - 更新前 & 更新後の古いチャンクと新しいチャンク間に生じる切れ目", - "§e - 再生成されたチャンクが古いチャンクとつながらない", - "§e - 再生成時に構造物が再びスポーンしない", - "§e - 洞窟がつながらない", - "§e - 地形レイヤーがつながらない", - "§cリスクを理解し、バックアップを作成したら実行してください:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cこの更新を準備する前に、ワールド全体をバックアップしてください。", + "§e既存のチャンクは再生成も上書きもしません。", + "§e再起動後、新しいチャンクは準備したパックを使用し、保存した境界で地形を徐々につなぎます。", + "§eワールドのシード、高さ、環境、ディメンションの種類は変更できません。", + "§eすべてのバックアップに iris/generation ディレクトリ全体を含めてください。", + "§c更新を準備し、正常な再起動を要求するには、次を実行してください。", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aバニラおよびデータパック構造物 §f{total}§a 件をインポートしています(mode={mode}、includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 047f32530..b0b74c46f 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "중심 기준 반경(청크 단위)", "iris.director.commanddeveloper.param.center_chunk_x": "중심 청크 X", "iris.director.commanddeveloper.param.center_chunk_z": "중심 청크 Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "월드의 팩을 업데이트합니다(위험!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "월드 생성 업데이트 준비", "iris.director.commanddeveloper.param.world_update": "업데이트할 월드", "iris.director.commanddeveloper.param.pack_install_into_world": "월드에 설치할 팩", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "반드시 백업을 만들고 경고를 먼저 읽으세요!", @@ -1053,15 +1053,13 @@ "other": "§7 ... · {count} 더 많은 경고.", "one": "§7 ... · {count} 더 많은 경고." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§c당신은 항상 이것을 사용하기 전에 백업을해야합니다", - "§e이것에 의해 발생 하는 문제는, 하지만 제한 되지 않습니다:", - "§e - 이전과 새로운 청크 사이에 깨진 청크 (& 업데이트 후)", - "§e - 오래된 청크와 맞지 않는 재생된 덩크", - "§e - 재생할 때 구조가 다시 달지 않습니다.", - "§e - 동굴 안감", - "§e - 지형 층은 위로 안대지 않습니다", - "§c당신은 위험의 인식이며, 백업을했다 :", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§c이 업데이트를 준비하기 전에 월드 전체를 백업하세요.", + "§e기존 청크는 다시 생성하거나 덮어쓰지 않습니다.", + "§e재시작 후 새 청크는 준비된 팩을 사용하며 저장된 경계에서 지형을 점진적으로 연결합니다.", + "§e월드의 시드, 높이, 환경, 차원 유형은 변경할 수 없습니다.", + "§e모든 백업에는 iris/generation 디렉터리 전체가 포함되어야 합니다.", + "§c업데이트를 준비하고 정상 재시작을 요청하려면 다음 명령을 사용하세요:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§a관련 상품 §f{total}§a 바닐라 & datapack 구조 (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 5bffb8522..d5d45cf45 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Spindulys į gabaliukus aplink centrą", "iris.director.commanddeveloper.param.center_chunk_x": "Centruotas mazgas X", "iris.director.commanddeveloper.param.center_chunk_z": "Centrinis chunk Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Atnaujinti pasaulio paketą (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Paruošti pasaulio generavimo atnaujinimą", "iris.director.commanddeveloper.param.world_update": "Atnaujinti pasaulį", "iris.director.commanddeveloper.param.pack_install_into_world": "Paketas įdiegti į pasaulį", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Įsitikinkite, kad yra atsarginė kopija & pirma perskaitykite įspėjimus!", @@ -1053,15 +1053,13 @@ "other": "§7 ... ir {count} daugiau įspėjimų.", "one": "§7 ... ir {count} daugiau įspėjimo." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cPrieš naudodami šią funkciją visada sukurkite atsarginę kopiją", - "§eGalimos problemos, be kita ko:", - "§e - Sugadinti chunkai (nutraukti kraštai) tarp senų ir naujų chunkų (prieš atnaujinimą & po jo)", - "§e - Iš naujo sugeneruoti chunkai, nesutampantys su senais chunkų kraštais", - "§e - Regeneruojant pakartotinai neatsirandančios struktūros", - "§e - Nesutampantys urvai", - "§e - Nesutampantys reljefo sluoksniai", - "§cKai suprantate riziką ir turite atsarginę kopiją:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cPrieš ruošdami šį atnaujinimą, sukurkite viso pasaulio atsarginę kopiją.", + "§eEsami chunkai nebus generuojami iš naujo ar perrašomi.", + "§ePaleidus iš naujo, nauji chunkai naudos paruoštą paketą ir palaipsniui susijungs ties išsaugota riba.", + "§eNegalima keisti pasaulio sėklos, aukščio, aplinkos ar dimensijos tipo.", + "§eKiekvienoje atsarginėje kopijoje turi būti visas iris/generation katalogas.", + "§cNorėdami paruošti atnaujinimą ir paprašyti tvarkingo paleidimo iš naujo:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportuojama §f{total}§a vanilla & duomenų kaupimo struktūros (režimas ={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 596d84f69..7eaf92508 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Straal in stukjes rond het centrum", "iris.director.commanddeveloper.param.center_chunk_x": "Middendeel X", "iris.director.commanddeveloper.param.center_chunk_z": "Middenstuk Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "De verpakking van een wereld bijwerken (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Een update van de wereldgeneratie klaarzetten", "iris.director.commanddeveloper.param.world_update": "De wereld om bij te werken", "iris.director.commanddeveloper.param.pack_install_into_world": "Het pakket te installeren in de wereld", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Zorg ervoor dat u een back-up maakt & Lees eerst de waarschuwingen!", @@ -1053,15 +1053,13 @@ "other": "§7 ... en {count} meer waarschuwingen.", "one": "§7 ... en {count} Meer waarschuwing." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cMaak altijd een back-up voordat je dit gebruikt", - "§eMogelijke problemen zijn onder andere:", - "§e - Gechunken chunks (afgesneden randen) tussen oude en nieuwe chunks (voor & na de update)", - "§e - Opnieuw gegenereerde chunks die niet aansluiten op de oude chunks", - "§e - Structuren die bij regeneratie niet opnieuw spawnen", - "§e - Grotten die niet op elkaar aansluiten", - "§e - Terreinlagen die niet op elkaar aansluiten", - "§cWanneer je de risico's begrijpt en een back-up hebt gemaakt:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cMaak een back-up van de volledige wereld voordat je deze update klaarzet.", + "§eBestaande chunks worden niet opnieuw gegenereerd of overschreven.", + "§eNa de herstart gebruiken nieuwe chunks het klaargezette pakket, met een geleidelijke overgang aan de opgeslagen grens.", + "§eDe seed, hoogte, omgeving en het dimensietype van de wereld mogen niet veranderen.", + "§eElke back-up moet de volledige map iris/generation bevatten.", + "§cOm de update klaar te zetten en een nette herstart aan te vragen:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImporteren §f{total}§a vanilla & datapack structuren (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 5b3e2f8a9..4cc3c2623 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Promień w chunkach wokół centrum", "iris.director.commanddeveloper.param.center_chunk_x": "Środek chunka X", "iris.director.commanddeveloper.param.center_chunk_z": "Środek chunka Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Aktualizacja pakietu świata (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Przygotuj aktualizację generowania świata", "iris.director.commanddeveloper.param.world_update": "Świat do aktualizacji", "iris.director.commanddeveloper.param.pack_install_into_world": "Paczka do instalacji na świecie", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Upewnij się, aby zrobić kopię zapasową & najpierw przeczytaj ostrzeżenia!", @@ -1053,15 +1053,13 @@ "other": "§7 ... oraz {count} więcej ostrzeżeń.", "one": "§7 ... oraz {count} Więcej ostrzeżeń." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cPrzed użyciem tej funkcji zawsze wykonaj kopię zapasową", - "§eMożliwe problemy obejmują między innymi:", - "§e - Uszkodzone chunki (ucięte krawędzie) pomiędzy starymi i nowymi chunkami (przed aktualizacją & po niej)", - "§e - Ponownie wygenerowane chunki, które nie pasują do starych chunków", - "§e - Struktury, które nie pojawiają się ponownie podczas regeneracji", - "§e - Niedopasowane jaskinie", - "§e - Niedopasowane warstwy terenu", - "§cGdy rozumiesz ryzyko i masz wykonaną kopię zapasową:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cPrzed przygotowaniem aktualizacji wykonaj kopię zapasową całego świata.", + "§eIstniejące chunki nie zostaną ponownie wygenerowane ani nadpisane.", + "§ePo restarcie nowe chunki użyją przygotowanego pakietu i stopniowo połączą się z terenem przy zapisanej granicy.", + "§eNie można zmienić ziarna, wysokości, środowiska ani typu wymiaru świata.", + "§eKażda kopia zapasowa musi zawierać cały katalog iris/generation.", + "§cAby przygotować aktualizację i zażądać prawidłowego restartu:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aPrzywóz §f{total}§a vanilla & struktury datapack (tryb ={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 63e38b16b..f9640cf95 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Raio em chunks ao redor do centro", "iris.director.commanddeveloper.param.center_chunk_x": "Centralizar o chunk X", "iris.director.commanddeveloper.param.center_chunk_z": "Centralizar o chunk Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Actualizar a pack de um mundo (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Preparar uma atualização da geração do mundo", "iris.director.commanddeveloper.param.world_update": "O mundo a actualizar", "iris.director.commanddeveloper.param.pack_install_into_world": "O pacote para instalar no mundo", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Certifique-se de fazer um backup & Leia primeiro os avisos!", @@ -1053,15 +1053,13 @@ "other": "§7 ... e {count} mais avisos.", "one": "§7 ... e {count} Mais aviso." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cFaça sempre uma cópia de segurança antes de usar esta função", - "§eOs possíveis problemas incluem, entre outros:", - "§e - Chunks partidos (cortes) entre chunks antigos e novos (antes da atualização & depois dela)", - "§e - Chunks regenerados que não encaixam nos chunks antigos", - "§e - Estruturas que não voltam a surgir durante a regeneração", - "§e - Cavernas que não ficam alinhadas", - "§e - Camadas de terreno que não ficam alinhadas", - "§cQuando compreender os riscos e tiver criado uma cópia de segurança:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cFaça uma cópia de segurança do mundo inteiro antes de preparar esta atualização.", + "§eOs chunks existentes não serão regenerados nem reescritos.", + "§eApós o reinício, os novos chunks usarão o pacote preparado com uma transição gradual no limite guardado.", + "§eNão é possível alterar a semente, a altura, o ambiente nem o tipo de dimensão do mundo.", + "§eCada cópia de segurança deve conservar todo o diretório iris/generation.", + "§cPara preparar a atualização e pedir um reinício normal:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aImportação §f{total}§a vanilla & estruturas do pacote de dados (modo={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 771e7a99b..9507d83d5 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Радиус в чанках вокруг центра", "iris.director.commanddeveloper.param.center_chunk_x": "Центрный чанк X", "iris.director.commanddeveloper.param.center_chunk_z": "Обсуждение Center Bunk Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Обновить пак мира (небезопасно!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Подготовить обновление генерации мира", "iris.director.commanddeveloper.param.world_update": "Мир для обновления", "iris.director.commanddeveloper.param.pack_install_into_world": "Пакет для установки в мире", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Обязательно сделайте резервную копию & Сначала прочитайте предупреждения!", @@ -1053,15 +1053,13 @@ "other": "§7 ... и {count} Больше предупреждений.", "one": "§7 ... и {count} Больше предупреждений." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cВсегда создавайте резервную копию перед использованием этой функции", - "§eВозможные проблемы включают, помимо прочего:", - "§e - Повреждённые чанки (обрезанные края) между старыми и новыми чанками (до обновления & после него)", - "§e - Перегенерированные чанки, которые не стыкуются со старыми чанками", - "§e - Структуры, которые не появляются повторно при регенерации", - "§e - Несовпадающие пещеры", - "§e - Несовпадающие слои рельефа", - "§cКогда вы понимаете риски и создали резервную копию:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cПеред подготовкой обновления создайте резервную копию всего мира.", + "§eСуществующие чанки не будут сгенерированы заново или перезаписаны.", + "§eПосле перезапуска новые чанки будут использовать подготовленный пакет с плавным переходом у сохранённой границы.", + "§eНельзя менять сид, высоту, окружение или тип измерения мира.", + "§eКаждая резервная копия должна содержать весь каталог iris/generation.", + "§cЧтобы подготовить обновление и запросить корректный перезапуск:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aимпортировать §f{total}§a vanilla & структуры пакета данных (режим ={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 88203a552..c7924e3f8 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Radius merkez çevresinde", "iris.director.commanddeveloper.param.center_chunk_x": "Merkez chunk X", "iris.director.commanddeveloper.param.center_chunk_z": "Merkez chunk Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Bir dünyanın paketini güncelleyin (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Dünya üretimi güncellemesini hazırla", "iris.director.commanddeveloper.param.world_update": "Dünyayı güncellemek için", "iris.director.commanddeveloper.param.pack_install_into_world": "Dünyaya yükleme paketi", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Bir yedekleme yapmak için emin olun & İlk önce uyarıları okuyun!", @@ -1053,15 +1053,13 @@ "other": "§7 ... ve {count} Daha fazla uyarı.", "one": "§7 ... ve {count} Daha fazla uyarı." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cBunu kullanmadan önce her zaman yedek alın", - "§eOluşabilecek sorunlar şunlarla sınırlı olmamak üzere şunlardır:", - "§e - Eski ve yeni chunk'lar arasında bozuk sınırlar (kesilmeler) (güncellemeden önce & sonra)", - "§e - Eski chunk'larla uyuşmayan yeniden oluşturulmuş chunk'lar", - "§e - Yeniden oluşturma sırasında tekrar spawnlanmayan yapılar", - "§e - Birbiriyle hizalanmayan mağaralar", - "§e - Birbiriyle hizalanmayan arazi katmanları", - "§cRiskleri anladığınızdan ve yedek aldığınızdan eminseniz:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cBu güncellemeyi hazırlamadan önce tüm dünyayı yedekleyin.", + "§eMevcut chunklar yeniden üretilmez veya üzerlerine yazılmaz.", + "§eYeniden başlatmadan sonra yeni chunklar hazırlanan paketi kullanır ve kaydedilen sınırda araziye kademeli olarak bağlanır.", + "§eDünyanın tohumu, yüksekliği, ortamı ve boyut türü değiştirilemez.", + "§eHer yedek, iris/generation dizininin tamamını içermelidir.", + "§cGüncellemeyi hazırlamak ve düzgün bir yeniden başlatma istemek için:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aİthalat §f{total}§a vanilla & Datapack yapıları (mode= ={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 465ea0119..2f0928daa 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "Bán kính trong mảng xung quanh trung tâm", "iris.director.commanddeveloper.param.center_chunk_x": "Chunk trung tâm X", "iris.director.commanddeveloper.param.center_chunk_z": "Chunk trung tâm Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "Cập nhật gói của một thế giới (UNSAFE!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "Chuẩn bị bản cập nhật tạo thế giới", "iris.director.commanddeveloper.param.world_update": "♪ Cả thế giới để cập nhật ♪", "iris.director.commanddeveloper.param.pack_install_into_world": "Name", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "Nhớ sao lưu đấy. & đọc lời cảnh báo trước!", @@ -1053,15 +1053,13 @@ "other": "§7 ... và {count} cảnh báo thêm.", "one": "§7 ... và {count} cảnh báo thêm." }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§cLuôn tạo bản sao lưu trước khi sử dụng chức năng này", - "§eCác vấn đề có thể xảy ra bao gồm nhưng không giới hạn ở:", - "§e - Chunk bị hỏng (đứt đoạn) giữa chunk cũ và mới (trước khi cập nhật & sau khi cập nhật)", - "§e - Chunk được tạo lại không khớp với chunk cũ", - "§e - Cấu trúc không xuất hiện lại khi tái tạo", - "§e - Hang động không nối khớp", - "§e - Các lớp địa hình không nối khớp", - "§cKhi bạn đã hiểu rủi ro và tạo bản sao lưu:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§cSao lưu toàn bộ thế giới trước khi chuẩn bị bản cập nhật này.", + "§eCác chunk hiện có sẽ không được tạo lại hoặc ghi đè.", + "§eSau khi khởi động lại, chunk mới dùng gói đã chuẩn bị và chuyển tiếp dần tại ranh giới đã lưu.", + "§eKhông thể thay đổi seed, chiều cao, môi trường hoặc loại chiều không gian của thế giới.", + "§eMỗi bản sao lưu phải giữ toàn bộ thư mục iris/generation.", + "§cĐể chuẩn bị bản cập nhật và yêu cầu khởi động lại đúng cách:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§aNhập §f{total}§a Name & cấu trúc gói dữ liệu (mode={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index f9a46a274..8ce7aa884 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "半径为中心周围的块", "iris.director.commanddeveloper.param.center_chunk_x": "居中块 X", "iris.director.commanddeveloper.param.center_chunk_z": "居中块 Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "更新世界的内容包(不安全!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "准备世界生成更新", "iris.director.commanddeveloper.param.world_update": "更新世界", "iris.director.commanddeveloper.param.pack_install_into_world": "加载世界的包", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "一定要做备份 & 先读一下警告!", @@ -1053,15 +1053,13 @@ "other": "§7 ... 和 {count} 更多的警告。 更多警告。", "one": "§7 ... 和 {count} 更多的警告。 更多警告。" }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§c在使用之前,你应该先做备份", - "§e由此产生的问题可以是,但不限于:", - "§e - 新旧块之间的断块(断块)(以前 & 更新后)", - "§e - 重新生成不适合旧的块", - "§e - 结构在再生时不再产生", - "§e - 洞穴不排队", - "§e - 地层不排队", - "§c现在,你意识到了风险, 并做了一个备份:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§c准备此更新前,请备份整个世界。", + "§e现有区块不会重新生成或被覆盖。", + "§e重启后,新区块将使用已准备的包,并在保存的边界处逐渐衔接地形。", + "§e世界的种子、高度、环境和维度类型不能更改。", + "§e每份备份都必须包含完整的 iris/generation 目录。", + "§c要准备更新并请求正常重启,请执行:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§a导入 §f{total}§a 原版 & 数据包结构(模式={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index c5a8a716d..b5fcea44b 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -626,7 +626,7 @@ "iris.director.commanddeveloper.param.radius_chunks_around_center": "半徑為中心周圍的塊", "iris.director.commanddeveloper.param.center_chunk_x": "居中塊 X", "iris.director.commanddeveloper.param.center_chunk_z": "居中塊 Z", - "iris.director.commanddeveloper.director.update_pack_world_unsafe": "更新世界的內容包(不安全!)", + "iris.director.commanddeveloper.director.stage_world_generation_update": "準備世界生成更新", "iris.director.commanddeveloper.param.world_update": "更新世界", "iris.director.commanddeveloper.param.pack_install_into_world": "載入世界的包", "iris.director.commanddeveloper.param.make_sure_make_backup_read_warnings_first": "一定要做備份 & 先讀一下警告!", @@ -1053,15 +1053,13 @@ "other": "§7 ... 和 {count} 更多的警告。 更多警告。", "one": "§7 ... 和 {count} 更多的警告。 更多警告。" }, - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ - "§c在使用之前,你應該先做備份", - "§e由此產生的問題可以是,但不限於:", - "§e - 新舊塊之間的斷塊(斷塊)(以前 & 更新後)", - "§e - 重新生成不適合舊的塊", - "§e - 結構在再生時不再產生", - "§e - 洞穴不排隊", - "§e - 地層不排隊", - "§c現在,你意識到了風險, 並做了一個備份:", + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ + "§c準備此更新前,請備份整個世界。", + "§e現有區塊不會重新生成或被覆寫。", + "§e重新啟動後,新區塊將使用已準備的套件,並在儲存的邊界處逐漸銜接地形。", + "§e世界的種子、高度、環境和維度類型不能變更。", + "§e每份備份都必須包含完整的 iris/generation 目錄。", + "§c要準備更新並要求正常重新啟動,請執行:", "§c/iris developer update-world {world} {pack} confirm=true" ], "iris.bukkit.runtime.bulkstructureimporter.importing_vanilla_datapack_structures_mode_includenonjigsaw": "§a匯入 §f{total}§a 原版 & 資料包結構(模式={mode}, includeNonJigsaw={includeNonJigsaw})...", diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java index e8975d700..48eb068c2 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java @@ -1,6 +1,11 @@ package art.arcane.iris.core; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding; +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; import org.junit.Assume; import org.junit.Rule; import org.junit.Test; @@ -13,6 +18,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.util.List; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; @@ -201,12 +207,102 @@ public class IrisDatapackCompilerInputFingerprintTest { nestedKeyPack.toAbsolutePath().normalize().toFile()), compilerRoots); } + @Test + public void worldInputRootDiscoveryIncludesEveryRetainedGenerationEpoch() throws Exception { + Path dataDirectory = tmp.newFolder("history-root-data").toPath(); + Path serverRoot = tmp.newFolder("history-root-server").toPath(); + Path world = serverRoot.resolve("dimensions/iris/history"); + Files.createDirectories(world); + Path firstPack = activePack("history-pack-first"); + Path secondPack = activePack("history-pack-second"); + write(secondPack.resolve("biomes/second.json"), "second-biome"); + GenerationHistory history = GenerationHistory.create( + world, + firstPack, + GenerationPackFingerprint.compute(firstPack, GenerationPackFingerprint.CURRENT_VERSION), + 41L, + generationContract(), + GenerationRegistryContract.empty() + ); + history.stageUpdate( + secondPack, + GenerationPackFingerprint.compute(secondPack, GenerationPackFingerprint.CURRENT_VERSION), + generationContract(), + GenerationRegistryContract.empty(), + 256 + ); + Path legacyDecoy = world.resolve("iris/pack"); + write(legacyDecoy.resolve("dimensions/legacy.json"), "legacy"); + + List compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot); + + Set expected = Set.of( + history.paths().packRoot(history.manifest().activeEpoch().epochId()).toFile(), + history.paths().packRoot(history.manifest().pendingEpoch().orElseThrow().epochId()).toFile() + ); + assertEquals(expected, Set.copyOf(compilerRoots)); + assertEquals(2, compilerRoots.size()); + } + + @Test + public void corruptGenerationHistoryFailsPackRootDiscoveryClosed() throws Exception { + Path dataDirectory = tmp.newFolder("corrupt-history-data").toPath(); + Path serverRoot = tmp.newFolder("corrupt-history-server").toPath(); + Files.createDirectories(serverRoot.resolve("dimensions/iris/corrupt/iris/generation")); + + try { + IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot); + fail("Generation history without a manifest must fail pack discovery"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("manifest")); + } + } + + @Test + public void rootWorldGenerationHistoryIsACompilerInput() throws Exception { + Path dataDirectory = tmp.newFolder("root-history-data").toPath(); + Path serverRoot = tmp.newFolder("root-history-server").toPath(); + Path pack = activePack("root-history-pack"); + GenerationHistory history = GenerationHistory.create( + serverRoot, + pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + 91L, + generationContract(), + GenerationRegistryContract.empty() + ); + + List compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot); + + assertEquals(List.of(history.activePackRoot().toFile()), compilerRoots); + } + private Path activePack(String name) throws IOException { Path pack = tmp.newFolder(name).toPath(); write(pack.resolve("dimensions/overworld.json"), "dimension-a"); return pack; } + private static GenerationEpoch.DimensionContract generationContract() { + return new GenerationEpoch.DimensionContract( + "overworld", + "iris:overworld_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "c".repeat(64) + ); + } + private Path write(Path path, String value) throws IOException { Files.createDirectories(path.getParent()); Files.writeString(path, value, StandardCharsets.UTF_8); diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index 468c621ae..4d024b7a7 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -1,8 +1,22 @@ package art.arcane.iris.core; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding; +import art.arcane.iris.core.lifecycle.BukkitStartupPaths; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.history.GenerationEpochContractFactory; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; import org.junit.Rule; @@ -17,12 +31,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class IrisDatapackCompilerTest { @Rule @@ -58,8 +75,9 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/alpha.json"))); assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/beta.json"))); assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/world_local.json"))); - assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json"))); - assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json"))); + assertEquals(3, regularFileCount(datapackRoot.resolve("data/iris/worldgen/biome/biomes"))); + assertFalse(Files.exists(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json"))); + assertFalse(Files.exists(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json"))); assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json"))); assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/hidden.json"))); assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/deleted.json"))); @@ -313,10 +331,200 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension/moon.json"))); } + @Test + public void changedCustomBiomeDefinitionsCoexistUnderContentKeys() throws Exception { + Path firstPack = temporaryFolder.newFolder("content-biome-first").toPath(); + Path secondPack = temporaryFolder.newFolder("content-biome-second").toPath(); + Path datapackRoot = temporaryFolder.newFolder("content-biome-datapack").toPath(); + createPack(firstPack, "shared", "shared_custom"); + createPack(secondPack, "shared", "shared_custom"); + setCustomBiomeTemperature(secondPack, 1.25); + + IrisDatapackCompiler.compile( + List.of(firstPack.toFile(), secondPack.toFile()), + new KList().qadd(datapackRoot.toFile()), + List.of(), + new DataFixerV1217(), + false + ); + + assertEquals(2, regularFileCount(datapackRoot.resolve("data/iris/worldgen/biome/biomes"))); + assertFalse(Files.exists(datapackRoot.resolve("data/shared/worldgen/biome/shared_custom.json"))); + } + + @Test + public void retainedPacksCompileBeforeAPlatformIsBound() throws Exception { + assertRetainedPackCompilesBeforePlatformBinding(false); + } + + @Test + public void retainedMultiDimensionPacksCompileAndBootstrapBeforePlatformBinding() throws Exception { + assertRetainedPackCompilesBeforePlatformBinding(true); + } + + private void assertRetainedPackCompilesBeforePlatformBinding(boolean multipleDimensions) throws Exception { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + Path pack = temporaryFolder.newFolder("unbound-retained-pack").toPath(); + Path server = temporaryFolder.newFolder("unbound-retained-server").toPath(); + Path world = server.resolve("world/dimensions/iris/history"); + Files.createDirectories(world); + Path output = temporaryFolder.newFolder("unbound-retained-output").toPath(); + createPack(pack, "overworld", "retained_custom"); + if (multipleDimensions) { + createPack(pack, "upper", "retained_custom", "NETHER"); + } + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load("overworld"); + String fingerprint = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(List.of()); + when(registries.entityKeys()).thenReturn(List.of()); + PlatformGenerationRegistry registry = new BootstrapGenerationRegistry(); + GenerationRegistryContract contract = GenerationRegistryContractFactory.create( + data, dimension, fingerprint, new DataFixerV1217(), registries, registry); + GenerationHistory history = GenerationHistory.create(world, pack, fingerprint, 77L, + GenerationEpochContractFactory.create(dimension, "overworld", "iris:overworld"), contract); + Path retainedPack = history.paths().packRoot(history.manifest().activeEpoch().epochId()); + IrisDatapackCompiler.CompilationResult compiled = IrisDatapackCompiler.compile(List.of(retainedPack.toFile()), + new KList().qadd(output.toFile()), List.of(), new DataFixerV1217(), false); + assertFalse(IrisPlatforms.isBound()); + int expectedDimensions = multipleDimensions ? 2 : 1; + assertEquals(expectedDimensions, compiled.dimensionCount()); + assertEquals(expectedDimensions, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes"))); + Map requirements = IrisDatapackCompiler.computeRegistryRequirements( + List.of(retainedPack.toFile()), new DataFixerV1217()); + assertTrue(requirements.containsKey("dimension_type/iris:overworld")); + if (multipleDimensions) { + assertTrue(requirements.containsKey("dimension_type/iris:upper")); + assertTrue(Files.isRegularFile(output.resolve("data/iris/dimension_type/upper.json"))); + assertNotEquals(Files.readString(output.resolve("data/iris/dimension_type/overworld.json")), + Files.readString(output.resolve("data/iris/dimension_type/upper.json"))); + Path dataDirectory = server.resolve("plugins/Iris"); + BukkitStartupPaths startupPaths = BukkitStartupPaths.resolve(server, new String[0]); + DefaultPackBootstrapProvisioner.ProvisionResult installed = + DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> { + }, startupPaths); + assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); + assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/iris/dimension_type/upper.json"))); + assertEquals(2, regularFileCount(installed.datapackRoot().resolve("data/iris/worldgen/biome/biomes"))); + DefaultPackBootstrapProvisioner.ProvisionResult unchanged = + DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> { + }, startupPaths); + assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status()); + assertFalse(IrisPlatforms.isBound()); + } + } finally { + data.close(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + public void duplicateCustomIdsPreservePhysicalBiomesAndDeterministicLegacyAlias() throws Exception { + Path pack = temporaryFolder.newFolder("duplicate-custom-biomes").toPath().resolve("iris/pack"); + Path output = temporaryFolder.newFolder("duplicate-custom-output").toPath(); + createPack(pack, "overworld", "savanna_plateau"); + Path secondBiome = pack.resolve("biomes/second.json"); + Files.writeString(secondBiome, """ + {"name":"Second Plateau","derivative":"minecraft:plains", + "customDerivitives":[{"id":"savanna_plateau","grassColor":"#BFB755"}]} + """, StandardCharsets.UTF_8); + IrisDatapackCompiler.CompilationResult compiled = IrisDatapackCompiler.compile( + List.of(pack.toFile()), new KList().qadd(output.toFile()), + List.of(), new DataFixerV1217(), false); + assertEquals(2, compiled.biomeCount()); + assertEquals(2, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes"))); + Map requirements = IrisDatapackCompiler.computeRegistryRequirements( + List.of(pack.toFile()), new DataFixerV1217()); + assertTrue(requirements.containsKey("worldgen/biome/overworld:savanna_plateau")); + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load("overworld"); + IrisBiomeCustom first = data.getBiomeLoader().load("test").getCustomDerivitives().getFirst(); + IrisBiomeCustom second = data.getBiomeLoader().load("second").getCustomDerivitives().getFirst(); + String firstKey = data.customBiomeResourceKey(dimension, first); + String secondKey = data.customBiomeResourceKey(dimension, second); + assertNotEquals(firstKey, secondKey); + String selected = firstKey.compareTo(secondKey) < 0 ? firstKey : secondKey; + assertEquals(selected, data.customBiomeResourceKey(dimension, "savanna_plateau")); + Path physicalFile = output.resolve("data/iris/worldgen/biome/" + selected.substring("iris:".length()) + ".json"); + assertEquals(Files.readString(physicalFile), + Files.readString(output.resolve("data/overworld/worldgen/biome/savanna_plateau.json"))); + } finally { + data.close(); + } + } + + @Test + public void sharedPhysicalBiomesMergeTagRequirementsAcrossPacks() throws Exception { + Path first = temporaryFolder.newFolder("biome-tags-first").toPath(); + Path second = temporaryFolder.newFolder("biome-tags-second").toPath(); + Path output = temporaryFolder.newFolder("biome-tags-output").toPath(); + createPack(first, "shared", "shared_custom"); + createPack(second, "shared", "shared_custom"); + Path changed = second.resolve("biomes/test.json"); + Files.writeString(changed, Files.readString(changed).replace( + "\"id\": \"shared_custom\"", + "\"id\": \"shared_custom\", \"tags\": [\"example:second\"]")); + Map requirements = IrisDatapackCompiler.computeRegistryRequirements( + List.of(first.toFile(), second.toFile()), new DataFixerV1217()); + Map reversed = IrisDatapackCompiler.computeRegistryRequirements( + List.of(second.toFile(), first.toFile()), new DataFixerV1217()); + assertEquals(requirements, reversed); + IrisDatapackCompiler.compile(List.of(first.toFile(), second.toFile()), + new KList().qadd(output.toFile()), List.of(), new DataFixerV1217(), false); + assertEquals(1, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes"))); + assertTrue(Files.isRegularFile(output.resolve("data/example/tags/worldgen/biome/second.json"))); + } + + @Test + public void conflictingLegacyBiomeAliasesFailClosed() throws Exception { + Path firstPack = temporaryFolder.newFolder("legacy-biome-first").toPath().resolve("iris/pack"); + Path secondPack = temporaryFolder.newFolder("legacy-biome-second").toPath().resolve("iris/pack"); + Path datapackRoot = temporaryFolder.newFolder("legacy-biome-datapack").toPath(); + createPack(firstPack, "shared", "shared_custom"); + createPack(secondPack, "shared", "shared_custom"); + setCustomBiomeTemperature(secondPack, 1.25); + + IOException failure = assertThrows( + IOException.class, + () -> IrisDatapackCompiler.compile( + List.of(firstPack.toFile(), secondPack.toFile()), + new KList().qadd(datapackRoot.toFile()), + List.of(), + new DataFixerV1217(), + false + ) + ); + + assertTrue(failure.getMessage().contains("Conflicting custom biome output")); + } + private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception { createPack(root, dimensionKey, biomeId, "NORMAL"); } + private static long regularFileCount(Path root) throws IOException { + try (Stream files = Files.list(root)) { + return files.filter(Files::isRegularFile).count(); + } + } + + private static void setCustomBiomeTemperature(Path packRoot, double temperature) throws IOException { + Path biome = packRoot.resolve("biomes/test.json"); + String source = Files.readString(biome, StandardCharsets.UTF_8); + Files.writeString( + biome, + source.replace("\"id\": \"shared_custom\"", + "\"id\": \"shared_custom\", \"temperature\": " + temperature), + StandardCharsets.UTF_8 + ); + } + private static void createPack( Path root, String dimensionKey, @@ -365,4 +573,26 @@ public class IrisDatapackCompilerTest { dimension ); } + + private static final class BootstrapGenerationRegistry implements PlatformGenerationRegistry { + @Override + public String runtimeIdentity() { + return "bootstrap-test"; + } + + @Override + public String generatedDefinitionRendererIdentity() { + return "bootstrap-renderer-v1"; + } + + @Override + public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { + return "iris:" + dimensionTypeKey; + } + + @Override + public Definition definition(String registryKey, String resourceKey) { + return Definition.resourceIdentity(registryKey, resourceKey); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java index 4e5e1ee86..692a93eac 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java @@ -40,6 +40,17 @@ public class IrisSettingsDefaultsTest { assertFalse(settings.isAutoImportDatapackStructures()); } + @Test + public void generationTransitionWidthHasSafeBounds() { + IrisSettings.IrisSettingsGenerator settings = new IrisSettings.IrisSettingsGenerator(); + + assertEquals(256, settings.getGenerationTransitionWidthBlocks()); + settings.setGenerationTransitionWidthBlocks(1); + assertEquals(16, settings.getGenerationTransitionWidthBlocks()); + settings.setGenerationTransitionWidthBlocks(20_000); + assertEquals(8_192, settings.getGenerationTransitionWidthBlocks()); + } + @Test public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() { IrisSettings previous = IrisSettings.settings; diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java index 2f4199e5d..b52f61f26 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java @@ -1,11 +1,17 @@ package art.arcane.iris.core; +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; import org.bukkit.NamespacedKey; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -406,26 +412,34 @@ public class IrisWorldStorageTest { } @Test - public void frozenPackRootRequiresRealWorldLocalSnapshot() throws Exception { - File dimensionRoot = temporaryFolder.newFolder("frozen-pack-world"); + public void activeGenerationPackRequiresValidImmutableHistory() throws Exception { + File dimensionRoot = temporaryFolder.newFolder("generation-history-world"); assertThrows( IllegalStateException.class, - () -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot) + () -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot) ); Path irisRoot = Files.createDirectory(dimensionRoot.toPath().resolve("iris")); - Path externalPack = temporaryFolder.newFolder("external-pack").toPath(); - Files.createSymbolicLink(irisRoot.resolve("pack"), externalPack); + Path externalGeneration = temporaryFolder.newFolder("external-generation").toPath(); + Files.createSymbolicLink(irisRoot.resolve("generation"), externalGeneration); assertThrows( IllegalStateException.class, - () -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot) + () -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot) ); - Files.delete(irisRoot.resolve("pack")); - Path packRoot = Files.createDirectory(irisRoot.resolve("pack")); - assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)); + Files.delete(irisRoot.resolve("generation")); + Path sourcePack = createGenerationPack("storage-active-pack"); + GenerationHistory history = createGenerationHistory(dimensionRoot.toPath(), sourcePack, 42L); + assertEquals( + history.activePackRoot().toFile(), + IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot, 42L) + ); + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot, 43L) + ); } @Test @@ -444,4 +458,44 @@ public class IrisWorldStorageTest { Files.createDirectory(namespace.resolve("moon")); assertTrue(IrisWorldStorage.hasManagedWorldStorage(levelRoot)); } + + private Path createGenerationPack(String name) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + Path dimensions = Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(dimensions.resolve("overworld.json"), "{}"); + return pack; + } + + private static GenerationHistory createGenerationHistory(Path world, Path pack, long seed) + throws IOException { + String fingerprint = GenerationPackFingerprint.compute( + pack, + GenerationPackFingerprint.CURRENT_VERSION + ); + GenerationEpoch.DimensionContract dimensionContract = 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, + fingerprint, + seed, + dimensionContract, + GenerationRegistryContract.empty() + ); + } } diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java index ba2ab7d23..eac8fa9b2 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorRegistryVerificationTest.java @@ -84,6 +84,7 @@ public class ServerConfiguratorRegistryVerificationTest { new File("packs/overworld/dimensions/overworld.json")); when(dimension.getDimensionTypeKey()).thenReturn("iris:overworld"); when(dimension.getLoader()).thenReturn(data); + when(data.customBiomeResourceKey(dimension, customBiome)).thenReturn("overworld:missing"); ResourceLoader loader = mock(ResourceLoader.class); when(loader.getPossibleKeys()).thenReturn(new String[]{"overworld"}); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java index 9608d0eba..c3f93dc68 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -175,7 +175,7 @@ public class BukkitWorldConfigurationTest { assertEquals( levelRoot.resolve("dimensions/iris/broken").toAbsolutePath().normalize(), entryOf(audited, "world_iris_broken").storagePath()); - assertTrue(entryOf(audited, "world_iris_broken").detail().contains("pack")); + assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history")); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 38bd5b4f4..a2a495b62 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -6,6 +6,11 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSna import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -59,18 +64,17 @@ public class WorldReplacementBootstrapTest { } @Test - public void publishesArmedReplacementAfterFinderAddsNestedMetadata() throws Exception { + public void rejectsNestedMetadataAddedAfterImmutableEpochPublication() throws Exception { Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); configureReplacement(transaction); - Path dimensions = paths(transaction).stage().resolve("iris/pack/dimensions"); + Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions"); Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata"); - WorldReplacementBootstrap.ReconcileResult result = reconcile(); + assertThrows(IOException.class, this::reconcile); - assertEquals(1, result.published()); - assertEquals("replacement", replacementContent(target.worldDirectory())); - assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); - assertEquals(Phase.PUBLISHED, loadSingle().phase()); + assertTrue(Files.isDirectory(paths(transaction).stage())); + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertEquals(Phase.ARMED, loadSingle().phase()); } @Test @@ -267,12 +271,8 @@ public class WorldReplacementBootstrapTest { UUID endId = UUID.randomUUID(); ReplacementPaths endPaths = WorldReplacementFilesystem.paths(endTarget, endId); writeOriginalTarget(endPaths, "end-original"); - Path endDimension = endPaths.stage().resolve("iris/pack/dimensions/theend.json"); - Files.createDirectories(endDimension.getParent()); - Files.writeString(endDimension, "end-replacement"); - String endFingerprint = WorldReplacementFilesystem.fingerprintPack( - endPaths.stage().resolve("iris/pack") - ); + Path endPack = createStagedHistory(endPaths.stage(), "theend", "end-replacement", SEED); + String endFingerprint = WorldReplacementFilesystem.fingerprintPack(endPack); Transaction end = new Transaction( endId, endKey, @@ -295,8 +295,8 @@ public class WorldReplacementBootstrapTest { assertEquals(0, result.rolledBack()); assertEquals(0, result.retained()); assertEquals("replacement", replacementContent(target.worldDirectory())); - assertEquals("end-replacement", Files.readString(endTarget.worldDirectory() - .resolve("iris/pack/dimensions/theend.json"))); + assertEquals("end-replacement", Files.readString(activePackRoot(endTarget.worldDirectory()) + .resolve("dimensions/theend.json"))); assertEquals("nether-original", Files.readString(backup(nether).resolve("original.txt"))); assertEquals("end-original", Files.readString(endPaths.backup().resolve("original.txt"))); List published = WorldReplacementJournal.load(dataDirectory, levelRoot); @@ -317,13 +317,14 @@ public class WorldReplacementBootstrapTest { UUID overworldId = UUID.randomUUID(); ReplacementPaths overworldPaths = WorldReplacementFilesystem.paths(overworldTarget, overworldId); writeOriginalTarget(overworldPaths, "overworld-original"); - Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json"); - Files.createDirectories(overworldDimension.getParent()); - Files.writeString(overworldDimension, "overworld-replacement"); - WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId); - String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack( - overworldPaths.stage().resolve("iris/pack") + Path overworldPack = createStagedHistory( + overworldPaths.stage(), + "overworld", + "overworld-replacement", + overworldSeed ); + WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId); + String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(overworldPack); Transaction overworld = new Transaction( overworldId, overworldKey, @@ -358,7 +359,7 @@ public class WorldReplacementBootstrapTest { target.worldDirectory() ); assertEquals("overworld-replacement", Files.readString( - overworldTarget.worldDirectory().resolve("iris/pack/dimensions/overworld.json") + activePackRoot(overworldTarget.worldDirectory()).resolve("dimensions/overworld.json") )); assertEquals("replacement", replacementContent(target.worldDirectory())); assertEquals("overworld-original", Files.readString(overworldPaths.backup().resolve("original.txt"))); @@ -474,10 +475,8 @@ public class WorldReplacementBootstrapTest { if (originalPresent) { writeOriginalTarget(paths, originalContent); } - Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json"); - Files.createDirectories(dimension.getParent()); - Files.writeString(dimension, "replacement"); - String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack")); + Path pack = createStagedHistory(paths.stage(), "underworld", "replacement", SEED); + String fingerprint = WorldReplacementFilesystem.fingerprintPack(pack); Transaction transaction = new Transaction( id, WORLD_KEY, @@ -545,6 +544,51 @@ public class WorldReplacementBootstrapTest { } private String replacementContent(Path worldDirectory) throws Exception { - return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json")); + return Files.readString(activePackRoot(worldDirectory).resolve("dimensions/underworld.json")); + } + + private Path createStagedHistory(Path world, String dimensionKey, String content, long seed) + throws Exception { + Path source = world.resolveSibling(world.getFileName() + "." + dimensionKey + ".pack-source"); + Path dimension = source.resolve("dimensions").resolve(dimensionKey + ".json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, content); + String fingerprint = GenerationPackFingerprint.compute( + source, + GenerationPackFingerprint.CURRENT_VERSION + ); + Files.createDirectory(world); + return GenerationHistory.create( + world, + source, + fingerprint, + seed, + generationContract(dimensionKey), + GenerationRegistryContract.empty() + ).activePackRoot(); + } + + private static Path activePackRoot(Path world) throws IOException { + return GenerationHistory.open(world).activePackRoot(); + } + + private static GenerationEpoch.DimensionContract generationContract(String dimensionKey) { + return new GenerationEpoch.DimensionContract( + dimensionKey, + "iris:" + dimensionKey + "_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "c".repeat(64) + ); } } diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index e2c649127..9622a9118 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -1,5 +1,10 @@ package art.arcane.iris.core.lifecycle; +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.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; import org.junit.Assume; import org.junit.Rule; import org.junit.Test; @@ -216,7 +221,7 @@ public class WorldReplacementFilesystemTest { WorldReplacementFilesystem.rollback(paths, true); assertEquals("original", Files.readString(paths.target().resolve("original.txt"))); - assertFalse(Files.exists(paths.target().resolve("iris/pack"))); + assertFalse(Files.exists(paths.target().resolve("iris/generation"))); assertFalse(Files.exists(paths.stage())); assertFalse(Files.exists(paths.backup())); } @@ -492,10 +497,24 @@ public class WorldReplacementFilesystemTest { } private String writeStage(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception { - Path contentFile = packContent(paths.stage()); + Path source = paths.stage().resolveSibling(paths.stage().getFileName() + ".pack-source"); + Path contentFile = source.resolve("dimensions/underworld.json"); Files.createDirectories(contentFile.getParent()); Files.writeString(contentFile, content); - return WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack")); + String generationFingerprint = GenerationPackFingerprint.compute( + source, + GenerationPackFingerprint.CURRENT_VERSION + ); + Files.createDirectory(paths.stage()); + GenerationHistory history = GenerationHistory.create( + paths.stage(), + source, + generationFingerprint, + 42L, + generationContract("underworld"), + GenerationRegistryContract.empty() + ); + return WorldReplacementFilesystem.fingerprintPack(history.activePackRoot()); } private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception { @@ -535,8 +554,30 @@ public class WorldReplacementFilesystemTest { return Files.readString(packContent(worldDirectory)); } - private Path packContent(Path worldDirectory) { - return worldDirectory.resolve("iris/pack/dimensions/underworld.json"); + private Path packContent(Path worldDirectory) throws IOException { + return GenerationHistory.open(worldDirectory) + .activePackRoot() + .resolve("dimensions/underworld.json"); + } + + private static GenerationEpoch.DimensionContract generationContract(String dimensionKey) { + return new GenerationEpoch.DimensionContract( + dimensionKey, + "iris:" + dimensionKey + "_type", + "NETHER", + "OVERWORLD", + 0, + 0, + 256, + 256, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "c".repeat(64) + ); } private String artifactStem(String name, UUID transactionId) { diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataRetainedPackTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataRetainedPackTest.java new file mode 100644 index 000000000..37b5bcaf1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataRetainedPackTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.core.loader; + +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.history.GenerationRegistryContract; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; + +public class IrisDataRetainedPackTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisData retained; + + @Before + public void openRetainedData() throws Exception { + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + retained = spy(IrisData.openRuntime(temporaryFolder.newFolder("retained-pack"))); + retained.bindGenerationRegistryContract(GenerationRegistryContract.empty()); + } + + @After + public void closeData() { + if (retained != null) { + retained.close(); + } + IrisServices.clear(); + } + + @Test + public void missingRetainedResourceNeverSearchesMutableGlobalPacks() { + doReturn(null).when(retained).load(IrisDimension.class, "missing", false); + + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + assertNull(IrisData.loadAnyDimension("missing", retained)); + platforms.verifyNoInteractions(); + } + } + + @Test + public void existingRetainedResourceComesFromItsBoundPack() { + IrisDimension dimension = new IrisDimension(); + doReturn(dimension).when(retained).load(IrisDimension.class, "overworld", false); + + try (MockedStatic platforms = mockStatic(IrisPlatforms.class)) { + assertSame(dimension, IrisData.loadAnyDimension("overworld", retained)); + platforms.verifyNoInteractions(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java index c431e5b8c..ba8062d86 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java @@ -379,15 +379,13 @@ public class IrisLanguageTest { { "locale": "de_DE", "messages": { - "iris.bukkit.runtime.commanddeveloper.update_world_warning": [ + "iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [ "Sicherung erstellen.", - "Mögliche Probleme:", - " - Beschädigte Chunks", - " - Neu generierte Chunks", - " - Fehlende Strukturen", - " - Nicht verbundene Höhlen", - " - Nicht verbundene Geländeschichten", - "Risiken bestätigt.", + "Vorhandene Chunks bleiben erhalten.", + "Neue Chunks verwenden das neue Paket.", + "Die Grenze bleibt gespeichert.", + "Den vollständigen Verlauf sichern.", + "Neustart bestätigen.", "Welt {world}, Paket {pack}" ], "iris.bukkit.runtime.commandpack.more_warning_s": { @@ -401,13 +399,11 @@ public class IrisLanguageTest { assertTrue(IrisLanguage.reload(dataFolder, "de_DE")); assertEquals( "Sicherung erstellen.\n" - + "Mögliche Probleme:\n" - + " - Beschädigte Chunks\n" - + " - Neu generierte Chunks\n" - + " - Fehlende Strukturen\n" - + " - Nicht verbundene Höhlen\n" - + " - Nicht verbundene Geländeschichten\n" - + "Risiken bestätigt.\n" + + "Vorhandene Chunks bleiben erhalten.\n" + + "Neue Chunks verwenden das neue Paket.\n" + + "Die Grenze bleibt gespeichert.\n" + + "Den vollständigen Verlauf sichern.\n" + + "Neustart bestätigen.\n" + "Welt world, Paket pack", IrisLanguage.plain( BukkitRuntimeMessages.COMMAND_DEVELOPER_UPDATE_WORLD_WARNING, diff --git a/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java b/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java index 9cb76368f..230aaf73b 100644 --- a/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java +++ b/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java @@ -2,20 +2,26 @@ package art.arcane.iris.core.nms; import org.junit.Test; +import java.net.URL; +import java.net.URLClassLoader; +import java.lang.reflect.Method; +import java.util.function.BooleanSupplier; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class ServerShutdownBoundaryTest { @Test public void await_returnsImmediatelyWhenBoundaryIsAlreadyReached() { - assertTrue(ServerShutdownBoundary.await( + assertTrue(new ServerShutdownBoundary( () -> true, - Thread.currentThread(), + Thread.currentThread()).await( 0L, TimeUnit.MILLISECONDS )); @@ -23,9 +29,9 @@ public class ServerShutdownBoundaryTest { @Test public void await_doesNotJoinTheCallingServerThread() { - assertFalse(ServerShutdownBoundary.await( + assertFalse(new ServerShutdownBoundary( () -> false, - Thread.currentThread(), + Thread.currentThread()).await( 5L, TimeUnit.SECONDS )); @@ -44,11 +50,10 @@ public class ServerShutdownBoundaryTest { await(releaseServer); boundaryReached.set(true); }, "server-boundary-test"); + ServerShutdownBoundary boundary = new ServerShutdownBoundary(boundaryReached::get, serverThread); Thread waiterThread = new Thread(() -> { waiterStarted.countDown(); - result.set(ServerShutdownBoundary.await( - boundaryReached::get, - serverThread, + result.set(boundary.await( 5L, TimeUnit.SECONDS )); @@ -76,9 +81,9 @@ public class ServerShutdownBoundaryTest { serverThread.start(); try { - assertFalse(ServerShutdownBoundary.await( + assertFalse(new ServerShutdownBoundary( () -> false, - serverThread, + serverThread).await( 0L, TimeUnit.MILLISECONDS )); @@ -88,6 +93,34 @@ public class ServerShutdownBoundaryTest { } } + @Test + public void preparationLinksTheBoundarySupplierBeforeShutdown() { + AtomicInteger reads = new AtomicInteger(); + ServerShutdownBoundary boundary = new ServerShutdownBoundary( + () -> reads.incrementAndGet() > 1, Thread.currentThread()); + + assertEquals(1, reads.get()); + assertTrue(boundary.await(0L, TimeUnit.MILLISECONDS)); + } + + @Test + public void preparedBoundaryRemainsUsableAfterItsClassLoaderCloses() throws Exception { + URL classes = ServerShutdownBoundary.class.getProtectionDomain().getCodeSource().getLocation(); + AtomicBoolean reached = new AtomicBoolean(false); + BooleanSupplier supplier = reached::get; + Object boundary; + Method awaitMethod; + try (URLClassLoader loader = new URLClassLoader(new URL[]{classes}, ClassLoader.getPlatformClassLoader())) { + Class type = Class.forName(ServerShutdownBoundary.class.getName(), true, loader); + boundary = type.getConstructor(BooleanSupplier.class, Thread.class) + .newInstance(supplier, Thread.currentThread()); + awaitMethod = type.getMethod("await", long.class, TimeUnit.class); + } + + reached.set(true); + assertEquals(Boolean.TRUE, awaitMethod.invoke(boundary, 0L, TimeUnit.MILLISECONDS)); + } + private static void await(CountDownLatch latch) { try { latch.await(); diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index 477334d61..c4525b85e 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.core.pack; import art.arcane.iris.core.lifecycle.BukkitStartupPaths; +import art.arcane.iris.engine.history.GenerationRegistryContractFactory; +import art.arcane.iris.engine.object.IrisBiomeCustom; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import org.junit.Assume; @@ -118,8 +120,8 @@ public class DefaultPackBootstrapProvisionerTest { assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json"))); assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot()); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); - assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); - assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(installed.datapackRoot(), "overworld", "bootstrap_biome"))); + assertTrue(Files.isRegularFile(installedBiomePath(installed.datapackRoot(), "underworld", "underworld_biome"))); assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack"))); assertTrue(DefaultPackBootstrapProvisioner.isProvisioned( dataDirectory, @@ -176,8 +178,8 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status()); assertEquals(2, requests.get()); assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta"))); - assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); - assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(rebuilt.datapackRoot(), "overworld", "bootstrap_biome"))); + assertTrue(Files.isRegularFile(installedBiomePath(rebuilt.datapackRoot(), "underworld", "underworld_biome"))); } finally { if (!serverStopped) { server.stop(0); @@ -323,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(1, requests.get()); assertTrue(Files.isSymbolicLink(link)); assertEquals(target.toRealPath(), link.toRealPath()); - assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(result.datapackRoot(), "overworld", "local_biome"))); Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8); assertFalse(DefaultPackBootstrapProvisioner.isProvisioned( @@ -375,7 +377,7 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); assertEquals(2, requests.get()); - assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "second", "second_biome"))); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json"))); } finally { server.stop(0); @@ -406,7 +408,7 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(3, requests.get()); assertTrue(Files.readString(editedBiome).contains("locally_edited_biome")); - assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "overworld", "locally_edited_biome"))); Properties marker = new Properties(); try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) { marker.load(input); @@ -446,8 +448,7 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(3, requests.get()); assertTrue(Files.readString(editedBiome).contains("underworld_local_edit")); - assertTrue(Files.isRegularFile(updated.datapackRoot() - .resolve("data/underworld/worldgen/biome/underworld_local_edit.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_local_edit"))); Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties")); assertEquals("true", marker.getProperty("pack.overworld.managed")); assertEquals("false", marker.getProperty("pack.underworld.managed")); @@ -491,12 +492,9 @@ public class DefaultPackBootstrapProvisionerTest { .contains("overworld_first")); assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json")) .contains("underworld_second")); - assertTrue(Files.isRegularFile(updated.datapackRoot() - .resolve("data/overworld/worldgen/biome/overworld_first.json"))); - assertTrue(Files.isRegularFile(updated.datapackRoot() - .resolve("data/underworld/worldgen/biome/underworld_second.json"))); - assertFalse(Files.exists(updated.datapackRoot() - .resolve("data/underworld/worldgen/biome/underworld_first.json"))); + assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "overworld", "overworld_first"))); + assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_second"))); + assertFalse(Files.exists(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_first"))); } finally { if (initialServer != null) { initialServer.stop(0); @@ -668,6 +666,17 @@ public class DefaultPackBootstrapProvisionerTest { } } + private static Path installedBiomePath(Path datapackRoot, String dimension, String id) { + String resourceKey = GenerationRegistryContractFactory.customBiomeResourceKey( + dimension, new IrisBiomeCustom().setId(id) + ); + int separator = resourceKey.indexOf(':'); + return datapackRoot.resolve("data") + .resolve(resourceKey.substring(0, separator)) + .resolve("worldgen/biome") + .resolve(resourceKey.substring(separator + 1) + ".json"); + } + private static DefaultPackBootstrapProvisioner.ProvisionOptions options( HttpServer server, Path serverRoot, diff --git a/core/src/test/java/art/arcane/iris/core/service/LogFilterSVCTest.java b/core/src/test/java/art/arcane/iris/core/service/LogFilterSVCTest.java new file mode 100644 index 000000000..0892f7837 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/LogFilterSVCTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.core.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LogFilterSVCTest { + @Test + public void removesFilterFromItsOriginalLoggerAfterLoggingShutdown() { + Logger logger = mock(Logger.class); + LoggerConfig configuration = mock(LoggerConfig.class); + when(logger.get()).thenReturn(configuration); + LogFilterSVC service = new LogFilterSVC(); + + try (MockedStatic logging = mockStatic(LogManager.class)) { + logging.when(LogManager::getRootLogger).thenReturn(logger); + service.onEnable(); + service.onEnable(); + logging.when(LogManager::getRootLogger).thenThrow(new IllegalStateException("Logging stopped")); + + service.onDisable(); + service.onDisable(); + + logging.verify(LogManager::getRootLogger, times(1)); + verify(configuration, times(1)).addFilter(service); + verify(configuration, times(1)).removeFilter(service); + } + } + + @Test + public void bindsToTheNewConfigurationAfterReenable() { + Logger firstLogger = mock(Logger.class); + Logger secondLogger = mock(Logger.class); + LoggerConfig firstConfiguration = mock(LoggerConfig.class); + LoggerConfig secondConfiguration = mock(LoggerConfig.class); + when(firstLogger.get()).thenReturn(firstConfiguration); + when(secondLogger.get()).thenReturn(secondConfiguration); + LogFilterSVC service = new LogFilterSVC(); + + try (MockedStatic logging = mockStatic(LogManager.class)) { + logging.when(LogManager::getRootLogger).thenReturn(firstLogger, secondLogger); + service.onEnable(); + service.onDisable(); + service.onEnable(); + service.onDisable(); + + verify(firstConfiguration).addFilter(service); + verify(firstConfiguration).removeFilter(service); + verify(secondConfiguration).addFilter(service); + verify(secondConfiguration).removeFilter(service); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/PreservationSVCTest.java b/core/src/test/java/art/arcane/iris/core/service/PreservationSVCTest.java new file mode 100644 index 000000000..5fcfdf25a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/PreservationSVCTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +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 PreservationSVCTest { + @Test + public void retainsShutdownExecutorsUntilTheyActuallyTerminate() { + PreservationSVC preservation = new PreservationSVC(); + ExecutorService executor = mock(ExecutorService.class); + when(executor.isShutdown()).thenReturn(true); + when(executor.isTerminated()).thenReturn(false); + preservation.register(executor); + + try (MockedStatic data = mockStatic(IrisData.class)) { + preservation.dereference(); + assertTrue(preservation.hasActiveResources()); + + when(executor.isTerminated()).thenReturn(true); + preservation.dereference(); + assertFalse(preservation.hasActiveResources()); + } + } + + @Test + public void tracksOwnedThreadsUntilTheyExit() throws Exception { + PreservationSVC preservation = new PreservationSVC(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch stop = new CountDownLatch(1); + Thread worker = new Thread(() -> awaitStop(started, stop), "preservation-shutdown-test"); + preservation.register(worker); + worker.start(); + try { + assertTrue(started.await(5L, TimeUnit.SECONDS)); + assertTrue(preservation.hasActiveResources()); + } finally { + stop.countDown(); + worker.join(5000L); + } + assertFalse(worker.isAlive()); + assertFalse(preservation.hasActiveResources()); + } + + @Test + public void emptyPreservationDoesNotPreventLoaderRelease() { + assertFalse(new PreservationSVC().hasActiveResources()); + } + + private static void awaitStop(CountDownLatch started, CountDownLatch stop) { + started.countDown(); + try { + stop.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackFeedbackContractTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackFeedbackContractTest.java index b48ee9520..1b94cd041 100644 --- a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackFeedbackContractTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackFeedbackContractTest.java @@ -15,8 +15,8 @@ public class StudioSVCWorldPackFeedbackContractTest { "src/main/java/art/arcane/iris/core/service/StudioSVC.java" )).replace("\r\n", "\n"); - assertEquals(1, occurrences(source, "STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD")); - assertEquals(2, occurrences(source, "STUDIO_S_V_C_PACK_INSTALL_FAILED")); + assertEquals(2, occurrences(source, "STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD")); + assertEquals(3, occurrences(source, "STUDIO_S_V_C_PACK_INSTALL_FAILED")); assertFalse(source.contains("STUDIO_S_V_C_INSTALLING_PACKAGE")); assertFalse(source.contains("Publishing snapshot")); assertFalse(source.contains("sender.sendMessage(\"Iris refused to copy a pack")); diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java index 956ea1f88..4bd2172ea 100644 --- a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java @@ -444,6 +444,26 @@ public class StudioSVCWorldPackPublishTest { assertEquals("second", second.join()); } + @Test + public void productionWorldPublicationUsesImmutableGenerationHistory() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/service/StudioSVC.java" + )).replace("\r\n", "\n"); + int productionStart = source.indexOf("private IrisDimension publishGenerationHistory("); + int transientStart = source.indexOf("private IrisDimension installIntoDirectory(", productionStart); + String production = source.substring(productionStart, transientStart); + + assertTrue(production.contains("GenerationHistory.create(")); + assertTrue(production.contains("GenerationHistory.openIfPresent(dimensionRoot, worldSeed)")); + assertTrue(production.contains("GenerationHistory.adoptLegacyPack(")); + assertTrue(production.contains("history.stageUpdate(")); + assertTrue(production.contains("getGenerationTransitionWidthBlocks()")); + assertTrue(production.contains("history.activePackRoot()")); + assertFalse(production.contains("AtomicDirectoryPublisher.publish(")); + assertTrue(source.contains("installIntoTransientWorld(")); + assertTrue(source.contains("new File(dimensionRoot, \"iris/pack\")")); + } + private static void writeValidPack(Path packRoot) throws Exception { Files.createDirectories(packRoot.resolve("dimensions")); Files.createDirectories(packRoot.resolve("regions")); diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java index afd3d377e..7e70ac12f 100644 --- a/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java @@ -19,7 +19,7 @@ public class IrisPersistentWorldCreationContractTest { int dimensionRoot = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)"); int levelRoot = create.indexOf("WorldCreatorCompat.persistentLevelRoot(worldKey)"); int existingStorage = create.indexOf("Files.exists(storageRoot.toPath())"); - int freezePack = create.indexOf(".installIntoWorld(sender, resolvedDimension, dimensionRoot)"); + int freezePack = create.indexOf("studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed)"); int persistentCreator = create.indexOf(".persistent(!studio && !benchmark)"); int bukkitCreate = create.indexOf("INMS.get().createWorldAsync(wc, request)"); int rollbackStorage = create.indexOf( @@ -48,13 +48,16 @@ public class IrisPersistentWorldCreationContractTest { int persistentStorage = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)"); int canonicalIdentity = create.indexOf(".platformIdentity(worldKey.toString())"); int configuredName = create.indexOf(".name(creator.name())"); - int exactPack = create.indexOf("new File(w.worldFolder(), \"iris/pack\")"); + int history = create.indexOf("requireGenerationHistory(w.worldFolder(), seed)"); + int exactPack = create.indexOf("requireActivePack(generationHistory)"); assertTrue(persistentCreator >= 0); assertTrue(persistentStorage > persistentCreator); assertTrue(canonicalIdentity > persistentStorage); assertTrue(configuredName > canonicalIdentity); - assertTrue(exactPack > configuredName); + assertTrue(history > configuredName); + assertTrue(exactPack > history); + assertTrue(create.contains("new File(w.worldFolder(), \"iris/pack\")")); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltShutdownContractTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltShutdownContractTest.java new file mode 100644 index 000000000..892c42826 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltShutdownContractTest.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisToolbeltShutdownContractTest { + @Test + public void disabledPluginDoesNotImplyServerShutdown() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.irisToolbeltSource"))); + int start = source.indexOf("public static boolean isServerStopping()"); + int end = source.indexOf("public static void beginWorldMaintenance", start); + assertTrue(start >= 0); + assertTrue(end > start); + String stopping = source.substring(start, end); + + assertTrue(stopping.contains("return INMS.isBound() && INMS.get().isServerStopping();")); + assertFalse(stopping.contains("isEnabled()")); + assertFalse(stopping.contains("hasPlugin()")); + assertFalse(stopping.contains("getMethod(")); + } +} 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 c475db299..4f385971e 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class EngineRuntimePublicationContractTest { @@ -37,6 +38,69 @@ public class EngineRuntimePublicationContractTest { assertBefore(publish, "sealAndAwait(", "closeRuntime(next, e)"); } + @Test + public void detachedRuntimeBuildDoesNotCreateWorldOwnedServices() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n"); + int buildStart = source.indexOf("GenerationRuntime buildDetachedGenerationRuntime("); + int buildEnd = source.indexOf("void publishRuntime(", buildStart); + String build = source.substring(buildStart, buildEnd); + + assertTrue(build.contains("assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan)")); + assertTrue(build.contains("assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory)")); + assertTrue(build.contains("assembly.mantle.hotload()")); + assertTrue(build.contains("assembly.runtimeKernel.createMode(engine)")); + assertTrue(build.contains("assembly.runtimeKernel.createUpperContext(engine)")); + assertTrue(build.contains("assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode)")); + assertFalse(build.contains("EngineEffectsProvider")); + assertFalse(build.contains("EngineWorldManagerProvider")); + assertFalse(build.contains("worldManager().start()")); + } + + @Test + public void runtimePublicationTransfersAnIdenticalMantleWithoutClosingIt() throws IOException { + String builderSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n"); + String shutdownSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n"); + + assertTrue(builderSource.contains("next.generation().mantle()")); + assertTrue(shutdownSource.contains("generationRuntime.mantle() != retainedMantle")); + } + + @Test + public void engineAndDetachedRuntimeAcceptExplicitMantleDirectories() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/IrisEngine.java")).replace("\r\n", "\n"); + + assertTrue(source.contains("InitializationMode initializationMode,\n Path mantleStorageDirectory")); + assertTrue(source.contains("EngineTarget runtimeTarget,\n Path mantleStorageDirectory")); + assertTrue(source.contains("exclusive mantle storage directory")); + } + + @Test + public void mantleIoPinsTheRuntimeDataAcrossWorkerThreads() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/IrisEngineMantle.java")).replace("\r\n", "\n"); + + assertTrue(source.contains("runtimeData::get")); + assertTrue(source.contains("runtimeData.set(Objects.requireNonNull(engine.getData()")); + assertFalse(source.contains("createDataAdapter(engine::getData)")); + } + + @Test + public void failedDetachedBuildReleasesTransferredTargetOwnership() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/IrisEngine.java")).replace("\r\n", "\n"); + int buildStart = source.indexOf("public GenerationRuntimeBinding buildDetachedGenerationRuntime("); + int buildEnd = source.indexOf("public void closeDetachedGenerationRuntime(", buildStart); + String build = source.substring(buildStart, buildEnd); + + assertTrue(build.contains("detachedData.registerEngine(this)")); + assertTrue(build.contains("shutdownSequence.closeDetachedTarget(requiredTarget, null)")); + assertTrue(build.contains("shutdownSequence.closeDetachedGenerationRuntime(detached, null)")); + } + private static void assertBefore(String source, String first, String second) { int firstIndex = source.indexOf(first); int secondIndex = source.indexOf(second); 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 8d814c044..609887d86 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java @@ -10,16 +10,28 @@ import static org.junit.Assert.assertTrue; public class EngineShutdownOwnershipContractTest { @Test - public void ownershipCloseMustSucceedBeforeMantleRelease() throws IOException { + public void attachedHistoryRouterClosesBeforeRuntimeOwnershipReleases() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n"); + int closeStart = source.indexOf("void close()"); + int routerClose = source.indexOf("engine::closeAttachedGenerationHistoryRuntimeRouter", closeStart); + int runtimeRelease = source.indexOf("releaseRuntime(failure)", routerClose); + + assertTrue(routerClose > closeStart); + assertTrue(runtimeRelease > routerClose); + } + + @Test + public void ownershipCloseMustSucceedBeforeRuntimeRelease() throws IOException { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n"); int ownershipClose = source.indexOf("NativeStructureOwnershipStore.close(engine)"); int ownershipGate = source.indexOf("if (ownershipFailure == null)", ownershipClose); - int mantleRelease = source.indexOf("releaseMantle(failure)", ownershipGate); + int runtimeRelease = source.indexOf("releaseRuntime(failure)", ownershipGate); assertTrue(ownershipClose >= 0); assertTrue(ownershipGate > ownershipClose); - assertTrue(mantleRelease > ownershipGate); + assertTrue(runtimeRelease > ownershipGate); } @Test @@ -31,12 +43,32 @@ 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 mantleRelease = cleanup.indexOf("engine.getMantle()::close", ownershipGate); + int detachedRelease = cleanup.indexOf("closeDetachedGenerationRuntimes(cleanupFailure)", ownershipGate); + int runtimeRelease = cleanup.indexOf("closeRuntime(engine.runtime, cleanupFailure)", ownershipGate); int closedPublication = cleanup.indexOf("engine.closed = true", ownershipGate); assertTrue(ownershipClose >= 0); assertTrue(ownershipGate > ownershipClose); - assertTrue(mantleRelease > ownershipGate); + assertTrue(detachedRelease > ownershipGate); + assertTrue(runtimeRelease > detachedRelease); assertTrue(closedPublication > ownershipGate); } + + @Test + public void detachedRuntimesCloseBeforeTheBaseRuntimeAndTarget() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n"); + int releaseStart = source.indexOf("private Throwable releaseRuntime("); + int releaseEnd = source.indexOf("private Throwable closeDetachedGenerationRuntimes(", releaseStart); + String release = source.substring(releaseStart, releaseEnd); + int detachedRelease = release.indexOf("closeDetachedGenerationRuntimes(null)"); + int detachedGate = release.indexOf("if (detachedFailure != null)", detachedRelease); + int baseRelease = release.indexOf("closeRuntime(engine.runtime, null)", detachedGate); + int targetRelease = source.indexOf("engine.publishedTarget::close", releaseEnd); + + assertTrue(detachedRelease >= 0); + assertTrue(detachedGate > detachedRelease); + assertTrue(baseRelease > detachedGate); + assertTrue(targetRelease > releaseEnd); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexTransitionTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexTransitionTest.java new file mode 100644 index 000000000..3bccc5bc9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexTransitionTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.engine.history.TransitionGenerationPlan; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import org.junit.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class IrisComplexTransitionTest { + @Test + public void blendsDryBoundaryTerrainFromTheFrozenSurface() { + TransitionGenerationPlan.TerrainSample sample = new TransitionGenerationPlan.TerrainSample( + 8D, + 0.5D, + 0D, + signature(OptionalInt.empty()), + 64D, + 55D, + 0D + ); + + assertEquals(82D, IrisComplex.blendNaturalTerrainHeight(100D, sample), 0D); + } + + @Test + public void blendsSubmergedBoundaryTerrainFromTheFrozenOceanFloor() { + TransitionGenerationPlan.TerrainSample sample = new TransitionGenerationPlan.TerrainSample( + 8D, + 0.5D, + 0D, + signature(OptionalInt.of(63)), + 64D, + 55D, + 0D + ); + + assertEquals(75D, IrisComplex.blendNaturalTerrainHeight(95D, sample), 0D); + } + + @Test + public void preservesNewTerrainAndHydrologyObjectsAtFullWeight() { + TransitionGenerationPlan.TerrainSample terrain = new TransitionGenerationPlan.TerrainSample( + 32D, + 1D, + 1D, + signature(OptionalInt.empty()), + 64D, + 55D, + 0D + ); + HydrologyColumnSample hydrology = hydrologySample(); + + assertEquals(101.25D, IrisComplex.blendNaturalTerrainHeight(101.25D, terrain), 0D); + assertSame(hydrology, IrisComplex.taperHydrologySample(hydrology, 1D)); + } + + @Test + public void tapersHydrologyGeometryTowardNaturalTerrain() { + HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(hydrologySample(), 0.5D); + HydrologyColumnLayer layer = tapered.layers().getFirst(); + + assertEquals(73, layer.bedY()); + assertEquals(75, layer.fluidHeadY()); + assertEquals(75, layer.ceilingY()); + assertEquals(73, tapered.terrainHeight()); + assertEquals("river", layer.profileKey()); + } + + private static TerrainBoundarySignature signature(OptionalInt fluidHeight) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(15, 8, 64, 55, fluidHeight, OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(0, 1, 1), + new TerrainBoundarySignature.BiomeEncoding(List.of("iris:frozen"), new short[]{0}) + ) + ); + } + + private static HydrologyColumnSample hydrologySample() { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + HydrologyFeatureType.SURFACE_POOL, + 2L, + 3L, + 4, + 70, + 6, + 1, + 0, + true + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 66, + 70, + 70, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "river", + "surface", + "mouth", + "shore", + "bank", + "cave" + ); + return new HydrologyColumnSample(4, 6, 80, 63, false, "parent", List.of(layer)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java new file mode 100644 index 000000000..4b2c9d206 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -0,0 +1,620 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.EngineStage; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.history.GenerationKernelRegistry; +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.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorld; +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.parallel.MultiBurst; +import art.arcane.iris.util.project.context.ChunkContext; +import org.junit.Test; +import org.junit.AfterClass; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntConsumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +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.verify; +import static org.mockito.Mockito.when; + +public class IrisEngineGenerationRuntimeScopeTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void scopedGettersResolveDetachedGenerationStateAndWorldServicesStayActive() throws Exception { + RuntimeFixture active = runtime(1, 1D, 2D, 3D); + RuntimeFixture detached = runtime(2, 4D, 5D, 6D); + EngineEffects effects = mock(EngineEffects.class); + EngineWorldManager worldManager = mock(EngineWorldManager.class); + IrisEngine engine = engine(active.runtime, effects, worldManager); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) { + assertSame(detached.target, engine.getTarget()); + assertSame(detached.data, engine.getData()); + assertSame(detached.dimension, engine.getDimension()); + assertSame(detached.runtime.seedManager(), engine.getSeedManager()); + assertSame(detached.complex, engine.getComplex()); + assertSame(detached.upperContext, engine.getUpperContext()); + assertSame(detached.mode, engine.getMode()); + assertSame(detached.mantle, engine.getMantle()); + assertSame(detached.hash32, engine.getHash32()); + assertEquals(2, engine.getCacheID()); + assertEquals(4D, engine.getMaxBiomeObjectDensity(), 0D); + assertEquals(5D, engine.getMaxBiomeLayerDensity(), 0D); + assertEquals(6D, engine.getMaxBiomeDecoratorDensity(), 0D); + assertSame(effects, engine.getEffects()); + assertSame(worldManager, engine.getWorldManager()); + } + + assertSame(active.target, engine.getTarget()); + assertSame(active.data, engine.getData()); + assertSame(active.runtime.seedManager(), engine.getSeedManager()); + assertSame(active.mantle, engine.getMantle()); + assertEquals(1, engine.getCacheID()); + } + + @Test + public void scopedKernelVersionProvidesTheAlgorithmDispatchKey() throws Exception { + GenerationKernelRegistry.Version activeVersion = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version historicalVersion = new GenerationKernelRegistry.Version(2, 3, 4); + RuntimeFixture active = runtime(1, 1D, 1D, 1D, activeVersion); + RuntimeFixture historical = runtime(2, 1D, 1D, 1D, historicalVersion); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, historical.runtime); + + assertEquals(activeVersion, engine.getGenerationKernelVersion()); + assertSame(active.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel()); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) { + assertEquals(historicalVersion, engine.getGenerationKernelVersion()); + assertSame(historical.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel()); + assertNotSame(active.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel()); + } + assertEquals(activeVersion, engine.getGenerationKernelVersion()); + } + + @Test + public void executableKernelFactoriesProduceDistinctRuntimeBindings() throws Exception { + GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); + IrisComplex complexOne = mock(IrisComplex.class); + IrisComplex complexTwo = mock(IrisComplex.class); + GenerationKernelRegistry kernels = new GenerationKernelRegistry( + versionTwo, + Set.of( + new GenerationKernelRegistry.Kernel( + 1, + "1".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> complexOne + ) + ), + new GenerationKernelRegistry.Kernel( + 2, + "2".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> complexTwo + ) + ) + ) + ); + GenerationKernelRegistry.RuntimeKernel kernelOne = kernels.select(versionOne); + GenerationKernelRegistry.RuntimeKernel kernelTwo = kernels.select(versionTwo); + IrisEngine factoryContext = mock(IrisEngine.class); + RuntimeFixture first = runtime( + 1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null)); + RuntimeFixture second = runtime( + 2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null)); + IrisEngine engine = engine(first.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding firstBinding = new IrisEngine.GenerationRuntimeBinding( + engine, first.runtime); + IrisEngine.GenerationRuntimeBinding secondBinding = detachedBinding(engine, second.runtime); + + assertSame(kernelOne, firstBinding.runtimeKernel()); + assertSame(kernelTwo, secondBinding.runtimeKernel()); + assertSame(complexOne, engine.getComplex()); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(secondBinding)) { + assertSame(kernelTwo, engine.getGenerationRuntimeKernel()); + assertSame(complexTwo, engine.getComplex()); + } + } + + @Test + public void burstWorkerReopensCapturedGenerationRuntimeScope() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + EngineMode mode = new IrisEngineMode(engine) { + }; + MultiBurst workerPool = new MultiBurst("Generation Runtime Scope Test", Thread.NORM_PRIORITY, () -> 2); + when(detached.target.getBurster()).thenReturn(workerPool); + AtomicReference observedTarget = new AtomicReference<>(); + AtomicReference observedThread = new AtomicReference<>(); + EngineStage stage = (x, z, blocks, biomes, multicore, context) -> { + observedTarget.set(engine.getTarget()); + observedThread.set(Thread.currentThread()); + }; + ChunkContext context = mock(ChunkContext.class); + when(context.getGenerationSessionId()).thenReturn(17L); + Thread caller = Thread.currentThread(); + + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) { + mode.burst(stage).generate(0, 0, null, null, true, context); + } finally { + mode.close(); + workerPool.close(); + } + + assertSame(detached.target, observedTarget.get()); + assertNotSame(caller, observedThread.get()); + } + + @Test + public void scopeRejectsForeignClosedAndOutOfOrderBindings() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture first = runtime(2, 2D, 2D, 2D); + RuntimeFixture second = runtime(3, 3D, 3D, 3D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine other = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding foreign = new IrisEngine.GenerationRuntimeBinding(other, first.runtime); + assertThrows(IllegalArgumentException.class, () -> engine.openGenerationRuntimeScope(foreign)); + + IrisEngine.GenerationRuntimeBinding closed = new IrisEngine.GenerationRuntimeBinding(engine, first.runtime); + assertThrows(IllegalStateException.class, () -> engine.openGenerationRuntimeScope(closed)); + + IrisEngine.GenerationRuntimeBinding firstBinding = detachedBinding(engine, first.runtime); + IrisEngine.GenerationRuntimeBinding secondBinding = detachedBinding(engine, second.runtime); + IrisEngine.GenerationRuntimeScope outer = engine.openGenerationRuntimeScope(firstBinding); + IrisEngine.GenerationRuntimeScope inner = engine.openGenerationRuntimeScope(secondBinding); + assertThrows(IllegalStateException.class, outer::close); + inner.close(); + outer.close(); + assertSame(active.target, engine.getTarget()); + } + + @Test + public void closingDetachedRuntimeReleasesGenerationTargetAndData() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + doAnswer(invocation -> { + assertSame(detached.mantle, engine.getMantle()); + return null; + }).when(detached.mode).close(); + + engine.closeDetachedGenerationRuntime(binding); + + verify(detached.mode).close(); + verify(detached.complex).close(); + verify(detached.mantle).saveAllNow(); + verify(detached.mantle).close(); + verify(detached.data).unregisterEngine(engine); + verify(detached.target).close(); + verify(detached.data).close(); + assertTrue(detached.hash32.isCancelled()); + engine.closeDetachedGenerationRuntime(binding); + } + + @Test + public void detachedRuntimeRetirementEvictsCachesOutsideLifecycleLock() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + AtomicInteger retiredRuntime = new AtomicInteger(-1); + AtomicBoolean lifecycleLockHeld = new AtomicBoolean(true); + engine.addGenerationRuntimeRetirementListener(runtimeId -> { + retiredRuntime.set(runtimeId); + lifecycleLockHeld.set(Thread.holdsLock(engine.lifecycleLock)); + }); + doAnswer(invocation -> { + assertEquals(2, retiredRuntime.get()); + return null; + }).when(detached.mode).close(); + + engine.closeDetachedGenerationRuntime(binding); + + assertEquals(2, binding.runtimeId()); + assertEquals(2, retiredRuntime.get()); + assertTrue(!lifecycleLockHeld.get()); + } + + @Test + public void detachedRuntimeRejectsNewScopesAfterRetirementBegins() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + AtomicReference rejected = new AtomicReference<>(); + engine.addGenerationRuntimeRetirementListener(runtimeId -> { + try { + engine.openGenerationRuntimeScope(binding); + } catch (Throwable failure) { + rejected.set(failure); + } + }); + + engine.closeDetachedGenerationRuntime(binding); + + assertTrue(rejected.get() instanceof IllegalStateException); + assertTrue(rejected.get().getMessage().contains("retiring")); + } + + @Test + public void closedDetachedScopeFallsBackToTheBaseRuntime() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime); + + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) { + assertSame(detached.target, engine.getTarget()); + engine.closeDetachedGenerationRuntime(binding); + assertSame(active.target, engine.getTarget()); + assertSame(active.mantle, engine.getMantle()); + } + } + + @Test + public void defaultRuntimeSelectionKeepsThePreviousRuntimeRoutable() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisEngine.GenerationRuntimeBinding activeBinding = new IrisEngine.GenerationRuntimeBinding( + engine, + active.runtime); + IrisEngine.GenerationRuntimeBinding detachedRuntimeBinding = detachedBinding(engine, detached.runtime); + + engine.setDefaultGenerationRuntime(detachedRuntimeBinding); + + assertSame(detached.target, engine.getTarget()); + assertThrows( + IllegalArgumentException.class, + () -> engine.closeDetachedGenerationRuntime(detachedRuntimeBinding)); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(activeBinding)) { + assertSame(active.target, engine.getTarget()); + } + + engine.setDefaultGenerationRuntime(activeBinding); + + assertSame(active.target, engine.getTarget()); + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(detachedRuntimeBinding)) { + assertSame(detached.target, engine.getTarget()); + } + engine.closeDetachedGenerationRuntime(detachedRuntimeBinding); + assertThrows( + IllegalStateException.class, + () -> engine.openGenerationRuntimeScope(detachedRuntimeBinding)); + } + + @Test + 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.engine()).thenReturn(engine); + engine.attachGenerationHistoryRuntimeRouter(router); + + assertTrue(engine.beginShutdown()); + engine.closeAttachedGenerationHistoryRuntimeRouter(); + engine.closeAttachedGenerationHistoryRuntimeRouter(); + + verify(router).close(); + } + + @Test + 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.engine()).thenReturn(engine); + engine.attachGenerationHistoryRuntimeRouter(router); + + IllegalStateException failure = assertThrows(IllegalStateException.class, engine::hotloadComplex); + + assertTrue(failure.getMessage().contains("immutable Iris generation history")); + assertSame(active.runtime, engine.runtime.generation()); + verify(active.complex, never()).close(); + } + + @Test + public void detachedHistoryCannotFallBackToTheDefaultGenerationRuntime() throws Exception { + 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.engine()).thenReturn(engine); + engine.attachGenerationHistoryRuntimeRouter(router); + engine.detachGenerationHistoryRuntimeRouter(router); + + assertThrows(IllegalStateException.class, () -> engine.openGenerationHistoryCoordinateScope(0, 0)); + assertThrows(IllegalStateException.class, () -> engine.getHeight(0, 0)); + } + + @Test + public void transferredMantleIsNotClosedWithTheRetiredRuntime() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + EngineShutdownSequence shutdownSequence = new EngineShutdownSequence(engine); + + Throwable failure = shutdownSequence.closeRuntime(engine.runtime, active.mantle, null); + + assertNull(failure); + verify(active.mantle, never()).saveAllNow(); + verify(active.mantle, never()).close(); + } + + @Test + public void ownedAssemblyMantleIsSavedAndClosedOnlyOnce() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + EngineRuntimeBuilder.RuntimeAssembly assembly = new EngineRuntimeBuilder.RuntimeAssembly( + 2, + active.target, + Path.of("build", "test-mantles", "assembly")); + assembly.mantle = mock(EngineMantle.class); + assembly.ownsMantle = true; + EngineShutdownSequence shutdownSequence = new EngineShutdownSequence(engine); + + assertNull(shutdownSequence.closeAssembly(assembly, null)); + assertNull(shutdownSequence.closeAssembly(assembly, null)); + + verify(assembly.mantle).saveAllNow(); + verify(assembly.mantle).close(); + } + + @Test + public void detachedRuntimeRejectsTheActiveMantleDirectory() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisWorld activeWorld = active.target.getWorld(); + when(detached.target.getWorld()).thenReturn(activeWorld); + setField(engine, "closing", new AtomicBoolean(false)); + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + + assertThrows( + IllegalArgumentException.class, + () -> engine.buildDetachedGenerationRuntime(detached.target, active.mantleStorageDirectory)); + + verify(detached.data, never()).registerEngine(engine); + } + + @Test + public void failedDetachedBuildReleasesTransferredTargetOwnership() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture detached = runtime(2, 2D, 2D, 2D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisWorld activeWorld = active.target.getWorld(); + when(detached.target.getWorld()).thenReturn(activeWorld); + setField(engine, "closing", new AtomicBoolean(false)); + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + EngineRuntimeBuilder runtimeBuilder = mock(EngineRuntimeBuilder.class); + setField(engine, "runtimeBuilder", runtimeBuilder); + IllegalStateException failure = new IllegalStateException("build failed"); + Path storageDirectory = Path.of("build", "test-mantles", "failed-detached").toAbsolutePath(); + when(runtimeBuilder.buildDetachedGenerationRuntime(detached.target, storageDirectory)).thenThrow(failure); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> engine.buildDetachedGenerationRuntime(detached.target, storageDirectory)); + + assertSame(failure, thrown); + verify(detached.data).registerEngine(engine); + verify(detached.data).unregisterEngine(engine); + verify(detached.target).close(); + verify(detached.data).close(); + } + + private static IrisEngine engine( + GenerationRuntime generationRuntime, + EngineEffects effects, + EngineWorldManager worldManager + ) throws Exception { + IrisEngine engine = mock(IrisEngine.class, CALLS_REAL_METHODS); + setField(engine, "lifecycleLock", new Object()); + setField(engine, "generationHistoryRuntimeRouterLock", new Object()); + setField(engine, "runtimeAssembly", new ThreadLocal()); + setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState()); + Set detached = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap())); + setField(engine, "detachedGenerationRuntimes", detached); + Set retiring = Collections.synchronizedSet( + Collections.newSetFromMap(new IdentityHashMap())); + setField(engine, "retiringGenerationRuntimes", retiring); + setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet()); + setField(engine, "shutdownSequence", new EngineShutdownSequence(engine)); + setField(engine, "hotloader", new EngineHotloader(engine)); + setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo()); + setField(engine, "closing", new AtomicBoolean(false)); + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + engine.runtime = new EngineRuntime(generationRuntime, effects, worldManager); + engine.publishedTarget = generationRuntime.target(); + return engine; + } + + @SuppressWarnings("unchecked") + private static IrisEngine.GenerationRuntimeBinding detachedBinding( + IrisEngine engine, + GenerationRuntime runtime + ) throws Exception { + Field field = IrisEngine.class.getDeclaredField("detachedGenerationRuntimes"); + field.setAccessible(true); + Set detached = (Set) field.get(engine); + detached.add(runtime); + return new IrisEngine.GenerationRuntimeBinding(engine, runtime); + } + + private static RuntimeFixture runtime( + int cacheId, + double objectDensity, + double layerDensity, + double decoratorDensity + ) throws IOException { + return runtime( + cacheId, + objectDensity, + layerDensity, + decoratorDensity, + GenerationKernelRegistry.standard().current() + ); + } + + private static RuntimeFixture runtime( + int cacheId, + double objectDensity, + double layerDensity, + double decoratorDensity, + GenerationKernelRegistry.Version kernelVersion + ) throws IOException { + GenerationKernelRegistry.RuntimeKernel runtimeKernel = runtimeKernel(kernelVersion); + return runtime( + cacheId, + objectDensity, + layerDensity, + decoratorDensity, + runtimeKernel, + mock(IrisComplex.class) + ); + } + + private static RuntimeFixture runtime( + int cacheId, + double objectDensity, + double layerDensity, + double decoratorDensity, + GenerationKernelRegistry.RuntimeKernel runtimeKernel, + IrisComplex complex + ) { + IrisWorld world = mock(IrisWorld.class); + IrisData data = mock(IrisData.class); + IrisDimension dimension = mock(IrisDimension.class); + EngineTarget target = mock(EngineTarget.class); + when(target.getWorld()).thenReturn(world); + when(target.getDimension()).thenReturn(dimension); + when(target.getData()).thenReturn(data); + when(target.getBurster()).thenReturn(MultiBurst.burst); + UpperDimensionContext upperContext = mock(UpperDimensionContext.class); + EngineMode mode = mock(EngineMode.class); + EngineMantle mantle = mock(EngineMantle.class); + Path mantleStorageDirectory = Path.of("build", "test-mantles", Integer.toString(cacheId)).toAbsolutePath(); + CompletableFuture hash32 = new CompletableFuture<>(); + GenerationRuntime runtime = new GenerationRuntime( + cacheId, + target, + data, + dimension, + runtimeKernel.version(), + runtimeKernel, + mock(SeedManager.class), + null, + complex, + upperContext, + mode, + mantle, + mantleStorageDirectory, + hash32, + new BiomeMaxes(objectDensity, layerDensity, decoratorDensity)); + return new RuntimeFixture( + runtime, + target, + data, + dimension, + complex, + upperContext, + mode, + mantle, + mantleStorageDirectory, + hash32); + } + + private static GenerationKernelRegistry.RuntimeKernel runtimeKernel( + GenerationKernelRegistry.Version version + ) throws IOException { + if (GenerationKernelRegistry.standard().supports( + version.generatorAbi(), + version.rngVersion(), + version.seedDerivationVersion())) { + return GenerationKernelRegistry.standard().select(version); + } + return new GenerationKernelRegistry.RuntimeKernel( + version, + "f".repeat(64), + (engine, transitionPlan) -> mock(IrisComplex.class) + ); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = IrisEngine.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private record RuntimeFixture( + GenerationRuntime runtime, + EngineTarget target, + IrisData data, + IrisDimension dimension, + IrisComplex complex, + UpperDimensionContext upperContext, + EngineMode mode, + EngineMantle mantle, + Path mantleStorageDirectory, + CompletableFuture hash32 + ) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java b/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java new file mode 100644 index 000000000..1def4757f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/UpperDimensionTransitionTest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.engine.history.TransitionGenerationPlan; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UpperDimensionTransitionTest { + @Test + public void gapLimitedTargetDoesNotCreateAnIntermediateCeilingDip() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("main"); + when(dimension.getUpperDimensionGap()).thenReturn(32); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 139D)); + when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 75D + 4D * x)); + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.terrainSampleAt(0, 8)).thenReturn(sample(75D, 0D)); + when(plan.terrainSampleAt(8, 8)).thenReturn(sample(75D, 0.5D)); + when(plan.terrainSampleAt(16, 8)).thenReturn(sample(75D, 1D)); + when(complex.getTransitionGenerationPlan()).thenReturn(plan); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(256); + + UpperDimensionContext context = UpperDimensionContext.create(engine, dimension); + + assertEquals(180, context.getEffectiveSurfaceY(0, 8)); + assertEquals(175, context.getEffectiveSurfaceY(8, 8)); + assertEquals(171, context.getEffectiveSurfaceY(16, 8)); + } + + @Test + public void blendsFrozenEffectiveCeilingTowardTheUnblendedSelfReference() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("main"); + when(dimension.getUpperDimensionGap()).thenReturn(32); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); + when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.terrainSampleAt(-17, 8)).thenReturn(sample(40D, 0.5D)); + when(complex.getTransitionGenerationPlan()).thenReturn(plan); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(256); + + UpperDimensionContext context = UpperDimensionContext.create(engine, dimension); + + assertEquals(195, context.getEffectiveSurfaceY(-17, 8)); + verify(complex, never()).getNaturalHeightStream(); + verify(complex, never()).getNaturalTrueBiomeStream(); + } + + @Test + public void preservesBoundaryAndCurrentEndpointsAndFixedGap() { + UpperDimensionContext.CeilingLayout layout = new UpperDimensionContext.CeilingLayout(384, 32); + double frozen = UpperDimensionContext.blendCeilingDepth(180D, sample(60D, 0D)); + double current = UpperDimensionContext.blendCeilingDepth(180D, sample(60D, 1D)); + + assertEquals(323, UpperDimensionContext.effectiveSurfaceY(frozen, layout, 100)); + assertEquals(203, UpperDimensionContext.effectiveSurfaceY(current, layout, 100)); + assertEquals(252, UpperDimensionContext.effectiveSurfaceY(current, layout, 220)); + assertEquals(188, -64 + UpperDimensionContext.effectiveSurfaceY(current, layout, 220)); + assertEquals(384, UpperDimensionContext.effectiveSurfaceY(current, layout, Integer.MAX_VALUE)); + } + + @Test + public void absentCeilingsRemainOutsideTheHunkAndAppearGradually() { + UpperDimensionContext.CeilingLayout layout = new UpperDimensionContext.CeilingLayout(256, 32); + assertEquals(256, UpperDimensionContext.effectiveSurfaceY( + UpperDimensionContext.blendCeilingDepth(80D, sample(0D, 0D)), layout, 64)); + assertEquals(215, UpperDimensionContext.effectiveSurfaceY( + UpperDimensionContext.blendCeilingDepth(80D, sample(0D, 0.5D)), layout, 64)); + assertEquals(215, UpperDimensionContext.effectiveSurfaceY( + UpperDimensionContext.blendCeilingDepth(0D, sample(80D, 0.5D)), layout, 64)); + assertEquals(256, UpperDimensionContext.effectiveSurfaceY( + UpperDimensionContext.blendCeilingDepth(0D, sample(80D, 1D)), layout, 64)); + assertEquals(256, UpperDimensionContext.effectiveSurfaceY(0.49D, layout, 64)); + assertEquals(254, UpperDimensionContext.effectiveSurfaceY(0.51D, layout, 64)); + } + + @Test + public void rejectsInvalidCeilingLayouts() { + assertThrows(IllegalArgumentException.class, () -> new UpperDimensionContext.CeilingLayout(0, 32)); + assertThrows(IllegalArgumentException.class, () -> new UpperDimensionContext.CeilingLayout(256, -1)); + } + + private static TransitionGenerationPlan.TerrainSample sample(double depth, double weight) { + TerrainBoundarySignature signature = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(15, 8, 64, 64, OptionalInt.empty(), + depth == 0D ? OptionalInt.empty() : OptionalInt.of((int) depth)), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 4, 1), + new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0}))); + return new TransitionGenerationPlan.TerrainSample(8D, weight, 0D, signature, 64D, 64D, depth); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java index d1a286194..2d84e21a0 100644 --- a/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisBiomeActuatorCoordinateTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.actuator; +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.framework.SeedManager; @@ -17,19 +18,25 @@ import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterBiomeInject; import org.junit.After; import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisBiomeActuatorCoordinateTest { @@ -42,7 +49,9 @@ public class IrisBiomeActuatorCoordinateTest { @SuppressWarnings("unchecked") public void derivativeScatterUsesEachWorldColumnCoordinate() { bindPlatform(); - Engine engine = engine(); + IrisComplex complex = mock(IrisComplex.class); + when(complex.allowsNewDiscreteContentAt(anyInt(), anyInt())).thenReturn(true); + Engine engine = engine(complex); IrisBiomeActuator actuator = new IrisBiomeActuator(engine); Hunk output = mock(Hunk.class); when(output.getWidth()).thenReturn(2); @@ -62,6 +71,7 @@ public class IrisBiomeActuatorCoordinateTest { ChunkedDataCache biomeCache = mock(ChunkedDataCache.class); when(biomeCache.get(anyInt(), anyInt())).thenReturn(biome); ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); when(context.getBiome()).thenReturn(biomeCache); actuator.onActuate(100, -200, output, false, context); @@ -69,8 +79,39 @@ public class IrisBiomeActuatorCoordinateTest { assertEquals(List.of("100,-200", "100,-199", "101,-200", "101,-199"), samples); } + @Test @SuppressWarnings("unchecked") - private Engine engine() { + public void transitionColumnEmitsFrozenPhysicalKeyWithoutResolvingANewBiome() { + bindPlatform(); + IrisComplex complex = mock(IrisComplex.class); + when(complex.allowsNewDiscreteContentAt(100, -200)).thenReturn(false); + when(complex.historicalPhysicalBiomeKeyAt(100, 0, -200)) + .thenReturn(Optional.of("iris:old-physical")); + Engine engine = engine(complex); + IrisBiomeActuator actuator = new IrisBiomeActuator(engine); + Hunk output = mock(Hunk.class); + when(output.getWidth()).thenReturn(1); + when(output.getDepth()).thenReturn(1); + when(output.getHeight()).thenReturn(1); + ChunkedDataCache biomeCache = mock(ChunkedDataCache.class); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + when(context.getBiome()).thenReturn(biomeCache); + + actuator.onActuate(100, -200, output, false, context); + + verify(biomeCache, never()).get(anyInt(), anyInt()); + verify(IrisPlatforms.get().registries()).biome("iris:old-physical"); + verify(IrisPlatforms.get().biomeWriter(), never()).biomeIdFor(anyString()); + Mantle mantle = engine.getMantle().getMantle(); + verify(mantle).set(eq(100), eq(0), eq(-200), argThat(value -> { + MatterBiomeInject injection = (MatterBiomeInject) value; + return !injection.isCustom() && "iris:old-physical".equals(injection.getBiomeKey()); + })); + } + + @SuppressWarnings("unchecked") + private Engine engine(IrisComplex complex) { Mantle mantle = mock(Mantle.class); EngineMantle engineMantle = mock(EngineMantle.class); when(engineMantle.getMantle()).thenReturn(mantle); @@ -80,6 +121,7 @@ public class IrisBiomeActuatorCoordinateTest { when(engine.getCacheID()).thenReturn(1); when(engine.getSeedManager()).thenReturn(seedManager); when(engine.getMantle()).thenReturn(engineMantle); + when(engine.getComplex()).thenReturn(complex); when(engine.getMetrics()).thenReturn(new EngineMetrics(16)); return engine; } diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java index c7618a6e2..c02fcad53 100644 --- a/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java @@ -9,9 +9,14 @@ import art.arcane.iris.engine.object.IrisDecorationStep; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisRiverMaterialConfig; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Test; +import org.junit.Before; +import org.junit.After; import java.util.ArrayList; import java.util.List; @@ -23,6 +28,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; @@ -31,6 +37,28 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisTerrainNormalActuatorHydrologyTest { + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + @Test public void hydrologyOwnedFluidBypassesBiomeSeaLayers() { PlatformBlockState water = mock(PlatformBlockState.class); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java index 91446c2cd..7df337af9 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java @@ -3,11 +3,16 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.history.ChunkGenerationSemantics; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.history.GenerationSemanticIndex; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.project.stream.ProceduralStream; @@ -22,6 +27,7 @@ import org.junit.Test; import java.io.File; import java.nio.file.Files; import java.util.HashSet; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; @@ -36,6 +42,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -91,6 +98,7 @@ public class HintedLocatorTest { when(engine.getFocusRegion()).thenReturn(null); when(engine.isClosed()).thenReturn(false); when(engine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop()); + when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); when(data.getBiomeLoader()).thenReturn(biomeLoader); when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); } @@ -428,6 +436,267 @@ public class HintedLocatorTest { assertTrue(elapsed < 10_000); } + @Test + public void locatorMergesRecordedAndActiveCandidatesByDistance() throws Exception { + Position2 origin = new Position2(0, 0); + Position2 activeChunk = new Position2(1, 0); + Locator locator = new Locator<>() { + @Override + public boolean matches(Engine ignoredEngine, Position2 chunk) { + return activeChunk.equals(chunk); + } + + @Override + public SearchCandidate nearestRecordedCandidate( + Engine ignoredEngine, + Position2 ignoredOrigin, + int ignoredRadius + ) { + return SearchCandidate.atChunkCenter(new Position2(20, 0)); + } + }; + + Position2 result = locator.find(engine, origin, 60_000, ignored -> { }).get(); + + assertEquals(activeChunk, result); + } + + @Test + public void impossibleTargetWithNoHistoricalFallbackReturnsWithoutScanning() throws Exception { + IrisEngine routedEngine = mock(IrisEngine.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(routedEngine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop()); + when(router.history()).thenReturn(history); + when(history.hasHistoricalFallbackInSquare(anyInt(), anyInt(), anyInt())).thenReturn(false); + Locator locator = new HintedLocator<>((ignoredEngine, chunk) -> { + throw new AssertionError("Impossible target must not enter procedural search."); + }, ignoredEngine -> HintedLocator.SearchPlan.impossible()); + + assertNull(locator.find(routedEngine, new Position2(0, 0), 60_000, ignored -> { }) + .get(3, TimeUnit.SECONDS)); + } + + @Test + public void hintedLocatorMergesRecordedAndActiveCandidatesByDistance() throws Exception { + Position2 origin = new Position2(0, 0); + Position2 activeChunk = new Position2(1, 0); + Locator exact = new Locator<>() { + @Override + public boolean matches(Engine ignoredEngine, Position2 chunk) { + return activeChunk.equals(chunk); + } + + @Override + public SearchCandidate nearestRecordedCandidate( + Engine ignoredEngine, + Position2 ignoredOrigin, + int ignoredRadius + ) { + return SearchCandidate.atChunkCenter(new Position2(20, 0)); + } + }; + Locator locator = new HintedLocator<>( + exact, + ignoredEngine -> HintedLocator.SearchPlan.unpruned() + ); + + Position2 result = locator.find(engine, origin, 60_000, ignored -> { }).get(); + + assertEquals(activeChunk, result); + } + + @Test + public void exactBlockPositionsDecideStructureCandidateDistance() { + Position2 origin = new Position2(0, 0); + Locator.SearchCandidate fartherRecorded = new Locator.SearchCandidate( + new Position2(1, 0), + 31, + 8 + ); + Locator.SearchCandidate nearerActive = new Locator.SearchCandidate( + new Position2(1, 0), + 16, + 8 + ); + + assertEquals(nearerActive, Locator.nearer(origin, fartherRecorded, nearerActive)); + } + + @Test + public void plainLocatorChecksLaterRingsThatCanContainACloserMatch() throws Exception { + Position2 corner = new Position2(-40, -40); + Position2 nearer = new Position2(50, 0); + Locator locator = (ignoredEngine, chunk) -> chunk.equals(corner) || chunk.equals(nearer); + + assertEquals(nearer, locator.find(engine, new Position2(0, 0), 10_000, ignored -> { }) + .get(15, TimeUnit.SECONDS)); + } + + @Test + public void fineLocatorChoosesTheSameNearestTieRegardlessOfWorkerOrder() throws Exception { + Set matches = Set.of(new Position2(-12, 0), new Position2(0, -12), + new Position2(0, 12), new Position2(12, 0)); + Locator locator = new HintedLocator<>((ignoredEngine, chunk) -> matches.contains(chunk), + ignoredEngine -> HintedLocator.SearchPlan.unpruned()); + + assertEquals(new Position2(-12, 0), locator.find(engine, new Position2(0, 0), 10_000, ignored -> { }) + .get(15, TimeUnit.SECONDS)); + } + + @Test + public void coarseRefinementRanksMatchesFromTheSearchOrigin() throws Exception { + Locator locator = new HintedLocator<>( + (ignoredEngine, chunk) -> chunk.equals(new Position2(8, 0)) || chunk.equals(new Position2(5, 0)), + ignoredEngine -> HintedLocator.SearchPlan.of((x, z) -> x == 136 && z == 8, 4, null)); + + assertEquals(new Position2(5, 0), locator.find(engine, new Position2(0, 0), 10_000, ignored -> { }) + .get(15, TimeUnit.SECONDS)); + } + + @Test + public void ringCoordinatesNeverWrapAcrossTheBlockCoordinateLimit() { + KList cells = new KList<>(); + int maximumChunk = Integer.MAX_VALUE >> 4; + HintedLocator.appendRing(cells, new Position2(maximumChunk, maximumChunk), 1, 1); + + assertEquals(Set.of(new Position2(maximumChunk - 1, maximumChunk - 1), + new Position2(maximumChunk - 1, maximumChunk), + new Position2(maximumChunk, maximumChunk - 1)), new HashSet<>(cells)); + } + + @Test + public void transitionGatePreventsProceduralLocatorEvaluation() { + AtomicInteger evaluations = new AtomicInteger(); + HistoryAwareLocator locator = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:temperate", + (ignoredEngine, ignoredChunk) -> { + evaluations.incrementAndGet(); + return true; + } + ); + when(complex.allowsNewGenerationChunk(3, 4)).thenReturn(false); + + assertFalse(locator.matchesForSearch(engine, new Position2(3, 4))); + assertEquals(0, evaluations.get()); + } + + @Test + public void semanticRecordsAndOwnershipSelectTheOnlyTruthfulLocatorSource() throws Exception { + IrisEngine routedEngine = mock(IrisEngine.class); + IrisComplex routedComplex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + AtomicInteger scopeDepth = new AtomicInteger(); + AtomicInteger evaluations = new AtomicInteger(); + ChunkGenerationSemantics negative = semantics(1, "iris:other"); + ChunkGenerationSemantics positive = semantics(2, "iris:target"); + HistoryAwareLocator locator = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:target", + (ignoredEngine, chunk) -> { + evaluations.incrementAndGet(); + return scopeDepth.get() > 0 && (chunk.getX() == 0 || chunk.getX() == 3); + } + ); + + when(routedEngine.getComplex()).thenReturn(routedComplex); + when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(history.semantics(anyInt(), anyInt())).thenAnswer(invocation -> switch ( + invocation.getArgument(0) + ) { + case 1 -> Optional.of(negative); + case 2 -> Optional.of(positive); + default -> Optional.empty(); + }); + when(history.isHistoricallyOwned(anyInt(), anyInt())).thenAnswer(invocation -> { + int chunkX = invocation.getArgument(0); + return chunkX >= 0 && chunkX <= 2; + }); + when(history.isActiveUnowned(anyInt(), anyInt())).thenAnswer(invocation -> + invocation.getArgument(0) == 3); + when(routedComplex.allowsNewGenerationChunk(anyInt(), anyInt())).thenAnswer(invocation -> + invocation.getArgument(0) == 3); + when(routedEngine.openGenerationHistoryCoordinateScope(anyInt(), anyInt())).thenAnswer(invocation -> { + GenerationHistoryRuntimeRouter.CoordinateScope scope = + mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); + scopeDepth.incrementAndGet(); + doAnswer(close -> { + scopeDepth.decrementAndGet(); + return null; + }).when(scope).close(); + return scope; + }); + + assertTrue(locator.matches(routedEngine, new Position2(0, 0))); + assertTrue(locator.matchesForSearch(routedEngine, new Position2(0, 0))); + assertFalse(locator.matches(routedEngine, new Position2(1, 0))); + assertFalse(locator.matchesForSearch(routedEngine, new Position2(1, 0))); + assertTrue(locator.matches(routedEngine, new Position2(2, 0))); + assertFalse(locator.matchesForSearch(routedEngine, new Position2(2, 0))); + assertTrue(locator.matches(routedEngine, new Position2(3, 0))); + assertTrue(locator.matchesForSearch(routedEngine, new Position2(3, 0))); + assertEquals(4, evaluations.get()); + assertEquals(0, scopeDepth.get()); + } + + @Test + public void historicalFallbackBypassesCurrentPackCoarseHints() throws Exception { + IrisEngine routedEngine = mock(IrisEngine.class); + IrisComplex routedComplex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + AtomicInteger scopeDepth = new AtomicInteger(); + HistoryAwareLocator exact = new HistoryAwareLocator<>( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:old_only", + (ignoredEngine, chunk) -> chunk.equals(new Position2(1, 0)) && scopeDepth.get() > 0 + ); + Locator locator = new HintedLocator<>( + exact, + ignoredEngine -> HintedLocator.SearchPlan.of((blockX, blockZ) -> false, 4, null) + ); + + when(routedEngine.getComplex()).thenReturn(routedComplex); + when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(routedEngine.isClosed()).thenReturn(false); + when(routedEngine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop()); + when(router.history()).thenReturn(history); + when(history.findRecorded(any())).thenReturn(Optional.empty()); + when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty()); + when(history.isHistoricallyOwned(anyInt(), anyInt())).thenAnswer(invocation -> + invocation.getArgument(0) == 1 + && invocation.getArgument(1) == 0); + when(history.hasHistoricalFallbackInSquare(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + Math.abs((long) invocation.getArgument(0) - 1L) <= invocation.getArgument(2) + && Math.abs((long) invocation.getArgument(1)) <= invocation.getArgument(2)); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(false); + when(routedComplex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(false); + when(routedEngine.openGenerationHistoryCoordinateScope(anyInt(), anyInt())).thenAnswer(invocation -> { + GenerationHistoryRuntimeRouter.CoordinateScope scope = + mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); + scopeDepth.incrementAndGet(); + doAnswer(close -> { + scopeDepth.decrementAndGet(); + return null; + }).when(scope).close(); + return scope; + }); + + Position2 result = locator.find( + routedEngine, + new Position2(0, 0), + 60_000, + ignored -> { } + ).get(10, TimeUnit.SECONDS); + + assertEquals(new Position2(1, 0), result); + assertEquals(0, scopeDepth.get()); + } + @Test public void startingAnotherSearchDoesNotCancelTheActiveRequest() throws Exception { CountDownLatch planning = new CountDownLatch(1); @@ -457,6 +726,13 @@ public class HintedLocatorTest { assertEquals(origin, first.get(10, TimeUnit.SECONDS)); } + private static ChunkGenerationSemantics semantics(int chunkX, String regionKey) { + return ChunkGenerationSemantics.builder(chunkX, 0, 1L) + .addRegion(regionKey) + .seal() + .build(); + } + @Test public void findLocatesObjectThroughCoarseCascade() throws Exception { IrisRegion regionA = region("reg_a"); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index 9ca27f964..c68b40654 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; import art.arcane.iris.engine.object.IrisDirection; import art.arcane.iris.engine.object.IrisDimension; @@ -41,6 +42,7 @@ import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.StructureDistribution; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import art.arcane.iris.util.project.stream.ProceduralStream; import org.junit.Test; import java.util.Set; @@ -53,6 +55,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -447,6 +450,14 @@ public class IrisStructureLocatorContractTest { @Test public void locateRanksEditableStartsByResolvedBlockOrigin() { Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(true); + @SuppressWarnings("unchecked") + ProceduralStream riverHeight = mock(ProceduralStream.class); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHeight); + when(riverHeight.get(anyDouble(), anyDouble())).thenReturn(63D); + when(engine.getAllBiomes()).thenReturn(new KList<>()); when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(128); when(engine.getDimension().getFluidHeight()).thenReturn(63); IrisStructurePlacement placement = engine.getDimension().getStructures().get(0); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java index 3388001b1..1b5cbb535 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java @@ -1,11 +1,20 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +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.matter.MatterSlice; import org.junit.Test; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -14,12 +23,19 @@ import java.util.concurrent.Future; 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; import static org.junit.Assert.assertEquals; 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; 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 NativeStructureOwnershipStoreTest { @@ -400,6 +416,74 @@ public class NativeStructureOwnershipStoreTest { assertEquals(2, storage.flushes); } + @Test + public void queuedOwnershipFlushUsesMantleCapturedBeforeRuntimePromotion() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + IrisEngine.GenerationRuntimeBinding bindingA = runtimeBinding(11); + IrisEngine.GenerationRuntimeBinding bindingB = runtimeBinding(12); + MantleFixture mantleA = mantleFixture(); + MantleFixture mantleB = mantleFixture(); + AtomicReference ownership = + new AtomicReference<>(new GenerationHistoryRuntimeRouter.RuntimeOwnership(1L, bindingA)); + AtomicReference currentMantle = new AtomicReference<>(mantleA.engineMantle()); + AtomicInteger currentRuntime = new AtomicInteger(11); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.currentRuntimeOwnership()).thenAnswer(ignored -> Optional.of(ownership.get())); + when(engine.getMantle()).thenAnswer(ignored -> currentMantle.get()); + when(engine.getCacheID()).thenAnswer(ignored -> currentRuntime.get()); + NativeStructureOwnershipStore.MantleStorage storage = + new NativeStructureOwnershipStore.MantleStorage(engine); + NativeStructureOwnershipRecord record = record("test:captured_mantle", 4, -5, 109L); + + storage.write(NativeStructureOwnershipStore.pack(4, -5), record); + ownership.set(new GenerationHistoryRuntimeRouter.RuntimeOwnership(2L, bindingB)); + currentMantle.set(mantleB.engineMantle()); + currentRuntime.set(12); + + assertTrue(storage.flush()); + verify(mantleA.mantle(), times(2)).getChunk(4, -5); + verify(mantleA.mantle()).saveIdleTectonicPlates(any()); + verify(mantleB.mantle(), never()).getChunk(anyInt(), anyInt()); + verify(mantleB.mantle(), never()).saveIdleTectonicPlates(any()); + storage.close(); + } + + @Test + public void flushPartitionsPendingOwnershipByCapturedRuntime() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + IrisEngine.GenerationRuntimeBinding bindingA = runtimeBinding(21); + IrisEngine.GenerationRuntimeBinding bindingB = runtimeBinding(22); + MantleFixture mantleA = mantleFixture(); + MantleFixture mantleB = mantleFixture(); + AtomicReference ownership = + new AtomicReference<>(new GenerationHistoryRuntimeRouter.RuntimeOwnership(3L, bindingA)); + AtomicReference currentMantle = new AtomicReference<>(mantleA.engineMantle()); + AtomicInteger currentRuntime = new AtomicInteger(21); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.currentRuntimeOwnership()).thenAnswer(ignored -> Optional.of(ownership.get())); + when(engine.getMantle()).thenAnswer(ignored -> currentMantle.get()); + when(engine.getCacheID()).thenAnswer(ignored -> currentRuntime.get()); + NativeStructureOwnershipStore.MantleStorage storage = + new NativeStructureOwnershipStore.MantleStorage(engine); + NativeStructureOwnershipRecord recordA = record("test:partition_a", -7, 8, 110L); + NativeStructureOwnershipRecord recordB = record("test:partition_b", 9, -10, 111L); + + storage.write(NativeStructureOwnershipStore.pack(-7, 8), recordA); + ownership.set(new GenerationHistoryRuntimeRouter.RuntimeOwnership(4L, bindingB)); + currentMantle.set(mantleB.engineMantle()); + currentRuntime.set(22); + storage.write(NativeStructureOwnershipStore.pack(9, -10), recordB); + + assertTrue(storage.flush()); + verify(mantleA.mantle(), times(2)).getChunk(-7, 8); + verify(mantleA.mantle()).saveIdleTectonicPlates(any()); + verify(mantleB.mantle(), times(2)).getChunk(9, -10); + verify(mantleB.mantle()).saveIdleTectonicPlates(any()); + storage.close(); + } + private static Engine engine() { Engine engine = mock(Engine.class); when(engine.isClosing()).thenReturn(false); @@ -407,6 +491,30 @@ public class NativeStructureOwnershipStoreTest { return engine; } + private static IrisEngine.GenerationRuntimeBinding runtimeBinding(int runtimeId) { + IrisEngine.GenerationRuntimeBinding binding = mock(IrisEngine.GenerationRuntimeBinding.class); + when(binding.runtimeId()).thenReturn(runtimeId); + return binding; + } + + @SuppressWarnings("unchecked") + private static MantleFixture mantleFixture() { + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + Matter section = mock(Matter.class); + MatterSlice slice = mock(MatterSlice.class); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + when(chunk.getOrCreate(0)).thenReturn(section); + when(section.slice( + NativeStructureOwnershipBundle.class)).thenReturn(slice); + when(slice.get(0, 0, 0)).thenReturn(null); + when(mantle.saveIdleTectonicPlates(any())).thenReturn(Set.of()); + return new MantleFixture(engineMantle, mantle); + } + private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, long placementIdentity) { return record(key, originX, originZ, placementIdentity, new IrisStructureTerrain(), 8); @@ -583,4 +691,7 @@ public class NativeStructureOwnershipStoreTest { } } } + + private record MantleFixture(EngineMantle engineMantle, Mantle mantle) { + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java index 0b0c617f7..7b9475d20 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java @@ -145,6 +145,18 @@ public class NativeStructurePlacementPlannerTest { assertSame(expected, plans.getFirst().placement()); } + @Test + public void transitionBandRejectsNativeStructureStarts() { + Engine engine = engine(77L, -64, 384, 150); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.allowsNewGenerationChunk(0, 0)).thenReturn(false); + IrisStructurePlacement placement = nativePlacement(); + + assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + assertTrue(NativeStructurePlacementPlanner.plansAt(engine, 0, 0).isEmpty()); + } + @Test public void weightedNativeSelectionPreservesAuthoredSourceOrder() { KList first = new KList<>(); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java index bddfbdeea..44dfb243f 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureVolumeMemoTest.java @@ -8,15 +8,18 @@ import java.util.List; 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 NativeStructureVolumeMemoTest { @Test public void repeatedQueriesInsideOneChunkAskThePlatformOnce() { RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15)); NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + Engine engine = engine(1); - KList first = memo.volumes(null, hooks, 2, 2, 6, 6); - KList second = memo.volumes(null, hooks, 8, 8, 12, 12); + KList first = memo.volumes(engine, hooks, 2, 2, 6, 6); + KList second = memo.volumes(engine, hooks, 8, 8, 12, 12); assertEquals(1, hooks.queries().size()); assertEquals(first, second); @@ -27,7 +30,7 @@ public class NativeStructureVolumeMemoTest { RecordingHooks hooks = new RecordingHooks(); NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); - memo.volumes(null, hooks, 20, 36, 21, 37); + memo.volumes(engine(1), hooks, 20, 36, 21, 37); assertEquals(1, hooks.queries().size()); assertEquals(List.of(16, 32, 31, 47), hooks.queries().getFirst()); @@ -39,7 +42,7 @@ public class NativeStructureVolumeMemoTest { RecordingHooks hooks = new RecordingHooks(shared); NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); - KList volumes = memo.volumes(null, hooks, 12, 12, 20, 20); + KList volumes = memo.volumes(engine(1), hooks, 12, 12, 20, 20); assertEquals(4, hooks.queries().size()); assertEquals(1, volumes.size()); @@ -51,7 +54,7 @@ public class NativeStructureVolumeMemoTest { RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 3, 78, 3)); NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); - assertTrue(memo.volumes(null, hooks, 5, 5, 9, 9).isEmpty()); + assertTrue(memo.volumes(engine(1), hooks, 5, 5, 9, 9).isEmpty()); } @Test @@ -59,9 +62,10 @@ public class NativeStructureVolumeMemoTest { RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15)); NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); - memo.volumes(null, hooks, 2, 2, 6, 6); + Engine engine = engine(1); + memo.volumes(engine, hooks, 2, 2, 6, 6); memo.clear(); - memo.volumes(null, hooks, 2, 2, 6, 6); + memo.volumes(engine, hooks, 2, 2, 6, 6); assertEquals(2, hooks.queries().size()); } @@ -70,7 +74,29 @@ public class NativeStructureVolumeMemoTest { public void missingHooksResolveNoVolumes() { NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); - assertTrue(memo.volumes(null, null, 0, 0, 15, 15).isEmpty()); + assertTrue(memo.volumes(engine(1), null, 0, 0, 15, 15).isEmpty()); + } + + @Test + public void evictingOneRuntimePreservesOtherRuntimeBuckets() { + RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15)); + NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo(); + Engine firstRuntime = engine(1); + Engine secondRuntime = engine(2); + + memo.volumes(firstRuntime, hooks, 2, 2, 6, 6); + memo.volumes(secondRuntime, hooks, 2, 2, 6, 6); + memo.evictRuntime(1); + memo.volumes(secondRuntime, hooks, 2, 2, 6, 6); + memo.volumes(firstRuntime, hooks, 2, 2, 6, 6); + + assertEquals(3, hooks.queries().size()); + } + + private static Engine engine(int runtimeId) { + Engine engine = mock(Engine.class); + when(engine.getCacheID()).thenReturn(runtimeId); + return engine; } private static NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { diff --git a/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java new file mode 100644 index 000000000..c9df042df --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationOwnershipTest.java @@ -0,0 +1,260 @@ +package art.arcane.iris.engine.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class ChunkGenerationOwnershipTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void unassignedChunksUseTheSuppliedCurrentActivation() throws Exception { + Path directory = temporaryFolder.newFolder("fallback").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + + assertEquals(4L, ownership.resolve(0, 0, 4L)); + assertEquals(9L, ownership.resolve(0, 0, 9L)); + assertFalse(ownership.isExplicitlyAssigned(0, 0)); + assertThrows(IllegalStateException.class, () -> ownership.explicitActivation(0, 0)); + } + + @Test + public void boundedQueryOnlyVisitsAssignedChunksInsideTheSquare() throws Exception { + Path directory = temporaryFolder.newFolder("bounded-query").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(-33, 0, 1L); + ownership.assign(-32, 1, 2L); + ownership.assign(-31, 2, 3L); + ownership.assign(8192, 8192, 4L); + ownership.persist(); + ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory); + Set visited = new HashSet<>(); + + assertFalse(loaded.anyMatchingInSquare(-33, 0, 1, (x, z, activation) -> { + visited.add(ChunkGenerationOwnership.packChunk(x, z)); + return false; + })); + assertEquals(Set.of(ChunkGenerationOwnership.packChunk(-33, 0), + ChunkGenerationOwnership.packChunk(-32, 1)), visited); + assertTrue(loaded.anyMatchingInSquare(-32, 1, 0, (x, z, activation) -> activation == 2L)); + assertFalse(loaded.anyMatchingInSquare(0, 0, 16, (x, z, activation) -> { + throw new AssertionError("Unassigned chunks must not be evaluated."); + })); + assertThrows(IllegalArgumentException.class, + () -> loaded.anyMatchingInSquare(0, 0, -1, (x, z, activation) -> true)); + } + + @Test + public void sparseWholeCoordinateQueryUsesTheCatalogWithoutOverflow() throws Exception { + Path directory = temporaryFolder.newFolder("whole-coordinate-query").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(Integer.MAX_VALUE, Integer.MAX_VALUE, 1L); + ownership.assign(Integer.MIN_VALUE, Integer.MIN_VALUE, 2L); + + assertTrue(ownership.anyMatchingInSquare(0, 0, Integer.MAX_VALUE, + (x, z, activation) -> activation == 1L)); + assertFalse(ownership.anyMatchingInSquare(0, 0, Integer.MAX_VALUE, + (x, z, activation) -> activation == 2L)); + assertTrue(ownership.anyMatchingInSquare(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, + (x, z, activation) -> activation == 2L)); + } + + @Test + public void assignmentsPersistAcrossEverySignedRegionBoundary() throws Exception { + Path directory = temporaryFolder.newFolder("boundaries").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + int[][] coordinates = { + {-33, -33}, + {-32, -32}, + {-1, -1}, + {0, 0}, + {31, 31}, + {32, 32} + }; + for (int index = 0; index < coordinates.length; index++) { + ownership.assign(coordinates[index][0], coordinates[index][1], index + 1L); + } + + assertEquals(4, ownership.persist()); + assertEquals(0, ownership.persist()); + ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory); + + assertEquals(coordinates.length, loaded.explicitChunkCount()); + for (int index = 0; index < coordinates.length; index++) { + int chunkX = coordinates[index][0]; + int chunkZ = coordinates[index][1]; + assertEquals(index + 1L, loaded.resolve(chunkX, chunkZ, 99L)); + assertTrue(loaded.isExplicitlyAssigned(chunkX, chunkZ)); + } + } + + @Test + public void assignmentCannotReplaceHistoricalOwnership() throws Exception { + Path directory = temporaryFolder.newFolder("immutable").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + + assertTrue(ownership.assign(8, -2, 41L)); + assertFalse(ownership.assign(8, -2, 41L)); + assertThrows(IllegalStateException.class, () -> ownership.assign(8, -2, 42L)); + assertEquals(41L, ownership.explicitActivation(8, -2)); + } + + @Test + public void bulkConflictDoesNotPartiallyAssignInventory() throws Exception { + Path directory = temporaryFolder.newFolder("bulk").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(0, 0, 3L); + WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks( + ChunkGenerationOwnership.packChunk(0, 0), + ChunkGenerationOwnership.packChunk(1, 0) + ); + + assertThrows(IllegalStateException.class, () -> ownership.assignAll(inventory, 4L)); + + assertFalse(ownership.isExplicitlyAssigned(1, 0)); + assertEquals(1, ownership.explicitChunkCount()); + assertEquals(1, ownership.assignAll(inventory, 3L)); + assertEquals(3L, ownership.explicitActivation(1, 0)); + } + + @Test + public void cutoverAssignmentOnlyMaterializesPreviouslyUnassignedChunks() throws Exception { + Path directory = temporaryFolder.newFolder("cutover").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(-1, 0, 2L); + WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks( + ChunkGenerationOwnership.packChunk(-1, 0), + ChunkGenerationOwnership.packChunk(0, 0), + ChunkGenerationOwnership.packChunk(1, 0) + ); + + assertEquals(2, ownership.assignUnassigned(inventory, 3L)); + + assertEquals(2L, ownership.explicitActivation(-1, 0)); + assertEquals(3L, ownership.explicitActivation(0, 0)); + assertEquals(3L, ownership.explicitActivation(1, 0)); + } + + @Test + public void snapshotUsesTheStablePackedCoordinateContract() throws Exception { + Path directory = temporaryFolder.newFolder("snapshot").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(Integer.MIN_VALUE, Integer.MAX_VALUE, 1L); + ownership.assign(-1, 0, 2L); + ownership.assign(32, -33, 3L); + + long[] expected = { + ChunkGenerationOwnership.packChunk(Integer.MIN_VALUE, Integer.MAX_VALUE), + ChunkGenerationOwnership.packChunk(-1, 0), + ChunkGenerationOwnership.packChunk(32, -33) + }; + Arrays.sort(expected); + long[] snapshot = ownership.snapshotExplicitChunkKeys(); + + assertArrayEquals(expected, snapshot); + for (long packed : snapshot) { + assertEquals(packed, ChunkGenerationOwnership.packChunk( + ChunkGenerationOwnership.chunkX(packed), + ChunkGenerationOwnership.chunkZ(packed) + )); + } + } + + @Test + public void loadedLookupsDecodeOnlyTheRequestedRegion() throws Exception { + Path directory = temporaryFolder.newFolder("lazy-load").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + ownership.assign(-65, 63, 17L); + ownership.persist(); + ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory); + + assertEquals(0, loaded.cachedRegionCount()); + assertEquals(17L, loaded.resolve(-65, 63, 30L)); + assertEquals(1, loaded.cachedRegionCount()); + assertEquals(30L, loaded.resolve(-64, 63, 30L)); + } + + @Test + public void regionCacheRemainsBoundedAndReloadsEvictedOwnership() throws Exception { + Path directory = temporaryFolder.newFolder("bounded-cache").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + for (int regionX = 0; regionX < 80; regionX++) { + ownership.assign(regionX << 5, 0, regionX + 1L); + assertTrue(ownership.cachedRegionCount() <= 64); + } + ownership.persist(); + + ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory); + assertEquals(0, loaded.cachedRegionCount()); + assertEquals(80, loaded.regionCount()); + for (int regionX = 79; regionX >= 0; regionX--) { + assertEquals(regionX + 1L, loaded.explicitActivation(regionX << 5, 0)); + assertTrue(loaded.cachedRegionCount() <= 64); + } + assertEquals(1L, loaded.explicitActivation(0, 0)); + } + + @Test + public void activationIdsMustBePositive() throws Exception { + Path directory = temporaryFolder.newFolder("activation-range").toPath(); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory); + + assertThrows(IllegalArgumentException.class, () -> ownership.resolve(0, 0, 0L)); + assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, -2L)); + assertThrows( + IllegalArgumentException.class, + () -> ownership.assignAll(WorldChunkInventory.empty(), 0L) + ); + } + + @Test + public void noncanonicalShardNameFailsClosed() throws Exception { + Path sourceDirectory = temporaryFolder.newFolder("canonical-source").toPath(); + ChunkGenerationOwnership source = ChunkGenerationOwnership.load(sourceDirectory); + source.assign(0, 0, 1L); + source.persist(); + Path invalidDirectory = temporaryFolder.newFolder("canonical-invalid").toPath(); + Files.copy( + sourceDirectory.resolve(RegionGenerationOwnership.fileName(0, 0)), + invalidDirectory.resolve("unexpected.irow") + ); + + assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(invalidDirectory)); + } + + @Test + public void symbolicLinkStorageRootAndParentFailClosed() throws Exception { + Path probe = temporaryFolder.newFolder("ownership-symlink-probe").toPath(); + Path probeTarget = temporaryFolder.newFolder("ownership-symlink-probe-target").toPath(); + try { + Files.createSymbolicLink(probe.resolve("link"), probeTarget); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + Path rootContainer = temporaryFolder.newFolder("ownership-linked-root-container").toPath(); + Path linkedRoot = rootContainer.resolve("ownership"); + Files.createSymbolicLink(linkedRoot, temporaryFolder.newFolder("ownership-linked-root-target").toPath()); + assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(linkedRoot)); + + Path parentContainer = temporaryFolder.newFolder("ownership-linked-parent-container").toPath(); + Path linkedParent = parentContainer.resolve("generation"); + Files.createSymbolicLink(linkedParent, temporaryFolder.newFolder("ownership-linked-parent-target").toPath()); + assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(linkedParent.resolve("ownership"))); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationSemanticsTest.java b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationSemanticsTest.java new file mode 100644 index 000000000..86a4c1dfe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/ChunkGenerationSemanticsTest.java @@ -0,0 +1,131 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class ChunkGenerationSemanticsTest { + @Test + public void builderCreatesADeeplyImmutableCanonicalRecord() { + ChunkGenerationSemantics semantics = ChunkGenerationSemantics.builder(-4, 9, 12L) + .addSurfaceBiomes(List.of("iris:wetlands", "iris:forest")) + .addCaveBiome("iris:limestone_caves") + .addRegion("temperate") + .addRiverProfile("rivers/default") + .addObject("procedural/tree/towering-oak#3@2") + .addStructure("minecraft:village_plains", -55, 71, 151) + .build(); + + assertEquals(List.of("iris:forest", "iris:wetlands"), List.copyOf(semantics.surfaceBiomeKeys())); + assertEquals(Set.of("iris:limestone_caves"), semantics.caveBiomeKeys()); + assertEquals(Set.of("temperate"), semantics.regionKeys()); + assertEquals(Set.of("rivers/default"), semantics.riverProfileKeys()); + assertEquals(Set.of("procedural/tree/towering-oak#3@2"), semantics.objectKeys()); + assertEquals( + Set.of(new ChunkGenerationSemantics.StructureOccurrence( + "minecraft:village_plains", + new ChunkGenerationSemantics.BlockPosition(-55, 71, 151) + )), + semantics.structures() + ); + assertThrows(UnsupportedOperationException.class, () -> semantics.surfaceBiomeKeys().add("iris:desert")); + assertThrows(UnsupportedOperationException.class, () -> semantics.structures().clear()); + } + + @Test + public void activationAndResourceKeysAreStrict() { + assertThrows(IllegalArgumentException.class, () -> ChunkGenerationSemantics.builder(0, 0, 0L)); + assertThrows(IllegalArgumentException.class, () -> ChunkGenerationSemantics.builder(0, 0, -1L)); + assertThrows( + IllegalArgumentException.class, + () -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("") + ); + assertThrows( + IllegalArgumentException.class, + () -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome(" Iris:Forest") + ); + assertThrows( + IllegalArgumentException.class, + () -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("iris:Forest") + ); + assertThrows( + IllegalArgumentException.class, + () -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("iris\\forest") + ); + } + + @Test + public void equivalentBuilderOrderProducesEqualRecords() { + ChunkGenerationSemantics first = ChunkGenerationSemantics.builder(2, 3, 4L) + .addSurfaceBiome("iris:b") + .addSurfaceBiome("iris:a") + .addStructure("iris:tower", 34, 90, 50) + .addStructure("iris:ruin", 35, 64, 51) + .build(); + ChunkGenerationSemantics second = ChunkGenerationSemantics.builder(2, 3, 4L) + .addStructure("iris:ruin", 35, 64, 51) + .addSurfaceBiome("iris:a") + .addStructure("iris:tower", 34, 90, 50) + .addSurfaceBiome("iris:b") + .build(); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + @Test + public void mergeUnionsStageFactsAndPropagatesSeal() { + ChunkGenerationSemantics first = ChunkGenerationSemantics.builder(2, 3, 4L) + .addSurfaceBiome("iris:forest") + .addRegion("temperate") + .build(); + ChunkGenerationSemantics second = ChunkGenerationSemantics.builder(2, 3, 4L) + .addCaveBiome("iris:limestone_caves") + .addRegion("temperate") + .addStructure("iris:tower", 40, 90, 55) + .seal() + .build(); + + ChunkGenerationSemantics merged = first.merge(second); + + assertEquals(Set.of("iris:forest"), merged.surfaceBiomeKeys()); + assertEquals(Set.of("iris:limestone_caves"), merged.caveBiomeKeys()); + assertEquals(Set.of("temperate"), merged.regionKeys()); + assertEquals(second.structures(), merged.structures()); + assertTrue(merged.sealed()); + assertFalse(first.sealed()); + } + + @Test + public void sealedRecordAcceptsRetriesButRejectsNewFacts() { + ChunkGenerationSemantics sealed = ChunkGenerationSemantics.builder(2, 3, 4L) + .addSurfaceBiome("iris:forest") + .addRegion("temperate") + .seal() + .build(); + ChunkGenerationSemantics retry = ChunkGenerationSemantics.builder(2, 3, 4L) + .addSurfaceBiome("iris:forest") + .build(); + ChunkGenerationSemantics newFact = ChunkGenerationSemantics.builder(2, 3, 4L) + .addSurfaceBiome("iris:desert") + .build(); + + assertSame(sealed, sealed.merge(retry)); + assertThrows(IllegalStateException.class, () -> sealed.merge(newFact)); + assertThrows( + IllegalStateException.class, + () -> sealed.merge(ChunkGenerationSemantics.builder(2, 3, 5L).build()) + ); + assertThrows( + IllegalArgumentException.class, + () -> sealed.merge(ChunkGenerationSemantics.builder(3, 3, 4L).build()) + ); + } +} 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 new file mode 100644 index 000000000..bacd1c467 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java @@ -0,0 +1,100 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationAdmissionTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void cutoverWaitsForEveryOpenGenerationStageAcrossInstances() throws Exception { + GenerationAdmission first = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission second = new GenerationAdmission(temporaryFolder.getRoot().toPath().resolve(".").normalize()); + GenerationAdmission.StageLease stage = first.enterStage(); + CountDownLatch requested = new CountDownLatch(1); + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + requested.countDown(); + try (GenerationAdmission.CutoverLease ignored = second.beginCutover()) { + } + }); + + assertTrue(requested.await(5L, TimeUnit.SECONDS)); + Thread.sleep(100L); + assertFalse(cutover.isDone()); + stage.close(); + cutover.get(5L, TimeUnit.SECONDS); + } + + @Test + public void aWaitingCutoverPreventsLaterStagesFromOvertakingIt() throws Exception { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.StageLease firstStage = admission.enterStage(); + CountDownLatch cutoverEntered = new CountDownLatch(1); + CountDownLatch releaseCutover = new CountDownLatch(1); + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) { + cutoverEntered.countDown(); + await(releaseCutover); + } + }); + CompletableFuture laterStage = CompletableFuture.runAsync(() -> { + try (GenerationAdmission.StageLease ignored = admission.enterStage()) { + } + }); + + firstStage.close(); + assertTrue(cutoverEntered.await(5L, TimeUnit.SECONDS)); + assertFalse(laterStage.isDone()); + releaseCutover.countDown(); + cutover.get(5L, TimeUnit.SECONDS); + laterStage.get(5L, TimeUnit.SECONDS); + } + + @Test + public void aStageMayCompleteOnAThreadOtherThanItsCaller() throws Exception { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.StageLease stage = admission.enterStage(); + CompletableFuture close = CompletableFuture.runAsync(stage::close); + close.get(5L, TimeUnit.SECONDS); + + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) { + } + }); + cutover.get(5L, TimeUnit.SECONDS); + } + + @Test + public void startupCutoverIsPermanentlyClosedByFirstStageAdmission() { + GenerationAdmission admission = new GenerationAdmission( + temporaryFolder.getRoot().toPath().resolve("startup-only") + ); + try (GenerationAdmission.CutoverLease ignored = admission.beginStartupCutover()) { + } + try (GenerationAdmission.StageLease ignored = admission.enterStage()) { + } + + assertThrows(IllegalStateException.class, admission::beginStartupCutover); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5L, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for test latch."); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for test latch.", exception); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationBlendTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationBlendTest.java new file mode 100644 index 000000000..a69135466 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationBlendTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationBlendTest { + @Test + public void smootherstepUsesExactEndpointsAndMidpoint() { + assertEquals(0D, GenerationBlend.newEpochWeight(0D, 128), 0D); + assertEquals(0.5D, GenerationBlend.newEpochWeight(64D, 128), 0D); + assertEquals(1D, GenerationBlend.newEpochWeight(128D, 128), 0D); + assertEquals(1D, GenerationBlend.newEpochWeight(256D, 128), 0D); + } + + @Test + public void smootherstepIsMonotonicAcrossTransition() { + double previous = 0D; + for (int distance = 1; distance <= 128; distance++) { + double weight = GenerationBlend.newEpochWeight(distance, 128); + assertTrue(weight > previous); + previous = weight; + } + } + + @Test + public void interpolationPreservesExactBoundaryValues() { + assertEquals(72D, GenerationBlend.interpolate(72D, 160D, 0D), 0D); + assertEquals(160D, GenerationBlend.interpolate(72D, 160D, 1D), 0D); + assertEquals(116D, GenerationBlend.interpolate(72D, 160D, 0.5D), 0D); + assertEquals(72, GenerationBlend.interpolateHeight(72, 160, 0D)); + assertEquals(160, GenerationBlend.interpolateHeight(72, 160, 1D)); + assertEquals(116, GenerationBlend.interpolateHeight(72, 160, 0.5D)); + } + + @Test + public void rejectsInvalidWeightsDistancesAndWidths() { + assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(-1D, 32)); + assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(Double.NaN, 32)); + assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(1D, 0)); + assertThrows(IllegalArgumentException.class, () -> GenerationBlend.interpolate(0D, 1D, -0.1D)); + assertThrows(IllegalArgumentException.class, () -> GenerationBlend.interpolateHeight(0, 1, 1.1D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java new file mode 100644 index 000000000..b2b24c783 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java @@ -0,0 +1,277 @@ +package art.arcane.iris.engine.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.CRC32; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationBoundaryStoreTest { + private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesDeterministicNegativeCoordinateSnapshot() throws Exception { + Path firstRoot = temporaryFolder.newFolder("first-dimension").toPath(); + Path secondRoot = temporaryFolder.newFolder("second-dimension").toPath(); + GenerationBoundaryStore firstStore = new GenerationBoundaryStore(firstRoot); + GenerationBoundaryStore secondStore = new GenerationBoundaryStore(secondRoot); + List chunks = List.of( + new GenerationBoundary.ChunkCoordinate(2, -3), + new GenerationBoundary.ChunkCoordinate(Integer.MIN_VALUE, Integer.MAX_VALUE), + new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(2, -3) + ); + + GenerationBoundary published = firstStore.publish(42L, chunks); + GenerationBoundary reordered = secondStore.publish(42L, List.of( + chunks.get(3), + chunks.get(2), + chunks.get(1), + chunks.get(0) + )); + GenerationBoundary loaded = firstStore.load(42L); + + assertEquals(firstRoot.toAbsolutePath().resolve("iris/generation/boundaries"), firstStore.directory()); + assertTrue(SHA_256.matcher(published.identity()).matches()); + assertEquals(published.identity(), reordered.identity()); + assertEquals(published.identity(), loaded.identity()); + assertEquals(3, loaded.historicalChunkCount()); + assertTrue(loaded.isHistoricalChunk(2, -3)); + assertTrue(loaded.isHistoricalChunk(-1, -1)); + assertTrue(loaded.isHistoricalChunk(Integer.MIN_VALUE, Integer.MAX_VALUE)); + assertArrayEquals( + Files.readAllBytes(firstStore.snapshotPath(42L)), + Files.readAllBytes(secondStore.snapshotPath(42L)) + ); + } + + @Test + public void keepsPublishedActivationImmutableAndAllowsIdempotentPublication() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("immutable-dimension").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + long[] original = { + GenerationBoundary.packChunk(-2, 7), + GenerationBoundary.packChunk(0, 0) + }; + + GenerationBoundary first = store.publishPacked(7L, original); + GenerationBoundary repeated = store.publishPacked(7L, original); + + assertEquals(first.identity(), repeated.identity()); + assertThrows( + FileAlreadyExistsException.class, + () -> store.publishPacked(7L, new long[]{GenerationBoundary.packChunk(9, 9)}) + ); + assertArrayEquals(first.packedHistoricalChunks(), store.load(7L).packedHistoricalChunks()); + } + + @Test + public void failsClosedForCorruptionAndTruncation() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("corrupt-dimension").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + store.publishPacked(3L, new long[]{GenerationBoundary.packChunk(-4, 8)}); + Path corrupted = store.snapshotPath(3L); + byte[] corruptedBytes = Files.readAllBytes(corrupted); + corruptedBytes[8] ^= 0x01; + Files.write(corrupted, corruptedBytes); + + IOException checksumFailure = assertThrows(IOException.class, () -> store.load(3L)); + assertTrue(checksumFailure.getMessage().contains("checksum")); + + store.publishPacked(4L, new long[]{GenerationBoundary.packChunk(5, -6)}); + Path truncated = store.snapshotPath(4L); + byte[] complete = Files.readAllBytes(truncated); + Files.write(truncated, Arrays.copyOf(complete, complete.length - 3)); + assertThrows(IOException.class, () -> store.load(4L)); + } + + @Test + public void verifiesContentIdentityAfterChecksumValidation() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("identity-dimension").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + store.publishPacked(11L, new long[]{GenerationBoundary.packChunk(-8, -13)}); + Path snapshot = store.snapshotPath(11L); + byte[] encoded = Files.readAllBytes(snapshot); + encoded[20] ^= 0x01; + updateChecksum(encoded); + Files.write(snapshot, encoded); + + IOException failure = assertThrows(IOException.class, () -> store.load(11L)); + assertTrue(failure.getMessage().contains("identity mismatch")); + } + + @Test + public void rejectsInvalidActivationAndDeclaredBounds() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("bounds-dimension").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + assertThrows(IllegalArgumentException.class, () -> store.publishPacked(0L, new long[0])); + store.publishPacked(5L, new long[0]); + Path snapshot = store.snapshotPath(5L); + byte[] encoded = Files.readAllBytes(snapshot); + ByteBuffer.wrap(encoded).putInt(16, Integer.MAX_VALUE); + updateChecksum(encoded); + Files.write(snapshot, encoded); + + assertThrows(IOException.class, () -> store.load(5L)); + } + + @Test + public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception { + Path probe = temporaryFolder.newFolder("boundary-symlink-probe").toPath(); + Path probeTarget = temporaryFolder.newFolder("boundary-symlink-probe-target").toPath(); + try { + Files.createSymbolicLink(probe.resolve("link"), probeTarget); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + for (String component : new String[]{"dimension", "iris", "generation", "boundaries"}) { + assertStorageLinkRejected(component); + } + } + + @Test + public void concurrentStoreInstancesCannotClobberABoundary() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("boundary-race").toPath(); + GenerationBoundaryStore firstStore = new GenerationBoundaryStore(dimensionRoot); + GenerationBoundaryStore secondStore = new GenerationBoundaryStore(dimensionRoot); + long firstChunk = GenerationBoundary.packChunk(1, 2); + long secondChunk = GenerationBoundary.packChunk(3, 4); + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit( + () -> publishAfterBarrier(firstStore, 21L, firstChunk, barrier) + ); + Future second = executor.submit( + () -> publishAfterBarrier(secondStore, 21L, secondChunk, barrier) + ); + + assertTrue(first.get() ^ second.get()); + GenerationBoundary loaded = firstStore.load(21L); + assertTrue(loaded.isHistoricalChunk(1, 2) ^ loaded.isHistoricalChunk(3, 4)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void contentAddressedRegionMasksAreReusedAcrossActivationHistory() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("boundary-history-reuse").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + store.publishPacked(1L, new long[]{ + GenerationBoundary.packChunk(0, 0), + GenerationBoundary.packChunk(32, 0) + }); + store.publishPacked(2L, new long[]{ + GenerationBoundary.packChunk(0, 0), + GenerationBoundary.packChunk(1, 0), + GenerationBoundary.packChunk(32, 0) + }); + store.publishPacked(3L, new long[]{ + GenerationBoundary.packChunk(0, 0), + GenerationBoundary.packChunk(1, 0), + GenerationBoundary.packChunk(32, 0), + GenerationBoundary.packChunk(33, 0) + }); + + long maskCount; + try (Stream files = Files.list(store.directory())) { + maskCount = files.filter(path -> path.getFileName().toString().endsWith(".irbm")).count(); + } + assertEquals(2L, maskCount); + GenerationBoundary reopened = new GenerationBoundaryStore(dimensionRoot).load(3L); + assertEquals(4, reopened.historicalChunkCount()); + assertTrue(reopened.isHistoricalChunk(33, 0)); + } + + @Test + public void loadedBoundaryKeepsOnlyAFixedNumberOfRegionMasksResident() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("boundary-cache").toPath(); + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + long[] chunks = new long[80]; + for (int regionX = 0; regionX < chunks.length; regionX++) { + chunks[regionX] = GenerationBoundary.packChunk(regionX << 5, 0); + } + store.publishPacked(1L, chunks); + GenerationBoundary boundary = new GenerationBoundaryStore(dimensionRoot).load(1L); + + for (int regionX = 0; regionX < chunks.length; regionX++) { + assertTrue(boundary.isHistoricalChunk(regionX << 5, 0)); + assertTrue(boundary.cachedRegionCount() <= 64); + } + assertTrue(boundary.isHistoricalChunk(0, 0)); + assertTrue(boundary.cachedRegionCount() <= 64); + } + + private void assertStorageLinkRejected(String component) throws Exception { + Path container = temporaryFolder.newFolder("boundary-linked-" + component).toPath(); + Path dimensionRoot = container.resolve("dimension"); + Path external = temporaryFolder.newFolder("boundary-linked-target-" + component).toPath(); + if (component.equals("dimension")) { + Files.createSymbolicLink(dimensionRoot, external); + } else { + Files.createDirectory(dimensionRoot); + Path iris = dimensionRoot.resolve("iris"); + if (component.equals("iris")) { + Files.createSymbolicLink(iris, external); + } else { + Files.createDirectory(iris); + Path generation = iris.resolve("generation"); + if (component.equals("generation")) { + Files.createSymbolicLink(generation, external); + } else { + Files.createDirectory(generation); + Files.createSymbolicLink(generation.resolve("boundaries"), external); + } + } + } + GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot); + + assertThrows(IOException.class, () -> store.publishPacked(1L, new long[0])); + } + + private static boolean publishAfterBarrier( + GenerationBoundaryStore store, + long activationId, + long chunk, + CyclicBarrier barrier + ) throws Exception { + barrier.await(); + try { + store.publishPacked(activationId, new long[]{chunk}); + return true; + } catch (IOException expected) { + return false; + } + } + + private static void updateChecksum(byte[] encoded) { + int bodyLength = encoded.length - Integer.BYTES; + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryTest.java new file mode 100644 index 000000000..81dc412ee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryTest.java @@ -0,0 +1,140 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationBoundaryTest { + @Test + public void freezesHistoricalOwnershipAndDeduplicatesCoordinates() { + ArrayList source = new ArrayList<>(List.of( + new GenerationBoundary.ChunkCoordinate(2, -3), + new GenerationBoundary.ChunkCoordinate(2, -3) + )); + + GenerationBoundary boundary = GenerationBoundary.freeze("activation-4-frontier", source); + source.clear(); + source.add(new GenerationBoundary.ChunkCoordinate(9, 9)); + + assertEquals(1, boundary.historicalChunkCount()); + assertTrue(boundary.isHistoricalChunk(2, -3)); + assertFalse(boundary.isHistoricalChunk(9, 9)); + + long[] exported = boundary.packedHistoricalChunks(); + exported[0] = GenerationBoundary.packChunk(9, 9); + assertTrue(boundary.isHistoricalChunk(2, -3)); + } + + @Test + public void mapsNegativeBlocksWithFloorDivision() { + GenerationBoundary boundary = GenerationBoundary.freeze("negative-frontier", List.of( + new GenerationBoundary.ChunkCoordinate(-1, -1) + )); + + assertTrue(boundary.isHistoricalBlock(-1, -1)); + assertTrue(boundary.isHistoricalBlock(-16, -16)); + assertFalse(boundary.isHistoricalBlock(-17, -16)); + assertEquals(1D, boundary.distanceToHistoricalChunks(-17, -16, 64), 0D); + assertEquals(1D, boundary.distanceToHistoricalChunks(0, -1, 64), 0D); + } + + @Test + public void measuresIrregularAndEnclosedFrontiersInBlockSpace() { + GenerationBoundary boundary = GenerationBoundary.freeze("enclosed-frontier", List.of( + new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(0, -1), + new GenerationBoundary.ChunkCoordinate(1, -1), + new GenerationBoundary.ChunkCoordinate(-1, 0), + new GenerationBoundary.ChunkCoordinate(1, 0), + new GenerationBoundary.ChunkCoordinate(-1, 1), + new GenerationBoundary.ChunkCoordinate(0, 1), + new GenerationBoundary.ChunkCoordinate(1, 1) + )); + + assertFalse(boundary.isHistoricalChunk(0, 0)); + assertEquals(8D, boundary.distanceToHistoricalChunks(8, 8, 64), 0D); + assertEquals(4D, boundary.distanceToHistoricalChunks(100, 8, 4), 0D); + + GenerationBoundary corner = GenerationBoundary.freeze("corner", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + assertEquals(Math.sqrt(2D), corner.distanceToHistoricalChunks(16, 16, 64), 0D); + } + + @Test + public void packedCoordinatesRoundTripAcrossIntegerRange() { + long[] packed = { + GenerationBoundary.packChunk(Integer.MIN_VALUE, Integer.MAX_VALUE), + GenerationBoundary.packChunk(-1, 0), + GenerationBoundary.packChunk(0, -1), + GenerationBoundary.packChunk(Integer.MAX_VALUE, Integer.MIN_VALUE) + }; + + GenerationBoundary boundary = GenerationBoundary.freezePacked("packed-frontier", packed); + + assertArrayEquals(packed, boundary.packedHistoricalChunks()); + assertEquals(new GenerationBoundary.ChunkCoordinate(Integer.MIN_VALUE, Integer.MAX_VALUE), + GenerationBoundary.unpackChunk(packed[0])); + assertEquals(new GenerationBoundary.ChunkCoordinate(Integer.MAX_VALUE, Integer.MIN_VALUE), + GenerationBoundary.unpackChunk(packed[3])); + } + + @Test + public void samplesEveryExposedEdgeBlockWithoutDuplicatingCorners() { + GenerationBoundary isolated = GenerationBoundary.freeze("isolated", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(-1, -1) + )); + + assertEquals(120, isolated.exposedBlockColumns().size()); + assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(-16, -16))); + assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(-1, -1))); + assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(0, 0))); + assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(15, 15))); + assertFalse(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(7, 7))); + + GenerationBoundary adjacent = GenerationBoundary.freeze("adjacent", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(1, 0) + )); + assertEquals(92, adjacent.exposedBlockColumns().size()); + assertFalse(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(15, 8))); + assertFalse(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(16, 8))); + assertTrue(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(0, 8))); + assertTrue(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(31, 8))); + } + + @Test + public void fragmentedExplorationStreamsBeyondTheFormerMonolithicSnapshotLimit() { + int chunkCount = 70_000; + ArrayList chunks = new ArrayList<>(chunkCount); + for (int index = 0; index < chunkCount; index++) { + chunks.add(new GenerationBoundary.ChunkCoordinate(index * 2, 0)); + } + + GenerationBoundary boundary = GenerationBoundary.freeze("fragmented", chunks); + + assertEquals(4_200_000L, boundary.exposedBlockColumnCount()); + } + + @Test + public void rejectsInvalidBoundaryInputs() { + assertThrows(NullPointerException.class, () -> GenerationBoundary.freeze("boundary", null)); + ArrayList withNull = new ArrayList<>(); + withNull.add(new GenerationBoundary.ChunkCoordinate(0, 0)); + withNull.add(null); + assertThrows(NullPointerException.class, () -> GenerationBoundary.freeze("boundary", withNull)); + assertThrows(IllegalArgumentException.class, () -> GenerationBoundary.freeze(" ", List.of())); + + GenerationBoundary boundary = GenerationBoundary.freeze("boundary", List.of()); + assertThrows(IllegalArgumentException.class, + () -> boundary.distanceToHistoricalChunks(0, 0, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java new file mode 100644 index 000000000..e5c7a91ad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochContractFactoryTest.java @@ -0,0 +1,288 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionMode; +import art.arcane.iris.engine.object.IrisDimensionModeType; +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.engine.object.IrisEnvironment; +import art.arcane.iris.engine.object.IrisRange; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GenerationEpochContractFactoryTest { + @Rule + public final TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void derivesTheAuthoredDimensionTypeSemantics() { + IrisDimension dimension = dimension(IrisEnvironment.NORMAL); + IrisDimensionType dimensionType = dimension.getDimensionType(); + + GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create( + dimension, + "worlds/overworld", + "iris:overworld" + ); + + assertEquals("worlds/overworld", contract.dimensionKey()); + assertEquals("iris:overworld", contract.dimensionTypeKey()); + assertEquals("NORMAL", contract.environment()); + assertEquals("OVERWORLD", contract.generationMode()); + assertEquals(127, contract.internalFluidHeight()); + assertEquals(-64, contract.minHeight()); + assertEquals(384, contract.height()); + assertEquals(256, contract.logicalHeight()); + assertEquals(1D, contract.coordinateScale(), 0D); + assertEquals(false, contract.upperTerrainEnabled()); + assertEquals("none", contract.upperDimensionKey()); + assertEquals(0, contract.upperDimensionGap()); + assertEquals("0".repeat(64), contract.upperTerrainPackFingerprint()); + assertEquals( + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + contract.dimensionTypeFingerprintSchema() + ); + assertEquals( + GenerationEpochContractFactory.fingerprintDimensionType(dimensionType), + contract.dimensionTypeFingerprint() + ); + } + + @Test + public void capturesGenerationModeAndInternalFluidHeight() { + IrisDimension dimension = dimension(IrisEnvironment.NORMAL) + .setMode(new IrisDimensionMode().setType(IrisDimensionModeType.ISLANDS)) + .setFluidHeight(100); + + GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ); + + assertEquals("ISLANDS", contract.generationMode()); + assertEquals(164, contract.internalFluidHeight()); + } + + @Test + public void upperTerrainMustBeLocalAndPackIdentityDoesNotChangeItsLayout() throws Exception { + File pack = temporary.newFolder("upper-pack"); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader loader = mock(ResourceLoader.class); + IrisDimension dimension = dimension(IrisEnvironment.NORMAL).setUpperDimension("ceiling"); + IrisDimension ceiling = dimension(IrisEnvironment.NORMAL); + dimension.setLoader(data); + ceiling.setLoader(data); + ceiling.setLoadKey("ceiling"); + when(data.getDataFolder()).thenReturn(pack); + when(data.getDimensionLoader()).thenReturn(loader); + when(loader.load("ceiling", false)).thenReturn(ceiling); + + GenerationEpoch.DimensionContract first = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ); + Files.writeString( + pack.toPath().resolve("upper-content.json"), + "{}", + StandardCharsets.UTF_8 + ); + GenerationEpoch.DimensionContract changedPack = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ); + + assertEquals(true, first.upperTerrainEnabled()); + assertEquals("ceiling", first.upperDimensionKey()); + assertEquals(32, first.upperDimensionGap()); + assertNotEquals(first.upperTerrainPackFingerprint(), changedPack.upperTerrainPackFingerprint()); + assertNotEquals(first, changedPack); + assertEquals(true, first.hasSameLayout(changedPack)); + dimension.setUpperDimensionGap(33); + assertEquals(false, first.hasSameLayout(GenerationEpochContractFactory.create( + dimension, "overworld", "iris:overworld"))); + + when(loader.load("ceiling", false)).thenReturn(null); + assertThrows( + IllegalStateException.class, + () -> GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ) + ); + } + + @Test + public void resolvesBaseEnvironmentCoordinateScale() { + IrisDimension dimension = dimension(IrisEnvironment.NETHER); + + GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create( + dimension, + "underworld", + "iris:underworld" + ); + + assertEquals("NETHER", contract.environment()); + assertEquals(8D, contract.coordinateScale(), 0D); + } + + @Test + public void explicitCoordinateScaleOverridesTheBaseEnvironment() { + IrisDimension dimension = dimension(IrisEnvironment.NETHER); + dimension.setDimensionOptions(new IrisDimensionTypeOptions().coordinateScale(3.5D)); + + GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create( + dimension, + "underworld", + "iris:underworld" + ); + + assertEquals(3.5D, contract.coordinateScale(), 0D); + } + + @Test + public void serializerChangesDoNotAlterTheDimensionContract() { + IrisDimension dimension = dimension(IrisEnvironment.NORMAL); + IrisDimensionType type = dimension.getDimensionType(); + String firstJson = type.toJson(new TaggedFixer("first")); + String secondJson = type.toJson(new TaggedFixer("second")); + + GenerationEpoch.DimensionContract first = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ); + GenerationEpoch.DimensionContract second = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld" + ); + + assertNotEquals(firstJson, secondJson); + assertEquals(first, second); + } + + @Test + public void authoredDimensionTypeChangesAlterTheFingerprint() { + IrisDimension normal = dimension(IrisEnvironment.NORMAL); + IrisDimension fullbright = dimension(IrisEnvironment.NORMAL); + fullbright.setFullbright(true); + IrisDimension nether = dimension(IrisEnvironment.NETHER); + + assertNotEquals( + GenerationEpochContractFactory.create( + normal, + "overworld", + "iris:overworld" + ).dimensionTypeFingerprint(), + GenerationEpochContractFactory.create( + fullbright, + "overworld", + "iris:overworld" + ).dimensionTypeFingerprint() + ); + assertNotEquals( + GenerationEpochContractFactory.fingerprintDimensionType(normal.getDimensionType()), + GenerationEpochContractFactory.fingerprintDimensionType(nether.getDimensionType()) + ); + } + + @Test + public void recordsAndValidatesTheFingerprintSchema() { + IrisDimension dimension = dimension(IrisEnvironment.NORMAL); + + GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld", + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE + ); + + assertEquals( + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + contract.dimensionTypeFingerprintSchema() + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationEpochContractFactory.create( + dimension, + "overworld", + "iris:overworld", + 2 + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationEpochContractFactory.fingerprintDimensionType( + dimension.getDimensionType(), + 2 + ) + ); + } + + @Test + public void refusesInvalidKeys() { + IrisDimension dimension = dimension(IrisEnvironment.NORMAL); + + assertThrows( + IllegalArgumentException.class, + () -> GenerationEpochContractFactory.create( + dimension, + "Overworld", + "iris:overworld" + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationEpochContractFactory.create( + dimension, + "overworld", + "overworld" + ) + ); + } + + private static IrisDimension dimension(IrisEnvironment environment) { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + dimension.setEnvironment(environment); + dimension.setDimensionHeight(new IrisRange(-64, 320)); + dimension.setLogicalHeight(256); + return dimension; + } + + private record TaggedFixer(String tag) implements IDataFixer { + @Override + public JSONObject resolve(Dimension dimension, IrisDimensionTypeOptions options) { + double coordinateScale = options.coordinateScale() == -1D + ? dimension == Dimension.NETHER ? 8D : 1D + : options.coordinateScale(); + return new JSONObject() + .put("coordinate_scale", coordinateScale) + .put("serializer", tag); + } + + @Override + public void fixDimension(Dimension dimension, JSONObject json) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochTest.java new file mode 100644 index 000000000..38014f728 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationEpochTest.java @@ -0,0 +1,320 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +public class GenerationEpochTest { + private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + @Test + public void derivesStableIdentityFromEveryGenerationInput() { + GenerationEpoch.DimensionContract contract = contract("iris:type_a", 1D); + GenerationRegistryContract registry = registry(PACK_A); + GenerationEpoch first = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry); + GenerationEpoch same = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry); + + assertEquals(first, same); + assertEquals(first.epochId(), same.epochId()); + assertNotEquals(first.epochId(), epoch(PACK_B, 1, 42L, 1, 1, 1, contract, registry).epochId()); + assertNotEquals(first.epochId(), epoch(PACK_A, 2, 42L, 1, 1, 1, contract, registry).epochId()); + assertNotEquals(first.epochId(), epoch(PACK_A, 1, 43L, 1, 1, 1, contract, registry).epochId()); + assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 2, 1, 1, contract, registry).epochId()); + assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 1, 2, 1, contract, registry).epochId()); + assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 1, 1, 2, contract, registry).epochId()); + GenerationEpoch changedKernelImplementation = GenerationEpoch.create(new GenerationEpoch.Spec( + PACK_A, + 1, + 42L, + 1, + 1, + 1, + "d".repeat(64), + contract, + registry + )); + assertNotEquals(first.epochId(), changedKernelImplementation.epochId()); + assertNotEquals( + first.epochId(), + epoch(PACK_A, 1, 42L, 1, 1, 1, contract("iris:type_b", 1D), registry).epochId() + ); + assertNotEquals( + first.epochId(), + epoch(PACK_A, 1, 42L, 1, 1, 1, contract("iris:type_a", 8D), registry).epochId() + ); + assertNotEquals( + first.epochId(), + epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry(PACK_B)).epochId() + ); + assertNotEquals(first.epochId(), epoch( + PACK_A, + 1, + 42L, + 1, + 1, + 1, + new GenerationEpoch.DimensionContract( + "overworld", + "iris:type_a", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + PACK_B + ), + registry + ).epochId()); + } + + @Test + public void preservesExactImmutableInputs() { + GenerationRegistryContract registry = registry(PACK_A); + GenerationEpoch epoch = epoch(PACK_A, 3, Long.MIN_VALUE, 7, 5, 9, contract("iris:type_a", 1D), registry); + + assertEquals(PACK_A, epoch.packFingerprint()); + assertEquals(3, epoch.packFingerprintVersion()); + assertEquals(Long.MIN_VALUE, epoch.worldSeed()); + assertEquals(7, epoch.seedDerivationVersion()); + assertEquals(5, epoch.generatorAbi()); + assertEquals(9, epoch.rngVersion()); + assertEquals(GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, epoch.kernelImplementationFingerprint()); + assertEquals(registry, epoch.registryContract()); + } + + @Test + public void roundTripsStrictDeterministicJson() { + GenerationEpoch epoch = epoch( + PACK_A, + 1, + Long.MAX_VALUE, + 1, + 3, + 7, + contract("iris:type_a", 1D), + registry(PACK_A) + ); + JsonObject json = epoch.toJson(); + + assertEquals(epoch, GenerationEpoch.fromJson(json.deepCopy())); + assertEquals(json.toString(), epoch.toJson().toString()); + + JsonObject unknownField = json.deepCopy(); + unknownField.addProperty("unexpected", true); + assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(unknownField)); + + JsonObject missingSeed = json.deepCopy(); + missingSeed.remove("worldSeed"); + assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(missingSeed)); + + JsonObject missingKernelFingerprint = json.deepCopy(); + missingKernelFingerprint.remove("kernelImplementationFingerprint"); + assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(missingKernelFingerprint)); + } + + @Test + public void rejectsMalformedGenerationInputs() { + GenerationEpoch.DimensionContract contract = contract("iris:type_a", 1D); + GenerationRegistryContract registry = registry(PACK_A); + + assertThrows( + IllegalArgumentException.class, + () -> epoch( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + 1, + 42L, + 1, + 1, + 1, + contract, + registry + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> epoch(PACK_A, 0, 42L, 1, 1, 1, contract, registry) + ); + assertThrows( + IllegalArgumentException.class, + () -> epoch(PACK_A, 1, 42L, 0, 1, 1, contract, registry) + ); + assertThrows( + IllegalArgumentException.class, + () -> epoch(PACK_A, 1, 42L, 1, 0, 1, contract, registry) + ); + assertThrows( + IllegalArgumentException.class, + () -> epoch(PACK_A, 1, 42L, 1, 1, 0, contract, registry) + ); + GenerationEpoch valid = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry); + JsonObject malformedKernelFingerprint = valid.toJson(); + malformedKernelFingerprint.addProperty("kernelImplementationFingerprint", "not-a-sha256"); + assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(malformedKernelFingerprint)); + assertThrows( + NullPointerException.class, + () -> epoch(PACK_A, 1, 42L, 1, 1, 1, contract, null) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationEpoch.DimensionContract( + "overworld", + "iris:type_a", + "NORMAL", + "OVERWORLD", + 127, + -64, + 0, + 0, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + PACK_A + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationEpoch.DimensionContract( + "overworld", + "iris:type_a", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 385, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + PACK_A + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationEpoch.DimensionContract( + "overworld", + "iris:type_a", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + Double.NaN, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + PACK_A + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationEpoch.DimensionContract( + "overworld", + "iris:type_a", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + 99, + PACK_A + ) + ); + } + + @Test + public void rejectsForgedContentAddress() { + GenerationEpoch epoch = epoch( + PACK_A, + 1, + 42L, + 1, + 1, + 1, + contract("iris:type_a", 1D), + registry(PACK_A) + ); + JsonObject json = epoch.toJson(); + json.addProperty("epochId", PACK_B); + + assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(json)); + } + + private static GenerationEpoch epoch( + String packFingerprint, + int packFingerprintVersion, + long worldSeed, + int seedDerivationVersion, + int generatorAbi, + int rngVersion, + GenerationEpoch.DimensionContract contract, + GenerationRegistryContract registryContract + ) { + return GenerationEpoch.create(new GenerationEpoch.Spec( + packFingerprint, + packFingerprintVersion, + worldSeed, + seedDerivationVersion, + generatorAbi, + rngVersion, + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, + contract, + registryContract + )); + } + + private static GenerationEpoch.DimensionContract contract(String typeKey, double coordinateScale) { + return new GenerationEpoch.DimensionContract( + "overworld", + typeKey, + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + coordinateScale, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE, + PACK_A + ); + } + + private static GenerationRegistryContract registry(String definitionSha256) { + GenerationRegistryContract.PhysicalResourceKey key = new GenerationRegistryContract.PhysicalResourceKey( + "minecraft:worldgen/biome", + "minecraft:plains" + ); + return GenerationRegistryContract.fromDefinitions(Map.of(key, definitionSha256)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationFindCatalogTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationFindCatalogTest.java new file mode 100644 index 000000000..515b553d2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationFindCatalogTest.java @@ -0,0 +1,283 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.util.common.director.specialhandlers.LocatableObjectHandler; +import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler; +import art.arcane.iris.util.common.director.specialhandlers.ReachableRegionHandler; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class GenerationFindCatalogTest { + @Test + public void parsesRemovedNamesFromRetainedPackWithoutBuildingRuntime() throws Exception { + Fixture fixture = new Fixture(); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertSame(fixture.meadow, fixture.biomeHandler().parse("meadow", false)); + assertSame(fixture.oldRegion, fixture.regionHandler().parse("old-region", false)); + assertEquals("old-tree", fixture.objectHandler().parse("old-tree", false)); + assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "/OLD-TREE.iob")); + assertFalse(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "unplaced")); + assertEquals("old meadow", GenerationFindCatalog.biome(fixture.engine, "meadow").getName()); + + data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile()), times(1)); + data.verifyNoMoreInteractions(); + verify(fixture.oldData).close(); + verify(fixture.history, never()).packRoot(2L); + verify(fixture.engine, never()).getActiveGenerationRuntimeBinding(); + } + } + + @Test + public void activeDefinitionsWinHistoricalKeyCollisions() throws Exception { + Fixture fixture = new Fixture(); + IrisBiome activeMeadow = biome("meadow", "new meadow"); + IrisRegion activeRegion = region("old-region"); + when(fixture.engine.getAllBiomes()).thenReturn(new KList<>(activeMeadow)); + when(fixture.activeDimension.getAllRegions(fixture.engine)).thenReturn(new KList<>(activeRegion)); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertSame(activeMeadow, fixture.biomeHandler().parse("MEADOW", false)); + assertSame(activeRegion, fixture.regionHandler().parse("OLD-REGION", false)); + assertEquals(1, GenerationFindCatalog.biomes(fixture.engine).size()); + assertEquals(1, GenerationFindCatalog.regions(fixture.engine).size()); + } + } + + @Test + public void unrelatedAuthoringAndUnreachableFilesAreNotFallbackCandidates() throws Exception { + Fixture fixture = new Fixture(); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertThrows(DirectorParsingException.class, () -> fixture.biomeHandler().parse("unused", false)); + assertThrows(DirectorParsingException.class, () -> fixture.regionHandler().parse("unused", false)); + assertThrows(DirectorParsingException.class, () -> fixture.objectHandler().parse("unplaced", false)); + verify(fixture.biomeLoader, never()).load("unused"); + data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())); + data.verifyNoMoreInteractions(); + } + } + + @Test + public void missingRetainedPackFailsInsteadOfSearchingAuthoringPacks() throws Exception { + Fixture fixture = new Fixture(); + IOException missingPack = new IOException("missing immutable pack"); + when(fixture.history.packRoot(1L)).thenThrow(missingPack); + + UncheckedIOException failure = assertThrows(UncheckedIOException.class, + () -> GenerationFindCatalog.biomes(fixture.engine)); + + assertSame(missingPack, failure.getCause()); + } + + @Test + public void retainsEditableStructureKeysFromEveryOwningPlacementScope() throws Exception { + Fixture fixture = new Fixture(); + fixture.oldDimension.setStructures(new KList<>(structure("old-house"))); + fixture.oldRegion.setStructures(new KList<>(structure("old-ruin"))); + fixture.meadow.setStructures(new KList<>(structure("old-tower"))); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "OLD-HOUSE")); + assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "old-ruin")); + assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "old-tower")); + assertFalse(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "unused")); + assertEquals(3, GenerationFindCatalog.retainedStructureKeys(fixture.engine).size()); + } + } + + @Test + public void parsesRemovedObjectsFromTheSeparateRetainedUpperDimension() throws Exception { + Fixture fixture = new Fixture(); + fixture.oldDimension.setUpperDimension("ceiling"); + IrisDimension upper = new IrisDimension().setRegions(new KList<>("sky-region")); + IrisRegion skyRegion = region("sky-region").setLandBiomes(new KList<>("sky-biome")); + IrisBiome skyBiome = biome("sky-biome", "ceiling biome"); + skyRegion.setObjects(new KList<>(object("upper-region-object"))); + skyBiome.setObjects(new KList<>(object("upper-biome-object"))); + when(fixture.dimensionLoader.load("ceiling", false)).thenReturn(upper); + when(fixture.regionLoader.load("sky-region")).thenReturn(skyRegion); + when(fixture.biomeLoader.load("sky-biome")).thenReturn(skyBiome); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertEquals("upper-biome-object", fixture.objectHandler().parse("upper-biome-object", false)); + assertEquals("upper-region-object", fixture.objectHandler().parse("upper-region-object", false)); + assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "upper-biome-object")); + assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "upper-region-object")); + assertNull(GenerationFindCatalog.biome(fixture.engine, "sky-biome")); + assertNull(GenerationFindCatalog.region(fixture.engine, "sky-region")); + data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())); + data.verifyNoMoreInteractions(); + verify(fixture.oldData).close(); + } + } + + @Test + public void missingRetainedUpperDimensionDoesNotFallBackToAnotherPack() throws Exception { + Fixture fixture = new Fixture(); + fixture.oldDimension.setUpperDimension("missing-upper"); + try (MockedStatic data = mockStatic(IrisData.class)) { + data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData); + + assertThrows(IllegalStateException.class, () -> GenerationFindCatalog.objectKeys(fixture.engine)); + data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())); + data.verifyNoMoreInteractions(); + verify(fixture.oldData).close(); + } + } + + private static IrisStructurePlacement structure(String key) { + return new IrisStructurePlacement().setStructures(new KList<>(key)); + } + + private static IrisObjectPlacement object(String key) { + return new IrisObjectPlacement().setPlace(new KList<>(key)); + } + + private static IrisBiome biome(String key, String name) { + IrisBiome biome = new IrisBiome().setName(name); + biome.setLoadKey(key); + return biome; + } + + private static IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + return region; + } + + private static final class Fixture { + private final IrisEngine engine = mock(IrisEngine.class); + private final GenerationHistory history = mock(GenerationHistory.class); + private final IrisDimension activeDimension = mock(IrisDimension.class); + private final IrisData oldData = mock(IrisData.class); + private final ResourceLoader dimensionLoader; + private final ResourceLoader regionLoader; + private final ResourceLoader biomeLoader; + private final IrisDimension oldDimension = new IrisDimension().setRegions(new KList<>("old-region")); + private final Path oldPack = Path.of("retained-test-pack").toAbsolutePath(); + private final IrisBiome meadow = biome("meadow", "old meadow"); + private final IrisRegion oldRegion = region("old-region"); + + @SuppressWarnings("unchecked") + private Fixture() throws IOException { + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation active = mock(GenerationActivation.class); + GenerationActivation old = mock(GenerationActivation.class); + GenerationEpoch epoch = mock(GenerationEpoch.class); + GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(history.manifest()).thenReturn(manifest); + when(manifest.activeActivation()).thenReturn(active); + when(active.activationId()).thenReturn(2L); + when(active.parentActivationId()).thenReturn(1L); + when(manifest.activation(1L)).thenReturn(Optional.of(old)); + when(old.epochId()).thenReturn("old"); + when(old.parentActivationId()).thenReturn(null); + when(manifest.epoch("old")).thenReturn(Optional.of(epoch)); + when(epoch.dimensionContract()).thenReturn(contract); + when(epoch.packFingerprint()).thenReturn("old-pack"); + when(contract.dimensionKey()).thenReturn("world"); + when(history.packRoot(1L)).thenReturn(oldPack); + + IrisData activeData = mock(IrisData.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(engine.getData()).thenReturn(activeData); + when(activeData.getObjectLoader()).thenReturn(objectLoader); + when(objectLoader.getPossibleKeys()).thenReturn(new String[]{"new-tree"}); + when(engine.getDimension()).thenReturn(activeDimension); + when(engine.getAllBiomes()).thenReturn(new KList<>(biome("plateau", "new plateau"))); + when(activeDimension.getAllRegions(engine)).thenReturn(new KList<>(region("new-region"))); + + dimensionLoader = mock(ResourceLoader.class); + regionLoader = mock(ResourceLoader.class); + biomeLoader = mock(ResourceLoader.class); + oldRegion.setLandBiomes(new KList<>("meadow")); + meadow.setObjects(new KList<>(new IrisObjectPlacement().setPlace(new KList<>("old-tree")))); + when(oldData.getDimensionLoader()).thenReturn(dimensionLoader); + when(oldData.getRegionLoader()).thenReturn(regionLoader); + when(oldData.getBiomeLoader()).thenReturn(biomeLoader); + when(dimensionLoader.load("world", false)).thenReturn(oldDimension); + when(regionLoader.load("old-region")).thenReturn(oldRegion); + when(biomeLoader.load("meadow")).thenReturn(meadow); + } + + private ReachableBiomeHandler biomeHandler() { + return new ReachableBiomeHandler() { + @Override + public Engine engine() { + return engine; + } + + @Override + public IrisData data() { + throw new AssertionError("Find must not use the authoring context"); + } + }; + } + + private ReachableRegionHandler regionHandler() { + return new ReachableRegionHandler() { + @Override + public Engine engine() { + return engine; + } + + @Override + public IrisData data() { + throw new AssertionError("Find must not use the authoring context"); + } + }; + } + + private LocatableObjectHandler objectHandler() { + return new LocatableObjectHandler() { + @Override + public Engine engine() { + return engine; + } + + @Override + public IrisData data() { + throw new AssertionError("Find must not use the authoring context"); + } + }; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryPathsTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryPathsTest.java new file mode 100644 index 000000000..b83b2487f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryPathsTest.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public final class GenerationHistoryPathsTest { + @Test + public void derivesTheCanonicalDimensionHistoryLayout() { + Path dimensionRoot = Path.of("build", "world", "..", "world"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot); + Path expectedRoot = dimensionRoot.toAbsolutePath().normalize(); + String epochId = "a".repeat(64); + + assertEquals(expectedRoot, paths.dimensionRoot()); + assertEquals(expectedRoot.resolve("iris"), paths.irisRoot()); + assertEquals(expectedRoot.resolve("iris/generation"), paths.generationRoot()); + assertEquals(expectedRoot.resolve("iris/generation/manifest.json"), paths.manifest()); + assertEquals(expectedRoot.resolve("iris/generation/epochs"), paths.epochsRoot()); + assertEquals(expectedRoot.resolve("iris/generation/activations"), paths.activationsRoot()); + assertEquals(expectedRoot.resolve("iris/generation/ownership"), paths.ownershipRoot()); + assertEquals(expectedRoot.resolve("iris/generation/epochs").resolve(epochId), paths.epochRoot(epochId)); + assertEquals(expectedRoot.resolve("iris/generation/epochs").resolve(epochId).resolve("pack"), + paths.packRoot(epochId)); + assertEquals(expectedRoot.resolve("iris/generation/activations/2"), paths.activationRoot(2L)); + assertEquals(expectedRoot.resolve("iris/generation/activations/2/mantle-hydrology"), + paths.activationMantleRoot(2L)); + assertEquals(expectedRoot.resolve("iris/pack"), paths.legacyPackRoot()); + assertEquals(expectedRoot.resolve("mantle-hydrology"), paths.legacyMantleRoot()); + assertEquals(expectedRoot.resolve("region"), paths.regionRoot()); + assertThrows(IllegalArgumentException.class, () -> paths.epochRoot("../escape")); + assertThrows(IllegalArgumentException.class, () -> paths.activationRoot(0L)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java new file mode 100644 index 000000000..da273328f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -0,0 +1,1073 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.nbt.common.mca.MCAFile; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Rule; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +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.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public final class GenerationHistoryRuntimeRouterTest { + private static final int SECTOR_BYTES = 4_096; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void initializeMantleBlockState() throws Exception { + PlatformBlockState air = mock(PlatformBlockState.class); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState("AIR")).thenReturn(air); + Class.forName(EngineMantle.class.getName()); + } + } + + @Test + public void promotionSamplesEveryBoundaryColumnThroughItsOwningRuntime() throws Exception { + Path world = temporaryFolder.newFolder("router-promotion-world").toPath(); + Path packA = createPack("router-promotion-a", "alpha"); + Path packB = createPack("router-promotion-b", "beta"); + GenerationHistory history = createHistory(world, packA); + writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), + new int[][]{{0, 0}}); + stage(history, packB); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + AtomicReference scoped = installScopeTracking(engine); + int[] samples = new int[1]; + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attachAndPromotePending( + engine, + history, + (routedEngine, blockX, blockZ) -> { + assertSame(engine, routedEngine); + assertSame(first, scoped.get()); + samples[0]++; + return signature(blockX, blockZ); + }, + 256, + runtimes, + GenerationHistoryRuntimeRouter.DEFAULT_RUNTIME_CACHE_CAPACITY + ); + + GenerationActivation activated = history.activeActivation(); + IrisEngine.GenerationRuntimeBinding second = runtimes.bindings.get(2L); + + assertEquals(2L, activated.activationId()); + assertEquals(history.boundary(2L).exposedBlockColumns().size(), samples[0]); + verify(engine).setDefaultGenerationRuntime(second); + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + assertSame(first, scoped.get()); + } + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(1, 0)) { + assertSame(second, scoped.get()); + } + router.close(); + } + + @Test + public void repeatedEpochActivationKeepsADistinctMantleAndBinding() throws Exception { + Path world = temporaryFolder.newFolder("router-repeat-world").toPath(); + Path packA = createPack("router-repeat-a", "alpha"); + Path packB = createPack("router-repeat-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{0, 0}}); + stage(history, packB); + promoteWithSignatures(history); + writeRegion(region, new int[][]{{0, 0}, {1, 0}}); + stage(history, packA); + GenerationActivation third = promoteWithSignatures(history); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding repeated = runtimes.binding(history, third); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(repeated); + AtomicReference scoped = installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + IrisEngine.GenerationRuntimeBinding first; + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + first = scoped.get(); + } + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(2, 0)) { + assertSame(repeated, scoped.get()); + } + + assertEquals(3L, third.activationId()); + assertEquals(history.manifest().activation(1L).orElseThrow().epochId(), third.epochId()); + assertNotSame(first, repeated); + assertNotSame(runtimes.bindings.get(1L), runtimes.bindings.get(3L)); + assertEquals(history.paths().activationMantleRoot(1L), runtimes.mantles.get(1L)); + assertEquals(history.paths().activationMantleRoot(3L), runtimes.mantles.get(3L)); + assertNotEquals(runtimes.mantles.get(1L), runtimes.mantles.get(3L)); + router.close(); + } + + @Test + public void preloadKeepsHistoricalActivationBindingsLazy() throws Exception { + Path world = temporaryFolder.newFolder("router-lazy-world").toPath(); + Path packA = createPack("router-lazy-a", "alpha"); + Path packB = createPack("router-lazy-b", "beta"); + Path packC = createPack("router-lazy-c", "gamma"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{0, 0}}); + stage(history, packB); + promoteWithSignatures(history); + writeRegion(region, new int[][]{{0, 0}, {1, 0}}); + stage(history, packC); + promoteWithSignatures(history); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + + router.preloadActiveRuntimes(); + + assertEquals(Set.of(3L), runtimes.bindings.keySet()); + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + assertEquals(Set.of(1L, 3L), runtimes.bindings.keySet()); + } + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(1, 0)) { + assertEquals(Set.of(1L, 2L, 3L), runtimes.bindings.keySet()); + } + router.close(); + } + + @Test + public void leastRecentlyUsedIdleRuntimeIsEvictedAndReloaded() throws Exception { + Path world = temporaryFolder.newFolder("router-eviction-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-eviction"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes, + 2 + ); + + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + } + IrisEngine.GenerationRuntimeBinding firstLoad = runtimes.bindings.get(1L); + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(1, 0)) { + } + + verify(engine).closeDetachedGenerationRuntime(firstLoad); + assertEquals(1, runtimes.loadCount(1L)); + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + } + assertEquals(2, runtimes.loadCount(1L)); + router.close(); + } + + @Test + public void reacquisitionWaitsUntilPreviousActivationMantleRetires() throws Exception { + Path world = temporaryFolder.newFolder("router-retirement-reload-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-retirement-reload"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes, 1); + GenerationHistoryRuntimeRouter.RuntimeRoute original = router.openRoute(0, 0); + IrisEngine.GenerationRuntimeBinding first = runtimes.bindings.get(1L); + CountDownLatch retirementStarted = new CountDownLatch(1); + CountDownLatch releaseRetirement = new CountDownLatch(1); + CountDownLatch reloadStarted = new CountDownLatch(1); + doAnswer(invocation -> { + retirementStarted.countDown(); + assertTrue(releaseRetirement.await(5L, TimeUnit.SECONDS)); + return null; + }).when(engine).closeDetachedGenerationRuntime(first); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CompletableFuture retiring = CompletableFuture.runAsync(original::close, executor); + assertTrue(retirementStarted.await(5L, TimeUnit.SECONDS)); + CompletableFuture reloading = + CompletableFuture.supplyAsync(() -> { + reloadStarted.countDown(); + return openRouteUnchecked(router, 0, 0); + }, executor); + assertTrue(reloadStarted.await(5L, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> reloading.get(100L, TimeUnit.MILLISECONDS)); + assertEquals(1, runtimes.loadCount(1L)); + + releaseRetirement.countDown(); + retiring.get(5L, TimeUnit.SECONDS); + try (GenerationHistoryRuntimeRouter.RuntimeRoute reloaded = reloading.get(5L, TimeUnit.SECONDS)) { + assertEquals(1L, reloaded.activation().activationId()); + assertEquals(2, runtimes.loadCount(1L)); + } + } finally { + releaseRetirement.countDown(); + executor.shutdownNow(); + router.close(); + } + } + + @Test + public void failedRetirementRejectsReopeningTheSameActivationMantle() throws Exception { + Path world = temporaryFolder.newFolder("router-retirement-failure-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-retirement-failure"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes, 1); + GenerationHistoryRuntimeRouter.RuntimeRoute original = router.openRoute(0, 0); + doAnswer(invocation -> { + throw new IllegalStateException("Mantle did not close"); + }).when(engine).closeDetachedGenerationRuntime(runtimes.bindings.get(1L)); + + assertThrows(IllegalStateException.class, original::close); + assertThrows(IOException.class, () -> router.openRoute(0, 0)); + assertEquals(1, runtimes.loadCount(1L)); + router.close(); + } + + @Test + public void routeLeaseSurvivesNestedScopesUntilRouteCloses() throws Exception { + Path world = temporaryFolder.newFolder("router-nested-lease-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-nested-lease"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes, + 1 + ); + GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(0, 0); + IrisEngine.GenerationRuntimeBinding historical = runtimes.bindings.get(1L); + + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope outer = route.openRuntimeScope(); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope inner = route.openRuntimeScope()) { + assertThrows(IllegalStateException.class, route::close); + verify(engine, never()).closeDetachedGenerationRuntime(historical); + } + verify(engine, never()).closeDetachedGenerationRuntime(historical); + + route.close(); + + verify(engine).closeDetachedGenerationRuntime(historical); + router.close(); + } + + @Test + public void stageCloseReleasesItsRouteWhenRuntimeScopeCloseFails() throws Exception { + Path world = temporaryFolder.newFolder("router-scope-close-failure-world").toPath(); + Path pack = createPack("router-scope-close-failure-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + IrisEngine.GenerationRuntimeScope runtimeScope = mock(IrisEngine.GenerationRuntimeScope.class); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + when(engine.openGenerationRuntimeScope(active)).thenReturn(runtimeScope); + doAnswer(ignored -> { + throw new IllegalStateException("scope close failed"); + }).when(runtimeScope).close(); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + GenerationHistoryRuntimeRouter.RuntimeStage stage = router.openStage(0, 0); + + IllegalStateException failure = assertThrows(IllegalStateException.class, stage::close); + + assertEquals("scope close failed", failure.getMessage()); + router.close(); + verify(engine).detachGenerationHistoryRuntimeRouter(router); + } + + @Test + public void concurrentRoutesDeduplicateHistoricalRuntimeLoad() throws Exception { + Path world = temporaryFolder.newFolder("router-deduplicated-load-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-deduplicated-load"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + runtimes.blockLoad(1L); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes, + 2 + ); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + CompletableFuture first = openRouteAsync( + router, executor, ready, start, 0, 0); + CompletableFuture second = openRouteAsync( + router, executor, ready, start, 0, 0); + assertTrue(ready.await(5L, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(runtimes.loadStarted.await(5L, TimeUnit.SECONDS)); + runtimes.releaseLoad.countDown(); + GenerationHistoryRuntimeRouter.RuntimeRoute firstRoute = first.get(5L, TimeUnit.SECONDS); + GenerationHistoryRuntimeRouter.RuntimeRoute secondRoute = second.get(5L, TimeUnit.SECONDS); + + assertEquals(1, runtimes.loadCount(1L)); + firstRoute.close(); + secondRoute.close(); + } finally { + executor.shutdownNow(); + } + router.close(); + } + + @Test + public void closeRejectsNewRoutesAndAwaitsLoadAndLease() throws Exception { + Path world = temporaryFolder.newFolder("router-close-await-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-close-await"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + runtimes.blockLoad(1L); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes, + 4 + ); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CompletableFuture routeFuture = CompletableFuture.supplyAsync( + () -> openRouteUnchecked(router, 0, 0), + executor + ); + assertTrue(runtimes.loadStarted.await(5L, TimeUnit.SECONDS)); + CountDownLatch closeInvoked = new CountDownLatch(1); + CompletableFuture closeFuture = CompletableFuture.runAsync(() -> { + closeInvoked.countDown(); + router.close(); + }, executor); + assertTrue(closeInvoked.await(5L, TimeUnit.SECONDS)); + awaitClosed(router); + + assertThrows(IllegalStateException.class, () -> router.openRoute(2, 0)); + runtimes.releaseLoad.countDown(); + GenerationHistoryRuntimeRouter.RuntimeRoute route = routeFuture.get(5L, TimeUnit.SECONDS); + assertThrows(TimeoutException.class, () -> closeFuture.get(100L, TimeUnit.MILLISECONDS)); + route.close(); + closeFuture.get(5L, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + verify(engine).detachGenerationHistoryRuntimeRouter(router); + verify(engine).closeDetachedGenerationRuntime(runtimes.bindings.get(1L)); + verify(engine, never()).closeDetachedGenerationRuntime(active); + } + + @Test + public void closeRetiresEveryCachedHistoricalRuntimeButKeepsDefault() throws Exception { + Path world = temporaryFolder.newFolder("router-close-cached-world").toPath(); + GenerationHistory history = createThreeActivationHistory(world, "router-close-cached"); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes, + 4 + ); + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(0, 0)) { + } + try (GenerationHistoryRuntimeRouter.RuntimeStage ignored = router.openStage(1, 0)) { + } + IrisEngine.GenerationRuntimeBinding first = runtimes.bindings.get(1L); + IrisEngine.GenerationRuntimeBinding second = runtimes.bindings.get(2L); + + router.close(); + + verify(engine).closeDetachedGenerationRuntime(first); + verify(engine).closeDetachedGenerationRuntime(second); + verify(engine, never()).closeDetachedGenerationRuntime(active); + } + + @Test + public void startupPromotionPinsNewDefaultAndRetiresOldDefault() throws Exception { + Path world = temporaryFolder.newFolder("router-promotion-lease-world").toPath(); + Path packA = createPack("router-promotion-lease-a", "alpha"); + Path packB = createPack("router-promotion-lease-b", "beta"); + GenerationHistory history = createHistory(world, packA); + writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), + new int[][]{{0, 0}}); + stage(history, packB); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding initial = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(initial); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attachAndPromotePending( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + 256, + runtimes, + 1 + ); + + assertEquals(2L, history.activeActivation().activationId()); + verify(engine).setDefaultGenerationRuntime(runtimes.bindings.get(2L)); + verify(engine).closeDetachedGenerationRuntime(initial); + router.close(); + } + + @Test + public void attachAndLoadedRuntimeRejectKernelMismatches() throws Exception { + Path baseWorld = temporaryFolder.newFolder("router-base-kernel-world").toPath(); + Path basePack = createPack("router-base-kernel-pack", "alpha"); + GenerationHistory baseHistory = createHistory(baseWorld, basePack); + IrisEngine baseEngine = mock(IrisEngine.class); + FakeRuntimeFactory baseRuntimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding mismatchedBase = baseRuntimes.binding( + baseHistory, + baseHistory.activeActivation(), + new GenerationKernelRegistry.Version(2, 1, 1) + ); + when(baseEngine.getActiveGenerationRuntimeBinding()).thenReturn(mismatchedBase); + + assertThrows( + IOException.class, + () -> GenerationHistoryRuntimeRouter.attach( + baseEngine, + baseHistory, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + baseRuntimes + ) + ); + verify(baseEngine, never()).attachGenerationHistoryRuntimeRouter(any()); + + Path loadedWorld = temporaryFolder.newFolder("router-loaded-kernel-world").toPath(); + Path loadedPackA = createPack("router-loaded-kernel-a", "alpha"); + Path loadedPackB = createPack("router-loaded-kernel-b", "beta"); + GenerationHistory loadedHistory = createHistory(loadedWorld, loadedPackA); + stage(loadedHistory, loadedPackB); + IrisEngine loadedEngine = mock(IrisEngine.class); + FakeRuntimeFactory loadedRuntimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding correctBase = loadedRuntimes.binding( + loadedHistory, + loadedHistory.activeActivation() + ); + when(loadedEngine.getActiveGenerationRuntimeBinding()).thenReturn(correctBase); + installScopeTracking(loadedEngine); + loadedRuntimes.loadedVersion = new GenerationKernelRegistry.Version(2, 1, 1); + + assertThrows( + IOException.class, + () -> GenerationHistoryRuntimeRouter.attachAndPromotePending( + loadedEngine, + loadedHistory, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + 256, + loadedRuntimes + ) + ); + verify(loadedEngine, never()).setDefaultGenerationRuntime(any()); + verify(loadedEngine).closeDetachedGenerationRuntime(loadedRuntimes.bindings.get(2L)); + assertEquals(2L, loadedHistory.activeActivation().activationId()); + } + + @Test + public void coordinateScopesUseFloorDivAndCloseRejectsFurtherRoutes() throws Exception { + Path world = temporaryFolder.newFolder("router-coordinate-world").toPath(); + Path pack = createPack("router-coordinate-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + AtomicReference scoped = installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + + try (GenerationHistoryRuntimeRouter.CoordinateScope scope = router.openCoordinateScope(-1, -17)) { + assertEquals(-1, scope.blockX()); + assertEquals(-17, scope.blockZ()); + assertEquals(-1, scope.chunkX()); + assertEquals(-2, scope.chunkZ()); + assertEquals(1L, scope.activation().activationId()); + assertSame(first, scoped.get()); + } + + router.close(); + + assertTrue(router.isClosed()); + verify(engine).detachGenerationHistoryRuntimeRouter(router); + verify(engine, never()).closeDetachedGenerationRuntime(any()); + assertThrows(IllegalStateException.class, () -> router.openStage(-1, -2)); + } + + @Test + public void runtimeRouteScopesWorkAndCloseAcrossAsyncThreads() throws Exception { + Path world = temporaryFolder.newFolder("router-async-world").toPath(); + Path pack = createPack("router-async-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + AtomicReference scoped = installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(-1, -2); + Thread caller = Thread.currentThread(); + AtomicReference completionThread = new AtomicReference<>(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + CompletableFuture work = CompletableFuture.runAsync(() -> { + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) { + assertSame(first, scoped.get()); + assertThrows(IllegalStateException.class, route::close); + } + }, executor); + CompletableFuture completion = work.whenComplete((ignored, failure) -> { + completionThread.set(Thread.currentThread()); + route.close(); + }); + completion.get(5L, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + assertNotSame(caller, completionThread.get()); + assertEquals(-1, route.chunkX()); + assertEquals(-2, route.chunkZ()); + assertThrows(IllegalStateException.class, route::openRuntimeScope); + router.close(); + } + + @Test + public void routeClaimsSealedSemanticsWhileItsRuntimeScopeIsOpen() throws Exception { + Path world = temporaryFolder.newFolder("router-claim-world").toPath(); + Path pack = createPack("router-claim-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + EngineMantle engineMantle = mock(EngineMantle.class); + @SuppressWarnings("unchecked") + Mantle mantle = mock(Mantle.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(4, -3); + + assertThrows(IllegalStateException.class, route::claimGeneratedSemantics); + try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) { + assertTrue(route.claimGeneratedSemantics()); + assertFalse(route.claimGeneratedSemantics()); + } + route.close(); + + ChunkGenerationSemantics semantics = history.semantics(4, -3).orElseThrow(); + assertTrue(semantics.sealed()); + assertEquals(1L, semantics.activationId()); + router.close(); + } + + @Test + public void coordinateScopeBorrowsMatchingRouteAndBypassesRawRuntimeScopes() throws Exception { + Path world = temporaryFolder.newFolder("router-borrow-world").toPath(); + Path pack = createPack("router-borrow-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + installScopeTracking(engine); + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + runtimes + ); + + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(-1, -2); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) { + try (GenerationHistoryRuntimeRouter.CoordinateScope borrowed = router.openCoordinateScope(-1, -17)) { + assertEquals(-1, borrowed.chunkX()); + assertEquals(-2, borrowed.chunkZ()); + assertSame(route.activation(), borrowed.activation()); + assertFalse(borrowed.claimGeneratedSemantics()); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope routed = router.openCoordinateScope(16, 0)) { + assertEquals(1, routed.chunkX()); + assertEquals(0, routed.chunkZ()); + assertSame(route.activation(), routed.activation()); + } + } + verify(engine, times(2)).openGenerationRuntimeScope(first); + + when(engine.hasGenerationRuntimeScope()).thenReturn(true); + try (GenerationHistoryRuntimeRouter.CoordinateScope bypassed = router.openCoordinateScope(31, 47)) { + assertEquals(1, bypassed.chunkX()); + assertEquals(2, bypassed.chunkZ()); + assertThrows(IllegalStateException.class, bypassed::activation); + assertFalse(bypassed.claimGeneratedSemantics()); + } + verify(engine, times(2)).openGenerationRuntimeScope(first); + router.close(); + } + + @Test + public void attachPromotesSamePackWhenTheCurrentKernelChanges() throws Exception { + Path world = temporaryFolder.newFolder("router-kernel-update-world").toPath(); + Path pack = createPack("router-kernel-update-pack", "alpha"); + GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); + GenerationKernelRegistry kernels = kernels(versionTwo); + GenerationHistory history = createHistory(world, pack, versionOne, kernels); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + installScopeTracking(engine); + + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attachAndPromotePending( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + 64, + runtimes + ); + + GenerationActivation active = history.activeActivation(); + assertEquals(2L, active.activationId()); + assertEquals(versionTwo, history.activeEpoch().kernelVersion()); + GenerationActivation initial = history.manifest().activation(1L).orElseThrow(); + assertEquals(history.manifest().epoch(initial.epochId()).orElseThrow().packFingerprint(), + history.activeEpoch().packFingerprint()); + verify(engine).setDefaultGenerationRuntime(runtimes.bindings.get(2L)); + router.close(); + } + + @Test + public void attachPromotesPendingPackThenRestagesItForTheCurrentKernel() throws Exception { + Path world = temporaryFolder.newFolder("router-pending-kernel-world").toPath(); + Path packA = createPack("router-pending-kernel-a", "alpha"); + Path packB = createPack("router-pending-kernel-b", "beta"); + GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); + GenerationKernelRegistry kernels = kernels(versionTwo); + GenerationHistory history = createHistory(world, packA, versionOne, kernels); + history.stageUpdate( + packB, + GenerationPackFingerprint.compute(packB, GenerationPackFingerprint.CURRENT_VERSION), + contract(), + GenerationRegistryContract.empty(), + 64, + versionOne + ); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding first = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(first); + installScopeTracking(engine); + + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attachAndPromotePending( + engine, + history, + (ignored, blockX, blockZ) -> signature(blockX, blockZ), + 64, + runtimes + ); + + assertEquals(3L, history.activeActivation().activationId()); + assertEquals(versionTwo, history.activeEpoch().kernelVersion()); + assertEquals(GenerationPackFingerprint.compute(packB, GenerationPackFingerprint.CURRENT_VERSION), + history.activeEpoch().packFingerprint()); + verify(engine).setDefaultGenerationRuntime(runtimes.bindings.get(2L)); + verify(engine).setDefaultGenerationRuntime(runtimes.bindings.get(3L)); + router.close(); + } + + private AtomicReference installScopeTracking(IrisEngine engine) { + AtomicReference scoped = new AtomicReference<>(); + when(engine.openGenerationRuntimeScope(any())).thenAnswer(invocation -> { + IrisEngine.GenerationRuntimeBinding binding = invocation.getArgument(0); + scoped.set(binding); + IrisEngine.GenerationRuntimeScope scope = mock(IrisEngine.GenerationRuntimeScope.class); + doAnswer(ignored -> { + scoped.compareAndSet(binding, null); + return null; + }).when(scope).close(); + return scope; + }); + return scoped; + } + + private CompletableFuture openRouteAsync( + GenerationHistoryRuntimeRouter router, + ExecutorService executor, + CountDownLatch ready, + CountDownLatch start, + int chunkX, + int chunkZ + ) { + return CompletableFuture.supplyAsync(() -> { + ready.countDown(); + try { + if (!start.await(5L, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to start concurrent route acquisition."); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted before concurrent route acquisition.", interrupted); + } + return openRouteUnchecked(router, chunkX, chunkZ); + }, executor); + } + + private static GenerationHistoryRuntimeRouter.RuntimeRoute openRouteUnchecked( + GenerationHistoryRuntimeRouter router, + int chunkX, + int chunkZ + ) { + try { + return router.openRoute(chunkX, chunkZ); + } catch (IOException failure) { + throw new IllegalStateException(failure); + } + } + + private static void awaitClosed(GenerationHistoryRuntimeRouter router) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (!router.isClosed() && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertTrue(router.isClosed()); + } + + private Path createPack(String name, String content) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), content); + return pack; + } + + private GenerationHistory createThreeActivationHistory(Path world, String prefix) throws IOException { + Path packA = createPack(prefix + "-a", "alpha"); + Path packB = createPack(prefix + "-b", "beta"); + Path packC = createPack(prefix + "-c", "gamma"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{0, 0}}); + stage(history, packB); + promoteWithSignatures(history); + writeRegion(region, new int[][]{{0, 0}, {1, 0}}); + stage(history, packC); + promoteWithSignatures(history); + return history; + } + + private static GenerationHistory createHistory(Path world, Path pack) throws IOException { + return GenerationHistory.create( + world, + pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + 42L, + contract(), + GenerationRegistryContract.empty() + ); + } + + private static GenerationHistory createHistory( + Path world, + Path pack, + GenerationKernelRegistry.Version version, + GenerationKernelRegistry kernels + ) throws IOException { + return GenerationHistory.create( + world, + pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + 42L, + contract(), + GenerationRegistryContract.empty(), + version, + kernels + ); + } + + private static GenerationKernelRegistry kernels(GenerationKernelRegistry.Version current) { + return new GenerationKernelRegistry( + current, + Set.of( + new GenerationKernelRegistry.Kernel( + 1, + "1".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("Mock runtime factory owns this test."); + } + ) + ), + new GenerationKernelRegistry.Kernel( + 2, + "2".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("Mock runtime factory owns this test."); + } + ) + ) + ) + ); + } + + private static GenerationActivation stage(GenerationHistory history, Path pack) throws IOException { + return history.stageUpdate( + pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + contract(), + GenerationRegistryContract.empty(), + 256 + ); + } + + private static GenerationActivation promoteWithSignatures(GenerationHistory history) throws IOException { + return history.promotePending(boundary -> GenerationHistoryRuntimeRouterTest::signature); + } + + private static GenerationEpoch.DimensionContract contract() { + return new GenerationEpoch.DimensionContract( + "overworld", + "iris:overworld_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "c".repeat(64) + ); + } + + private static TerrainBoundarySignature signature(int blockX, int blockZ) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 64, 2), + new TerrainBoundarySignature.BiomeEncoding(List.of("iris:test"), new short[]{0, 0}) + ) + ); + } + + private static void writeRegion(Path file, int[][] chunks) throws IOException { + try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) { + output.setLength((long) (2 + chunks.length) * SECTOR_BYTES); + for (int index = 0; index < chunks.length; index++) { + int chunkIndex = MCAFile.getChunkIndex(chunks[index][0], chunks[index][1]); + output.seek((long) chunkIndex * Integer.BYTES); + output.writeInt((2 + index) << Byte.SIZE | 1); + } + } + } + + private static final class FakeRuntimeFactory + implements GenerationHistoryRuntimeRouter.ActivationRuntimeFactory { + private final Map bindings = new HashMap<>(); + private final Map mantles = new HashMap<>(); + private final Map loadCounts = new HashMap<>(); + private GenerationKernelRegistry.Version loadedVersion; + private long blockedActivation = -1L; + private CountDownLatch loadStarted = new CountDownLatch(0); + private CountDownLatch releaseLoad = new CountDownLatch(0); + + @Override + public void validateBase( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch, + IrisEngine.GenerationRuntimeBinding binding + ) { + } + + @Override + public IrisEngine.GenerationRuntimeBinding load( + IrisEngine engine, + GenerationHistory history, + GenerationActivation activation, + GenerationEpoch epoch + ) throws IOException { + synchronized (loadCounts) { + loadCounts.computeIfAbsent(activation.activationId(), ignored -> new AtomicInteger()).incrementAndGet(); + } + if (activation.activationId() == blockedActivation) { + loadStarted.countDown(); + try { + if (!releaseLoad.await(5L, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release the blocked runtime load."); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while loading a test runtime.", interrupted); + } + } + GenerationKernelRegistry.Version version = loadedVersion == null + ? epoch.kernelVersion() + : loadedVersion; + return binding(history, activation, version); + } + + private void blockLoad(long activationId) { + blockedActivation = activationId; + loadStarted = new CountDownLatch(1); + releaseLoad = new CountDownLatch(1); + } + + private int loadCount(long activationId) { + synchronized (loadCounts) { + AtomicInteger count = loadCounts.get(activationId); + return count == null ? 0 : count.get(); + } + } + + private IrisEngine.GenerationRuntimeBinding binding( + GenerationHistory history, + GenerationActivation activation + ) throws IOException { + return binding(history, activation, requireEpoch(history, activation).kernelVersion()); + } + + private IrisEngine.GenerationRuntimeBinding binding( + GenerationHistory history, + GenerationActivation activation, + GenerationKernelRegistry.Version version + ) throws IOException { + GenerationEpoch epoch = requireEpoch(history, activation); + IrisEngine.GenerationRuntimeBinding binding = mock(IrisEngine.GenerationRuntimeBinding.class); + Path mantle = history.paths().activationMantleRoot(activation.activationId()); + TransitionGenerationPlan plan = activation.isInitial() + ? null + : history.transitionPlan(activation.activationId()); + when(binding.kernelVersion()).thenReturn(version); + when(binding.runtimeKernel()).thenReturn(new GenerationKernelRegistry.RuntimeKernel( + version, + epoch.kernelImplementationFingerprint(), + (engine, transitionPlan) -> mock(IrisComplex.class) + )); + when(binding.mantleStorageDirectory()).thenReturn(mantle); + when(binding.transitionPlan()).thenReturn(plan); + when(binding.runtimeId()).thenReturn(Math.toIntExact(activation.activationId())); + bindings.put(activation.activationId(), binding); + mantles.put(activation.activationId(), mantle); + return binding; + } + + private static GenerationEpoch requireEpoch( + GenerationHistory history, + GenerationActivation activation + ) { + return history.manifest().epoch(activation.epochId()).orElseThrow(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java new file mode 100644 index 000000000..4ccb2d06b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryStoreTest.java @@ -0,0 +1,341 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationHistoryStoreTest { + private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(123456789L), ZoneOffset.UTC); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void persistsActiveAndPendingHistoryAcrossReopen() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation"); + GenerationEpoch epochA = epoch(PACK_A); + GenerationEpoch epochB = epoch(PACK_B); + GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epochA, CLOCK); + + GenerationActivation pending = store.preparePendingActivation(epochB, 512); + GenerationHistoryStore reopenedPending = GenerationHistoryStore.open(directory, CLOCK); + + assertEquals(epochA, reopenedPending.activeEpoch()); + assertEquals(epochB, reopenedPending.pendingEpoch().orElseThrow()); + assertEquals(2L, pending.activationId()); + assertEquals(Long.valueOf(1L), pending.parentActivationId()); + + reopenedPending.completePendingTransition(pending.activationId(), digest('d'), digest('e')); + reopenedPending.activatePending(pending.activationId()); + GenerationHistoryStore reopenedActive = GenerationHistoryStore.open(directory, CLOCK); + + assertEquals(epochB, reopenedActive.activeEpoch()); + assertEquals(2L, reopenedActive.activeActivation().activationId()); + assertFalse(reopenedActive.pendingActivation().isPresent()); + assertEquals(2, reopenedActive.manifest().epochs().size()); + assertEquals(2, reopenedActive.manifest().activations().size()); + } + + @Test + public void repeatedPreparationDoesNotRewriteOrRenumberPendingActivation() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation"); + GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK); + GenerationEpoch epochB = epoch(PACK_B); + GenerationActivation first = store.preparePendingActivation(epochB, 512); + byte[] firstManifest = Files.readAllBytes(directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME)); + + GenerationActivation repeated = store.preparePendingActivation(epochB, 512); + byte[] repeatedManifest = Files.readAllBytes(directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME)); + + assertEquals(first, repeated); + assertEquals(2L, repeated.activationId()); + assertEquals(2, store.manifest().activations().size()); + assertArrayEquals(firstManifest, repeatedManifest); + } + + @Test + public void manifestMutationsLeaveOnlyTheCanonicalFile() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation"); + GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK); + GenerationActivation pending = store.preparePendingActivation(epoch(PACK_B), 512); + store.completePendingTransition(pending.activationId(), digest('d'), digest('e')); + store.activatePending(pending.activationId()); + + try (Stream entries = Files.list(directory)) { + List names = entries.map(path -> path.getFileName().toString()).toList(); + assertEquals(List.of(GenerationHistoryStore.MANIFEST_FILE_NAME), names); + } + GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK); + assertEquals(store.manifest(), reopened.manifest()); + } + + @Test + public void failsClosedWhenManifestIsMissing() throws Exception { + Path directory = temporaryFolder.newFolder("generation").toPath(); + + assertThrows(NoSuchFileException.class, () -> GenerationHistoryStore.open(directory, CLOCK)); + } + + @Test + public void failsClosedOnMalformedOrUnknownSchema() throws Exception { + Path malformedDirectory = temporaryFolder.newFolder("malformed").toPath(); + Path malformedManifest = malformedDirectory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME); + Files.writeString(malformedManifest, "{not-json", StandardCharsets.UTF_8); + + assertThrows(IOException.class, () -> GenerationHistoryStore.open(malformedDirectory, CLOCK)); + + Path unknownDirectory = temporaryFolder.getRoot().toPath().resolve("unknown-schema"); + GenerationHistoryStore.initialize(unknownDirectory, epoch(PACK_A), CLOCK); + JsonObject unknown = readJson(unknownDirectory); + unknown.addProperty("schemaVersion", GenerationManifest.CURRENT_SCHEMA_VERSION + 1); + writeJson(unknownDirectory, unknown); + + assertThrows(IOException.class, () -> GenerationHistoryStore.open(unknownDirectory, CLOCK)); + } + + @Test + public void failsClosedOnMissingEpochReference() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("missing-epoch"); + GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK); + JsonObject json = readJson(directory); + json.getAsJsonArray("activations") + .get(0) + .getAsJsonObject() + .addProperty("epochId", PACK_B); + writeJson(directory, json); + + assertThrows(IOException.class, () -> GenerationHistoryStore.open(directory, CLOCK)); + } + + @Test + public void failsClosedOnForgedEpochIdentityAndUnknownFields() throws Exception { + Path forgedDirectory = temporaryFolder.getRoot().toPath().resolve("forged"); + GenerationHistoryStore.initialize(forgedDirectory, epoch(PACK_A), CLOCK); + JsonObject forged = readJson(forgedDirectory); + forged.getAsJsonArray("epochs") + .get(0) + .getAsJsonObject() + .addProperty("epochId", PACK_B); + writeJson(forgedDirectory, forged); + + assertThrows(IOException.class, () -> GenerationHistoryStore.open(forgedDirectory, CLOCK)); + + Path unknownFieldDirectory = temporaryFolder.getRoot().toPath().resolve("unknown-field"); + GenerationHistoryStore.initialize(unknownFieldDirectory, epoch(PACK_A), CLOCK); + JsonObject unknownField = readJson(unknownFieldDirectory); + unknownField.addProperty("unexpected", true); + writeJson(unknownFieldDirectory, unknownField); + + assertThrows(IOException.class, () -> GenerationHistoryStore.open(unknownFieldDirectory, CLOCK)); + } + + @Test + public void rejectsInitializationOverExistingManifest() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation"); + GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK); + + assertThrows( + FileAlreadyExistsException.class, + () -> GenerationHistoryStore.initialize(directory, epoch(PACK_B), CLOCK) + ); + GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK); + assertEquals(PACK_A, reopened.activeEpoch().packFingerprint()); + } + + @Test + public void failedActivationDoesNotChangePersistedState() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation"); + GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK); + store.preparePendingActivation(epoch(PACK_B), 512); + + assertThrows(IllegalStateException.class, () -> store.activatePending(99L)); + + GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK); + assertEquals(PACK_A, reopened.activeEpoch().packFingerprint()); + assertTrue(reopened.pendingActivation().isPresent()); + } + + @Test + public void rejectsSymbolicLinkStorageRootAndParent() throws Exception { + Path probe = temporaryFolder.newFolder("history-symlink-probe").toPath(); + Path probeTarget = temporaryFolder.newFolder("history-symlink-probe-target").toPath(); + try { + Files.createSymbolicLink(probe.resolve("link"), probeTarget); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + Path rootContainer = temporaryFolder.newFolder("history-linked-root-container").toPath(); + Path linkedRoot = rootContainer.resolve("generation"); + Files.createSymbolicLink(linkedRoot, temporaryFolder.newFolder("history-linked-root-target").toPath()); + assertThrows( + IOException.class, + () -> GenerationHistoryStore.initialize(linkedRoot, epoch(PACK_A), CLOCK) + ); + + Path parentContainer = temporaryFolder.newFolder("history-linked-parent-container").toPath(); + Path linkedParent = parentContainer.resolve("iris"); + Files.createSymbolicLink(linkedParent, temporaryFolder.newFolder("history-linked-parent-target").toPath()); + assertThrows( + IOException.class, + () -> GenerationHistoryStore.initialize(linkedParent.resolve("generation"), epoch(PACK_A), CLOCK) + ); + } + + @Test + public void concurrentInitializationCannotClobberTheInitialManifest() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("generation-race"); + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit( + () -> initializeAfterBarrier(directory, epoch(PACK_A), barrier) + ); + Future second = executor.submit( + () -> initializeAfterBarrier(directory, epoch(PACK_B), barrier) + ); + + assertTrue(first.get() ^ second.get()); + String fingerprint = GenerationHistoryStore.open(directory, CLOCK).activeEpoch().packFingerprint(); + assertTrue(fingerprint.equals(PACK_A) || fingerprint.equals(PACK_B)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void postRenameSyncFailureReconcilesTheLiveManifest() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("sync-reconcile"); + AtomicInteger syncCount = new AtomicInteger(); + GenerationHistoryStore.DirectorySync directorySync = ignored -> { + if (syncCount.incrementAndGet() > 1) { + throw new IOException("Injected directory sync failure"); + } + }; + GenerationHistoryStore store = GenerationHistoryStore.initialize( + directory, + epoch(PACK_A), + CLOCK, + directorySync + ); + + assertThrows(IOException.class, () -> store.preparePendingActivation(epoch(PACK_B), 512)); + + assertEquals(PACK_B, store.pendingEpoch().orElseThrow().packFingerprint()); + assertEquals(store.manifest(), GenerationHistoryStore.open(directory, CLOCK).manifest()); + } + + @Test + public void failedPostRenameReconciliationPoisonsTheLiveStore() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("sync-poison"); + AtomicInteger syncCount = new AtomicInteger(); + GenerationHistoryStore.DirectorySync directorySync = syncedDirectory -> { + if (syncCount.incrementAndGet() > 1) { + Files.writeString(syncedDirectory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME), "{"); + throw new IOException("Injected unrecoverable directory sync failure"); + } + }; + GenerationHistoryStore store = GenerationHistoryStore.initialize( + directory, + epoch(PACK_A), + CLOCK, + directorySync + ); + + assertThrows(IOException.class, () -> store.preparePendingActivation(epoch(PACK_B), 512)); + assertThrows(IllegalStateException.class, store::activeEpoch); + } + + private static boolean initializeAfterBarrier( + Path directory, + GenerationEpoch epoch, + CyclicBarrier barrier + ) throws Exception { + barrier.await(); + try { + GenerationHistoryStore.initialize(directory, epoch, CLOCK); + return true; + } catch (IOException expected) { + return false; + } + } + + private static GenerationEpoch epoch(String packFingerprint) { + 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, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + return GenerationEpoch.create(new GenerationEpoch.Spec( + packFingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + 42L, + 1, + 1, + 1, + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, + contract, + GenerationRegistryContract.empty() + )); + } + + private static String digest(char value) { + return String.valueOf(value).repeat(64); + } + + private static JsonObject readJson(Path directory) throws IOException { + String content = Files.readString( + directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME), + StandardCharsets.UTF_8 + ); + return JsonParser.parseString(content).getAsJsonObject(); + } + + private static void writeJson(Path directory, JsonObject json) throws IOException { + Files.writeString( + directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME), + json.toString(), + StandardCharsets.UTF_8 + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java new file mode 100644 index 000000000..32976a033 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryTest.java @@ -0,0 +1,789 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.util.nbt.common.mca.MCAFile; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class GenerationHistoryTest { + private static final int SECTOR_BYTES = 4_096; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void upperContentUpdatesPreserveLayoutAndDistinctActivationsAcrossRestarts() throws Exception { + Path world = temporaryFolder.newFolder("upper-update-world").toPath(); + Path packA = createPack("upper-update-a", "alpha"); + Path packB = createPack("upper-update-b", "beta"); + GenerationEpoch.DimensionContract contractA = upperContract(fingerprint(packA)); + GenerationEpoch.DimensionContract contractB = upperContract(fingerprint(packB)); + GenerationHistory history = GenerationHistory.create( + world, packA, fingerprint(packA), 42L, contractA, GenerationRegistryContract.empty()); + String epochA = history.activeEpoch().epochId(); + history.stageUpdate(packB, fingerprint(packB), contractB, GenerationRegistryContract.empty(), 32); + history.promotePending(List.of()); + + GenerationHistory restarted = GenerationHistory.open(world); + assertEquals(contractB, restarted.activeEpoch().dimensionContract()); + restarted.stageUpdate(packA, fingerprint(packA), contractA, GenerationRegistryContract.empty(), 32); + restarted.promotePending(List.of()); + + GenerationHistory returned = GenerationHistory.open(world); + assertEquals(3L, returned.activeActivation().activationId()); + assertEquals(epochA, returned.activeEpoch().epochId()); + assertFalse(returned.paths().activationMantleRoot(1L).equals(returned.paths().activationMantleRoot(3L))); + } + + @Test + public void createPublishesThePackBeforeTheCanonicalManifest() throws Exception { + Path world = temporaryFolder.newFolder("create-world").toPath(); + Path pack = createPack("create-pack", "alpha"); + String fingerprint = fingerprint(pack); + + GenerationHistory history = createHistory(world, pack); + + assertTrue(Files.isRegularFile(history.paths().manifest())); + assertTrue(Files.isRegularFile(history.activePackRoot().resolve("dimensions/main.json"))); + assertEquals(1L, history.activeActivation().activationId()); + assertEquals(1L, history.resolveActivation(40, -70).activationId()); + assertEquals(fingerprint, history.resolveEpoch(40, -70).packFingerprint()); + } + + @Test + public void failedPackPublicationNeverCreatesAManifest() throws Exception { + Path world = temporaryFolder.newFolder("failed-create-world").toPath(); + Path pack = createPack("failed-create-pack", "alpha"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); + + assertThrows( + IOException.class, + () -> GenerationHistory.create( + world, + pack, + "f".repeat(64), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertFalse(Files.exists(paths.manifest())); + } + + @Test + public void openFailsClosedWhenAnyReferencedPackIsMissingOrChanged() throws Exception { + Path missingWorld = temporaryFolder.newFolder("missing-world").toPath(); + Path missingPack = createPack("missing-pack", "alpha"); + GenerationHistory missingHistory = createHistory(missingWorld, missingPack); + Files.delete(missingHistory.activePackRoot().resolve("dimensions/main.json")); + + assertThrows(IOException.class, () -> GenerationHistory.open(missingWorld)); + + Path changedWorld = temporaryFolder.newFolder("changed-world").toPath(); + Path changedPack = createPack("changed-pack", "alpha"); + GenerationHistory changedHistory = createHistory(changedWorld, changedPack); + Files.writeString(changedHistory.activePackRoot().resolve("dimensions/main.json"), "changed"); + + assertThrows(IOException.class, () -> GenerationHistory.open(changedWorld)); + } + + @Test + public void openRejectsUnsupportedGeneratorAndRngVersions() throws Exception { + Path abiWorld = temporaryFolder.newFolder("abi-world").toPath(); + Path abiPack = createPack("abi-pack", "alpha"); + initializeRawHistory(abiWorld, abiPack, 2, GenerationKernelRegistry.standard().current().rngVersion()); + IOException abiFailure = assertThrows(IOException.class, () -> GenerationHistory.open(abiWorld)); + assertTrue(abiFailure.getMessage().contains("generation ABI")); + + Path rngWorld = temporaryFolder.newFolder("rng-world").toPath(); + Path rngPack = createPack("rng-pack", "alpha"); + initializeRawHistory(rngWorld, rngPack, GenerationKernelRegistry.standard().current().generatorAbi(), 2); + IOException rngFailure = assertThrows(IOException.class, () -> GenerationHistory.open(rngWorld)); + assertTrue(rngFailure.getMessage().contains("RNG/seed derivation version")); + } + + @Test + public void promotionFreezesAllocatedChunksBeforePublishingTheNewActivation() throws Exception { + Path world = temporaryFolder.newFolder("promotion-world").toPath(); + Path packA = createPack("promotion-pack-a", "alpha"); + Path packB = createPack("promotion-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{3, 4}, {31, 31}}); + + GenerationActivation pending = stage(history, packB); + GenerationActivation active = history.promotePending(signaturesForChunks(new int[][]{{3, 4}, {31, 31}})); + + assertEquals(2L, pending.activationId()); + assertEquals(pending.activationId(), active.activationId()); + assertEquals(pending.epochId(), active.epochId()); + assertTrue(active.transition().isComplete()); + assertEquals(1L, history.resolveActivation(3, 4).activationId()); + assertEquals(1L, history.resolveActivation(31, 31).activationId()); + assertEquals(2L, history.resolveActivation(32, 31).activationId()); + GenerationBoundary boundary = history.boundary(2L); + assertTrue(boundary.isHistoricalChunk(3, 4)); + assertTrue(boundary.isHistoricalChunk(31, 31)); + assertFalse(boundary.isHistoricalChunk(32, 31)); + assertEquals(history.paths().packRoot(history.activeEpoch().epochId()), history.activePackRoot()); + assertTrue(Files.isRegularFile(history.packRoot(1L).resolve("dimensions/main.json"))); + assertEquals(256, history.transitionPlan(2L).widthBlocks()); + assertEquals(boundary.exposedBlockColumns().size(), history.terrainSignatures(2L).size()); + + GenerationHistory reopened = GenerationHistory.open(world); + assertEquals(1L, reopened.resolveActivation(3, 4).activationId()); + assertEquals(2L, reopened.resolveActivation(32, 31).activationId()); + assertEquals(2, reopened.explicitChunkCount()); + assertEquals(256, reopened.transitionPlan(2L).widthBlocks()); + } + + @Test + public void promotionCapturesSignaturesFromTheDurableBoundary() throws Exception { + Path world = temporaryFolder.newFolder("capture-boundary-world").toPath(); + Path packA = createPack("capture-boundary-pack-a", "alpha"); + Path packB = createPack("capture-boundary-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{3, 4}}); + stage(history, packB); + + GenerationActivation active = history.promotePending(boundary -> { + assertTrue(boundary.isHistoricalChunk(3, 4)); + assertFalse(boundary.isHistoricalChunk(4, 4)); + return GenerationHistoryTest::signature; + }); + + assertEquals(2L, active.activationId()); + assertEquals(history.boundary(2L).identity(), active.transition().boundaryIdentity()); + } + + @Test + public void promotionIsIdempotentForEmptyAndRecoveredWorlds() throws Exception { + Path emptyWorld = temporaryFolder.newFolder("empty-world").toPath(); + Path emptyPackA = createPack("empty-pack-a", "alpha"); + Path emptyPackB = createPack("empty-pack-b", "beta"); + GenerationHistory emptyHistory = createHistory(emptyWorld, emptyPackA); + stage(emptyHistory, emptyPackB); + + assertEquals(2L, emptyHistory.promotePending(List.of()).activationId()); + assertEquals(2L, emptyHistory.promotePending(List.of()).activationId()); + assertEquals(2L, emptyHistory.resolveActivation(0, 0).activationId()); + assertEquals(0, emptyHistory.explicitChunkCount()); + + Path recoveredWorld = temporaryFolder.newFolder("recovered-world").toPath(); + Path recoveredPackA = createPack("recovered-pack-a", "alpha"); + Path recoveredPackB = createPack("recovered-pack-b", "beta"); + GenerationHistory recovered = createHistory(recoveredWorld, recoveredPackA); + stage(recovered, recoveredPackB); + + GenerationHistory reopened = GenerationHistory.open(recoveredWorld); + assertEquals(2L, reopened.promotePending(List.of()).activationId()); + assertEquals(2L, reopened.resolveActivation(8, 9).activationId()); + } + + @Test + public void stageUpdateIsIdempotentAndRejectsIncompatibleCandidates() throws Exception { + Path world = temporaryFolder.newFolder("stage-world").toPath(); + Path packA = createPack("stage-pack-a", "alpha"); + Path packB = createPack("stage-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + + GenerationActivation first = stage(history, packB); + GenerationActivation repeated = stage(history, packB); + + assertEquals(first, repeated); + assertEquals(2, history.manifest().activations().size()); + GenerationEpoch.DimensionContract incompatible = new GenerationEpoch.DimensionContract( + "overworld", + "iris:overworld_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 512, + 512, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + assertThrows( + IllegalArgumentException.class, + () -> history.stageUpdate( + packB, + fingerprint(packB), + incompatible, + GenerationRegistryContract.empty(), + 256 + ) + ); + } + + @Test + public void legacyAdoptionIsDurableExplicitAndIdempotent() throws Exception { + Path world = temporaryFolder.newFolder("legacy-world").toPath(); + Path legacy = Files.createDirectories(world.resolve("iris/pack/dimensions")); + Files.writeString(legacy.resolve("main.json"), "legacy"); + Path legacyRoot = world.resolve("iris/pack"); + Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); + Files.writeString(legacyMantle.resolve("r.0.0.lz4b"), "mantle"); + String fingerprint = fingerprint(legacyRoot); + + GenerationHistory adopted = GenerationHistory.adoptLegacyPack( + world, + fingerprint, + 42L, + contract(), + GenerationRegistryContract.empty() + ); + + assertFalse(Files.exists(legacyRoot)); + assertFalse(Files.exists(legacyMantle)); + assertTrue(Files.isDirectory(adopted.activePackRoot())); + Path activationMantle = adopted.paths().activationMantleRoot(1L); + assertEquals("mantle", Files.readString(activationMantle.resolve("r.0.0.lz4b"))); + assertEquals(1L, adopted.activeActivation().activationId()); + GenerationHistory repeated = GenerationHistory.adoptLegacyPack( + world, + fingerprint, + 42L, + contract(), + GenerationRegistryContract.empty() + ); + assertEquals(adopted.activeEpoch(), repeated.activeEpoch()); + assertEquals("mantle", Files.readString(repeated.paths().activationMantleRoot(1L) + .resolve("r.0.0.lz4b"))); + } + + @Test + public void legacyMantleAdoptionFailsClosedOnDualRoots() throws Exception { + Path world = temporaryFolder.newFolder("legacy-dual-mantle-world").toPath(); + Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); + Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology")); + Files.writeString(legacyMantle.resolve("legacy.bin"), "legacy"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); + Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); + Files.writeString(activationMantle.resolve("activation.bin"), "activation"); + + IOException failure = assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + world, + fingerprint(legacyPack), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + + assertTrue(failure.getMessage().contains("mantle roots conflict")); + assertEquals("legacy", Files.readString(legacyMantle.resolve("legacy.bin"))); + assertEquals("activation", Files.readString(activationMantle.resolve("activation.bin"))); + } + + @Test + public void legacyMantleAdoptionRejectsRootAliases() throws Exception { + Path world = temporaryFolder.newFolder("legacy-aliased-mantle-world").toPath(); + Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy"); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world); + Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L)); + Path alias = paths.legacyMantleRoot(); + try { + Files.createSymbolicLink(alias, activationMantle); + } catch (UnsupportedOperationException exception) { + return; + } + + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + world, + fingerprint(legacyPack), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isSymbolicLink(alias)); + assertTrue(Files.isDirectory(activationMantle)); + } + + @Test + public void legacyAdoptionNeverDeletesMismatchedOrUnsafeInput() throws Exception { + Path mismatchWorld = temporaryFolder.newFolder("legacy-mismatch-world").toPath(); + Path mismatchRoot = Files.createDirectories(mismatchWorld.resolve("iris/pack/dimensions")).getParent(); + Files.writeString(mismatchRoot.resolve("dimensions/main.json"), "legacy"); + + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + mismatchWorld, + "f".repeat(64), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isDirectory(mismatchRoot)); + assertFalse(Files.exists(GenerationHistoryPaths.forDimension(mismatchWorld).manifest())); + + Path linkedWorld = temporaryFolder.newFolder("legacy-linked-world").toPath(); + Files.createDirectories(linkedWorld.resolve("iris")); + Path outside = temporaryFolder.newFolder("legacy-outside").toPath(); + Path link = linkedWorld.resolve("iris/pack"); + try { + Files.createSymbolicLink(link, outside); + } catch (UnsupportedOperationException exception) { + return; + } + assertThrows( + IOException.class, + () -> GenerationHistory.adoptLegacyPack( + linkedWorld, + "f".repeat(64), + 42L, + contract(), + GenerationRegistryContract.empty() + ) + ); + assertTrue(Files.isDirectory(outside)); + assertTrue(Files.isSymbolicLink(link)); + } + + @Test + public void openFailsClosedOnDanglingOwnershipAndPartialHistory() throws Exception { + Path danglingWorld = temporaryFolder.newFolder("dangling-world").toPath(); + Path pack = createPack("dangling-pack", "alpha"); + GenerationHistory history = createHistory(danglingWorld, pack); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + ownership.assign(1, 2, 99L); + ownership.persist(); + + assertThrows(IOException.class, () -> GenerationHistory.open(danglingWorld)); + + Path absentWorld = temporaryFolder.newFolder("absent-world").toPath(); + Optional absent = GenerationHistory.openIfPresent(absentWorld); + assertTrue(absent.isEmpty()); + + Path partialWorld = temporaryFolder.newFolder("partial-world").toPath(); + Files.createDirectories(GenerationHistoryPaths.forDimension(partialWorld).generationRoot()); + assertThrows(NoSuchFileException.class, () -> GenerationHistory.openIfPresent(partialWorld)); + } + + @Test + public void stagingTheActiveEpochIsANoOp() throws Exception { + Path world = temporaryFolder.newFolder("same-epoch-world").toPath(); + Path pack = createPack("same-epoch-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + + GenerationActivation activation = stage(history, pack); + + assertEquals(history.activeActivation(), activation); + assertTrue(history.pendingActivation().isEmpty()); + assertEquals(1, history.manifest().activations().size()); + } + + @Test + public void returningToAnEarlierEpochCreatesANewActivation() throws Exception { + Path world = temporaryFolder.newFolder("return-world").toPath(); + Path packA = createPack("return-pack-a", "alpha"); + Path packB = createPack("return-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + String epochA = history.activeEpoch().epochId(); + + stage(history, packB); + history.promotePending(List.of()); + GenerationActivation activationA2 = stage(history, packA); + history.promotePending(List.of()); + + assertEquals(3L, activationA2.activationId()); + assertEquals(epochA, activationA2.epochId()); + assertEquals(2, history.manifest().epochs().size()); + assertEquals(3, history.manifest().activations().size()); + } + + @Test + public void retainedAbiRegistryReopensEveryHistoricalEpoch() throws Exception { + Path world = temporaryFolder.newFolder("retained-abi-world").toPath(); + Path packA = createPack("retained-abi-pack-a", "alpha"); + Path packB = createPack("retained-abi-pack-b", "beta"); + GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1); + GenerationKernelRegistry kernels = new GenerationKernelRegistry( + versionTwo, + List.of( + new GenerationKernelRegistry.Kernel( + 1, + "1".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("History-only kernel factory was invoked."); + } + ) + ), + new GenerationKernelRegistry.Kernel( + 2, + "2".repeat(64), + Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), + (engine, transitionPlan) -> { + throw new AssertionError("History-only kernel factory was invoked."); + } + ) + ) + ) + ); + GenerationHistory history = GenerationHistory.create( + world, + packA, + fingerprint(packA), + 42L, + contract(), + GenerationRegistryContract.empty(), + versionOne, + kernels + ); + + history.stageUpdate( + packB, + fingerprint(packB), + contract(), + GenerationRegistryContract.empty(), + 256, + versionTwo + ); + history.promotePending(List.of()); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + GenerationHistory reopened = GenerationHistory.open(world, kernels); + assertEquals(2, reopened.activeEpoch().generatorAbi()); + assertEquals(1, reopened.manifest().activation(1L).orElseThrow().activationId()); + } + + @Test + public void openRejectsWorldSeedMismatch() throws Exception { + Path world = temporaryFolder.newFolder("seed-world").toPath(); + Path pack = createPack("seed-pack", "alpha"); + createHistory(world, pack); + + assertThrows(IOException.class, () -> GenerationHistory.open(world, 43L)); + assertEquals(42L, GenerationHistory.open(world, 42L).activeEpoch().worldSeed()); + } + + @Test + public void openFailsClosedWhenAnOwnershipShardDisappears() throws Exception { + Path world = temporaryFolder.newFolder("missing-ownership-world").toPath(); + Path packA = createPack("missing-ownership-pack-a", "alpha"); + Path packB = createPack("missing-ownership-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{1, 2}}); + stage(history, packB); + history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); + Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void pendingCutoverResumesAfterPartialOutgoingOwnershipPublication() throws Exception { + Path world = temporaryFolder.newFolder("partial-cutover-world").toPath(); + Path packA = createPack("partial-cutover-pack-a", "alpha"); + Path packB = createPack("partial-cutover-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + int[][] chunks = {{1, 2}, {3, 4}}; + writeRegion(region, chunks); + stage(history, packB); + + ChunkGenerationOwnership partial = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + assertTrue(partial.assign(1, 2, 1L)); + partial.persist(); + + GenerationHistory recovered = GenerationHistory.open(world); + assertEquals(1, recovered.explicitChunkCount()); + assertEquals(2L, recovered.promotePending(signaturesForChunks(chunks)).activationId()); + assertEquals(2, GenerationHistory.open(world).explicitChunkCount()); + } + + @Test + public void pendingCutoverStillRejectsMissingOlderOwnership() throws Exception { + Path world = temporaryFolder.newFolder("pending-missing-old-world").toPath(); + Path packA = createPack("pending-missing-old-pack-a", "alpha"); + Path packB = createPack("pending-missing-old-pack-b", "beta"); + Path packC = createPack("pending-missing-old-pack-c", "gamma"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{1, 2}}); + stage(history, packB); + history.promotePending(signaturesForChunks(new int[][]{{1, 2}})); + stage(history, packC); + Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0))); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void pendingCutoverRejectsOwnershipForUngeneratedChunks() throws Exception { + Path world = temporaryFolder.newFolder("pending-forged-world").toPath(); + Path packA = createPack("pending-forged-pack-a", "alpha"); + Path packB = createPack("pending-forged-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + stage(history, packB); + ChunkGenerationOwnership forged = ChunkGenerationOwnership.load(history.paths().ownershipRoot()); + assertTrue(forged.assign(9, 9, 1L)); + forged.persist(); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void sealedGenerationClaimSurvivesCrashBeforeChunkAllocationAndJoinsCutoverOwnership() throws Exception { + Path world = temporaryFolder.newFolder("semantic-claim-cutover-world").toPath(); + Path packA = createPack("semantic-claim-cutover-pack-a", "alpha"); + Path packB = createPack("semantic-claim-cutover-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + GenerationHistory.GenerationStage generation = history.openStage(9, 9); + ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(9, 9, 1L) + .addSurfaceBiome("iris:forest") + .seal() + .build(); + + assertTrue(history.claimGeneratedSemantics(generation, claim)); + assertFalse(history.claimGeneratedSemantics(generation, claim)); + generation.close(); + assertThrows(IllegalStateException.class, () -> history.claimGeneratedSemantics(generation, claim)); + assertFalse(Files.exists(world.resolve("region"))); + stage(history, packB); + + Path recoveredWorld = temporaryFolder.newFolder("restored-semantic-claim-world").toPath(); + try (Stream entries = Files.walk(world)) { + for (Path source : entries.toList()) { + Path target = recoveredWorld.resolve(world.relativize(source)); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.copy(source, target); + } + } + } + GenerationHistory recovered = GenerationHistory.open(recoveredWorld); + recovered.promotePending(signaturesForChunks(new int[][]{{9, 9}})); + + assertEquals(1L, recovered.resolveActivation(9, 9).activationId()); + assertEquals(2L, recovered.resolveActivation(10, 9).activationId()); + assertEquals(1, recovered.explicitChunkCount()); + GenerationHistory reopened = GenerationHistory.open(recoveredWorld); + assertEquals(Optional.of(claim), reopened.semantics(9, 9)); + assertEquals(1L, reopened.resolveActivation(9, 9).activationId()); + } + + @Test + public void openFailsClosedOnSemanticActivationMismatch() throws Exception { + Path world = temporaryFolder.newFolder("semantic-mismatch-world").toPath(); + Path packA = createPack("semantic-mismatch-pack-a", "alpha"); + Path packB = createPack("semantic-mismatch-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + stage(history, packB); + history.promotePending(List.of()); + GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world); + index.recordAndPersist(ChunkGenerationSemantics.builder(7, 8, 1L) + .addSurfaceBiome("iris:old") + .seal() + .build()); + + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + + @Test + public void promotionIsRejectedAfterGenerationAdmissionOpens() throws Exception { + Path world = temporaryFolder.newFolder("admission-world").toPath(); + Path packA = createPack("admission-pack-a", "alpha"); + Path packB = createPack("admission-pack-b", "beta"); + GenerationHistory history = createHistory(world, packA); + stage(history, packB); + GenerationHistory.GenerationStage generationStage = history.openStage(2, 3); + assertEquals(1L, generationStage.activation().activationId()); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> history.promotePending(List.of()) + ); + assertTrue(failure.getMessage().contains("before generation admission opens")); + generationStage.close(); + assertThrows(IllegalStateException.class, () -> history.promotePending(List.of())); + } + + @Test + public void openFailsClosedOnDeletedOrCorruptTransitionSnapshots() throws Exception { + Path deletedWorld = temporaryFolder.newFolder("deleted-snapshot-world").toPath(); + Path deletedPackA = createPack("deleted-snapshot-pack-a", "alpha"); + Path deletedPackB = createPack("deleted-snapshot-pack-b", "beta"); + GenerationHistory deletedHistory = createHistory(deletedWorld, deletedPackA); + stage(deletedHistory, deletedPackB); + deletedHistory.promotePending(List.of()); + Files.delete(new TerrainBoundarySignatureStore(deletedWorld).snapshotPath(2L)); + + assertThrows(IOException.class, () -> GenerationHistory.open(deletedWorld)); + + Path corruptWorld = temporaryFolder.newFolder("corrupt-snapshot-world").toPath(); + Path corruptPackA = createPack("corrupt-snapshot-pack-a", "alpha"); + Path corruptPackB = createPack("corrupt-snapshot-pack-b", "beta"); + GenerationHistory corruptHistory = createHistory(corruptWorld, corruptPackA); + stage(corruptHistory, corruptPackB); + corruptHistory.promotePending(List.of()); + Files.write(new GenerationBoundaryStore(corruptWorld).snapshotPath(2L), new byte[]{1, 2, 3}); + + assertThrows(IOException.class, () -> GenerationHistory.open(corruptWorld)); + } + + private void initializeRawHistory( + Path world, + Path pack, + int generatorAbi, + int rngVersion + ) throws IOException { + String fingerprint = fingerprint(pack); + GenerationEpoch epoch = GenerationEpoch.create(new GenerationEpoch.Spec( + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + 42L, + GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION, + generatorAbi, + rngVersion, + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, + contract(), + GenerationRegistryContract.empty() + )); + GenerationPackRepository repository = new GenerationPackRepository(world); + repository.publish( + epoch.epochId(), + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + pack + ); + GenerationSemanticIndex.initialize(world); + GenerationHistoryStore.initialize(repository.generationRoot(), epoch); + } + + private Path createPack(String name, String content) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), content); + return pack; + } + + private static String fingerprint(Path pack) throws IOException { + return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + } + + private static GenerationEpoch.DimensionContract upperContract(String packFingerprint) { + GenerationEpoch.DimensionContract base = contract(); + return new GenerationEpoch.DimensionContract( + base.dimensionKey(), base.dimensionTypeKey(), base.environment(), base.generationMode(), + base.internalFluidHeight(), base.minHeight(), base.height(), base.logicalHeight(), + base.coordinateScale(), true, "ceiling", 32, packFingerprint, + base.dimensionTypeFingerprintSchema(), base.dimensionTypeFingerprint()); + } + + private static GenerationEpoch.DimensionContract contract() { + return new GenerationEpoch.DimensionContract( + "overworld", + "iris:overworld_type", + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + } + + private static GenerationHistory createHistory(Path world, Path pack) throws IOException { + return GenerationHistory.create( + world, + pack, + fingerprint(pack), + 42L, + contract(), + GenerationRegistryContract.empty() + ); + } + + private static GenerationActivation stage(GenerationHistory history, Path pack) throws IOException { + return history.stageUpdate( + pack, + fingerprint(pack), + contract(), + GenerationRegistryContract.empty(), + 256 + ); + } + + private static List signaturesForChunks(int[][] chunks) { + ArrayList coordinates = new ArrayList<>(chunks.length); + for (int[] chunk : chunks) { + coordinates.add(new GenerationBoundary.ChunkCoordinate(chunk[0], chunk[1])); + } + GenerationBoundary boundary = GenerationBoundary.freeze("test-boundary", coordinates); + ArrayList signatures = new ArrayList<>(boundary.exposedBlockColumns().size()); + for (GenerationBoundary.BlockColumn column : boundary.exposedBlockColumns()) { + signatures.add(signature(column.blockX(), column.blockZ())); + } + return List.copyOf(signatures); + } + + private static TerrainBoundarySignature signature(int blockX, int blockZ) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 64, 2), + new TerrainBoundarySignature.BiomeEncoding( + List.of("iris:test"), + new short[]{0, 0} + ) + ) + ); + } + + private static void writeRegion(Path file, int[][] chunks) throws IOException { + try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) { + output.setLength((long) (2 + chunks.length) * SECTOR_BYTES); + for (int index = 0; index < chunks.length; index++) { + int chunkIndex = MCAFile.getChunkIndex(chunks[index][0], chunks[index][1]); + output.seek((long) chunkIndex * Integer.BYTES); + output.writeInt((2 + index) << Byte.SIZE | 1); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelCatalogTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelCatalogTest.java new file mode 100644 index 000000000..31981f692 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelCatalogTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class GenerationKernelCatalogTest { + @Test + public void appendsNewRegistrationsWithoutChangingAnExistingSeal() throws Exception { + GenerationKernelCatalog.Catalog catalog = parse("current\t2\t3\t4\nkernel\t1\nkernel\t2\n"); + assertEquals(new GenerationKernelRegistry.Version(2, 3, 4), catalog.current()); + assertEquals(List.of(1, 2), catalog.abis()); + } + + @Test + public void rejectsMissingCurrentDuplicateAndExecutableCatalogEntries() { + assertThrows(IOException.class, () -> parse("current\t2\t1\t1\nkernel\t1\n")); + assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t1\nkernel\t1\n")); + assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t1\tfactory.Override\n")); + assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t01\n")); + } + + @Test + public void installedCatalogUsesOnlyItsSealedFactoryAndAlgorithms() throws Exception { + GenerationKernelRegistry registry = GenerationKernelCatalog.load(); + GenerationKernelSourceSeal.Descriptor seal = GenerationKernelSourceSeal.load(1); + assertEquals("art.arcane.iris.engine.history.GenerationKernelV1", seal.factoryClass()); + assertEquals(seal.fingerprint(), registry.select(new GenerationKernelRegistry.Version(1, 1, 1)) + .implementationFingerprint()); + assertThrows(IOException.class, () -> registry.select(new GenerationKernelRegistry.Version(1, 2, 1))); + } + + private static GenerationKernelCatalog.Catalog parse(String entries) throws IOException { + return GenerationKernelCatalog.parse(("iris-generation-kernel-catalog-v1\n" + entries) + .getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java new file mode 100644 index 000000000..9aca7794a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.SeedManager; +import org.junit.Test; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; + +public class GenerationKernelRegistryTest { + @Test + public void selectsAnExactImplementationFingerprint() throws Exception { + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2); + String fingerprint = "a".repeat(64); + GenerationKernelRegistry registry = registry(version, fingerprint); + + GenerationKernelRegistry.RuntimeKernel runtime = registry.select(version); + + assertEquals(fingerprint, runtime.implementationFingerprint()); + assertEquals(fingerprint, registry.requireSupported(version, fingerprint).implementationFingerprint()); + } + + @Test + public void algorithmTupleSelectsItsOwnSeedFactory() throws Exception { + SeedManager first = mock(SeedManager.class); + SeedManager second = mock(SeedManager.class); + GenerationKernelRegistry.Version original = new GenerationKernelRegistry.Version(1, 1, 1); + GenerationKernelRegistry.Version changed = new GenerationKernelRegistry.Version(1, 2, 3); + GenerationKernelRegistry registry = new GenerationKernelRegistry(changed, Set.of( + new GenerationKernelRegistry.Kernel(1, "a".repeat(64), Map.of( + new GenerationKernelRegistry.AlgorithmVersion(1, 1), seedFactory(first), + new GenerationKernelRegistry.AlgorithmVersion(2, 3), seedFactory(second))))); + + assertSame(first, registry.select(original).createSeedManager(991L)); + assertSame(second, registry.select(changed).createSeedManager(991L)); + } + + @Test + public void missingSeedFactoryFailsClosed() throws Exception { + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2); + assertThrows(UnsupportedOperationException.class, + () -> registry(version, "a".repeat(64)).select(version).createSeedManager(991L)); + } + + @Test + public void rejectsAnEpochWhoseImplementationChangedWithoutAVersionChange() { + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2); + GenerationKernelRegistry registry = registry(version, "a".repeat(64)); + + assertThrows(IOException.class, () -> registry.requireSupported(version, "b".repeat(64))); + } + + @Test + public void rejectsMalformedImplementationFingerprints() { + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2); + + assertThrows(IllegalArgumentException.class, () -> registry(version, "not-a-sha256")); + } + + private static GenerationKernelRegistry registry( + GenerationKernelRegistry.Version version, + String fingerprint + ) { + return new GenerationKernelRegistry( + version, + Set.of(new GenerationKernelRegistry.Kernel( + version.generatorAbi(), + fingerprint, + Map.of( + new GenerationKernelRegistry.AlgorithmVersion( + version.rngVersion(), + version.seedDerivationVersion() + ), + (engine, transitionPlan) -> mock(IrisComplex.class) + ) + )) + ); + } + + private static GenerationKernelRegistry.RuntimeFactory seedFactory(SeedManager seedManager) { + return new GenerationKernelRegistry.RuntimeFactory() { + @Override + public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) { + return mock(IrisComplex.class); + } + + @Override + public SeedManager createSeedManager(long worldSeed) { + assertEquals(991L, worldSeed); + return seedManager; + } + }; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelSourceSealTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelSourceSealTest.java new file mode 100644 index 000000000..07fbbb256 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelSourceSealTest.java @@ -0,0 +1,56 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +public class GenerationKernelSourceSealTest { + @Test + public void fingerprintUsesTheCompleteManifest() throws Exception { + byte[] initial = seal("a".repeat(64)); + assertEquals(GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial), + GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial)); + assertNotEquals(GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial), + GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", seal("b".repeat(64)))); + } + + @Test + public void rejectsMismatchedAbiFactoryAndMalformedHashes() { + assertThrows(IOException.class, + () -> GenerationKernelSourceSeal.fingerprint(2, "example.KernelV1", seal("a".repeat(64)))); + assertThrows(IOException.class, + () -> GenerationKernelSourceSeal.fingerprint(1, "example.OtherKernel", seal("a".repeat(64)))); + assertThrows(IOException.class, + () -> GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", seal("not-a-hash"))); + assertThrows(IOException.class, + () -> GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", new byte[0])); + } + + @Test + public void rejectsDuplicateAndUnsortedEntries() { + String source = new String(seal("a".repeat(64)), StandardCharsets.UTF_8); + String duplicate = source + "dependency\tmath\t" + "a".repeat(64) + "\n"; + assertThrows(IOException.class, () -> GenerationKernelSourceSeal.fingerprint(1, + "example.KernelV1", duplicate.getBytes(StandardCharsets.UTF_8))); + String unordered = source + "scope\tother\n"; + assertThrows(IOException.class, () -> GenerationKernelSourceSeal.fingerprint(1, + "example.KernelV1", unordered.getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void standardKernelUsesThePackagedSourceSeal() { + assertEquals(GenerationKernelSourceSeal.requireFingerprint(1, GenerationKernelV1.class), + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT); + } + + private static byte[] seal(String fingerprint) { + return ("iris-generation-kernel-seal-v1\nabi\t1\nfactory\texample.KernelV1\n" + + "algorithm\t1\t1\nscope\tgeneration\nsource\tgeneration/Noise.java\t" + fingerprint + + "\ndependency\tmath\t" + "c".repeat(64) + "\n").getBytes(StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationManifestTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationManifestTest.java new file mode 100644 index 000000000..dff25e3ea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationManifestTest.java @@ -0,0 +1,136 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationManifestTest { + private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + @Test + public void keepsEpochsSeparateFromMonotonicActivations() { + GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a")); + GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a")); + GenerationManifest initial = GenerationManifest.initial(epochA, 10L); + GenerationManifest pendingB = initial.preparePending(epochB, 20L, 256); + GenerationActivation activationB = pendingB.pendingActivation().orElseThrow(); + GenerationManifest activeB = complete(pendingB, activationB.activationId()).activatePending( + activationB.activationId() + ); + GenerationManifest pendingA = activeB.preparePending(epochA, 30L, 256); + GenerationActivation activationA2 = pendingA.pendingActivation().orElseThrow(); + + assertEquals(3L, activationA2.activationId()); + assertEquals(Long.valueOf(2L), activationA2.parentActivationId()); + assertEquals(epochA.epochId(), activationA2.epochId()); + assertEquals(2, pendingA.epochs().size()); + assertEquals(List.of(1L, 2L, 3L), pendingA.activations().stream() + .map(GenerationActivation::activationId) + .toList()); + assertEquals(epochB, pendingA.activeEpoch()); + } + + @Test + public void repeatedPendingPreparationIsIdempotent() { + GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a")); + GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a")); + GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256); + + GenerationManifest repeated = pending.preparePending(epochB, 99L, 256); + + assertSame(pending, repeated); + assertEquals(2, repeated.activations().size()); + assertEquals(20L, repeated.pendingActivation().orElseThrow().createdAtEpochMillis()); + } + + @Test + public void refusesCompetingPendingActivation() { + GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a")); + GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a")); + GenerationEpoch epochC = epoch( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + contract("iris:type_a") + ); + GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256); + + assertThrows(IllegalStateException.class, () -> pending.preparePending(epochC, 30L, 256)); + } + + @Test + public void refusesImmutableDimensionContractChange() { + GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a")); + GenerationEpoch changedType = epoch(PACK_B, contract("iris:type_b")); + GenerationManifest manifest = GenerationManifest.initial(epochA, 10L); + + assertThrows(IllegalArgumentException.class, () -> manifest.preparePending(changedType, 20L, 256)); + assertFalse(manifest.pendingActivation().isPresent()); + assertEquals(epochA, manifest.activeEpoch()); + } + + @Test + public void activatesOnlyTheExpectedPendingRecord() { + GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a")); + GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a")); + GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256); + + assertThrows(IllegalStateException.class, () -> pending.activatePending(3L)); + assertThrows(IllegalArgumentException.class, () -> pending.activatePending(2L)); + + GenerationManifest active = complete(pending, 2L).activatePending(2L); + assertEquals(2L, active.activeActivation().activationId()); + assertEquals(epochB, active.activeEpoch()); + assertTrue(active.pendingActivation().isEmpty()); + } + + private static GenerationEpoch epoch( + String packFingerprint, + GenerationEpoch.DimensionContract contract + ) { + return GenerationEpoch.create(new GenerationEpoch.Spec( + packFingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + 42L, + GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION, + 1, + 1, + GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, + contract, + GenerationRegistryContract.empty() + )); + } + + private static GenerationEpoch.DimensionContract contract(String typeKey) { + return new GenerationEpoch.DimensionContract( + "overworld", + typeKey, + "NORMAL", + "OVERWORLD", + 127, + -64, + 384, + 384, + 1D, + false, + "none", + 0, + "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + } + + private static GenerationManifest complete(GenerationManifest manifest, long activationId) { + return manifest.completePendingTransition( + activationId, + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java new file mode 100644 index 000000000..d198655e2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackFingerprintTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.engine.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +public class GenerationPackFingerprintTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void versionOneMatchesItsGoldenContentAddress() throws Exception { + Path pack = temporaryFolder.newFolder("golden-pack").toPath(); + Files.createDirectories(pack.resolve("objects")); + Files.writeString(pack.resolve("objects/tree.json"), "oak"); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), "{}"); + Files.createDirectories(pack.resolve(".git")); + Files.writeString(pack.resolve(".git/config"), "ignored"); + Files.writeString(pack.resolve("workspace.code-workspace"), "ignored"); + + String fingerprint = GenerationPackFingerprint.compute( + pack, + GenerationPackFingerprint.CURRENT_VERSION + ); + + assertEquals("ad7d39c2b95bd3001248778911ce6bbd6b4cbdcece28145838c2b923a0bf73b3", fingerprint); + } + + @Test + public void contentChangesProduceDifferentAddresses() throws Exception { + Path pack = temporaryFolder.newFolder("changed-pack").toPath(); + Files.writeString(pack.resolve("dimension.json"), "first"); + String first = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + Files.writeString(pack.resolve("dimension.json"), "second"); + + String second = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + + assertNotEquals(first, second); + } + + @Test + public void unsupportedVersionsAndSymbolicLinksFailClosed() throws Exception { + Path pack = temporaryFolder.newFolder("unsafe-pack").toPath(); + assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 2)); + + Path target = temporaryFolder.newFile("outside.json").toPath(); + try { + Files.createSymbolicLink(pack.resolve("linked.json"), target); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + assertThrows( + IOException.class, + () -> GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java new file mode 100644 index 000000000..21415ca0e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationPackRepositoryTest.java @@ -0,0 +1,233 @@ +package art.arcane.iris.engine.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationPackRepositoryTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesAnImmutableFilteredPack() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("world").toPath(); + Path source = createPack("source", "{}"); + Files.createDirectories(source.resolve(".git")); + Files.writeString(source.resolve(".git/config"), "local"); + Files.writeString(source.resolve("pack.code-workspace"), "{}"); + String fingerprint = fingerprint(source); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + String epochId = digest('a'); + + Path published = repository.publish(epochId, fingerprint, GenerationPackFingerprint.CURRENT_VERSION, source); + + assertTrue(Files.isRegularFile(published.resolve("dimensions/main.json"))); + assertFalse(Files.exists(published.resolve(".git"))); + assertFalse(Files.exists(published.resolve("pack.code-workspace"))); + assertEquals(published, repository.publish( + epochId, + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + source + )); + } + + @Test + public void rejectsMutationOfAnExistingEpoch() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("mutation-world").toPath(); + Path source = createPack("first", "{}"); + String fingerprint = fingerprint(source); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + String epochId = digest('b'); + Path published = repository.publish(epochId, fingerprint, GenerationPackFingerprint.CURRENT_VERSION, source); + Files.writeString(published.resolve("dimensions/main.json"), "{\"changed\":true}"); + + assertThrows(IOException.class, () -> repository.requireExactPack( + epochId, + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION + )); + assertThrows(IOException.class, () -> repository.publish( + epochId, + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + source + )); + } + + @Test + public void rejectsNestedSymbolicLinks() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("linked-world").toPath(); + Path source = createPack("linked", "{}"); + Path outside = temporaryFolder.newFile("outside.json").toPath(); + try { + Files.createSymbolicLink(source.resolve("dimensions/link.json"), outside); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + + assertThrows(IOException.class, () -> repository.publish( + digest('c'), + digest('d'), + GenerationPackFingerprint.CURRENT_VERSION, + source)); + } + + @Test + public void rejectsInvalidEpochIdentity() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("invalid-world").toPath(); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + + assertThrows(IllegalArgumentException.class, () -> repository.packRoot("not-a-digest")); + assertThrows(IllegalArgumentException.class, () -> repository.packRoot(digest('A'))); + } + + @Test + public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception { + Path probe = temporaryFolder.newFolder("symlink-probe").toPath(); + Path probeTarget = temporaryFolder.newFolder("symlink-probe-target").toPath(); + try { + Files.createSymbolicLink(probe.resolve("link"), probeTarget); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + for (String component : new String[]{"dimension", "iris", "generation", "epochs", "epoch", "pack"}) { + assertStorageLinkRejected(component); + } + } + + @Test + public void concurrentRepositoryInstancesCannotClobberAnEpoch() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("race-world").toPath(); + Path firstSource = createPack("race-first", "{\"winner\":1}"); + Path secondSource = createPack("race-second", "{\"winner\":2}"); + String firstFingerprint = fingerprint(firstSource); + String secondFingerprint = fingerprint(secondSource); + String epochId = digest('e'); + GenerationPackRepository firstRepository = new GenerationPackRepository(dimensionRoot); + GenerationPackRepository secondRepository = new GenerationPackRepository(dimensionRoot); + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> publishAfterBarrier( + firstRepository, + epochId, + firstFingerprint, + firstSource, + barrier + )); + Future second = executor.submit(() -> publishAfterBarrier( + secondRepository, + epochId, + secondFingerprint, + secondSource, + barrier + )); + + assertTrue(first.get() ^ second.get()); + String content = Files.readString(firstRepository.packRoot(epochId).resolve("dimensions/main.json")); + assertTrue(content.equals("{\"winner\":1}") || content.equals("{\"winner\":2}")); + } finally { + executor.shutdownNow(); + } + } + + private void assertStorageLinkRejected(String component) throws Exception { + Path container = temporaryFolder.newFolder("linked-storage-" + component).toPath(); + Path dimensionRoot = container.resolve("dimension"); + Path external = temporaryFolder.newFolder("linked-storage-target-" + component).toPath(); + String epochId = digest('f'); + if (component.equals("dimension")) { + Files.createSymbolicLink(dimensionRoot, external); + } else { + Files.createDirectory(dimensionRoot); + Path iris = dimensionRoot.resolve("iris"); + if (component.equals("iris")) { + Files.createSymbolicLink(iris, external); + } else { + Files.createDirectory(iris); + Path generation = iris.resolve("generation"); + if (component.equals("generation")) { + Files.createSymbolicLink(generation, external); + } else { + Files.createDirectory(generation); + Path epochs = generation.resolve("epochs"); + if (component.equals("epochs")) { + Files.createSymbolicLink(epochs, external); + } else { + Files.createDirectory(epochs); + Path epoch = epochs.resolve(epochId); + if (component.equals("epoch")) { + Files.createSymbolicLink(epoch, external); + } else { + Files.createDirectory(epoch); + Files.createSymbolicLink(epoch.resolve("pack"), external); + } + } + } + } + } + Path source = createPack("linked-storage-source-" + component, "{}"); + String fingerprint = fingerprint(source); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + + assertThrows(IOException.class, () -> repository.publish( + epochId, + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + source + )); + } + + private static boolean publishAfterBarrier( + GenerationPackRepository repository, + String epochId, + String fingerprint, + Path source, + CyclicBarrier barrier + ) throws Exception { + barrier.await(); + try { + repository.publish( + epochId, + fingerprint, + GenerationPackFingerprint.CURRENT_VERSION, + source + ); + return true; + } catch (IOException expected) { + return false; + } + } + + private Path createPack(String name, String dimensionJson) throws IOException { + Path source = temporaryFolder.newFolder(name).toPath(); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/main.json"), dimensionJson); + return source; + } + + private static String digest(char value) { + return String.valueOf(value).repeat(64); + } + + private static String fingerprint(Path source) throws IOException { + return GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractFactoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractFactoryTest.java new file mode 100644 index 000000000..0cadc095c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractFactoryTest.java @@ -0,0 +1,810 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.KeyStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisBiomeCustomSpawn; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class GenerationRegistryContractFactoryTest { + private static final String HASH_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String HASH_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + @Rule + public final TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void capturesGeneratedAndReferencedPhysicalDefinitions() throws Exception { + File pack = temporary.newFolder("pack"); + Files.writeString( + pack.toPath().resolve("content.json"), + "{\"block\":\"minecraft:stone[axis=y]\",\"type\":\"minecraft:zombie\"}", + StandardCharsets.UTF_8 + ); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + ContentGate contentGate = mock(ContentGate.class); + when(data.getContentGate()).thenReturn(contentGate); + IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist")); + IDataFixer fixer = DataVersion.getLatest().get(); + doReturn("{\"effects\":{},\"temperature\":0.5}") + .when(custom).generateJson(fixer, contentGate); + IrisNativeStructure nativeStructure = new IrisNativeStructure().setStructure("minecraft:village_plains"); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setNativeStructures(new KList<>(nativeStructure)); + IrisBiome biome = new IrisBiome() + .setDerivative("minecraft:plains") + .setVanillaDerivative("minecraft:forest") + .setBiomeScatter(new KList<>("minecraft:desert", "overworld:mist")) + .setBiomeSkyScatter(new KList<>("minecraft:taiga")) + .setCustomDerivitives(new KList<>(custom)); + biome.setLoadKey("surface"); + IrisDimension dimension = spy(new IrisDimension() + .setStructures(new KList<>(placement))); + dimension.setLoadKey("overworld"); + stubDimensions(data, dimension); + doReturn(new KList<>(biome)).when(dimension).getReachableBiomes(any()); + doReturn(new KList<>()).when(dimension).getAllRegions(any()); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(new KList<>("minecraft:stone", "minecraft:dirt")); + when(registries.entityKeys()).thenReturn(new KList<>("minecraft:zombie", "minecraft:pig")); + FakeGenerationRegistry generationRegistry = new FakeGenerationRegistry("test-runtime"); + String packFingerprint = GenerationPackFingerprint.compute( + pack.toPath(), + GenerationPackFingerprint.CURRENT_VERSION + ); + + GenerationRegistryContract contract = GenerationRegistryContractFactory.create( + data, + dimension, + packFingerprint, + fixer, + registries, + generationRegistry + ); + + Set keys = contract.definitions().keySet(); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, "test:overworld_type"))); + String customFingerprint = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity( + GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(custom), + "overworld", + "mist" + ); + assertTrue(keys.contains(key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "test:biomes/" + customFingerprint + ))); + assertFalse(keys.contains(key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "test:overworld/mist" + ))); + assertFalse(keys.contains(key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "overworld:mist" + ))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:plains"))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:forest"))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:desert"))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:taiga"))); + assertTrue(keys.contains(key( + GenerationRegistryContractFactory.STRUCTURE_REGISTRY, + "minecraft:village_plains" + ))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.BLOCK_REGISTRY, "minecraft:stone"))); + assertTrue(keys.contains(key(GenerationRegistryContractFactory.ENTITY_TYPE_REGISTRY, "minecraft:zombie"))); + + GenerationRegistryContract adopted = GenerationRegistryContractFactory.create( + data, + dimension, + packFingerprint, + fixer, + registries, + generationRegistry, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + ); + assertTrue(adopted.definitions().containsKey(key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "test:overworld/mist" + ))); + } + + @Test + public void duplicateLogicalBiomeIdsKeepExactDefinitionsAndChooseStableLegacyAlias() throws Exception { + File pack = temporary.newFolder("duplicate-biomes"); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + ContentGate contentGate = mock(ContentGate.class); + when(contentGate.entity("example:missing")).thenReturn(KeyStatus.MISSING); + when(data.getContentGate()).thenReturn(contentGate); + IrisBiomeCustom plateau = new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#FFCB31") + .setSpawns(new KList<>(new IrisBiomeCustomSpawn().setType("example:missing"))); + IrisBiomeCustom vanilla = new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#BFB755"); + IrisBiome first = new IrisBiome().setCustomDerivitives(new KList<>(plateau)); + IrisBiome second = new IrisBiome().setCustomDerivitives(new KList<>(vanilla)); + IrisDimension dimension = spy(new IrisDimension()); + dimension.setLoadKey("overworld"); + stubDimensions(data, dimension); + doReturn(new KList<>(first, second)).when(dimension).getAllBiomes(any()); + doReturn(new KList<>(first)).when(dimension).getReachableBiomes(any()); + doReturn(new KList<>()).when(dimension).getAllRegions(any()); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(List.of()); + when(registries.entityKeys()).thenReturn(List.of()); + FakeGenerationRegistry registry = new FakeGenerationRegistry("duplicate-runtime"); + String fingerprint = GenerationPackFingerprint.compute( + pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION); + GenerationRegistryContract contract = GenerationRegistryContractFactory.create( + data, dimension, fingerprint, DataVersion.getLatest().get(), registries, registry, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES); + String plateauKey = GenerationRegistryContractFactory.customBiomeResourceKey( + "overworld", plateau, contentGate, registry); + String vanillaKey = GenerationRegistryContractFactory.customBiomeResourceKey( + "overworld", vanilla, contentGate, registry); + assertNotEquals(plateauKey, vanillaKey); + assertTrue(contract.definitions().containsKey(key(GenerationRegistryContractFactory.BIOME_REGISTRY, plateauKey))); + assertTrue(contract.definitions().containsKey(key(GenerationRegistryContractFactory.BIOME_REGISTRY, vanillaKey))); + String selected = plateauKey.compareTo(vanillaKey) < 0 ? plateauKey : vanillaKey; + assertEquals(contract.generatedSources().get(key(GenerationRegistryContractFactory.BIOME_REGISTRY, selected)).sourceJson(), + contract.generatedSources().get(key(GenerationRegistryContractFactory.BIOME_REGISTRY, + "test:overworld/savanna_plateau")).sourceJson()); + GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver = + new GenerationRegistryContractFactory.CustomBiomeResourceResolver(contract, registry::customBiomeResourceKey); + assertEquals(plateauKey, resolver.resolve("overworld", plateau)); + assertEquals(vanillaKey, resolver.resolve("overworld", vanilla)); + assertNotEquals(plateauKey, GenerationRegistryContractFactory.customBiomeResourceKey("overworld", plateau, registry)); + assertThrows(IOException.class, + () -> resolver.resolve("overworld", new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#FF0000"))); + + doReturn(new KList<>(second, first)).when(dimension).getAllBiomes(any()); + GenerationRegistryContract reversed = GenerationRegistryContractFactory.create( + data, dimension, fingerprint, DataVersion.getLatest().get(), registries, registry, + GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES); + assertEquals(contract, reversed); + } + + @Test + public void customBiomeIdentityUsesAuthoredDefinitionAndLogicalIdentity() { + IrisBiomeCustom firstBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.5D); + IrisBiomeCustom changedBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.7D); + String firstDefinition = GenerationRegistryContractFactory + .fingerprintCustomBiomeAuthoredDefinition(firstBiome); + String changedDefinition = GenerationRegistryContractFactory + .fingerprintCustomBiomeAuthoredDefinition(changedBiome); + String first = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity( + firstDefinition, + "Overworld", + "Mist" + ); + String normalized = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity( + firstDefinition, + "overworld", + "mist" + ); + String changedBiomeIdentity = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity( + changedDefinition, + "overworld", + "mist" + ); + + assertEquals(first, normalized); + assertNotEquals(first, changedBiomeIdentity); + assertEquals("iris:biomes/" + first, + PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(first)); + } + + @Test + public void contentGateEffectsParticipateInCustomBiomeIdentity() { + IrisBiomeCustom custom = new IrisBiomeCustom() + .setId("mist") + .setSpawns(new KList<>(new IrisBiomeCustomSpawn().setType("example:animal"))); + ContentGate present = mock(ContentGate.class); + ContentGate missing = mock(ContentGate.class); + when(present.entity("example:animal")).thenReturn(KeyStatus.PRESENT); + when(missing.entity("example:animal")).thenReturn(KeyStatus.MISSING); + + String withSpawn = GenerationRegistryContractFactory.customBiomeResourceKey( + "overworld", + custom, + present + ); + String withoutSpawn = GenerationRegistryContractFactory.customBiomeResourceKey( + "overworld", + custom, + missing + ); + + assertNotEquals(withSpawn, withoutSpawn); + } + + @Test + public void unrelatedPackChangesReuseCustomBiomePhysicalIdentity() throws Exception { + File firstPack = temporary.newFolder("identity-pack-a"); + File secondPack = temporary.newFolder("identity-pack-b"); + Files.writeString(firstPack.toPath().resolve("unrelated.json"), "{\"value\":1}", StandardCharsets.UTF_8); + Files.writeString(secondPack.toPath().resolve("unrelated.json"), "{\"value\":2}", StandardCharsets.UTF_8); + ContentGate contentGate = mock(ContentGate.class); + IrisData firstData = mock(IrisData.class); + when(firstData.getDataFolder()).thenReturn(firstPack); + when(firstData.getContentGate()).thenReturn(contentGate); + IrisData secondData = mock(IrisData.class); + when(secondData.getDataFolder()).thenReturn(secondPack); + when(secondData.getContentGate()).thenReturn(contentGate); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + IDataFixer fixer = mock(IDataFixer.class); + IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist").setTemperature(0.5D)); + doReturn("{\"temperature\":0.5,\"effects\":{}}") + .when(custom).generateJson(fixer, contentGate); + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime"); + + GenerationRegistryContractFactory.CustomBiomeDefinition first = + GenerationRegistryContractFactory.customBiomeDefinition( + firstData, + dimension, + custom, + fixer, + registry + ); + GenerationRegistryContractFactory.CustomBiomeDefinition second = + GenerationRegistryContractFactory.customBiomeDefinition( + secondData, + dimension, + custom, + fixer, + registry + ); + + assertNotEquals( + GenerationPackFingerprint.compute(firstPack.toPath(), GenerationPackFingerprint.CURRENT_VERSION), + GenerationPackFingerprint.compute(secondPack.toPath(), GenerationPackFingerprint.CURRENT_VERSION) + ); + assertEquals(first.physicalKey(), second.physicalKey()); + assertEquals(first.identityFingerprint(), second.identityFingerprint()); + } + + @Test + public void serializerChangesDoNotAlterCustomBiomePhysicalIdentityOrContract() throws Exception { + File pack = temporary.newFolder("serializer-stable-pack"); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + ContentGate contentGate = mock(ContentGate.class); + when(data.getContentGate()).thenReturn(contentGate); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist")); + IDataFixer firstFixer = mock(IDataFixer.class); + IDataFixer secondFixer = mock(IDataFixer.class); + doReturn( + "{\"temperature\":0.5,\"effects\":{}}" + ).when(custom).generateJson(firstFixer, contentGate); + doReturn( + "{\"temperature\":0.5,\"effects\":{},\"new_schema_default\":true}" + ).when(custom).generateJson(secondFixer, contentGate); + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime-a") { + @Override + public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) { + return Definition.exactJson("{\"effects\":{},\"temperature\":0.5}"); + } + }; + + GenerationRegistryContractFactory.CustomBiomeDefinition first = + GenerationRegistryContractFactory.customBiomeDefinition( + data, + dimension, + custom, + firstFixer, + registry + ); + GenerationRegistryContractFactory.CustomBiomeDefinition second = + GenerationRegistryContractFactory.customBiomeDefinition( + data, + dimension, + custom, + secondFixer, + registry + ); + + assertEquals(first.physicalKey(), second.physicalKey()); + assertEquals(first.identityFingerprint(), second.identityFingerprint()); + assertEquals(first.definition(), second.definition()); + assertEquals(first.contentFingerprint(), second.contentFingerprint()); + assertEquals(first.sourceJson(), second.sourceJson()); + assertEquals( + GenerationRegistryContractFactory.fingerprintDefinition( + first.physicalKey(), + first.definition(), + "runtime-a" + ), + GenerationRegistryContractFactory.fingerprintDefinition( + second.physicalKey(), + second.definition(), + "runtime-b" + ) + ); + } + + @Test + public void customBiomeDefinitionFingerprintCanonicalizesCurrentEmission() { + String first = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition( + "{\"temperature\":0.50,\"effects\":{\"sky_color\":1}}" + ); + String reordered = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition( + "{\"effects\":{\"sky_color\":1.0},\"temperature\":0.5}" + ); + String changed = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition( + "{\"effects\":{\"sky_color\":2},\"temperature\":0.5}" + ); + + assertEquals(first, reordered); + assertNotEquals(first, changed); + } + + @Test + public void canonicalJsonIgnoresObjectOrderAndNumericFormatting() { + GenerationRegistryContract.PhysicalResourceKey key = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "minecraft:plains" + ); + + String first = GenerationRegistryContractFactory.fingerprintDefinition( + key, + PlatformGenerationRegistry.Definition.exactJson("{\"b\":2.0,\"a\":1}"), + "runtime-a" + ); + String second = GenerationRegistryContractFactory.fingerprintDefinition( + key, + PlatformGenerationRegistry.Definition.exactJson("{\"a\":1.00,\"b\":2}"), + "runtime-b" + ); + + assertEquals(first, second); + } + + @Test + public void resourceIdentitySurvivesCompatibleRuntimeUpdates() { + GenerationRegistryContract.PhysicalResourceKey key = key( + GenerationRegistryContractFactory.BLOCK_REGISTRY, + "minecraft:stone" + ); + PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.resourceIdentity( + key.registryKey(), + key.resourceKey() + ); + + String first = GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "runtime-a"); + String second = GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "runtime-b"); + + assertEquals(first, second); + } + + @Test + public void capturesAndValidatesTheRetainedContractUnion() throws Exception { + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime"); + GenerationRegistryContract.PhysicalResourceKey plains = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "minecraft:plains" + ); + GenerationRegistryContract.PhysicalResourceKey village = key( + GenerationRegistryContractFactory.STRUCTURE_REGISTRY, + "minecraft:village_plains" + ); + GenerationRegistryContract first = contract(registry, plains); + GenerationRegistryContract second = contract(registry, village); + + GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(first, second), + registry + ); + + assertEquals(Set.of(plains, village), available.definitions().keySet()); + first.requireDefinitionsAvailableIn(available); + second.requireDefinitionsAvailableIn(available); + } + + @Test + public void rejectsConflictingRetainedPhysicalDefinitions() { + GenerationRegistryContract.PhysicalResourceKey plains = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "minecraft:plains" + ); + GenerationRegistryContract first = GenerationRegistryContract.fromDefinitions(Map.of(plains, HASH_A)); + GenerationRegistryContract second = GenerationRegistryContract.fromDefinitions(Map.of(plains, HASH_B)); + + assertThrows( + IOException.class, + () -> GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(first, second), + new FakeGenerationRegistry("runtime") + ) + ); + } + + @Test + public void acceptsChangedExternalRuntimeDetailsButRejectsMissingKeys() throws Exception { + FakeGenerationRegistry original = new FakeGenerationRegistry("runtime"); + GenerationRegistryContract.PhysicalResourceKey plains = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "minecraft:plains" + ); + GenerationRegistryContract required = contract(original, plains); + FakeGenerationRegistry changed = new FakeGenerationRegistry("different-runtime"); + changed.put( + plains, + PlatformGenerationRegistry.Definition.resourceIdentity( + plains.registryKey(), + plains.resourceKey() + ) + ); + FakeGenerationRegistry missing = new FakeGenerationRegistry("runtime"); + missing.remove(plains); + + assertEquals( + required, + GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), changed) + ); + assertThrows( + IOException.class, + () -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), missing) + ); + } + + @Test + public void validatesGeneratedDefinitionsByRecordedCanonicalContent() throws Exception { + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime-a"); + GenerationRegistryContract.PhysicalResourceKey generated = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + IrisBiomeCustom customBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.5D); + String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(customBiome, null); + String semanticFingerprint = GenerationRegistryContractFactory + .fingerprintCustomBiomeAuthoredDefinition(semantic); + IDataFixer firstFixer = new TaggedBiomeFixer("first"); + String source = customBiome.generateJson( + firstFixer, + new ContentGate(null, Map.of(), null) + ); + PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.exactJson(source); + registry.put(generated, definition); + GenerationRegistryContract.GeneratedSource generatedSource = new GenerationRegistryContract.GeneratedSource( + GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + semanticFingerprint, + semantic, + registry.generatedDefinitionRendererIdentity(), + GenerationRegistryContractFactory.fingerprintDefinition( + generated, + definition, + "generated" + ), + source + ); + GenerationRegistryContract required = GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(generated, GenerationRegistryContractFactory.fingerprintGeneratedSemantic( + generated, + generatedSource + )), + Map.of(generated, generatedSource) + ); + + GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(required), + registry + ); + + required.requireDefinitionsAvailableIn(available); + assertEquals( + source, + GenerationRegistryContractFactory.requireGeneratedSource( + required, + generated, + firstFixer, + registry + ) + ); + + registry.put(generated, PlatformGenerationRegistry.Definition.exactJson( + "{\"effects\":{},\"temperature\":0.7}" + )); + assertThrows( + IOException.class, + () -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry) + ); + registry.remove(generated); + assertThrows( + IOException.class, + () -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry) + ); + + FakeGenerationRegistry changedRenderer = new FakeGenerationRegistry("runtime-b") { + @Override + public String generatedDefinitionRendererIdentity() { + return "test-renderer-v2"; + } + }; + IDataFixer secondFixer = new TaggedBiomeFixer("second"); + String rerendered = GenerationRegistryContractFactory.requireGeneratedSource( + required, + generated, + secondFixer, + changedRenderer + ); + assertTrue(rerendered.contains("second")); + assertNotEquals(source, rerendered); + changedRenderer.put(generated, PlatformGenerationRegistry.Definition.exactJson(rerendered)); + GenerationRegistryContract availableAfterUpgrade = GenerationRegistryContractFactory + .captureRequiredDefinitions(Set.of(required), secondFixer, changedRenderer); + required.requireDefinitionsAvailableIn(availableAfterUpgrade); + } + + @Test + public void rerendersDimensionTypeSemanticsForANewRenderer() throws Exception { + assertDimensionRerender(new IrisDimension().getDimensionType()); + } + + @Test + public void dimensionRerenderPreservesNullableAndNondefaultOptions() throws Exception { + IrisDimensionType original = new IrisDimension().getDimensionType(); + IrisDimensionTypeOptions options = new IrisDimensionTypeOptions() + .coordinateScale(8.0) + .ambientLight(0.375F) + .fixedTime(null) + .cloudHeight(null) + .monsterSpawnBlockLightLimit(7) + .ultrawarm(IrisDimensionTypeOptions.TriState.TRUE) + .natural(IrisDimensionTypeOptions.TriState.FALSE) + .ceiling(IrisDimensionTypeOptions.TriState.TRUE); + assertDimensionRerender(new IrisDimensionType(original.base(), options, 128, 256, -64)); + options.fixedTime(6000L).cloudHeight(-256); + assertDimensionRerender(new IrisDimensionType(original.base(), options, 128, 256, -64)); + } + + private void assertDimensionRerender(IrisDimensionType dimensionType) throws Exception { + GenerationRegistryContract.PhysicalResourceKey key = key( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + "iris:overworld_type" + ); + IDataFixer firstFixer = new TaggedBiomeFixer("first"); + String semantic = GenerationEpochContractFactory.dimensionTypeSemanticJson( + dimensionType + ); + String source = dimensionType.toJson(firstFixer); + PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.exactJson(source); + GenerationRegistryContract.GeneratedSource generatedSource = new GenerationRegistryContract.GeneratedSource( + GenerationRegistryContractFactory.DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA, + GenerationEpochContractFactory.fingerprintDimensionType(dimensionType), + semantic, + "test-renderer-v1", + GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "generated"), + source + ); + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, generatedSource)), + Map.of(key, generatedSource) + ); + FakeGenerationRegistry upgraded = new FakeGenerationRegistry("runtime-b") { + @Override + public String generatedDefinitionRendererIdentity() { + return "test-renderer-v2"; + } + }; + + String rerendered = GenerationRegistryContractFactory.requireGeneratedSource( + contract, + key, + new TaggedBiomeFixer("second"), + upgraded + ); + + assertTrue(rerendered.contains("second")); + assertNotEquals(source, rerendered); + JsonObject expected = JsonParser.parseString(source).getAsJsonObject(); + expected.addProperty("serializer", "second"); + assertEquals(expected, JsonParser.parseString(rerendered)); + } + + @Test + public void rejectsAChangedPackBeforeCapture() throws Exception { + File pack = temporary.newFolder("changed-pack"); + String original = GenerationPackFingerprint.compute( + pack.toPath(), + GenerationPackFingerprint.CURRENT_VERSION + ); + Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + + assertThrows( + IOException.class, + () -> GenerationRegistryContractFactory.create( + data, + new IrisDimension(), + original, + DataVersion.getLatest().get(), + mock(PlatformRegistries.class), + new FakeGenerationRegistry("runtime") + ) + ); + } + + @Test + public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception { + File pack = temporary.newFolder("missing-secondary-dimension"); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + ResourceLoader loader = stubDimensions(data, dimension); + when(loader.getPossibleKeys()).thenReturn(new String[]{"overworld", "upper"}); + String fingerprint = GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION); + + IOException exception = assertThrows(IOException.class, () -> GenerationRegistryContractFactory.create( + data, dimension, fingerprint, DataVersion.getLatest().get(), + mock(PlatformRegistries.class), new FakeGenerationRegistry("runtime"))); + + assertTrue(exception.getMessage().contains("Unable to load Iris dimension 'upper'")); + } + + @SuppressWarnings("unchecked") + private static ResourceLoader stubDimensions(IrisData data, IrisDimension dimension) { + ResourceLoader loader = mock(ResourceLoader.class); + String dimensionKey = dimension.getLoadKey(); + when(data.getDimensionLoader()).thenReturn(loader); + when(loader.getPossibleKeys()).thenReturn(new String[]{dimensionKey}); + return loader; + } + + private static GenerationRegistryContract contract( + FakeGenerationRegistry registry, + GenerationRegistryContract.PhysicalResourceKey... keys + ) { + Map definitions = new LinkedHashMap<>(); + for (GenerationRegistryContract.PhysicalResourceKey key : keys) { + PlatformGenerationRegistry.Definition definition = registry.definition( + key.registryKey(), + key.resourceKey() + ); + definitions.put( + key, + GenerationRegistryContractFactory.fingerprintDefinition( + key, + definition, + registry.runtimeIdentity() + ) + ); + } + return GenerationRegistryContract.fromDefinitions(definitions); + } + + private static GenerationRegistryContract.PhysicalResourceKey key(String registry, String resource) { + return new GenerationRegistryContract.PhysicalResourceKey(registry, resource); + } + + private record TaggedBiomeFixer(String tag) implements IDataFixer { + @Override + public JSONObject fixCustomBiome(IrisBiomeCustom biome, JSONObject json) { + return json.put("renderer", tag); + } + + @Override + public JSONObject resolve(Dimension dimension, IrisDimensionTypeOptions options) { + return new JSONObject().put("serializer", tag).put("options", + new JSONObject(new GsonBuilder().serializeNulls().create().toJson(options))); + } + + @Override + public void fixDimension(Dimension dimension, JSONObject json) { + } + } + + private static class FakeGenerationRegistry implements PlatformGenerationRegistry { + private final String runtimeIdentity; + private final Map definitions = + new LinkedHashMap<>(); + + private FakeGenerationRegistry(String runtimeIdentity) { + this.runtimeIdentity = runtimeIdentity; + } + + @Override + public String runtimeIdentity() { + return runtimeIdentity; + } + + @Override + public String generatedDefinitionRendererIdentity() { + return "test-renderer-v1"; + } + + @Override + public String customBiomeResourceKey(String identitySha256) { + return "test:biomes/" + identitySha256; + } + + @Override + public List legacyCustomBiomeResourceKeys( + String packName, + String dimensionKey, + String customBiomeId + ) { + return List.of("test:" + dimensionKey + "/" + customBiomeId); + } + + @Override + public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { + return "test:" + dimensionKey + "_type"; + } + + @Override + public Definition definition(String registryKey, String resourceKey) { + GenerationRegistryContract.PhysicalResourceKey key = key(registryKey, resourceKey); + Definition definition = definitions.get(key); + if (definition == null && !definitions.containsKey(key)) { + definition = Definition.resourceIdentity(registryKey, resourceKey); + definitions.put(key, definition); + } + return definition; + } + + @Override + public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) { + return Definition.exactJson(sourceJson); + } + + @Override + public Definition generatedDefinition(String registryKey, String resourceKey) { + Definition definition = definitions.get(key(registryKey, resourceKey)); + if (definition == null || definition.encoding() != Encoding.JSON) { + throw new IllegalStateException("Missing generated definition " + resourceKey); + } + return definition; + } + + private void put(GenerationRegistryContract.PhysicalResourceKey key, Definition definition) { + definitions.put(key, definition); + } + + private void remove(GenerationRegistryContract.PhysicalResourceKey key) { + definitions.put(key, null); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractTest.java new file mode 100644 index 000000000..502ce1645 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationRegistryContractTest.java @@ -0,0 +1,229 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; + +public class GenerationRegistryContractTest { + private static final String DEFINITION_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String DEFINITION_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + @Test + public void canonicalizesPhysicalResourceKeysAndRetainsCurrentFingerprint() { + GenerationRegistryContract.PhysicalResourceKey plains = key( + "minecraft:worldgen/biome", + "minecraft:plains" + ); + GenerationRegistryContract.PhysicalResourceKey desert = key( + "minecraft:worldgen/biome", + "minecraft:desert" + ); + LinkedHashMap reverseOrder = new LinkedHashMap<>(); + reverseOrder.put(plains, DEFINITION_A); + reverseOrder.put(desert, DEFINITION_B); + + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitions( + GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, + reverseOrder + ); + + assertEquals( + "b9165819aa1cdf4e7024a85ca1b63801c3cfaaefb1898de7fd83b0adf366b16e", + contract.fingerprint() + ); + assertEquals(desert, contract.definitions().firstKey()); + assertEquals(plains, contract.definitions().lastKey()); + assertThrows( + UnsupportedOperationException.class, + () -> contract.definitions().put(key("minecraft:block", "minecraft:stone"), DEFINITION_A) + ); + } + + @Test + public void derivesStableFingerprintIndependentOfMapIterationOrder() { + GenerationRegistryContract.PhysicalResourceKey plains = key( + "minecraft:worldgen/biome", + "minecraft:plains" + ); + GenerationRegistryContract.PhysicalResourceKey desert = key( + "minecraft:worldgen/biome", + "minecraft:desert" + ); + LinkedHashMap firstOrder = new LinkedHashMap<>(); + firstOrder.put(plains, DEFINITION_A); + firstOrder.put(desert, DEFINITION_B); + LinkedHashMap secondOrder = new LinkedHashMap<>(); + secondOrder.put(desert, DEFINITION_B); + secondOrder.put(plains, DEFINITION_A); + + GenerationRegistryContract first = GenerationRegistryContract.fromDefinitions(firstOrder); + GenerationRegistryContract second = GenerationRegistryContract.fromDefinitions(secondOrder); + + assertEquals(first, second); + assertEquals(first.fingerprint(), second.fingerprint()); + } + + @Test + public void definitionOrPhysicalKeyMutationChangesFingerprint() { + GenerationRegistryContract.PhysicalResourceKey plains = key( + "minecraft:worldgen/biome", + "minecraft:plains" + ); + GenerationRegistryContract.PhysicalResourceKey forest = key( + "minecraft:worldgen/biome", + "minecraft:forest" + ); + GenerationRegistryContract original = GenerationRegistryContract.fromDefinitions(Map.of(plains, DEFINITION_A)); + GenerationRegistryContract changedDefinition = GenerationRegistryContract.fromDefinitions( + Map.of(plains, DEFINITION_B) + ); + GenerationRegistryContract changedKey = GenerationRegistryContract.fromDefinitions(Map.of(forest, DEFINITION_A)); + + assertNotEquals(original.fingerprint(), changedDefinition.fingerprint()); + assertNotEquals(original.fingerprint(), changedKey.fingerprint()); + } + + @Test + public void roundTripsStrictDeterministicJson() { + GenerationRegistryContract.PhysicalResourceKey generatedBiome = key( + "minecraft:worldgen/biome", + "iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources(Map.of( + key("minecraft:worldgen/biome", "minecraft:plains"), + DEFINITION_A, + key("minecraft:dimension_type", "minecraft:overworld"), + DEFINITION_B, + generatedBiome, + DEFINITION_A + ), Map.of(generatedBiome, generatedSource("{\"temperature\":0.5}"))); + JsonObject json = contract.toJson(); + + assertEquals(contract, GenerationRegistryContract.fromJson(json.deepCopy())); + assertEquals(json.toString(), contract.toJson().toString()); + + JsonObject unknownField = json.deepCopy(); + unknownField.addProperty("unexpected", true); + assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(unknownField)); + + JsonObject missingDefinitions = json.deepCopy(); + missingDefinitions.remove("definitions"); + assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(missingDefinitions)); + + JsonObject missingGeneratedSources = json.deepCopy(); + missingGeneratedSources.remove("generatedSources"); + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromJson(missingGeneratedSources) + ); + } + + @Test + public void generatedSourcesAreDefinitionBoundedAndFingerprintProtected() { + GenerationRegistryContract.PhysicalResourceKey generatedBiome = key( + "minecraft:worldgen/biome", + "iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(generatedBiome, DEFINITION_A), + Map.of(generatedBiome, generatedSource("{\"temperature\":0.5}")) + ); + JsonObject changed = contract.toJson(); + changed.getAsJsonArray("generatedSources") + .get(0) + .getAsJsonObject() + .addProperty("sourceJson", "{\"temperature\":0.7}"); + + assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(changed)); + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(), + Map.of(generatedBiome, generatedSource("{}")) + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(generatedBiome, DEFINITION_A), + Map.of(generatedBiome, generatedSource("[]")) + ) + ); + String oversized = "{\"value\":\"" + "a".repeat(1024 * 1024) + "\"}"; + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(generatedBiome, DEFINITION_A), + Map.of(generatedBiome, generatedSource(oversized)) + ) + ); + } + + @Test + public void refusesUnsortedPersistedDefinitionsAndForgedFingerprint() { + LinkedHashMap definitions = new LinkedHashMap<>(); + definitions.put(key("minecraft:worldgen/biome", "minecraft:desert"), DEFINITION_B); + definitions.put(key("minecraft:worldgen/biome", "minecraft:plains"), DEFINITION_A); + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitions(definitions); + JsonObject unsorted = contract.toJson(); + JsonArray definitionArray = unsorted.getAsJsonArray("definitions"); + JsonElement first = definitionArray.get(0).deepCopy(); + JsonElement second = definitionArray.get(1).deepCopy(); + definitionArray.set(0, second); + definitionArray.set(1, first); + + assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(unsorted)); + + JsonObject forged = contract.toJson(); + forged.addProperty("fingerprint", DEFINITION_A); + assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(forged)); + } + + @Test + public void refusesNonCanonicalKeysHashesAndUnknownSchemas() { + assertThrows( + IllegalArgumentException.class, + () -> key("Minecraft:worldgen/biome", "minecraft:plains") + ); + assertThrows( + IllegalArgumentException.class, + () -> key("minecraft:worldgen/biome", "plains") + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromDefinitions(Map.of( + key("minecraft:worldgen/biome", "minecraft:plains"), + "not-a-sha256" + )) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationRegistryContract.fromDefinitions(2, Map.of()) + ); + } + + private static GenerationRegistryContract.PhysicalResourceKey key(String registryKey, String resourceKey) { + return new GenerationRegistryContract.PhysicalResourceKey(registryKey, resourceKey); + } + + private static GenerationRegistryContract.GeneratedSource generatedSource(String sourceJson) { + return new GenerationRegistryContract.GeneratedSource( + "test-source-v1", + DEFINITION_A, + "{\"authored\":true}", + "test-renderer-v1", + DEFINITION_B, + sourceJson + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java new file mode 100644 index 000000000..61f4fecb0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticIndexTest.java @@ -0,0 +1,730 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.zip.CRC32; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class GenerationSemanticIndexTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void roundTripPreservesAllGeneratedTruthAndQueriesWithoutDiskAccess() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("dimension").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics semantics = completeSemantics(-33, -1, 7L); + + assertTrue(index.recordAndPersist(semantics)); + assertFalse(index.recordAndPersist(semantics)); + Path expectedDirectory = dimensionRoot.resolve("iris/generation/semantics"); + assertEquals(expectedDirectory, index.storageDirectory()); + Path shard = onlyShard(expectedDirectory); + assertTrue(shard.getFileName().toString().matches("r\\.-2\\.-1\\.[0-9a-f]{64}\\.isem")); + + GenerationSemanticIndex loaded = GenerationSemanticIndex.load(dimensionRoot); + assertEquals(Optional.of(semantics), loaded.get(-33, -1)); + assertEquals(1, loaded.recordCount()); + Files.delete(shard); + + assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.SURFACE_BIOME, "iris:forest").chunk().chunkX()); + assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.CAVE_BIOME, "iris:limestone_caves").chunk().chunkX()); + assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.REGION, "temperate").chunk().chunkX()); + assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, "rivers/default").chunk().chunkX()); + assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:oak").chunk().chunkX()); + GenerationSemanticIndex.Match structure = requiredMatch( + loaded, + GenerationSemanticIndex.SemanticKind.STRUCTURE, + "minecraft:village_plains" + ); + assertEquals(new ChunkGenerationSemantics.BlockPosition(-520, 72, -7), structure.exactPosition().orElseThrow()); + } + + @Test + public void stagedFactsMergeMonotonicallyAndSealDurably() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("staged").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics noiseStage = ChunkGenerationSemantics.builder(4, 5, 3L) + .addSurfaceBiome("iris:forest") + .build(); + ChunkGenerationSemantics biomeStage = ChunkGenerationSemantics.builder(4, 5, 3L) + .addRegion("temperate") + .addRiverProfile("rivers/new") + .build(); + ChunkGenerationSemantics fullStage = ChunkGenerationSemantics.builder(4, 5, 3L) + .addObject("iris:oak") + .addStructure("iris:tower", 72, 90, 88) + .seal() + .build(); + + assertTrue(index.recordAndPersist(noiseStage)); + assertFalse(index.recordAndPersist(noiseStage)); + assertTrue(index.recordAndPersist(biomeStage)); + assertTrue(index.recordAndPersist(fullStage)); + assertFalse(index.recordAndPersist(noiseStage)); + + ChunkGenerationSemantics expected = ChunkGenerationSemantics.builder(4, 5, 3L) + .addSurfaceBiome("iris:forest") + .addRegion("temperate") + .addRiverProfile("rivers/new") + .addObject("iris:oak") + .addStructure("iris:tower", 72, 90, 88) + .seal() + .build(); + assertEquals(Optional.of(expected), index.get(4, 5)); + assertEquals(1, index.recordCount()); + assertEquals(Optional.of(expected), GenerationSemanticIndex.load(dimensionRoot).get(4, 5)); + } + + @Test + public void conflictingActivationsAndFactsAfterSealAreRejectedWithoutDiskChanges() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("sealed").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics sealed = ChunkGenerationSemantics.builder(4, 5, 3L) + .addSurfaceBiome("iris:forest") + .seal() + .build(); + assertTrue(index.recordAndPersist(sealed)); + Path shard = onlyShard(index.storageDirectory()); + byte[] persisted = Files.readAllBytes(shard); + + ChunkGenerationSemantics additionalFact = ChunkGenerationSemantics.builder(4, 5, 3L) + .addSurfaceBiome("iris:desert") + .build(); + ChunkGenerationSemantics differentActivation = ChunkGenerationSemantics.builder(4, 5, 4L) + .addSurfaceBiome("iris:forest") + .build(); + + assertThrows(IllegalStateException.class, () -> index.recordAndPersist(additionalFact)); + assertThrows(IllegalStateException.class, () -> index.recordAndPersist(differentActivation)); + assertArrayEquals(persisted, Files.readAllBytes(shard)); + assertEquals(Optional.of(sealed), index.get(4, 5)); + assertEquals(Optional.of(sealed), GenerationSemanticIndex.load(dimensionRoot).get(4, 5)); + } + + @Test + public void nearestChunkUsesDistanceStableTiesRadiusAndActivationFilters() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("nearest-chunks").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + index.recordAndPersist(ChunkGenerationSemantics.builder(-1, 0, 1L).addRiverProfile("rivers/new").build()); + index.recordAndPersist(ChunkGenerationSemantics.builder(1, 0, 2L).addRiverProfile("rivers/new").build()); + ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 80, 0); + + GenerationSemanticIndex.Match tied = index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, + "rivers/new", + origin, + 2 + )).orElseThrow(); + assertEquals(new GenerationSemanticIndex.ChunkReference(-1, 0, 1L), tied.chunk()); + + GenerationSemanticIndex.Match filtered = index.findNearest(GenerationSemanticIndex.Query.forActivation( + GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, + "rivers/new", + origin, + 2, + 2L + )).orElseThrow(); + assertEquals(new GenerationSemanticIndex.ChunkReference(1, 0, 2L), filtered.chunk()); + + assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, + "rivers/new", + origin, + 0 + )).isEmpty()); + assertTrue(index.findNearest(GenerationSemanticIndex.Query.forActivation( + GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, + "rivers/new", + origin, + 2, + 99L + )).isEmpty()); + } + + @Test + public void nearestStructureUsesExactHorizontalDistanceAndStablePositionTies() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("nearest-structures").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L) + .addStructure("iris:tower", -10, 90, 0) + .addStructure("iris:tower", 10, 70, 0) + .build()); + index.recordAndPersist(ChunkGenerationSemantics.builder(2, 0, 2L) + .addStructure("iris:tower", 32, 64, 0) + .build()); + ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 0, 0); + + GenerationSemanticIndex.Match tied = index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.STRUCTURE, + "iris:tower", + origin, + 4 + )).orElseThrow(); + assertEquals(new ChunkGenerationSemantics.BlockPosition(-10, 90, 0), tied.exactPosition().orElseThrow()); + + GenerationSemanticIndex.Match filtered = index.findNearest(GenerationSemanticIndex.Query.forActivation( + GenerationSemanticIndex.SemanticKind.STRUCTURE, + "iris:tower", + origin, + 4, + 2L + )).orElseThrow(); + assertEquals(new ChunkGenerationSemantics.BlockPosition(32, 64, 0), filtered.exactPosition().orElseThrow()); + } + + @Test + public void shardBytesAreDeterministicAcrossRecordInsertionOrder() throws Exception { + Path firstRoot = temporaryFolder.newFolder("deterministic-first").toPath(); + Path secondRoot = temporaryFolder.newFolder("deterministic-second").toPath(); + List semantics = List.of( + completeSemantics(0, 0, 1L), + completeSemantics(4, 7, 2L), + completeSemantics(31, 31, 3L) + ); + GenerationSemanticIndex first = GenerationSemanticIndex.load(firstRoot); + GenerationSemanticIndex second = GenerationSemanticIndex.load(secondRoot); + for (ChunkGenerationSemantics record : semantics) { + first.recordAndPersist(record); + } + for (int index = semantics.size() - 1; index >= 0; index--) { + second.recordAndPersist(semantics.get(index)); + } + + assertArrayEquals( + Files.readAllBytes(onlyShard(first.storageDirectory())), + Files.readAllBytes(onlyShard(second.storageDirectory())) + ); + assertArrayEquals( + Files.readAllBytes(first.storageDirectory().resolve("index.isix")), + Files.readAllBytes(second.storageDirectory().resolve("index.isix")) + ); + } + + @Test + public void failedPublicationLeavesMemoryAndPersistedStateUnchanged() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("publication-failure").toPath(); + GenerationSemanticIndex initial = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics baseline = ChunkGenerationSemantics.builder(2, 3, 8L) + .addSurfaceBiome("iris:forest") + .build(); + initial.recordAndPersist(baseline); + byte[] persisted = Files.readAllBytes(onlyShard(initial.storageDirectory())); + + GenerationSemanticIndex failing = GenerationSemanticIndex.load( + dimensionRoot, + (directory, regionX, regionZ, encoded) -> { + throw new IOException("simulated publication failure"); + } + ); + ChunkGenerationSemantics update = ChunkGenerationSemantics.builder(2, 3, 8L) + .addRegion("temperate") + .build(); + + IOException error = assertThrows(IOException.class, () -> failing.recordAndPersist(update)); + + assertEquals("simulated publication failure", error.getMessage()); + assertEquals(Optional.of(baseline), failing.get(2, 3)); + assertArrayEquals(persisted, Files.readAllBytes(onlyShard(initial.storageDirectory()))); + assertEquals(Optional.of(baseline), GenerationSemanticIndex.load(dimensionRoot).get(2, 3)); + } + + @Test + public void failedCatalogPublicationLeavesAnUnreferencedShardAndReopensPreviousTruth() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("catalog-publication-failure").toPath(); + GenerationSemanticIndex initial = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics baseline = ChunkGenerationSemantics.builder(2, 3, 8L) + .addSurfaceBiome("iris:forest") + .build(); + initial.recordAndPersist(baseline); + + GenerationSemanticIndex failing = GenerationSemanticIndex.loadWithCatalogPublisher( + dimensionRoot, + (directory, regionKeys) -> { + throw new IOException("simulated catalog failure"); + } + ); + ChunkGenerationSemantics update = ChunkGenerationSemantics.builder(32, 3, 9L) + .addRegion("temperate") + .build(); + + IOException error = assertThrows(IOException.class, () -> failing.recordAndPersist(update)); + + assertEquals("simulated catalog failure", error.getMessage()); + assertEquals(Optional.of(baseline), failing.get(2, 3)); + assertTrue(failing.get(32, 3).isEmpty()); + assertEquals(Optional.of(baseline), GenerationSemanticIndex.load(dimensionRoot).get(2, 3)); + assertTrue(GenerationSemanticIndex.load(dimensionRoot).get(32, 3).isEmpty()); + assertEquals(2, shardCount(initial.storageDirectory())); + } + + @Test + public void snapshotIsImmutableAndUsesStableChunkOrder() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("snapshot").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics last = completeSemantics(7, -3, 1L); + ChunkGenerationSemantics first = completeSemantics(-2, 9, 2L); + index.recordAndPersist(last); + index.recordAndPersist(first); + + List snapshot = index.recordsSnapshot(); + + assertEquals(List.of(first, last), snapshot); + assertThrows(UnsupportedOperationException.class, () -> snapshot.clear()); + } + + @Test + public void checksumCorruptionAndTruncationFailClosed() throws Exception { + Path checksumRoot = persistedRoot("corrupt-checksum"); + Path checksumShard = onlyShard(checksumRoot.resolve("iris/generation/semantics")); + byte[] corrupted = Files.readAllBytes(checksumShard); + corrupted[20] ^= 0x10; + replaceReferencedShard(checksumRoot, checksumShard, corrupted); + + IOException checksumError = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(checksumRoot)); + assertTrue(checksumError.getMessage().contains("checksum mismatch")); + + Path truncatedRoot = persistedRoot("corrupt-truncated"); + Path truncatedShard = onlyShard(truncatedRoot.resolve("iris/generation/semantics")); + byte[] complete = Files.readAllBytes(truncatedShard); + replaceReferencedShard(truncatedRoot, truncatedShard, Arrays.copyOf(complete, complete.length - 5)); + + IOException truncatedError = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(truncatedRoot)); + assertTrue(truncatedError.getMessage().contains("Invalid generation semantic shard")); + } + + @Test + public void catalogDetectsMissingShardsAndFailsClosedWhenItIsMissingOrCorrupt() throws Exception { + Path missingShardRoot = persistedRoot("missing-shard"); + Files.delete(onlyShard(missingShardRoot.resolve("iris/generation/semantics"))); + IOException missingShard = assertThrows( + IOException.class, + () -> GenerationSemanticIndex.load(missingShardRoot) + ); + assertTrue(missingShard.getMessage().contains("catalog references a missing shard")); + + Path missingPointerRoot = persistedRoot("missing-pointer"); + Files.delete(missingPointerRoot.resolve("iris/generation/semantics/r.0.0.isix")); + IOException missingPointer = assertThrows( + IOException.class, + () -> GenerationSemanticIndex.load(missingPointerRoot) + ); + assertTrue(missingPointer.getMessage().contains("catalog references a missing shard pointer")); + + Path missingCatalogRoot = persistedRoot("missing-catalog"); + Files.delete(missingCatalogRoot.resolve("iris/generation/semantics/index.isix")); + IOException missingCatalog = assertThrows( + IOException.class, + () -> GenerationSemanticIndex.load(missingCatalogRoot) + ); + assertTrue(missingCatalog.getMessage().contains("shard catalog is missing")); + + Path corruptCatalogRoot = persistedRoot("corrupt-catalog"); + Path catalog = corruptCatalogRoot.resolve("iris/generation/semantics/index.isix"); + byte[] corrupt = Files.readAllBytes(catalog); + corrupt[8] ^= 0x01; + Files.write(catalog, corrupt); + IOException corruptCatalog = assertThrows( + IOException.class, + () -> GenerationSemanticIndex.load(corruptCatalogRoot) + ); + assertTrue(corruptCatalog.getMessage().contains("semantic catalog")); + assertTrue(corruptCatalog.getMessage().contains("checksum mismatch")); + + Path corruptPointerRoot = persistedRoot("corrupt-pointer"); + Path pointer = corruptPointerRoot.resolve("iris/generation/semantics/r.0.0.isix"); + byte[] corruptPointer = Files.readAllBytes(pointer); + corruptPointer[16] ^= 0x01; + Files.write(pointer, corruptPointer); + IOException pointerError = assertThrows( + IOException.class, + () -> GenerationSemanticIndex.load(corruptPointerRoot) + ); + assertTrue(pointerError.getMessage().contains("semantic shard pointer")); + assertTrue(pointerError.getMessage().contains("checksum mismatch")); + } + + @Test + public void unknownSchemaFailsClosedEvenWithAValidChecksum() throws Exception { + Path dimensionRoot = persistedRoot("unknown-schema"); + Path shard = onlyShard(dimensionRoot.resolve("iris/generation/semantics")); + byte[] encoded = Files.readAllBytes(shard); + encoded[4] = 0; + encoded[5] = 6; + rewriteChecksum(encoded); + replaceReferencedShard(dimensionRoot, shard, encoded); + + IOException error = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(dimensionRoot)); + + assertTrue(error.getMessage().contains("unsupported format version 6")); + } + + @Test + public void sealedClaimJournalReplaysIntoKeyedAndRiverPostings() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("journal-postings").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(64, -32, 7L) + .addRegion("iris:temperate") + .addRiverFeature( + "iris:rivers/default", + HydrologyFeatureType.RIFFLE, + 17L, + 1031, + 71, + -503 + ) + .seal() + .build(); + + assertTrue(index.claimAndPersist(claim)); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot); + + GenerationSemanticIndex.Match region = loaded.findNearest( + GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:temperate", + new ChunkGenerationSemantics.BlockPosition(1024, 0, -512), + 2 + ) + ).orElseThrow(); + assertEquals(new GenerationSemanticIndex.ChunkReference(64, -32, 7L), region.chunk()); + GenerationSemanticIndex.RiverMatch river = loaded.findNearestRiver( + GenerationSemanticIndex.RiverQuery.acrossActivations( + java.util.Set.of(HydrologyFeatureType.RIFFLE), + "iris:rivers/default", + new ChunkGenerationSemantics.BlockPosition(1024, 0, -512), + 2 + ), + ChunkGenerationSemantics::sealed + ).orElseThrow(); + assertEquals(new ChunkGenerationSemantics.BlockPosition(1031, 71, -503), river.occurrence().position()); + assertTrue(sealedClaims(loaded, 7L).contains(ChunkGenerationOwnership.packChunk(64, -32))); + } + + @Test + public void journalReplayTruncatesOnlyATornTerminalEntry() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("journal-torn-tail").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + index.claimAndPersist(sealedRegion(0, 0, 1L, "iris:first")); + index.claimAndPersist(sealedRegion(1, 0, 1L, "iris:second")); + Path journal = onlyJournal(index.storageDirectory()); + byte[] bytes = Files.readAllBytes(journal); + Files.write(journal, Arrays.copyOf(bytes, bytes.length - 3)); + + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot); + + assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:first")), loaded.get(0, 0)); + assertTrue(loaded.get(1, 0).isEmpty()); + assertTrue(Files.size(journal) < bytes.length); + } + + @Test + public void journalReplayRejectsInteriorCorruption() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("journal-corrupt-interior").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + index.claimAndPersist(sealedRegion(0, 0, 1L, "iris:first")); + index.claimAndPersist(sealedRegion(1, 0, 1L, "iris:second")); + Path journal = onlyJournal(index.storageDirectory()); + byte[] bytes = Files.readAllBytes(journal); + bytes[16] ^= 0x20; + Files.write(journal, bytes); + + IOException error = assertThrows(IOException.class, () -> GenerationSemanticIndex.loadRequired(dimensionRoot)); + + assertTrue(error.getMessage().contains("interior entry")); + } + + @Test + public void duplicateJournalEntriesReplayIdempotently() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("journal-idempotent").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + ChunkGenerationSemantics claim = sealedRegion(0, 0, 1L, "iris:temperate"); + index.claimAndPersist(claim); + Path journal = onlyJournal(index.storageDirectory()); + byte[] entry = Files.readAllBytes(journal); + Files.write(journal, entry, StandardOpenOption.APPEND); + + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot); + + assertEquals(1, loaded.recordCount()); + assertEquals(Optional.of(claim), loaded.get(0, 0)); + assertEquals(1, sealedClaims(loaded, 1L).size()); + } + + @Test + public void boundedCacheEvictsAndReloadsUncompactedClaimsWithoutLosingTruth() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("journal-cache-eviction").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + for (int regionX = 0; regionX < 270; regionX++) { + index.claimAndPersist(sealedRegion(regionX << 5, 0, 1L, "iris:temperate")); + if (regionX < 10) { + index.claimAndPersist(sealedRegion((regionX << 5) + 1, 0, 1L, "iris:temperate")); + } + assertTrue(index.cachedRegionCount() <= 64); + assertTrue(index.cachedSummaryCount() <= 256); + } + + assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:temperate")), index.get(0, 0)); + assertTrue(index.cachedSummaryCount() <= 256); + assertTrue(index.cachedRegionCount() <= 64); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot); + assertEquals(280, loaded.recordCount()); + assertTrue(loaded.cachedSummaryCount() <= 256); + assertEquals(280, sealedClaims(loaded, 1L).size()); + assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:temperate")), loaded.get(0, 0)); + } + + @Test + public void keyedNearestLoadsOnlyRegionsInThatPosting() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("keyed-nearest-lazy").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot); + for (int regionX = 0; regionX < 70; regionX++) { + String region = regionX == 0 ? "iris:target" : "iris:unrelated"; + index.claimAndPersist(sealedRegion(regionX << 5, 0, 1L, region)); + } + index.compactJournals(); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot); + long before = loaded.regionDecodeCount(); + assertEquals(0L, before); + assertEquals(0, loaded.cachedRegionCount()); + + GenerationSemanticIndex.Match match = loaded.findNearest( + GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:target", + new ChunkGenerationSemantics.BlockPosition(8, 0, 8), + 5000 + ) + ).orElseThrow(); + + assertEquals(0, match.chunk().chunkX()); + assertEquals(before + 1L, loaded.regionDecodeCount()); + assertTrue(loaded.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:missing", + new ChunkGenerationSemantics.BlockPosition(8, 0, 8), + 5000 + )).isEmpty()); + assertEquals(before + 1L, loaded.regionDecodeCount()); + } + + @Test + public void lazilyDecodedUnknownRecordFlagsFailClosedEvenWithAValidChecksum() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("unknown-record-flags").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).seal().build()); + Path shard = onlyShard(index.storageDirectory()); + byte[] encoded = Files.readAllBytes(shard); + encoded[firstRecordFlagsOffset(encoded)] = 2; + rewriteChecksum(encoded); + replaceReferencedShard(dimensionRoot, shard, encoded); + + GenerationSemanticIndex loaded = GenerationSemanticIndex.load(dimensionRoot); + IllegalStateException error = assertThrows(IllegalStateException.class, () -> loaded.get(0, 0)); + + assertTrue(error.getCause().getMessage().contains("unsupported semantic record flags 2")); + } + + @Test + public void symbolicLinkStorageAncestorsAreRejected() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("symlink-dimension").toPath(); + Path external = temporaryFolder.newFolder("symlink-external").toPath(); + Files.createDirectories(external.resolve("generation/semantics")); + Files.createSymbolicLink(dimensionRoot.resolve("iris"), external); + + assertThrows(IOException.class, () -> GenerationSemanticIndex.load(dimensionRoot)); + } + + @Test + public void queriesValidateKindsKeysRadiiAndActivationFilters() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("invalid-query").toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 0, 0); + + assertThrows( + IllegalArgumentException.class, + () -> GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + " Iris:Forest", + origin, + 1 + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:forest", + origin, + -1 + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationSemanticIndex.Query.forActivation( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:forest", + origin, + 1, + 0L + ) + ); + assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, + "iris:missing", + origin, + 1 + )).isEmpty()); + } + + private GenerationSemanticIndex.Match requiredMatch( + GenerationSemanticIndex index, + GenerationSemanticIndex.SemanticKind kind, + String key + ) { + return index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + kind, + key, + new ChunkGenerationSemantics.BlockPosition(-520, 80, -8), + 2 + )).orElseThrow(); + } + + private Path persistedRoot(String name) throws Exception { + Path dimensionRoot = temporaryFolder.newFolder(name).toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot); + index.recordAndPersist(completeSemantics(0, 0, 1L)); + return dimensionRoot; + } + + private static ChunkGenerationSemantics completeSemantics(int chunkX, int chunkZ, long activationId) { + return ChunkGenerationSemantics.builder(chunkX, chunkZ, activationId) + .addSurfaceBiome("iris:forest") + .addSurfaceBiome("iris:wetlands") + .addCaveBiome("iris:limestone_caves") + .addRegion("temperate") + .addRiverProfile("rivers/default") + .addRiverFeature( + "rivers/default", + HydrologyFeatureType.RIFFLE, + 5L, + (chunkX << 4) + 7, + 70, + (chunkZ << 4) + 6 + ) + .addObject("iris:oak") + .addStructure( + "minecraft:village_plains", + (chunkX << 4) + 8, + 72, + (chunkZ << 4) + 9 + ) + .seal() + .build(); + } + + private static ChunkGenerationSemantics sealedRegion( + int chunkX, + int chunkZ, + long activationId, + String regionKey + ) { + return ChunkGenerationSemantics.builder(chunkX, chunkZ, activationId) + .addRegion(regionKey) + .seal() + .build(); + } + + private static Path onlyShard(Path directory) throws Exception { + try (Stream files = Files.list(directory)) { + List shards = files.filter(path -> path.getFileName().toString().endsWith(".isem")).toList(); + assertEquals(1, shards.size()); + return shards.getFirst(); + } + } + + private static Path onlyJournal(Path directory) throws Exception { + try (Stream files = Files.list(directory)) { + List journals = files.filter(path -> path.getFileName().toString().endsWith(".iswal")) + .toList(); + assertEquals(1, journals.size()); + return journals.getFirst(); + } + } + + private static long shardCount(Path directory) throws Exception { + try (Stream files = Files.list(directory)) { + return files.filter(path -> path.getFileName().toString().endsWith(".isem")).count(); + } + } + + private static void replaceReferencedShard(Path dimensionRoot, Path previousShard, byte[] encoded) throws Exception { + int regionX = ByteBuffer.wrap(encoded, 8, Integer.BYTES).getInt(); + int regionZ = ByteBuffer.wrap(encoded, 12, Integer.BYTES).getInt(); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(encoded); + String hash = HexFormat.of().formatHex(digest); + Path directory = dimensionRoot.resolve("iris/generation/semantics"); + Path replacement = directory.resolve("r." + regionX + "." + regionZ + "." + hash + ".isem"); + Files.write(replacement, encoded); + if (!replacement.equals(previousShard)) { + Files.delete(previousShard); + } + + Path pointer = directory.resolve("r." + regionX + "." + regionZ + ".isix"); + byte[] pointerBytes = Files.readAllBytes(pointer); + System.arraycopy(digest, 0, pointerBytes, 16, digest.length); + rewriteChecksum(pointerBytes); + Files.write(pointer, pointerBytes); + } + + private static void rewriteChecksum(byte[] encoded) { + int bodyLength = encoded.length - Integer.BYTES; + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue()); + } + + private static LongOpenHashSet sealedClaims( + GenerationSemanticIndex index, + long activationId + ) throws IOException { + LongOpenHashSet claims = new LongOpenHashSet(); + index.forEachSealedClaim( + activationId, + (chunkX, chunkZ) -> claims.add(ChunkGenerationOwnership.packChunk(chunkX, chunkZ)) + ); + return claims; + } + + private static int firstRecordFlagsOffset(byte[] encoded) { + ByteBuffer input = ByteBuffer.wrap(encoded); + int paletteSize = input.getInt(20); + int summaryLength = input.getInt(24); + input.position(28 + summaryLength); + for (int index = 0; index < paletteSize; index++) { + int keyLength = Short.toUnsignedInt(input.getShort()); + input.position(input.position() + keyLength); + } + return input.position() + Short.BYTES + Long.BYTES; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticQueriesTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticQueriesTest.java new file mode 100644 index 000000000..22be83934 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticQueriesTest.java @@ -0,0 +1,253 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.object.IrisDimension; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +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.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +public final class GenerationSemanticQueriesTest { + @Test + public void mergesExactHistoricalStructureStartsWithEligibleActiveCandidates() { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); + when(complex.allowsNewGenerationChunk(1, 0)).thenReturn(false); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true); + when(history.isActiveUnowned(0, 0)).thenReturn(false); + ChunkGenerationSemantics sealedEmpty = ChunkGenerationSemantics.builder(2, 0, 2L).seal().build(); + when(history.semantics(2, 0)).thenReturn(Optional.of(sealedEmpty)); + GenerationSemanticIndex.Match recorded = new GenerationSemanticIndex.Match( + GenerationSemanticIndex.SemanticKind.STRUCTURE, + "old-tower", + new GenerationSemanticIndex.ChunkReference(6, 0, 1L), + Optional.of(new ChunkGenerationSemantics.BlockPosition(100, 73, 5))); + when(history.findRecorded(any())).thenReturn(Optional.of(recorded)); + IrisStructureLocator.LocateResult nearer = foundStructure(48, 90, 0); + + try (MockedStatic structures = mockStatic(IrisStructureLocator.class)) { + structures.when(() -> IrisStructureLocator.locate( + eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any())) + .thenAnswer(invocation -> { + IrisStructureLocator.CandidateFilter filter = invocation.getArgument(5); + assertFalse(filter.accept(0, 0)); + assertFalse(filter.accept(1, 0)); + assertFalse(filter.accept(2, 0)); + assertTrue(filter.accept(3, 0)); + return nearer; + }); + assertSame(nearer, GenerationSemanticQueries.nearestStructure(engine, "OLD-TOWER", 0, 0, 20)); + + structures.when(() -> IrisStructureLocator.locate( + eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any())) + .thenReturn(foundStructure(120, 90, 0)); + assertEquals(foundStructure(100, 73, 5), + GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20)); + + structures.when(() -> IrisStructureLocator.locate( + eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any())) + .thenReturn(foundStructure(100, 90, 5)); + assertEquals(foundStructure(100, 73, 5), + GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20)); + + structures.when(() -> IrisStructureLocator.locate( + eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any())) + .thenReturn(new IrisStructureLocator.LocateResult(IrisStructureLocator.LocateStatus.NOT_FOUND, 0, 0, 0)); + assertEquals(foundStructure(100, 73, 5), + GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20)); + } + } + + @Test + public void exhaustedStructureSearchDoesNotClaimARecordedResultIsNearest() { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + IrisStructureLocator.LocateResult limit = new IrisStructureLocator.LocateResult( + IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); + try (MockedStatic structures = mockStatic(IrisStructureLocator.class)) { + structures.when(() -> IrisStructureLocator.locate( + eq(engine), eq("tower"), eq(0), eq(0), eq(20), any())).thenReturn(limit); + assertSame(limit, GenerationSemanticQueries.nearestStructure(engine, "tower", 0, 0, 20)); + } + } + + private static IrisStructureLocator.LocateResult foundStructure(int x, int y, int z) { + return new IrisStructureLocator.LocateResult(IrisStructureLocator.LocateStatus.FOUND, x, y, z); + } + + @Test + public void rejectsTaperedPredictionsAndMergesTheNearestEligibleRiver() { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + IrisDimension dimension = mock(IrisDimension.class); + TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + GenerationActivation activation = mock(GenerationActivation.class); + HydrologyFeatureRef tapered = feature(1L, 8, 20, 0); + HydrologyFeatureRef eligible = feature(2L, 40, 20, 0); + ChunkGenerationSemantics.RiverFeatureOccurrence recordedOccurrence = + new ChunkGenerationSemantics.RiverFeatureOccurrence( + "iris:river", + HydrologyFeatureType.RIFFLE, + 3L, + new ChunkGenerationSemantics.BlockPosition(100, 70, 0) + ); + GenerationSemanticIndex.RiverMatch recorded = new GenerationSemanticIndex.RiverMatch( + recordedOccurrence, + new GenerationSemanticIndex.ChunkReference(6, 0, 1L) + ); + + when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(complex.getTransitionGenerationPlan()).thenReturn(transition); + when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); + when(transition.hydrologyWeightAt(anyInt(), anyInt())).thenAnswer(invocation -> + invocation.getArgument(0) == tapered.x() ? 0.5D : 1D); + when(dimension.getMinHeight()).thenReturn(-64); + when(router.history()).thenReturn(history); + when(history.findRecordedRiver(any())).thenReturn(Optional.of(recorded)); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true); + when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty()); + when(history.activeActivation()).thenReturn(activation); + when(activation.activationId()).thenReturn(2L); + when(runtime.nearestFeature( + any(), nullable(String.class), anyInt(), anyInt(), anyInt(), any(), any() + )).thenAnswer(invocation -> firstEligible( + List.of(tapered, eligible), + invocation.getArgument(5) + )); + + GenerationSemanticQueries.RiverResult active = GenerationSemanticQueries.nearestRiver( + engine, + HydrologyFeatureQuery.parse("surface"), + 0, + 0, + 200, + ignored -> { } + ).orElseThrow(); + + assertEquals(GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION, active.source()); + assertEquals(40, active.x()); + assertEquals(-44, active.y()); + + HydrologyFeatureRef fartherActive = feature(4L, 120, 20, 0); + doAnswer(invocation -> firstEligible( + List.of(tapered, fartherActive), + invocation.getArgument(5) + )).when(runtime).nearestFeature( + any(), nullable(String.class), anyInt(), anyInt(), anyInt(), any(), any() + ); + + GenerationSemanticQueries.RiverResult historical = GenerationSemanticQueries.nearestRiver( + engine, + HydrologyFeatureQuery.parse("surface"), + 0, + 0, + 200, + ignored -> { } + ).orElseThrow(); + + assertEquals(GenerationSemanticQueries.RiverSource.RECORDED, historical.source()); + assertEquals(100, historical.x()); + assertEquals(70, historical.y()); + } + + @Test + public void returnsRecordedRiverWhenTheActivePackHasNoHydrologyRuntime() { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + ChunkGenerationSemantics.RiverFeatureOccurrence occurrence = + new ChunkGenerationSemantics.RiverFeatureOccurrence( + "iris:legacy_river", + HydrologyFeatureType.WATERFALL, + 9L, + new ChunkGenerationSemantics.BlockPosition(-30, 55, 12) + ); + + when(engine.getComplex()).thenReturn(complex); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(history.findRecordedRiver(any())).thenReturn(Optional.of( + new GenerationSemanticIndex.RiverMatch( + occurrence, + new GenerationSemanticIndex.ChunkReference(-2, 0, 1L) + ) + )); + + GenerationSemanticQueries.RiverResult result = GenerationSemanticQueries.nearestRiver( + engine, + HydrologyFeatureQuery.parse("waterfall"), + 0, + 0, + 100, + ignored -> { } + ).orElseThrow(); + + assertEquals(GenerationSemanticQueries.RiverSource.RECORDED, result.source()); + assertEquals(HydrologyFeatureType.WATERFALL, result.type()); + assertEquals("iris:legacy_river", result.profileKey().orElseThrow()); + } + + private static Optional firstEligible( + List candidates, + Predicate eligibility + ) { + for (HydrologyFeatureRef candidate : candidates) { + if (eligibility.test(candidate)) { + return Optional.of(candidate); + } + } + return Optional.empty(); + } + + private static HydrologyFeatureRef feature(long id, int x, int y, int z) { + return new HydrologyFeatureRef( + id, + HydrologyFeatureType.RIFFLE, + 10L, + 11L, + x, + y, + z, + 1, + 0, + false + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationTransitionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationTransitionTest.java new file mode 100644 index 000000000..4fcb68529 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationTransitionTest.java @@ -0,0 +1,83 @@ +package art.arcane.iris.engine.history; + +import com.google.gson.JsonObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationTransitionTest { + private static final String BOUNDARY = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String TERRAIN = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + @Test + public void completionPreservesTheImmutableTransitionRecipe() { + GenerationTransition pending = GenerationTransition.pending(256); + + GenerationTransition completed = pending.complete(BOUNDARY, TERRAIN); + + assertEquals(GenerationTransition.CURRENT_ALGORITHM_VERSION, completed.algorithmVersion()); + assertEquals(256, completed.widthBlocks()); + assertEquals(BOUNDARY, completed.boundaryIdentity()); + assertEquals(TERRAIN, completed.terrainSignatureIdentity()); + assertFalse(pending.isComplete()); + assertTrue(completed.isComplete()); + assertSame(completed, completed.complete(BOUNDARY, TERRAIN)); + assertThrows(IllegalStateException.class, () -> completed.complete(TERRAIN, BOUNDARY)); + } + + @Test + public void strictJsonRoundTripRetainsPendingAndCompleteStates() { + GenerationTransition pending = GenerationTransition.pending(512); + GenerationTransition completed = pending.complete(BOUNDARY, TERRAIN); + + assertEquals(pending, GenerationTransition.fromJson(pending.toJson())); + assertEquals(completed, GenerationTransition.fromJson(completed.toJson())); + + JsonObject unknown = completed.toJson(); + unknown.addProperty("unexpected", true); + assertThrows(IllegalArgumentException.class, () -> GenerationTransition.fromJson(unknown)); + } + + @Test + public void rejectsInvalidWidthsAndPartiallyCompletedSnapshots() { + assertThrows( + IllegalArgumentException.class, + () -> GenerationTransition.pending(GenerationTransition.MINIMUM_WIDTH_BLOCKS - 1) + ); + assertThrows( + IllegalArgumentException.class, + () -> GenerationTransition.pending(GenerationTransition.MAXIMUM_WIDTH_BLOCKS + 1) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationTransition( + GenerationTransition.CURRENT_ALGORITHM_VERSION, + 256, + BOUNDARY, + null + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new GenerationTransition(0, 256, BOUNDARY, TERRAIN) + ); + } + + @Test + public void activationRoundTripRetainsItsTransition() { + GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256) + .completeTransition(BOUNDARY, TERRAIN); + + GenerationActivation restored = GenerationActivation.fromJson(activation.toJson()); + + assertEquals(activation, restored); + assertEquals(256, restored.transition().widthBlocks()); + assertEquals(BOUNDARY, restored.transition().boundaryIdentity()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/IrisBoundarySignatureSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/history/IrisBoundarySignatureSamplerTest.java new file mode 100644 index 000000000..ed740aec2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/IrisBoundarySignatureSamplerTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisBoundarySignatureSamplerTest { + @Test + public void recordsTheEffectiveCeilingInInternalCoordinatesAndBiomesInWorldCoordinates() throws Exception { + IrisEngine engine = engine(); + UpperDimensionContext upper = mock(UpperDimensionContext.class); + when(engine.getUpperContext()).thenReturn(upper); + when(upper.getEffectiveSurfaceY(-17, 8)).thenReturn(200); + + TerrainBoundarySignature signature = IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8); + + assertEquals(OptionalInt.of(55), signature.upperCeilingDepth()); + assertEquals(64, signature.oceanFloorHeight()); + assertEquals(-64, signature.sampleY(0)); + assertEquals(188, signature.sampleY(63)); + verify(upper).getEffectiveSurfaceY(-17, 8); + } + + @Test + public void recordsAbsentUpperMassWithAndWithoutAnUpperContext() throws Exception { + IrisEngine engine = engine(); + assertTrue(IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8).upperCeilingDepth().isEmpty()); + UpperDimensionContext upper = mock(UpperDimensionContext.class); + when(engine.getUpperContext()).thenReturn(upper); + when(upper.getEffectiveSurfaceY(-17, 8)).thenReturn(256); + assertTrue(IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8).upperCeilingDepth().isEmpty()); + } + + private static IrisEngine engine() { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(256); + when(engine.getMinHeight()).thenReturn(-64); + when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + when(complex.historicalPhysicalBiomeKeyAt(anyInt(), anyInt(), anyInt())) + .thenReturn(Optional.of("minecraft:plains")); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/RegionGenerationOwnershipTest.java b/core/src/test/java/art/arcane/iris/engine/history/RegionGenerationOwnershipTest.java new file mode 100644 index 000000000..469c6954b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/RegionGenerationOwnershipTest.java @@ -0,0 +1,157 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.CRC32; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class RegionGenerationOwnershipTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void roundTripPreservesAssignmentsAcrossNegativeRegionCoordinates() throws Exception { + Path directory = temporaryFolder.newFolder("ownership").toPath(); + RegionGenerationOwnership ownership = new RegionGenerationOwnership(-1, -1); + + assertTrue(ownership.assign(-32, -32, 7L)); + assertTrue(ownership.assign(-1, -1, 7L)); + assertTrue(ownership.assign(-17, -9, 19L)); + assertTrue(ownership.persist(directory)); + + Path file = directory.resolve(RegionGenerationOwnership.fileName(-1, -1)); + assertTrue(Files.size(file) < 512L); + RegionGenerationOwnership loaded = RegionGenerationOwnership.read(file); + + assertEquals(3, loaded.assignmentCount()); + assertEquals(7L, loaded.resolve(-32, -32, 31L)); + assertEquals(7L, loaded.resolve(-1, -1, 31L)); + assertEquals(19L, loaded.resolve(-17, -9, 31L)); + assertEquals(31L, loaded.resolve(-16, -8, 31L)); + assertFalse(loaded.persist(directory)); + } + + @Test + public void everyPaletteWidthRoundTripsWithoutBitBoundaryLoss() throws Exception { + Path directory = temporaryFolder.newFolder("wide-palette").toPath(); + RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0); + for (int chunkZ = 0; chunkZ < 32; chunkZ++) { + for (int chunkX = 0; chunkX < 32; chunkX++) { + int index = chunkX + chunkZ * 32; + ownership.assign(chunkX, chunkZ, index + 1L); + } + } + + ownership.persist(directory); + Path file = directory.resolve(RegionGenerationOwnership.fileName(0, 0)); + RegionGenerationOwnership loaded = RegionGenerationOwnership.read(file); + + assertEquals(1_024, loaded.assignmentCount()); + assertTrue(Files.size(file) < 10_000L); + for (int chunkZ = 0; chunkZ < 32; chunkZ++) { + for (int chunkX = 0; chunkX < 32; chunkX++) { + int index = chunkX + chunkZ * 32; + assertEquals(index + 1L, loaded.explicitActivation(chunkX, chunkZ)); + } + } + } + + @Test + public void assignmentsAreIdempotentButCannotChangeOwner() { + RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0); + + assertTrue(ownership.assign(3, 5, 12L)); + assertFalse(ownership.assign(3, 5, 12L)); + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> ownership.assign(3, 5, 13L) + ); + + assertTrue(error.getMessage().contains("already belongs to activation 12")); + assertEquals(12L, ownership.explicitActivation(3, 5)); + } + + @Test + public void checksumCorruptionFailsClosed() throws Exception { + Path file = persistedSingleAssignment("checksum"); + byte[] encoded = Files.readAllBytes(file); + encoded[20] ^= 0x10; + Files.write(file, encoded); + + IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file)); + + assertTrue(error.getMessage().contains("checksum mismatch")); + } + + @Test + public void truncatedDataFailsClosed() throws Exception { + Path file = persistedSingleAssignment("truncated"); + byte[] encoded = Files.readAllBytes(file); + Files.write(file, java.util.Arrays.copyOf(encoded, encoded.length - 3)); + + IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file)); + + assertTrue(error.getMessage().contains("Invalid generation ownership file")); + } + + @Test + public void unsupportedVersionFailsClosedEvenWithAValidChecksum() throws Exception { + Path file = persistedSingleAssignment("version"); + byte[] encoded = Files.readAllBytes(file); + encoded[4] = 0; + encoded[5] = 2; + rewriteChecksum(encoded); + Files.write(file, encoded); + + IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file)); + + assertTrue(error.getMessage().contains("unsupported format version 2")); + } + + @Test + public void regionCoordinatesOutsideTheChunkRangeFailClosed() throws Exception { + Path file = persistedSingleAssignment("coordinate-range"); + byte[] encoded = Files.readAllBytes(file); + ByteBuffer.wrap(encoded, 8, Integer.BYTES).putInt(Integer.MAX_VALUE); + rewriteChecksum(encoded); + Files.write(file, encoded); + + IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file)); + + assertTrue(error.getMessage().contains("region coordinate is outside")); + } + + @Test + public void activationIdsMustBePositive() { + RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0); + + assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, 0L)); + assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, -1L)); + assertThrows(IllegalArgumentException.class, () -> ownership.resolve(0, 0, 0L)); + } + + private Path persistedSingleAssignment(String directoryName) throws Exception { + Path directory = temporaryFolder.newFolder(directoryName).toPath(); + RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0); + ownership.assign(0, 0, 1L); + ownership.persist(directory); + return directory.resolve(RegionGenerationOwnership.fileName(0, 0)); + } + + private static void rewriteChecksum(byte[] encoded) { + int bodyLength = encoded.length - Integer.BYTES; + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStoreTest.java new file mode 100644 index 000000000..554f517b5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStoreTest.java @@ -0,0 +1,445 @@ +package art.arcane.iris.engine.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalInt; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.zip.CRC32; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class TerrainBoundarySignatureStoreTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preservesPresentAndAbsentUpperCeilingsAcrossShardReloads() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("upper-ceiling-shards").toPath()); + TerrainBoundarySignature.Samples samples = new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 4, 0), + new TerrainBoundarySignature.BiomeEncoding(List.of(), new short[0])); + TerrainBoundarySignature present = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(-17, 8, 64, 64, OptionalInt.empty(), OptionalInt.of(80)), samples); + TerrainBoundarySignature absent = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(257, 8, 64, 64, OptionalInt.empty(), OptionalInt.empty()), samples); + store.publish(2L, List.of(present, absent)); + + TerrainBoundarySignatureStore.Snapshot reopened = store.load(2L); + + assertEquals(OptionalInt.of(80), reopened.signatureAt(-17, 8).orElseThrow().upperCeilingDepth()); + assertTrue(reopened.signatureAt(257, 8).orElseThrow().upperCeilingDepth().isEmpty()); + } + + @Test + public void preservesEveryCompactFieldAtNegativeCoordinates() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("signature-dimension").toPath(); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignature negative = signature( + -17, + Integer.MIN_VALUE, + 93, + -48, + OptionalInt.of(62), + -64, + 32, + List.of("iris:forest", "iris:cavern", "iris:forest"), + new short[]{2, 1, 0} + ); + TerrainBoundarySignature positive = signature( + Integer.MAX_VALUE, + 4, + 77, + 41, + OptionalInt.empty(), + 0, + 1, + List.of(), + new short[0] + ); + + TerrainBoundarySignatureStore.Snapshot published = store.publish(19L, List.of(positive, negative)); + TerrainBoundarySignatureStore.Snapshot loaded = store.load(19L); + + assertEquals(dimensionRoot.toAbsolutePath().resolve("iris/generation/boundaries"), store.directory()); + assertEquals(19L, published.activationId()); + assertEquals(published.identity(), loaded.identity()); + assertEquals(2, loaded.size()); + assertSignatureEquals(negative, loaded.signatureAt(-17, Integer.MIN_VALUE).orElseThrow()); + assertSignatureEquals(positive, loaded.signatureAt(Integer.MAX_VALUE, 4).orElseThrow()); + assertTrue(loaded.signatureAt(0, 0).isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> loaded.signatures().clear()); + } + + @Test + public void writesDeterministicallyRegardlessOfCollectionOrder() throws Exception { + Path firstRoot = temporaryFolder.newFolder("deterministic-first").toPath(); + Path secondRoot = temporaryFolder.newFolder("deterministic-second").toPath(); + TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(firstRoot); + TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(secondRoot); + TerrainBoundarySignature first = signature( + -1, + 9, + 80, + 40, + OptionalInt.empty(), + -32, + 16, + List.of("iris:snowy_森林"), + new short[]{0, 0} + ); + TerrainBoundarySignature second = signature( + 12, + -8, + 70, + 35, + OptionalInt.of(50), + 5, + 4, + List.of("iris:desert"), + new short[]{0} + ); + + TerrainBoundarySignatureStore.Snapshot firstSnapshot = firstStore.publish(8L, List.of(first, second)); + TerrainBoundarySignatureStore.Snapshot secondSnapshot = secondStore.publish(8L, List.of(second, first)); + + assertEquals(firstSnapshot.identity(), secondSnapshot.identity()); + assertArrayEquals( + Files.readAllBytes(firstStore.snapshotPath(8L)), + Files.readAllBytes(secondStore.snapshotPath(8L)) + ); + } + + @Test + public void streamedBoundaryMatchesCollectionPublicationAcrossCells() throws Exception { + TerrainBoundarySignatureStore streamed = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("streamed-boundary").toPath() + ); + TerrainBoundarySignatureStore collected = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("collected-boundary").toPath() + ); + List chunks = new ArrayList<>(); + for (int index = -40; index < 40; index++) { + chunks.add(new GenerationBoundary.ChunkCoordinate(index * 3, index % 5)); + } + GenerationBoundary boundary = GenerationBoundary.freeze("a".repeat(64), chunks); + List signatures = new ArrayList<>(); + boundary.forEachExposedBlockColumn((x, z) -> signatures.add(simpleSignature(x, z, 81))); + + TerrainBoundarySignatureStore.Snapshot first = streamed.publish( + 2L, boundary, (x, z) -> simpleSignature(x, z, 81) + ); + TerrainBoundarySignatureStore.Snapshot second = collected.publish(2L, signatures); + + assertEquals(boundary.exposedBlockColumnCount(), first.size()); + assertEquals(second.identity(), first.identity()); + assertArrayEquals(Files.readAllBytes(collected.snapshotPath(2L)), Files.readAllBytes(streamed.snapshotPath(2L))); + boundary.forEachExposedBlockColumn((x, z) -> assertTrue(first.signatureAt(x, z).isPresent())); + assertTrue(first.cachedShardCount() <= 64); + } + + @Test + public void streamedBoundaryDoesNotPublishOnMissingOrMisplacedSamples() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("failed-streamed-boundary").toPath() + ); + GenerationBoundary boundary = GenerationBoundary.freeze( + "a".repeat(64), List.of(new GenerationBoundary.ChunkCoordinate(0, 0)) + ); + + assertThrows(IOException.class, () -> store.publish(2L, boundary, (x, z) -> { + throw new IOException("sample failed"); + })); + assertFalse(Files.exists(store.snapshotPath(2L))); + assertThrows(IOException.class, () -> store.publish(2L, boundary, (x, z) -> simpleSignature(x + 1, z, 81))); + assertFalse(Files.exists(store.snapshotPath(2L))); + assertEquals(60, store.publish(2L, boundary, (x, z) -> simpleSignature(x, z, 81)).size()); + } + + @Test + public void canonicalizesEquivalentBiomePalettesBeforeIdentityAndPublication() throws Exception { + Path firstRoot = temporaryFolder.newFolder("palette-first").toPath(); + Path secondRoot = temporaryFolder.newFolder("palette-second").toPath(); + TerrainBoundarySignature first = signature( + 5, + -7, + 80, + 40, + OptionalInt.empty(), + -64, + 32, + List.of("iris:z", "iris:a", "iris:z", "iris:unused"), + new short[]{0, 1, 2} + ); + TerrainBoundarySignature equivalent = signature( + 5, + -7, + 80, + 40, + OptionalInt.empty(), + -64, + 32, + List.of("iris:a", "iris:z"), + new short[]{1, 0, 1} + ); + TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(firstRoot); + TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(secondRoot); + + TerrainBoundarySignatureStore.Snapshot firstSnapshot = firstStore.publish(13L, List.of(first)); + TerrainBoundarySignatureStore.Snapshot repeated = firstStore.publish(13L, List.of(equivalent)); + TerrainBoundarySignatureStore.Snapshot secondSnapshot = secondStore.publish(13L, List.of(equivalent)); + + assertEquals(firstSnapshot.identity(), repeated.identity()); + assertEquals(firstSnapshot.identity(), secondSnapshot.identity()); + assertArrayEquals( + Files.readAllBytes(firstStore.snapshotPath(13L)), + Files.readAllBytes(secondStore.snapshotPath(13L)) + ); + TerrainBoundarySignature loaded = firstStore.load(13L).signatureAt(5, -7).orElseThrow(); + assertEquals(List.of("iris:a", "iris:z"), loaded.samples().biomes().palette()); + assertArrayEquals(new short[]{1, 0, 1}, loaded.samples().biomes().paletteIndices()); + } + + @Test + public void keepsActivationSnapshotImmutableAndRejectsDuplicateCoordinates() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("immutable-signature-dimension").toPath(); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignature original = simpleSignature(-5, 6, 81); + TerrainBoundarySignature changed = simpleSignature(-5, 6, 82); + + TerrainBoundarySignatureStore.Snapshot first = store.publish(2L, List.of(original)); + TerrainBoundarySignatureStore.Snapshot repeated = store.publish(2L, List.of(original)); + + assertEquals(first.identity(), repeated.identity()); + assertThrows(FileAlreadyExistsException.class, () -> store.publish(2L, List.of(changed))); + assertThrows(IllegalArgumentException.class, () -> store.publish(3L, List.of(original, changed))); + assertEquals(81, store.load(2L).signatureAt(-5, 6).orElseThrow().surfaceHeight()); + } + + @Test + public void failsClosedForChecksumCorruptionAndTruncation() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("corrupt-signature-dimension").toPath(); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + store.publish(4L, List.of(simpleSignature(1, -2, 70))); + Path corrupted = store.snapshotPath(4L); + byte[] corruptedBytes = Files.readAllBytes(corrupted); + corruptedBytes[8] ^= 0x01; + Files.write(corrupted, corruptedBytes); + + IOException checksumFailure = assertThrows(IOException.class, () -> store.load(4L)); + assertTrue(checksumFailure.getMessage().contains("checksum")); + + store.publish(5L, List.of(simpleSignature(-3, 7, 72))); + Path truncated = store.snapshotPath(5L); + byte[] complete = Files.readAllBytes(truncated); + Files.write(truncated, Arrays.copyOf(complete, complete.length - 2)); + assertThrows(IOException.class, () -> store.load(5L)); + } + + @Test + public void verifiesContentIdentityAndDeclaredBounds() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("verified-signature-dimension").toPath(); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + store.publish(9L, List.of(simpleSignature(-9, -10, 90))); + Path identitySnapshot = store.snapshotPath(9L); + byte[] identityBytes = Files.readAllBytes(identitySnapshot); + identityBytes[20] ^= 0x01; + updateChecksum(identityBytes); + Files.write(identitySnapshot, identityBytes); + + IOException identityFailure = assertThrows(IOException.class, () -> store.load(9L)); + assertTrue(identityFailure.getMessage().contains("identity mismatch")); + + store.publish(10L, List.of()); + Path boundedSnapshot = store.snapshotPath(10L); + byte[] boundedBytes = Files.readAllBytes(boundedSnapshot); + ByteBuffer.wrap(boundedBytes).putInt(16, Integer.MAX_VALUE); + updateChecksum(boundedBytes); + Files.write(boundedSnapshot, boundedBytes); + assertThrows(IOException.class, () -> store.load(10L)); + } + + @Test + public void rejectsInvalidActivationAndMalformedUtf8() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("invalid-signature-dimension").toPath(); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + assertThrows(IllegalArgumentException.class, () -> store.publish(0L, List.of())); + String malformed = new String(new char[]{'i', 'r', 'i', 's', ':', '\uD800'}); + TerrainBoundarySignature invalid = signature( + 0, + 0, + 1, + 0, + OptionalInt.empty(), + 0, + 1, + List.of(malformed), + new short[]{0} + ); + assertThrows(IOException.class, () -> store.publish(1L, List.of(invalid))); + assertFalse(Files.exists(store.snapshotPath(1L))); + } + + @Test + public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception { + Path probe = temporaryFolder.newFolder("terrain-symlink-probe").toPath(); + Path probeTarget = temporaryFolder.newFolder("terrain-symlink-probe-target").toPath(); + try { + Files.createSymbolicLink(probe.resolve("link"), probeTarget); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + for (String component : new String[]{"dimension", "iris", "generation", "boundaries"}) { + assertStorageLinkRejected(component); + } + } + + @Test + public void concurrentStoreInstancesCannotClobberTerrainSignatures() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("terrain-race").toPath(); + TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignature firstSignature = simpleSignature(1, 2, 70); + TerrainBoundarySignature secondSignature = simpleSignature(3, 4, 90); + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit( + () -> publishAfterBarrier(firstStore, 22L, firstSignature, barrier) + ); + Future second = executor.submit( + () -> publishAfterBarrier(secondStore, 22L, secondSignature, barrier) + ); + + assertTrue(first.get() ^ second.get()); + TerrainBoundarySignatureStore.Snapshot loaded = firstStore.load(22L); + assertTrue(loaded.signatureAt(1, 2).isPresent() ^ loaded.signatureAt(3, 4).isPresent()); + } finally { + executor.shutdownNow(); + } + } + + private void assertStorageLinkRejected(String component) throws Exception { + Path container = temporaryFolder.newFolder("terrain-linked-" + component).toPath(); + Path dimensionRoot = container.resolve("dimension"); + Path external = temporaryFolder.newFolder("terrain-linked-target-" + component).toPath(); + if (component.equals("dimension")) { + Files.createSymbolicLink(dimensionRoot, external); + } else { + Files.createDirectory(dimensionRoot); + Path iris = dimensionRoot.resolve("iris"); + if (component.equals("iris")) { + Files.createSymbolicLink(iris, external); + } else { + Files.createDirectory(iris); + Path generation = iris.resolve("generation"); + if (component.equals("generation")) { + Files.createSymbolicLink(generation, external); + } else { + Files.createDirectory(generation); + Files.createSymbolicLink(generation.resolve("boundaries"), external); + } + } + } + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + + assertThrows(IOException.class, () -> store.publish(1L, List.of())); + } + + private static boolean publishAfterBarrier( + TerrainBoundarySignatureStore store, + long activationId, + TerrainBoundarySignature signature, + CyclicBarrier barrier + ) throws Exception { + barrier.await(); + try { + store.publish(activationId, List.of(signature)); + return true; + } catch (IOException expected) { + return false; + } + } + + private static TerrainBoundarySignature simpleSignature(int blockX, int blockZ, int surfaceHeight) { + return signature( + blockX, + blockZ, + surfaceHeight, + surfaceHeight - 20, + OptionalInt.of(surfaceHeight - 10), + -64, + 32, + List.of("iris:plains"), + new short[]{0, 0} + ); + } + + private static TerrainBoundarySignature signature( + int blockX, + int blockZ, + int surfaceHeight, + int oceanFloorHeight, + OptionalInt fluidHeight, + int minimumY, + int sampleStep, + List palette, + short[] paletteIndices + ) { + TerrainBoundarySignature.VerticalLayout layout = new TerrainBoundarySignature.VerticalLayout( + minimumY, + sampleStep, + paletteIndices.length + ); + TerrainBoundarySignature.BiomeEncoding biomeEncoding = + new TerrainBoundarySignature.BiomeEncoding(palette, paletteIndices); + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column( + blockX, + blockZ, + surfaceHeight, + oceanFloorHeight, + fluidHeight, + OptionalInt.empty() + ), + new TerrainBoundarySignature.Samples(layout, biomeEncoding) + ); + } + + private static void assertSignatureEquals( + TerrainBoundarySignature expected, + TerrainBoundarySignature actual + ) { + assertEquals(expected.column(), actual.column()); + assertEquals(expected.samples().layout(), actual.samples().layout()); + for (int index = 0; index < expected.sampleCount(); index++) { + assertEquals(expected.biomeAtSample(index), actual.biomeAtSample(index)); + } + } + + private static void updateChecksum(byte[] encoded) { + int bodyLength = encoded.length - Integer.BYTES; + CRC32 checksum = new CRC32(); + checksum.update(encoded, 0, bodyLength); + ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureTest.java b/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureTest.java new file mode 100644 index 000000000..7547bf600 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/TerrainBoundarySignatureTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class TerrainBoundarySignatureTest { + @Test + public void storesPlatformNeutralTerrainSamples() { + ArrayList palette = new ArrayList<>(List.of("iris:forest", "iris:cave")); + short[] biomeIndices = {0, 1, 1}; + + TerrainBoundarySignature.BiomeEncoding biomeEncoding = + new TerrainBoundarySignature.BiomeEncoding(palette, biomeIndices); + TerrainBoundarySignature signature = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(-1, 32, 91, 48, OptionalInt.of(63), OptionalInt.of(48)), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 32, 3), + biomeEncoding + ) + ); + + palette.set(0, "changed"); + biomeIndices[0] = 1; + + assertEquals(-1, signature.blockX()); + assertEquals(32, signature.blockZ()); + assertEquals(91, signature.surfaceHeight()); + assertEquals(48, signature.oceanFloorHeight()); + assertEquals(63, signature.fluidHeight().orElseThrow()); + assertEquals(3, signature.sampleCount()); + assertEquals(-64, signature.sampleY(0)); + assertEquals(0, signature.sampleY(2)); + assertEquals("iris:forest", signature.biomeAtSample(0)); + } + + @Test + public void returnedCompactSamplesCannotMutateSignature() { + TerrainBoundarySignature.BiomeEncoding biomes = new TerrainBoundarySignature.BiomeEncoding( + List.of("iris:forest"), new short[]{0} + ); + + short[] exportedBiomes = biomes.paletteIndices(); + exportedBiomes[0] = -1; + + assertEquals("iris:forest", biomes.biomeAtSample(0)); + assertThrows(UnsupportedOperationException.class, () -> biomes.palette().add("iris:other")); + } + + @Test + public void supportsColumnsWithoutFluid() { + TerrainBoundarySignature.Column column = + new TerrainBoundarySignature.Column(0, 0, 80, 50, OptionalInt.empty(), OptionalInt.empty()); + assertTrue(column.fluidHeight().isEmpty()); + assertTrue(column.upperCeilingDepth().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> new TerrainBoundarySignature.Column( + 0, 0, 80, 50, OptionalInt.empty(), OptionalInt.of(0))); + } + + @Test + public void rejectsInvalidSampleEncodings() { + assertThrows(IllegalArgumentException.class, + () -> new TerrainBoundarySignature.VerticalLayout(0, 0, 1)); + assertThrows(IllegalArgumentException.class, + () -> new TerrainBoundarySignature.BiomeEncoding(List.of("iris:forest"), new short[]{1})); + + TerrainBoundarySignature.VerticalLayout layout = + new TerrainBoundarySignature.VerticalLayout(0, 4, 2); + TerrainBoundarySignature.BiomeEncoding oneBiome = + new TerrainBoundarySignature.BiomeEncoding(List.of("iris:forest"), new short[]{0}); + assertThrows(IllegalArgumentException.class, + () -> new TerrainBoundarySignature.Samples(layout, oneBiome)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java b/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java new file mode 100644 index 000000000..9b48eea1f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/TransitionGenerationPlanTest.java @@ -0,0 +1,486 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class TransitionGenerationPlanTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void interpolatesPresentAndAbsentUpperDepthUsingTheSameBoundedNeighbours() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("upper-depth-neighbours").toPath()); + TerrainBoundarySignature.Samples samples = new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(-64, 4, 0), + new TerrainBoundarySignature.BiomeEncoding(List.of(), new short[0])); + TerrainBoundarySignature present = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(15, 0, 64, 64, OptionalInt.empty(), OptionalInt.of(100)), samples); + TerrainBoundarySignature absent = new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(15, 2, 64, 64, OptionalInt.empty(), OptionalInt.empty()), samples); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, store.publish(2L, List.of(present, absent))); + + assertEquals(100D, sampler.sample(16, 0).historicalUpperCeilingDepth(), 0D); + assertEquals(0D, sampler.sample(16, 2).historicalUpperCeilingDepth(), 0D); + assertEquals(50D, sampler.sample(17, 1).historicalUpperCeilingDepth(), 0D); + assertEquals(1L, sampler.candidateBuildCount()); + } + + @Test + public void bindsImmutableActivationEpochWidthAndBoundaryInputs() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("frontier-sha256", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(9L, boundary); + TransitionGenerationPlan.Specification specification = new TransitionGenerationPlan.Specification( + 9L, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + GenerationTransition.CURRENT_ALGORITHM_VERSION, + 32, + "frontier-sha256", + snapshot.identity() + ); + + TransitionGenerationPlan plan = new TransitionGenerationPlan(specification, boundary, snapshot); + + assertEquals(9L, plan.activationId()); + assertEquals(specification.oldEpochId(), plan.oldEpochId()); + assertEquals(specification.newEpochId(), plan.newEpochId()); + assertEquals(32, plan.widthBlocks()); + assertEquals(GenerationTransition.CURRENT_ALGORITHM_VERSION, plan.algorithmVersion()); + assertEquals("frontier-sha256", plan.boundaryIdentity()); + assertEquals(snapshot.identity(), plan.terrainSignatures().identity()); + assertTrue(plan.isHistoricalBlock(15, 15)); + assertEquals(0D, plan.newEpochWeightAt(15, 15), 0D); + assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D); + } + + @Test + public void computesTransitionForEnclosedNewTerritory() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of( + new GenerationBoundary.ChunkCoordinate(-1, 0), + new GenerationBoundary.ChunkCoordinate(1, 0), + new GenerationBoundary.ChunkCoordinate(0, -1), + new GenerationBoundary.ChunkCoordinate(0, 1) + )); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(2L, boundary); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(2L, "old", "new", 16, "ring", snapshot.identity()), + boundary, + snapshot + ); + + assertEquals(8D, plan.distanceToHistoricalChunks(8, 8), 0D); + assertEquals(0.5D, plan.newEpochWeightAt(8, 8), 0D); + } + + @Test + public void samplesFrozenTerrainAndSeparatelyTapersHydrology() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("terrain", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(3L, boundary); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(3L, "old", "new", 32, "terrain", snapshot.identity()), + boundary, + snapshot + ); + + TransitionGenerationPlan.TerrainSample edge = plan.terrainSampleAt(16, 8); + assertNotNull(edge.nearestSignature()); + assertEquals(1D, edge.distanceToHistoricalTerrain(), 0D); + assertEquals(64D, edge.historicalSurfaceHeight(), 0D); + assertEquals(0D, edge.hydrologyWeight(), 0D); + assertTrue(edge.newEpochWeight() > 0D); + assertTrue(edge.newEpochWeight() < 0.001D); + + assertEquals(1D, plan.newEpochWeightAt(47, 8), 0D); + assertEquals(0D, plan.hydrologyWeightAt(47, 8), 0D); + assertEquals(0.5D, plan.hydrologyWeightAt(63, 8), 0D); + assertEquals(1D, plan.hydrologyWeightAt(79, 8), 0D); + assertFalse(plan.allowsNewFootprint(31, 8, 48, 8)); + assertTrue(plan.allowsNewFootprint(47, 8, 63, 8)); + } + + @Test + public void eachNewBoundaryColumnStartsAtItsTouchingHistoricalEdgeHeight() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("varying-edge", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("varying-edge"); + Files.createDirectory(dimensionRoot); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignatureStore.Snapshot snapshot = store.publish( + 7L, + boundary.exposedBlockColumns().stream() + .map(column -> heightSignature( + column.blockX(), + column.blockZ(), + column.blockX() == 15 ? 40 + column.blockZ() * 7 : 180 + )) + .toList() + ); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(7L, "old", "new", 16, "varying-edge", snapshot.identity()), + boundary, + snapshot + ); + + for (int blockZ = 1; blockZ < 15; blockZ++) { + assertEquals( + 40D + blockZ * 7D, + plan.terrainSampleAt(16, blockZ).historicalSurfaceHeight(), + 0D + ); + } + } + + @Test + public void rejectsFootprintsWhoseInteriorCrossesHistoryOrTheTerrainBand() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("footprints", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(5L, boundary); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(5L, "old", "new", 16, "footprints", snapshot.identity()), + boundary, + snapshot + ); + + assertFalse(plan.allowsNewFootprint(-20, -20, 40, 40)); + assertFalse(plan.allowsNewFootprint(16, -20, 16, 35)); + assertTrue(plan.allowsNewFootprint(31, 8, 31, 8)); + } + + @Test + public void exposesTheNearestFrozenPhysicalBiomeUntilDiscreteHandoff() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("biomes", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0) + )); + Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("activation-biomes"); + Files.createDirectory(dimensionRoot); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(6L, boundary.exposedBlockColumns().stream() + .map(column -> biomeSignature(column.blockX(), column.blockZ())) + .toList()); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(6L, "old", "new", 16, "biomes", snapshot.identity()), + boundary, + snapshot + ); + + assertEquals("iris:middle", plan.historicalPhysicalBiomeKeyAt(16, 14, 8).orElseThrow()); + assertEquals("iris:high", plan.historicalPhysicalBiomeKeyAt(16, 18, 8).orElseThrow()); + assertTrue(plan.historicalPhysicalBiomeKeyAt(31, 14, 8).isEmpty()); + } + + @Test + public void emptyHistoryUsesTheNewEpochWithoutAStoredTerrainSample() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("empty", List.of()); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(4L, boundary); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(4L, "old", "new", 32, "empty", snapshot.identity()), + boundary, + snapshot + ); + + TransitionGenerationPlan.TerrainSample sample = plan.terrainSampleAt(-120, 240); + assertFalse(sample.hasHistoricalSignature()); + assertEquals(1D, sample.newEpochWeight(), 0D); + assertEquals(1D, sample.hydrologyWeight(), 0D); + assertTrue(plan.allowsNewDiscreteContentAt(-120, 240)); + } + + @Test + public void boundedChunkCandidatesMatchExhaustiveNearestWeightingAndTies() throws Exception { + List signatures = new ArrayList<>(); + for (int x = -160; x <= 160; x += 11) { + for (int z = -160; z <= 160; z += 13) { + signatures.add(heightSignature(x, z, Math.floorMod(x * 7 + z * 17, 220))); + } + } + signatures.add(heightSignature(-1, 0, 77)); + signatures.add(heightSignature(1, 0, 101)); + signatures.add(heightSignature(0, -1, 125)); + signatures.add(heightSignature(0, 1, 149)); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("nearest-oracle", signatures); + for (int width : new int[]{32, GenerationTransition.MAXIMUM_WIDTH_BLOCKS}) { + TransitionBoundarySampler sampler = new TransitionBoundarySampler(width, snapshot); + for (GenerationBoundary.ChunkCoordinate chunk : List.of( + new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(4, 5) + )) { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + assertMatchesExhaustive(signatures, sampler, chunk.chunkX() * 16 + localX, + chunk.chunkZ() * 16 + localZ, width * 2); + } + } + } + } + } + + @Test + public void includesSearchRadiusTiesAndExcludesMoreDistantSignatures() throws Exception { + List signatures = List.of( + heightSignature(-64, 0, 40), heightSignature(64, 0, 60), + heightSignature(0, -64, 80), heightSignature(0, 64, 100), + heightSignature(65, 0, 500) + ); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot("radius-ties", signatures)); + + assertMatchesExhaustive(signatures, sampler, 0, 0, 64); + assertEquals(70D, sampler.sample(0, 0).historicalSurfaceHeight(), 0D); + assertEquals(-64, sampler.sample(0, 0).nearestSignature().blockX()); + assertMatchesExhaustive(signatures, sampler, 0, 1, 64); + } + + @Test + public void wideFragmentedBoundaryOnlyLoadsCompetitiveCellsAndKeepsBoundedCandidates() throws Exception { + List signatures = fragmentedSignatures(); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("fragmented-nearest", signatures); + + List candidates = snapshot.nearestCandidatesForChunk(0, 0, 32_768); + + assertTrue(signatures.size() > 1_024); + assertTrue(candidates.size() <= 1_024); + assertEquals(4, candidates.size()); + assertEquals(1L, snapshot.shardLoadCount()); + assertTrue(snapshot.catalogProbeCount() <= 4_356L); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertMatchesExhaustive(signatures, sampler, x, z, 32_768); + } + } + assertEquals(1L, sampler.candidateBuildCount()); + assertEquals(1L, snapshot.shardLoadCount()); + assertTrue(snapshot.cachedShardCount() <= 64); + } + + @Test + public void farOutsideChunkDoesBoundedCatalogWorkOnceAndDoesNotReadShards() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("distant-nearest", fragmentedSignatures()); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertFalse(sampler.sample(1_000_000_000 + x, -1_000_000_000 + z).hasHistoricalSignature()); + } + } + + assertEquals(1L, sampler.candidateBuildCount()); + assertEquals(0L, sampler.shardLoadCount()); + assertTrue(sampler.catalogProbeCount() <= 4_356L); + } + + @Test + public void footprintIntersectionStopsAtTheFirstWitnessWithoutCollectingSignatures() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("footprint-stream", fragmentedSignatures()); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot); + + assertTrue(sampler.intersectsTerrainBand(Integer.MIN_VALUE, Integer.MIN_VALUE, + Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertEquals(1L, sampler.shardLoadCount()); + assertEquals(1L, sampler.catalogProbeCount()); + + TerrainBoundarySignatureStore.Snapshot exact = snapshot("footprint-edge", List.of(heightSignature(0, 0, 70))); + TransitionBoundarySampler edge = new TransitionBoundarySampler(32, exact); + assertFalse(edge.intersectsTerrainBand(32, 0, 32, 0)); + assertTrue(edge.intersectsTerrainBand(31, 0, 31, 0)); + assertFalse(edge.intersectsTerrainBand(23, 23, 23, 23)); + } + + @Test + public void rejectsInvalidOrMismatchedImmutableInputs() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("actual", List.of()); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(1L, boundary); + + assertThrows(IllegalArgumentException.class, + () -> specification(0L, "old", "new", 16, "actual", snapshot.identity())); + assertThrows(IllegalArgumentException.class, + () -> specification(1L, "same", "same", 16, "actual", snapshot.identity())); + assertThrows(IllegalArgumentException.class, + () -> specification(1L, "old", "new", 0, "actual", snapshot.identity())); + assertThrows(IllegalArgumentException.class, + () -> new TransitionGenerationPlan( + specification(1L, "old", "new", 16, "different", snapshot.identity()), + boundary, + snapshot + )); + assertThrows(IllegalArgumentException.class, + () -> new TransitionGenerationPlan( + specification(2L, "old", "new", 16, "actual", snapshot.identity()), + boundary, + snapshot + )); + assertThrows(IllegalArgumentException.class, + () -> new TransitionGenerationPlan( + specification(1L, "old", "new", 16, "actual", "f".repeat(64)), + boundary, + snapshot + )); + assertThrows(IllegalArgumentException.class, + () -> new TransitionGenerationPlan( + new TransitionGenerationPlan.Specification( + 1L, + "old", + "new", + GenerationTransition.CURRENT_ALGORITHM_VERSION + 1, + 16, + "actual", + snapshot.identity() + ), + boundary, + snapshot + )); + } + + private TerrainBoundarySignatureStore.Snapshot snapshot( + long activationId, + GenerationBoundary boundary + ) throws IOException { + Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("activation-" + activationId); + Files.createDirectory(dimensionRoot); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot); + return store.publish(activationId, boundary.exposedBlockColumns().stream() + .map(column -> signature(column.blockX(), column.blockZ())) + .toList()); + } + + private TerrainBoundarySignatureStore.Snapshot snapshot( + String name, + List signatures + ) throws IOException { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(temporaryFolder.newFolder(name).toPath()); + store.publish(1L, signatures); + return store.load(1L); + } + + private static List fragmentedSignatures() { + List signatures = new ArrayList<>(); + for (int x = -16; x <= 16; x++) { + for (int z = -16; z <= 16; z++) { + for (int local = 0; local < 4; local++) { + signatures.add(heightSignature(x * 1_024 + local * 4, z * 1_024 + local * 4, 60 + local)); + } + } + } + return signatures; + } + + private static void assertMatchesExhaustive( + List signatures, + TransitionBoundarySampler sampler, + int blockX, + int blockZ, + int searchWidth + ) { + List nearest = signatures.stream() + .filter(signature -> distanceSquared(signature, blockX, blockZ) <= (double) searchWidth * searchWidth) + .sorted(Comparator.comparingDouble((TerrainBoundarySignature signature) -> distanceSquared(signature, blockX, blockZ)) + .thenComparingInt(TerrainBoundarySignature::blockX) + .thenComparingInt(TerrainBoundarySignature::blockZ)) + .limit(4) + .toList(); + TransitionGenerationPlan.TerrainSample sampled = sampler.sample(blockX, blockZ); + assertEquals(!nearest.isEmpty(), sampled.hasHistoricalSignature()); + if (nearest.isEmpty()) { + return; + } + assertEquals(nearest.getFirst().blockX(), sampled.nearestSignature().blockX()); + assertEquals(nearest.getFirst().blockZ(), sampled.nearestSignature().blockZ()); + assertEquals(Math.sqrt(distanceSquared(nearest.getFirst(), blockX, blockZ)), sampled.distanceToHistoricalTerrain(), 0D); + double surface = nearest.getFirst().surfaceHeight(); + double floor = nearest.getFirst().oceanFloorHeight(); + if (distanceSquared(nearest.getFirst(), blockX, blockZ) > 1D) { + surface = 0D; + floor = 0D; + double totalWeight = 0D; + for (TerrainBoundarySignature signature : nearest) { + double weight = 1D / distanceSquared(signature, blockX, blockZ); + surface += signature.surfaceHeight() * weight; + floor += signature.oceanFloorHeight() * weight; + totalWeight += weight; + } + surface /= totalWeight; + floor /= totalWeight; + } + assertEquals(surface, sampled.historicalSurfaceHeight(), 0D); + assertEquals(floor, sampled.historicalOceanFloorHeight(), 0D); + } + + private static double distanceSquared(TerrainBoundarySignature signature, int blockX, int blockZ) { + long x = (long) signature.blockX() - blockX; + long z = (long) signature.blockZ() - blockZ; + return (double) x * x + (double) z * z; + } + + private static TransitionGenerationPlan.Specification specification( + long activationId, + String oldEpochId, + String newEpochId, + int widthBlocks, + String boundaryIdentity, + String terrainSignatureIdentity + ) { + return new TransitionGenerationPlan.Specification( + activationId, + oldEpochId, + newEpochId, + GenerationTransition.CURRENT_ALGORITHM_VERSION, + widthBlocks, + boundaryIdentity, + terrainSignatureIdentity + ); + } + + private static TerrainBoundarySignature signature(int blockX, int blockZ) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(0, 1, 1), + new TerrainBoundarySignature.BiomeEncoding(List.of("iris:test"), new short[]{0}) + ) + ); + } + + private static TerrainBoundarySignature biomeSignature(int blockX, int blockZ) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(0, 10, 3), + new TerrainBoundarySignature.BiomeEncoding( + List.of("iris:low", "iris:middle", "iris:high"), + new short[]{0, 1, 2}) + ) + ); + } + + private static TerrainBoundarySignature heightSignature(int blockX, int blockZ, int height) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(blockX, blockZ, height, height - 1, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples( + new TerrainBoundarySignature.VerticalLayout(0, 1, 1), + new TerrainBoundarySignature.BiomeEncoding(List.of("iris:test"), new short[]{0}) + ) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java b/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java new file mode 100644 index 000000000..ef9a25bea --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/WorldChunkInventoryTest.java @@ -0,0 +1,212 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.util.nbt.common.mca.MCAFile; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class WorldChunkInventoryTest { + private static final int SECTOR_BYTES = 4_096; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void scanReadsAllocationTablesAcrossNegativeRegionBoundaries() throws Exception { + Path world = temporaryFolder.newFolder("world").toPath(); + Path regionDirectory = Files.createDirectories(world.resolve("region")); + writeRegion(regionDirectory.resolve("r.-1.-1.mca"), new int[][]{{0, 0}, {31, 31}}); + writeRegion(regionDirectory.resolve("r.0.0.mca"), new int[][]{{0, 0}, {31, 31}}); + writeRegion(regionDirectory.resolve("r.1.-2.mca"), new int[][]{{0, 31}}); + + WorldChunkInventory inventory = WorldChunkInventory.scan(world); + + assertEquals(5, inventory.size()); + assertTrue(inventory.contains(-32, -32)); + assertTrue(inventory.contains(-1, -1)); + assertTrue(inventory.contains(0, 0)); + assertTrue(inventory.contains(31, 31)); + assertTrue(inventory.contains(32, -33)); + assertFalse(inventory.contains(32, -32)); + } + + @Test + public void scanNeverReadsOrDecompressesAllocatedChunkPayloads() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-payload").toPath(); + Path region = regionDirectory.resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{7, 11}}); + try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) { + file.seek(SECTOR_BYTES * 2L); + file.writeInt(Integer.MAX_VALUE); + file.writeByte(127); + } + + WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(regionDirectory); + + assertEquals(1, inventory.size()); + assertTrue(inventory.contains(7, 11)); + } + + @Test + public void inventoryRemainsUsableAfterTheRegionFilesDisappear() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-memory").toPath(); + Path region = regionDirectory.resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{1, 2}}); + WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(regionDirectory); + Files.delete(region); + Files.delete(regionDirectory); + + assertTrue(inventory.contains(1, 2)); + assertEquals(1, inventory.size()); + } + + @Test + public void truncatedRegionFileFailsClosed() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-truncated").toPath(); + Path region = regionDirectory.resolve("r.0.0.mca"); + try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) { + file.setLength(SECTOR_BYTES); + } + + IOException error = assertThrows( + IOException.class, + () -> WorldChunkInventory.scanRegionDirectory(regionDirectory) + ); + + assertTrue(error.getMessage().contains("Truncated world region file")); + } + + @Test + public void allocationOutsideTheFileFailsClosed() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-outside").toPath(); + Path region = regionDirectory.resolve("r.0.0.mca"); + try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) { + file.setLength(SECTOR_BYTES * 2L); + writeLocation(file, 0, 0, 2, 1); + } + + IOException error = assertThrows( + IOException.class, + () -> WorldChunkInventory.scanRegionDirectory(regionDirectory) + ); + + assertTrue(error.getMessage().contains("Invalid chunk allocation")); + } + + @Test + public void overlappingAllocationsFailClosed() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-overlap").toPath(); + Path region = regionDirectory.resolve("r.0.0.mca"); + try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) { + file.setLength(SECTOR_BYTES * 3L); + writeLocation(file, 0, 0, 2, 1); + writeLocation(file, 1, 0, 2, 1); + } + + IOException error = assertThrows( + IOException.class, + () -> WorldChunkInventory.scanRegionDirectory(regionDirectory) + ); + + assertTrue(error.getMessage().contains("Overlapping chunk allocations")); + } + + @Test + public void malformedRegionFilenameFailsClosed() throws Exception { + Path regionDirectory = temporaryFolder.newFolder("region-name").toPath(); + Path region = regionDirectory.resolve("not-a-region.mca"); + try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) { + file.setLength(SECTOR_BYTES * 2L); + } + + IOException error = assertThrows( + IOException.class, + () -> WorldChunkInventory.scanRegionDirectory(regionDirectory) + ); + + assertTrue(error.getMessage().contains("Invalid world region filename")); + } + + @Test + public void packedInventoryIsDeduplicatedAndDefensivelyCopied() throws Exception { + long first = ChunkGenerationOwnership.packChunk(-1, 2); + long second = ChunkGenerationOwnership.packChunk(3, -4); + long[] packed = new long[]{first, second, first}; + WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(packed); + packed[0] = 0L; + List visited = new ArrayList<>(); + + inventory.forEach((chunkX, chunkZ) -> visited.add(ChunkGenerationOwnership.packChunk(chunkX, chunkZ))); + + assertEquals(2, inventory.size()); + assertTrue(inventory.contains(-1, 2)); + assertTrue(inventory.contains(3, -4)); + assertEquals(List.of(first, second), visited); + } + + @Test + public void largeInventoryRetainsOnlyRegionMasksAndStreamsOwnership() throws Exception { + Path world = temporaryFolder.newFolder("many-regions").toPath(); + Path regions = Files.createDirectory(world.resolve("region")); + int[][] chunks = new int[1024][2]; + for (int index = 0; index < chunks.length; index++) { + chunks[index][0] = index & 31; + chunks[index][1] = index >> 5; + } + for (int regionX = -48; regionX < 48; regionX++) { + writeRegion(regions.resolve("r." + regionX + ".-2.mca"), chunks); + } + + WorldChunkInventory inventory = WorldChunkInventory.scan(world); + ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(world.resolve("ownership")); + + assertEquals(96 * 1024, inventory.size()); + assertEquals(96 * 128, inventory.retainedAllocationBytes()); + assertEquals(inventory.size(), ownership.assignUnassigned(inventory, 1L)); + assertEquals(0, ownership.assignUnassigned(inventory, 2L)); + inventory.forEach((chunkX, chunkZ) -> assertEquals(1L, ownership.resolve(chunkX, chunkZ, 2L))); + assertEquals(inventory.size(), ownership.explicitChunkCount()); + } + + @Test + public void missingRegionDirectoryProducesAnEmptyInventory() throws Exception { + Path missing = temporaryFolder.getRoot().toPath().resolve("missing"); + + WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(missing); + + assertTrue(inventory.isEmpty()); + } + + private static void writeRegion(Path file, int[][] chunks) throws IOException { + try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) { + output.setLength((long) (2 + chunks.length) * SECTOR_BYTES); + for (int index = 0; index < chunks.length; index++) { + writeLocation(output, chunks[index][0], chunks[index][1], 2 + index, 1); + } + } + } + + private static void writeLocation( + RandomAccessFile file, + int localChunkX, + int localChunkZ, + int sectorOffset, + int sectorCount + ) throws IOException { + int chunkIndex = MCAFile.getChunkIndex(localChunkX, localChunkZ); + file.seek((long) chunkIndex * Integer.BYTES); + file.writeInt(sectorOffset << Byte.SIZE | sectorCount); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java index 5030eac5a..15ed282cf 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java @@ -668,15 +668,56 @@ public class HydrologyModelTest { HydrologyColumnSample column = new HydrologyColumnSample( 1000, 0, 80, 63, false, "parent", List.of(layer) ); + HydrologyFeatureRef middle = new HydrologyFeatureRef( + 203L, + HydrologyFeatureType.DEEP_CHANNEL, + course.id(), + segment.id(), + 500, + 20, + 0, + 1, + 0, + false + ); + HydrologyColumnLayer middleLayer = new HydrologyColumnLayer( + middle, + 18, + 20, + 24, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "acid", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnSample middleColumn = new HydrologyColumnSample( + 500, 0, 80, 63, false, "parent", List.of(middleLayer) + ); HashMap actions = new HashMap<>(); HashMap preconditions = new HashMap<>(); - for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { - CavePosition position = new CavePosition(column.x(), y, column.z()); - actions.put( - position, - y > layer.fluidHeadY() ? HydrologyCaveAction.DRY_AIR : HydrologyCaveAction.WET_SOURCE - ); - preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + for (HydrologyColumnSample plannedColumn : List.of(column, middleColumn)) { + HydrologyColumnLayer plannedLayer = plannedColumn.layers().getFirst(); + for (int y = plannedLayer.bedY() + 1; y <= plannedLayer.ceilingY(); y++) { + CavePosition position = new CavePosition(plannedColumn.x(), y, plannedColumn.z()); + actions.put( + position, + y > plannedLayer.fluidHeadY() + ? HydrologyCaveAction.DRY_AIR + : HydrologyCaveAction.WET_SOURCE + ); + preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } } HydrologyCavePlan plan = new HydrologyCavePlan( new HydrologyCaveSource( @@ -702,13 +743,24 @@ public class HydrologyModelTest { List.of(course), List.of(plan), List.of(), - new RiverFootprint(Map.of(RiverFootprint.pack(1000, 0), column)) + new RiverFootprint(Map.of( + RiverFootprint.pack(1000, 0), column, + RiverFootprint.pack(500, 0), middleColumn + )) ); assertEquals(endpoint, tile.nearestFeature( Set.of(HydrologyFeatureType.DEEP_CHANNEL), "acid", 1000, 0, 10).orElseThrow()); assertTrue(tile.nearestFeature( Set.of(HydrologyFeatureType.DEEP_CHANNEL), "deep_lava", 1000, 0, 10).isEmpty()); + assertEquals(middle, tile.nearestFeature( + Set.of(HydrologyFeatureType.DEEP_CHANNEL), + "acid", + 1000, + 0, + 1000, + feature -> feature.x() != 1000 + ).orElseThrow()); } private HydrologyFeatureRef feature(HydrologyFeatureType type, long id, int y) { diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java index 910e1b471..246aea5b6 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java @@ -2,6 +2,8 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.engine.object.IrisDimension; @@ -43,6 +45,8 @@ public class MantleWriterOverlayTest { private MatterSlice cavernSlice; private MatterSlice hydrologySlice; private IrisDimension dimension; + private EngineMantle engineMantle; + private Mantle mantle; @Before @SuppressWarnings("unchecked") @@ -55,10 +59,10 @@ public class MantleWriterOverlayTest { when(platform.registries()).thenReturn(registries); IrisPlatforms.bind(platform); - EngineMantle engineMantle = mock(EngineMantle.class); + engineMantle = mock(EngineMantle.class); Engine engine = mock(Engine.class); dimension = mock(IrisDimension.class); - Mantle mantle = mock(Mantle.class); + mantle = mock(Mantle.class); chunk = mock(MantleChunk.class); matter = mock(Matter.class); blockSlice = mock(MatterSlice.class); @@ -184,6 +188,29 @@ public class MantleWriterOverlayTest { verify(cavernSlice, never()).set(X, Y, Z, cavern); } + @Test + public void historicalChunkRejectsAllMutationKinds() { + writer.close(); + IrisComplex complex = mock(IrisComplex.class); + TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class); + when(engineMantle.getComplex()).thenReturn(complex); + when(complex.getTransitionGenerationPlan()).thenReturn(transition); + when(transition.isHistoricalBlock(X, Z)).thenReturn(true); + writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); + PlatformBlockState replacement = mock(PlatformBlockState.class); + MatterCavern cavern = new MatterCavern(true, "", (byte) 3); + + writer.setData(X, Y, Z, replacement); + assertFalse(writer.setDataIfAbsent(X, Y, Z, cavern)); + assertFalse(writer.carveDataIfAbsent(X, Y, Z, cavern)); + writer.setForcedCarve(X, Y, Z, cavern); + writer.clearBlock(X, Y, Z); + writer.clearData(X, Y, Z, PlatformBlockState.class); + + verify(blockSlice, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(cavernSlice, never()).set(anyInt(), anyInt(), anyInt(), any()); + } + @Test public void hydrologyOverridesBaselineCarvedQueriesWithoutChangingIt() { MatterCavern baseline = new MatterCavern(true, "", (byte) 0); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java index 1ebc5901d..341c50625 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java @@ -3,12 +3,18 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.object.IrisDimension; +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.project.context.ChunkContext; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; +import org.junit.AfterClass; +import org.junit.BeforeClass; import org.junit.Test; import java.util.LinkedHashSet; @@ -19,11 +25,34 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class MatterGeneratorCarvePassRadiusTest { + private static IrisPlatform previousPlatform; + + @BeforeClass + public static void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(registries.block(anyString())).thenReturn(air); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + @Test @SuppressWarnings("unchecked") public void platformFilterRemovesSkippedComponentFromGenerationReach() { @@ -345,6 +374,9 @@ public class MatterGeneratorCarvePassRadiusTest { private final List components; private TestMatterGenerator(Engine engine, Mantle mantle, List components) { + EngineMantle engineMantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getEngine()).thenReturn(engine); this.engine = engine; this.mantle = mantle; this.components = components; diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java index 9a1510bb0..e85cc81a5 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java @@ -18,6 +18,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.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; @@ -126,6 +127,25 @@ public class CaveObjectPlacementTransactionTest { verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); } + @Test + public void transitionIntersectionRejectsTheWholePlacement() { + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(128); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.allowsNewDiscreteContentAt(4, 7)).thenReturn(false); + IObjectPlacer delegate = mock(IObjectPlacer.class); + when(delegate.getEngine()).thenReturn(engine); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + + transaction.set(4, 30, 7, mock(PlatformBlockState.class)); + transaction.setData(4, 30, 7, "object@1"); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_TRANSITION, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + private IObjectPlacer createPlacer(int worldHeight, int surfaceHeight, int caveFloor, int caveCeiling) { Engine engine = mock(Engine.class); when(engine.getHeight()).thenReturn(worldHeight); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java index b45ff18bd..4cf8e9a2e 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.UpperDimensionContext; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.MantleComponent; @@ -47,6 +48,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -54,6 +56,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; public class MantleObjectComponentBoundaryRadiusTest { @Rule @@ -102,6 +105,22 @@ public class MantleObjectComponentBoundaryRadiusTest { assertEquals(43, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 4, 3), placement)); } + @Test + public void knownObjectFootprintMustClearTheTransitionBand() { + IrisComplex complex = mock(IrisComplex.class); + IrisObject object = new IrisObject(5, 4, 3); + IrisObjectPlacement placement = new IrisObjectPlacement(); + when(complex.allowsNewGenerationFootprint(95, 195, 105, 205)).thenReturn(false); + + assertFalse(MantleObjectComponent.allowsObjectPlacement( + complex, + object, + placement, + 100, + 200)); + verify(complex).allowsNewGenerationFootprint(95, 195, 105, 205); + } + @Test public void sourceReplayUsesAscendingSignedCoordinates() { List replayedSources = new ArrayList<>(); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationHistoryContractTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationHistoryContractTest.java new file mode 100644 index 000000000..596f549d0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationHistoryContractTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.platform; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public final class BukkitChunkGeneratorGenerationHistoryContractTest { + @Test + public void setupPromotesBeforePublicationAndRebuildsFullServicesFromTheNewPack() throws IOException { + String source = source(); + String setup = method(source, "private void setupEngine()"); + + assertBefore(setup, "IrisEngine createdEngine = createEngine(engineTarget);", + "createdEngine.attachGenerationHistory("); + assertBefore(setup, "createdEngine.attachGenerationHistory(", + "generationHistory.activeActivation().activationId() != initialActivationId"); + assertBefore(setup, "createdEngine.close();", "createEngine(loadActiveGenerationHistoryTarget())"); + assertTrue(count(setup, "createdEngine.attachGenerationHistory(") == 2); + assertBefore(setup, "createEngine(loadActiveGenerationHistoryTarget())", "engine = createdEngine;"); + assertBefore(setup, "IrisBoundarySignatureSampler.INSTANCE", "engine = createdEngine;"); + assertTrue(setup.contains("getGenerationTransitionWidthBlocks()")); + } + + @Test + public void historyEngineStartsWithTheActiveMantleKernelAndTransition() throws IOException { + String createEngine = method(source(), "private IrisEngine createEngine(EngineTarget engineTarget)"); + + assertTrue(createEngine.contains("generationHistory.paths().activationMantleRoot(active.activationId())")); + assertTrue(createEngine.contains("epoch.kernelVersion()")); + assertTrue(createEngine.contains("generationHistory.transitionPlan(active.activationId())")); + assertTrue(createEngine.contains("if (generationHistory == null)")); + } + + @Test + public void publicBukkitTerrainClaimsSemanticsOnlyAfterSuccessfulApplication() throws IOException { + String generateNoise = method( + source(), + "public void generateNoise(@NotNull WorldInfo world, @NotNull Random random, int x, int z, @NotNull ChunkGenerator.ChunkData d)" + ); + + assertBefore(generateNoise, "engine.generate(x << 4, z << 4", "blocks.apply()"); + assertBefore(generateNoise, "blocks.apply()", "historyScope.claimGeneratedSemantics()"); + } + + private static String source() throws IOException { + return Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))) + .replace("\r\n", "\n"); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract token: " + signature, start >= 0); + int openingBrace = source.indexOf('{', start); + assertTrue(openingBrace >= 0); + int depth = 0; + for (int index = openingBrace; index < source.length(); index++) { + char character = source.charAt(index); + if (character == '{') { + depth++; + } else if (character == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new AssertionError("Method body did not close: " + signature); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static int count(String source, String token) { + int count = 0; + int index = 0; + while ((index = source.indexOf(token, index)) >= 0) { + count++; + index += token.length(); + } + return count; + } +} diff --git a/gradle/generation-kernel-seals.gradle b/gradle/generation-kernel-seals.gradle new file mode 100644 index 000000000..3ee1589ee --- /dev/null +++ b/gradle/generation-kernel-seals.gradle @@ -0,0 +1,133 @@ +import art.arcane.iris.buildtools.GenerationKernelSeal +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.ResolvedArtifact +import org.gradle.api.tasks.TaskProvider +import org.gradle.jvm.tasks.Jar + +import java.nio.file.Path + +Configuration kernelDependencies = configurations.create('generationKernelDependencies') { + canBeConsumed = false + canBeResolved = true + extendsFrom(configurations.implementation, configurations.slim, configurations.annotationProcessor) +} +dependencies.add(kernelDependencies.name, libs.guava) + +List generationSources = [ + 'lombok.config', + 'core/agent/build.gradle', + 'core/agent/src/main/java', + 'core/src/main/java', + 'spi/src/main/java', + 'adapters/bukkit/plugin/src/main/java', + 'adapters/bukkit/nms/v26_2_R1/src/main/java', + 'adapters/minecraft-common/src/main/java', + 'adapters/modded-common/src/main/java', + 'adapters/client-common/src/main/java', + 'adapters/fabric/src/main/java', + 'adapters/forge/src/main/java', + 'adapters/neoforge/src/main/java' +] +Map kernelScopes = [ + (1): new GenerationKernelSeal.CaptureOptions(rootProject.projectDir.toPath(), 1, + 'art.arcane.iris.engine.history.GenerationKernelV1', [new GenerationKernelSeal.AlgorithmVersion(1, 1)], generationSources, + ['core/src/main/java/art/arcane/iris/engine/kernels']) +] +File sealDirectory = file('generation-kernels') +FileTree sealFiles = fileTree(sealDirectory) { include('abi-*.seal') } +TaskProvider kernelAgentJar = project(':core:agent').tasks.named('jar', Jar) + +Closure> resolvedKernelDependencies = { + Map artifacts = new TreeMap<>() + for (ResolvedArtifact artifact : kernelDependencies.resolvedConfiguration.resolvedArtifacts) { + String group = artifact.moduleVersion.id.group + String name = artifact.moduleVersion.id.name + if (group == 'art.arcane' && name == 'spi') { + continue + } + boolean volmLib = (group == 'com.github.VolmitSoftware' && name == 'VolmLib') + || group == 'com.github.VolmitSoftware.VolmLib' + || (group == 'art.arcane' && name == 'shared') + String key = volmLib ? 'volmlib' : "${group}:${name}".toString() + if (artifact.classifier != null) { + key += ':' + artifact.classifier + } + if (artifacts.putIfAbsent(key, artifact.file.toPath()) != null) { + throw new GradleException("Duplicate generation dependency identity: ${key}") + } + } + if (!artifacts.containsKey('volmlib')) { + throw new GradleException('The generation seal must include the resolved VolmLib artifact.') + } + artifacts.put('iris-agent', kernelAgentJar.get().archiveFile.get().asFile.toPath()) + return artifacts +} + +TaskProvider verifySeals = tasks.register('verifyGenerationKernelSeals') { + group = 'verification' + description = 'Rejects generation source or dependency changes under a retained generation ABI.' + dependsOn(kernelDependencies, kernelAgentJar) + inputs.files(sealFiles, kernelDependencies, kernelAgentJar.flatMap { Jar jar -> jar.archiveFile }) + doLast { + Map dependencies = resolvedKernelDependencies() + Map manifests = new TreeMap<>() + for (Map.Entry entry : kernelScopes.entrySet()) { + File seal = new File(sealDirectory, "abi-${entry.key}.seal") + GenerationKernelSeal.SourceManifest recorded = GenerationKernelSeal.read(seal.toPath()) + GenerationKernelSeal.CaptureOptions scope = entry.value + if (recorded.abi() != scope.abi() || recorded.factoryClass() != scope.factoryClass() || recorded.algorithms() != scope.algorithms() + || recorded.roots() != scope.roots() || recorded.exclusions() != scope.exclusions()) { + throw new GradleException("Generation ABI ${entry.key} source scope no longer matches its seal.") + } + GenerationKernelSeal.verify(rootProject.projectDir.toPath(), seal.toPath(), dependencies) + manifests.put(entry.key, recorded) + } + GenerationKernelSeal.verifyCatalog(new File(sealDirectory, 'catalog.tsv').toPath(), manifests) + Set expectedNames = kernelScopes.keySet().collect { Integer abi -> "abi-${abi}.seal".toString() }.toSet() + if (sealFiles.files.collect { File seal -> seal.name }.toSet() != expectedNames) { + throw new GradleException('Every retained generation seal must have an explicit build scope.') + } + } +} + +tasks.register('captureGenerationKernelSeal') { + group = 'build setup' + description = 'Creates new compatibility metadata for an explicitly selected, previously unsealed generation ABI.' + dependsOn(kernelDependencies, kernelAgentJar) + doLast { + String requested = providers.gradleProperty('generationKernelAbi').orNull + if (requested == null || !(requested ==~ '[1-9][0-9]*')) { + throw new GradleException('Select a new generation ABI with -PgenerationKernelAbi=.') + } + int abi = Integer.parseInt(requested) + GenerationKernelSeal.CaptureOptions scope = kernelScopes.get(abi) + if (scope == null) { + throw new GradleException("Generation ABI ${abi} has no explicit source scope.") + } + GenerationKernelSeal.SourceManifest captured = GenerationKernelSeal.capture(scope, resolvedKernelDependencies()) + GenerationKernelSeal.writeNew(new File(sealDirectory, "abi-${abi}.seal").toPath(), captured) + logger.lifecycle("Sealed generation ABI ${abi}: ${GenerationKernelSeal.fingerprint(captured)}") + } +} + +TaskProvider sealResources = tasks.register('generateGenerationKernelSealResources', Sync) { + from(sealFiles) + from(new File(sealDirectory, 'catalog.tsv')) + into(layout.buildDirectory.dir('generated/kernel-seals/META-INF/iris/generation-kernels')) +} +tasks.named('processResources').configure { + dependsOn(sealResources) + from(layout.buildDirectory.dir('generated/kernel-seals')) +} +tasks.named('test', Test).configure { + dependsOn(sealResources) + classpath = files(layout.buildDirectory.dir('generated/kernel-seals')) + classpath +} +tasks.named('check').configure { dependsOn(verifySeals) } +tasks.withType(Jar).configureEach { dependsOn(verifySeals) } +rootProject.tasks.named('check').configure { dependsOn(verifySeals) } +rootProject.tasks.withType(Jar).configureEach { dependsOn(verifySeals) } +rootProject.tasks.matching { Task task -> task.name in ['fabricJar', 'forgeJar', 'neoforgeJar'] } + .configureEach { dependsOn(verifySeals) } +rootProject.subprojects.findAll { Project candidate -> candidate.path.startsWith(':adapters:bukkit:') } + .each { Project adapter -> adapter.tasks.withType(Jar).configureEach { dependsOn(verifySeals) } } diff --git a/gradle/modded-generation-kernel-seals.gradle b/gradle/modded-generation-kernel-seals.gradle new file mode 100644 index 000000000..f23256210 --- /dev/null +++ b/gradle/modded-generation-kernel-seals.gradle @@ -0,0 +1,51 @@ +import org.gradle.api.artifacts.ResolvedArtifact +import org.gradle.api.initialization.IncludedBuild +import org.gradle.api.tasks.TaskReference +import org.gradle.api.tasks.TaskProvider +import org.gradle.jvm.tasks.Jar + +IncludedBuild coreBuild = gradle.includedBuilds.find { IncludedBuild candidate -> + candidate.projectDir.canonicalFile == file('../..').canonicalFile +} +if (coreBuild == null) { + throw new GradleException('The modded artifact requires the included Iris core build and its generation seal.') +} +TaskReference kernelSealVerification = coreBuild.task(':core:verifyGenerationKernelSeals') +TaskProvider resolvedSealVerification = tasks.register('verifyResolvedGenerationKernelDependencies', JavaExec) { + group = 'verification' + description = 'Checks the actual bundled generation library contents against the retained kernel seal.' + dependsOn(kernelSealVerification) + classpath = files('../../buildSrc/build/libs/buildSrc.jar') + mainClass.set('art.arcane.iris.buildtools.GenerationKernelSeal') + doFirst { + List arguments = ['verify-dependencies', file('../../core/generation-kernels/abi-1.seal').absolutePath] + for (ResolvedArtifact artifact : configurations.bundle.resolvedConfiguration.resolvedArtifacts) { + String group = artifact.moduleVersion.id.group + String name = artifact.moduleVersion.id.name + if (group == 'art.arcane' && name in ['core', 'spi']) { + continue + } + if ("${group}:${name}".toString() in [ + 'org.jetbrains:annotations', 'org.jspecify:jspecify', + 'com.google.errorprone:error_prone_annotations', 'com.google.j2objc:j2objc-annotations', + 'org.checkerframework:checker-qual']) { + continue + } + boolean volmLib = (group == 'com.github.VolmitSoftware' && name == 'VolmLib') + || group == 'com.github.VolmitSoftware.VolmLib' + || (group == 'art.arcane' && name == 'shared') + String key = volmLib ? 'volmlib' : "${group}:${name}".toString() + if (artifact.classifier != null) { + key += ':' + artifact.classifier + } + arguments.add(key) + arguments.add(artifact.file.absolutePath) + } + setArgs(arguments) + } +} +gradle.projectsEvaluated { + resolvedSealVerification.configure { dependsOn(configurations.bundle) } +} +tasks.withType(Jar).configureEach { dependsOn(resolvedSealVerification) } +tasks.named('check').configure { dependsOn(resolvedSealVerification) } diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java b/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java new file mode 100644 index 000000000..7ae4bf70b --- /dev/null +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java @@ -0,0 +1,84 @@ +package art.arcane.iris.spi; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +public interface PlatformGenerationRegistry { + Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + String runtimeIdentity(); + + String generatedDefinitionRendererIdentity(); + + default String customBiomeResourceKey(String identitySha256) { + return contentAddressedCustomBiomeResourceKey(identitySha256); + } + + default List legacyCustomBiomeResourceKeys( + String packName, + String dimensionKey, + String customBiomeId + ) { + return List.of(); + } + + String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey); + + Definition definition(String registryKey, String resourceKey); + + default Definition generatedDefinition(String registryKey, String resourceKey) { + return definition(registryKey, resourceKey); + } + + default Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) { + return Definition.exactJson(sourceJson); + } + + static String contentAddressedCustomBiomeResourceKey(String identitySha256) { + String requiredFingerprint = Objects.requireNonNull(identitySha256, "identitySha256"); + if (!SHA_256_PATTERN.matcher(requiredFingerprint).matches()) { + throw new IllegalArgumentException("Custom biome identity fingerprint must be a lowercase SHA-256 value."); + } + return "iris:biomes/" + requiredFingerprint; + } + + enum Encoding { + JSON, + CONSERVATIVE_IDENTITY + } + + record Definition(Encoding encoding, String value) { + public Definition { + encoding = Objects.requireNonNull(encoding, "encoding"); + value = Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("Platform registry definition must not be blank."); + } + } + + public static Definition exactJson(String json) { + return new Definition(Encoding.JSON, json); + } + + public static Definition conservativeIdentity(String identity) { + return new Definition(Encoding.CONSERVATIVE_IDENTITY, identity); + } + + public static Definition resourceIdentity(String registryKey, String resourceKey) { + String requiredRegistryKey = requireText(registryKey, "registryKey"); + String requiredResourceKey = requireText(resourceKey, "resourceKey"); + return conservativeIdentity( + "registry-resource-key-v1|" + requiredRegistryKey + "|" + requiredResourceKey + ); + } + + private static String requireText(String value, String label) { + String required = Objects.requireNonNull(value, label).trim(); + if (required.isEmpty()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return required; + } + } +} diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java index 94a39fa1a..2cb954418 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java @@ -31,6 +31,12 @@ import java.util.Map; * Internal to Iris; not a published integration surface. */ public interface PlatformRegistries { + default PlatformGenerationRegistry generationRegistry() { + throw new UnsupportedOperationException( + "The active platform does not expose generation registry definitions." + ); + } + /** * Resolves a block key through the platform's compatibility layer, which rewrites keys that moved between * Minecraft versions and consults registered custom-content providers. An unresolvable key is reported and